Merge branch 'release/0.9-rc1' 0.9-rc1
authorDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Tue, 16 Jun 2015 15:54:09 +0000 (17:54 +0200)
committerDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Tue, 16 Jun 2015 15:54:09 +0000 (17:54 +0200)
259 files changed:
pom.xml
src/main/java/net/pterodactylus/sone/core/ConfigurationSoneParser.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/core/Core.java
src/main/java/net/pterodactylus/sone/core/FreenetInterface.java
src/main/java/net/pterodactylus/sone/core/ImageInserter.java
src/main/java/net/pterodactylus/sone/core/Options.java
src/main/java/net/pterodactylus/sone/core/Preferences.java
src/main/java/net/pterodactylus/sone/core/PreferencesLoader.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/core/SoneChangeDetector.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/core/SoneDownloader.java
src/main/java/net/pterodactylus/sone/core/SoneDownloaderImpl.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/core/SoneException.java
src/main/java/net/pterodactylus/sone/core/SoneInsertException.java
src/main/java/net/pterodactylus/sone/core/SoneInserter.java
src/main/java/net/pterodactylus/sone/core/SoneModificationDetector.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/core/SoneParser.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/core/SoneRescuer.java
src/main/java/net/pterodactylus/sone/core/SoneUri.java
src/main/java/net/pterodactylus/sone/core/UpdateChecker.java
src/main/java/net/pterodactylus/sone/core/WebOfTrustUpdater.java
src/main/java/net/pterodactylus/sone/core/WebOfTrustUpdaterImpl.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/core/event/InsertionDelayChangedEvent.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/core/event/SoneInsertedEvent.java
src/main/java/net/pterodactylus/sone/data/Album.java
src/main/java/net/pterodactylus/sone/data/AlbumImpl.java [deleted file]
src/main/java/net/pterodactylus/sone/data/Client.java
src/main/java/net/pterodactylus/sone/data/Image.java
src/main/java/net/pterodactylus/sone/data/ImageImpl.java [deleted file]
src/main/java/net/pterodactylus/sone/data/Post.java
src/main/java/net/pterodactylus/sone/data/PostReply.java
src/main/java/net/pterodactylus/sone/data/Profile.java
src/main/java/net/pterodactylus/sone/data/Reply.java
src/main/java/net/pterodactylus/sone/data/Sone.java
src/main/java/net/pterodactylus/sone/data/SoneImpl.java [deleted file]
src/main/java/net/pterodactylus/sone/data/SoneOptions.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/data/impl/AbstractAlbumBuilder.java
src/main/java/net/pterodactylus/sone/data/impl/AbstractSoneBuilder.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/data/impl/AlbumBuilderImpl.java
src/main/java/net/pterodactylus/sone/data/impl/AlbumImpl.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/data/impl/IdOnlySone.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/data/impl/ImageBuilderImpl.java
src/main/java/net/pterodactylus/sone/data/impl/ImageImpl.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/data/impl/PostImpl.java
src/main/java/net/pterodactylus/sone/data/impl/SoneImpl.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/database/AlbumBuilder.java
src/main/java/net/pterodactylus/sone/database/BookmarkDatabase.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/database/Database.java
src/main/java/net/pterodactylus/sone/database/FriendDatabase.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/database/FriendProvider.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/database/FriendStore.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/database/PostBuilderFactory.java
src/main/java/net/pterodactylus/sone/database/PostProvider.java
src/main/java/net/pterodactylus/sone/database/PostReplyBuilderFactory.java
src/main/java/net/pterodactylus/sone/database/PostReplyStore.java
src/main/java/net/pterodactylus/sone/database/PostStore.java
src/main/java/net/pterodactylus/sone/database/SoneBuilder.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/database/SoneBuilderFactory.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/database/SoneDatabase.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/database/SoneProvider.java
src/main/java/net/pterodactylus/sone/database/SoneStore.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/database/memory/ConfigurationLoader.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/database/memory/MemoryBookmarkDatabase.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/database/memory/MemoryDatabase.java
src/main/java/net/pterodactylus/sone/database/memory/MemoryFriendDatabase.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/database/memory/MemoryPost.java
src/main/java/net/pterodactylus/sone/database/memory/MemorySoneBuilder.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/fcp/FcpInterface.java
src/main/java/net/pterodactylus/sone/fcp/event/FcpInterfaceActivatedEvent.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/fcp/event/FcpInterfaceDeactivatedEvent.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/fcp/event/FullAccessRequiredChanged.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/freenet/Key.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/freenet/PluginStoreConfigurationBackend.java
src/main/java/net/pterodactylus/sone/freenet/StringBucket.java [deleted file]
src/main/java/net/pterodactylus/sone/freenet/plugin/PluginConnector.java
src/main/java/net/pterodactylus/sone/freenet/wot/Context.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/freenet/wot/DefaultIdentity.java
src/main/java/net/pterodactylus/sone/freenet/wot/DefaultOwnIdentity.java
src/main/java/net/pterodactylus/sone/freenet/wot/Identity.java
src/main/java/net/pterodactylus/sone/freenet/wot/IdentityChangeDetector.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/freenet/wot/IdentityChangeEventSender.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/freenet/wot/IdentityLoader.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/freenet/wot/IdentityManager.java
src/main/java/net/pterodactylus/sone/freenet/wot/IdentityManagerImpl.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/freenet/wot/OwnIdentity.java
src/main/java/net/pterodactylus/sone/freenet/wot/Trust.java
src/main/java/net/pterodactylus/sone/freenet/wot/WebOfTrustConnector.java
src/main/java/net/pterodactylus/sone/freenet/wot/event/IdentityEvent.java
src/main/java/net/pterodactylus/sone/freenet/wot/event/OwnIdentityEvent.java
src/main/java/net/pterodactylus/sone/main/SonePlugin.java
src/main/java/net/pterodactylus/sone/notify/ListNotificationFilters.java
src/main/java/net/pterodactylus/sone/template/CollectionAccessor.java
src/main/java/net/pterodactylus/sone/template/IdentityAccessor.java
src/main/java/net/pterodactylus/sone/template/ImageLinkFilter.java
src/main/java/net/pterodactylus/sone/template/ParserFilter.java
src/main/java/net/pterodactylus/sone/template/PostAccessor.java
src/main/java/net/pterodactylus/sone/template/ProfileAccessor.java
src/main/java/net/pterodactylus/sone/template/RequestChangeFilter.java
src/main/java/net/pterodactylus/sone/template/SoneAccessor.java
src/main/java/net/pterodactylus/sone/text/SoneTextParser.java
src/main/java/net/pterodactylus/sone/utils/DefaultOption.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/utils/IntegerRangePredicate.java
src/main/java/net/pterodactylus/sone/utils/NumberParsers.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/utils/Option.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/utils/Optionals.java [new file with mode: 0644]
src/main/java/net/pterodactylus/sone/web/BookmarkPage.java
src/main/java/net/pterodactylus/sone/web/BookmarksPage.java
src/main/java/net/pterodactylus/sone/web/CreateAlbumPage.java
src/main/java/net/pterodactylus/sone/web/CreatePostPage.java
src/main/java/net/pterodactylus/sone/web/CreateReplyPage.java
src/main/java/net/pterodactylus/sone/web/CreateSonePage.java
src/main/java/net/pterodactylus/sone/web/DeleteAlbumPage.java
src/main/java/net/pterodactylus/sone/web/DeletePostPage.java
src/main/java/net/pterodactylus/sone/web/EditAlbumPage.java
src/main/java/net/pterodactylus/sone/web/EditImagePage.java
src/main/java/net/pterodactylus/sone/web/EditProfilePage.java
src/main/java/net/pterodactylus/sone/web/ImageBrowserPage.java
src/main/java/net/pterodactylus/sone/web/IndexPage.java
src/main/java/net/pterodactylus/sone/web/KnownSonesPage.java
src/main/java/net/pterodactylus/sone/web/LockSonePage.java
src/main/java/net/pterodactylus/sone/web/LoginPage.java
src/main/java/net/pterodactylus/sone/web/NewPage.java
src/main/java/net/pterodactylus/sone/web/OptionsPage.java
src/main/java/net/pterodactylus/sone/web/RescuePage.java
src/main/java/net/pterodactylus/sone/web/SearchPage.java
src/main/java/net/pterodactylus/sone/web/UnbookmarkPage.java
src/main/java/net/pterodactylus/sone/web/UnlockSonePage.java
src/main/java/net/pterodactylus/sone/web/UploadImagePage.java
src/main/java/net/pterodactylus/sone/web/ViewSonePage.java
src/main/java/net/pterodactylus/sone/web/WebInterface.java
src/main/java/net/pterodactylus/sone/web/ajax/BookmarkAjaxPage.java
src/main/java/net/pterodactylus/sone/web/ajax/CreatePostAjaxPage.java
src/main/java/net/pterodactylus/sone/web/ajax/CreateReplyAjaxPage.java
src/main/java/net/pterodactylus/sone/web/ajax/EditAlbumAjaxPage.java
src/main/java/net/pterodactylus/sone/web/ajax/EditImageAjaxPage.java
src/main/java/net/pterodactylus/sone/web/ajax/GetNotificationsAjaxPage.java
src/main/java/net/pterodactylus/sone/web/ajax/GetStatusAjaxPage.java
src/main/java/net/pterodactylus/sone/web/ajax/JsonPage.java
src/main/java/net/pterodactylus/sone/web/ajax/LockSoneAjaxPage.java
src/main/java/net/pterodactylus/sone/web/ajax/UnbookmarkAjaxPage.java
src/main/java/net/pterodactylus/sone/web/ajax/UnlockSoneAjaxPage.java
src/main/java/net/pterodactylus/sone/web/page/FreenetTemplatePage.java
src/main/java/net/pterodactylus/sone/web/page/PageToadlet.java
src/main/resources/i18n/sone.de.properties
src/main/resources/i18n/sone.en.properties
src/main/resources/i18n/sone.fr.properties
src/main/resources/i18n/sone.ja.properties
src/main/resources/i18n/sone.no.properties
src/main/resources/i18n/sone.pl.properties
src/main/resources/i18n/sone.ru.properties
src/main/resources/templates/emptyAlbumTitle.html [new file with mode: 0644]
src/main/resources/templates/emptyImageTitle.html [new file with mode: 0644]
src/main/resources/templates/imageBrowser.html
src/main/resources/templates/include/viewSone.html
src/test/java/net/pterodactylus/sone/Matchers.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/TestAlbumBuilder.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/TestImageBuilder.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/TestPostBuilder.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/TestPostReplyBuilder.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/TestUtil.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/TestValue.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/core/ConfigurationSoneParserTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/core/CoreTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/core/FreenetInterfaceTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/core/ImageInserterTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/core/OptionsTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/core/PreferencesLoaderTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/core/PreferencesTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/core/SoneChangeDetectorTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/core/SoneDownloaderTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/core/SoneInserterTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/core/SoneModificationDetectorTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/core/SoneParserTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/core/SoneRescuerTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/core/SoneUriTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/core/UpdateCheckerTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/core/WebOfTrustUpdaterTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/data/ProfileTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/data/impl/AbstractSoneBuilderTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/data/impl/ImageImplTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/database/memory/ConfigurationLoaderTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/database/memory/MemoryBookmarkDatabaseTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/database/memory/MemoryDatabaseTest.java
src/test/java/net/pterodactylus/sone/fcp/FcpInterfaceTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/fcp/LockSoneCommandTest.java
src/test/java/net/pterodactylus/sone/fcp/UnlockSoneCommandTest.java
src/test/java/net/pterodactylus/sone/freenet/KeyTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/freenet/wot/DefaultIdentityTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/freenet/wot/DefaultOwnIdentityTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/freenet/wot/Identities.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/freenet/wot/IdentityChangeDetectorTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/freenet/wot/IdentityChangeEventSenderTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/freenet/wot/IdentityLoaderTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/freenet/wot/IdentityManagerTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/freenet/wot/event/IdentityEventTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/freenet/wot/event/OwnIdentityEventTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/template/AlbumAccessorTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/template/CollectionAccessorTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/template/CssClassNameFilterTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/template/GetPagePluginTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/template/HttpRequestAccessorTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/template/IdentityAccessorTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/text/SoneTextParserTest.java
src/test/java/net/pterodactylus/sone/utils/DefaultOptionTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/utils/IntegerRangePredicateTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/utils/NumberParsersTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/utils/OptionalsTest.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sone/web/ajax/BookmarkAjaxPageTest.java
src/test/resources/net/pterodactylus/sone/core/sone-inserter-faulty-manifest.txt [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-inserter-invalid-manifest.txt [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-inserter-manifest.txt [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-missing-client-name.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-missing-client-version.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-missing-protocol-version.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-negative-protocol-version.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-no-payload.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-no-profile.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-no-time.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-not-xml.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-profile-duplicate-field-name.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-profile-empty-field-name.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-profile-missing-field-name.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-time-not-numeric.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-too-large-protocol-version.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-with-client-info.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-with-image.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-with-invalid-image-height.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-with-invalid-image-width.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-with-invalid-parent-album-id.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-with-invalid-post-reply-time.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-with-invalid-post-time.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-with-invalid-recipient.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-with-liked-post-ids.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-with-liked-post-reply-ids.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-with-multiple-albums.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-with-profile.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-with-recipient.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-with-valid-post-reply-time.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-with-valid-post-time.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-with-zero-time.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-without-album-id.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-without-album-title.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-without-albums.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-without-fields.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-without-image-height.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-without-image-id.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-without-image-key.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-without-image-time.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-without-image-title.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-without-image-width.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-without-images.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-without-liked-post-ids.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-without-liked-post-reply-ids.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-without-post-id.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-without-post-reply-id.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-without-post-reply-post-id.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-without-post-reply-text.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-without-post-reply-time.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-without-post-text.xml [new file with mode: 0644]
src/test/resources/net/pterodactylus/sone/core/sone-parser-without-post-time.xml [new file with mode: 0644]

diff --git a/pom.xml b/pom.xml
index e96a971..139d3c1 100644 (file)
--- a/pom.xml
+++ b/pom.xml
@@ -2,7 +2,7 @@
        <modelVersion>4.0.0</modelVersion>
        <groupId>net.pterodactylus</groupId>
        <artifactId>sone</artifactId>
-       <version>0.8.9</version>
+       <version>0.9-rc1</version>
        <dependencies>
                <dependency>
                        <groupId>net.pterodactylus</groupId>
                        <scope>test</scope>
                </dependency>
                <dependency>
+                       <groupId>org.hamcrest</groupId>
+                       <artifactId>hamcrest-all</artifactId>
+                       <version>1.3</version>
+               </dependency>
+               <dependency>
                        <groupId>org.freenetproject</groupId>
                        <artifactId>fred</artifactId>
-                       <version>0.7.5.1405</version>
+                       <version>0.7.5.1467.99.3</version>
                        <scope>provided</scope>
                </dependency>
                <dependency>
                        <groupId>org.freenetproject</groupId>
                        <artifactId>freenet-ext</artifactId>
-                       <version>26</version>
+                       <version>29</version>
                        <scope>provided</scope>
                </dependency>
                <dependency>
@@ -41,7 +46,7 @@
                <dependency>
                        <groupId>com.google.guava</groupId>
                        <artifactId>guava</artifactId>
-                       <version>14.0-rc1</version>
+                       <version>14.0.1</version>
                </dependency>
                <dependency>
                        <groupId>commons-lang</groupId>
                                        <footer>© 2010–2013 David ‘Bombe’ Roden</footer>
                                </configuration>
                        </plugin>
+                       <plugin>
+                               <groupId>org.jacoco</groupId>
+                               <artifactId>jacoco-maven-plugin</artifactId>
+                               <version>0.7.1.201405082137</version>
+                               <executions>
+                                       <execution>
+                                               <id>default-prepare-agent</id>
+                                               <goals>
+                                                       <goal>prepare-agent</goal>
+                                               </goals>
+                                       </execution>
+                                       <execution>
+                                               <id>default-report</id>
+                                               <phase>prepare-package</phase>
+                                               <goals>
+                                                       <goal>report</goal>
+                                               </goals>
+                                       </execution>
+                                       <execution>
+                                               <id>default-check</id>
+                                               <goals>
+                                                       <goal>check</goal>
+                                               </goals>
+                                               <configuration>
+                                                       <rules>
+                                                               <!--  implmentation is needed only for Maven 2  -->
+                                                               <rule implementation="org.jacoco.maven.RuleConfiguration">
+                                                                       <element>BUNDLE</element>
+                                                                       <limits>
+                                                                               <!--  implmentation is needed only for Maven 2  -->
+                                                                               <limit implementation="org.jacoco.report.check.Limit">
+                                                                                       <counter>COMPLEXITY</counter>
+                                                                                       <value>COVEREDRATIO</value>
+                                                                                       <minimum>0.60</minimum>
+                                                                               </limit>
+                                                                       </limits>
+                                                               </rule>
+                                                       </rules>
+                                               </configuration>
+                                       </execution>
+                               </executions>
+                       </plugin>
                </plugins>
        </build>
 </project>
diff --git a/src/main/java/net/pterodactylus/sone/core/ConfigurationSoneParser.java b/src/main/java/net/pterodactylus/sone/core/ConfigurationSoneParser.java
new file mode 100644 (file)
index 0000000..a29856b
--- /dev/null
@@ -0,0 +1,312 @@
+package net.pterodactylus.sone.core;
+
+import static java.util.Collections.unmodifiableMap;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.annotation.Nullable;
+
+import net.pterodactylus.sone.data.Album;
+import net.pterodactylus.sone.data.Image;
+import net.pterodactylus.sone.data.Post;
+import net.pterodactylus.sone.data.PostReply;
+import net.pterodactylus.sone.data.Profile;
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.sone.database.AlbumBuilderFactory;
+import net.pterodactylus.sone.database.ImageBuilderFactory;
+import net.pterodactylus.sone.database.PostBuilder;
+import net.pterodactylus.sone.database.PostBuilderFactory;
+import net.pterodactylus.sone.database.PostReplyBuilder;
+import net.pterodactylus.sone.database.PostReplyBuilderFactory;
+import net.pterodactylus.util.config.Configuration;
+
+/**
+ * Parses a {@link Sone}’s data from a {@link Configuration}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class ConfigurationSoneParser {
+
+       private final Configuration configuration;
+       private final Sone sone;
+       private final String sonePrefix;
+       private final Map<String, Album> albums = new HashMap<String, Album>();
+       private final List<Album> topLevelAlbums = new ArrayList<Album>();
+       private final Map<String, Image> images = new HashMap<String, Image>();
+
+       public ConfigurationSoneParser(Configuration configuration, Sone sone) {
+               this.configuration = configuration;
+               this.sone = sone;
+               sonePrefix = "Sone/" + sone.getId();
+       }
+
+       public Profile parseProfile() {
+               Profile profile = new Profile(sone);
+               profile.setFirstName(getString("/Profile/FirstName", null));
+               profile.setMiddleName(getString("/Profile/MiddleName", null));
+               profile.setLastName(getString("/Profile/LastName", null));
+               profile.setBirthDay(getInt("/Profile/BirthDay", null));
+               profile.setBirthMonth(getInt("/Profile/BirthMonth", null));
+               profile.setBirthYear(getInt("/Profile/BirthYear", null));
+
+               /* load profile fields. */
+               int fieldCount = 0;
+               while (true) {
+                       String fieldPrefix = "/Profile/Fields/" + fieldCount++;
+                       String fieldName = getString(fieldPrefix + "/Name", null);
+                       if (fieldName == null) {
+                               break;
+                       }
+                       String fieldValue = getString(fieldPrefix + "/Value", "");
+                       profile.addField(fieldName).setValue(fieldValue);
+               }
+
+               return profile;
+       }
+
+       private String getString(String nodeName, @Nullable String defaultValue) {
+               return configuration.getStringValue(sonePrefix + nodeName)
+                               .getValue(defaultValue);
+       }
+
+       private Integer getInt(String nodeName, @Nullable Integer defaultValue) {
+               return configuration.getIntValue(sonePrefix + nodeName)
+                               .getValue(defaultValue);
+       }
+
+       private Long getLong(String nodeName, @Nullable Long defaultValue) {
+               return configuration.getLongValue(sonePrefix + nodeName)
+                               .getValue(defaultValue);
+       }
+
+       public Set<Post> parsePosts(PostBuilderFactory postBuilderFactory)
+       throws InvalidPostFound {
+               Set<Post> posts = new HashSet<Post>();
+               while (true) {
+                       String postPrefix = "/Posts/" + posts.size();
+                       String postId = getString(postPrefix + "/ID", null);
+                       if (postId == null) {
+                               break;
+                       }
+                       long postTime = getLong(postPrefix + "/Time", 0L);
+                       String postText = getString(postPrefix + "/Text", null);
+                       if (postAttributesAreInvalid(postTime, postText)) {
+                               throw new InvalidPostFound();
+                       }
+                       PostBuilder postBuilder = postBuilderFactory.newPostBuilder()
+                                       .withId(postId)
+                                       .from(sone.getId())
+                                       .withTime(postTime)
+                                       .withText(postText);
+                       String postRecipientId =
+                                       getString(postPrefix + "/Recipient", null);
+                       if (postRecipientIsValid(postRecipientId)) {
+                               postBuilder.to(postRecipientId);
+                       }
+                       posts.add(postBuilder.build());
+               }
+               return posts;
+       }
+
+       private boolean postAttributesAreInvalid(long postTime, String postText) {
+               return (postTime == 0) || (postText == null);
+       }
+
+       private boolean postRecipientIsValid(String postRecipientId) {
+               return (postRecipientId != null) && (postRecipientId.length() == 43);
+       }
+
+       public Set<PostReply> parsePostReplies(
+                       PostReplyBuilderFactory postReplyBuilderFactory) {
+               Set<PostReply> replies = new HashSet<PostReply>();
+               while (true) {
+                       String replyPrefix = "/Replies/" + replies.size();
+                       String replyId = getString(replyPrefix + "/ID", null);
+                       if (replyId == null) {
+                               break;
+                       }
+                       String postId = getString(replyPrefix + "/Post/ID", null);
+                       long replyTime = getLong(replyPrefix + "/Time", 0L);
+                       String replyText = getString(replyPrefix + "/Text", null);
+                       if ((postId == null) || (replyTime == 0) || (replyText == null)) {
+                               throw new InvalidPostReplyFound();
+                       }
+                       PostReplyBuilder postReplyBuilder = postReplyBuilderFactory
+                                       .newPostReplyBuilder()
+                                       .withId(replyId)
+                                       .from(sone.getId())
+                                       .to(postId)
+                                       .withTime(replyTime)
+                                       .withText(replyText);
+                       replies.add(postReplyBuilder.build());
+               }
+               return replies;
+       }
+
+       public Set<String> parseLikedPostIds() {
+               Set<String> likedPostIds = new HashSet<String>();
+               while (true) {
+                       String likedPostId =
+                                       getString("/Likes/Post/" + likedPostIds.size() + "/ID",
+                                                       null);
+                       if (likedPostId == null) {
+                               break;
+                       }
+                       likedPostIds.add(likedPostId);
+               }
+               return likedPostIds;
+       }
+
+       public Set<String> parseLikedPostReplyIds() {
+               Set<String> likedPostReplyIds = new HashSet<String>();
+               while (true) {
+                       String likedReplyId = getString(
+                                       "/Likes/Reply/" + likedPostReplyIds.size() + "/ID", null);
+                       if (likedReplyId == null) {
+                               break;
+                       }
+                       likedPostReplyIds.add(likedReplyId);
+               }
+               return likedPostReplyIds;
+       }
+
+       public Set<String> parseFriends() {
+               Set<String> friends = new HashSet<String>();
+               while (true) {
+                       String friendId =
+                                       getString("/Friends/" + friends.size() + "/ID", null);
+                       if (friendId == null) {
+                               break;
+                       }
+                       friends.add(friendId);
+               }
+               return friends;
+       }
+
+       public List<Album> parseTopLevelAlbums(
+                       AlbumBuilderFactory albumBuilderFactory) {
+               int albumCounter = 0;
+               while (true) {
+                       String albumPrefix = "/Albums/" + albumCounter++;
+                       String albumId = getString(albumPrefix + "/ID", null);
+                       if (albumId == null) {
+                               break;
+                       }
+                       String albumTitle = getString(albumPrefix + "/Title", null);
+                       String albumDescription =
+                                       getString(albumPrefix + "/Description", null);
+                       String albumParentId = getString(albumPrefix + "/Parent", null);
+                       String albumImageId =
+                                       getString(albumPrefix + "/AlbumImage", null);
+                       if ((albumTitle == null) || (albumDescription == null)) {
+                               throw new InvalidAlbumFound();
+                       }
+                       Album album = albumBuilderFactory.newAlbumBuilder()
+                                       .withId(albumId)
+                                       .by(sone)
+                                       .build()
+                                       .modify()
+                                       .setTitle(albumTitle)
+                                       .setDescription(albumDescription)
+                                       .setAlbumImage(albumImageId)
+                                       .update();
+                       if (albumParentId != null) {
+                               Album parentAlbum = albums.get(albumParentId);
+                               if (parentAlbum == null) {
+                                       throw new InvalidParentAlbumFound(albumParentId);
+                               }
+                               parentAlbum.addAlbum(album);
+                       } else {
+                               topLevelAlbums.add(album);
+                       }
+                       albums.put(albumId, album);
+               }
+               return topLevelAlbums;
+       }
+
+       public Map<String, Album> getAlbums() {
+               return unmodifiableMap(albums);
+       }
+
+       public void parseImages(ImageBuilderFactory imageBuilderFactory) {
+               int imageCounter = 0;
+               while (true) {
+                       String imagePrefix = "/Images/" + imageCounter++;
+                       String imageId = getString(imagePrefix + "/ID", null);
+                       if (imageId == null) {
+                               break;
+                       }
+                       String albumId = getString(imagePrefix + "/Album", null);
+                       String key = getString(imagePrefix + "/Key", null);
+                       String title = getString(imagePrefix + "/Title", null);
+                       String description =
+                                       getString(imagePrefix + "/Description", null);
+                       Long creationTime = getLong(imagePrefix + "/CreationTime", null);
+                       Integer width = getInt(imagePrefix + "/Width", null);
+                       Integer height = getInt(imagePrefix + "/Height", null);
+                       if (albumAttributesAreInvalid(albumId, key, title, description,
+                                       creationTime,
+                                       width, height)) {
+                               throw new InvalidImageFound();
+                       }
+                       Album album = albums.get(albumId);
+                       if (album == null) {
+                               throw new InvalidParentAlbumFound(albumId);
+                       }
+                       Image image = imageBuilderFactory.newImageBuilder()
+                                       .withId(imageId)
+                                       .build()
+                                       .modify()
+                                       .setSone(sone)
+                                       .setCreationTime(creationTime)
+                                       .setKey(key)
+                                       .setTitle(title)
+                                       .setDescription(description)
+                                       .setWidth(width)
+                                       .setHeight(height)
+                                       .update();
+                       album.addImage(image);
+                       images.put(image.getId(), image);
+               }
+       }
+
+       public Map<String, Image> getImages() {
+               return images;
+       }
+
+       private boolean albumAttributesAreInvalid(String albumId, String key,
+                       String title, String description, Long creationTime,
+                       Integer width, Integer height) {
+               return (albumId == null) || (key == null) || (title == null) || (
+                               description == null) || (creationTime == null) || (width
+                               == null) || (height == null);
+       }
+
+       public static class InvalidPostFound extends RuntimeException { }
+
+       public static class InvalidPostReplyFound extends RuntimeException { }
+
+       public static class InvalidAlbumFound extends RuntimeException { }
+
+       public static class InvalidParentAlbumFound extends RuntimeException {
+
+               private final String albumParentId;
+
+               public InvalidParentAlbumFound(String albumParentId) {
+                       this.albumParentId = albumParentId;
+               }
+
+               public String getAlbumParentId() {
+                       return albumParentId;
+               }
+
+       }
+
+       public static class InvalidImageFound extends RuntimeException { }
+
+}
index ecb5857..1153c0f 100644 (file)
 
 package net.pterodactylus.sone.core;
 
+import static com.google.common.base.Optional.fromNullable;
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.primitives.Longs.tryParse;
+import static java.lang.String.format;
+import static java.util.logging.Level.WARNING;
+import static java.util.logging.Logger.getLogger;
 
-import java.net.MalformedURLException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -36,9 +40,13 @@ import java.util.concurrent.TimeUnit;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
-import net.pterodactylus.sone.core.Options.DefaultOption;
-import net.pterodactylus.sone.core.Options.Option;
-import net.pterodactylus.sone.core.Options.OptionWatcher;
+import net.pterodactylus.sone.core.ConfigurationSoneParser.InvalidAlbumFound;
+import net.pterodactylus.sone.core.ConfigurationSoneParser.InvalidImageFound;
+import net.pterodactylus.sone.core.ConfigurationSoneParser.InvalidParentAlbumFound;
+import net.pterodactylus.sone.core.ConfigurationSoneParser.InvalidPostFound;
+import net.pterodactylus.sone.core.ConfigurationSoneParser.InvalidPostReplyFound;
+import net.pterodactylus.sone.core.SoneChangeDetector.PostProcessor;
+import net.pterodactylus.sone.core.SoneChangeDetector.PostReplyProcessor;
 import net.pterodactylus.sone.core.event.ImageInsertFinishedEvent;
 import net.pterodactylus.sone.core.event.MarkPostKnownEvent;
 import net.pterodactylus.sone.core.event.MarkPostReplyKnownEvent;
@@ -62,17 +70,17 @@ import net.pterodactylus.sone.data.Reply;
 import net.pterodactylus.sone.data.Sone;
 import net.pterodactylus.sone.data.Sone.ShowCustomAvatars;
 import net.pterodactylus.sone.data.Sone.SoneStatus;
-import net.pterodactylus.sone.data.SoneImpl;
 import net.pterodactylus.sone.data.TemporaryImage;
+import net.pterodactylus.sone.database.AlbumBuilder;
 import net.pterodactylus.sone.database.Database;
 import net.pterodactylus.sone.database.DatabaseException;
+import net.pterodactylus.sone.database.ImageBuilder;
 import net.pterodactylus.sone.database.PostBuilder;
 import net.pterodactylus.sone.database.PostProvider;
 import net.pterodactylus.sone.database.PostReplyBuilder;
 import net.pterodactylus.sone.database.PostReplyProvider;
+import net.pterodactylus.sone.database.SoneBuilder;
 import net.pterodactylus.sone.database.SoneProvider;
-import net.pterodactylus.sone.fcp.FcpInterface;
-import net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired;
 import net.pterodactylus.sone.freenet.wot.Identity;
 import net.pterodactylus.sone.freenet.wot.IdentityManager;
 import net.pterodactylus.sone.freenet.wot.OwnIdentity;
@@ -82,52 +90,45 @@ import net.pterodactylus.sone.freenet.wot.event.IdentityUpdatedEvent;
 import net.pterodactylus.sone.freenet.wot.event.OwnIdentityAddedEvent;
 import net.pterodactylus.sone.freenet.wot.event.OwnIdentityRemovedEvent;
 import net.pterodactylus.sone.main.SonePlugin;
-import net.pterodactylus.sone.utils.IntegerRangePredicate;
 import net.pterodactylus.util.config.Configuration;
 import net.pterodactylus.util.config.ConfigurationException;
-import net.pterodactylus.util.logging.Logging;
-import net.pterodactylus.util.number.Numbers;
 import net.pterodactylus.util.service.AbstractService;
 import net.pterodactylus.util.thread.NamedThreadFactory;
 
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Function;
 import com.google.common.base.Optional;
-import com.google.common.base.Predicate;
-import com.google.common.base.Predicates;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.HashMultimap;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Multimap;
 import com.google.common.collect.Multimaps;
 import com.google.common.eventbus.EventBus;
 import com.google.common.eventbus.Subscribe;
 import com.google.inject.Inject;
-
-import freenet.keys.FreenetURI;
+import com.google.inject.Singleton;
 
 /**
  * The Sone core.
  *
  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
  */
+@Singleton
 public class Core extends AbstractService implements SoneProvider, PostProvider, PostReplyProvider {
 
        /** The logger. */
-       private static final Logger logger = Logging.getLogger(Core.class);
+       private static final Logger logger = getLogger("Sone.Core");
 
        /** The start time. */
        private final long startupTime = System.currentTimeMillis();
 
-       /** The options. */
-       private final Options options = new Options();
-
        /** The preferences. */
-       private final Preferences preferences = new Preferences(options);
+       private final Preferences preferences;
 
        /** The event bus. */
        private final EventBus eventBus;
 
        /** The configuration. */
-       private Configuration configuration;
+       private final Configuration configuration;
 
        /** Whether we’re currently saving the configuration. */
        private boolean storingConfiguration = false;
@@ -153,9 +154,6 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
        /** The trust updater. */
        private final WebOfTrustUpdater webOfTrustUpdater;
 
-       /** The FCP interface. */
-       private volatile FcpInterface fcpInterface;
-
        /** The times Sones were followed. */
        private final Map<String, Long> soneFollowingTimes = new HashMap<String, Long>();
 
@@ -171,20 +169,12 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
        /* synchronize access on this on sones. */
        private final Map<Sone, SoneRescuer> soneRescuers = new HashMap<Sone, SoneRescuer>();
 
-       /** All Sones. */
-       /* synchronize access on this on itself. */
-       private final Map<String, Sone> sones = new HashMap<String, Sone>();
-
        /** All known Sones. */
        private final Set<String> knownSones = new HashSet<String>();
 
        /** The post database. */
        private final Database database;
 
-       /** All bookmarked posts. */
-       /* synchronize access on itself. */
-       private final Set<String> bookmarkedPosts = new HashSet<String>();
-
        /** Trusted identities, sorted by own identities. */
        private final Multimap<OwnIdentity, Identity> trustedIdentities = Multimaps.synchronizedSetMultimap(HashMultimap.<OwnIdentity, Identity>create());
 
@@ -219,12 +209,28 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
                this.configuration = configuration;
                this.freenetInterface = freenetInterface;
                this.identityManager = identityManager;
-               this.soneDownloader = new SoneDownloader(this, freenetInterface);
-               this.imageInserter = new ImageInserter(freenetInterface);
+               this.soneDownloader = new SoneDownloaderImpl(this, freenetInterface);
+               this.imageInserter = new ImageInserter(freenetInterface, freenetInterface.new InsertTokenSupplier());
                this.updateChecker = new UpdateChecker(eventBus, freenetInterface);
                this.webOfTrustUpdater = webOfTrustUpdater;
                this.eventBus = eventBus;
                this.database = database;
+               preferences = new Preferences(eventBus);
+       }
+
+       @VisibleForTesting
+       protected Core(Configuration configuration, FreenetInterface freenetInterface, IdentityManager identityManager, SoneDownloader soneDownloader, ImageInserter imageInserter, UpdateChecker updateChecker, WebOfTrustUpdater webOfTrustUpdater, EventBus eventBus, Database database) {
+               super("Sone Core");
+               this.configuration = configuration;
+               this.freenetInterface = freenetInterface;
+               this.identityManager = identityManager;
+               this.soneDownloader = soneDownloader;
+               this.imageInserter = imageInserter;
+               this.updateChecker = updateChecker;
+               this.webOfTrustUpdater = webOfTrustUpdater;
+               this.eventBus = eventBus;
+               this.database = database;
+               preferences = new Preferences(eventBus);
        }
 
        //
@@ -241,18 +247,6 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
        }
 
        /**
-        * Sets the configuration to use. This will automatically save the current
-        * configuration to the given configuration.
-        *
-        * @param configuration
-        *            The new configuration to use
-        */
-       public void setConfiguration(Configuration configuration) {
-               this.configuration = configuration;
-               touchConfiguration();
-       }
-
-       /**
         * Returns the options used by the core.
         *
         * @return The options of the core
@@ -280,16 +274,6 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
        }
 
        /**
-        * Sets the FCP interface to use.
-        *
-        * @param fcpInterface
-        *            The FCP interface to use
-        */
-       public void setFcpInterface(FcpInterface fcpInterface) {
-               this.fcpInterface = fcpInterface;
-       }
-
-       /**
         * Returns the Sone rescuer for the given local Sone.
         *
         * @param sone
@@ -299,7 +283,7 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
        public SoneRescuer getSoneRescuer(Sone sone) {
                checkNotNull(sone, "sone must not be null");
                checkArgument(sone.isLocal(), "sone must be local");
-               synchronized (sones) {
+               synchronized (soneRescuers) {
                        SoneRescuer soneRescuer = soneRescuers.get(sone);
                        if (soneRescuer == null) {
                                soneRescuer = new SoneRescuer(this, soneDownloader, sone);
@@ -323,14 +307,21 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
                }
        }
 
+       public SoneBuilder soneBuilder() {
+               return database.newSoneBuilder();
+       }
+
        /**
         * {@inheritDocs}
         */
        @Override
        public Collection<Sone> getSones() {
-               synchronized (sones) {
-                       return ImmutableSet.copyOf(sones.values());
-               }
+               return database.getSones();
+       }
+
+       @Override
+       public Function<String, Optional<Sone>> soneLoader() {
+               return database.soneLoader();
        }
 
        /**
@@ -344,9 +335,7 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
         */
        @Override
        public Optional<Sone> getSone(String id) {
-               synchronized (sones) {
-                       return Optional.fromNullable(sones.get(id));
-               }
+               return database.getSone(id);
        }
 
        /**
@@ -354,15 +343,7 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
         */
        @Override
        public Collection<Sone> getLocalSones() {
-               synchronized (sones) {
-                       return FluentIterable.from(sones.values()).filter(new Predicate<Sone>() {
-
-                               @Override
-                               public boolean apply(Sone sone) {
-                                       return sone.isLocal();
-                               }
-                       }).toSet();
-               }
+               return database.getLocalSones();
        }
 
        /**
@@ -370,24 +351,14 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
         *
         * @param id
         *            The ID of the Sone
-        * @param create
-        *            {@code true} to create a new Sone if none exists,
-        *            {@code false} to return null if none exists
         * @return The Sone with the given ID, or {@code null}
         */
-       public Sone getLocalSone(String id, boolean create) {
-               synchronized (sones) {
-                       Sone sone = sones.get(id);
-                       if ((sone == null) && create) {
-                               sone = new SoneImpl(id, true);
-                               sones.put(id, sone);
-                       }
-                       if ((sone != null) && !sone.isLocal()) {
-                               sone = new SoneImpl(id, true);
-                               sones.put(id, sone);
-                       }
-                       return sone;
+       public Sone getLocalSone(String id) {
+               Optional<Sone> sone = database.getSone(id);
+               if (sone.isPresent() && sone.get().isLocal()) {
+                       return sone.get();
                }
+               return null;
        }
 
        /**
@@ -395,36 +366,19 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
         */
        @Override
        public Collection<Sone> getRemoteSones() {
-               synchronized (sones) {
-                       return FluentIterable.from(sones.values()).filter(new Predicate<Sone>() {
-
-                               @Override
-                               public boolean apply(Sone sone) {
-                                       return !sone.isLocal();
-                               }
-                       }).toSet();
-               }
+               return database.getRemoteSones();
        }
 
        /**
         * Returns the remote Sone with the given ID.
         *
+        *
         * @param id
         *            The ID of the remote Sone to get
-        * @param create
-        *            {@code true} to always create a Sone, {@code false} to return
-        *            {@code null} if no Sone with the given ID exists
         * @return The Sone with the given ID
         */
-       public Sone getRemoteSone(String id, boolean create) {
-               synchronized (sones) {
-                       Sone sone = sones.get(id);
-                       if ((sone == null) && create && (id != null) && (id.length() == 43)) {
-                               sone = new SoneImpl(id, false);
-                               sones.put(id, sone);
-                       }
-                       return sone;
-               }
+       public Sone getRemoteSone(String id) {
+               return database.getSone(id).orNull();
        }
 
        /**
@@ -436,7 +390,7 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
         *         {@code false} otherwise
         */
        public boolean isModifiedSone(Sone sone) {
-               return (soneInserters.containsKey(sone)) ? soneInserters.get(sone).isModified() : false;
+               return soneInserters.containsKey(sone) && soneInserters.get(sone).isModified();
        }
 
        /**
@@ -454,22 +408,6 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
        }
 
        /**
-        * Returns whether the target Sone is trusted by the origin Sone.
-        *
-        * @param origin
-        *            The origin Sone
-        * @param target
-        *            The target Sone
-        * @return {@code true} if the target Sone is trusted by the origin Sone
-        */
-       public boolean isSoneTrusted(Sone origin, Sone target) {
-               checkNotNull(origin, "origin must not be null");
-               checkNotNull(target, "target must not be null");
-               checkArgument(origin.getIdentity() instanceof OwnIdentity, "origin’s identity must be an OwnIdentity");
-               return trustedIdentities.containsEntry(origin.getIdentity(), target.getIdentity());
-       }
-
-       /**
         * Returns a post builder.
         *
         * @return A new post builder
@@ -571,21 +509,7 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
         *         otherwise
         */
        public boolean isBookmarked(Post post) {
-               return isPostBookmarked(post.getId());
-       }
-
-       /**
-        * Returns whether the post with the given ID is bookmarked.
-        *
-        * @param id
-        *            The ID of the post to check
-        * @return {@code true} if the post with the given ID is bookmarked,
-        *         {@code false} otherwise
-        */
-       public boolean isPostBookmarked(String id) {
-               synchronized (bookmarkedPosts) {
-                       return bookmarkedPosts.contains(id);
-               }
+               return database.isPostBookmarked(post);
        }
 
        /**
@@ -594,28 +518,11 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
         * @return All bookmarked posts
         */
        public Set<Post> getBookmarkedPosts() {
-               Set<Post> posts = new HashSet<Post>();
-               synchronized (bookmarkedPosts) {
-                       for (String bookmarkedPostId : bookmarkedPosts) {
-                               Optional<Post> post = getPost(bookmarkedPostId);
-                               if (post.isPresent()) {
-                                       posts.add(post.get());
-                               }
-                       }
-               }
-               return posts;
+               return database.getBookmarkedPosts();
        }
 
-       /**
-        * Returns the album with the given ID, creating a new album if no album
-        * with the given ID can be found.
-        *
-        * @param albumId
-        *            The ID of the album
-        * @return The album with the given ID
-        */
-       public Album getAlbum(String albumId) {
-               return getAlbum(albumId, true);
+       public AlbumBuilder albumBuilder() {
+               return database.newAlbumBuilder();
        }
 
        /**
@@ -624,23 +531,15 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
         *
         * @param albumId
         *            The ID of the album
-        * @param create
-        *            {@code true} to create a new album if none exists for the
-        *            given ID
         * @return The album with the given ID, or {@code null} if no album with the
-        *         given ID exists and {@code create} is {@code false}
+        *         given ID exists
         */
-       public Album getAlbum(String albumId, boolean create) {
-               Optional<Album> album = database.getAlbum(albumId);
-               if (album.isPresent()) {
-                       return album.get();
-               }
-               if (!create) {
-                       return null;
-               }
-               Album newAlbum = database.newAlbumBuilder().withId(albumId).build();
-               database.storeAlbum(newAlbum);
-               return newAlbum;
+       public Album getAlbum(String albumId) {
+               return database.getAlbum(albumId).orNull();
+       }
+
+       public ImageBuilder imageBuilder() {
+               return database.newImageBuilder();
        }
 
        /**
@@ -741,26 +640,19 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
                        return null;
                }
                logger.info(String.format("Adding Sone from OwnIdentity: %s", ownIdentity));
-               synchronized (sones) {
-                       final Sone sone;
-                       try {
-                               sone = getLocalSone(ownIdentity.getId(), true).setIdentity(ownIdentity).setInsertUri(new FreenetURI(ownIdentity.getInsertUri())).setRequestUri(new FreenetURI(ownIdentity.getRequestUri()));
-                       } catch (MalformedURLException mue1) {
-                               logger.log(Level.SEVERE, String.format("Could not convert the Identity’s URIs to Freenet URIs: %s, %s", ownIdentity.getInsertUri(), ownIdentity.getRequestUri()), mue1);
-                               return null;
-                       }
-                       sone.setLatestEdition(Numbers.safeParseLong(ownIdentity.getProperty("Sone.LatestEdition"), (long) 0));
-                       sone.setClient(new Client("Sone", SonePlugin.VERSION.toString()));
-                       sone.setKnown(true);
-                       /* TODO - load posts ’n stuff */
-                       sones.put(ownIdentity.getId(), sone);
-                       final SoneInserter soneInserter = new SoneInserter(this, eventBus, freenetInterface, sone);
+               Sone sone = database.newSoneBuilder().local().from(ownIdentity).build();
+               sone.setLatestEdition(fromNullable(tryParse(ownIdentity.getProperty("Sone.LatestEdition"))).or(0L));
+               sone.setClient(new Client("Sone", SonePlugin.VERSION.toString()));
+               sone.setKnown(true);
+               SoneInserter soneInserter = new SoneInserter(this, eventBus, freenetInterface, ownIdentity.getId());
+               eventBus.register(soneInserter);
+               synchronized (soneInserters) {
                        soneInserters.put(sone, soneInserter);
-                       sone.setStatus(SoneStatus.idle);
-                       loadSone(sone);
-                       soneInserter.start();
-                       return sone;
                }
+               loadSone(sone);
+               sone.setStatus(SoneStatus.idle);
+               soneInserter.start();
+               return sone;
        }
 
        /**
@@ -776,12 +668,6 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
                        return null;
                }
                Sone sone = addLocalSone(ownIdentity);
-               sone.getOptions().addBooleanOption("AutoFollow", new DefaultOption<Boolean>(false));
-               sone.getOptions().addBooleanOption("EnableSoneInsertNotifications", new DefaultOption<Boolean>(false));
-               sone.getOptions().addBooleanOption("ShowNotification/NewSones", new DefaultOption<Boolean>(true));
-               sone.getOptions().addBooleanOption("ShowNotification/NewPosts", new DefaultOption<Boolean>(true));
-               sone.getOptions().addBooleanOption("ShowNotification/NewReplies", new DefaultOption<Boolean>(true));
-               sone.getOptions().addEnumOption("ShowCustomAvatars", new DefaultOption<ShowCustomAvatars>(ShowCustomAvatars.NEVER));
 
                followSone(sone, "nwa8lHa271k2QvJ8aa0Ov7IHAV-DFOCFgmDt3X6BpCI");
                touchConfiguration();
@@ -800,41 +686,33 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
                        logger.log(Level.WARNING, "Given Identity is null!");
                        return null;
                }
-               synchronized (sones) {
-                       final Sone sone = getRemoteSone(identity.getId(), true);
-                       if (sone.isLocal()) {
-                               return sone;
+               final Long latestEdition = tryParse(fromNullable(
+                               identity.getProperty("Sone.LatestEdition")).or("0"));
+               Optional<Sone> existingSone = getSone(identity.getId());
+               if (existingSone.isPresent() && existingSone.get().isLocal()) {
+                       return existingSone.get();
+               }
+               boolean newSone = !existingSone.isPresent();
+               Sone sone = !newSone ? existingSone.get() : database.newSoneBuilder().from(identity).build();
+               sone.setLatestEdition(latestEdition);
+               if (newSone) {
+                       synchronized (knownSones) {
+                               newSone = !knownSones.contains(sone.getId());
                        }
-                       sone.setIdentity(identity);
-                       boolean newSone = sone.getRequestUri() == null;
-                       sone.setRequestUri(SoneUri.create(identity.getRequestUri()));
-                       sone.setLatestEdition(Numbers.safeParseLong(identity.getProperty("Sone.LatestEdition"), (long) 0));
+                       sone.setKnown(!newSone);
                        if (newSone) {
-                               synchronized (knownSones) {
-                                       newSone = !knownSones.contains(sone.getId());
-                               }
-                               sone.setKnown(!newSone);
-                               if (newSone) {
-                                       eventBus.post(new NewSoneFoundEvent(sone));
-                                       for (Sone localSone : getLocalSones()) {
-                                               if (localSone.getOptions().getBooleanOption("AutoFollow").get()) {
-                                                       followSone(localSone, sone.getId());
-                                               }
+                               eventBus.post(new NewSoneFoundEvent(sone));
+                               for (Sone localSone : getLocalSones()) {
+                                       if (localSone.getOptions().isAutoFollow()) {
+                                               followSone(localSone, sone.getId());
                                        }
                                }
                        }
-                       soneDownloader.addSone(sone);
-                       soneDownloaders.execute(new Runnable() {
-
-                               @Override
-                               @SuppressWarnings("synthetic-access")
-                               public void run() {
-                                       soneDownloader.fetchSone(sone, sone.getRequestUri());
-                               }
-
-                       });
-                       return sone;
                }
+               database.storeSone(sone);
+               soneDownloader.addSone(sone);
+               soneDownloaders.execute(soneDownloader.fetchSoneWithUriAction(sone));
+               return sone;
        }
 
        /**
@@ -848,7 +726,7 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
        public void followSone(Sone sone, String soneId) {
                checkNotNull(sone, "sone must not be null");
                checkNotNull(soneId, "soneId must not be null");
-               sone.addFriend(soneId);
+               database.addFriend(sone, soneId);
                synchronized (soneFollowingTimes) {
                        if (!soneFollowingTimes.containsKey(soneId)) {
                                long now = System.currentTimeMillis();
@@ -883,7 +761,7 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
        public void unfollowSone(Sone sone, String soneId) {
                checkNotNull(sone, "sone must not be null");
                checkNotNull(soneId, "soneId must not be null");
-               sone.removeFriend(soneId);
+               database.removeFriend(sone, soneId);
                boolean unfollowedSoneStillFollowed = false;
                for (Sone localSone : getLocalSones()) {
                        unfollowedSoneStillFollowed |= localSone.hasFriend(soneId);
@@ -986,75 +864,67 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
         *            {@code true} if the stored Sone should be updated regardless
         *            of the age of the given Sone
         */
-       public void updateSone(Sone sone, boolean soneRescueMode) {
+       public void updateSone(final Sone sone, boolean soneRescueMode) {
                Optional<Sone> storedSone = getSone(sone.getId());
                if (storedSone.isPresent()) {
                        if (!soneRescueMode && !(sone.getTime() > storedSone.get().getTime())) {
                                logger.log(Level.FINE, String.format("Downloaded Sone %s is not newer than stored Sone %s.", sone, storedSone));
                                return;
                        }
-                       /* find removed posts. */
-                       Collection<Post> existingPosts = database.getPosts(sone.getId());
-                       for (Post oldPost : existingPosts) {
-                               if (!sone.getPosts().contains(oldPost)) {
-                                       eventBus.post(new PostRemovedEvent(oldPost));
-                               }
+                       List<Object> events =
+                                       collectEventsForChangesInSone(storedSone.get(), sone);
+                       database.storeSone(sone);
+                       for (Object event : events) {
+                               eventBus.post(event);
                        }
-                       /* find new posts. */
-                       for (Post newPost : sone.getPosts()) {
-                               if (existingPosts.contains(newPost)) {
-                                       continue;
-                               }
-                               if (newPost.getTime() < getSoneFollowingTime(sone)) {
-                                       newPost.setKnown(true);
-                               } else if (!newPost.isKnown()) {
-                                       eventBus.post(new NewPostFoundEvent(newPost));
-                               }
-                       }
-                       /* store posts. */
-                       database.storePosts(sone, sone.getPosts());
-                       if (!soneRescueMode) {
-                               for (PostReply reply : storedSone.get().getReplies()) {
-                                       if (!sone.getReplies().contains(reply)) {
-                                               eventBus.post(new PostReplyRemovedEvent(reply));
-                                       }
-                               }
+                       sone.setOptions(storedSone.get().getOptions());
+                       sone.setKnown(storedSone.get().isKnown());
+                       sone.setStatus((sone.getTime() == 0) ? SoneStatus.unknown : SoneStatus.idle);
+                       if (sone.isLocal()) {
+                               touchConfiguration();
                        }
-                       Set<PostReply> storedReplies = storedSone.get().getReplies();
-                       for (PostReply reply : sone.getReplies()) {
-                               if (storedReplies.contains(reply)) {
-                                       continue;
-                               }
-                               if (reply.getTime() < getSoneFollowingTime(sone)) {
-                                       reply.setKnown(true);
-                               } else if (!reply.isKnown()) {
-                                       eventBus.post(new NewPostReplyFoundEvent(reply));
+               }
+       }
+
+       private List<Object> collectEventsForChangesInSone(Sone oldSone,
+                       final Sone newSone) {
+               final List<Object> events = new ArrayList<Object>();
+               SoneChangeDetector soneChangeDetector = new SoneChangeDetector(
+                               oldSone);
+               soneChangeDetector.onNewPosts(new PostProcessor() {
+                       @Override
+                       public void processPost(Post post) {
+                               if (post.getTime() < getSoneFollowingTime(newSone)) {
+                                       post.setKnown(true);
+                               } else if (!post.isKnown()) {
+                                       events.add(new NewPostFoundEvent(post));
                                }
                        }
-                       database.storePostReplies(sone, sone.getReplies());
-                       for (Album album : storedSone.get().getRootAlbum().getAlbums()) {
-                               database.removeAlbum(album);
-                               for (Image image : album.getImages()) {
-                                       database.removeImage(image);
-                               }
+               });
+               soneChangeDetector.onRemovedPosts(new PostProcessor() {
+                       @Override
+                       public void processPost(Post post) {
+                               events.add(new PostRemovedEvent(post));
                        }
-                       for (Album album : sone.getRootAlbum().getAlbums()) {
-                               database.storeAlbum(album);
-                               for (Image image : album.getImages()) {
-                                       database.storeImage(image);
+               });
+               soneChangeDetector.onNewPostReplies(new PostReplyProcessor() {
+                       @Override
+                       public void processPostReply(PostReply postReply) {
+                               if (postReply.getTime() < getSoneFollowingTime(newSone)) {
+                                       postReply.setKnown(true);
+                               } else if (!postReply.isKnown()) {
+                                       events.add(new NewPostReplyFoundEvent(postReply));
                                }
                        }
-                       synchronized (sones) {
-                               sone.setOptions(storedSone.get().getOptions());
-                               sone.setKnown(storedSone.get().isKnown());
-                               sone.setStatus((sone.getTime() == 0) ? SoneStatus.unknown : SoneStatus.idle);
-                               if (sone.isLocal()) {
-                                       soneInserters.get(storedSone.get()).setSone(sone);
-                                       touchConfiguration();
-                               }
-                               sones.put(sone.getId(), sone);
+               });
+               soneChangeDetector.onRemovedPostReplies(new PostReplyProcessor() {
+                       @Override
+                       public void processPostReply(PostReply postReply) {
+                               events.add(new PostReplyRemovedEvent(postReply));
                        }
-               }
+               });
+               soneChangeDetector.detectChanges(newSone);
+               return events;
        }
 
        /**
@@ -1070,15 +940,13 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
                        logger.log(Level.WARNING, String.format("Tried to delete Sone of non-own identity: %s", sone));
                        return;
                }
-               synchronized (sones) {
-                       if (!getLocalSones().contains(sone)) {
-                               logger.log(Level.WARNING, String.format("Tried to delete non-local Sone: %s", sone));
-                               return;
-                       }
-                       sones.remove(sone.getId());
-                       SoneInserter soneInserter = soneInserters.remove(sone);
-                       soneInserter.stop();
+               if (!getLocalSones().contains(sone)) {
+                       logger.log(Level.WARNING, String.format("Tried to delete non-local Sone: %s", sone));
+                       return;
                }
+               SoneInserter soneInserter = soneInserters.remove(sone);
+               soneInserter.stop();
+               database.removeSone(sone);
                webOfTrustUpdater.removeContext((OwnIdentity) sone.getIdentity(), "Sone");
                webOfTrustUpdater.removeProperty((OwnIdentity) sone.getIdentity(), "Sone.LatestEdition");
                try {
@@ -1120,14 +988,6 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
                }
                logger.info(String.format("Loading local Sone: %s", sone));
 
-               /* initialize options. */
-               sone.getOptions().addBooleanOption("AutoFollow", new DefaultOption<Boolean>(false));
-               sone.getOptions().addBooleanOption("EnableSoneInsertNotifications", new DefaultOption<Boolean>(false));
-               sone.getOptions().addBooleanOption("ShowNotification/NewSones", new DefaultOption<Boolean>(true));
-               sone.getOptions().addBooleanOption("ShowNotification/NewPosts", new DefaultOption<Boolean>(true));
-               sone.getOptions().addBooleanOption("ShowNotification/NewReplies", new DefaultOption<Boolean>(true));
-               sone.getOptions().addEnumOption("ShowCustomAvatars", new DefaultOption<ShowCustomAvatars>(ShowCustomAvatars.NEVER));
-
                /* load Sone. */
                String sonePrefix = "Sone/" + sone.getId();
                Long soneTime = configuration.getLongValue(sonePrefix + "/Time").getValue(null);
@@ -1138,169 +998,77 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
                String lastInsertFingerprint = configuration.getStringValue(sonePrefix + "/LastInsertFingerprint").getValue("");
 
                /* load profile. */
-               Profile profile = new Profile(sone);
-               profile.setFirstName(configuration.getStringValue(sonePrefix + "/Profile/FirstName").getValue(null));
-               profile.setMiddleName(configuration.getStringValue(sonePrefix + "/Profile/MiddleName").getValue(null));
-               profile.setLastName(configuration.getStringValue(sonePrefix + "/Profile/LastName").getValue(null));
-               profile.setBirthDay(configuration.getIntValue(sonePrefix + "/Profile/BirthDay").getValue(null));
-               profile.setBirthMonth(configuration.getIntValue(sonePrefix + "/Profile/BirthMonth").getValue(null));
-               profile.setBirthYear(configuration.getIntValue(sonePrefix + "/Profile/BirthYear").getValue(null));
-
-               /* load profile fields. */
-               while (true) {
-                       String fieldPrefix = sonePrefix + "/Profile/Fields/" + profile.getFields().size();
-                       String fieldName = configuration.getStringValue(fieldPrefix + "/Name").getValue(null);
-                       if (fieldName == null) {
-                               break;
-                       }
-                       String fieldValue = configuration.getStringValue(fieldPrefix + "/Value").getValue("");
-                       profile.addField(fieldName).setValue(fieldValue);
-               }
+               ConfigurationSoneParser configurationSoneParser = new ConfigurationSoneParser(configuration, sone);
+               Profile profile = configurationSoneParser.parseProfile();
 
                /* load posts. */
-               Set<Post> posts = new HashSet<Post>();
-               while (true) {
-                       String postPrefix = sonePrefix + "/Posts/" + posts.size();
-                       String postId = configuration.getStringValue(postPrefix + "/ID").getValue(null);
-                       if (postId == null) {
-                               break;
-                       }
-                       String postRecipientId = configuration.getStringValue(postPrefix + "/Recipient").getValue(null);
-                       long postTime = configuration.getLongValue(postPrefix + "/Time").getValue((long) 0);
-                       String postText = configuration.getStringValue(postPrefix + "/Text").getValue(null);
-                       if ((postTime == 0) || (postText == null)) {
-                               logger.log(Level.WARNING, "Invalid post found, aborting load!");
-                               return;
-                       }
-                       PostBuilder postBuilder = postBuilder().withId(postId).from(sone.getId()).withTime(postTime).withText(postText);
-                       if ((postRecipientId != null) && (postRecipientId.length() == 43)) {
-                               postBuilder.to(postRecipientId);
-                       }
-                       posts.add(postBuilder.build());
+               Collection<Post> posts;
+               try {
+                       posts = configurationSoneParser.parsePosts(database);
+               } catch (InvalidPostFound ipf) {
+                       logger.log(Level.WARNING, "Invalid post found, aborting load!");
+                       return;
                }
 
                /* load replies. */
-               Set<PostReply> replies = new HashSet<PostReply>();
-               while (true) {
-                       String replyPrefix = sonePrefix + "/Replies/" + replies.size();
-                       String replyId = configuration.getStringValue(replyPrefix + "/ID").getValue(null);
-                       if (replyId == null) {
-                               break;
-                       }
-                       String postId = configuration.getStringValue(replyPrefix + "/Post/ID").getValue(null);
-                       long replyTime = configuration.getLongValue(replyPrefix + "/Time").getValue((long) 0);
-                       String replyText = configuration.getStringValue(replyPrefix + "/Text").getValue(null);
-                       if ((postId == null) || (replyTime == 0) || (replyText == null)) {
-                               logger.log(Level.WARNING, "Invalid reply found, aborting load!");
-                               return;
-                       }
-                       PostReplyBuilder postReplyBuilder = postReplyBuilder().withId(replyId).from(sone.getId()).to(postId).withTime(replyTime).withText(replyText);
-                       replies.add(postReplyBuilder.build());
+               Collection<PostReply> replies;
+               try {
+                       replies = configurationSoneParser.parsePostReplies(database);
+               } catch (InvalidPostReplyFound iprf) {
+                       logger.log(Level.WARNING, "Invalid reply found, aborting load!");
+                       return;
                }
 
                /* load post likes. */
-               Set<String> likedPostIds = new HashSet<String>();
-               while (true) {
-                       String likedPostId = configuration.getStringValue(sonePrefix + "/Likes/Post/" + likedPostIds.size() + "/ID").getValue(null);
-                       if (likedPostId == null) {
-                               break;
-                       }
-                       likedPostIds.add(likedPostId);
-               }
+               Set<String> likedPostIds =
+                               configurationSoneParser.parseLikedPostIds();
 
                /* load reply likes. */
-               Set<String> likedReplyIds = new HashSet<String>();
-               while (true) {
-                       String likedReplyId = configuration.getStringValue(sonePrefix + "/Likes/Reply/" + likedReplyIds.size() + "/ID").getValue(null);
-                       if (likedReplyId == null) {
-                               break;
-                       }
-                       likedReplyIds.add(likedReplyId);
-               }
-
-               /* load friends. */
-               Set<String> friends = new HashSet<String>();
-               while (true) {
-                       String friendId = configuration.getStringValue(sonePrefix + "/Friends/" + friends.size() + "/ID").getValue(null);
-                       if (friendId == null) {
-                               break;
-                       }
-                       friends.add(friendId);
-               }
+               Set<String> likedReplyIds =
+                               configurationSoneParser.parseLikedPostReplyIds();
 
                /* load albums. */
-               List<Album> topLevelAlbums = new ArrayList<Album>();
-               int albumCounter = 0;
-               while (true) {
-                       String albumPrefix = sonePrefix + "/Albums/" + albumCounter++;
-                       String albumId = configuration.getStringValue(albumPrefix + "/ID").getValue(null);
-                       if (albumId == null) {
-                               break;
-                       }
-                       String albumTitle = configuration.getStringValue(albumPrefix + "/Title").getValue(null);
-                       String albumDescription = configuration.getStringValue(albumPrefix + "/Description").getValue(null);
-                       String albumParentId = configuration.getStringValue(albumPrefix + "/Parent").getValue(null);
-                       String albumImageId = configuration.getStringValue(albumPrefix + "/AlbumImage").getValue(null);
-                       if ((albumTitle == null) || (albumDescription == null)) {
-                               logger.log(Level.WARNING, "Invalid album found, aborting load!");
-                               return;
-                       }
-                       Album album = getAlbum(albumId).setSone(sone).modify().setTitle(albumTitle).setDescription(albumDescription).setAlbumImage(albumImageId).update();
-                       if (albumParentId != null) {
-                               Album parentAlbum = getAlbum(albumParentId, false);
-                               if (parentAlbum == null) {
-                                       logger.log(Level.WARNING, String.format("Invalid parent album ID: %s", albumParentId));
-                                       return;
-                               }
-                               parentAlbum.addAlbum(album);
-                       } else {
-                               if (!topLevelAlbums.contains(album)) {
-                                       topLevelAlbums.add(album);
-                               }
-                       }
+               List<Album> topLevelAlbums;
+               try {
+                       topLevelAlbums =
+                                       configurationSoneParser.parseTopLevelAlbums(database);
+               } catch (InvalidAlbumFound iaf) {
+                       logger.log(Level.WARNING, "Invalid album found, aborting load!");
+                       return;
+               } catch (InvalidParentAlbumFound ipaf) {
+                       logger.log(Level.WARNING, format("Invalid parent album ID: %s",
+                                       ipaf.getAlbumParentId()));
+                       return;
                }
 
                /* load images. */
-               int imageCounter = 0;
-               while (true) {
-                       String imagePrefix = sonePrefix + "/Images/" + imageCounter++;
-                       String imageId = configuration.getStringValue(imagePrefix + "/ID").getValue(null);
-                       if (imageId == null) {
-                               break;
-                       }
-                       String albumId = configuration.getStringValue(imagePrefix + "/Album").getValue(null);
-                       String key = configuration.getStringValue(imagePrefix + "/Key").getValue(null);
-                       String title = configuration.getStringValue(imagePrefix + "/Title").getValue(null);
-                       String description = configuration.getStringValue(imagePrefix + "/Description").getValue(null);
-                       Long creationTime = configuration.getLongValue(imagePrefix + "/CreationTime").getValue(null);
-                       Integer width = configuration.getIntValue(imagePrefix + "/Width").getValue(null);
-                       Integer height = configuration.getIntValue(imagePrefix + "/Height").getValue(null);
-                       if ((albumId == null) || (key == null) || (title == null) || (description == null) || (creationTime == null) || (width == null) || (height == null)) {
-                               logger.log(Level.WARNING, "Invalid image found, aborting load!");
-                               return;
-                       }
-                       Album album = getAlbum(albumId, false);
-                       if (album == null) {
-                               logger.log(Level.WARNING, "Invalid album image encountered, aborting load!");
-                               return;
-                       }
-                       Image image = getImage(imageId).modify().setSone(sone).setCreationTime(creationTime).setKey(key).setTitle(title).setDescription(description).setWidth(width).setHeight(height).update();
-                       album.addImage(image);
+               try {
+                       configurationSoneParser.parseImages(database);
+               } catch (InvalidImageFound iif) {
+                       logger.log(WARNING, "Invalid image found, aborting load!");
+                       return;
+               } catch (InvalidParentAlbumFound ipaf) {
+                       logger.log(Level.WARNING,
+                                       format("Invalid album image (%s) encountered, aborting load!",
+                                                       ipaf.getAlbumParentId()));
+                       return;
                }
 
                /* load avatar. */
                String avatarId = configuration.getStringValue(sonePrefix + "/Profile/Avatar").getValue(null);
                if (avatarId != null) {
-                       profile.setAvatar(getImage(avatarId, false));
+                       final Map<String, Image> images =
+                                       configurationSoneParser.getImages();
+                       profile.setAvatar(images.get(avatarId));
                }
 
                /* load options. */
-               sone.getOptions().getBooleanOption("AutoFollow").set(configuration.getBooleanValue(sonePrefix + "/Options/AutoFollow").getValue(null));
-               sone.getOptions().getBooleanOption("EnableSoneInsertNotifications").set(configuration.getBooleanValue(sonePrefix + "/Options/EnableSoneInsertNotifications").getValue(null));
-               sone.getOptions().getBooleanOption("ShowNotification/NewSones").set(configuration.getBooleanValue(sonePrefix + "/Options/ShowNotification/NewSones").getValue(null));
-               sone.getOptions().getBooleanOption("ShowNotification/NewPosts").set(configuration.getBooleanValue(sonePrefix + "/Options/ShowNotification/NewPosts").getValue(null));
-               sone.getOptions().getBooleanOption("ShowNotification/NewReplies").set(configuration.getBooleanValue(sonePrefix + "/Options/ShowNotification/NewReplies").getValue(null));
-               sone.getOptions().<ShowCustomAvatars> getEnumOption("ShowCustomAvatars").set(ShowCustomAvatars.valueOf(configuration.getStringValue(sonePrefix + "/Options/ShowCustomAvatars").getValue(ShowCustomAvatars.NEVER.name())));
+               sone.getOptions().setAutoFollow(configuration.getBooleanValue(sonePrefix + "/Options/AutoFollow").getValue(null));
+               sone.getOptions().setSoneInsertNotificationEnabled(configuration.getBooleanValue(sonePrefix + "/Options/EnableSoneInsertNotifications").getValue(null));
+               sone.getOptions().setShowNewSoneNotifications(configuration.getBooleanValue(sonePrefix + "/Options/ShowNotification/NewSones").getValue(null));
+               sone.getOptions().setShowNewPostNotifications(configuration.getBooleanValue(sonePrefix + "/Options/ShowNotification/NewPosts").getValue(null));
+               sone.getOptions().setShowNewReplyNotifications(configuration.getBooleanValue(sonePrefix + "/Options/ShowNotification/NewReplies").getValue(null));
+               sone.getOptions().setShowCustomAvatars(ShowCustomAvatars.valueOf(configuration.getStringValue(sonePrefix + "/Options/ShowCustomAvatars").getValue(ShowCustomAvatars.NEVER.name())));
 
                /* if we’re still here, Sone was loaded successfully. */
                synchronized (sone) {
@@ -1310,27 +1078,20 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
                        sone.setReplies(replies);
                        sone.setLikePostIds(likedPostIds);
                        sone.setLikeReplyIds(likedReplyIds);
-                       for (String friendId : friends) {
-                               followSone(sone, friendId);
-                       }
                        for (Album album : sone.getRootAlbum().getAlbums()) {
                                sone.getRootAlbum().removeAlbum(album);
                        }
                        for (Album album : topLevelAlbums) {
                                sone.getRootAlbum().addAlbum(album);
                        }
-                       soneInserters.get(sone).setLastInsertFingerprint(lastInsertFingerprint);
-               }
-               synchronized (knownSones) {
-                       for (String friend : friends) {
-                               knownSones.add(friend);
+                       database.storeSone(sone);
+                       synchronized (soneInserters) {
+                               soneInserters.get(sone).setLastInsertFingerprint(lastInsertFingerprint);
                        }
                }
-               database.storePosts(sone, posts);
                for (Post post : posts) {
                        post.setKnown(true);
                }
-               database.storePostReplies(sone, replies);
                for (PostReply reply : replies) {
                        reply.setKnown(true);
                }
@@ -1343,34 +1104,6 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
         *
         * @param sone
         *            The Sone that creates the post
-        * @param text
-        *            The text of the post
-        * @return The created post
-        */
-       public Post createPost(Sone sone, String text) {
-               return createPost(sone, System.currentTimeMillis(), text);
-       }
-
-       /**
-        * Creates a new post.
-        *
-        * @param sone
-        *            The Sone that creates the post
-        * @param time
-        *            The time of the post
-        * @param text
-        *            The text of the post
-        * @return The created post
-        */
-       public Post createPost(Sone sone, long time, String text) {
-               return createPost(sone, null, time, text);
-       }
-
-       /**
-        * Creates a new post.
-        *
-        * @param sone
-        *            The Sone that creates the post
         * @param recipient
         *            The recipient Sone, or {@code null} if this post does not have
         *            a recipient
@@ -1379,24 +1112,6 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
         * @return The created post
         */
        public Post createPost(Sone sone, Optional<Sone> recipient, String text) {
-               return createPost(sone, recipient, System.currentTimeMillis(), text);
-       }
-
-       /**
-        * Creates a new post.
-        *
-        * @param sone
-        *            The Sone that creates the post
-        * @param recipient
-        *            The recipient Sone, or {@code null} if this post does not have
-        *            a recipient
-        * @param time
-        *            The time of the post
-        * @param text
-        *            The text of the post
-        * @return The created post
-        */
-       public Post createPost(Sone sone, Optional<Sone> recipient, long time, String text) {
                checkNotNull(text, "text must not be null");
                checkArgument(text.trim().length() > 0, "text must not be empty");
                if (!sone.isLocal()) {
@@ -1404,7 +1119,7 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
                        return null;
                }
                PostBuilder postBuilder = database.newPostBuilder();
-               postBuilder.from(sone.getId()).randomId().withTime(time).withText(text.trim());
+               postBuilder.from(sone.getId()).randomId().currentTime().withText(text.trim());
                if (recipient.isPresent()) {
                        postBuilder.to(recipient.get().getId());
                }
@@ -1413,16 +1128,7 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
                eventBus.post(new NewPostFoundEvent(post));
                sone.addPost(post);
                touchConfiguration();
-               localElementTicker.schedule(new Runnable() {
-
-                       /**
-                        * {@inheritDoc}
-                        */
-                       @Override
-                       public void run() {
-                               markPostKnown(post);
-                       }
-               }, 10, TimeUnit.SECONDS);
+               localElementTicker.schedule(new MarkPostKnown(post), 10, TimeUnit.SECONDS);
                return post;
        }
 
@@ -1459,26 +1165,8 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
                }
        }
 
-       /**
-        * Bookmarks the given post.
-        *
-        * @param post
-        *            The post to bookmark
-        */
-       public void bookmark(Post post) {
-               bookmarkPost(post.getId());
-       }
-
-       /**
-        * Bookmarks the post with the given ID.
-        *
-        * @param id
-        *            The ID of the post to bookmark
-        */
-       public void bookmarkPost(String id) {
-               synchronized (bookmarkedPosts) {
-                       bookmarkedPosts.add(id);
-               }
+       public void bookmarkPost(Post post) {
+               database.bookmarkPost(post);
        }
 
        /**
@@ -1487,20 +1175,8 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
         * @param post
         *            The post to unbookmark
         */
-       public void unbookmark(Post post) {
-               unbookmarkPost(post.getId());
-       }
-
-       /**
-        * Removes the post with the given ID from the bookmarks.
-        *
-        * @param id
-        *            The ID of the post to unbookmark
-        */
-       public void unbookmarkPost(String id) {
-               synchronized (bookmarkedPosts) {
-                       bookmarkedPosts.remove(id);
-               }
+       public void unbookmarkPost(Post post) {
+               database.unbookmarkPost(post);
        }
 
        /**
@@ -1528,16 +1204,7 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
                eventBus.post(new NewPostReplyFoundEvent(reply));
                sone.addReply(reply);
                touchConfiguration();
-               localElementTicker.schedule(new Runnable() {
-
-                       /**
-                        * {@inheritDoc}
-                        */
-                       @Override
-                       public void run() {
-                               markReplyKnown(reply);
-                       }
-               }, 10, TimeUnit.SECONDS);
+               localElementTicker.schedule(new MarkReplyKnown(reply), 10, TimeUnit.SECONDS);
                return reply;
        }
 
@@ -1576,17 +1243,6 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
        }
 
        /**
-        * Creates a new top-level album for the given Sone.
-        *
-        * @param sone
-        *            The Sone to create the album for
-        * @return The new album
-        */
-       public Album createAlbum(Sone sone) {
-               return createAlbum(sone, sone.getRootAlbum());
-       }
-
-       /**
         * Creates a new album for the given Sone.
         *
         * @param sone
@@ -1597,9 +1253,8 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
         * @return The new album
         */
        public Album createAlbum(Sone sone, Album parent) {
-               Album album = database.newAlbumBuilder().randomId().build();
+               Album album = database.newAlbumBuilder().randomId().by(sone).build();
                database.storeAlbum(album);
-               album.setSone(sone);
                parent.addAlbum(album);
                return album;
        }
@@ -1650,7 +1305,7 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
         * Deletes the given image. This method will also delete a matching
         * temporary image.
         *
-        * @see #deleteTemporaryImage(TemporaryImage)
+        * @see #deleteTemporaryImage(String)
         * @param image
         *            The image to delete
         */
@@ -1682,17 +1337,6 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
        }
 
        /**
-        * Deletes the given temporary image.
-        *
-        * @param temporaryImage
-        *            The temporary image to delete
-        */
-       public void deleteTemporaryImage(TemporaryImage temporaryImage) {
-               checkNotNull(temporaryImage, "temporaryImage must not be null");
-               deleteTemporaryImage(temporaryImage.getId());
-       }
-
-       /**
         * Deletes the temporary image with the given ID.
         *
         * @param imageId
@@ -1760,10 +1404,15 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
        @Override
        public void serviceStop() {
                localElementTicker.shutdownNow();
-               synchronized (sones) {
+               synchronized (soneInserters) {
                        for (Entry<Sone, SoneInserter> soneInserter : soneInserters.entrySet()) {
                                soneInserter.getValue().stop();
-                               saveSone(getLocalSone(soneInserter.getKey().getId(), false));
+                               saveSone(soneInserter.getKey());
+                       }
+               }
+               synchronized (soneRescuers) {
+                       for (SoneRescuer soneRescuer : soneRescuers.values()) {
+                               soneRescuer.stop();
                        }
                }
                saveConfiguration();
@@ -1858,13 +1507,6 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
                        }
                        configuration.getStringValue(sonePrefix + "/Likes/Reply/" + replyLikeCounter + "/ID").setValue(null);
 
-                       /* save friends. */
-                       int friendCounter = 0;
-                       for (String friendId : sone.getFriends()) {
-                               configuration.getStringValue(sonePrefix + "/Friends/" + friendCounter++ + "/ID").setValue(friendId);
-                       }
-                       configuration.getStringValue(sonePrefix + "/Friends/" + friendCounter + "/ID").setValue(null);
-
                        /* save albums. first, collect in a flat structure, top-level first. */
                        List<Album> albums = FluentIterable.from(sone.getRootAlbum().getAlbums()).transformAndConcat(Album.FLATTENER).toList();
 
@@ -1900,12 +1542,12 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
                        configuration.getStringValue(sonePrefix + "/Images/" + imageCounter + "/ID").setValue(null);
 
                        /* save options. */
-                       configuration.getBooleanValue(sonePrefix + "/Options/AutoFollow").setValue(sone.getOptions().getBooleanOption("AutoFollow").getReal());
-                       configuration.getBooleanValue(sonePrefix + "/Options/ShowNotification/NewSones").setValue(sone.getOptions().getBooleanOption("ShowNotification/NewSones").getReal());
-                       configuration.getBooleanValue(sonePrefix + "/Options/ShowNotification/NewPosts").setValue(sone.getOptions().getBooleanOption("ShowNotification/NewPosts").getReal());
-                       configuration.getBooleanValue(sonePrefix + "/Options/ShowNotification/NewReplies").setValue(sone.getOptions().getBooleanOption("ShowNotification/NewReplies").getReal());
-                       configuration.getBooleanValue(sonePrefix + "/Options/EnableSoneInsertNotifications").setValue(sone.getOptions().getBooleanOption("EnableSoneInsertNotifications").getReal());
-                       configuration.getStringValue(sonePrefix + "/Options/ShowCustomAvatars").setValue(sone.getOptions().<ShowCustomAvatars> getEnumOption("ShowCustomAvatars").get().name());
+                       configuration.getBooleanValue(sonePrefix + "/Options/AutoFollow").setValue(sone.getOptions().isAutoFollow());
+                       configuration.getBooleanValue(sonePrefix + "/Options/EnableSoneInsertNotifications").setValue(sone.getOptions().isSoneInsertNotificationEnabled());
+                       configuration.getBooleanValue(sonePrefix + "/Options/ShowNotification/NewSones").setValue(sone.getOptions().isShowNewSoneNotifications());
+                       configuration.getBooleanValue(sonePrefix + "/Options/ShowNotification/NewPosts").setValue(sone.getOptions().isShowNewPostNotifications());
+                       configuration.getBooleanValue(sonePrefix + "/Options/ShowNotification/NewReplies").setValue(sone.getOptions().isShowNewReplyNotifications());
+                       configuration.getStringValue(sonePrefix + "/Options/ShowCustomAvatars").setValue(sone.getOptions().getShowCustomAvatars().name());
 
                        configuration.save();
 
@@ -1931,18 +1573,7 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
 
                /* store the options first. */
                try {
-                       configuration.getIntValue("Option/ConfigurationVersion").setValue(0);
-                       configuration.getIntValue("Option/InsertionDelay").setValue(options.getIntegerOption("InsertionDelay").getReal());
-                       configuration.getIntValue("Option/PostsPerPage").setValue(options.getIntegerOption("PostsPerPage").getReal());
-                       configuration.getIntValue("Option/ImagesPerPage").setValue(options.getIntegerOption("ImagesPerPage").getReal());
-                       configuration.getIntValue("Option/CharactersPerPost").setValue(options.getIntegerOption("CharactersPerPost").getReal());
-                       configuration.getIntValue("Option/PostCutOffLength").setValue(options.getIntegerOption("PostCutOffLength").getReal());
-                       configuration.getBooleanValue("Option/RequireFullAccess").setValue(options.getBooleanOption("RequireFullAccess").getReal());
-                       configuration.getIntValue("Option/PositiveTrust").setValue(options.getIntegerOption("PositiveTrust").getReal());
-                       configuration.getIntValue("Option/NegativeTrust").setValue(options.getIntegerOption("NegativeTrust").getReal());
-                       configuration.getStringValue("Option/TrustComment").setValue(options.getStringOption("TrustComment").getReal());
-                       configuration.getBooleanValue("Option/ActivateFcpInterface").setValue(options.getBooleanOption("ActivateFcpInterface").getReal());
-                       configuration.getIntValue("Option/FcpFullAccessRequired").setValue(options.getIntegerOption("FcpFullAccessRequired").getReal());
+                       preferences.saveTo(configuration);
 
                        /* save known Sones. */
                        int soneCounter = 0;
@@ -1967,15 +1598,6 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
                        /* save known posts. */
                        database.save();
 
-                       /* save bookmarked posts. */
-                       int bookmarkedPostCounter = 0;
-                       synchronized (bookmarkedPosts) {
-                               for (String bookmarkedPostId : bookmarkedPosts) {
-                                       configuration.getStringValue("Bookmarks/Post/" + bookmarkedPostCounter++ + "/ID").setValue(bookmarkedPostId);
-                               }
-                       }
-                       configuration.getStringValue("Bookmarks/Post/" + bookmarkedPostCounter++ + "/ID").setValue(null);
-
                        /* now save it. */
                        configuration.save();
 
@@ -1994,52 +1616,7 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
         * Loads the configuration.
         */
        private void loadConfiguration() {
-               /* create options. */
-               options.addIntegerOption("InsertionDelay", new DefaultOption<Integer>(60, new IntegerRangePredicate(0, Integer.MAX_VALUE), new OptionWatcher<Integer>() {
-
-                       @Override
-                       public void optionChanged(Option<Integer> option, Integer oldValue, Integer newValue) {
-                               SoneInserter.setInsertionDelay(newValue);
-                       }
-
-               }));
-               options.addIntegerOption("PostsPerPage", new DefaultOption<Integer>(10, new IntegerRangePredicate(1, Integer.MAX_VALUE)));
-               options.addIntegerOption("ImagesPerPage", new DefaultOption<Integer>(9, new IntegerRangePredicate(1, Integer.MAX_VALUE)));
-               options.addIntegerOption("CharactersPerPost", new DefaultOption<Integer>(400, Predicates.<Integer> or(new IntegerRangePredicate(50, Integer.MAX_VALUE), Predicates.equalTo(-1))));
-               options.addIntegerOption("PostCutOffLength", new DefaultOption<Integer>(200, Predicates.<Integer> or(new IntegerRangePredicate(50, Integer.MAX_VALUE), Predicates.equalTo(-1))));
-               options.addBooleanOption("RequireFullAccess", new DefaultOption<Boolean>(false));
-               options.addIntegerOption("PositiveTrust", new DefaultOption<Integer>(75, new IntegerRangePredicate(0, 100)));
-               options.addIntegerOption("NegativeTrust", new DefaultOption<Integer>(-25, new IntegerRangePredicate(-100, 100)));
-               options.addStringOption("TrustComment", new DefaultOption<String>("Set from Sone Web Interface"));
-               options.addBooleanOption("ActivateFcpInterface", new DefaultOption<Boolean>(false, new OptionWatcher<Boolean>() {
-
-                       @Override
-                       @SuppressWarnings("synthetic-access")
-                       public void optionChanged(Option<Boolean> option, Boolean oldValue, Boolean newValue) {
-                               fcpInterface.setActive(newValue);
-                       }
-               }));
-               options.addIntegerOption("FcpFullAccessRequired", new DefaultOption<Integer>(2, new OptionWatcher<Integer>() {
-
-                       @Override
-                       @SuppressWarnings("synthetic-access")
-                       public void optionChanged(Option<Integer> option, Integer oldValue, Integer newValue) {
-                               fcpInterface.setFullAccessRequired(FullAccessRequired.values()[newValue]);
-                       }
-
-               }));
-
-               loadConfigurationValue("InsertionDelay");
-               loadConfigurationValue("PostsPerPage");
-               loadConfigurationValue("ImagesPerPage");
-               loadConfigurationValue("CharactersPerPost");
-               loadConfigurationValue("PostCutOffLength");
-               options.getBooleanOption("RequireFullAccess").set(configuration.getBooleanValue("Option/RequireFullAccess").getValue(null));
-               loadConfigurationValue("PositiveTrust");
-               loadConfigurationValue("NegativeTrust");
-               options.getStringOption("TrustComment").set(configuration.getStringValue("Option/TrustComment").getValue(null));
-               options.getBooleanOption("ActivateFcpInterface").set(configuration.getBooleanValue("Option/ActivateFcpInterface").getValue(null));
-               options.getIntegerOption("FcpFullAccessRequired").set(configuration.getIntValue("Option/FcpFullAccessRequired").getValue(null));
+               new PreferencesLoader(preferences).loadFrom(configuration);
 
                /* load known Sones. */
                int soneCounter = 0;
@@ -2066,34 +1643,6 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
                        }
                        ++soneCounter;
                }
-
-               /* load bookmarked posts. */
-               int bookmarkedPostCounter = 0;
-               while (true) {
-                       String bookmarkedPostId = configuration.getStringValue("Bookmarks/Post/" + bookmarkedPostCounter++ + "/ID").getValue(null);
-                       if (bookmarkedPostId == null) {
-                               break;
-                       }
-                       synchronized (bookmarkedPosts) {
-                               bookmarkedPosts.add(bookmarkedPostId);
-                       }
-               }
-
-       }
-
-       /**
-        * Loads an {@link Integer} configuration value for the option with the
-        * given name, logging validation failures.
-        *
-        * @param optionName
-        *            The name of the option to load
-        */
-       private void loadConfigurationValue(String optionName) {
-               try {
-                       options.getIntegerOption(optionName).set(configuration.getIntValue("Option/" + optionName).getValue(null));
-               } catch (IllegalArgumentException iae1) {
-                       logger.log(Level.WARNING, String.format("Invalid value for %s in configuration, using default.", optionName));
-               }
        }
 
        /**
@@ -2146,22 +1695,14 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
         */
        @Subscribe
        public void identityUpdated(IdentityUpdatedEvent identityUpdatedEvent) {
-               final Identity identity = identityUpdatedEvent.identity();
-               soneDownloaders.execute(new Runnable() {
-
-                       @Override
-                       @SuppressWarnings("synthetic-access")
-                       public void run() {
-                               Sone sone = getRemoteSone(identity.getId(), false);
-                               if (sone.isLocal()) {
-                                       return;
-                               }
-                               sone.setIdentity(identity);
-                               sone.setLatestEdition(Numbers.safeParseLong(identity.getProperty("Sone.LatestEdition"), sone.getLatestEdition()));
-                               soneDownloader.addSone(sone);
-                               soneDownloader.fetchSone(sone);
-                       }
-               });
+               Identity identity = identityUpdatedEvent.identity();
+               final Sone sone = getRemoteSone(identity.getId());
+               if (sone.isLocal()) {
+                       return;
+               }
+               sone.setLatestEdition(fromNullable(tryParse(identity.getProperty("Sone.LatestEdition"))).or(sone.getLatestEdition()));
+               soneDownloader.addSone(sone);
+               soneDownloaders.execute(soneDownloader.fetchSoneAction(sone));
        }
 
        /**
@@ -2175,35 +1716,20 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
                OwnIdentity ownIdentity = identityRemovedEvent.ownIdentity();
                Identity identity = identityRemovedEvent.identity();
                trustedIdentities.remove(ownIdentity, identity);
-               boolean foundIdentity = false;
                for (Entry<OwnIdentity, Collection<Identity>> trustedIdentity : trustedIdentities.asMap().entrySet()) {
                        if (trustedIdentity.getKey().equals(ownIdentity)) {
                                continue;
                        }
                        if (trustedIdentity.getValue().contains(identity)) {
-                               foundIdentity = true;
+                               return;
                        }
                }
-               if (foundIdentity) {
-                       /* some local identity still trusts this identity, don’t remove. */
-                       return;
-               }
                Optional<Sone> sone = getSone(identity.getId());
                if (!sone.isPresent()) {
                        /* TODO - we don’t have the Sone anymore. should this happen? */
                        return;
                }
-               database.removePosts(sone.get());
-               for (Post post : sone.get().getPosts()) {
-                       eventBus.post(new PostRemovedEvent(post));
-               }
-               database.removePostReplies(sone.get());
-               for (PostReply reply : sone.get().getReplies()) {
-                       eventBus.post(new PostReplyRemovedEvent(reply));
-               }
-               synchronized (sones) {
-                       sones.remove(identity.getId());
-               }
+               database.removeSone(sone.get());
                eventBus.post(new SoneRemovedEvent(sone.get()));
        }
 
@@ -2221,4 +1747,36 @@ public class Core extends AbstractService implements SoneProvider, PostProvider,
                touchConfiguration();
        }
 
+       @VisibleForTesting
+       class MarkPostKnown implements Runnable {
+
+               private final Post post;
+
+               public MarkPostKnown(Post post) {
+                       this.post = post;
+               }
+
+               @Override
+               public void run() {
+                       markPostKnown(post);
+               }
+
+       }
+
+       @VisibleForTesting
+       class MarkReplyKnown implements Runnable {
+
+               private final PostReply postReply;
+
+               public MarkReplyKnown(PostReply postReply) {
+                       this.postReply = postReply;
+               }
+
+               @Override
+               public void run() {
+                       markReplyKnown(postReply);
+               }
+
+       }
+
 }
index e92cf8e..e802ba2 100644 (file)
 
 package net.pterodactylus.sone.core;
 
+import static freenet.keys.USK.create;
+import static java.lang.String.format;
+import static java.util.logging.Level.WARNING;
+import static java.util.logging.Logger.getLogger;
+import static net.pterodactylus.sone.freenet.Key.routingKey;
+
 import java.net.MalformedURLException;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.concurrent.TimeUnit;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
@@ -32,17 +37,17 @@ import net.pterodactylus.sone.core.event.ImageInsertStartedEvent;
 import net.pterodactylus.sone.data.Image;
 import net.pterodactylus.sone.data.Sone;
 import net.pterodactylus.sone.data.TemporaryImage;
-import net.pterodactylus.util.logging.Logging;
 
-import com.db4o.ObjectContainer;
+import com.google.common.base.Function;
 import com.google.common.eventbus.EventBus;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 
 import freenet.client.ClientMetadata;
 import freenet.client.FetchException;
+import freenet.client.FetchException.FetchExceptionMode;
 import freenet.client.FetchResult;
 import freenet.client.HighLevelSimpleClient;
-import freenet.client.HighLevelSimpleClientImpl;
 import freenet.client.InsertBlock;
 import freenet.client.InsertContext;
 import freenet.client.InsertException;
@@ -55,19 +60,23 @@ import freenet.keys.FreenetURI;
 import freenet.keys.InsertableClientSSK;
 import freenet.keys.USK;
 import freenet.node.Node;
+import freenet.node.RequestClient;
 import freenet.node.RequestStarter;
 import freenet.support.api.Bucket;
+import freenet.support.api.RandomAccessBucket;
 import freenet.support.io.ArrayBucket;
+import freenet.support.io.ResumeFailedException;
 
 /**
  * Contains all necessary functionality for interacting with the Freenet node.
  *
  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
  */
+@Singleton
 public class FreenetInterface {
 
        /** The logger. */
-       private static final Logger logger = Logging.getLogger(FreenetInterface.class);
+       private static final Logger logger = getLogger("Sone.FreenetInterface");
 
        /** The event bus. */
        private final EventBus eventBus;
@@ -84,6 +93,18 @@ public class FreenetInterface {
        /** The not-Sone-related USK callbacks. */
        private final Map<FreenetURI, USKCallback> uriUskCallbacks = Collections.synchronizedMap(new HashMap<FreenetURI, USKCallback>());
 
+       private final RequestClient imageInserts = new RequestClient() {
+               @Override
+               public boolean persistent() {
+                       return false;
+               }
+
+               @Override
+               public boolean realTimeFlag() {
+                       return true;
+               }
+       };
+
        /**
         * Creates a new Freenet interface.
         *
@@ -111,14 +132,13 @@ public class FreenetInterface {
         * @return The result of the fetch, or {@code null} if an error occured
         */
        public Fetched fetchUri(FreenetURI uri) {
-               FetchResult fetchResult = null;
                FreenetURI currentUri = new FreenetURI(uri);
                while (true) {
                        try {
-                               fetchResult = client.fetch(currentUri);
+                               FetchResult fetchResult = client.fetch(currentUri);
                                return new Fetched(currentUri, fetchResult);
                        } catch (FetchException fe1) {
-                               if (fe1.getMode() == FetchException.PERMANENT_REDIRECT) {
+                               if (fe1.getMode() == FetchExceptionMode.PERMANENT_REDIRECT) {
                                        currentUri = fe1.newURI;
                                        continue;
                                }
@@ -129,16 +149,6 @@ public class FreenetInterface {
        }
 
        /**
-        * Creates a key pair.
-        *
-        * @return The request key at index 0, the insert key at index 1
-        */
-       public String[] generateKeyPair() {
-               FreenetURI[] keyPair = client.generateKeyPair("");
-               return new String[] { keyPair[1].toString(), keyPair[0].toString() };
-       }
-
-       /**
         * Inserts the image data of the given {@link TemporaryImage} and returns
         * the given insert token that can be used to add listeners or cancel the
         * insert.
@@ -157,11 +167,12 @@ public class FreenetInterface {
                InsertableClientSSK key = InsertableClientSSK.createRandom(node.random, "");
                FreenetURI targetUri = key.getInsertURI().setDocName(filenameHint);
                InsertContext insertContext = client.getInsertContext(true);
-               Bucket bucket = new ArrayBucket(temporaryImage.getImageData());
+               RandomAccessBucket bucket = new ArrayBucket(temporaryImage.getImageData());
+               insertToken.setBucket(bucket);
                ClientMetadata metadata = new ClientMetadata(temporaryImage.getMimeType());
                InsertBlock insertBlock = new InsertBlock(bucket, metadata, targetUri);
                try {
-                       ClientPutter clientPutter = client.insert(insertBlock, false, null, false, insertContext, insertToken, RequestStarter.INTERACTIVE_PRIORITY_CLASS);
+                       ClientPutter clientPutter = client.insert(insertBlock, null, false, insertContext, insertToken, RequestStarter.INTERACTIVE_PRIORITY_CLASS);
                        insertToken.setClientPutter(clientPutter);
                } catch (InsertException ie1) {
                        throw new SoneInsertException("Could not start image insert.", ie1);
@@ -189,51 +200,30 @@ public class FreenetInterface {
                }
        }
 
-       /**
-        * Registers the USK for the given Sone and notifies the given
-        * {@link SoneDownloader} if an update was found.
-        *
-        * @param sone
-        *            The Sone to watch
-        * @param soneDownloader
-        *            The Sone download to notify on updates
-        */
-       public void registerUsk(final Sone sone, final SoneDownloader soneDownloader) {
+       public void registerActiveUsk(FreenetURI requestUri,
+                       USKCallback uskCallback) {
                try {
-                       logger.log(Level.FINE, String.format("Registering Sone “%s” for USK updates at %s…", sone, sone.getRequestUri().setMetaString(new String[] { "sone.xml" })));
-                       USKCallback uskCallback = new USKCallback() {
-
-                               @Override
-                               @SuppressWarnings("synthetic-access")
-                               public void onFoundEdition(long edition, USK key, ObjectContainer objectContainer, ClientContext clientContext, boolean metadata, short codec, byte[] data, boolean newKnownGood, boolean newSlotToo) {
-                                       logger.log(Level.FINE, String.format("Found USK update for Sone “%s” at %s, new known good: %s, new slot too: %s.", sone, key, newKnownGood, newSlotToo));
-                                       if (edition > sone.getLatestEdition()) {
-                                               sone.setLatestEdition(edition);
-                                               new Thread(new Runnable() {
-
-                                                       @Override
-                                                       public void run() {
-                                                               soneDownloader.fetchSone(sone);
-                                                       }
-                                               }, "Sone Downloader").start();
-                                       }
-                               }
-
-                               @Override
-                               public short getPollingPriorityProgress() {
-                                       return RequestStarter.INTERACTIVE_PRIORITY_CLASS;
-                               }
+                       soneUskCallbacks.put(routingKey(requestUri), uskCallback);
+                       node.clientCore.uskManager.subscribe(create(requestUri),
+                                       uskCallback, true, (RequestClient) client);
+               } catch (MalformedURLException mue1) {
+                       logger.log(WARNING, format("Could not subscribe USK “%s”!",
+                                       requestUri), mue1);
+               }
+       }
 
-                               @Override
-                               public short getPollingPriorityNormal() {
-                                       return RequestStarter.INTERACTIVE_PRIORITY_CLASS;
-                               }
-                       };
-                       soneUskCallbacks.put(sone.getId(), uskCallback);
-                       boolean runBackgroundFetch = (System.currentTimeMillis() - sone.getTime()) < TimeUnit.DAYS.toMillis(7);
-                       node.clientCore.uskManager.subscribe(USK.create(sone.getRequestUri()), uskCallback, runBackgroundFetch, (HighLevelSimpleClientImpl) client);
+       public void registerPassiveUsk(FreenetURI requestUri,
+                       USKCallback uskCallback) {
+               try {
+                       soneUskCallbacks.put(routingKey(requestUri), uskCallback);
+                       node.clientCore
+                                       .uskManager
+                                       .subscribe(create(requestUri), uskCallback, false,
+                                                       (RequestClient) client);
                } catch (MalformedURLException mue1) {
-                       logger.log(Level.WARNING, String.format("Could not subscribe USK “%s”!", sone.getRequestUri()), mue1);
+                       logger.log(WARNING,
+                                       format("Could not subscribe USK “%s”!", requestUri),
+                                       mue1);
                }
        }
 
@@ -269,7 +259,7 @@ public class FreenetInterface {
                USKCallback uskCallback = new USKCallback() {
 
                        @Override
-                       public void onFoundEdition(long edition, USK key, ObjectContainer objectContainer, ClientContext clientContext, boolean metadata, short codec, byte[] data, boolean newKnownGood, boolean newSlotToo) {
+                       public void onFoundEdition(long edition, USK key, ClientContext clientContext, boolean metadata, short codec, byte[] data, boolean newKnownGood, boolean newSlotToo) {
                                callback.editionFound(key.getURI(), edition, newKnownGood, newSlotToo);
                        }
 
@@ -285,7 +275,7 @@ public class FreenetInterface {
 
                };
                try {
-                       node.clientCore.uskManager.subscribe(USK.create(uri), uskCallback, true, (HighLevelSimpleClientImpl) client);
+                       node.clientCore.uskManager.subscribe(USK.create(uri), uskCallback, true, (RequestClient) client);
                        uriUskCallbacks.put(uri, uskCallback);
                } catch (MalformedURLException mue1) {
                        logger.log(Level.WARNING, String.format("Could not subscribe to USK: %s", uri), mue1);
@@ -401,6 +391,7 @@ public class FreenetInterface {
 
                /** The client putter. */
                private ClientPutter clientPutter;
+               private Bucket bucket;
 
                /** The final URI. */
                private volatile FreenetURI resultingUri;
@@ -432,6 +423,10 @@ public class FreenetInterface {
                        eventBus.post(new ImageInsertStartedEvent(image));
                }
 
+               public void setBucket(Bucket bucket) {
+                       this.bucket = bucket;
+               }
+
                //
                // ACTIONS
                //
@@ -441,20 +436,23 @@ public class FreenetInterface {
                 */
                @SuppressWarnings("synthetic-access")
                public void cancel() {
-                       clientPutter.cancel(null, node.clientCore.clientContext);
+                       clientPutter.cancel(node.clientCore.clientContext);
                        eventBus.post(new ImageInsertAbortedEvent(image));
+                       bucket.free();
                }
 
                //
                // INTERFACE ClientPutCallback
                //
 
-               /**
-                * {@inheritDoc}
-                */
                @Override
-               public void onMajorProgress(ObjectContainer objectContainer) {
-                       /* ignore, we don’t care. */
+               public RequestClient getRequestClient() {
+                       return imageInserts;
+               }
+
+               @Override
+               public void onResume(ClientContext context) throws ResumeFailedException {
+                       /* ignore. */
                }
 
                /**
@@ -462,19 +460,20 @@ public class FreenetInterface {
                 */
                @Override
                @SuppressWarnings("synthetic-access")
-               public void onFailure(InsertException insertException, BaseClientPutter clientPutter, ObjectContainer objectContainer) {
+               public void onFailure(InsertException insertException, BaseClientPutter clientPutter) {
                        if ((insertException != null) && ("Cancelled by user".equals(insertException.getMessage()))) {
                                eventBus.post(new ImageInsertAbortedEvent(image));
                        } else {
                                eventBus.post(new ImageInsertFailedEvent(image, insertException));
                        }
+                       bucket.free();
                }
 
                /**
                 * {@inheritDoc}
                 */
                @Override
-               public void onFetchable(BaseClientPutter clientPutter, ObjectContainer objectContainer) {
+               public void onFetchable(BaseClientPutter clientPutter) {
                        /* ignore, we don’t care. */
                }
 
@@ -482,7 +481,7 @@ public class FreenetInterface {
                 * {@inheritDoc}
                 */
                @Override
-               public void onGeneratedMetadata(Bucket metadata, BaseClientPutter clientPutter, ObjectContainer objectContainer) {
+               public void onGeneratedMetadata(Bucket metadata, BaseClientPutter clientPutter) {
                        /* ignore, we don’t care. */
                }
 
@@ -490,7 +489,7 @@ public class FreenetInterface {
                 * {@inheritDoc}
                 */
                @Override
-               public void onGeneratedURI(FreenetURI generatedUri, BaseClientPutter clientPutter, ObjectContainer objectContainer) {
+               public void onGeneratedURI(FreenetURI generatedUri, BaseClientPutter clientPutter) {
                        resultingUri = generatedUri;
                }
 
@@ -499,8 +498,18 @@ public class FreenetInterface {
                 */
                @Override
                @SuppressWarnings("synthetic-access")
-               public void onSuccess(BaseClientPutter clientPutter, ObjectContainer objectContainer) {
+               public void onSuccess(BaseClientPutter clientPutter) {
                        eventBus.post(new ImageInsertFinishedEvent(image, resultingUri));
+                       bucket.free();
+               }
+
+       }
+
+       public class InsertTokenSupplier implements Function<Image, InsertToken> {
+
+               @Override
+               public InsertToken apply(Image image) {
+                       return new InsertToken(image);
                }
 
        }
index 791663f..25362a6 100644 (file)
@@ -19,6 +19,7 @@ package net.pterodactylus.sone.core;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.logging.Logger.getLogger;
 
 import java.util.Collections;
 import java.util.HashMap;
@@ -29,7 +30,8 @@ import java.util.logging.Logger;
 import net.pterodactylus.sone.core.FreenetInterface.InsertToken;
 import net.pterodactylus.sone.data.Image;
 import net.pterodactylus.sone.data.TemporaryImage;
-import net.pterodactylus.util.logging.Logging;
+
+import com.google.common.base.Function;
 
 /**
  * The image inserter is responsible for inserting images using
@@ -42,10 +44,11 @@ import net.pterodactylus.util.logging.Logging;
 public class ImageInserter {
 
        /** The logger. */
-       private static final Logger logger = Logging.getLogger(ImageInserter.class);
+       private static final Logger logger = getLogger("Sone.Image.Inserter");
 
        /** The freenet interface. */
        private final FreenetInterface freenetInterface;
+       private final Function<Image, InsertToken> insertTokenSupplier;
 
        /** The tokens of running inserts. */
        private final Map<String, InsertToken> insertTokens = Collections.synchronizedMap(new HashMap<String, InsertToken>());
@@ -55,9 +58,12 @@ public class ImageInserter {
         *
         * @param freenetInterface
         *            The freenet interface
+        * @param insertTokenSupplier
+        *            The supplier for insert tokens
         */
-       public ImageInserter(FreenetInterface freenetInterface) {
+       public ImageInserter(FreenetInterface freenetInterface, Function<Image, InsertToken> insertTokenSupplier) {
                this.freenetInterface = freenetInterface;
+               this.insertTokenSupplier = insertTokenSupplier;
        }
 
        /**
@@ -73,7 +79,7 @@ public class ImageInserter {
                checkNotNull(image, "image must not be null");
                checkArgument(image.getId().equals(temporaryImage.getId()), "image IDs must match");
                try {
-                       InsertToken insertToken = freenetInterface.new InsertToken(image);
+                       InsertToken insertToken = insertTokenSupplier.apply(image);
                        insertTokens.put(image.getId(), insertToken);
                        freenetInterface.insertImage(temporaryImage, image, insertToken);
                } catch (SoneException se1) {
index c8e3589..9e79fca 100644 (file)
@@ -21,6 +21,8 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 
+import net.pterodactylus.sone.utils.Option;
+
 import com.google.common.base.Predicate;
 
 /**
@@ -30,211 +32,6 @@ import com.google.common.base.Predicate;
  */
 public class Options {
 
-       /**
-        * Contains current and default value of an option.
-        *
-        * @param <T>
-        *            The type of the option
-        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
-        */
-       public static interface Option<T> {
-
-               /**
-                * Returns the default value of the option.
-                *
-                * @return The default value of the option
-                */
-               public T getDefault();
-
-               /**
-                * Returns the current value of the option. If the current value is not
-                * set (usually {@code null}), the default value is returned.
-                *
-                * @return The current value of the option
-                */
-               public T get();
-
-               /**
-                * Returns the real value of the option. This will also return an unset
-                * value (usually {@code null})!
-                *
-                * @return The real value of the option
-                */
-               public T getReal();
-
-               /**
-                * Validates the given value. Note that {@code null} is always a valid
-                * value!
-                *
-                * @param value
-                *            The value to validate
-                * @return {@code true} if this option does not have a validator, or the
-                *         validator validates this object, {@code false} otherwise
-                */
-               public boolean validate(T value);
-
-               /**
-                * Sets the current value of the option.
-                *
-                * @param value
-                *            The new value of the option
-                * @throws IllegalArgumentException
-                *             if the value is not valid for this option
-                */
-               public void set(T value) throws IllegalArgumentException;
-
-       }
-
-       /**
-        * Interface for objects that want to be notified when an option changes its
-        * value.
-        *
-        * @param <T>
-        *            The type of the option
-        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
-        */
-       public static interface OptionWatcher<T> {
-
-               /**
-                * Notifies an object that an option has been changed.
-                *
-                * @param option
-                *            The option that has changed
-                * @param oldValue
-                *            The old value of the option
-                * @param newValue
-                *            The new value of the option
-                */
-               public void optionChanged(Option<T> option, T oldValue, T newValue);
-
-       }
-
-       /**
-        * Basic implementation of an {@link Option} that notifies an
-        * {@link OptionWatcher} if the value changes.
-        *
-        * @param <T>
-        *            The type of the option
-        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
-        */
-       public static class DefaultOption<T> implements Option<T> {
-
-               /** The default value. */
-               private final T defaultValue;
-
-               /** The current value. */
-               private volatile T value;
-
-               /** The validator. */
-               private Predicate<T> validator;
-
-               /** The option watcher. */
-               private final OptionWatcher<T> optionWatcher;
-
-               /**
-                * Creates a new default option.
-                *
-                * @param defaultValue
-                *            The default value of the option
-                */
-               public DefaultOption(T defaultValue) {
-                       this(defaultValue, (OptionWatcher<T>) null);
-               }
-
-               /**
-                * Creates a new default option.
-                *
-                * @param defaultValue
-                *            The default value of the option
-                * @param validator
-                *            The validator for value validation (may be {@code null})
-                */
-               public DefaultOption(T defaultValue, Predicate<T> validator) {
-                       this(defaultValue, validator, null);
-               }
-
-               /**
-                * Creates a new default option.
-                *
-                * @param defaultValue
-                *            The default value of the option
-                * @param optionWatchers
-                *            The option watchers (may be {@code null})
-                */
-               public DefaultOption(T defaultValue, OptionWatcher<T> optionWatchers) {
-                       this(defaultValue, null, optionWatchers);
-               }
-
-               /**
-                * Creates a new default option.
-                *
-                * @param defaultValue
-                *            The default value of the option
-                * @param validator
-                *            The validator for value validation (may be {@code null})
-                * @param optionWatcher
-                *            The option watcher (may be {@code null})
-                */
-               public DefaultOption(T defaultValue, Predicate<T> validator, OptionWatcher<T> optionWatcher) {
-                       this.defaultValue = defaultValue;
-                       this.validator = validator;
-                       this.optionWatcher = optionWatcher;
-               }
-
-               /**
-                * {@inheritDoc}
-                */
-               @Override
-               public T getDefault() {
-                       return defaultValue;
-               }
-
-               /**
-                * {@inheritDoc}
-                */
-               @Override
-               public T get() {
-                       return (value != null) ? value : defaultValue;
-               }
-
-               /**
-                * Returns the real value of the option. This will also return an unset
-                * value (usually {@code null})!
-                *
-                * @return The real value of the option
-                */
-               @Override
-               public T getReal() {
-                       return value;
-               }
-
-               /**
-                * {@inheritDoc}
-                */
-               @Override
-               public boolean validate(T value) {
-                       return (validator == null) || (value == null) || validator.apply(value);
-               }
-
-               /**
-                * {@inheritDoc}
-                */
-               @Override
-               public void set(T value) {
-                       if ((value != null) && (validator != null) && (!validator.apply(value))) {
-                               throw new IllegalArgumentException("New Value (" + value + ") could not be validated.");
-                       }
-                       T oldValue = this.value;
-                       this.value = value;
-                       if (!get().equals(oldValue)) {
-                               if (optionWatcher != null) {
-                                       optionWatcher.optionChanged(this, oldValue, get());
-                               }
-                       }
-               }
-
-       }
-
        /** Holds all {@link Boolean} {@link Option}s. */
        private final Map<String, Option<Boolean>> booleanOptions = Collections.synchronizedMap(new HashMap<String, Option<Boolean>>());
 
index 16d9453..56bfa73 100644 (file)
 
 package net.pterodactylus.sone.core;
 
+import static com.google.common.base.Predicates.equalTo;
+import static java.lang.Integer.MAX_VALUE;
+import static net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired.ALWAYS;
+import static net.pterodactylus.sone.utils.IntegerRangePredicate.range;
+
+import net.pterodactylus.sone.core.event.InsertionDelayChangedEvent;
 import net.pterodactylus.sone.fcp.FcpInterface;
 import net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired;
+import net.pterodactylus.sone.fcp.event.FcpInterfaceActivatedEvent;
+import net.pterodactylus.sone.fcp.event.FcpInterfaceDeactivatedEvent;
+import net.pterodactylus.sone.fcp.event.FullAccessRequiredChanged;
+import net.pterodactylus.sone.utils.DefaultOption;
+import net.pterodactylus.sone.utils.Option;
+import net.pterodactylus.util.config.Configuration;
+import net.pterodactylus.util.config.ConfigurationException;
+
+import com.google.common.base.Predicates;
+import com.google.common.eventbus.EventBus;
 
 /**
  * Convenience interface for external classes that want to access the core’s
@@ -28,17 +44,33 @@ import net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired;
  */
 public class Preferences {
 
-       /** The wrapped options. */
-       private final Options options;
-
-       /**
-        * Creates a new preferences object wrapped around the given options.
-        *
-        * @param options
-        *            The options to wrap
-        */
-       public Preferences(Options options) {
-               this.options = options;
+       private final EventBus eventBus;
+       private final Option<Integer> insertionDelay =
+                       new DefaultOption<Integer>(60, range(0, MAX_VALUE));
+       private final Option<Integer> postsPerPage =
+                       new DefaultOption<Integer>(10, range(1, MAX_VALUE));
+       private final Option<Integer> imagesPerPage =
+                       new DefaultOption<Integer>(9, range(1, MAX_VALUE));
+       private final Option<Integer> charactersPerPost =
+                       new DefaultOption<Integer>(400, Predicates.<Integer>or(
+                                       range(50, MAX_VALUE), equalTo(-1)));
+       private final Option<Integer> postCutOffLength =
+                       new DefaultOption<Integer>(200, range(50, MAX_VALUE));
+       private final Option<Boolean> requireFullAccess =
+                       new DefaultOption<Boolean>(false);
+       private final Option<Integer> positiveTrust =
+                       new DefaultOption<Integer>(75, range(0, 100));
+       private final Option<Integer> negativeTrust =
+                       new DefaultOption<Integer>(-25, range(-100, 100));
+       private final Option<String> trustComment =
+                       new DefaultOption<String>("Set from Sone Web Interface");
+       private final Option<Boolean> activateFcpInterface =
+                       new DefaultOption<Boolean>(false);
+       private final Option<FullAccessRequired> fcpFullAccessRequired =
+                       new DefaultOption<FullAccessRequired>(ALWAYS);
+
+       public Preferences(EventBus eventBus) {
+               this.eventBus = eventBus;
        }
 
        /**
@@ -47,7 +79,7 @@ public class Preferences {
         * @return The insertion delay
         */
        public int getInsertionDelay() {
-               return options.getIntegerOption("InsertionDelay").get();
+               return insertionDelay.get();
        }
 
        /**
@@ -59,7 +91,7 @@ public class Preferences {
         *         {@code false} otherwise
         */
        public boolean validateInsertionDelay(Integer insertionDelay) {
-               return options.getIntegerOption("InsertionDelay").validate(insertionDelay);
+               return this.insertionDelay.validate(insertionDelay);
        }
 
        /**
@@ -71,7 +103,8 @@ public class Preferences {
         * @return This preferences
         */
        public Preferences setInsertionDelay(Integer insertionDelay) {
-               options.getIntegerOption("InsertionDelay").set(insertionDelay);
+               this.insertionDelay.set(insertionDelay);
+               eventBus.post(new InsertionDelayChangedEvent(getInsertionDelay()));
                return this;
        }
 
@@ -81,7 +114,7 @@ public class Preferences {
         * @return The number of posts to show per page
         */
        public int getPostsPerPage() {
-               return options.getIntegerOption("PostsPerPage").get();
+               return postsPerPage.get();
        }
 
        /**
@@ -93,7 +126,7 @@ public class Preferences {
         *         {@code false} otherwise
         */
        public boolean validatePostsPerPage(Integer postsPerPage) {
-               return options.getIntegerOption("PostsPerPage").validate(postsPerPage);
+               return this.postsPerPage.validate(postsPerPage);
        }
 
        /**
@@ -104,7 +137,7 @@ public class Preferences {
         * @return This preferences object
         */
        public Preferences setPostsPerPage(Integer postsPerPage) {
-               options.getIntegerOption("PostsPerPage").set(postsPerPage);
+               this.postsPerPage.set(postsPerPage);
                return this;
        }
 
@@ -114,7 +147,7 @@ public class Preferences {
         * @return The number of images to show per page
         */
        public int getImagesPerPage() {
-               return options.getIntegerOption("ImagesPerPage").get();
+               return imagesPerPage.get();
        }
 
        /**
@@ -126,7 +159,7 @@ public class Preferences {
         *         {@code false} otherwise
         */
        public boolean validateImagesPerPage(Integer imagesPerPage) {
-               return options.getIntegerOption("ImagesPerPage").validate(imagesPerPage);
+               return this.imagesPerPage.validate(imagesPerPage);
        }
 
        /**
@@ -137,7 +170,7 @@ public class Preferences {
         * @return This preferences object
         */
        public Preferences setImagesPerPage(Integer imagesPerPage) {
-               options.getIntegerOption("ImagesPerPage").set(imagesPerPage);
+               this.imagesPerPage.set(imagesPerPage);
                return this;
        }
 
@@ -148,7 +181,7 @@ public class Preferences {
         * @return The numbers of characters per post
         */
        public int getCharactersPerPost() {
-               return options.getIntegerOption("CharactersPerPost").get();
+               return charactersPerPost.get();
        }
 
        /**
@@ -160,7 +193,7 @@ public class Preferences {
         *         {@code false} otherwise
         */
        public boolean validateCharactersPerPost(Integer charactersPerPost) {
-               return options.getIntegerOption("CharactersPerPost").validate(charactersPerPost);
+               return this.charactersPerPost.validate(charactersPerPost);
        }
 
        /**
@@ -172,7 +205,7 @@ public class Preferences {
         * @return This preferences objects
         */
        public Preferences setCharactersPerPost(Integer charactersPerPost) {
-               options.getIntegerOption("CharactersPerPost").set(charactersPerPost);
+               this.charactersPerPost.set(charactersPerPost);
                return this;
        }
 
@@ -182,7 +215,7 @@ public class Preferences {
         * @return The number of characters of the snippet
         */
        public int getPostCutOffLength() {
-               return options.getIntegerOption("PostCutOffLength").get();
+               return postCutOffLength.get();
        }
 
        /**
@@ -194,7 +227,7 @@ public class Preferences {
         *         valid, {@code false} otherwise
         */
        public boolean validatePostCutOffLength(Integer postCutOffLength) {
-               return options.getIntegerOption("PostCutOffLength").validate(postCutOffLength);
+               return this.postCutOffLength.validate(postCutOffLength);
        }
 
        /**
@@ -205,7 +238,7 @@ public class Preferences {
         * @return This preferences
         */
        public Preferences setPostCutOffLength(Integer postCutOffLength) {
-               options.getIntegerOption("PostCutOffLength").set(postCutOffLength);
+               this.postCutOffLength.set(postCutOffLength);
                return this;
        }
 
@@ -216,7 +249,7 @@ public class Preferences {
         *         otherwise
         */
        public boolean isRequireFullAccess() {
-               return options.getBooleanOption("RequireFullAccess").get();
+               return requireFullAccess.get();
        }
 
        /**
@@ -227,7 +260,7 @@ public class Preferences {
         *            otherwise
         */
        public void setRequireFullAccess(Boolean requireFullAccess) {
-               options.getBooleanOption("RequireFullAccess").set(requireFullAccess);
+               this.requireFullAccess.set(requireFullAccess);
        }
 
        /**
@@ -236,7 +269,7 @@ public class Preferences {
         * @return The positive trust
         */
        public int getPositiveTrust() {
-               return options.getIntegerOption("PositiveTrust").get();
+               return positiveTrust.get();
        }
 
        /**
@@ -248,7 +281,7 @@ public class Preferences {
         *         otherwise
         */
        public boolean validatePositiveTrust(Integer positiveTrust) {
-               return options.getIntegerOption("PositiveTrust").validate(positiveTrust);
+               return this.positiveTrust.validate(positiveTrust);
        }
 
        /**
@@ -260,7 +293,7 @@ public class Preferences {
         * @return This preferences
         */
        public Preferences setPositiveTrust(Integer positiveTrust) {
-               options.getIntegerOption("PositiveTrust").set(positiveTrust);
+               this.positiveTrust.set(positiveTrust);
                return this;
        }
 
@@ -270,7 +303,7 @@ public class Preferences {
         * @return The negative trust
         */
        public int getNegativeTrust() {
-               return options.getIntegerOption("NegativeTrust").get();
+               return negativeTrust.get();
        }
 
        /**
@@ -282,7 +315,7 @@ public class Preferences {
         *         otherwise
         */
        public boolean validateNegativeTrust(Integer negativeTrust) {
-               return options.getIntegerOption("NegativeTrust").validate(negativeTrust);
+               return this.negativeTrust.validate(negativeTrust);
        }
 
        /**
@@ -294,7 +327,7 @@ public class Preferences {
         * @return The preferences
         */
        public Preferences setNegativeTrust(Integer negativeTrust) {
-               options.getIntegerOption("NegativeTrust").set(negativeTrust);
+               this.negativeTrust.set(negativeTrust);
                return this;
        }
 
@@ -305,7 +338,7 @@ public class Preferences {
         * @return The trust comment
         */
        public String getTrustComment() {
-               return options.getStringOption("TrustComment").get();
+               return trustComment.get();
        }
 
        /**
@@ -317,7 +350,7 @@ public class Preferences {
         * @return This preferences
         */
        public Preferences setTrustComment(String trustComment) {
-               options.getStringOption("TrustComment").set(trustComment);
+               this.trustComment.set(trustComment);
                return this;
        }
 
@@ -330,7 +363,7 @@ public class Preferences {
         *         {@code false} otherwise
         */
        public boolean isFcpInterfaceActive() {
-               return options.getBooleanOption("ActivateFcpInterface").get();
+               return activateFcpInterface.get();
        }
 
        /**
@@ -343,8 +376,13 @@ public class Preferences {
         *            to deactivate the FCP interface
         * @return This preferences object
         */
-       public Preferences setFcpInterfaceActive(boolean fcpInterfaceActive) {
-               options.getBooleanOption("ActivateFcpInterface").set(fcpInterfaceActive);
+       public Preferences setFcpInterfaceActive(Boolean fcpInterfaceActive) {
+               this.activateFcpInterface.set(fcpInterfaceActive);
+               if (isFcpInterfaceActive()) {
+                       eventBus.post(new FcpInterfaceActivatedEvent());
+               } else {
+                       eventBus.post(new FcpInterfaceDeactivatedEvent());
+               }
                return this;
        }
 
@@ -356,7 +394,7 @@ public class Preferences {
         *         is required
         */
        public FullAccessRequired getFcpFullAccessRequired() {
-               return FullAccessRequired.values()[options.getIntegerOption("FcpFullAccessRequired").get()];
+               return fcpFullAccessRequired.get();
        }
 
        /**
@@ -367,9 +405,30 @@ public class Preferences {
         *            The action level
         * @return This preferences
         */
-       public Preferences setFcpFullAccessRequired(FullAccessRequired fcpFullAccessRequired) {
-               options.getIntegerOption("FcpFullAccessRequired").set((fcpFullAccessRequired != null) ? fcpFullAccessRequired.ordinal() : null);
+       public Preferences setFcpFullAccessRequired(
+                       FullAccessRequired fcpFullAccessRequired) {
+               this.fcpFullAccessRequired.set(fcpFullAccessRequired);
+               eventBus.post(new FullAccessRequiredChanged(getFcpFullAccessRequired()));
                return this;
        }
 
+       public void saveTo(Configuration configuration) throws ConfigurationException {
+               configuration.getIntValue("Option/ConfigurationVersion").setValue(0);
+               configuration.getIntValue("Option/InsertionDelay").setValue(insertionDelay.getReal());
+               configuration.getIntValue("Option/PostsPerPage").setValue(postsPerPage.getReal());
+               configuration.getIntValue("Option/ImagesPerPage").setValue(imagesPerPage.getReal());
+               configuration.getIntValue("Option/CharactersPerPost").setValue(charactersPerPost.getReal());
+               configuration.getIntValue("Option/PostCutOffLength").setValue(postCutOffLength.getReal());
+               configuration.getBooleanValue("Option/RequireFullAccess").setValue(requireFullAccess.getReal());
+               configuration.getIntValue("Option/PositiveTrust").setValue(positiveTrust.getReal());
+               configuration.getIntValue("Option/NegativeTrust").setValue(negativeTrust.getReal());
+               configuration.getStringValue("Option/TrustComment").setValue(trustComment.getReal());
+               configuration.getBooleanValue("Option/ActivateFcpInterface").setValue(activateFcpInterface.getReal());
+               configuration.getIntValue("Option/FcpFullAccessRequired").setValue(toInt(fcpFullAccessRequired.getReal()));
+       }
+
+       private Integer toInt(FullAccessRequired fullAccessRequired) {
+               return (fullAccessRequired == null) ? null : fullAccessRequired.ordinal();
+       }
+
 }
diff --git a/src/main/java/net/pterodactylus/sone/core/PreferencesLoader.java b/src/main/java/net/pterodactylus/sone/core/PreferencesLoader.java
new file mode 100644 (file)
index 0000000..0ae13ba
--- /dev/null
@@ -0,0 +1,105 @@
+package net.pterodactylus.sone.core;
+
+import net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired;
+import net.pterodactylus.util.config.Configuration;
+import net.pterodactylus.util.config.ConfigurationException;
+
+/**
+ * Loads preferences stored in a {@link Configuration} into a {@link
+ * Preferences} object.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class PreferencesLoader {
+
+       private final Preferences preferences;
+
+       public PreferencesLoader(Preferences preferences) {
+               this.preferences = preferences;
+       }
+
+       public void loadFrom(Configuration configuration) {
+               loadInsertionDelay(configuration);
+               loadPostsPerPage(configuration);
+               loadImagesPerPage(configuration);
+               loadCharactersPerPost(configuration);
+               loadPostCutOffLength(configuration);
+               loadRequireFullAccess(configuration);
+               loadPositiveTrust(configuration);
+               loadNegativeTrust(configuration);
+               loadTrustComment(configuration);
+               loadFcpInterfaceActive(configuration);
+               loadFcpFullAccessRequired(configuration);
+       }
+
+       private void loadInsertionDelay(Configuration configuration) {
+               preferences.setInsertionDelay(configuration.getIntValue(
+                               "Option/InsertionDelay").getValue(null));
+       }
+
+       private void loadPostsPerPage(Configuration configuration) {
+               preferences.setPostsPerPage(
+                               configuration.getIntValue("Option/PostsPerPage")
+                                               .getValue(null));
+       }
+
+       private void loadImagesPerPage(Configuration configuration) {
+               preferences.setImagesPerPage(
+                               configuration.getIntValue("Option/ImagesPerPage")
+                                               .getValue(null));
+       }
+
+       private void loadCharactersPerPost(Configuration configuration) {
+               preferences.setCharactersPerPost(
+                               configuration.getIntValue("Option/CharactersPerPost")
+                                               .getValue(null));
+       }
+
+       private void loadPostCutOffLength(Configuration configuration) {
+               try {
+                       preferences.setPostCutOffLength(
+                                       configuration.getIntValue("Option/PostCutOffLength")
+                                                       .getValue(null));
+               } catch (IllegalArgumentException iae1) {
+                       /* previous versions allowed -1, ignore and use default. */
+               }
+       }
+
+       private void loadRequireFullAccess(Configuration configuration) {
+               preferences.setRequireFullAccess(
+                               configuration.getBooleanValue("Option/RequireFullAccess")
+                                               .getValue(null));
+       }
+
+       private void loadPositiveTrust(Configuration configuration) {
+               preferences.setPositiveTrust(
+                               configuration.getIntValue("Option/PositiveTrust")
+                                               .getValue(null));
+       }
+
+       private void loadNegativeTrust(Configuration configuration) {
+               preferences.setNegativeTrust(
+                               configuration.getIntValue("Option/NegativeTrust")
+                                               .getValue(null));
+       }
+
+       private void loadTrustComment(Configuration configuration) {
+               preferences.setTrustComment(
+                               configuration.getStringValue("Option/TrustComment")
+                                               .getValue(null));
+       }
+
+       private void loadFcpInterfaceActive(Configuration configuration) {
+               preferences.setFcpInterfaceActive(configuration.getBooleanValue(
+                               "Option/ActivateFcpInterface").getValue(null));
+       }
+
+       private void loadFcpFullAccessRequired(Configuration configuration) {
+               Integer fullAccessRequiredInteger = configuration
+                               .getIntValue("Option/FcpFullAccessRequired").getValue(null);
+               preferences.setFcpFullAccessRequired(
+                               (fullAccessRequiredInteger == null) ? null :
+                                               FullAccessRequired.values()[fullAccessRequiredInteger]);
+       }
+
+}
diff --git a/src/main/java/net/pterodactylus/sone/core/SoneChangeDetector.java b/src/main/java/net/pterodactylus/sone/core/SoneChangeDetector.java
new file mode 100644 (file)
index 0000000..efcdc6e
--- /dev/null
@@ -0,0 +1,114 @@
+package net.pterodactylus.sone.core;
+
+import static com.google.common.base.Optional.absent;
+import static com.google.common.base.Optional.fromNullable;
+import static com.google.common.collect.FluentIterable.from;
+
+import java.util.Collection;
+
+import net.pterodactylus.sone.data.Post;
+import net.pterodactylus.sone.data.PostReply;
+import net.pterodactylus.sone.data.Sone;
+
+import com.google.common.base.Optional;
+import com.google.common.base.Predicate;
+import com.google.common.collect.FluentIterable;
+
+/**
+ * Compares the contents of two {@link Sone}s and fires events for new and
+ * removed elements.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class SoneChangeDetector {
+
+       private final Sone oldSone;
+       private Optional<PostProcessor> newPostProcessor = absent();
+       private Optional<PostProcessor> removedPostProcessor = absent();
+       private Optional<PostReplyProcessor> newPostReplyProcessor = absent();
+       private Optional<PostReplyProcessor> removedPostReplyProcessor = absent();
+
+       public SoneChangeDetector(Sone oldSone) {
+               this.oldSone = oldSone;
+       }
+
+       public void onNewPosts(PostProcessor newPostProcessor) {
+               this.newPostProcessor = fromNullable(newPostProcessor);
+       }
+
+       public void onRemovedPosts(PostProcessor removedPostProcessor) {
+               this.removedPostProcessor = fromNullable(removedPostProcessor);
+       }
+
+       public void onNewPostReplies(PostReplyProcessor newPostReplyProcessor) {
+               this.newPostReplyProcessor = fromNullable(newPostReplyProcessor);
+       }
+
+       public void onRemovedPostReplies(
+                       PostReplyProcessor removedPostReplyProcessor) {
+               this.removedPostReplyProcessor = fromNullable(removedPostReplyProcessor);
+       }
+
+       public void detectChanges(Sone newSone) {
+               processPosts(from(newSone.getPosts()).filter(
+                               notContainedIn(oldSone.getPosts())), newPostProcessor);
+               processPosts(from(oldSone.getPosts()).filter(
+                               notContainedIn(newSone.getPosts())), removedPostProcessor);
+               processPostReplies(from(newSone.getReplies()).filter(
+                               notContainedIn(oldSone.getReplies())), newPostReplyProcessor);
+               processPostReplies(from(oldSone.getReplies()).filter(
+                               notContainedIn(newSone.getReplies())), removedPostReplyProcessor);
+       }
+
+       private void processPostReplies(FluentIterable<PostReply> postReplies,
+                       Optional<PostReplyProcessor> postReplyProcessor) {
+               for (PostReply postReply : postReplies) {
+                       notifyPostReplyProcessor(postReplyProcessor, postReply);
+               }
+       }
+
+       private void notifyPostReplyProcessor(
+                       Optional<PostReplyProcessor> postReplyProcessor,
+                       PostReply postReply) {
+               if (postReplyProcessor.isPresent()) {
+                       postReplyProcessor.get()
+                                       .processPostReply(postReply);
+               }
+       }
+
+       private void processPosts(FluentIterable<Post> posts,
+                       Optional<PostProcessor> newPostProcessor) {
+               for (Post post : posts) {
+                       notifyPostProcessor(newPostProcessor, post);
+               }
+       }
+
+       private void notifyPostProcessor(Optional<PostProcessor> postProcessor,
+                       Post newPost) {
+               if (postProcessor.isPresent()) {
+                       postProcessor.get().processPost(newPost);
+               }
+       }
+
+       private <T> Predicate<T> notContainedIn(final Collection<T> posts) {
+               return new Predicate<T>() {
+                       @Override
+                       public boolean apply(T element) {
+                               return !posts.contains(element);
+                       }
+               };
+       }
+
+       public interface PostProcessor {
+
+               void processPost(Post post);
+
+       }
+
+       public interface PostReplyProcessor {
+
+               void processPostReply(PostReply postReply);
+
+       }
+
+}
index 53eef16..be0be02 100644 (file)
-/*
- * Sone - SoneDownloader.java - Copyright © 2010–2013 David Roden
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
 package net.pterodactylus.sone.core;
 
-import java.io.InputStream;
-import java.net.MalformedURLException;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-
-import net.pterodactylus.sone.core.FreenetInterface.Fetched;
-import net.pterodactylus.sone.data.Album;
-import net.pterodactylus.sone.data.Client;
-import net.pterodactylus.sone.data.Image;
-import net.pterodactylus.sone.data.Post;
-import net.pterodactylus.sone.data.PostReply;
-import net.pterodactylus.sone.data.Profile;
 import net.pterodactylus.sone.data.Sone;
-import net.pterodactylus.sone.data.Sone.SoneStatus;
-import net.pterodactylus.sone.data.SoneImpl;
-import net.pterodactylus.sone.database.PostBuilder;
-import net.pterodactylus.sone.database.PostReplyBuilder;
-import net.pterodactylus.util.io.Closer;
-import net.pterodactylus.util.logging.Logging;
-import net.pterodactylus.util.number.Numbers;
-import net.pterodactylus.util.service.AbstractService;
-import net.pterodactylus.util.xml.SimpleXML;
-import net.pterodactylus.util.xml.XML;
+import net.pterodactylus.util.service.Service;
 
-import org.w3c.dom.Document;
-
-import freenet.client.FetchResult;
 import freenet.keys.FreenetURI;
-import freenet.support.api.Bucket;
 
 /**
- * The Sone downloader is responsible for download Sones as they are updated.
+ * Downloads and parses Sone and {@link Core#updateSone(Sone) updates the
+ * core}.
  *
  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
  */
-public class SoneDownloader extends AbstractService {
-
-       /** The logger. */
-       private static final Logger logger = Logging.getLogger(SoneDownloader.class);
-
-       /** The maximum protocol version. */
-       private static final int MAX_PROTOCOL_VERSION = 0;
-
-       /** The core. */
-       private final Core core;
-
-       /** The Freenet interface. */
-       private final FreenetInterface freenetInterface;
-
-       /** The sones to update. */
-       private final Set<Sone> sones = new HashSet<Sone>();
-
-       /**
-        * Creates a new Sone downloader.
-        *
-        * @param core
-        *            The core
-        * @param freenetInterface
-        *            The Freenet interface
-        */
-       public SoneDownloader(Core core, FreenetInterface freenetInterface) {
-               super("Sone Downloader", false);
-               this.core = core;
-               this.freenetInterface = freenetInterface;
-       }
-
-       //
-       // ACTIONS
-       //
-
-       /**
-        * Adds the given Sone to the set of Sones that will be watched for updates.
-        *
-        * @param sone
-        *            The Sone to add
-        */
-       public void addSone(Sone sone) {
-               if (!sones.add(sone)) {
-                       freenetInterface.unregisterUsk(sone);
-               }
-               freenetInterface.registerUsk(sone, this);
-       }
-
-       /**
-        * Removes the given Sone from the downloader.
-        *
-        * @param sone
-        *            The Sone to stop watching
-        */
-       public void removeSone(Sone sone) {
-               if (sones.remove(sone)) {
-                       freenetInterface.unregisterUsk(sone);
-               }
-       }
-
-       /**
-        * Fetches the updated Sone. This method is a callback method for
-        * {@link FreenetInterface#registerUsk(Sone, SoneDownloader)}.
-        *
-        * @param sone
-        *            The Sone to fetch
-        */
-       public void fetchSone(Sone sone) {
-               fetchSone(sone, sone.getRequestUri().sskForUSK());
-       }
-
-       /**
-        * Fetches the updated Sone. This method can be used to fetch a Sone from a
-        * specific URI.
-        *
-        * @param sone
-        *            The Sone to fetch
-        * @param soneUri
-        *            The URI to fetch the Sone from
-        */
-       public void fetchSone(Sone sone, FreenetURI soneUri) {
-               fetchSone(sone, soneUri, false);
-       }
-
-       /**
-        * Fetches the Sone from the given URI.
-        *
-        * @param sone
-        *            The Sone to fetch
-        * @param soneUri
-        *            The URI of the Sone to fetch
-        * @param fetchOnly
-        *            {@code true} to only fetch and parse the Sone, {@code false}
-        *            to {@link Core#updateSone(Sone) update} it in the core
-        * @return The downloaded Sone, or {@code null} if the Sone could not be
-        *         downloaded
-        */
-       public Sone fetchSone(Sone sone, FreenetURI soneUri, boolean fetchOnly) {
-               logger.log(Level.FINE, String.format("Starting fetch for Sone “%s” from %s…", sone, soneUri));
-               FreenetURI requestUri = soneUri.setMetaString(new String[] { "sone.xml" });
-               sone.setStatus(SoneStatus.downloading);
-               try {
-                       Fetched fetchResults = freenetInterface.fetchUri(requestUri);
-                       if (fetchResults == null) {
-                               /* TODO - mark Sone as bad. */
-                               return null;
-                       }
-                       logger.log(Level.FINEST, String.format("Got %d bytes back.", fetchResults.getFetchResult().size()));
-                       Sone parsedSone = parseSone(sone, fetchResults.getFetchResult(), fetchResults.getFreenetUri());
-                       if (parsedSone != null) {
-                               if (!fetchOnly) {
-                                       parsedSone.setStatus((parsedSone.getTime() == 0) ? SoneStatus.unknown : SoneStatus.idle);
-                                       core.updateSone(parsedSone);
-                                       addSone(parsedSone);
-                               }
-                       }
-                       return parsedSone;
-               } finally {
-                       sone.setStatus((sone.getTime() == 0) ? SoneStatus.unknown : SoneStatus.idle);
-               }
-       }
-
-       /**
-        * Parses a Sone from a fetch result.
-        *
-        * @param originalSone
-        *            The sone to parse, or {@code null} if the Sone is yet unknown
-        * @param fetchResult
-        *            The fetch result
-        * @param requestUri
-        *            The requested URI
-        * @return The parsed Sone, or {@code null} if the Sone could not be parsed
-        */
-       public Sone parseSone(Sone originalSone, FetchResult fetchResult, FreenetURI requestUri) {
-               logger.log(Level.FINEST, String.format("Parsing FetchResult (%d bytes, %s) for %s…", fetchResult.size(), fetchResult.getMimeType(), originalSone));
-               Bucket soneBucket = fetchResult.asBucket();
-               InputStream soneInputStream = null;
-               try {
-                       soneInputStream = soneBucket.getInputStream();
-                       Sone parsedSone = parseSone(originalSone, soneInputStream);
-                       if (parsedSone != null) {
-                               parsedSone.setLatestEdition(requestUri.getEdition());
-                               if (requestUri.getKeyType().equals("USK")) {
-                                       parsedSone.setRequestUri(requestUri.setMetaString(new String[0]));
-                               } else {
-                                       parsedSone.setRequestUri(requestUri.setKeyType("USK").setDocName("Sone").setMetaString(new String[0]));
-                               }
-                       }
-                       return parsedSone;
-               } catch (Exception e1) {
-                       logger.log(Level.WARNING, String.format("Could not parse Sone from %s!", requestUri), e1);
-               } finally {
-                       Closer.close(soneInputStream);
-                       soneBucket.free();
-               }
-               return null;
-       }
-
-       /**
-        * Parses a Sone from the given input stream and creates a new Sone from the
-        * parsed data.
-        *
-        * @param originalSone
-        *            The Sone to update
-        * @param soneInputStream
-        *            The input stream to parse the Sone from
-        * @return The parsed Sone
-        * @throws SoneException
-        *             if a parse error occurs, or the protocol is invalid
-        */
-       public Sone parseSone(Sone originalSone, InputStream soneInputStream) throws SoneException {
-               /* TODO - impose a size limit? */
-
-               Document document;
-               /* XML parsing is not thread-safe. */
-               synchronized (this) {
-                       document = XML.transformToDocument(soneInputStream);
-               }
-               if (document == null) {
-                       /* TODO - mark Sone as bad. */
-                       logger.log(Level.WARNING, String.format("Could not parse XML for Sone %s!", originalSone));
-                       return null;
-               }
-
-               Sone sone = new SoneImpl(originalSone.getId(), originalSone.isLocal()).setIdentity(originalSone.getIdentity());
-
-               SimpleXML soneXml;
-               try {
-                       soneXml = SimpleXML.fromDocument(document);
-               } catch (NullPointerException npe1) {
-                       /* for some reason, invalid XML can cause NPEs. */
-                       logger.log(Level.WARNING, String.format("XML for Sone %s can not be parsed!", sone), npe1);
-                       return null;
-               }
-
-               Integer protocolVersion = null;
-               String soneProtocolVersion = soneXml.getValue("protocol-version", null);
-               if (soneProtocolVersion != null) {
-                       protocolVersion = Numbers.safeParseInteger(soneProtocolVersion);
-               }
-               if (protocolVersion == null) {
-                       logger.log(Level.INFO, "No protocol version found, assuming 0.");
-                       protocolVersion = 0;
-               }
-
-               if (protocolVersion < 0) {
-                       logger.log(Level.WARNING, String.format("Invalid protocol version: %d! Not parsing Sone.", protocolVersion));
-                       return null;
-               }
-
-               /* check for valid versions. */
-               if (protocolVersion > MAX_PROTOCOL_VERSION) {
-                       logger.log(Level.WARNING, String.format("Unknown protocol version: %d! Not parsing Sone.", protocolVersion));
-                       return null;
-               }
-
-               String soneTime = soneXml.getValue("time", null);
-               if (soneTime == null) {
-                       /* TODO - mark Sone as bad. */
-                       logger.log(Level.WARNING, String.format("Downloaded time for Sone %s was null!", sone));
-                       return null;
-               }
-               try {
-                       sone.setTime(Long.parseLong(soneTime));
-               } catch (NumberFormatException nfe1) {
-                       /* TODO - mark Sone as bad. */
-                       logger.log(Level.WARNING, String.format("Downloaded Sone %s with invalid time: %s", sone, soneTime));
-                       return null;
-               }
-
-               SimpleXML clientXml = soneXml.getNode("client");
-               if (clientXml != null) {
-                       String clientName = clientXml.getValue("name", null);
-                       String clientVersion = clientXml.getValue("version", null);
-                       if ((clientName == null) || (clientVersion == null)) {
-                               logger.log(Level.WARNING, String.format("Download Sone %s with client XML but missing name or version!", sone));
-                               return null;
-                       }
-                       sone.setClient(new Client(clientName, clientVersion));
-               }
-
-               String soneRequestUri = soneXml.getValue("request-uri", null);
-               if (soneRequestUri != null) {
-                       try {
-                               sone.setRequestUri(new FreenetURI(soneRequestUri));
-                       } catch (MalformedURLException mue1) {
-                               /* TODO - mark Sone as bad. */
-                               logger.log(Level.WARNING, String.format("Downloaded Sone %s has invalid request URI: %s", sone, soneRequestUri), mue1);
-                               return null;
-                       }
-               }
-
-               if (originalSone.getInsertUri() != null) {
-                       sone.setInsertUri(originalSone.getInsertUri());
-               }
-
-               SimpleXML profileXml = soneXml.getNode("profile");
-               if (profileXml == null) {
-                       /* TODO - mark Sone as bad. */
-                       logger.log(Level.WARNING, String.format("Downloaded Sone %s has no profile!", sone));
-                       return null;
-               }
-
-               /* parse profile. */
-               String profileFirstName = profileXml.getValue("first-name", null);
-               String profileMiddleName = profileXml.getValue("middle-name", null);
-               String profileLastName = profileXml.getValue("last-name", null);
-               Integer profileBirthDay = Numbers.safeParseInteger(profileXml.getValue("birth-day", null));
-               Integer profileBirthMonth = Numbers.safeParseInteger(profileXml.getValue("birth-month", null));
-               Integer profileBirthYear = Numbers.safeParseInteger(profileXml.getValue("birth-year", null));
-               Profile profile = new Profile(sone).setFirstName(profileFirstName).setMiddleName(profileMiddleName).setLastName(profileLastName);
-               profile.setBirthDay(profileBirthDay).setBirthMonth(profileBirthMonth).setBirthYear(profileBirthYear);
-               /* avatar is processed after images are loaded. */
-               String avatarId = profileXml.getValue("avatar", null);
-
-               /* parse profile fields. */
-               SimpleXML profileFieldsXml = profileXml.getNode("fields");
-               if (profileFieldsXml != null) {
-                       for (SimpleXML fieldXml : profileFieldsXml.getNodes("field")) {
-                               String fieldName = fieldXml.getValue("field-name", null);
-                               String fieldValue = fieldXml.getValue("field-value", "");
-                               if (fieldName == null) {
-                                       logger.log(Level.WARNING, String.format("Downloaded profile field for Sone %s with missing data! Name: %s, Value: %s", sone, fieldName, fieldValue));
-                                       return null;
-                               }
-                               try {
-                                       profile.addField(fieldName).setValue(fieldValue);
-                               } catch (IllegalArgumentException iae1) {
-                                       logger.log(Level.WARNING, String.format("Duplicate field: %s", fieldName), iae1);
-                                       return null;
-                               }
-                       }
-               }
-
-               /* parse posts. */
-               SimpleXML postsXml = soneXml.getNode("posts");
-               Set<Post> posts = new HashSet<Post>();
-               if (postsXml == null) {
-                       /* TODO - mark Sone as bad. */
-                       logger.log(Level.WARNING, String.format("Downloaded Sone %s has no posts!", sone));
-               } else {
-                       for (SimpleXML postXml : postsXml.getNodes("post")) {
-                               String postId = postXml.getValue("id", null);
-                               String postRecipientId = postXml.getValue("recipient", null);
-                               String postTime = postXml.getValue("time", null);
-                               String postText = postXml.getValue("text", null);
-                               if ((postId == null) || (postTime == null) || (postText == null)) {
-                                       /* TODO - mark Sone as bad. */
-                                       logger.log(Level.WARNING, String.format("Downloaded post for Sone %s with missing data! ID: %s, Time: %s, Text: %s", sone, postId, postTime, postText));
-                                       return null;
-                               }
-                               try {
-                                       PostBuilder postBuilder = core.postBuilder();
-                                       /* TODO - parse time correctly. */
-                                       postBuilder.withId(postId).from(sone.getId()).withTime(Long.parseLong(postTime)).withText(postText);
-                                       if ((postRecipientId != null) && (postRecipientId.length() == 43)) {
-                                               postBuilder.to(postRecipientId);
-                                       }
-                                       posts.add(postBuilder.build());
-                               } catch (NumberFormatException nfe1) {
-                                       /* TODO - mark Sone as bad. */
-                                       logger.log(Level.WARNING, String.format("Downloaded post for Sone %s with invalid time: %s", sone, postTime));
-                                       return null;
-                               }
-                       }
-               }
-
-               /* parse replies. */
-               SimpleXML repliesXml = soneXml.getNode("replies");
-               Set<PostReply> replies = new HashSet<PostReply>();
-               if (repliesXml == null) {
-                       /* TODO - mark Sone as bad. */
-                       logger.log(Level.WARNING, String.format("Downloaded Sone %s has no replies!", sone));
-               } else {
-                       for (SimpleXML replyXml : repliesXml.getNodes("reply")) {
-                               String replyId = replyXml.getValue("id", null);
-                               String replyPostId = replyXml.getValue("post-id", null);
-                               String replyTime = replyXml.getValue("time", null);
-                               String replyText = replyXml.getValue("text", null);
-                               if ((replyId == null) || (replyPostId == null) || (replyTime == null) || (replyText == null)) {
-                                       /* TODO - mark Sone as bad. */
-                                       logger.log(Level.WARNING, String.format("Downloaded reply for Sone %s with missing data! ID: %s, Post: %s, Time: %s, Text: %s", sone, replyId, replyPostId, replyTime, replyText));
-                                       return null;
-                               }
-                               try {
-                                       PostReplyBuilder postReplyBuilder = core.postReplyBuilder();
-                                       /* TODO - parse time correctly. */
-                                       postReplyBuilder.withId(replyId).from(sone.getId()).to(replyPostId).withTime(Long.parseLong(replyTime)).withText(replyText);
-                                       replies.add(postReplyBuilder.build());
-                               } catch (NumberFormatException nfe1) {
-                                       /* TODO - mark Sone as bad. */
-                                       logger.log(Level.WARNING, String.format("Downloaded reply for Sone %s with invalid time: %s", sone, replyTime));
-                                       return null;
-                               }
-                       }
-               }
-
-               /* parse liked post IDs. */
-               SimpleXML likePostIdsXml = soneXml.getNode("post-likes");
-               Set<String> likedPostIds = new HashSet<String>();
-               if (likePostIdsXml == null) {
-                       /* TODO - mark Sone as bad. */
-                       logger.log(Level.WARNING, String.format("Downloaded Sone %s has no post likes!", sone));
-               } else {
-                       for (SimpleXML likedPostIdXml : likePostIdsXml.getNodes("post-like")) {
-                               String postId = likedPostIdXml.getValue();
-                               likedPostIds.add(postId);
-                       }
-               }
-
-               /* parse liked reply IDs. */
-               SimpleXML likeReplyIdsXml = soneXml.getNode("reply-likes");
-               Set<String> likedReplyIds = new HashSet<String>();
-               if (likeReplyIdsXml == null) {
-                       /* TODO - mark Sone as bad. */
-                       logger.log(Level.WARNING, String.format("Downloaded Sone %s has no reply likes!", sone));
-               } else {
-                       for (SimpleXML likedReplyIdXml : likeReplyIdsXml.getNodes("reply-like")) {
-                               String replyId = likedReplyIdXml.getValue();
-                               likedReplyIds.add(replyId);
-                       }
-               }
-
-               /* parse albums. */
-               SimpleXML albumsXml = soneXml.getNode("albums");
-               List<Album> topLevelAlbums = new ArrayList<Album>();
-               if (albumsXml != null) {
-                       for (SimpleXML albumXml : albumsXml.getNodes("album")) {
-                               String id = albumXml.getValue("id", null);
-                               String parentId = albumXml.getValue("parent", null);
-                               String title = albumXml.getValue("title", null);
-                               String description = albumXml.getValue("description", "");
-                               String albumImageId = albumXml.getValue("album-image", null);
-                               if ((id == null) || (title == null) || (description == null)) {
-                                       logger.log(Level.WARNING, String.format("Downloaded Sone %s contains invalid album!", sone));
-                                       return null;
-                               }
-                               Album parent = null;
-                               if (parentId != null) {
-                                       parent = core.getAlbum(parentId, false);
-                                       if (parent == null) {
-                                               logger.log(Level.WARNING, String.format("Downloaded Sone %s has album with invalid parent!", sone));
-                                               return null;
-                                       }
-                               }
-                               Album album = core.getAlbum(id).setSone(sone).modify().setTitle(title).setDescription(description).update();
-                               if (parent != null) {
-                                       parent.addAlbum(album);
-                               } else {
-                                       topLevelAlbums.add(album);
-                               }
-                               SimpleXML imagesXml = albumXml.getNode("images");
-                               if (imagesXml != null) {
-                                       for (SimpleXML imageXml : imagesXml.getNodes("image")) {
-                                               String imageId = imageXml.getValue("id", null);
-                                               String imageCreationTimeString = imageXml.getValue("creation-time", null);
-                                               String imageKey = imageXml.getValue("key", null);
-                                               String imageTitle = imageXml.getValue("title", null);
-                                               String imageDescription = imageXml.getValue("description", "");
-                                               String imageWidthString = imageXml.getValue("width", null);
-                                               String imageHeightString = imageXml.getValue("height", null);
-                                               if ((imageId == null) || (imageCreationTimeString == null) || (imageKey == null) || (imageTitle == null) || (imageWidthString == null) || (imageHeightString == null)) {
-                                                       logger.log(Level.WARNING, String.format("Downloaded Sone %s contains invalid images!", sone));
-                                                       return null;
-                                               }
-                                               long creationTime = Numbers.safeParseLong(imageCreationTimeString, 0L);
-                                               int imageWidth = Numbers.safeParseInteger(imageWidthString, 0);
-                                               int imageHeight = Numbers.safeParseInteger(imageHeightString, 0);
-                                               if ((imageWidth < 1) || (imageHeight < 1)) {
-                                                       logger.log(Level.WARNING, String.format("Downloaded Sone %s contains image %s with invalid dimensions (%s, %s)!", sone, imageId, imageWidthString, imageHeightString));
-                                                       return null;
-                                               }
-                                               Image image = core.getImage(imageId).modify().setSone(sone).setKey(imageKey).setCreationTime(creationTime).update();
-                                               image = image.modify().setTitle(imageTitle).setDescription(imageDescription).update();
-                                               image = image.modify().setWidth(imageWidth).setHeight(imageHeight).update();
-                                               album.addImage(image);
-                                       }
-                               }
-                               album.modify().setAlbumImage(albumImageId).update();
-                       }
-               }
-
-               /* process avatar. */
-               if (avatarId != null) {
-                       profile.setAvatar(core.getImage(avatarId, false));
-               }
-
-               /* okay, apparently everything was parsed correctly. Now import. */
-               /* atomic setter operation on the Sone. */
-               synchronized (sone) {
-                       sone.setProfile(profile);
-                       sone.setPosts(posts);
-                       sone.setReplies(replies);
-                       sone.setLikePostIds(likedPostIds);
-                       sone.setLikeReplyIds(likedReplyIds);
-                       for (Album album : topLevelAlbums) {
-                               sone.getRootAlbum().addAlbum(album);
-                       }
-               }
-
-               return sone;
-       }
+public interface SoneDownloader extends Service {
 
-       //
-       // SERVICE METHODS
-       //
+       void addSone(Sone sone);
+       void fetchSone(Sone sone, FreenetURI soneUri);
+       Sone fetchSone(Sone sone, FreenetURI soneUri, boolean fetchOnly);
 
-       /**
-        * {@inheritDoc}
-        */
-       @Override
-       protected void serviceStop() {
-               for (Sone sone : sones) {
-                       freenetInterface.unregisterUsk(sone);
-               }
-       }
+       Runnable fetchSoneWithUriAction(Sone sone);
+       Runnable fetchSoneAction(Sone sone);
 
 }
diff --git a/src/main/java/net/pterodactylus/sone/core/SoneDownloaderImpl.java b/src/main/java/net/pterodactylus/sone/core/SoneDownloaderImpl.java
new file mode 100644 (file)
index 0000000..b36c2d3
--- /dev/null
@@ -0,0 +1,274 @@
+/*
+ * Sone - SoneDownloader.java - Copyright © 2010–2013 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone.core;
+
+import static freenet.support.io.Closer.close;
+import static java.lang.String.format;
+import static java.lang.System.currentTimeMillis;
+import static java.util.concurrent.TimeUnit.DAYS;
+import static java.util.logging.Logger.getLogger;
+
+import java.io.InputStream;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import net.pterodactylus.sone.core.FreenetInterface.Fetched;
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.sone.data.Sone.SoneStatus;
+import net.pterodactylus.util.service.AbstractService;
+
+import freenet.client.FetchResult;
+import freenet.client.async.ClientContext;
+import freenet.client.async.USKCallback;
+import freenet.keys.FreenetURI;
+import freenet.keys.USK;
+import freenet.node.RequestStarter;
+import freenet.support.api.Bucket;
+import freenet.support.io.Closer;
+import com.db4o.ObjectContainer;
+
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * The Sone downloader is responsible for download Sones as they are updated.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class SoneDownloaderImpl extends AbstractService implements SoneDownloader {
+
+       /** The logger. */
+       private static final Logger logger = getLogger("Sone.Downloader");
+
+       /** The maximum protocol version. */
+       private static final int MAX_PROTOCOL_VERSION = 0;
+
+       /** The core. */
+       private final Core core;
+       private final SoneParser soneParser;
+
+       /** The Freenet interface. */
+       private final FreenetInterface freenetInterface;
+
+       /** The sones to update. */
+       private final Set<Sone> sones = new HashSet<Sone>();
+
+       /**
+        * Creates a new Sone downloader.
+        *
+        * @param core
+        *              The core
+        * @param freenetInterface
+        *              The Freenet interface
+        */
+       public SoneDownloaderImpl(Core core, FreenetInterface freenetInterface) {
+               this(core, freenetInterface, new SoneParser(core));
+       }
+
+       /**
+        * Creates a new Sone downloader.
+        *
+        * @param core
+        *              The core
+        * @param freenetInterface
+        *              The Freenet interface
+        * @param soneParser
+        */
+       @VisibleForTesting
+       SoneDownloaderImpl(Core core, FreenetInterface freenetInterface, SoneParser soneParser) {
+               super("Sone Downloader", false);
+               this.core = core;
+               this.freenetInterface = freenetInterface;
+               this.soneParser = soneParser;
+       }
+
+       //
+       // ACTIONS
+       //
+
+       /**
+        * Adds the given Sone to the set of Sones that will be watched for updates.
+        *
+        * @param sone
+        *              The Sone to add
+        */
+       @Override
+       public void addSone(final Sone sone) {
+               if (!sones.add(sone)) {
+                       freenetInterface.unregisterUsk(sone);
+               }
+               final USKCallback uskCallback = new USKCallback() {
+
+                       @Override
+                       @SuppressWarnings("synthetic-access")
+                       public void onFoundEdition(long edition, USK key,
+                                       ClientContext clientContext, boolean metadata,
+                                       short codec, byte[] data, boolean newKnownGood,
+                                       boolean newSlotToo) {
+                               logger.log(Level.FINE, format(
+                                               "Found USK update for Sone “%s” at %s, new known good: %s, new slot too: %s.",
+                                               sone, key, newKnownGood, newSlotToo));
+                               if (edition > sone.getLatestEdition()) {
+                                       sone.setLatestEdition(edition);
+                                       new Thread(fetchSoneAction(sone),
+                                                       "Sone Downloader").start();
+                               }
+                       }
+
+                       @Override
+                       public short getPollingPriorityProgress() {
+                               return RequestStarter.INTERACTIVE_PRIORITY_CLASS;
+                       }
+
+                       @Override
+                       public short getPollingPriorityNormal() {
+                               return RequestStarter.INTERACTIVE_PRIORITY_CLASS;
+                       }
+               };
+               if (soneHasBeenActiveRecently(sone)) {
+                       freenetInterface.registerActiveUsk(sone.getRequestUri(),
+                                       uskCallback);
+               } else {
+                       freenetInterface.registerPassiveUsk(sone.getRequestUri(),
+                                       uskCallback);
+               }
+       }
+
+       private boolean soneHasBeenActiveRecently(Sone sone) {
+               return (currentTimeMillis() - sone.getTime()) < DAYS.toMillis(7);
+       }
+
+       private void fetchSone(Sone sone) {
+               fetchSone(sone, sone.getRequestUri().sskForUSK());
+       }
+
+       /**
+        * Fetches the updated Sone. This method can be used to fetch a Sone from a
+        * specific URI.
+        *
+        * @param sone
+        *              The Sone to fetch
+        * @param soneUri
+        *              The URI to fetch the Sone from
+        */
+       @Override
+       public void fetchSone(Sone sone, FreenetURI soneUri) {
+               fetchSone(sone, soneUri, false);
+       }
+
+       /**
+        * Fetches the Sone from the given URI.
+        *
+        * @param sone
+        *              The Sone to fetch
+        * @param soneUri
+        *              The URI of the Sone to fetch
+        * @param fetchOnly
+        *              {@code true} to only fetch and parse the Sone, {@code false}
+        *              to {@link Core#updateSone(Sone) update} it in the core
+        * @return The downloaded Sone, or {@code null} if the Sone could not be
+        *         downloaded
+        */
+       @Override
+       public Sone fetchSone(Sone sone, FreenetURI soneUri, boolean fetchOnly) {
+               logger.log(Level.FINE, String.format("Starting fetch for Sone “%s” from %s…", sone, soneUri));
+               FreenetURI requestUri = soneUri.setMetaString(new String[] { "sone.xml" });
+               sone.setStatus(SoneStatus.downloading);
+               try {
+                       Fetched fetchResults = freenetInterface.fetchUri(requestUri);
+                       if (fetchResults == null) {
+                               /* TODO - mark Sone as bad. */
+                               return null;
+                       }
+                       logger.log(Level.FINEST, String.format("Got %d bytes back.", fetchResults.getFetchResult().size()));
+                       Sone parsedSone = parseSone(sone, fetchResults.getFetchResult(), fetchResults.getFreenetUri());
+                       if (parsedSone != null) {
+                               if (!fetchOnly) {
+                                       parsedSone.setStatus((parsedSone.getTime() == 0) ? SoneStatus.unknown : SoneStatus.idle);
+                                       core.updateSone(parsedSone);
+                                       addSone(parsedSone);
+                               }
+                       }
+                       return parsedSone;
+               } finally {
+                       sone.setStatus((sone.getTime() == 0) ? SoneStatus.unknown : SoneStatus.idle);
+               }
+       }
+
+       /**
+        * Parses a Sone from a fetch result.
+        *
+        * @param originalSone
+        *              The sone to parse, or {@code null} if the Sone is yet unknown
+        * @param fetchResult
+        *              The fetch result
+        * @param requestUri
+        *              The requested URI
+        * @return The parsed Sone, or {@code null} if the Sone could not be parsed
+        */
+       private Sone parseSone(Sone originalSone, FetchResult fetchResult, FreenetURI requestUri) {
+               logger.log(Level.FINEST, String.format("Parsing FetchResult (%d bytes, %s) for %s…", fetchResult.size(), fetchResult.getMimeType(), originalSone));
+               Bucket soneBucket = fetchResult.asBucket();
+               InputStream soneInputStream = null;
+               try {
+                       soneInputStream = soneBucket.getInputStream();
+                       Sone parsedSone = soneParser.parseSone(originalSone,
+                                       soneInputStream);
+                       if (parsedSone != null) {
+                               parsedSone.setLatestEdition(requestUri.getEdition());
+                       }
+                       return parsedSone;
+               } catch (Exception e1) {
+                       logger.log(Level.WARNING, String.format("Could not parse Sone from %s!", requestUri), e1);
+               } finally {
+                       close(soneInputStream);
+                       close(soneBucket);
+               }
+               return null;
+       }
+
+       @Override
+       public Runnable fetchSoneWithUriAction(final Sone sone) {
+               return new Runnable() {
+                       @Override
+                       public void run() {
+                               fetchSone(sone, sone.getRequestUri());
+                       }
+               };
+       }
+
+       @Override
+       public Runnable fetchSoneAction(final Sone sone) {
+               return new Runnable() {
+                       @Override
+                       public void run() {
+                               fetchSone(sone);
+                       }
+               };
+       }
+
+       /** {@inheritDoc} */
+       @Override
+       protected void serviceStop() {
+               for (Sone sone : sones) {
+                       freenetInterface.unregisterUsk(sone);
+               }
+       }
+
+}
index f1af354..2b1f1de 100644 (file)
@@ -26,23 +26,6 @@ public class SoneException extends Exception {
 
        /**
         * Creates a new Sone exception.
-        */
-       public SoneException() {
-               super();
-       }
-
-       /**
-        * Creates a new Sone exception.
-        *
-        * @param message
-        *            The message of the exception
-        */
-       public SoneException(String message) {
-               super(message);
-       }
-
-       /**
-        * Creates a new Sone exception.
         *
         * @param cause
         *            The cause of the exception
index 5c3f7cd..f67f093 100644 (file)
@@ -26,33 +26,6 @@ public class SoneInsertException extends SoneException {
 
        /**
         * Creates a new Sone insert exception.
-        */
-       public SoneInsertException() {
-               super();
-       }
-
-       /**
-        * Creates a new Sone insert exception.
-        *
-        * @param message
-        *            The message of the exception
-        */
-       public SoneInsertException(String message) {
-               super(message);
-       }
-
-       /**
-        * Creates a new Sone insert exception.
-        *
-        * @param cause
-        *            The cause of the exception
-        */
-       public SoneInsertException(Throwable cause) {
-               super(cause);
-       }
-
-       /**
-        * Creates a new Sone insert exception.
         *
         * @param message
         *            The message of the exception
index a1bb903..86bf049 100644 (file)
 
 package net.pterodactylus.sone.core;
 
-import static com.google.common.base.Preconditions.checkArgument;
+import static java.lang.String.format;
+import static java.lang.System.currentTimeMillis;
+import static java.util.logging.Logger.getLogger;
 import static net.pterodactylus.sone.data.Album.NOT_EMPTY;
 
+import java.io.Closeable;
+import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.io.StringWriter;
 import java.nio.charset.Charset;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
+import net.pterodactylus.sone.core.SoneModificationDetector.LockableFingerprintProvider;
+import net.pterodactylus.sone.core.event.InsertionDelayChangedEvent;
 import net.pterodactylus.sone.core.event.SoneInsertAbortedEvent;
 import net.pterodactylus.sone.core.event.SoneInsertedEvent;
 import net.pterodactylus.sone.core.event.SoneInsertingEvent;
@@ -37,10 +45,8 @@ import net.pterodactylus.sone.data.Post;
 import net.pterodactylus.sone.data.Reply;
 import net.pterodactylus.sone.data.Sone;
 import net.pterodactylus.sone.data.Sone.SoneStatus;
-import net.pterodactylus.sone.freenet.StringBucket;
 import net.pterodactylus.sone.main.SonePlugin;
 import net.pterodactylus.util.io.Closer;
-import net.pterodactylus.util.logging.Logging;
 import net.pterodactylus.util.service.AbstractService;
 import net.pterodactylus.util.template.HtmlFilter;
 import net.pterodactylus.util.template.ReflectionAccessor;
@@ -51,12 +57,19 @@ import net.pterodactylus.util.template.TemplateException;
 import net.pterodactylus.util.template.TemplateParser;
 import net.pterodactylus.util.template.XmlFilter;
 
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Charsets;
+import com.google.common.base.Optional;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Ordering;
 import com.google.common.eventbus.EventBus;
+import com.google.common.eventbus.Subscribe;
 
-import freenet.client.async.ManifestElement;
 import freenet.keys.FreenetURI;
+import freenet.support.api.Bucket;
+import freenet.support.api.ManifestElement;
+import freenet.support.api.RandomAccessBucket;
+import freenet.support.io.ArrayBucket;
 
 /**
  * A Sone inserter is responsible for inserting a Sone if it has changed.
@@ -66,10 +79,10 @@ import freenet.keys.FreenetURI;
 public class SoneInserter extends AbstractService {
 
        /** The logger. */
-       private static final Logger logger = Logging.getLogger(SoneInserter.class);
+       private static final Logger logger = getLogger("Sone.Inserter");
 
        /** The insertion delay (in seconds). */
-       private static volatile int insertionDelay = 60;
+       private static final AtomicInteger insertionDelay = new AtomicInteger(60);
 
        /** The template factory used to create the templates. */
        private static final TemplateContextFactory templateContextFactory = new TemplateContextFactory();
@@ -92,14 +105,9 @@ public class SoneInserter extends AbstractService {
        /** The Freenet interface. */
        private final FreenetInterface freenetInterface;
 
-       /** The Sone to insert. */
-       private volatile Sone sone;
-
-       /** Whether a modification has been detected. */
-       private volatile boolean modified = false;
-
-       /** The fingerprint of the last insert. */
-       private volatile String lastInsertFingerprint;
+       private final SoneModificationDetector soneModificationDetector;
+       private final long delay;
+       private final String soneId;
 
        /**
         * Creates a new Sone inserter.
@@ -110,32 +118,49 @@ public class SoneInserter extends AbstractService {
         *            The event bus
         * @param freenetInterface
         *            The freenet interface
-        * @param sone
-        *            The Sone to insert
+        * @param soneId
+        *            The ID of the Sone to insert
         */
-       public SoneInserter(Core core, EventBus eventBus, FreenetInterface freenetInterface, Sone sone) {
-               super("Sone Inserter for “" + sone.getName() + "”", false);
+       public SoneInserter(final Core core, EventBus eventBus, FreenetInterface freenetInterface, final String soneId) {
+               this(core, eventBus, freenetInterface, soneId, new SoneModificationDetector(new LockableFingerprintProvider() {
+                       @Override
+                       public boolean isLocked() {
+                               final Optional<Sone> sone = core.getSone(soneId);
+                               if (!sone.isPresent()) {
+                                       return false;
+                               }
+                               return core.isLocked(sone.get());
+                       }
+
+                       @Override
+                       public String getFingerprint() {
+                               final Optional<Sone> sone = core.getSone(soneId);
+                               if (!sone.isPresent()) {
+                                       return null;
+                               }
+                               return sone.get().getFingerprint();
+                       }
+               }, insertionDelay), 1000);
+       }
+
+       @VisibleForTesting
+       SoneInserter(Core core, EventBus eventBus, FreenetInterface freenetInterface, String soneId, SoneModificationDetector soneModificationDetector, long delay) {
+               super("Sone Inserter for “" + soneId + "”", false);
                this.core = core;
                this.eventBus = eventBus;
                this.freenetInterface = freenetInterface;
-               this.sone = sone;
+               this.soneId = soneId;
+               this.soneModificationDetector = soneModificationDetector;
+               this.delay = delay;
        }
 
        //
        // ACCESSORS
        //
 
-       /**
-        * Sets the Sone to insert.
-        *
-        * @param sone
-        *              The Sone to insert
-        * @return This Sone inserter
-        */
-       public SoneInserter setSone(Sone sone) {
-               checkArgument((this.sone == null) || sone.equals(this.sone), "Sone to insert can not be set to a different Sone");
-               this.sone = sone;
-               return this;
+       @VisibleForTesting
+       static AtomicInteger getInsertionDelay() {
+               return insertionDelay;
        }
 
        /**
@@ -145,8 +170,8 @@ public class SoneInserter extends AbstractService {
         * @param insertionDelay
         *            The insertion delay (in seconds)
         */
-       public static void setInsertionDelay(int insertionDelay) {
-               SoneInserter.insertionDelay = insertionDelay;
+       private static void setInsertionDelay(int insertionDelay) {
+               SoneInserter.insertionDelay.set(insertionDelay);
        }
 
        /**
@@ -155,7 +180,7 @@ public class SoneInserter extends AbstractService {
         * @return The fingerprint of the last insert
         */
        public String getLastInsertFingerprint() {
-               return lastInsertFingerprint;
+               return soneModificationDetector.getOriginalFingerprint();
        }
 
        /**
@@ -165,7 +190,7 @@ public class SoneInserter extends AbstractService {
         *            The fingerprint of the last insert
         */
        public void setLastInsertFingerprint(String lastInsertFingerprint) {
-               this.lastInsertFingerprint = lastInsertFingerprint;
+               soneModificationDetector.setFingerprint(lastInsertFingerprint);
        }
 
        /**
@@ -176,7 +201,7 @@ public class SoneInserter extends AbstractService {
         *         otherwise
         */
        public boolean isModified() {
-               return modified;
+               return soneModificationDetector.isModified();
        }
 
        //
@@ -188,59 +213,28 @@ public class SoneInserter extends AbstractService {
         */
        @Override
        protected void serviceRun() {
-               long lastModificationTime = 0;
-               String lastInsertedFingerprint = lastInsertFingerprint;
-               String lastFingerprint = "";
-               Sone sone;
                while (!shouldStop()) {
                        try {
-                               /* check every seconds. */
-                               sleep(1000);
-
-                               /* don’t insert locked Sones. */
-                               sone = this.sone;
-                               if (core.isLocked(sone)) {
-                                       /* trigger redetection when the Sone is unlocked. */
-                                       synchronized (sone) {
-                                               modified = !sone.getFingerprint().equals(lastInsertedFingerprint);
+                               /* check every second. */
+                               sleep(delay);
+
+                               if (soneModificationDetector.isEligibleForInsert()) {
+                                       Optional<Sone> soneOptional = core.getSone(soneId);
+                                       if (!soneOptional.isPresent()) {
+                                               logger.log(Level.WARNING, format("Sone %s has disappeared, exiting inserter.", soneId));
+                                               return;
                                        }
-                                       lastFingerprint = "";
-                                       lastModificationTime = 0;
-                                       continue;
-                               }
-
-                               InsertInformation insertInformation = null;
-                               synchronized (sone) {
-                                       String fingerprint = sone.getFingerprint();
-                                       if (!fingerprint.equals(lastFingerprint)) {
-                                               if (fingerprint.equals(lastInsertedFingerprint)) {
-                                                       modified = false;
-                                                       lastModificationTime = 0;
-                                                       logger.log(Level.FINE, String.format("Sone %s has been reverted to last insert state.", sone));
-                                               } else {
-                                                       lastModificationTime = System.currentTimeMillis();
-                                                       modified = true;
-                                                       logger.log(Level.FINE, String.format("Sone %s has been modified, waiting %d seconds before inserting.", sone.getName(), insertionDelay));
-                                               }
-                                               lastFingerprint = fingerprint;
-                                       }
-                                       if (modified && (lastModificationTime > 0) && ((System.currentTimeMillis() - lastModificationTime) > (insertionDelay * 1000))) {
-                                               lastInsertedFingerprint = fingerprint;
-                                               insertInformation = new InsertInformation(sone);
-                                       }
-                               }
-
-                               if (insertInformation != null) {
+                                       Sone sone = soneOptional.get();
+                                       InsertInformation insertInformation = new InsertInformation(sone);
                                        logger.log(Level.INFO, String.format("Inserting Sone “%s”…", sone.getName()));
 
                                        boolean success = false;
                                        try {
                                                sone.setStatus(SoneStatus.inserting);
-                                               long insertTime = System.currentTimeMillis();
-                                               insertInformation.setTime(insertTime);
+                                               long insertTime = currentTimeMillis();
                                                eventBus.post(new SoneInsertingEvent(sone));
-                                               FreenetURI finalUri = freenetInterface.insertDirectory(insertInformation.getInsertUri(), insertInformation.generateManifestEntries(), "index.html");
-                                               eventBus.post(new SoneInsertedEvent(sone, System.currentTimeMillis() - insertTime));
+                                               FreenetURI finalUri = freenetInterface.insertDirectory(sone.getInsertUri(), insertInformation.generateManifestEntries(), "index.html");
+                                               eventBus.post(new SoneInsertedEvent(sone, currentTimeMillis() - insertTime, insertInformation.getFingerprint()));
                                                /* at this point we might already be stopped. */
                                                if (shouldStop()) {
                                                        /* if so, bail out, don’t change anything. */
@@ -255,6 +249,7 @@ public class SoneInserter extends AbstractService {
                                                eventBus.post(new SoneInsertAbortedEvent(sone, se1));
                                                logger.log(Level.WARNING, String.format("Could not insert Sone “%s”!", sone.getName()), se1);
                                        } finally {
+                                               insertInformation.close();
                                                sone.setStatus(SoneStatus.idle);
                                        }
 
@@ -264,12 +259,10 @@ public class SoneInserter extends AbstractService {
                                         */
                                        if (success) {
                                                synchronized (sone) {
-                                                       if (lastInsertedFingerprint.equals(sone.getFingerprint())) {
+                                                       if (insertInformation.getFingerprint().equals(sone.getFingerprint())) {
                                                                logger.log(Level.FINE, String.format("Sone “%s” was not modified further, resetting counter…", sone));
-                                                               lastModificationTime = 0;
-                                                               lastInsertFingerprint = lastInsertedFingerprint;
+                                                               soneModificationDetector.setFingerprint(insertInformation.getFingerprint());
                                                                core.touchConfiguration();
-                                                               modified = false;
                                                        }
                                                }
                                        }
@@ -280,6 +273,11 @@ public class SoneInserter extends AbstractService {
                }
        }
 
+       @Subscribe
+       public void insertionDelayChanged(InsertionDelayChangedEvent insertionDelayChangedEvent) {
+               setInsertionDelay(insertionDelayChangedEvent.getInsertionDelay());
+       }
+
        /**
         * Container for information that are required to insert a Sone. This
         * container merely exists to copy all relevant data without holding a lock
@@ -287,10 +285,13 @@ public class SoneInserter extends AbstractService {
         *
         * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
         */
-       private class InsertInformation {
+       @VisibleForTesting
+       class InsertInformation implements Closeable {
 
                /** All properties of the Sone, copied for thread safety. */
                private final Map<String, Object> soneProperties = new HashMap<String, Object>();
+               private final String fingerprint;
+               private final ManifestCreator manifestCreator;
 
                /**
                 * Creates a new insert information container.
@@ -299,40 +300,28 @@ public class SoneInserter extends AbstractService {
                 *            The sone to insert
                 */
                public InsertInformation(Sone sone) {
+                       this.fingerprint = sone.getFingerprint();
+                       Map<String, Object> soneProperties = new HashMap<String, Object>();
                        soneProperties.put("id", sone.getId());
                        soneProperties.put("name", sone.getName());
-                       soneProperties.put("time", sone.getTime());
+                       soneProperties.put("time", currentTimeMillis());
                        soneProperties.put("requestUri", sone.getRequestUri());
-                       soneProperties.put("insertUri", sone.getInsertUri());
                        soneProperties.put("profile", sone.getProfile());
                        soneProperties.put("posts", Ordering.from(Post.TIME_COMPARATOR).sortedCopy(sone.getPosts()));
                        soneProperties.put("replies", Ordering.from(Reply.TIME_COMPARATOR).reverse().sortedCopy(sone.getReplies()));
                        soneProperties.put("likedPostIds", new HashSet<String>(sone.getLikedPostIds()));
                        soneProperties.put("likedReplyIds", new HashSet<String>(sone.getLikedReplyIds()));
                        soneProperties.put("albums", FluentIterable.from(sone.getRootAlbum().getAlbums()).transformAndConcat(Album.FLATTENER).filter(NOT_EMPTY).toList());
+                       manifestCreator = new ManifestCreator(core, soneProperties);
                }
 
                //
                // ACCESSORS
                //
 
-               /**
-                * Returns the insert URI of the Sone.
-                *
-                * @return The insert URI of the Sone
-                */
-               public FreenetURI getInsertUri() {
-                       return (FreenetURI) soneProperties.get("insertUri");
-               }
-
-               /**
-                * Sets the time of the Sone at the time of the insert.
-                *
-                * @param time
-                *            The time of the Sone
-                */
-               public void setTime(long time) {
-                       soneProperties.put("time", time);
+               @VisibleForTesting
+               String getFingerprint() {
+                       return fingerprint;
                }
 
                //
@@ -348,41 +337,56 @@ public class SoneInserter extends AbstractService {
                        HashMap<String, Object> manifestEntries = new HashMap<String, Object>();
 
                        /* first, create an index.html. */
-                       manifestEntries.put("index.html", createManifestElement("index.html", "text/html; charset=utf-8", "/templates/insert/index.html"));
+                       manifestEntries.put("index.html", manifestCreator.createManifestElement(
+                                       "index.html", "text/html; charset=utf-8",
+                                       "/templates/insert/index.html"));
 
                        /* now, store the sone. */
-                       manifestEntries.put("sone.xml", createManifestElement("sone.xml", "text/xml; charset=utf-8", "/templates/insert/sone.xml"));
+                       manifestEntries.put("sone.xml", manifestCreator.createManifestElement(
+                                       "sone.xml", "text/xml; charset=utf-8",
+                                       "/templates/insert/sone.xml"));
 
                        return manifestEntries;
                }
 
-               //
-               // PRIVATE METHODS
-               //
+               @Override
+               public void close() {
+                       manifestCreator.close();
+               }
 
-               /**
-                * Creates a new manifest element.
-                *
-                * @param name
-                *            The name of the file
-                * @param contentType
-                *            The content type of the file
-                * @param templateName
-                *            The name of the template to render
-                * @return The manifest element
-                */
-               @SuppressWarnings("synthetic-access")
-               private ManifestElement createManifestElement(String name, String contentType, String templateName) {
+       }
+
+       /**
+        * Creates manifest elements for an insert by rendering a template.
+        *
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       @VisibleForTesting
+       static class ManifestCreator implements Closeable {
+
+               private final Core core;
+               private final Map<String, Object> soneProperties;
+               private final Set<Bucket> buckets = new HashSet<Bucket>();
+
+               ManifestCreator(Core core, Map<String, Object> soneProperties) {
+                       this.core = core;
+                       this.soneProperties = soneProperties;
+               }
+
+               public ManifestElement createManifestElement(String name, String contentType, String templateName) {
                        InputStreamReader templateInputStreamReader = null;
+                       InputStream templateInputStream = null;
                        Template template;
                        try {
-                               templateInputStreamReader = new InputStreamReader(getClass().getResourceAsStream(templateName), utf8Charset);
+                               templateInputStream = getClass().getResourceAsStream(templateName);
+                               templateInputStreamReader = new InputStreamReader(templateInputStream, utf8Charset);
                                template = TemplateParser.parse(templateInputStreamReader);
                        } catch (TemplateException te1) {
                                logger.log(Level.SEVERE, String.format("Could not parse template “%s”!", templateName), te1);
                                return null;
                        } finally {
                                Closer.close(templateInputStreamReader);
+                               Closer.close(templateInputStream);
                        }
 
                        TemplateContext templateContext = templateContextFactory.createTemplateContext();
@@ -391,19 +395,22 @@ public class SoneInserter extends AbstractService {
                        templateContext.set("currentEdition", core.getUpdateChecker().getLatestEdition());
                        templateContext.set("version", SonePlugin.VERSION);
                        StringWriter writer = new StringWriter();
-                       StringBucket bucket = null;
                        try {
                                template.render(templateContext, writer);
-                               bucket = new StringBucket(writer.toString(), utf8Charset);
+                               RandomAccessBucket bucket = new ArrayBucket(writer.toString().getBytes(Charsets.UTF_8));
+                               buckets.add(bucket);
                                return new ManifestElement(name, bucket, contentType, bucket.size());
                        } catch (TemplateException te1) {
                                logger.log(Level.SEVERE, String.format("Could not render template “%s”!", templateName), te1);
                                return null;
                        } finally {
                                Closer.close(writer);
-                               if (bucket != null) {
-                                       bucket.free();
-                               }
+                       }
+               }
+
+               public void close() {
+                       for (Bucket bucket : buckets) {
+                               bucket.free();
                        }
                }
 
diff --git a/src/main/java/net/pterodactylus/sone/core/SoneModificationDetector.java b/src/main/java/net/pterodactylus/sone/core/SoneModificationDetector.java
new file mode 100644 (file)
index 0000000..290fcbe
--- /dev/null
@@ -0,0 +1,95 @@
+package net.pterodactylus.sone.core;
+
+import static com.google.common.base.Optional.absent;
+import static com.google.common.base.Optional.of;
+import static com.google.common.base.Ticker.systemTicker;
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import net.pterodactylus.sone.data.Sone;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Optional;
+import com.google.common.base.Ticker;
+
+/**
+ * Class that detects {@link Sone} modifications (as per their {@link
+ * Sone#getFingerprint() fingerprints} and determines when a modified Sone may
+ * be inserted.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+class SoneModificationDetector {
+
+       private final Ticker ticker;
+       private final LockableFingerprintProvider lockableFingerprintProvider;
+       private final AtomicInteger insertionDelay;
+       private Optional<Long> lastModificationTime;
+       private String originalFingerprint;
+       private String lastFingerprint;
+
+       SoneModificationDetector(LockableFingerprintProvider lockableFingerprintProvider, AtomicInteger insertionDelay) {
+               this(systemTicker(), lockableFingerprintProvider, insertionDelay);
+       }
+
+       @VisibleForTesting
+       SoneModificationDetector(Ticker ticker, LockableFingerprintProvider lockableFingerprintProvider, AtomicInteger insertionDelay) {
+               this.ticker = ticker;
+               this.lockableFingerprintProvider = lockableFingerprintProvider;
+               this.insertionDelay = insertionDelay;
+               lastFingerprint = originalFingerprint;
+       }
+
+       public boolean isEligibleForInsert() {
+               if (lockableFingerprintProvider.isLocked()) {
+                       lastModificationTime = absent();
+                       lastFingerprint = "";
+                       return false;
+               }
+               String fingerprint = lockableFingerprintProvider.getFingerprint();
+               if (originalFingerprint.equals(fingerprint)) {
+                       lastModificationTime = absent();
+                       lastFingerprint = fingerprint;
+                       return false;
+               }
+               if (!lastFingerprint.equals(fingerprint)) {
+                       lastModificationTime = of(ticker.read());
+                       lastFingerprint = fingerprint;
+                       return false;
+               }
+               return insertionDelayHasPassed();
+       }
+
+       public String getOriginalFingerprint() {
+               return originalFingerprint;
+       }
+
+       public void setFingerprint(String fingerprint) {
+               originalFingerprint = fingerprint;
+               lastFingerprint = originalFingerprint;
+               lastModificationTime = absent();
+       }
+
+       private boolean insertionDelayHasPassed() {
+               return NANOSECONDS.toSeconds(ticker.read() - lastModificationTime.get()) >= insertionDelay.get();
+       }
+
+       public boolean isModified() {
+               return !lockableFingerprintProvider.getFingerprint().equals(originalFingerprint);
+       }
+
+       /**
+        * Provider for a fingerprint and the information if a {@link Sone} is locked. This
+        * prevents us from having to lug a Sone object around.
+        *
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       static interface LockableFingerprintProvider {
+
+               boolean isLocked();
+               String getFingerprint();
+
+       }
+
+}
diff --git a/src/main/java/net/pterodactylus/sone/core/SoneParser.java b/src/main/java/net/pterodactylus/sone/core/SoneParser.java
new file mode 100644 (file)
index 0000000..ae600a6
--- /dev/null
@@ -0,0 +1,344 @@
+package net.pterodactylus.sone.core;
+
+import static java.util.logging.Logger.getLogger;
+import static net.pterodactylus.sone.utils.NumberParsers.parseInt;
+import static net.pterodactylus.sone.utils.NumberParsers.parseLong;
+
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import net.pterodactylus.sone.data.Album;
+import net.pterodactylus.sone.data.Client;
+import net.pterodactylus.sone.data.Image;
+import net.pterodactylus.sone.data.Post;
+import net.pterodactylus.sone.data.PostReply;
+import net.pterodactylus.sone.data.Profile;
+import net.pterodactylus.sone.data.Profile.DuplicateField;
+import net.pterodactylus.sone.data.Profile.EmptyFieldName;
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.sone.database.PostBuilder;
+import net.pterodactylus.sone.database.PostReplyBuilder;
+import net.pterodactylus.sone.database.SoneBuilder;
+import net.pterodactylus.util.xml.SimpleXML;
+import net.pterodactylus.util.xml.XML;
+
+import org.w3c.dom.Document;
+
+/**
+ * Parses a {@link Sone} from an XML {@link InputStream}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class SoneParser {
+
+       private static final Logger logger = getLogger("Sone.Parser");
+       private static final int MAX_PROTOCOL_VERSION = 0;
+       private final Core core;
+
+       public SoneParser(Core core) {
+               this.core = core;
+       }
+
+       public Sone parseSone(Sone originalSone, InputStream soneInputStream) throws SoneException {
+               /* TODO - impose a size limit? */
+
+               Document document;
+               /* XML parsing is not thread-safe. */
+               synchronized (this) {
+                       document = XML.transformToDocument(soneInputStream);
+               }
+               if (document == null) {
+                       /* TODO - mark Sone as bad. */
+                       logger.log(Level.WARNING, String.format("Could not parse XML for Sone %s!", originalSone));
+                       return null;
+               }
+
+               SoneBuilder soneBuilder = core.soneBuilder().from(originalSone.getIdentity());
+               if (originalSone.isLocal()) {
+                       soneBuilder = soneBuilder.local();
+               }
+               Sone sone = soneBuilder.build();
+
+               SimpleXML soneXml;
+               try {
+                       soneXml = SimpleXML.fromDocument(document);
+               } catch (NullPointerException npe1) {
+                       /* for some reason, invalid XML can cause NPEs. */
+                       logger.log(Level.WARNING, String.format("XML for Sone %s can not be parsed!", sone), npe1);
+                       return null;
+               }
+
+               Integer protocolVersion = null;
+               String soneProtocolVersion = soneXml.getValue("protocol-version", null);
+               if (soneProtocolVersion != null) {
+                       protocolVersion = parseInt(soneProtocolVersion, null);
+               }
+               if (protocolVersion == null) {
+                       logger.log(Level.INFO, "No protocol version found, assuming 0.");
+                       protocolVersion = 0;
+               }
+
+               if (protocolVersion < 0) {
+                       logger.log(Level.WARNING, String.format("Invalid protocol version: %d! Not parsing Sone.", protocolVersion));
+                       return null;
+               }
+
+               /* check for valid versions. */
+               if (protocolVersion > MAX_PROTOCOL_VERSION) {
+                       logger.log(Level.WARNING, String.format("Unknown protocol version: %d! Not parsing Sone.", protocolVersion));
+                       return null;
+               }
+
+               String soneTime = soneXml.getValue("time", null);
+               if (soneTime == null) {
+                       /* TODO - mark Sone as bad. */
+                       logger.log(Level.WARNING, String.format("Downloaded time for Sone %s was null!", sone));
+                       return null;
+               }
+               try {
+                       sone.setTime(Long.parseLong(soneTime));
+               } catch (NumberFormatException nfe1) {
+                       /* TODO - mark Sone as bad. */
+                       logger.log(Level.WARNING, String.format("Downloaded Sone %s with invalid time: %s", sone, soneTime));
+                       return null;
+               }
+
+               SimpleXML clientXml = soneXml.getNode("client");
+               if (clientXml != null) {
+                       String clientName = clientXml.getValue("name", null);
+                       String clientVersion = clientXml.getValue("version", null);
+                       if ((clientName == null) || (clientVersion == null)) {
+                               logger.log(Level.WARNING, String.format("Download Sone %s with client XML but missing name or version!", sone));
+                               return null;
+                       }
+                       sone.setClient(new Client(clientName, clientVersion));
+               }
+
+               SimpleXML profileXml = soneXml.getNode("profile");
+               if (profileXml == null) {
+                       /* TODO - mark Sone as bad. */
+                       logger.log(Level.WARNING, String.format("Downloaded Sone %s has no profile!", sone));
+                       return null;
+               }
+
+               /* parse profile. */
+               String profileFirstName = profileXml.getValue("first-name", null);
+               String profileMiddleName = profileXml.getValue("middle-name", null);
+               String profileLastName = profileXml.getValue("last-name", null);
+               Integer profileBirthDay = parseInt(profileXml.getValue("birth-day", ""), null);
+               Integer profileBirthMonth = parseInt(profileXml.getValue("birth-month", ""), null);
+               Integer profileBirthYear = parseInt(profileXml.getValue("birth-year", ""), null);
+               Profile profile = new Profile(sone).setFirstName(profileFirstName).setMiddleName(profileMiddleName).setLastName(profileLastName);
+               profile.setBirthDay(profileBirthDay).setBirthMonth(profileBirthMonth).setBirthYear(profileBirthYear);
+               /* avatar is processed after images are loaded. */
+               String avatarId = profileXml.getValue("avatar", null);
+
+               /* parse profile fields. */
+               SimpleXML profileFieldsXml = profileXml.getNode("fields");
+               if (profileFieldsXml != null) {
+                       for (SimpleXML fieldXml : profileFieldsXml.getNodes("field")) {
+                               String fieldName = fieldXml.getValue("field-name", null);
+                               String fieldValue = fieldXml.getValue("field-value", "");
+                               if (fieldName == null) {
+                                       logger.log(Level.WARNING, String.format("Downloaded profile field for Sone %s with missing data! Name: %s, Value: %s", sone, fieldName, fieldValue));
+                                       return null;
+                               }
+                               try {
+                                       profile.addField(fieldName.trim()).setValue(fieldValue);
+                               } catch (EmptyFieldName efn1) {
+                                       logger.log(Level.WARNING, "Empty field name!", efn1);
+                                       return null;
+                               } catch (DuplicateField df1) {
+                                       logger.log(Level.WARNING, String.format("Duplicate field: %s", fieldName), df1);
+                                       return null;
+                               }
+                       }
+               }
+
+               /* parse posts. */
+               SimpleXML postsXml = soneXml.getNode("posts");
+               Set<Post> posts = new HashSet<Post>();
+               if (postsXml == null) {
+                       /* TODO - mark Sone as bad. */
+                       logger.log(Level.WARNING, String.format("Downloaded Sone %s has no posts!", sone));
+               } else {
+                       for (SimpleXML postXml : postsXml.getNodes("post")) {
+                               String postId = postXml.getValue("id", null);
+                               String postRecipientId = postXml.getValue("recipient", null);
+                               String postTime = postXml.getValue("time", null);
+                               String postText = postXml.getValue("text", null);
+                               if ((postId == null) || (postTime == null) || (postText == null)) {
+                                       /* TODO - mark Sone as bad. */
+                                       logger.log(Level.WARNING, String.format("Downloaded post for Sone %s with missing data! ID: %s, Time: %s, Text: %s", sone, postId, postTime, postText));
+                                       return null;
+                               }
+                               try {
+                                       PostBuilder postBuilder = core.postBuilder();
+                                       /* TODO - parse time correctly. */
+                                       postBuilder.withId(postId).from(sone.getId()).withTime(Long.parseLong(postTime)).withText(postText);
+                                       if ((postRecipientId != null) && (postRecipientId.length() == 43)) {
+                                               postBuilder.to(postRecipientId);
+                                       }
+                                       posts.add(postBuilder.build());
+                               } catch (NumberFormatException nfe1) {
+                                       /* TODO - mark Sone as bad. */
+                                       logger.log(Level.WARNING, String.format("Downloaded post for Sone %s with invalid time: %s", sone, postTime));
+                                       return null;
+                               }
+                       }
+               }
+
+               /* parse replies. */
+               SimpleXML repliesXml = soneXml.getNode("replies");
+               Set<PostReply> replies = new HashSet<PostReply>();
+               if (repliesXml == null) {
+                       /* TODO - mark Sone as bad. */
+                       logger.log(Level.WARNING, String.format("Downloaded Sone %s has no replies!", sone));
+               } else {
+                       for (SimpleXML replyXml : repliesXml.getNodes("reply")) {
+                               String replyId = replyXml.getValue("id", null);
+                               String replyPostId = replyXml.getValue("post-id", null);
+                               String replyTime = replyXml.getValue("time", null);
+                               String replyText = replyXml.getValue("text", null);
+                               if ((replyId == null) || (replyPostId == null) || (replyTime == null) || (replyText == null)) {
+                                       /* TODO - mark Sone as bad. */
+                                       logger.log(Level.WARNING, String.format("Downloaded reply for Sone %s with missing data! ID: %s, Post: %s, Time: %s, Text: %s", sone, replyId, replyPostId, replyTime, replyText));
+                                       return null;
+                               }
+                               try {
+                                       PostReplyBuilder postReplyBuilder = core.postReplyBuilder();
+                                       /* TODO - parse time correctly. */
+                                       postReplyBuilder.withId(replyId).from(sone.getId()).to(replyPostId).withTime(Long.parseLong(replyTime)).withText(replyText);
+                                       replies.add(postReplyBuilder.build());
+                               } catch (NumberFormatException nfe1) {
+                                       /* TODO - mark Sone as bad. */
+                                       logger.log(Level.WARNING, String.format("Downloaded reply for Sone %s with invalid time: %s", sone, replyTime));
+                                       return null;
+                               }
+                       }
+               }
+
+               /* parse liked post IDs. */
+               SimpleXML likePostIdsXml = soneXml.getNode("post-likes");
+               Set<String> likedPostIds = new HashSet<String>();
+               if (likePostIdsXml == null) {
+                       /* TODO - mark Sone as bad. */
+                       logger.log(Level.WARNING, String.format("Downloaded Sone %s has no post likes!", sone));
+               } else {
+                       for (SimpleXML likedPostIdXml : likePostIdsXml.getNodes("post-like")) {
+                               String postId = likedPostIdXml.getValue();
+                               likedPostIds.add(postId);
+                       }
+               }
+
+               /* parse liked reply IDs. */
+               SimpleXML likeReplyIdsXml = soneXml.getNode("reply-likes");
+               Set<String> likedReplyIds = new HashSet<String>();
+               if (likeReplyIdsXml == null) {
+                       /* TODO - mark Sone as bad. */
+                       logger.log(Level.WARNING, String.format("Downloaded Sone %s has no reply likes!", sone));
+               } else {
+                       for (SimpleXML likedReplyIdXml : likeReplyIdsXml.getNodes("reply-like")) {
+                               String replyId = likedReplyIdXml.getValue();
+                               likedReplyIds.add(replyId);
+                       }
+               }
+
+               /* parse albums. */
+               SimpleXML albumsXml = soneXml.getNode("albums");
+               Map<String, Image> allImages = new HashMap<String, Image>();
+               List<Album> topLevelAlbums = new ArrayList<Album>();
+               if (albumsXml != null) {
+                       for (SimpleXML albumXml : albumsXml.getNodes("album")) {
+                               String id = albumXml.getValue("id", null);
+                               String parentId = albumXml.getValue("parent", null);
+                               String title = albumXml.getValue("title", null);
+                               String description = albumXml.getValue("description", "");
+                               String albumImageId = albumXml.getValue("album-image", null);
+                               if ((id == null) || (title == null)) {
+                                       logger.log(Level.WARNING, String.format("Downloaded Sone %s contains invalid album!", sone));
+                                       return null;
+                               }
+                               Album parent = null;
+                               if (parentId != null) {
+                                       parent = core.getAlbum(parentId);
+                                       if (parent == null) {
+                                               logger.log(Level.WARNING, String.format("Downloaded Sone %s has album with invalid parent!", sone));
+                                               return null;
+                                       }
+                               }
+                               Album album = core.albumBuilder()
+                                               .withId(id)
+                                               .by(sone)
+                                               .build()
+                                               .modify()
+                                               .setTitle(title)
+                                               .setDescription(description)
+                                               .update();
+                               if (parent != null) {
+                                       parent.addAlbum(album);
+                               } else {
+                                       topLevelAlbums.add(album);
+                               }
+                               SimpleXML imagesXml = albumXml.getNode("images");
+                               if (imagesXml != null) {
+                                       for (SimpleXML imageXml : imagesXml.getNodes("image")) {
+                                               String imageId = imageXml.getValue("id", null);
+                                               String imageCreationTimeString = imageXml.getValue("creation-time", null);
+                                               String imageKey = imageXml.getValue("key", null);
+                                               String imageTitle = imageXml.getValue("title", null);
+                                               String imageDescription = imageXml.getValue("description", "");
+                                               String imageWidthString = imageXml.getValue("width", null);
+                                               String imageHeightString = imageXml.getValue("height", null);
+                                               if ((imageId == null) || (imageCreationTimeString == null) || (imageKey == null) || (imageTitle == null) || (imageWidthString == null) || (imageHeightString == null)) {
+                                                       logger.log(Level.WARNING, String.format("Downloaded Sone %s contains invalid images!", sone));
+                                                       return null;
+                                               }
+                                               long creationTime = parseLong(imageCreationTimeString, 0L);
+                                               int imageWidth = parseInt(imageWidthString, 0);
+                                               int imageHeight = parseInt(imageHeightString, 0);
+                                               if ((imageWidth < 1) || (imageHeight < 1)) {
+                                                       logger.log(Level.WARNING, String.format("Downloaded Sone %s contains image %s with invalid dimensions (%s, %s)!", sone, imageId, imageWidthString, imageHeightString));
+                                                       return null;
+                                               }
+                                               Image image = core.imageBuilder().withId(imageId).build().modify().setSone(sone).setKey(imageKey).setCreationTime(creationTime).update();
+                                               image = image.modify().setTitle(imageTitle).setDescription(imageDescription).update();
+                                               image = image.modify().setWidth(imageWidth).setHeight(imageHeight).update();
+                                               album.addImage(image);
+                                               allImages.put(imageId, image);
+                                       }
+                               }
+                               album.modify().setAlbumImage(albumImageId).update();
+                       }
+               }
+
+               /* process avatar. */
+               if (avatarId != null) {
+                       profile.setAvatar(allImages.get(avatarId));
+               }
+
+               /* okay, apparently everything was parsed correctly. Now import. */
+               /* atomic setter operation on the Sone. */
+               synchronized (sone) {
+                       sone.setProfile(profile);
+                       sone.setPosts(posts);
+                       sone.setReplies(replies);
+                       sone.setLikePostIds(likedPostIds);
+                       sone.setLikeReplyIds(likedReplyIds);
+                       for (Album album : topLevelAlbums) {
+                               sone.getRootAlbum().addAlbum(album);
+                       }
+               }
+
+               return sone;
+
+       }
+
+}
index ed3d819..f45e351 100644 (file)
@@ -74,6 +74,7 @@ public class SoneRescuer extends AbstractService {
         *
         * @return {@code true} if the Sone rescuer is currently fetching a Sone
         */
+       @SuppressWarnings("unused") // used in rescue.html
        public boolean isFetching() {
                return fetching;
        }
@@ -83,6 +84,7 @@ public class SoneRescuer extends AbstractService {
         *
         * @return The edition that is currently being downloaded
         */
+       @SuppressWarnings("unused") // used in rescue.html
        public long getCurrentEdition() {
                return currentEdition;
        }
@@ -102,6 +104,7 @@ public class SoneRescuer extends AbstractService {
         *
         * @return The next edition the Sone rescuer can download
         */
+       @SuppressWarnings("unused") // used in rescue.html
        public long getNextEdition() {
                return currentEdition - 1;
        }
@@ -124,6 +127,7 @@ public class SoneRescuer extends AbstractService {
         * @return {@code true} if the last fetch was successful, {@code false}
         *         otherwise
         */
+       @SuppressWarnings("unused") // used in rescue.html
        public boolean isLastFetchSuccessful() {
                return lastFetchSuccessful;
        }
index 6c5bf16..ea84b39 100644 (file)
 
 package net.pterodactylus.sone.core;
 
+import static java.util.logging.Logger.getLogger;
+
 import java.net.MalformedURLException;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
-import net.pterodactylus.util.logging.Logging;
 import freenet.keys.FreenetURI;
 
 /**
@@ -33,7 +34,7 @@ import freenet.keys.FreenetURI;
 public class SoneUri {
 
        /** The logger. */
-       private static final Logger logger = Logging.getLogger(SoneUri.class);
+       private static final Logger logger = getLogger("Sone.Data");
 
        /**
         * Generate a Sone URI from the given URI.
index 307025a..85c5841 100644 (file)
@@ -17,6 +17,8 @@
 
 package net.pterodactylus.sone.core;
 
+import static java.util.logging.Logger.getLogger;
+
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
@@ -30,7 +32,6 @@ import net.pterodactylus.sone.core.FreenetInterface.Fetched;
 import net.pterodactylus.sone.core.event.UpdateFoundEvent;
 import net.pterodactylus.sone.main.SonePlugin;
 import net.pterodactylus.util.io.Closer;
-import net.pterodactylus.util.logging.Logging;
 import net.pterodactylus.util.version.Version;
 
 import com.google.common.eventbus.EventBus;
@@ -47,13 +48,13 @@ import freenet.support.api.Bucket;
 public class UpdateChecker {
 
        /** The logger. */
-       private static final Logger logger = Logging.getLogger(UpdateChecker.class);
+       private static final Logger logger = getLogger("Sone.UpdateChecker");
 
        /** The key of the Sone homepage. */
        private static final String SONE_HOMEPAGE = "USK@nwa8lHa271k2QvJ8aa0Ov7IHAV-DFOCFgmDt3X6BpCI,DuQSUZiI~agF8c-6tjsFFGuZ8eICrzWCILB60nT8KKo,AQACAAE/sone/";
 
        /** The current latest known edition. */
-       private static final int LATEST_EDITION = 62;
+       private static final int LATEST_EDITION = 65;
 
        /** The event bus. */
        private final EventBus eventBus;
index dd3de58..908bd96 100644 (file)
-/*
- * Sone - WebOfTrustUpdater.java - Copyright © 2013 David Roden
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
 package net.pterodactylus.sone.core;
 
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import java.util.concurrent.BlockingQueue;
-import java.util.concurrent.LinkedBlockingQueue;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-
-import net.pterodactylus.sone.freenet.plugin.PluginException;
-import net.pterodactylus.sone.freenet.wot.DefaultIdentity;
 import net.pterodactylus.sone.freenet.wot.Identity;
 import net.pterodactylus.sone.freenet.wot.OwnIdentity;
-import net.pterodactylus.sone.freenet.wot.Trust;
-import net.pterodactylus.sone.freenet.wot.WebOfTrustConnector;
-import net.pterodactylus.sone.freenet.wot.WebOfTrustException;
-import net.pterodactylus.util.logging.Logging;
-import net.pterodactylus.util.service.AbstractService;
+import net.pterodactylus.util.service.Service;
 
-import com.google.inject.Inject;
+import com.google.inject.ImplementedBy;
 
 /**
- * Updates WebOfTrust identity data in a background thread because communicating
- * with the WebOfTrust plugin can potentially last quite long.
+ * Updates WebOfTrust identity data.
  *
  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
  */
-public class WebOfTrustUpdater extends AbstractService {
-
-       /** The logger. */
-       private static final Logger logger = Logging.getLogger(WebOfTrustUpdater.class);
-
-       /** Stop job. */
-       @SuppressWarnings("synthetic-access")
-       private final WebOfTrustUpdateJob stopJob = new WebOfTrustUpdateJob();
-
-       /** The web of trust connector. */
-       private final WebOfTrustConnector webOfTrustConnector;
-
-       /** The queue for jobs. */
-       private final BlockingQueue<WebOfTrustUpdateJob> updateJobs = new LinkedBlockingQueue<WebOfTrustUpdateJob>();
-
-       /**
-        * Creates a new trust updater.
-        *
-        * @param webOfTrustConnector
-        *              The web of trust connector
-        */
-       @Inject
-       public WebOfTrustUpdater(WebOfTrustConnector webOfTrustConnector) {
-               super("Trust Updater");
-               this.webOfTrustConnector = webOfTrustConnector;
-       }
-
-       //
-       // ACTIONS
-       //
-
-       /**
-        * Updates the trust relation between the truster and the trustee. This method
-        * will return immediately and perform a trust update in the background.
-        *
-        * @param truster
-        *              The identity giving the trust
-        * @param trustee
-        *              The identity receiving the trust
-        * @param score
-        *              The new level of trust (from -100 to 100, may be {@code null} to remove
-        *              the trust completely)
-        * @param comment
-        *              The comment of the trust relation
-        */
-       public void setTrust(OwnIdentity truster, Identity trustee, Integer score, String comment) {
-               SetTrustJob setTrustJob = new SetTrustJob(truster, trustee, score, comment);
-               if (updateJobs.contains(setTrustJob)) {
-                       updateJobs.remove(setTrustJob);
-               }
-               logger.log(Level.FINER, "Adding Trust Update Job: " + setTrustJob);
-               try {
-                       updateJobs.put(setTrustJob);
-               } catch (InterruptedException e) {
-                       /* the queue is unbounded so it should never block. */
-               }
-       }
-
-       /**
-        * Adds the given context to the given own identity.
-        *
-        * @param ownIdentity
-        *              The own identity to add the context to
-        * @param context
-        *              The context to add
-        */
-       public void addContext(OwnIdentity ownIdentity, String context) {
-               addContextWait(ownIdentity, context, false);
-       }
-
-       /**
-        * Adds the given context to the given own identity, waiting for completion of
-        * the operation.
-        *
-        * @param ownIdentity
-        *              The own identity to add the context to
-        * @param context
-        *              The context to add
-        * @return {@code true} if the context was added successfully, {@code false}
-        *         otherwise
-        */
-       public boolean addContextWait(OwnIdentity ownIdentity, String context) {
-               return addContextWait(ownIdentity, context, true);
-       }
-
-       /**
-        * Adds the given context to the given own identity, waiting for completion of
-        * the operation.
-        *
-        * @param ownIdentity
-        *              The own identity to add the context to
-        * @param context
-        *              The context to add
-        * @param wait
-        *              {@code true} to wait for the end of the operation, {@code false} to return
-        *              immediately
-        * @return {@code true} if the context was added successfully, {@code false} if
-        *         the context was not added successfully, or if the job should not
-        *         wait for completion
-        */
-       private boolean addContextWait(OwnIdentity ownIdentity, String context, boolean wait) {
-               AddContextJob addContextJob = new AddContextJob(ownIdentity, context);
-               if (!updateJobs.contains(addContextJob)) {
-                       logger.log(Level.FINER, "Adding Context Job: " + addContextJob);
-                       try {
-                               updateJobs.put(addContextJob);
-                       } catch (InterruptedException ie1) {
-                               /* the queue is unbounded so it should never block. */
-                       }
-                       if (wait) {
-                               return addContextJob.waitForCompletion();
-                       }
-               } else if (wait) {
-                       for (WebOfTrustUpdateJob updateJob : updateJobs) {
-                               if (updateJob.equals(addContextJob)) {
-                                       return updateJob.waitForCompletion();
-                               }
-                       }
-               }
-               return false;
-       }
-
-       /**
-        * Removes the given context from the given own identity.
-        *
-        * @param ownIdentity
-        *              The own identity to remove the context from
-        * @param context
-        *              The context to remove
-        */
-       public void removeContext(OwnIdentity ownIdentity, String context) {
-               RemoveContextJob removeContextJob = new RemoveContextJob(ownIdentity, context);
-               if (!updateJobs.contains(removeContextJob)) {
-                       logger.log(Level.FINER, "Adding Context Job: " + removeContextJob);
-                       try {
-                               updateJobs.put(removeContextJob);
-                       } catch (InterruptedException ie1) {
-                               /* the queue is unbounded so it should never block. */
-                       }
-               }
-       }
-
-       /**
-        * Sets a property on the given own identity.
-        *
-        * @param ownIdentity
-        *              The own identity to set the property on
-        * @param propertyName
-        *              The name of the property to set
-        * @param propertyValue
-        *              The value of the property to set
-        */
-       public void setProperty(OwnIdentity ownIdentity, String propertyName, String propertyValue) {
-               SetPropertyJob setPropertyJob = new SetPropertyJob(ownIdentity, propertyName, propertyValue);
-               if (updateJobs.contains(setPropertyJob)) {
-                       updateJobs.remove(setPropertyJob);
-               }
-               logger.log(Level.FINER, "Adding Property Job: " + setPropertyJob);
-               try {
-                       updateJobs.put(setPropertyJob);
-               } catch (InterruptedException e) {
-                       /* the queue is unbounded so it should never block. */
-               }
-       }
-
-       /**
-        * Removes a property from the given own identity.
-        *
-        * @param ownIdentity
-        *              The own identity to remove the property from
-        * @param propertyName
-        *              The name of the property to remove
-        */
-       public void removeProperty(OwnIdentity ownIdentity, String propertyName) {
-               setProperty(ownIdentity, propertyName, null);
-       }
-
-       //
-       // SERVICE METHODS
-       //
-
-       /** {@inheritDoc} */
-       @Override
-       protected void serviceRun() {
-               while (!shouldStop()) {
-                       try {
-                               WebOfTrustUpdateJob updateJob = updateJobs.take();
-                               if (shouldStop() || (updateJob == stopJob)) {
-                                       break;
-                               }
-                               logger.log(Level.FINE, "Running Trust Update Job: " + updateJob);
-                               long startTime = System.currentTimeMillis();
-                               updateJob.run();
-                               long endTime = System.currentTimeMillis();
-                               logger.log(Level.FINE, "Trust Update Job finished, took " + (endTime - startTime) + " ms.");
-                       } catch (InterruptedException ie1) {
-                               /* happens, ignore, loop. */
-                       }
-               }
-       }
-
-       /** {@inheritDoc} */
-       @Override
-       protected void serviceStop() {
-               try {
-                       updateJobs.put(stopJob);
-               } catch (InterruptedException ie1) {
-                       /* the queue is unbounded so it should never block. */
-               }
-       }
-
-       /**
-        * Base class for WebOfTrust update jobs.
-        *
-        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
-        */
-       private class WebOfTrustUpdateJob {
-
-               /** Object for synchronization. */
-               @SuppressWarnings("hiding")
-               private final Object syncObject = new Object();
-
-               /** Whether the job has finished. */
-               private boolean finished;
-
-               /** Whether the job was successful. */
-               private boolean success;
-
-               //
-               // ACTIONS
-               //
-
-               /**
-                * Performs the actual update operation.
-                * <p/>
-                * The implementation of this class does nothing.
-                */
-               public void run() {
-                       /* does nothing. */
-               }
-
-               /**
-                * Waits for completion of this job or stopping of the WebOfTrust updater.
-                *
-                * @return {@code true} if this job finished successfully, {@code false}
-                *         otherwise
-                * @see WebOfTrustUpdater#stop()
-                */
-               @SuppressWarnings("synthetic-access")
-               public boolean waitForCompletion() {
-                       synchronized (syncObject) {
-                               while (!finished && !shouldStop()) {
-                                       try {
-                                               syncObject.wait();
-                                       } catch (InterruptedException ie1) {
-                                               /* we’re looping, ignore. */
-                                       }
-                               }
-                               return success;
-                       }
-               }
-
-               //
-               // PROTECTED METHODS
-               //
-
-               /**
-                * Signals that this job has finished.
-                *
-                * @param success
-                *              {@code true} if this job finished successfully, {@code false} otherwise
-                */
-               protected void finish(boolean success) {
-                       synchronized (syncObject) {
-                               finished = true;
-                               this.success = success;
-                               syncObject.notifyAll();
-                       }
-               }
-
-       }
-
-       /**
-        * Update job that sets the trust relation between two identities.
-        *
-        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
-        */
-       private class SetTrustJob extends WebOfTrustUpdateJob {
-
-               /** The identity giving the trust. */
-               private final OwnIdentity truster;
-
-               /** The identity receiving the trust. */
-               private final Identity trustee;
-
-               /** The score of the relation. */
-               private final Integer score;
-
-               /** The comment of the relation. */
-               private final String comment;
-
-               /**
-                * Creates a new set trust job.
-                *
-                * @param truster
-                *              The identity giving the trust
-                * @param trustee
-                *              The identity receiving the trust
-                * @param score
-                *              The score of the trust (from -100 to 100, may be {@code null} to remote
-                *              the trust relation completely)
-                * @param comment
-                *              The comment of the trust relation
-                */
-               public SetTrustJob(OwnIdentity truster, Identity trustee, Integer score, String comment) {
-                       this.truster = truster;
-                       this.trustee = trustee;
-                       this.score = score;
-                       this.comment = comment;
-               }
-
-               /** {@inheritDoc} */
-               @Override
-               @SuppressWarnings("synthetic-access")
-               public void run() {
-                       try {
-                               if (score != null) {
-                                       if (trustee instanceof DefaultIdentity) {
-                                               ((DefaultIdentity) trustee).setTrust(truster, new Trust(score, null, 0));
-                                       }
-                                       webOfTrustConnector.setTrust(truster, trustee, score, comment);
-                               } else {
-                                       if (trustee instanceof DefaultIdentity) {
-                                               ((DefaultIdentity) trustee).setTrust(truster, null);
-                                       }
-                                       webOfTrustConnector.removeTrust(truster, trustee);
-                               }
-                               finish(true);
-                       } catch (WebOfTrustException wote1) {
-                               logger.log(Level.WARNING, "Could not set Trust value for " + truster + " -> " + trustee + " to " + score + " (" + comment + ")!", wote1);
-                               finish(false);
-                       }
-               }
-
-               //
-               // OBJECT METHODS
-               //
-
-               /** {@inheritDoc} */
-               @Override
-               public boolean equals(Object object) {
-                       if ((object == null) || !object.getClass().equals(getClass())) {
-                               return false;
-                       }
-                       SetTrustJob updateJob = (SetTrustJob) object;
-                       return ((truster == null) ? (updateJob.truster == null) : updateJob.truster.equals(truster)) && ((trustee == null) ? (updateJob.trustee == null) : updateJob.trustee.equals(trustee));
-               }
-
-               /** {@inheritDoc} */
-               @Override
-               public int hashCode() {
-                       return getClass().hashCode() ^ ((truster == null) ? 0 : truster.hashCode()) ^ ((trustee == null) ? 0 : trustee.hashCode());
-               }
-
-               /** {@inheritDoc} */
-               @Override
-               public String toString() {
-                       return String.format("%s[truster=%s,trustee=%s]", getClass().getSimpleName(), (truster == null) ? null : truster.getId(), (trustee == null) ? null : trustee.getId());
-               }
-
-       }
-
-       /**
-        * Base class for context updates of an {@link OwnIdentity}.
-        *
-        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
-        */
-       private class WebOfTrustContextUpdateJob extends WebOfTrustUpdateJob {
-
-               /** The own identity whose contexts to manage. */
-               protected final OwnIdentity ownIdentity;
-
-               /** The context to update. */
-               protected final String context;
-
-               /**
-                * Creates a new context update job.
-                *
-                * @param ownIdentity
-                *              The own identity to update
-                * @param context
-                *              The context to update
-                */
-               @SuppressWarnings("synthetic-access")
-               public WebOfTrustContextUpdateJob(OwnIdentity ownIdentity, String context) {
-                       this.ownIdentity = checkNotNull(ownIdentity, "ownIdentity must not be null");
-                       this.context = checkNotNull(context, "context must not be null");
-               }
-
-               //
-               // OBJECT METHODS
-               //
-
-               /** {@inheritDoc} */
-               @Override
-               public boolean equals(Object object) {
-                       if ((object == null) || !object.getClass().equals(getClass())) {
-                               return false;
-                       }
-                       WebOfTrustContextUpdateJob updateJob = (WebOfTrustContextUpdateJob) object;
-                       return updateJob.ownIdentity.equals(ownIdentity) && updateJob.context.equals(context);
-               }
-
-               /** {@inheritDoc} */
-               @Override
-               public int hashCode() {
-                       return getClass().hashCode() ^ ownIdentity.hashCode() ^ context.hashCode();
-               }
-
-               /** {@inheritDoc} */
-               @Override
-               public String toString() {
-                       return String.format("%s[ownIdentity=%s,context=%s]", getClass().getSimpleName(), ownIdentity, context);
-               }
-
-       }
-
-       /**
-        * Job that adds a context to an {@link OwnIdentity}.
-        *
-        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
-        */
-       private class AddContextJob extends WebOfTrustContextUpdateJob {
-
-               /**
-                * Creates a new add-context job.
-                *
-                * @param ownIdentity
-                *              The own identity whose contexts to manage
-                * @param context
-                *              The context to add
-                */
-               public AddContextJob(OwnIdentity ownIdentity, String context) {
-                       super(ownIdentity, context);
-               }
-
-               /** {@inheritDoc} */
-               @Override
-               @SuppressWarnings("synthetic-access")
-               public void run() {
-                       try {
-                               webOfTrustConnector.addContext(ownIdentity, context);
-                               ownIdentity.addContext(context);
-                               finish(true);
-                       } catch (PluginException pe1) {
-                               logger.log(Level.WARNING, String.format("Could not add Context “%2$s” to Own Identity %1$s!", ownIdentity, context), pe1);
-                               finish(false);
-                       }
-               }
-
-       }
-
-       /**
-        * Job that removes a context from an {@link OwnIdentity}.
-        *
-        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
-        */
-       private class RemoveContextJob extends WebOfTrustContextUpdateJob {
-
-               /**
-                * Creates a new remove-context job.
-                *
-                * @param ownIdentity
-                *              The own identity whose contexts to manage
-                * @param context
-                *              The context to remove
-                */
-               public RemoveContextJob(OwnIdentity ownIdentity, String context) {
-                       super(ownIdentity, context);
-               }
-
-               /** {@inheritDoc} */
-               @Override
-               @SuppressWarnings("synthetic-access")
-               public void run() {
-                       try {
-                               webOfTrustConnector.removeContext(ownIdentity, context);
-                               ownIdentity.removeContext(context);
-                               finish(true);
-                       } catch (PluginException pe1) {
-                               logger.log(Level.WARNING, String.format("Could not remove Context “%2$s” to Own Identity %1$s!", ownIdentity, context), pe1);
-                               finish(false);
-                       }
-               }
-
-       }
-
-       /**
-        * WebOfTrust update job that sets a property on an {@link OwnIdentity}.
-        *
-        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
-        */
-       private class SetPropertyJob extends WebOfTrustUpdateJob {
-
-               /** The own identity to update properties on. */
-               private final OwnIdentity ownIdentity;
-
-               /** The name of the property to update. */
-               private final String propertyName;
-
-               /** The value of the property to set. */
-               private final String propertyValue;
-
-               /**
-                * Creates a new set-property job.
-                *
-                * @param ownIdentity
-                *              The own identity to set the property on
-                * @param propertyName
-                *              The name of the property to set
-                * @param propertyValue
-                *              The value of the property to set
-                */
-               public SetPropertyJob(OwnIdentity ownIdentity, String propertyName, String propertyValue) {
-                       this.ownIdentity = ownIdentity;
-                       this.propertyName = propertyName;
-                       this.propertyValue = propertyValue;
-               }
-
-               /** {@inheritDoc} */
-               @Override
-               @SuppressWarnings("synthetic-access")
-               public void run() {
-                       try {
-                               if (propertyValue == null) {
-                                       webOfTrustConnector.removeProperty(ownIdentity, propertyName);
-                                       ownIdentity.removeProperty(propertyName);
-                               } else {
-                                       webOfTrustConnector.setProperty(ownIdentity, propertyName, propertyValue);
-                                       ownIdentity.setProperty(propertyName, propertyValue);
-                               }
-                               finish(true);
-                       } catch (PluginException pe1) {
-                               logger.log(Level.WARNING, String.format("Could not set Property “%2$s” to “%3$s” on Own Identity %1$s!", ownIdentity, propertyName, propertyValue), pe1);
-                               finish(false);
-                       }
-               }
-
-               //
-               // OBJECT METHODS
-               //
-
-               /** {@inheritDoc} */
-               @Override
-               public boolean equals(Object object) {
-                       if ((object == null) || !object.getClass().equals(getClass())) {
-                               return false;
-                       }
-                       SetPropertyJob updateJob = (SetPropertyJob) object;
-                       return updateJob.ownIdentity.equals(ownIdentity) && updateJob.propertyName.equals(propertyName);
-               }
-
-               /** {@inheritDoc} */
-               @Override
-               public int hashCode() {
-                       return getClass().hashCode() ^ ownIdentity.hashCode() ^ propertyName.hashCode();
-               }
-
-               /** {@inheritDoc} */
-               @Override
-               public String toString() {
-                       return String.format("%s[ownIdentity=%s,propertyName=%s]", getClass().getSimpleName(), ownIdentity, propertyName);
-               }
-
-       }
+@ImplementedBy(WebOfTrustUpdaterImpl.class)
+public interface WebOfTrustUpdater extends Service {
+
+       void setTrust(OwnIdentity truster, Identity trustee, Integer score, String comment);
+       boolean addContextWait(OwnIdentity ownIdentity, String context);
+       void removeContext(OwnIdentity ownIdentity, String context);
+       void setProperty(OwnIdentity ownIdentity, String propertyName, String propertyValue);
+       void removeProperty(OwnIdentity ownIdentity, String propertyName);
 
 }
diff --git a/src/main/java/net/pterodactylus/sone/core/WebOfTrustUpdaterImpl.java b/src/main/java/net/pterodactylus/sone/core/WebOfTrustUpdaterImpl.java
new file mode 100644 (file)
index 0000000..56bd8c9
--- /dev/null
@@ -0,0 +1,598 @@
+/*
+ * Sone - WebOfTrustUpdater.java - Copyright © 2013 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone.core;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.logging.Logger.getLogger;
+
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import net.pterodactylus.sone.freenet.plugin.PluginException;
+import net.pterodactylus.sone.freenet.wot.Identity;
+import net.pterodactylus.sone.freenet.wot.OwnIdentity;
+import net.pterodactylus.sone.freenet.wot.Trust;
+import net.pterodactylus.sone.freenet.wot.WebOfTrustConnector;
+import net.pterodactylus.sone.freenet.wot.WebOfTrustException;
+import net.pterodactylus.util.service.AbstractService;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/**
+ * Updates WebOfTrust identity data in a background thread because communicating
+ * with the WebOfTrust plugin can potentially last quite long.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+@Singleton
+public class WebOfTrustUpdaterImpl extends AbstractService implements WebOfTrustUpdater {
+
+       /** The logger. */
+       private static final Logger logger = getLogger("Sone.WoT.Updater");
+
+       /** Stop job. */
+       @SuppressWarnings("synthetic-access")
+       private final WebOfTrustUpdateJob stopJob = new WebOfTrustUpdateJob();
+
+       /** The web of trust connector. */
+       private final WebOfTrustConnector webOfTrustConnector;
+
+       /** The queue for jobs. */
+       private final BlockingQueue<WebOfTrustUpdateJob> updateJobs = new LinkedBlockingQueue<WebOfTrustUpdateJob>();
+
+       /**
+        * Creates a new trust updater.
+        *
+        * @param webOfTrustConnector
+        *              The web of trust connector
+        */
+       @Inject
+       public WebOfTrustUpdaterImpl(WebOfTrustConnector webOfTrustConnector) {
+               super("Trust Updater");
+               this.webOfTrustConnector = webOfTrustConnector;
+       }
+
+       //
+       // ACTIONS
+       //
+
+       /**
+        * Updates the trust relation between the truster and the trustee. This method
+        * will return immediately and perform a trust update in the background.
+        *
+        * @param truster
+        *              The identity giving the trust
+        * @param trustee
+        *              The identity receiving the trust
+        * @param score
+        *              The new level of trust (from -100 to 100, may be {@code null} to remove
+        *              the trust completely)
+        * @param comment
+        *              The comment of the trust relation
+        */
+       @Override
+       public void setTrust(OwnIdentity truster, Identity trustee, Integer score, String comment) {
+               SetTrustJob setTrustJob = new SetTrustJob(truster, trustee, score, comment);
+               if (updateJobs.contains(setTrustJob)) {
+                       updateJobs.remove(setTrustJob);
+               }
+               logger.log(Level.FINER, "Adding Trust Update Job: " + setTrustJob);
+               try {
+                       updateJobs.put(setTrustJob);
+               } catch (InterruptedException e) {
+                       /* the queue is unbounded so it should never block. */
+               }
+       }
+
+       /**
+        * Adds the given context to the given own identity, waiting for completion of
+        * the operation.
+        *
+        * @param ownIdentity
+        *              The own identity to add the context to
+        * @param context
+        *              The context to add
+        * @return {@code true} if the context was added successfully, {@code false}
+        *         otherwise
+        */
+       @Override
+       public boolean addContextWait(OwnIdentity ownIdentity, String context) {
+               AddContextJob addContextJob = new AddContextJob(ownIdentity, context);
+               if (!updateJobs.contains(addContextJob)) {
+                       logger.log(Level.FINER, "Adding Context Job: " + addContextJob);
+                       try {
+                               updateJobs.put(addContextJob);
+                       } catch (InterruptedException ie1) {
+                               /* the queue is unbounded so it should never block. */
+                       }
+                       return addContextJob.waitForCompletion();
+               } else {
+                       for (WebOfTrustUpdateJob updateJob : updateJobs) {
+                               if (updateJob.equals(addContextJob)) {
+                                       return updateJob.waitForCompletion();
+                               }
+                       }
+               }
+               return false;
+       }
+
+       /**
+        * Removes the given context from the given own identity.
+        *
+        * @param ownIdentity
+        *              The own identity to remove the context from
+        * @param context
+        *              The context to remove
+        */
+       @Override
+       public void removeContext(OwnIdentity ownIdentity, String context) {
+               RemoveContextJob removeContextJob = new RemoveContextJob(ownIdentity, context);
+               if (!updateJobs.contains(removeContextJob)) {
+                       logger.log(Level.FINER, "Adding Context Job: " + removeContextJob);
+                       try {
+                               updateJobs.put(removeContextJob);
+                       } catch (InterruptedException ie1) {
+                               /* the queue is unbounded so it should never block. */
+                       }
+               }
+       }
+
+       /**
+        * Sets a property on the given own identity.
+        *
+        * @param ownIdentity
+        *              The own identity to set the property on
+        * @param propertyName
+        *              The name of the property to set
+        * @param propertyValue
+        *              The value of the property to set
+        */
+       @Override
+       public void setProperty(OwnIdentity ownIdentity, String propertyName, String propertyValue) {
+               SetPropertyJob setPropertyJob = new SetPropertyJob(ownIdentity, propertyName, propertyValue);
+               if (updateJobs.contains(setPropertyJob)) {
+                       updateJobs.remove(setPropertyJob);
+               }
+               logger.log(Level.FINER, "Adding Property Job: " + setPropertyJob);
+               try {
+                       updateJobs.put(setPropertyJob);
+               } catch (InterruptedException e) {
+                       /* the queue is unbounded so it should never block. */
+               }
+       }
+
+       /**
+        * Removes a property from the given own identity.
+        *
+        * @param ownIdentity
+        *              The own identity to remove the property from
+        * @param propertyName
+        *              The name of the property to remove
+        */
+       @Override
+       public void removeProperty(OwnIdentity ownIdentity, String propertyName) {
+               setProperty(ownIdentity, propertyName, null);
+       }
+
+       //
+       // SERVICE METHODS
+       //
+
+       /** {@inheritDoc} */
+       @Override
+       protected void serviceRun() {
+               while (!shouldStop()) {
+                       try {
+                               WebOfTrustUpdateJob updateJob = updateJobs.take();
+                               if (shouldStop()) {
+                                       break;
+                               }
+                               logger.log(Level.FINE, "Running Trust Update Job: " + updateJob);
+                               long startTime = System.currentTimeMillis();
+                               updateJob.run();
+                               long endTime = System.currentTimeMillis();
+                               logger.log(Level.FINE, "Trust Update Job finished, took " + (endTime - startTime) + " ms.");
+                       } catch (InterruptedException ie1) {
+                               /* happens, ignore, loop. */
+                       }
+               }
+       }
+
+       /** {@inheritDoc} */
+       @Override
+       protected void serviceStop() {
+               try {
+                       updateJobs.put(stopJob);
+               } catch (InterruptedException ie1) {
+                       /* the queue is unbounded so it should never block. */
+               }
+       }
+
+       /**
+        * Base class for WebOfTrust update jobs.
+        *
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       @VisibleForTesting
+       class WebOfTrustUpdateJob implements Runnable {
+
+               /** Object for synchronization. */
+               @SuppressWarnings("hiding")
+               private final Object syncObject = new Object();
+
+               /** Whether the job has finished. */
+               private boolean finished;
+
+               /** Whether the job was successful. */
+               private boolean success;
+
+               //
+               // ACTIONS
+               //
+
+               /**
+                * Performs the actual update operation.
+                * <p/>
+                * The implementation of this class does nothing.
+                */
+               @Override
+               public void run() {
+                       /* does nothing. */
+               }
+
+               /**
+                * Waits for completion of this job or stopping of the WebOfTrust updater.
+                *
+                * @return {@code true} if this job finished successfully, {@code false}
+                *         otherwise
+                * @see WebOfTrustUpdaterImpl#stop()
+                */
+               @SuppressWarnings("synthetic-access")
+               public boolean waitForCompletion() {
+                       synchronized (syncObject) {
+                               while (!finished && !shouldStop()) {
+                                       try {
+                                               syncObject.wait();
+                                       } catch (InterruptedException ie1) {
+                                               /* we’re looping, ignore. */
+                                       }
+                               }
+                               return success;
+                       }
+               }
+
+               //
+               // PROTECTED METHODS
+               //
+
+               /**
+                * Signals that this job has finished.
+                *
+                * @param success
+                *              {@code true} if this job finished successfully, {@code false} otherwise
+                */
+               protected void finish(boolean success) {
+                       synchronized (syncObject) {
+                               finished = true;
+                               this.success = success;
+                               syncObject.notifyAll();
+                       }
+               }
+
+       }
+
+       /**
+        * Update job that sets the trust relation between two identities.
+        *
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       @VisibleForTesting
+       class SetTrustJob extends WebOfTrustUpdateJob {
+
+               /** The identity giving the trust. */
+               private final OwnIdentity truster;
+
+               /** The identity receiving the trust. */
+               private final Identity trustee;
+
+               /** The score of the relation. */
+               private final Integer score;
+
+               /** The comment of the relation. */
+               private final String comment;
+
+               /**
+                * Creates a new set trust job.
+                *
+                * @param truster
+                *              The identity giving the trust
+                * @param trustee
+                *              The identity receiving the trust
+                * @param score
+                *              The score of the trust (from -100 to 100, may be {@code null} to remote
+                *              the trust relation completely)
+                * @param comment
+                *              The comment of the trust relation
+                */
+               public SetTrustJob(OwnIdentity truster, Identity trustee, Integer score, String comment) {
+                       this.truster = checkNotNull(truster, "truster must not be null");
+                       this.trustee = checkNotNull(trustee, "trustee must not be null");
+                       this.score = score;
+                       this.comment = comment;
+               }
+
+               /** {@inheritDoc} */
+               @Override
+               @SuppressWarnings("synthetic-access")
+               public void run() {
+                       try {
+                               if (score != null) {
+                                       webOfTrustConnector.setTrust(truster, trustee, score, comment);
+                                       trustee.setTrust(truster, new Trust(score, null, 0));
+                               } else {
+                                       webOfTrustConnector.removeTrust(truster, trustee);
+                                       trustee.removeTrust(truster);
+                               }
+                               finish(true);
+                       } catch (WebOfTrustException wote1) {
+                               logger.log(Level.WARNING, "Could not set Trust value for " + truster + " -> " + trustee + " to " + score + " (" + comment + ")!", wote1);
+                               finish(false);
+                       }
+               }
+
+               //
+               // OBJECT METHODS
+               //
+
+               /** {@inheritDoc} */
+               @Override
+               public boolean equals(Object object) {
+                       if ((object == null) || !object.getClass().equals(getClass())) {
+                               return false;
+                       }
+                       SetTrustJob updateJob = (SetTrustJob) object;
+                       return updateJob.truster.equals(truster) && updateJob.trustee.equals(trustee);
+               }
+
+               /** {@inheritDoc} */
+               @Override
+               public int hashCode() {
+                       return getClass().hashCode() ^ truster.hashCode() ^ trustee.hashCode();
+               }
+
+               /** {@inheritDoc} */
+               @Override
+               public String toString() {
+                       return String.format("%s[truster=%s,trustee=%s]", getClass().getSimpleName(), truster.getId(), trustee.getId());
+               }
+
+       }
+
+       /**
+        * Base class for context updates of an {@link OwnIdentity}.
+        *
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       @VisibleForTesting
+       class WebOfTrustContextUpdateJob extends WebOfTrustUpdateJob {
+
+               /** The own identity whose contexts to manage. */
+               protected final OwnIdentity ownIdentity;
+
+               /** The context to update. */
+               protected final String context;
+
+               /**
+                * Creates a new context update job.
+                *
+                * @param ownIdentity
+                *              The own identity to update
+                * @param context
+                *              The context to update
+                */
+               @SuppressWarnings("synthetic-access")
+               public WebOfTrustContextUpdateJob(OwnIdentity ownIdentity, String context) {
+                       this.ownIdentity = checkNotNull(ownIdentity, "ownIdentity must not be null");
+                       this.context = checkNotNull(context, "context must not be null");
+               }
+
+               //
+               // OBJECT METHODS
+               //
+
+               /** {@inheritDoc} */
+               @Override
+               public boolean equals(Object object) {
+                       if ((object == null) || !object.getClass().equals(getClass())) {
+                               return false;
+                       }
+                       WebOfTrustContextUpdateJob updateJob = (WebOfTrustContextUpdateJob) object;
+                       return updateJob.ownIdentity.equals(ownIdentity) && updateJob.context.equals(context);
+               }
+
+               /** {@inheritDoc} */
+               @Override
+               public int hashCode() {
+                       return getClass().hashCode() ^ ownIdentity.hashCode() ^ context.hashCode();
+               }
+
+               /** {@inheritDoc} */
+               @Override
+               public String toString() {
+                       return String.format("%s[ownIdentity=%s,context=%s]", getClass().getSimpleName(), ownIdentity, context);
+               }
+
+       }
+
+       /**
+        * Job that adds a context to an {@link OwnIdentity}.
+        *
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       @VisibleForTesting
+       class AddContextJob extends WebOfTrustContextUpdateJob {
+
+               /**
+                * Creates a new add-context job.
+                *
+                * @param ownIdentity
+                *              The own identity whose contexts to manage
+                * @param context
+                *              The context to add
+                */
+               public AddContextJob(OwnIdentity ownIdentity, String context) {
+                       super(ownIdentity, context);
+               }
+
+               /** {@inheritDoc} */
+               @Override
+               @SuppressWarnings("synthetic-access")
+               public void run() {
+                       try {
+                               webOfTrustConnector.addContext(ownIdentity, context);
+                               ownIdentity.addContext(context);
+                               finish(true);
+                       } catch (PluginException pe1) {
+                               logger.log(Level.WARNING, String.format("Could not add Context “%2$s” to Own Identity %1$s!", ownIdentity, context), pe1);
+                               finish(false);
+                       }
+               }
+
+       }
+
+       /**
+        * Job that removes a context from an {@link OwnIdentity}.
+        *
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       @VisibleForTesting
+       class RemoveContextJob extends WebOfTrustContextUpdateJob {
+
+               /**
+                * Creates a new remove-context job.
+                *
+                * @param ownIdentity
+                *              The own identity whose contexts to manage
+                * @param context
+                *              The context to remove
+                */
+               public RemoveContextJob(OwnIdentity ownIdentity, String context) {
+                       super(ownIdentity, context);
+               }
+
+               /** {@inheritDoc} */
+               @Override
+               @SuppressWarnings("synthetic-access")
+               public void run() {
+                       try {
+                               webOfTrustConnector.removeContext(ownIdentity, context);
+                               ownIdentity.removeContext(context);
+                               finish(true);
+                       } catch (PluginException pe1) {
+                               logger.log(Level.WARNING, String.format("Could not remove Context “%2$s” to Own Identity %1$s!", ownIdentity, context), pe1);
+                               finish(false);
+                       }
+               }
+
+       }
+
+       /**
+        * WebOfTrust update job that sets a property on an {@link OwnIdentity}.
+        *
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       @VisibleForTesting
+       class SetPropertyJob extends WebOfTrustUpdateJob {
+
+               /** The own identity to update properties on. */
+               private final OwnIdentity ownIdentity;
+
+               /** The name of the property to update. */
+               private final String propertyName;
+
+               /** The value of the property to set. */
+               private final String propertyValue;
+
+               /**
+                * Creates a new set-property job.
+                *
+                * @param ownIdentity
+                *              The own identity to set the property on
+                * @param propertyName
+                *              The name of the property to set
+                * @param propertyValue
+                *              The value of the property to set
+                */
+               public SetPropertyJob(OwnIdentity ownIdentity, String propertyName, String propertyValue) {
+                       this.ownIdentity = ownIdentity;
+                       this.propertyName = propertyName;
+                       this.propertyValue = propertyValue;
+               }
+
+               /** {@inheritDoc} */
+               @Override
+               @SuppressWarnings("synthetic-access")
+               public void run() {
+                       try {
+                               if (propertyValue == null) {
+                                       webOfTrustConnector.removeProperty(ownIdentity, propertyName);
+                                       ownIdentity.removeProperty(propertyName);
+                               } else {
+                                       webOfTrustConnector.setProperty(ownIdentity, propertyName, propertyValue);
+                                       ownIdentity.setProperty(propertyName, propertyValue);
+                               }
+                               finish(true);
+                       } catch (PluginException pe1) {
+                               logger.log(Level.WARNING, String.format("Could not set Property “%2$s” to “%3$s” on Own Identity %1$s!", ownIdentity, propertyName, propertyValue), pe1);
+                               finish(false);
+                       }
+               }
+
+               //
+               // OBJECT METHODS
+               //
+
+               /** {@inheritDoc} */
+               @Override
+               public boolean equals(Object object) {
+                       if ((object == null) || !object.getClass().equals(getClass())) {
+                               return false;
+                       }
+                       SetPropertyJob updateJob = (SetPropertyJob) object;
+                       return updateJob.ownIdentity.equals(ownIdentity) && updateJob.propertyName.equals(propertyName);
+               }
+
+               /** {@inheritDoc} */
+               @Override
+               public int hashCode() {
+                       return getClass().hashCode() ^ ownIdentity.hashCode() ^ propertyName.hashCode();
+               }
+
+               /** {@inheritDoc} */
+               @Override
+               public String toString() {
+                       return String.format("%s[ownIdentity=%s,propertyName=%s]", getClass().getSimpleName(), ownIdentity, propertyName);
+               }
+
+       }
+
+}
diff --git a/src/main/java/net/pterodactylus/sone/core/event/InsertionDelayChangedEvent.java b/src/main/java/net/pterodactylus/sone/core/event/InsertionDelayChangedEvent.java
new file mode 100644 (file)
index 0000000..a3dc2ce
--- /dev/null
@@ -0,0 +1,23 @@
+package net.pterodactylus.sone.core.event;
+
+import com.google.common.eventbus.EventBus;
+
+/**
+ * Notifies interested {@link EventBus} clients that the Sone insertion delay
+ * has changed.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class InsertionDelayChangedEvent {
+
+       private final int insertionDelay;
+
+       public InsertionDelayChangedEvent(int insertionDelay) {
+               this.insertionDelay = insertionDelay;
+       }
+
+       public int getInsertionDelay() {
+               return insertionDelay;
+       }
+
+}
index 8a4280d..936a134 100644 (file)
@@ -26,33 +26,21 @@ import net.pterodactylus.sone.data.Sone;
  */
 public class SoneInsertedEvent extends SoneEvent {
 
-       /** The duration of the insert. */
        private final long insertDuration;
+       private final String insertFingerprint;
 
-       /**
-        * Creates a new “Sone was inserted” event.
-        *
-        * @param sone
-        *            The Sone that was inserted
-        * @param insertDuration
-        *            The duration of the insert (in milliseconds)
-        */
-       public SoneInsertedEvent(Sone sone, long insertDuration) {
+       public SoneInsertedEvent(Sone sone, long insertDuration, String insertFingerprint) {
                super(sone);
                this.insertDuration = insertDuration;
+               this.insertFingerprint = insertFingerprint;
        }
 
-       //
-       // ACCESSORS
-       //
-
-       /**
-        * Returns the duration of the insert.
-        *
-        * @return The duration of the insert (in milliseconds)
-        */
        public long insertDuration() {
                return insertDuration;
        }
 
+       public String insertFingerprint() {
+               return insertFingerprint;
+       }
+
 }
index 09a7da4..c75088f 100644 (file)
@@ -116,16 +116,6 @@ public interface Album extends Identified, Fingerprintable {
        Sone getSone();
 
        /**
-        * Sets the owner of the album. The owner can only be set as long as the
-        * current owner is {@code null}.
-        *
-        * @param sone
-        *              The album owner
-        * @return This album
-        */
-       Album setSone(Sone sone);
-
-       /**
         * Returns the nested albums.
         *
         * @return The nested albums
@@ -302,6 +292,8 @@ public interface Album extends Identified, Fingerprintable {
 
                Album update() throws IllegalStateException;
 
+               class AlbumTitleMustNotBeEmpty extends IllegalStateException { }
+
        }
 
 }
diff --git a/src/main/java/net/pterodactylus/sone/data/AlbumImpl.java b/src/main/java/net/pterodactylus/sone/data/AlbumImpl.java
deleted file mode 100644 (file)
index f489b0b..0000000
+++ /dev/null
@@ -1,380 +0,0 @@
-/*
- * Sone - Album.java - Copyright © 2011–2013 David Roden
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package net.pterodactylus.sone.data;
-
-import static com.google.common.base.Optional.absent;
-import static com.google.common.base.Optional.fromNullable;
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.UUID;
-
-import com.google.common.base.Function;
-import com.google.common.base.Optional;
-import com.google.common.base.Predicates;
-import com.google.common.collect.Collections2;
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
-
-/**
- * Container for images that can also contain nested {@link AlbumImpl}s.
- *
- * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
- */
-public class AlbumImpl implements Album {
-
-       /** The ID of this album. */
-       private final String id;
-
-       /** The Sone this album belongs to. */
-       private Sone sone;
-
-       /** Nested albums. */
-       private final List<Album> albums = new ArrayList<Album>();
-
-       /** The image IDs in order. */
-       private final List<String> imageIds = new ArrayList<String>();
-
-       /** The images in this album. */
-       private final Map<String, Image> images = new HashMap<String, Image>();
-
-       /** The parent album. */
-       private Album parent;
-
-       /** The title of this album. */
-       private String title;
-
-       /** The description of this album. */
-       private String description;
-
-       /** The ID of the album picture. */
-       private String albumImage;
-
-       /** Creates a new album with a random ID. */
-       public AlbumImpl() {
-               this(UUID.randomUUID().toString());
-       }
-
-       /**
-        * Creates a new album with the given ID.
-        *
-        * @param id
-        *              The ID of the album
-        */
-       public AlbumImpl(String id) {
-               this.id = checkNotNull(id, "id must not be null");
-       }
-
-       //
-       // ACCESSORS
-       //
-
-       @Override
-       public String getId() {
-               return id;
-       }
-
-       @Override
-       public Sone getSone() {
-               return sone;
-       }
-
-       @Override
-       public Album setSone(Sone sone) {
-               checkNotNull(sone, "sone must not be null");
-               checkState((this.sone == null) || (this.sone.equals(sone)), "album owner must not already be set to some other Sone");
-               this.sone = sone;
-               return this;
-       }
-
-       @Override
-       public List<Album> getAlbums() {
-               return new ArrayList<Album>(albums);
-       }
-
-       @Override
-       public void addAlbum(Album album) {
-               checkNotNull(album, "album must not be null");
-               checkArgument(album.getSone().equals(sone), "album must belong to the same Sone as this album");
-               album.setParent(this);
-               if (!albums.contains(album)) {
-                       albums.add(album);
-               }
-       }
-
-       @Override
-       public void removeAlbum(Album album) {
-               checkNotNull(album, "album must not be null");
-               checkArgument(album.getSone().equals(sone), "album must belong this album’s Sone");
-               checkArgument(equals(album.getParent()), "album must belong to this album");
-               albums.remove(album);
-               album.removeParent();
-       }
-
-       @Override
-       public Album moveAlbumUp(Album album) {
-               checkNotNull(album, "album must not be null");
-               checkArgument(album.getSone().equals(sone), "album must belong to the same Sone as this album");
-               checkArgument(equals(album.getParent()), "album must belong to this album");
-               int oldIndex = albums.indexOf(album);
-               if (oldIndex <= 0) {
-                       return null;
-               }
-               albums.remove(oldIndex);
-               albums.add(oldIndex - 1, album);
-               return albums.get(oldIndex);
-       }
-
-       @Override
-       public Album moveAlbumDown(Album album) {
-               checkNotNull(album, "album must not be null");
-               checkArgument(album.getSone().equals(sone), "album must belong to the same Sone as this album");
-               checkArgument(equals(album.getParent()), "album must belong to this album");
-               int oldIndex = albums.indexOf(album);
-               if ((oldIndex < 0) || (oldIndex >= (albums.size() - 1))) {
-                       return null;
-               }
-               albums.remove(oldIndex);
-               albums.add(oldIndex + 1, album);
-               return albums.get(oldIndex);
-       }
-
-       @Override
-       public List<Image> getImages() {
-               return new ArrayList<Image>(Collections2.filter(Collections2.transform(imageIds, new Function<String, Image>() {
-
-                       @Override
-                       @SuppressWarnings("synthetic-access")
-                       public Image apply(String imageId) {
-                               return images.get(imageId);
-                       }
-               }), Predicates.notNull()));
-       }
-
-       @Override
-       public void addImage(Image image) {
-               checkNotNull(image, "image must not be null");
-               checkNotNull(image.getSone(), "image must have an owner");
-               checkArgument(image.getSone().equals(sone), "image must belong to the same Sone as this album");
-               if (image.getAlbum() != null) {
-                       image.getAlbum().removeImage(image);
-               }
-               image.setAlbum(this);
-               if (imageIds.isEmpty() && (albumImage == null)) {
-                       albumImage = image.getId();
-               }
-               if (!imageIds.contains(image.getId())) {
-                       imageIds.add(image.getId());
-                       images.put(image.getId(), image);
-               }
-       }
-
-       @Override
-       public void removeImage(Image image) {
-               checkNotNull(image, "image must not be null");
-               checkNotNull(image.getSone(), "image must have an owner");
-               checkArgument(image.getSone().equals(sone), "image must belong to the same Sone as this album");
-               imageIds.remove(image.getId());
-               images.remove(image.getId());
-               if (image.getId().equals(albumImage)) {
-                       if (images.isEmpty()) {
-                               albumImage = null;
-                       } else {
-                               albumImage = images.values().iterator().next().getId();
-                       }
-               }
-       }
-
-       @Override
-       public Image moveImageUp(Image image) {
-               checkNotNull(image, "image must not be null");
-               checkNotNull(image.getSone(), "image must have an owner");
-               checkArgument(image.getSone().equals(sone), "image must belong to the same Sone as this album");
-               checkArgument(image.getAlbum().equals(this), "image must belong to this album");
-               int oldIndex = imageIds.indexOf(image.getId());
-               if (oldIndex <= 0) {
-                       return null;
-               }
-               imageIds.remove(image.getId());
-               imageIds.add(oldIndex - 1, image.getId());
-               return images.get(imageIds.get(oldIndex));
-       }
-
-       @Override
-       public Image moveImageDown(Image image) {
-               checkNotNull(image, "image must not be null");
-               checkNotNull(image.getSone(), "image must have an owner");
-               checkArgument(image.getSone().equals(sone), "image must belong to the same Sone as this album");
-               checkArgument(image.getAlbum().equals(this), "image must belong to this album");
-               int oldIndex = imageIds.indexOf(image.getId());
-               if ((oldIndex == -1) || (oldIndex >= (imageIds.size() - 1))) {
-                       return null;
-               }
-               imageIds.remove(image.getId());
-               imageIds.add(oldIndex + 1, image.getId());
-               return images.get(imageIds.get(oldIndex));
-       }
-
-       @Override
-       public Image getAlbumImage() {
-               if (albumImage == null) {
-                       return null;
-               }
-               return Optional.fromNullable(images.get(albumImage)).or(images.values().iterator().next());
-       }
-
-       @Override
-       public boolean isEmpty() {
-               return albums.isEmpty() && images.isEmpty();
-       }
-
-       @Override
-       public boolean isRoot() {
-               return parent == null;
-       }
-
-       @Override
-       public Album getParent() {
-               return parent;
-       }
-
-       @Override
-       public Album setParent(Album parent) {
-               this.parent = checkNotNull(parent, "parent must not be null");
-               return this;
-       }
-
-       @Override
-       public Album removeParent() {
-               this.parent = null;
-               return this;
-       }
-
-       @Override
-       public String getTitle() {
-               return title;
-       }
-
-       @Override
-       public String getDescription() {
-               return description;
-       }
-
-       @Override
-       public Modifier modify() throws IllegalStateException {
-               // TODO: reenable check for local Sones
-               return new Modifier() {
-                       private Optional<String> title = absent();
-
-                       private Optional<String> description = absent();
-
-                       private Optional<String> albumImage = absent();
-
-                       @Override
-                       public Modifier setTitle(String title) {
-                               this.title = fromNullable(title);
-                               return this;
-                       }
-
-                       @Override
-                       public Modifier setDescription(String description) {
-                               this.description = fromNullable(description);
-                               return this;
-                       }
-
-                       @Override
-                       public Modifier setAlbumImage(String imageId) {
-                               this.albumImage = fromNullable(imageId);
-                               return this;
-                       }
-
-                       @Override
-                       public Album update() throws IllegalStateException {
-                               if (title.isPresent()) {
-                                       AlbumImpl.this.title = title.get();
-                               }
-                               if (description.isPresent()) {
-                                       AlbumImpl.this.description = description.get();
-                               }
-                               if (albumImage.isPresent()) {
-                                       AlbumImpl.this.albumImage = albumImage.get();
-                               }
-                               return AlbumImpl.this;
-                       }
-               };
-       }
-
-       //
-       // FINGERPRINTABLE METHODS
-       //
-
-       @Override
-       public String getFingerprint() {
-               Hasher hash = Hashing.sha256().newHasher();
-               hash.putString("Album(");
-               hash.putString("ID(").putString(id).putString(")");
-               hash.putString("Title(").putString(title).putString(")");
-               hash.putString("Description(").putString(description).putString(")");
-               if (albumImage != null) {
-                       hash.putString("AlbumImage(").putString(albumImage).putString(")");
-               }
-
-               /* add nested albums. */
-               hash.putString("Albums(");
-               for (Album album : albums) {
-                       hash.putString(album.getFingerprint());
-               }
-               hash.putString(")");
-
-               /* add images. */
-               hash.putString("Images(");
-               for (Image image : getImages()) {
-                       if (image.isInserted()) {
-                               hash.putString(image.getFingerprint());
-                       }
-               }
-               hash.putString(")");
-
-               hash.putString(")");
-               return hash.hash().toString();
-       }
-
-       //
-       // OBJECT METHODS
-       //
-
-       @Override
-       public int hashCode() {
-               return id.hashCode();
-       }
-
-       @Override
-       public boolean equals(Object object) {
-               if (!(object instanceof AlbumImpl)) {
-                       return false;
-               }
-               AlbumImpl album = (AlbumImpl) object;
-               return id.equals(album.id);
-       }
-
-}
index b77b9f2..ad094f2 100644 (file)
@@ -17,6 +17,8 @@
 
 package net.pterodactylus.sone.data;
 
+import static com.google.common.base.Objects.equal;
+
 /**
  * Container for the client information of a {@link Sone}.
  *
@@ -65,4 +67,13 @@ public class Client {
                return version;
        }
 
+       @Override
+       public boolean equals(Object object) {
+               if (!(object instanceof Client)) {
+                       return false;
+               }
+               Client client = (Client) object;
+               return equal(getName(), client.getName()) && equal(getVersion(), client.getVersion());
+       }
+
 }
index e3e1b4d..22ddc29 100644 (file)
@@ -134,6 +134,8 @@ public interface Image extends Identified, Fingerprintable {
 
                Image update() throws IllegalStateException;
 
+               class ImageTitleMustNotBeEmpty extends IllegalStateException { }
+
        }
 
 }
diff --git a/src/main/java/net/pterodactylus/sone/data/ImageImpl.java b/src/main/java/net/pterodactylus/sone/data/ImageImpl.java
deleted file mode 100644 (file)
index 447bb82..0000000
+++ /dev/null
@@ -1,271 +0,0 @@
-/*
- * Sone - ImageImpl.java - Copyright © 2011–2013 David Roden
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-package net.pterodactylus.sone.data;
-
-import static com.google.common.base.Optional.absent;
-import static com.google.common.base.Optional.fromNullable;
-import static com.google.common.base.Optional.of;
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-
-import java.util.UUID;
-
-import com.google.common.base.Optional;
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
-
-/**
- * Container for image metadata.
- *
- * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
- */
-public class ImageImpl implements Image {
-
-       /** The ID of the image. */
-       private final String id;
-
-       /** The Sone the image belongs to. */
-       private Sone sone;
-
-       /** The album this image belongs to. */
-       private Album album;
-
-       /** The request key of the image. */
-       private String key;
-
-       /** The creation time of the image. */
-       private long creationTime;
-
-       /** The width of the image. */
-       private int width;
-
-       /** The height of the image. */
-       private int height;
-
-       /** The title of the image. */
-       private String title;
-
-       /** The description of the image. */
-       private String description;
-
-       /** Creates a new image with a random ID. */
-       public ImageImpl() {
-               this(UUID.randomUUID().toString());
-               this.creationTime = System.currentTimeMillis();
-       }
-
-       /**
-        * Creates a new image.
-        *
-        * @param id
-        *              The ID of the image
-        */
-       public ImageImpl(String id) {
-               this.id = checkNotNull(id, "id must not be null");
-       }
-
-       //
-       // ACCESSORS
-       //
-
-       @Override
-       public String getId() {
-               return id;
-       }
-
-       @Override
-       public Sone getSone() {
-               return sone;
-       }
-
-       @Override
-       public Album getAlbum() {
-               return album;
-       }
-
-       @Override
-       public Image setAlbum(Album album) {
-               checkNotNull(album, "album must not be null");
-               checkNotNull(album.getSone().equals(getSone()), "album must belong to the same Sone as this image");
-               this.album = album;
-               return this;
-       }
-
-       @Override
-       public String getKey() {
-               return key;
-       }
-
-       @Override
-       public boolean isInserted() {
-               return key != null;
-       }
-
-       @Override
-       public long getCreationTime() {
-               return creationTime;
-       }
-
-       @Override
-       public int getWidth() {
-               return width;
-       }
-
-       @Override
-       public int getHeight() {
-               return height;
-       }
-
-       @Override
-       public String getTitle() {
-               return title;
-       }
-
-       @Override
-       public String getDescription() {
-               return description;
-       }
-
-       public Modifier modify() throws IllegalStateException {
-               // TODO: reenable check for local images
-               return new Modifier() {
-                       private Optional<Sone> sone = absent();
-
-                       private Optional<Long> creationTime = absent();
-
-                       private Optional<String> key = absent();
-
-                       private Optional<String> title = absent();
-
-                       private Optional<String> description = absent();
-
-                       private Optional<Integer> width = absent();
-
-                       private Optional<Integer> height = absent();
-
-                       @Override
-                       public Modifier setSone(Sone sone) {
-                               this.sone = fromNullable(sone);
-                               return this;
-                       }
-
-                       @Override
-                       public Modifier setCreationTime(long creationTime) {
-                               this.creationTime = of(creationTime);
-                               return this;
-                       }
-
-                       @Override
-                       public Modifier setKey(String key) {
-                               this.key = fromNullable(key);
-                               return this;
-                       }
-
-                       @Override
-                       public Modifier setTitle(String title) {
-                               this.title = fromNullable(title);
-                               return this;
-                       }
-
-                       @Override
-                       public Modifier setDescription(String description) {
-                               this.description = fromNullable(description);
-                               return this;
-                       }
-
-                       @Override
-                       public Modifier setWidth(int width) {
-                               this.width = of(width);
-                               return this;
-                       }
-
-                       @Override
-                       public Modifier setHeight(int height) {
-                               this.height = of(height);
-                               return this;
-                       }
-
-                       @Override
-                       public Image update() throws IllegalStateException {
-                               checkState(!sone.isPresent() || (ImageImpl.this.sone == null) || sone.get().equals(ImageImpl.this.sone), "can not change Sone once set");
-                               checkState(!creationTime.isPresent() || ((ImageImpl.this.creationTime == 0) || (ImageImpl.this.creationTime == creationTime.get())), "can not change creation time once set");
-                               checkState(!key.isPresent() || (ImageImpl.this.key == null) || key.get().equals(ImageImpl.this.key), "can not change key once set");
-                               checkState(!width.isPresent() || (ImageImpl.this.width == 0) || width.get().equals(ImageImpl.this.width), "can not change width once set");
-                               checkState(!height.isPresent() || (ImageImpl.this.height == 0) || height.get().equals(ImageImpl.this.height), "can not change height once set");
-
-                               if (sone.isPresent()) {
-                                       ImageImpl.this.sone = sone.get();
-                               }
-                               if (creationTime.isPresent()) {
-                                       ImageImpl.this.creationTime = creationTime.get();
-                               }
-                               if (key.isPresent()) {
-                                       ImageImpl.this.key = key.get();
-                               }
-                               if (title.isPresent()) {
-                                       ImageImpl.this.title = title.get();
-                               }
-                               if (description.isPresent()) {
-                                       ImageImpl.this.description = description.get();
-                               }
-                               if (width.isPresent()) {
-                                       ImageImpl.this.width = width.get();
-                               }
-                               if (height.isPresent()) {
-                                       ImageImpl.this.height = height.get();
-                               }
-
-                               return ImageImpl.this;
-                       }
-               };
-       }
-
-       //
-       // FINGERPRINTABLE METHODS
-       //
-
-       @Override
-       public String getFingerprint() {
-               Hasher hash = Hashing.sha256().newHasher();
-               hash.putString("Image(");
-               hash.putString("ID(").putString(id).putString(")");
-               hash.putString("Title(").putString(title).putString(")");
-               hash.putString("Description(").putString(description).putString(")");
-               hash.putString(")");
-               return hash.hash().toString();
-       }
-
-       //
-       // OBJECT METHODS
-       //
-
-       /** {@inheritDoc} */
-       @Override
-       public int hashCode() {
-               return id.hashCode();
-       }
-
-       /** {@inheritDoc} */
-       @Override
-       public boolean equals(Object object) {
-               if (!(object instanceof ImageImpl)) {
-                       return false;
-               }
-               return ((ImageImpl) object).id.equals(id);
-       }
-
-}
index 759a8ba..95abae6 100644 (file)
@@ -17,6 +17,8 @@
 
 package net.pterodactylus.sone.data;
 
+import static com.google.common.base.Optional.absent;
+
 import java.util.Comparator;
 
 import com.google.common.base.Optional;
@@ -45,7 +47,7 @@ public interface Post extends Identified {
 
                @Override
                public boolean apply(Post post) {
-                       return (post == null) ? false : post.getTime() <= System.currentTimeMillis();
+                       return (post != null) && (post.getTime() <= System.currentTimeMillis());
                }
 
        };
@@ -62,6 +64,14 @@ public interface Post extends Identified {
        public String getId();
 
        /**
+        * Returns whether this post has already been loaded.
+        *
+        * @return {@code true} if this post has already been loaded, {@code
+        * false} otherwise
+        */
+       boolean isLoaded();
+
+       /**
         * Returns the Sone this post belongs to.
         *
         * @return The Sone of this post
@@ -114,4 +124,65 @@ public interface Post extends Identified {
         */
        public Post setKnown(boolean known);
 
+       /**
+        * Shell for a post that has not yet been loaded.
+        *
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’
+        *         Roden</a>
+        */
+       public static class EmptyPost implements Post {
+
+               private final String id;
+
+               public EmptyPost(String id) {
+                       this.id = id;
+               }
+
+               @Override
+               public String getId() {
+                       return id;
+               }
+
+               @Override
+               public boolean isLoaded() {
+                       return false;
+               }
+
+               @Override
+               public Sone getSone() {
+                       return null;
+               }
+
+               @Override
+               public Optional<String> getRecipientId() {
+                       return absent();
+               }
+
+               @Override
+               public Optional<Sone> getRecipient() {
+                       return absent();
+               }
+
+               @Override
+               public long getTime() {
+                       return 0;
+               }
+
+               @Override
+               public String getText() {
+                       return null;
+               }
+
+               @Override
+               public boolean isKnown() {
+                       return false;
+               }
+
+               @Override
+               public Post setKnown(boolean known) {
+                       return this;
+               }
+
+       }
+
 }
index 4a1fbad..d010261 100644 (file)
@@ -36,7 +36,7 @@ public interface PostReply extends Reply<PostReply> {
 
                @Override
                public boolean apply(PostReply postReply) {
-                       return (postReply == null) ? false : postReply.getPost().isPresent();
+                       return (postReply != null) && postReply.getPost().isPresent();
                }
        };
 
index 5a4fde0..4970cf9 100644 (file)
@@ -325,10 +325,14 @@ public class Profile implements Fingerprintable {
         */
        public Field addField(String fieldName) throws IllegalArgumentException {
                checkNotNull(fieldName, "fieldName must not be null");
-               checkArgument(fieldName.length() > 0, "fieldName must not be empty");
-               checkState(getFieldByName(fieldName) == null, "fieldName must be unique");
+               if (fieldName.length() == 0) {
+                       throw new EmptyFieldName();
+               }
+               if (getFieldByName(fieldName) != null) {
+                       throw new DuplicateField();
+               }
                @SuppressWarnings("synthetic-access")
-               Field field = new Field().setName(fieldName);
+               Field field = new Field().setName(fieldName).setValue("");
                fields.add(field);
                return field;
        }
@@ -553,4 +557,18 @@ public class Profile implements Fingerprintable {
 
        }
 
+       /**
+        * Exception that signals the addition of a field with an empty name.
+        *
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       public static class EmptyFieldName extends IllegalArgumentException { }
+
+       /**
+        * Exception that signals the addition of a field that already exists.
+        *
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       public static class DuplicateField extends IllegalArgumentException { }
+
 }
index c229d04..e9b7a1d 100644 (file)
@@ -51,7 +51,7 @@ public interface Reply<T extends Reply<T>> extends Identified {
                 */
                @Override
                public boolean apply(Reply<?> reply) {
-                       return (reply == null) ? false : reply.getTime() <= System.currentTimeMillis();
+                       return (reply != null) && (reply.getTime() <= System.currentTimeMillis());
                }
 
        };
index d7a7dee..04c5f39 100644 (file)
@@ -23,17 +23,21 @@ import static net.pterodactylus.sone.data.Album.FLATTENER;
 import static net.pterodactylus.sone.data.Album.IMAGES;
 
 import java.util.Collection;
+import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
 import java.util.Set;
 
-import net.pterodactylus.sone.core.Options;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
 import net.pterodactylus.sone.freenet.wot.Identity;
 import net.pterodactylus.sone.freenet.wot.OwnIdentity;
 import net.pterodactylus.sone.template.SoneAccessor;
 
 import freenet.keys.FreenetURI;
 
+import com.google.common.base.Function;
 import com.google.common.base.Predicate;
 import com.google.common.primitives.Ints;
 
@@ -144,7 +148,7 @@ public interface Sone extends Identified, Fingerprintable, Comparable<Sone> {
 
                @Override
                public boolean apply(Sone sone) {
-                       return (sone == null) ? false : sone.getTime() != 0;
+                       return (sone != null) && (sone.getTime() != 0);
                }
        };
 
@@ -153,7 +157,7 @@ public interface Sone extends Identified, Fingerprintable, Comparable<Sone> {
 
                @Override
                public boolean apply(Sone sone) {
-                       return (sone == null) ? false : sone.getIdentity() instanceof OwnIdentity;
+                       return (sone != null) && (sone.getIdentity() instanceof OwnIdentity);
                }
 
        };
@@ -163,7 +167,35 @@ public interface Sone extends Identified, Fingerprintable, Comparable<Sone> {
 
                @Override
                public boolean apply(Sone sone) {
-                       return (sone == null) ? false : !sone.getRootAlbum().getAlbums().isEmpty();
+                       return (sone != null) && !sone.getRootAlbum().getAlbums().isEmpty();
+               }
+       };
+
+       public static final Function<Sone, String> toSoneXmlUri =
+                       new Function<Sone, String>() {
+                               @Nonnull
+                               @Override
+                               public String apply(@Nullable Sone input) {
+                                       return input.getRequestUri()
+                                                       .setMetaString(new String[] { "sone.xml" })
+                                                       .toString();
+                               }
+                       };
+
+       public static final Function<Sone, List<Album>> toAllAlbums = new Function<Sone, List<Album>>() {
+               @Override
+               public List<Album> apply(@Nullable Sone sone) {
+                       return (sone == null) ? Collections.<Album>emptyList() : FLATTENER.apply(
+                                       sone.getRootAlbum());
+               }
+       };
+
+       public static final Function<Sone, List<Image>> toAllImages = new Function<Sone, List<Image>>() {
+               @Override
+               public List<Image> apply(@Nullable Sone sone) {
+                       return (sone == null) ? Collections.<Image>emptyList() :
+                                       from(FLATTENER.apply(sone.getRootAlbum()))
+                                                       .transformAndConcat(IMAGES).toList();
                }
        };
 
@@ -175,18 +207,6 @@ public interface Sone extends Identified, Fingerprintable, Comparable<Sone> {
        Identity getIdentity();
 
        /**
-        * Sets the identity of this Sone. The {@link Identity#getId() ID} of the
-        * identity has to match this Sone’s {@link #getId()}.
-        *
-        * @param identity
-        *              The identity of this Sone
-        * @return This Sone (for method chaining)
-        * @throws IllegalArgumentException
-        *              if the ID of the identity does not match this Sone’s ID
-        */
-       Sone setIdentity(Identity identity) throws IllegalArgumentException;
-
-       /**
         * Returns the name of this Sone.
         *
         * @return The name of this Sone
@@ -208,15 +228,6 @@ public interface Sone extends Identified, Fingerprintable, Comparable<Sone> {
        FreenetURI getRequestUri();
 
        /**
-        * Sets the request URI of this Sone.
-        *
-        * @param requestUri
-        *              The request URI of this Sone
-        * @return This Sone (for method chaining)
-        */
-       Sone setRequestUri(FreenetURI requestUri);
-
-       /**
         * Returns the insert URI of this Sone.
         *
         * @return The insert URI of this Sone
@@ -224,15 +235,6 @@ public interface Sone extends Identified, Fingerprintable, Comparable<Sone> {
        FreenetURI getInsertUri();
 
        /**
-        * Sets the insert URI of this Sone.
-        *
-        * @param insertUri
-        *              The insert URI of this Sone
-        * @return This Sone (for method chaining)
-        */
-       Sone setInsertUri(FreenetURI insertUri);
-
-       /**
         * Returns the latest edition of this Sone.
         *
         * @return The latest edition of this Sone
@@ -339,7 +341,7 @@ public interface Sone extends Identified, Fingerprintable, Comparable<Sone> {
         *
         * @return The friend Sones of this Sone
         */
-       List<String> getFriends();
+       Collection<String> getFriends();
 
        /**
         * Returns whether this Sone has the given Sone as a friend Sone.
@@ -352,24 +354,6 @@ public interface Sone extends Identified, Fingerprintable, Comparable<Sone> {
        boolean hasFriend(String friendSoneId);
 
        /**
-        * Adds the given Sone as a friend Sone.
-        *
-        * @param friendSone
-        *              The friend Sone to add
-        * @return This Sone (for method chaining)
-        */
-       Sone addFriend(String friendSone);
-
-       /**
-        * Removes the given Sone as a friend Sone.
-        *
-        * @param friendSoneId
-        *              The ID of the friend Sone to remove
-        * @return This Sone (for method chaining)
-        */
-       Sone removeFriend(String friendSoneId);
-
-       /**
         * Returns the list of posts of this Sone, sorted by time, newest first.
         *
         * @return All posts of this Sone
@@ -535,7 +519,7 @@ public interface Sone extends Identified, Fingerprintable, Comparable<Sone> {
         *
         * @return The options of this Sone
         */
-       Options getOptions();
+       SoneOptions getOptions();
 
        /**
         * Sets the options of this Sone.
@@ -544,6 +528,6 @@ public interface Sone extends Identified, Fingerprintable, Comparable<Sone> {
         *              The options of this Sone
         */
        /* TODO - remove this method again, maybe add an option provider */
-       void setOptions(Options options);
+       void setOptions(SoneOptions options);
 
 }
diff --git a/src/main/java/net/pterodactylus/sone/data/SoneImpl.java b/src/main/java/net/pterodactylus/sone/data/SoneImpl.java
deleted file mode 100644 (file)
index 880eb95..0000000
+++ /dev/null
@@ -1,749 +0,0 @@
-/*
- * Sone - SoneImpl.java - Copyright © 2010–2013 David Roden
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package net.pterodactylus.sone.data;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.Set;
-import java.util.concurrent.CopyOnWriteArraySet;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-
-import net.pterodactylus.sone.core.Options;
-import net.pterodactylus.sone.freenet.wot.Identity;
-import net.pterodactylus.util.logging.Logging;
-
-import freenet.keys.FreenetURI;
-
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
-
-/**
- * {@link Sone} implementation.
- * <p/>
- * Operations that modify the Sone need to synchronize on the Sone in question.
- *
- * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
- */
-public class SoneImpl implements Sone {
-
-       /** The logger. */
-       private static final Logger logger = Logging.getLogger(SoneImpl.class);
-
-       /** The ID of this Sone. */
-       private final String id;
-
-       /** Whether the Sone is local. */
-       private final boolean local;
-
-       /** The identity of this Sone. */
-       private Identity identity;
-
-       /** The URI under which the Sone is stored in Freenet. */
-       private volatile FreenetURI requestUri;
-
-       /** The URI used to insert a new version of this Sone. */
-       /* This will be null for remote Sones! */
-       private volatile FreenetURI insertUri;
-
-       /** The latest edition of the Sone. */
-       private volatile long latestEdition;
-
-       /** The time of the last inserted update. */
-       private volatile long time;
-
-       /** The status of this Sone. */
-       private volatile SoneStatus status = SoneStatus.unknown;
-
-       /** The profile of this Sone. */
-       private volatile Profile profile = new Profile(this);
-
-       /** The client used by the Sone. */
-       private volatile Client client;
-
-       /** Whether this Sone is known. */
-       private volatile boolean known;
-
-       /** All friend Sones. */
-       private final Set<String> friendSones = new CopyOnWriteArraySet<String>();
-
-       /** All posts. */
-       private final Set<Post> posts = new CopyOnWriteArraySet<Post>();
-
-       /** All replies. */
-       private final Set<PostReply> replies = new CopyOnWriteArraySet<PostReply>();
-
-       /** The IDs of all liked posts. */
-       private final Set<String> likedPostIds = new CopyOnWriteArraySet<String>();
-
-       /** The IDs of all liked replies. */
-       private final Set<String> likedReplyIds = new CopyOnWriteArraySet<String>();
-
-       /** The root album containing all albums. */
-       private final Album rootAlbum = new AlbumImpl().setSone(this);
-
-       /** Sone-specific options. */
-       private Options options = new Options();
-
-       /**
-        * Creates a new Sone.
-        *
-        * @param id
-        *              The ID of the Sone
-        * @param local
-        *              {@code true} if the Sone is a local Sone, {@code false} otherwise
-        */
-       public SoneImpl(String id, boolean local) {
-               this.id = id;
-               this.local = local;
-       }
-
-       //
-       // ACCESSORS
-       //
-
-       /**
-        * Returns the identity of this Sone.
-        *
-        * @return The identity of this Sone
-        */
-       public String getId() {
-               return id;
-       }
-
-       /**
-        * Returns the identity of this Sone.
-        *
-        * @return The identity of this Sone
-        */
-       public Identity getIdentity() {
-               return identity;
-       }
-
-       /**
-        * Sets the identity of this Sone. The {@link Identity#getId() ID} of the
-        * identity has to match this Sone’s {@link #getId()}.
-        *
-        * @param identity
-        *              The identity of this Sone
-        * @return This Sone (for method chaining)
-        * @throws IllegalArgumentException
-        *              if the ID of the identity does not match this Sone’s ID
-        */
-       public SoneImpl setIdentity(Identity identity) throws IllegalArgumentException {
-               if (!identity.getId().equals(id)) {
-                       throw new IllegalArgumentException("Identity’s ID does not match Sone’s ID!");
-               }
-               this.identity = identity;
-               return this;
-       }
-
-       /**
-        * Returns the name of this Sone.
-        *
-        * @return The name of this Sone
-        */
-       public String getName() {
-               return (identity != null) ? identity.getNickname() : null;
-       }
-
-       /**
-        * Returns whether this Sone is a local Sone.
-        *
-        * @return {@code true} if this Sone is a local Sone, {@code false} otherwise
-        */
-       public boolean isLocal() {
-               return local;
-       }
-
-       /**
-        * Returns the request URI of this Sone.
-        *
-        * @return The request URI of this Sone
-        */
-       public FreenetURI getRequestUri() {
-               return (requestUri != null) ? requestUri.setSuggestedEdition(latestEdition) : null;
-       }
-
-       /**
-        * Sets the request URI of this Sone.
-        *
-        * @param requestUri
-        *              The request URI of this Sone
-        * @return This Sone (for method chaining)
-        */
-       public Sone setRequestUri(FreenetURI requestUri) {
-               if (this.requestUri == null) {
-                       this.requestUri = requestUri.setKeyType("USK").setDocName("Sone").setMetaString(new String[0]);
-                       return this;
-               }
-               if (!this.requestUri.equalsKeypair(requestUri)) {
-                       logger.log(Level.WARNING, String.format("Request URI %s tried to overwrite %s!", requestUri, this.requestUri));
-                       return this;
-               }
-               return this;
-       }
-
-       /**
-        * Returns the insert URI of this Sone.
-        *
-        * @return The insert URI of this Sone
-        */
-       public FreenetURI getInsertUri() {
-               return (insertUri != null) ? insertUri.setSuggestedEdition(latestEdition) : null;
-       }
-
-       /**
-        * Sets the insert URI of this Sone.
-        *
-        * @param insertUri
-        *              The insert URI of this Sone
-        * @return This Sone (for method chaining)
-        */
-       public Sone setInsertUri(FreenetURI insertUri) {
-               if (this.insertUri == null) {
-                       this.insertUri = insertUri.setKeyType("USK").setDocName("Sone").setMetaString(new String[0]);
-                       return this;
-               }
-               if (!this.insertUri.equalsKeypair(insertUri)) {
-                       logger.log(Level.WARNING, String.format("Request URI %s tried to overwrite %s!", insertUri, this.insertUri));
-                       return this;
-               }
-               return this;
-       }
-
-       /**
-        * Returns the latest edition of this Sone.
-        *
-        * @return The latest edition of this Sone
-        */
-       public long getLatestEdition() {
-               return latestEdition;
-       }
-
-       /**
-        * Sets the latest edition of this Sone. If the given latest edition is not
-        * greater than the current latest edition, the latest edition of this Sone is
-        * not changed.
-        *
-        * @param latestEdition
-        *              The latest edition of this Sone
-        */
-       public void setLatestEdition(long latestEdition) {
-               if (!(latestEdition > this.latestEdition)) {
-                       logger.log(Level.FINE, String.format("New latest edition %d is not greater than current latest edition %d!", latestEdition, this.latestEdition));
-                       return;
-               }
-               this.latestEdition = latestEdition;
-       }
-
-       /**
-        * Return the time of the last inserted update of this Sone.
-        *
-        * @return The time of the update (in milliseconds since Jan 1, 1970 UTC)
-        */
-       public long getTime() {
-               return time;
-       }
-
-       /**
-        * Sets the time of the last inserted update of this Sone.
-        *
-        * @param time
-        *              The time of the update (in milliseconds since Jan 1, 1970 UTC)
-        * @return This Sone (for method chaining)
-        */
-       public Sone setTime(long time) {
-               this.time = time;
-               return this;
-       }
-
-       /**
-        * Returns the status of this Sone.
-        *
-        * @return The status of this Sone
-        */
-       public SoneStatus getStatus() {
-               return status;
-       }
-
-       /**
-        * Sets the new status of this Sone.
-        *
-        * @param status
-        *              The new status of this Sone
-        * @return This Sone
-        * @throws IllegalArgumentException
-        *              if {@code status} is {@code null}
-        */
-       public Sone setStatus(SoneStatus status) {
-               this.status = checkNotNull(status, "status must not be null");
-               return this;
-       }
-
-       /**
-        * Returns a copy of the profile. If you want to update values in the profile
-        * of this Sone, update the values in the returned {@link Profile} and use
-        * {@link #setProfile(Profile)} to change the profile in this Sone.
-        *
-        * @return A copy of the profile
-        */
-       public Profile getProfile() {
-               return new Profile(profile);
-       }
-
-       /**
-        * Sets the profile of this Sone. A copy of the given profile is stored so that
-        * subsequent modifications of the given profile are not reflected in this
-        * Sone!
-        *
-        * @param profile
-        *              The profile to set
-        */
-       public void setProfile(Profile profile) {
-               this.profile = new Profile(profile);
-       }
-
-       /**
-        * Returns the client used by this Sone.
-        *
-        * @return The client used by this Sone, or {@code null}
-        */
-       public Client getClient() {
-               return client;
-       }
-
-       /**
-        * Sets the client used by this Sone.
-        *
-        * @param client
-        *              The client used by this Sone, or {@code null}
-        * @return This Sone (for method chaining)
-        */
-       public Sone setClient(Client client) {
-               this.client = client;
-               return this;
-       }
-
-       /**
-        * Returns whether this Sone is known.
-        *
-        * @return {@code true} if this Sone is known, {@code false} otherwise
-        */
-       public boolean isKnown() {
-               return known;
-       }
-
-       /**
-        * Sets whether this Sone is known.
-        *
-        * @param known
-        *              {@code true} if this Sone is known, {@code false} otherwise
-        * @return This Sone
-        */
-       public Sone setKnown(boolean known) {
-               this.known = known;
-               return this;
-       }
-
-       /**
-        * Returns all friend Sones of this Sone.
-        *
-        * @return The friend Sones of this Sone
-        */
-       public List<String> getFriends() {
-               List<String> friends = new ArrayList<String>(friendSones);
-               return friends;
-       }
-
-       /**
-        * Returns whether this Sone has the given Sone as a friend Sone.
-        *
-        * @param friendSoneId
-        *              The ID of the Sone to check for
-        * @return {@code true} if this Sone has the given Sone as a friend, {@code
-        *         false} otherwise
-        */
-       public boolean hasFriend(String friendSoneId) {
-               return friendSones.contains(friendSoneId);
-       }
-
-       /**
-        * Adds the given Sone as a friend Sone.
-        *
-        * @param friendSone
-        *              The friend Sone to add
-        * @return This Sone (for method chaining)
-        */
-       public Sone addFriend(String friendSone) {
-               if (!friendSone.equals(id)) {
-                       friendSones.add(friendSone);
-               }
-               return this;
-       }
-
-       /**
-        * Removes the given Sone as a friend Sone.
-        *
-        * @param friendSoneId
-        *              The ID of the friend Sone to remove
-        * @return This Sone (for method chaining)
-        */
-       public Sone removeFriend(String friendSoneId) {
-               friendSones.remove(friendSoneId);
-               return this;
-       }
-
-       /**
-        * Returns the list of posts of this Sone, sorted by time, newest first.
-        *
-        * @return All posts of this Sone
-        */
-       public List<Post> getPosts() {
-               List<Post> sortedPosts;
-               synchronized (this) {
-                       sortedPosts = new ArrayList<Post>(posts);
-               }
-               Collections.sort(sortedPosts, Post.TIME_COMPARATOR);
-               return sortedPosts;
-       }
-
-       /**
-        * Sets all posts of this Sone at once.
-        *
-        * @param posts
-        *              The new (and only) posts of this Sone
-        * @return This Sone (for method chaining)
-        */
-       public Sone setPosts(Collection<Post> posts) {
-               synchronized (this) {
-                       this.posts.clear();
-                       this.posts.addAll(posts);
-               }
-               return this;
-       }
-
-       /**
-        * Adds the given post to this Sone. The post will not be added if its {@link
-        * Post#getSone() Sone} is not this Sone.
-        *
-        * @param post
-        *              The post to add
-        */
-       public void addPost(Post post) {
-               if (post.getSone().equals(this) && posts.add(post)) {
-                       logger.log(Level.FINEST, String.format("Adding %s to “%s”.", post, getName()));
-               }
-       }
-
-       /**
-        * Removes the given post from this Sone.
-        *
-        * @param post
-        *              The post to remove
-        */
-       public void removePost(Post post) {
-               if (post.getSone().equals(this)) {
-                       posts.remove(post);
-               }
-       }
-
-       /**
-        * Returns all replies this Sone made.
-        *
-        * @return All replies this Sone made
-        */
-       public Set<PostReply> getReplies() {
-               return Collections.unmodifiableSet(replies);
-       }
-
-       /**
-        * Sets all replies of this Sone at once.
-        *
-        * @param replies
-        *              The new (and only) replies of this Sone
-        * @return This Sone (for method chaining)
-        */
-       public Sone setReplies(Collection<PostReply> replies) {
-               this.replies.clear();
-               this.replies.addAll(replies);
-               return this;
-       }
-
-       /**
-        * Adds a reply to this Sone. If the given reply was not made by this Sone,
-        * nothing is added to this Sone.
-        *
-        * @param reply
-        *              The reply to add
-        */
-       public void addReply(PostReply reply) {
-               if (reply.getSone().equals(this)) {
-                       replies.add(reply);
-               }
-       }
-
-       /**
-        * Removes a reply from this Sone.
-        *
-        * @param reply
-        *              The reply to remove
-        */
-       public void removeReply(PostReply reply) {
-               if (reply.getSone().equals(this)) {
-                       replies.remove(reply);
-               }
-       }
-
-       /**
-        * Returns the IDs of all liked posts.
-        *
-        * @return All liked posts’ IDs
-        */
-       public Set<String> getLikedPostIds() {
-               return Collections.unmodifiableSet(likedPostIds);
-       }
-
-       /**
-        * Sets the IDs of all liked posts.
-        *
-        * @param likedPostIds
-        *              All liked posts’ IDs
-        * @return This Sone (for method chaining)
-        */
-       public Sone setLikePostIds(Set<String> likedPostIds) {
-               this.likedPostIds.clear();
-               this.likedPostIds.addAll(likedPostIds);
-               return this;
-       }
-
-       /**
-        * Checks whether the given post ID is liked by this Sone.
-        *
-        * @param postId
-        *              The ID of the post
-        * @return {@code true} if this Sone likes the given post, {@code false}
-        *         otherwise
-        */
-       public boolean isLikedPostId(String postId) {
-               return likedPostIds.contains(postId);
-       }
-
-       /**
-        * Adds the given post ID to the list of posts this Sone likes.
-        *
-        * @param postId
-        *              The ID of the post
-        * @return This Sone (for method chaining)
-        */
-       public Sone addLikedPostId(String postId) {
-               likedPostIds.add(postId);
-               return this;
-       }
-
-       /**
-        * Removes the given post ID from the list of posts this Sone likes.
-        *
-        * @param postId
-        *              The ID of the post
-        * @return This Sone (for method chaining)
-        */
-       public Sone removeLikedPostId(String postId) {
-               likedPostIds.remove(postId);
-               return this;
-       }
-
-       /**
-        * Returns the IDs of all liked replies.
-        *
-        * @return All liked replies’ IDs
-        */
-       public Set<String> getLikedReplyIds() {
-               return Collections.unmodifiableSet(likedReplyIds);
-       }
-
-       /**
-        * Sets the IDs of all liked replies.
-        *
-        * @param likedReplyIds
-        *              All liked replies’ IDs
-        * @return This Sone (for method chaining)
-        */
-       public Sone setLikeReplyIds(Set<String> likedReplyIds) {
-               this.likedReplyIds.clear();
-               this.likedReplyIds.addAll(likedReplyIds);
-               return this;
-       }
-
-       /**
-        * Checks whether the given reply ID is liked by this Sone.
-        *
-        * @param replyId
-        *              The ID of the reply
-        * @return {@code true} if this Sone likes the given reply, {@code false}
-        *         otherwise
-        */
-       public boolean isLikedReplyId(String replyId) {
-               return likedReplyIds.contains(replyId);
-       }
-
-       /**
-        * Adds the given reply ID to the list of replies this Sone likes.
-        *
-        * @param replyId
-        *              The ID of the reply
-        * @return This Sone (for method chaining)
-        */
-       public Sone addLikedReplyId(String replyId) {
-               likedReplyIds.add(replyId);
-               return this;
-       }
-
-       /**
-        * Removes the given post ID from the list of replies this Sone likes.
-        *
-        * @param replyId
-        *              The ID of the reply
-        * @return This Sone (for method chaining)
-        */
-       public Sone removeLikedReplyId(String replyId) {
-               likedReplyIds.remove(replyId);
-               return this;
-       }
-
-       /**
-        * Returns the root album that contains all visible albums of this Sone.
-        *
-        * @return The root album of this Sone
-        */
-       public Album getRootAlbum() {
-               return rootAlbum;
-       }
-
-       /**
-        * Returns Sone-specific options.
-        *
-        * @return The options of this Sone
-        */
-       public Options getOptions() {
-               return options;
-       }
-
-       /**
-        * Sets the options of this Sone.
-        *
-        * @param options
-        *              The options of this Sone
-        */
-       /* TODO - remove this method again, maybe add an option provider */
-       public void setOptions(Options options) {
-               this.options = options;
-       }
-
-       //
-       // FINGERPRINTABLE METHODS
-       //
-
-       /** {@inheritDoc} */
-       @Override
-       public synchronized String getFingerprint() {
-               Hasher hash = Hashing.sha256().newHasher();
-               hash.putString(profile.getFingerprint());
-
-               hash.putString("Posts(");
-               for (Post post : getPosts()) {
-                       hash.putString("Post(").putString(post.getId()).putString(")");
-               }
-               hash.putString(")");
-
-               List<PostReply> replies = new ArrayList<PostReply>(getReplies());
-               Collections.sort(replies, Reply.TIME_COMPARATOR);
-               hash.putString("Replies(");
-               for (PostReply reply : replies) {
-                       hash.putString("Reply(").putString(reply.getId()).putString(")");
-               }
-               hash.putString(")");
-
-               List<String> likedPostIds = new ArrayList<String>(getLikedPostIds());
-               Collections.sort(likedPostIds);
-               hash.putString("LikedPosts(");
-               for (String likedPostId : likedPostIds) {
-                       hash.putString("Post(").putString(likedPostId).putString(")");
-               }
-               hash.putString(")");
-
-               List<String> likedReplyIds = new ArrayList<String>(getLikedReplyIds());
-               Collections.sort(likedReplyIds);
-               hash.putString("LikedReplies(");
-               for (String likedReplyId : likedReplyIds) {
-                       hash.putString("Reply(").putString(likedReplyId).putString(")");
-               }
-               hash.putString(")");
-
-               hash.putString("Albums(");
-               for (Album album : rootAlbum.getAlbums()) {
-                       if (!Album.NOT_EMPTY.apply(album)) {
-                               continue;
-                       }
-                       hash.putString(album.getFingerprint());
-               }
-               hash.putString(")");
-
-               return hash.hash().toString();
-       }
-
-       //
-       // INTERFACE Comparable<Sone>
-       //
-
-       /** {@inheritDoc} */
-       @Override
-       public int compareTo(Sone sone) {
-               return NICE_NAME_COMPARATOR.compare(this, sone);
-       }
-
-       //
-       // OBJECT METHODS
-       //
-
-       /** {@inheritDoc} */
-       @Override
-       public int hashCode() {
-               return id.hashCode();
-       }
-
-       /** {@inheritDoc} */
-       @Override
-       public boolean equals(Object object) {
-               if (!(object instanceof Sone)) {
-                       return false;
-               }
-               return ((Sone) object).getId().equals(id);
-       }
-
-       /** {@inheritDoc} */
-       @Override
-       public String toString() {
-               return getClass().getName() + "[identity=" + identity + ",requestUri=" + requestUri + ",insertUri(" + String.valueOf(insertUri).length() + "),friends(" + friendSones.size() + "),posts(" + posts.size() + "),replies(" + replies.size() + "),albums(" + getRootAlbum().getAlbums().size() + ")]";
-       }
-
-}
diff --git a/src/main/java/net/pterodactylus/sone/data/SoneOptions.java b/src/main/java/net/pterodactylus/sone/data/SoneOptions.java
new file mode 100644 (file)
index 0000000..819e94a
--- /dev/null
@@ -0,0 +1,108 @@
+package net.pterodactylus.sone.data;
+
+import static net.pterodactylus.sone.data.Sone.ShowCustomAvatars.NEVER;
+
+import net.pterodactylus.sone.data.Sone.ShowCustomAvatars;
+
+/**
+ * All Sone-specific options.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public interface SoneOptions {
+
+       boolean isAutoFollow();
+       void setAutoFollow(boolean autoFollow);
+
+       boolean isSoneInsertNotificationEnabled();
+       void setSoneInsertNotificationEnabled(boolean soneInsertNotificationEnabled);
+
+       boolean isShowNewSoneNotifications();
+       void setShowNewSoneNotifications(boolean showNewSoneNotifications);
+
+       boolean isShowNewPostNotifications();
+       void setShowNewPostNotifications(boolean showNewPostNotifications);
+
+       boolean isShowNewReplyNotifications();
+       void setShowNewReplyNotifications(boolean showNewReplyNotifications);
+
+       ShowCustomAvatars getShowCustomAvatars();
+       void setShowCustomAvatars(ShowCustomAvatars showCustomAvatars);
+
+       /**
+        * {@link SoneOptions} implementation.
+        *
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       public class DefaultSoneOptions implements SoneOptions {
+
+               private boolean autoFollow = false;
+               private boolean soneInsertNotificationsEnabled = false;
+               private boolean showNewSoneNotifications = true;
+               private boolean showNewPostNotifications = true;
+               private boolean showNewReplyNotifications = true;
+               private ShowCustomAvatars showCustomAvatars = NEVER;
+
+               @Override
+               public boolean isAutoFollow() {
+                       return autoFollow;
+               }
+
+               @Override
+               public void setAutoFollow(boolean autoFollow) {
+                       this.autoFollow = autoFollow;
+               }
+
+               @Override
+               public boolean isSoneInsertNotificationEnabled() {
+                       return soneInsertNotificationsEnabled;
+               }
+
+               @Override
+               public void setSoneInsertNotificationEnabled(boolean soneInsertNotificationEnabled) {
+                       this.soneInsertNotificationsEnabled = soneInsertNotificationEnabled;
+               }
+
+               @Override
+               public boolean isShowNewSoneNotifications() {
+                       return showNewSoneNotifications;
+               }
+
+               @Override
+               public void setShowNewSoneNotifications(boolean showNewSoneNotifications) {
+                       this.showNewSoneNotifications = showNewSoneNotifications;
+               }
+
+               @Override
+               public boolean isShowNewPostNotifications() {
+                       return showNewPostNotifications;
+               }
+
+               @Override
+               public void setShowNewPostNotifications(boolean showNewPostNotifications) {
+                       this.showNewPostNotifications = showNewPostNotifications;
+               }
+
+               @Override
+               public boolean isShowNewReplyNotifications() {
+                       return showNewReplyNotifications;
+               }
+
+               @Override
+               public void setShowNewReplyNotifications(boolean showNewReplyNotifications) {
+                       this.showNewReplyNotifications = showNewReplyNotifications;
+               }
+
+               @Override
+               public ShowCustomAvatars getShowCustomAvatars() {
+                       return showCustomAvatars;
+               }
+
+               @Override
+               public void setShowCustomAvatars(ShowCustomAvatars showCustomAvatars) {
+                       this.showCustomAvatars = showCustomAvatars;
+               }
+
+       }
+
+}
index 312c795..8e15b6f 100644 (file)
@@ -19,6 +19,7 @@ package net.pterodactylus.sone.data.impl;
 
 import static com.google.common.base.Preconditions.checkState;
 
+import net.pterodactylus.sone.data.Sone;
 import net.pterodactylus.sone.database.AlbumBuilder;
 
 /**
@@ -34,6 +35,7 @@ public abstract class AbstractAlbumBuilder implements AlbumBuilder {
 
        /** The ID of the album to create. */
        protected String id;
+       protected Sone sone;
 
        @Override
        public AlbumBuilder randomId() {
@@ -47,6 +49,11 @@ public abstract class AbstractAlbumBuilder implements AlbumBuilder {
                return this;
        }
 
+       public AlbumBuilder by(Sone sone) {
+               this.sone = sone;
+               return this;
+       }
+
        //
        // PROTECTED METHODS
        //
@@ -59,6 +66,7 @@ public abstract class AbstractAlbumBuilder implements AlbumBuilder {
         */
        protected void validate() throws IllegalStateException {
                checkState((randomId && (id == null)) || (!randomId && (id != null)), "exactly one of random ID or custom ID must be set");
+               checkState(sone != null, "Sone must not be null");
        }
 
 }
diff --git a/src/main/java/net/pterodactylus/sone/data/impl/AbstractSoneBuilder.java b/src/main/java/net/pterodactylus/sone/data/impl/AbstractSoneBuilder.java
new file mode 100644 (file)
index 0000000..a214677
--- /dev/null
@@ -0,0 +1,37 @@
+package net.pterodactylus.sone.data.impl;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import net.pterodactylus.sone.database.SoneBuilder;
+import net.pterodactylus.sone.freenet.wot.Identity;
+import net.pterodactylus.sone.freenet.wot.OwnIdentity;
+
+/**
+ * Abstract {@link SoneBuilder} implementation.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public abstract class AbstractSoneBuilder implements SoneBuilder {
+
+       protected Identity identity;
+       protected boolean local;
+
+       @Override
+       public SoneBuilder from(Identity identity) {
+               this.identity = identity;
+               return this;
+       }
+
+       @Override
+       public SoneBuilder local() {
+               this.local = true;
+               return this;
+       }
+
+       protected void validate() throws IllegalStateException {
+               checkState(identity != null, "identity must not be null");
+               checkState(!local || (identity instanceof OwnIdentity),
+                               "can not create local Sone from remote identity");
+       }
+
+}
index 3403a62..df61e36 100644 (file)
@@ -18,7 +18,6 @@
 package net.pterodactylus.sone.data.impl;
 
 import net.pterodactylus.sone.data.Album;
-import net.pterodactylus.sone.data.AlbumImpl;
 import net.pterodactylus.sone.database.AlbumBuilder;
 
 /**
@@ -31,7 +30,7 @@ public class AlbumBuilderImpl extends AbstractAlbumBuilder {
        @Override
        public Album build() throws IllegalStateException {
                validate();
-               return randomId ? new AlbumImpl() : new AlbumImpl(id);
+               return randomId ? new AlbumImpl(sone) : new AlbumImpl(sone, id);
        }
 
 }
diff --git a/src/main/java/net/pterodactylus/sone/data/impl/AlbumImpl.java b/src/main/java/net/pterodactylus/sone/data/impl/AlbumImpl.java
new file mode 100644 (file)
index 0000000..c569e0a
--- /dev/null
@@ -0,0 +1,379 @@
+/*
+ * Sone - Album.java - Copyright © 2011–2013 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone.data.impl;
+
+import static com.google.common.base.Optional.absent;
+import static com.google.common.base.Optional.fromNullable;
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import net.pterodactylus.sone.data.Album;
+import net.pterodactylus.sone.data.Image;
+import net.pterodactylus.sone.data.Sone;
+
+import com.google.common.base.Function;
+import com.google.common.base.Optional;
+import com.google.common.base.Predicates;
+import com.google.common.collect.Collections2;
+import com.google.common.hash.Hasher;
+import com.google.common.hash.Hashing;
+
+/**
+ * Container for images that can also contain nested {@link AlbumImpl}s.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class AlbumImpl implements Album {
+
+       /** The ID of this album. */
+       private final String id;
+
+       /** The Sone this album belongs to. */
+       private final Sone sone;
+
+       /** Nested albums. */
+       private final List<Album> albums = new ArrayList<Album>();
+
+       /** The image IDs in order. */
+       private final List<String> imageIds = new ArrayList<String>();
+
+       /** The images in this album. */
+       private final Map<String, Image> images = new HashMap<String, Image>();
+
+       /** The parent album. */
+       private Album parent;
+
+       /** The title of this album. */
+       private String title;
+
+       /** The description of this album. */
+       private String description;
+
+       /** The ID of the album picture. */
+       private String albumImage;
+
+       /** Creates a new album with a random ID. */
+       public AlbumImpl(Sone sone) {
+               this(sone, UUID.randomUUID().toString());
+       }
+
+       /**
+        * Creates a new album with the given ID.
+        *
+        * @param id
+        *              The ID of the album
+        */
+       public AlbumImpl(Sone sone, String id) {
+               this.sone = checkNotNull(sone, "Sone must not be null");
+               this.id = checkNotNull(id, "id must not be null");
+       }
+
+       //
+       // ACCESSORS
+       //
+
+       @Override
+       public String getId() {
+               return id;
+       }
+
+       @Override
+       public Sone getSone() {
+               return sone;
+       }
+
+       @Override
+       public List<Album> getAlbums() {
+               return new ArrayList<Album>(albums);
+       }
+
+       @Override
+       public void addAlbum(Album album) {
+               checkNotNull(album, "album must not be null");
+               checkArgument(album.getSone().equals(sone), "album must belong to the same Sone as this album");
+               album.setParent(this);
+               if (!albums.contains(album)) {
+                       albums.add(album);
+               }
+       }
+
+       @Override
+       public void removeAlbum(Album album) {
+               checkNotNull(album, "album must not be null");
+               checkArgument(album.getSone().equals(sone), "album must belong this album’s Sone");
+               checkArgument(equals(album.getParent()), "album must belong to this album");
+               albums.remove(album);
+               album.removeParent();
+       }
+
+       @Override
+       public Album moveAlbumUp(Album album) {
+               checkNotNull(album, "album must not be null");
+               checkArgument(album.getSone().equals(sone), "album must belong to the same Sone as this album");
+               checkArgument(equals(album.getParent()), "album must belong to this album");
+               int oldIndex = albums.indexOf(album);
+               if (oldIndex <= 0) {
+                       return null;
+               }
+               albums.remove(oldIndex);
+               albums.add(oldIndex - 1, album);
+               return albums.get(oldIndex);
+       }
+
+       @Override
+       public Album moveAlbumDown(Album album) {
+               checkNotNull(album, "album must not be null");
+               checkArgument(album.getSone().equals(sone), "album must belong to the same Sone as this album");
+               checkArgument(equals(album.getParent()), "album must belong to this album");
+               int oldIndex = albums.indexOf(album);
+               if ((oldIndex < 0) || (oldIndex >= (albums.size() - 1))) {
+                       return null;
+               }
+               albums.remove(oldIndex);
+               albums.add(oldIndex + 1, album);
+               return albums.get(oldIndex);
+       }
+
+       @Override
+       public List<Image> getImages() {
+               return new ArrayList<Image>(Collections2.filter(Collections2.transform(imageIds, new Function<String, Image>() {
+
+                       @Override
+                       @SuppressWarnings("synthetic-access")
+                       public Image apply(String imageId) {
+                               return images.get(imageId);
+                       }
+               }), Predicates.notNull()));
+       }
+
+       @Override
+       public void addImage(Image image) {
+               checkNotNull(image, "image must not be null");
+               checkNotNull(image.getSone(), "image must have an owner");
+               checkArgument(image.getSone().equals(sone), "image must belong to the same Sone as this album");
+               if (image.getAlbum() != null) {
+                       image.getAlbum().removeImage(image);
+               }
+               image.setAlbum(this);
+               if (imageIds.isEmpty() && (albumImage == null)) {
+                       albumImage = image.getId();
+               }
+               if (!imageIds.contains(image.getId())) {
+                       imageIds.add(image.getId());
+                       images.put(image.getId(), image);
+               }
+       }
+
+       @Override
+       public void removeImage(Image image) {
+               checkNotNull(image, "image must not be null");
+               checkNotNull(image.getSone(), "image must have an owner");
+               checkArgument(image.getSone().equals(sone), "image must belong to the same Sone as this album");
+               imageIds.remove(image.getId());
+               images.remove(image.getId());
+               if (image.getId().equals(albumImage)) {
+                       if (images.isEmpty()) {
+                               albumImage = null;
+                       } else {
+                               albumImage = images.values().iterator().next().getId();
+                       }
+               }
+       }
+
+       @Override
+       public Image moveImageUp(Image image) {
+               checkNotNull(image, "image must not be null");
+               checkNotNull(image.getSone(), "image must have an owner");
+               checkArgument(image.getSone().equals(sone), "image must belong to the same Sone as this album");
+               checkArgument(image.getAlbum().equals(this), "image must belong to this album");
+               int oldIndex = imageIds.indexOf(image.getId());
+               if (oldIndex <= 0) {
+                       return null;
+               }
+               imageIds.remove(image.getId());
+               imageIds.add(oldIndex - 1, image.getId());
+               return images.get(imageIds.get(oldIndex));
+       }
+
+       @Override
+       public Image moveImageDown(Image image) {
+               checkNotNull(image, "image must not be null");
+               checkNotNull(image.getSone(), "image must have an owner");
+               checkArgument(image.getSone().equals(sone), "image must belong to the same Sone as this album");
+               checkArgument(image.getAlbum().equals(this), "image must belong to this album");
+               int oldIndex = imageIds.indexOf(image.getId());
+               if ((oldIndex == -1) || (oldIndex >= (imageIds.size() - 1))) {
+                       return null;
+               }
+               imageIds.remove(image.getId());
+               imageIds.add(oldIndex + 1, image.getId());
+               return images.get(imageIds.get(oldIndex));
+       }
+
+       @Override
+       public Image getAlbumImage() {
+               if (albumImage == null) {
+                       return null;
+               }
+               return Optional.fromNullable(images.get(albumImage)).or(images.values().iterator().next());
+       }
+
+       @Override
+       public boolean isEmpty() {
+               return albums.isEmpty() && images.isEmpty();
+       }
+
+       @Override
+       public boolean isRoot() {
+               return parent == null;
+       }
+
+       @Override
+       public Album getParent() {
+               return parent;
+       }
+
+       @Override
+       public Album setParent(Album parent) {
+               this.parent = checkNotNull(parent, "parent must not be null");
+               return this;
+       }
+
+       @Override
+       public Album removeParent() {
+               this.parent = null;
+               return this;
+       }
+
+       @Override
+       public String getTitle() {
+               return title;
+       }
+
+       @Override
+       public String getDescription() {
+               return description;
+       }
+
+       @Override
+       public Modifier modify() throws IllegalStateException {
+               // TODO: reenable check for local Sones
+               return new Modifier() {
+                       private Optional<String> title = absent();
+
+                       private Optional<String> description = absent();
+
+                       private Optional<String> albumImage = absent();
+
+                       @Override
+                       public Modifier setTitle(String title) {
+                               this.title = fromNullable(title);
+                               return this;
+                       }
+
+                       @Override
+                       public Modifier setDescription(String description) {
+                               this.description = fromNullable(description);
+                               return this;
+                       }
+
+                       @Override
+                       public Modifier setAlbumImage(String imageId) {
+                               this.albumImage = fromNullable(imageId);
+                               return this;
+                       }
+
+                       @Override
+                       public Album update() throws IllegalStateException {
+                               if (title.isPresent() && title.get().trim().isEmpty()) {
+                                       throw new AlbumTitleMustNotBeEmpty();
+                               }
+                               if (title.isPresent()) {
+                                       AlbumImpl.this.title = title.get();
+                               }
+                               if (description.isPresent()) {
+                                       AlbumImpl.this.description = description.get();
+                               }
+                               if (albumImage.isPresent()) {
+                                       AlbumImpl.this.albumImage = albumImage.get();
+                               }
+                               return AlbumImpl.this;
+                       }
+               };
+       }
+
+       //
+       // FINGERPRINTABLE METHODS
+       //
+
+       @Override
+       public String getFingerprint() {
+               Hasher hash = Hashing.sha256().newHasher();
+               hash.putString("Album(");
+               hash.putString("ID(").putString(id).putString(")");
+               hash.putString("Title(").putString(title).putString(")");
+               hash.putString("Description(").putString(description).putString(")");
+               if (albumImage != null) {
+                       hash.putString("AlbumImage(").putString(albumImage).putString(")");
+               }
+
+               /* add nested albums. */
+               hash.putString("Albums(");
+               for (Album album : albums) {
+                       hash.putString(album.getFingerprint());
+               }
+               hash.putString(")");
+
+               /* add images. */
+               hash.putString("Images(");
+               for (Image image : getImages()) {
+                       if (image.isInserted()) {
+                               hash.putString(image.getFingerprint());
+                       }
+               }
+               hash.putString(")");
+
+               hash.putString(")");
+               return hash.hash().toString();
+       }
+
+       //
+       // OBJECT METHODS
+       //
+
+       @Override
+       public int hashCode() {
+               return id.hashCode();
+       }
+
+       @Override
+       public boolean equals(Object object) {
+               if (!(object instanceof AlbumImpl)) {
+                       return false;
+               }
+               AlbumImpl album = (AlbumImpl) object;
+               return id.equals(album.id);
+       }
+
+}
diff --git a/src/main/java/net/pterodactylus/sone/data/impl/IdOnlySone.java b/src/main/java/net/pterodactylus/sone/data/impl/IdOnlySone.java
new file mode 100644 (file)
index 0000000..0ef220b
--- /dev/null
@@ -0,0 +1,243 @@
+package net.pterodactylus.sone.data.impl;
+
+import static java.util.Collections.emptyList;
+import static java.util.Collections.emptySet;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+
+import net.pterodactylus.sone.data.Album;
+import net.pterodactylus.sone.data.Client;
+import net.pterodactylus.sone.data.Post;
+import net.pterodactylus.sone.data.PostReply;
+import net.pterodactylus.sone.data.Profile;
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.sone.data.SoneOptions;
+import net.pterodactylus.sone.freenet.wot.Identity;
+
+import freenet.keys.FreenetURI;
+
+/**
+ * {@link Sone} implementation that only stores the ID of a Sone and returns
+ * {@code null}, {@code 0}, or empty collections where appropriate.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class IdOnlySone implements Sone {
+
+       private final String id;
+
+       public IdOnlySone(String id) {
+               this.id = id;
+       }
+
+       @Override
+       public Identity getIdentity() {
+               return null;
+       }
+
+       @Override
+       public String getName() {
+               return id;
+       }
+
+       @Override
+       public boolean isLocal() {
+               return false;
+       }
+
+       @Override
+       public FreenetURI getRequestUri() {
+               return null;
+       }
+
+       @Override
+       public FreenetURI getInsertUri() {
+               return null;
+       }
+
+       @Override
+       public long getLatestEdition() {
+               return 0;
+       }
+
+       @Override
+       public void setLatestEdition(long latestEdition) {
+       }
+
+       @Override
+       public long getTime() {
+               return 0;
+       }
+
+       @Override
+       public Sone setTime(long time) {
+               return null;
+       }
+
+       @Override
+       public SoneStatus getStatus() {
+               return null;
+       }
+
+       @Override
+       public Sone setStatus(SoneStatus status) {
+               return null;
+       }
+
+       @Override
+       public Profile getProfile() {
+               return new Profile(this);
+       }
+
+       @Override
+       public void setProfile(Profile profile) {
+       }
+
+       @Override
+       public Client getClient() {
+               return null;
+       }
+
+       @Override
+       public Sone setClient(Client client) {
+               return null;
+       }
+
+       @Override
+       public boolean isKnown() {
+               return false;
+       }
+
+       @Override
+       public Sone setKnown(boolean known) {
+               return null;
+       }
+
+       @Override
+       public List<String> getFriends() {
+               return emptyList();
+       }
+
+       @Override
+       public boolean hasFriend(String friendSoneId) {
+               return false;
+       }
+
+       @Override
+       public List<Post> getPosts() {
+               return emptyList();
+       }
+
+       @Override
+       public Sone setPosts(Collection<Post> posts) {
+               return this;
+       }
+
+       @Override
+       public void addPost(Post post) {
+       }
+
+       @Override
+       public void removePost(Post post) {
+       }
+
+       @Override
+       public Set<PostReply> getReplies() {
+               return emptySet();
+       }
+
+       @Override
+       public Sone setReplies(Collection<PostReply> replies) {
+               return this;
+       }
+
+       @Override
+       public void addReply(PostReply reply) {
+       }
+
+       @Override
+       public void removeReply(PostReply reply) {
+       }
+
+       @Override
+       public Set<String> getLikedPostIds() {
+               return emptySet();
+       }
+
+       @Override
+       public Sone setLikePostIds(Set<String> likedPostIds) {
+               return this;
+       }
+
+       @Override
+       public boolean isLikedPostId(String postId) {
+               return false;
+       }
+
+       @Override
+       public Sone addLikedPostId(String postId) {
+               return this;
+       }
+
+       @Override
+       public Sone removeLikedPostId(String postId) {
+               return this;
+       }
+
+       @Override
+       public Set<String> getLikedReplyIds() {
+               return emptySet();
+       }
+
+       @Override
+       public Sone setLikeReplyIds(Set<String> likedReplyIds) {
+               return this;
+       }
+
+       @Override
+       public boolean isLikedReplyId(String replyId) {
+               return false;
+       }
+
+       @Override
+       public Sone addLikedReplyId(String replyId) {
+               return this;
+       }
+
+       @Override
+       public Sone removeLikedReplyId(String replyId) {
+               return this;
+       }
+
+       @Override
+       public Album getRootAlbum() {
+               return null;
+       }
+
+       @Override
+       public SoneOptions getOptions() {
+               return null;
+       }
+
+       @Override
+       public void setOptions(SoneOptions options) {
+       }
+
+       @Override
+       public int compareTo(Sone o) {
+               return 0;
+       }
+
+       @Override
+       public String getFingerprint() {
+               return null;
+       }
+
+       @Override
+       public String getId() {
+               return id;
+       }
+
+}
index 870b5d7..ba7d75f 100644 (file)
@@ -18,7 +18,6 @@
 package net.pterodactylus.sone.data.impl;
 
 import net.pterodactylus.sone.data.Image;
-import net.pterodactylus.sone.data.ImageImpl;
 import net.pterodactylus.sone.database.ImageBuilder;
 
 /**
diff --git a/src/main/java/net/pterodactylus/sone/data/impl/ImageImpl.java b/src/main/java/net/pterodactylus/sone/data/impl/ImageImpl.java
new file mode 100644 (file)
index 0000000..2df98b1
--- /dev/null
@@ -0,0 +1,278 @@
+/*
+ * Sone - ImageImpl.java - Copyright © 2011–2013 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+package net.pterodactylus.sone.data.impl;
+
+import static com.google.common.base.Optional.absent;
+import static com.google.common.base.Optional.fromNullable;
+import static com.google.common.base.Optional.of;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+
+import java.util.UUID;
+
+import net.pterodactylus.sone.data.Album;
+import net.pterodactylus.sone.data.Image;
+import net.pterodactylus.sone.data.Sone;
+
+import com.google.common.base.Optional;
+import com.google.common.hash.Hasher;
+import com.google.common.hash.Hashing;
+
+/**
+ * Container for image metadata.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class ImageImpl implements Image {
+
+       /** The ID of the image. */
+       private final String id;
+
+       /** The Sone the image belongs to. */
+       private Sone sone;
+
+       /** The album this image belongs to. */
+       private Album album;
+
+       /** The request key of the image. */
+       private String key;
+
+       /** The creation time of the image. */
+       private long creationTime;
+
+       /** The width of the image. */
+       private int width;
+
+       /** The height of the image. */
+       private int height;
+
+       /** The title of the image. */
+       private String title;
+
+       /** The description of the image. */
+       private String description;
+
+       /** Creates a new image with a random ID. */
+       public ImageImpl() {
+               this(UUID.randomUUID().toString());
+               this.creationTime = System.currentTimeMillis();
+       }
+
+       /**
+        * Creates a new image.
+        *
+        * @param id
+        *              The ID of the image
+        */
+       public ImageImpl(String id) {
+               this.id = checkNotNull(id, "id must not be null");
+       }
+
+       //
+       // ACCESSORS
+       //
+
+       @Override
+       public String getId() {
+               return id;
+       }
+
+       @Override
+       public Sone getSone() {
+               return sone;
+       }
+
+       @Override
+       public Album getAlbum() {
+               return album;
+       }
+
+       @Override
+       public Image setAlbum(Album album) {
+               checkNotNull(album, "album must not be null");
+               checkNotNull(album.getSone().equals(getSone()), "album must belong to the same Sone as this image");
+               this.album = album;
+               return this;
+       }
+
+       @Override
+       public String getKey() {
+               return key;
+       }
+
+       @Override
+       public boolean isInserted() {
+               return key != null;
+       }
+
+       @Override
+       public long getCreationTime() {
+               return creationTime;
+       }
+
+       @Override
+       public int getWidth() {
+               return width;
+       }
+
+       @Override
+       public int getHeight() {
+               return height;
+       }
+
+       @Override
+       public String getTitle() {
+               return title;
+       }
+
+       @Override
+       public String getDescription() {
+               return description;
+       }
+
+       public Modifier modify() throws IllegalStateException {
+               // TODO: reenable check for local images
+               return new Modifier() {
+                       private Optional<Sone> sone = absent();
+
+                       private Optional<Long> creationTime = absent();
+
+                       private Optional<String> key = absent();
+
+                       private Optional<String> title = absent();
+
+                       private Optional<String> description = absent();
+
+                       private Optional<Integer> width = absent();
+
+                       private Optional<Integer> height = absent();
+
+                       @Override
+                       public Modifier setSone(Sone sone) {
+                               this.sone = fromNullable(sone);
+                               return this;
+                       }
+
+                       @Override
+                       public Modifier setCreationTime(long creationTime) {
+                               this.creationTime = of(creationTime);
+                               return this;
+                       }
+
+                       @Override
+                       public Modifier setKey(String key) {
+                               this.key = fromNullable(key);
+                               return this;
+                       }
+
+                       @Override
+                       public Modifier setTitle(String title) {
+                               this.title = fromNullable(title);
+                               return this;
+                       }
+
+                       @Override
+                       public Modifier setDescription(String description) {
+                               this.description = fromNullable(description);
+                               return this;
+                       }
+
+                       @Override
+                       public Modifier setWidth(int width) {
+                               this.width = of(width);
+                               return this;
+                       }
+
+                       @Override
+                       public Modifier setHeight(int height) {
+                               this.height = of(height);
+                               return this;
+                       }
+
+                       @Override
+                       public Image update() throws IllegalStateException {
+                               checkState(!sone.isPresent() || (ImageImpl.this.sone == null) || sone.get().equals(ImageImpl.this.sone), "can not change Sone once set");
+                               checkState(!creationTime.isPresent() || ((ImageImpl.this.creationTime == 0) || (ImageImpl.this.creationTime == creationTime.get())), "can not change creation time once set");
+                               checkState(!key.isPresent() || (ImageImpl.this.key == null) || key.get().equals(ImageImpl.this.key), "can not change key once set");
+                               if (title.isPresent() && title.get().trim().isEmpty()) {
+                                       throw new ImageTitleMustNotBeEmpty();
+                               }
+                               checkState(!width.isPresent() || (ImageImpl.this.width == 0) || width.get().equals(ImageImpl.this.width), "can not change width once set");
+                               checkState(!height.isPresent() || (ImageImpl.this.height == 0) || height.get().equals(ImageImpl.this.height), "can not change height once set");
+
+                               if (sone.isPresent()) {
+                                       ImageImpl.this.sone = sone.get();
+                               }
+                               if (creationTime.isPresent()) {
+                                       ImageImpl.this.creationTime = creationTime.get();
+                               }
+                               if (key.isPresent()) {
+                                       ImageImpl.this.key = key.get();
+                               }
+                               if (title.isPresent()) {
+                                       ImageImpl.this.title = title.get();
+                               }
+                               if (description.isPresent()) {
+                                       ImageImpl.this.description = description.get();
+                               }
+                               if (width.isPresent()) {
+                                       ImageImpl.this.width = width.get();
+                               }
+                               if (height.isPresent()) {
+                                       ImageImpl.this.height = height.get();
+                               }
+
+                               return ImageImpl.this;
+                       }
+               };
+       }
+
+       //
+       // FINGERPRINTABLE METHODS
+       //
+
+       @Override
+       public String getFingerprint() {
+               Hasher hash = Hashing.sha256().newHasher();
+               hash.putString("Image(");
+               hash.putString("ID(").putString(id).putString(")");
+               hash.putString("Title(").putString(title).putString(")");
+               hash.putString("Description(").putString(description).putString(")");
+               hash.putString(")");
+               return hash.hash().toString();
+       }
+
+       //
+       // OBJECT METHODS
+       //
+
+       /** {@inheritDoc} */
+       @Override
+       public int hashCode() {
+               return id.hashCode();
+       }
+
+       /** {@inheritDoc} */
+       @Override
+       public boolean equals(Object object) {
+               if (!(object instanceof ImageImpl)) {
+                       return false;
+               }
+               return ((ImageImpl) object).id.equals(id);
+       }
+
+}
index 2d25715..9dcd7d0 100644 (file)
@@ -17,8 +17,6 @@
 
 package net.pterodactylus.sone.data.impl;
 
-import java.util.UUID;
-
 import net.pterodactylus.sone.data.Post;
 import net.pterodactylus.sone.data.Sone;
 import net.pterodactylus.sone.database.SoneProvider;
@@ -37,7 +35,7 @@ public class PostImpl implements Post {
        private final SoneProvider soneProvider;
 
        /** The GUID of the post. */
-       private final UUID id;
+       private final String id;
 
        /** The ID of the owning Sone. */
        private final String soneId;
@@ -72,7 +70,7 @@ public class PostImpl implements Post {
         */
        public PostImpl(SoneProvider soneProvider, String id, String soneId, String recipientId, long time, String text) {
                this.soneProvider = soneProvider;
-               this.id = UUID.fromString(id);
+               this.id = id;
                this.soneId = soneId;
                this.recipientId = recipientId;
                this.time = time;
@@ -88,7 +86,12 @@ public class PostImpl implements Post {
         */
        @Override
        public String getId() {
-               return id.toString();
+               return id;
+       }
+
+       @Override
+       public boolean isLoaded() {
+               return true;
        }
 
        /**
diff --git a/src/main/java/net/pterodactylus/sone/data/impl/SoneImpl.java b/src/main/java/net/pterodactylus/sone/data/impl/SoneImpl.java
new file mode 100644 (file)
index 0000000..2fdd40c
--- /dev/null
@@ -0,0 +1,694 @@
+/*
+ * Sone - SoneImpl.java - Copyright © 2010–2013 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone.data.impl;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.lang.String.format;
+import static java.util.logging.Logger.getLogger;
+
+import java.net.MalformedURLException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArraySet;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import net.pterodactylus.sone.data.Album;
+import net.pterodactylus.sone.data.Client;
+import net.pterodactylus.sone.data.Post;
+import net.pterodactylus.sone.data.PostReply;
+import net.pterodactylus.sone.data.Profile;
+import net.pterodactylus.sone.data.Reply;
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.sone.data.SoneOptions;
+import net.pterodactylus.sone.data.SoneOptions.DefaultSoneOptions;
+import net.pterodactylus.sone.database.Database;
+import net.pterodactylus.sone.freenet.wot.Identity;
+import net.pterodactylus.sone.freenet.wot.OwnIdentity;
+
+import freenet.keys.FreenetURI;
+
+import com.google.common.hash.Hasher;
+import com.google.common.hash.Hashing;
+
+/**
+ * {@link Sone} implementation.
+ * <p/>
+ * Operations that modify the Sone need to synchronize on the Sone in question.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class SoneImpl implements Sone {
+
+       /** The logger. */
+       private static final Logger logger = getLogger("Sone.Data");
+
+       /** The database. */
+       private final Database database;
+
+       /** The ID of this Sone. */
+       private final String id;
+
+       /** Whether the Sone is local. */
+       private final boolean local;
+
+       /** The identity of this Sone. */
+       private final Identity identity;
+
+       /** The latest edition of the Sone. */
+       private volatile long latestEdition;
+
+       /** The time of the last inserted update. */
+       private volatile long time;
+
+       /** The status of this Sone. */
+       private volatile SoneStatus status = SoneStatus.unknown;
+
+       /** The profile of this Sone. */
+       private volatile Profile profile = new Profile(this);
+
+       /** The client used by the Sone. */
+       private volatile Client client;
+
+       /** Whether this Sone is known. */
+       private volatile boolean known;
+
+       /** All posts. */
+       private final Set<Post> posts = new CopyOnWriteArraySet<Post>();
+
+       /** All replies. */
+       private final Set<PostReply> replies = new CopyOnWriteArraySet<PostReply>();
+
+       /** The IDs of all liked posts. */
+       private final Set<String> likedPostIds = new CopyOnWriteArraySet<String>();
+
+       /** The IDs of all liked replies. */
+       private final Set<String> likedReplyIds = new CopyOnWriteArraySet<String>();
+
+       /** The root album containing all albums. */
+       private final Album rootAlbum = new AlbumImpl(this);
+
+       /** Sone-specific options. */
+       private SoneOptions options = new DefaultSoneOptions();
+
+       /**
+        * Creates a new Sone.
+        *
+        * @param database The database
+        * @param identity
+        *              The identity of the Sone
+        * @param local
+        *              {@code true} if the Sone is a local Sone, {@code false} otherwise
+        */
+       public SoneImpl(Database database, Identity identity, boolean local) {
+               this.database = database;
+               this.id = identity.getId();
+               this.identity = identity;
+               this.local = local;
+       }
+
+       //
+       // ACCESSORS
+       //
+
+       /**
+        * Returns the identity of this Sone.
+        *
+        * @return The identity of this Sone
+        */
+       public String getId() {
+               return id;
+       }
+
+       /**
+        * Returns the identity of this Sone.
+        *
+        * @return The identity of this Sone
+        */
+       public Identity getIdentity() {
+               return identity;
+       }
+
+       /**
+        * Returns the name of this Sone.
+        *
+        * @return The name of this Sone
+        */
+       public String getName() {
+               return (identity != null) ? identity.getNickname() : null;
+       }
+
+       /**
+        * Returns whether this Sone is a local Sone.
+        *
+        * @return {@code true} if this Sone is a local Sone, {@code false} otherwise
+        */
+       public boolean isLocal() {
+               return local;
+       }
+
+       /**
+        * Returns the request URI of this Sone.
+        *
+        * @return The request URI of this Sone
+        */
+       public FreenetURI getRequestUri() {
+               try {
+                       return new FreenetURI(getIdentity().getRequestUri())
+                                       .setKeyType("USK")
+                                       .setDocName("Sone")
+                                       .setMetaString(new String[0])
+                                       .setSuggestedEdition(latestEdition);
+               } catch (MalformedURLException e) {
+                       throw new IllegalStateException(
+                                       format("Identity %s's request URI is incorrect.",
+                                                       getIdentity()), e);
+               }
+       }
+
+       /**
+        * Returns the insert URI of this Sone.
+        *
+        * @return The insert URI of this Sone
+        */
+       public FreenetURI getInsertUri() {
+               if (!isLocal()) {
+                       return null;
+               }
+               try {
+                       return new FreenetURI(((OwnIdentity) getIdentity()).getInsertUri())
+                                       .setDocName("Sone")
+                                       .setMetaString(new String[0])
+                                       .setSuggestedEdition(latestEdition);
+               } catch (MalformedURLException e) {
+                       throw new IllegalStateException(format("Own identity %s's insert URI is incorrect.", getIdentity()), e);
+               }
+       }
+
+       /**
+        * Returns the latest edition of this Sone.
+        *
+        * @return The latest edition of this Sone
+        */
+       public long getLatestEdition() {
+               return latestEdition;
+       }
+
+       /**
+        * Sets the latest edition of this Sone. If the given latest edition is not
+        * greater than the current latest edition, the latest edition of this Sone is
+        * not changed.
+        *
+        * @param latestEdition
+        *              The latest edition of this Sone
+        */
+       public void setLatestEdition(long latestEdition) {
+               if (!(latestEdition > this.latestEdition)) {
+                       logger.log(Level.FINE, String.format("New latest edition %d is not greater than current latest edition %d!", latestEdition, this.latestEdition));
+                       return;
+               }
+               this.latestEdition = latestEdition;
+       }
+
+       /**
+        * Return the time of the last inserted update of this Sone.
+        *
+        * @return The time of the update (in milliseconds since Jan 1, 1970 UTC)
+        */
+       public long getTime() {
+               return time;
+       }
+
+       /**
+        * Sets the time of the last inserted update of this Sone.
+        *
+        * @param time
+        *              The time of the update (in milliseconds since Jan 1, 1970 UTC)
+        * @return This Sone (for method chaining)
+        */
+       public Sone setTime(long time) {
+               this.time = time;
+               return this;
+       }
+
+       /**
+        * Returns the status of this Sone.
+        *
+        * @return The status of this Sone
+        */
+       public SoneStatus getStatus() {
+               return status;
+       }
+
+       /**
+        * Sets the new status of this Sone.
+        *
+        * @param status
+        *              The new status of this Sone
+        * @return This Sone
+        * @throws IllegalArgumentException
+        *              if {@code status} is {@code null}
+        */
+       public Sone setStatus(SoneStatus status) {
+               this.status = checkNotNull(status, "status must not be null");
+               return this;
+       }
+
+       /**
+        * Returns a copy of the profile. If you want to update values in the profile
+        * of this Sone, update the values in the returned {@link Profile} and use
+        * {@link #setProfile(Profile)} to change the profile in this Sone.
+        *
+        * @return A copy of the profile
+        */
+       public Profile getProfile() {
+               return new Profile(profile);
+       }
+
+       /**
+        * Sets the profile of this Sone. A copy of the given profile is stored so that
+        * subsequent modifications of the given profile are not reflected in this
+        * Sone!
+        *
+        * @param profile
+        *              The profile to set
+        */
+       public void setProfile(Profile profile) {
+               this.profile = new Profile(profile);
+       }
+
+       /**
+        * Returns the client used by this Sone.
+        *
+        * @return The client used by this Sone, or {@code null}
+        */
+       public Client getClient() {
+               return client;
+       }
+
+       /**
+        * Sets the client used by this Sone.
+        *
+        * @param client
+        *              The client used by this Sone, or {@code null}
+        * @return This Sone (for method chaining)
+        */
+       public Sone setClient(Client client) {
+               this.client = client;
+               return this;
+       }
+
+       /**
+        * Returns whether this Sone is known.
+        *
+        * @return {@code true} if this Sone is known, {@code false} otherwise
+        */
+       public boolean isKnown() {
+               return known;
+       }
+
+       /**
+        * Sets whether this Sone is known.
+        *
+        * @param known
+        *              {@code true} if this Sone is known, {@code false} otherwise
+        * @return This Sone
+        */
+       public Sone setKnown(boolean known) {
+               this.known = known;
+               return this;
+       }
+
+       /**
+        * Returns all friend Sones of this Sone.
+        *
+        * @return The friend Sones of this Sone
+        */
+       public Collection<String> getFriends() {
+               return database.getFriends(this);
+       }
+
+       /**
+        * Returns whether this Sone has the given Sone as a friend Sone.
+        *
+        * @param friendSoneId
+        *              The ID of the Sone to check for
+        * @return {@code true} if this Sone has the given Sone as a friend, {@code
+        *         false} otherwise
+        */
+       public boolean hasFriend(String friendSoneId) {
+               return database.isFriend(this, friendSoneId);
+       }
+
+       /**
+        * Returns the list of posts of this Sone, sorted by time, newest first.
+        *
+        * @return All posts of this Sone
+        */
+       public List<Post> getPosts() {
+               List<Post> sortedPosts;
+               synchronized (this) {
+                       sortedPosts = new ArrayList<Post>(posts);
+               }
+               Collections.sort(sortedPosts, Post.TIME_COMPARATOR);
+               return sortedPosts;
+       }
+
+       /**
+        * Sets all posts of this Sone at once.
+        *
+        * @param posts
+        *              The new (and only) posts of this Sone
+        * @return This Sone (for method chaining)
+        */
+       public Sone setPosts(Collection<Post> posts) {
+               synchronized (this) {
+                       this.posts.clear();
+                       this.posts.addAll(posts);
+               }
+               return this;
+       }
+
+       /**
+        * Adds the given post to this Sone. The post will not be added if its {@link
+        * Post#getSone() Sone} is not this Sone.
+        *
+        * @param post
+        *              The post to add
+        */
+       public void addPost(Post post) {
+               if (post.getSone().equals(this) && posts.add(post)) {
+                       logger.log(Level.FINEST, String.format("Adding %s to “%s”.", post, getName()));
+               }
+       }
+
+       /**
+        * Removes the given post from this Sone.
+        *
+        * @param post
+        *              The post to remove
+        */
+       public void removePost(Post post) {
+               if (post.getSone().equals(this)) {
+                       posts.remove(post);
+               }
+       }
+
+       /**
+        * Returns all replies this Sone made.
+        *
+        * @return All replies this Sone made
+        */
+       public Set<PostReply> getReplies() {
+               return Collections.unmodifiableSet(replies);
+       }
+
+       /**
+        * Sets all replies of this Sone at once.
+        *
+        * @param replies
+        *              The new (and only) replies of this Sone
+        * @return This Sone (for method chaining)
+        */
+       public Sone setReplies(Collection<PostReply> replies) {
+               this.replies.clear();
+               this.replies.addAll(replies);
+               return this;
+       }
+
+       /**
+        * Adds a reply to this Sone. If the given reply was not made by this Sone,
+        * nothing is added to this Sone.
+        *
+        * @param reply
+        *              The reply to add
+        */
+       public void addReply(PostReply reply) {
+               if (reply.getSone().equals(this)) {
+                       replies.add(reply);
+               }
+       }
+
+       /**
+        * Removes a reply from this Sone.
+        *
+        * @param reply
+        *              The reply to remove
+        */
+       public void removeReply(PostReply reply) {
+               if (reply.getSone().equals(this)) {
+                       replies.remove(reply);
+               }
+       }
+
+       /**
+        * Returns the IDs of all liked posts.
+        *
+        * @return All liked posts’ IDs
+        */
+       public Set<String> getLikedPostIds() {
+               return Collections.unmodifiableSet(likedPostIds);
+       }
+
+       /**
+        * Sets the IDs of all liked posts.
+        *
+        * @param likedPostIds
+        *              All liked posts’ IDs
+        * @return This Sone (for method chaining)
+        */
+       public Sone setLikePostIds(Set<String> likedPostIds) {
+               this.likedPostIds.clear();
+               this.likedPostIds.addAll(likedPostIds);
+               return this;
+       }
+
+       /**
+        * Checks whether the given post ID is liked by this Sone.
+        *
+        * @param postId
+        *              The ID of the post
+        * @return {@code true} if this Sone likes the given post, {@code false}
+        *         otherwise
+        */
+       public boolean isLikedPostId(String postId) {
+               return likedPostIds.contains(postId);
+       }
+
+       /**
+        * Adds the given post ID to the list of posts this Sone likes.
+        *
+        * @param postId
+        *              The ID of the post
+        * @return This Sone (for method chaining)
+        */
+       public Sone addLikedPostId(String postId) {
+               likedPostIds.add(postId);
+               return this;
+       }
+
+       /**
+        * Removes the given post ID from the list of posts this Sone likes.
+        *
+        * @param postId
+        *              The ID of the post
+        * @return This Sone (for method chaining)
+        */
+       public Sone removeLikedPostId(String postId) {
+               likedPostIds.remove(postId);
+               return this;
+       }
+
+       /**
+        * Returns the IDs of all liked replies.
+        *
+        * @return All liked replies’ IDs
+        */
+       public Set<String> getLikedReplyIds() {
+               return Collections.unmodifiableSet(likedReplyIds);
+       }
+
+       /**
+        * Sets the IDs of all liked replies.
+        *
+        * @param likedReplyIds
+        *              All liked replies’ IDs
+        * @return This Sone (for method chaining)
+        */
+       public Sone setLikeReplyIds(Set<String> likedReplyIds) {
+               this.likedReplyIds.clear();
+               this.likedReplyIds.addAll(likedReplyIds);
+               return this;
+       }
+
+       /**
+        * Checks whether the given reply ID is liked by this Sone.
+        *
+        * @param replyId
+        *              The ID of the reply
+        * @return {@code true} if this Sone likes the given reply, {@code false}
+        *         otherwise
+        */
+       public boolean isLikedReplyId(String replyId) {
+               return likedReplyIds.contains(replyId);
+       }
+
+       /**
+        * Adds the given reply ID to the list of replies this Sone likes.
+        *
+        * @param replyId
+        *              The ID of the reply
+        * @return This Sone (for method chaining)
+        */
+       public Sone addLikedReplyId(String replyId) {
+               likedReplyIds.add(replyId);
+               return this;
+       }
+
+       /**
+        * Removes the given post ID from the list of replies this Sone likes.
+        *
+        * @param replyId
+        *              The ID of the reply
+        * @return This Sone (for method chaining)
+        */
+       public Sone removeLikedReplyId(String replyId) {
+               likedReplyIds.remove(replyId);
+               return this;
+       }
+
+       /**
+        * Returns the root album that contains all visible albums of this Sone.
+        *
+        * @return The root album of this Sone
+        */
+       public Album getRootAlbum() {
+               return rootAlbum;
+       }
+
+       /**
+        * Returns Sone-specific options.
+        *
+        * @return The options of this Sone
+        */
+       public SoneOptions getOptions() {
+               return options;
+       }
+
+       /**
+        * Sets the options of this Sone.
+        *
+        * @param options
+        *              The options of this Sone
+        */
+       /* TODO - remove this method again, maybe add an option provider */
+       public void setOptions(SoneOptions options) {
+               this.options = options;
+       }
+
+       //
+       // FINGERPRINTABLE METHODS
+       //
+
+       /** {@inheritDoc} */
+       @Override
+       public synchronized String getFingerprint() {
+               Hasher hash = Hashing.sha256().newHasher();
+               hash.putString(profile.getFingerprint());
+
+               hash.putString("Posts(");
+               for (Post post : getPosts()) {
+                       hash.putString("Post(").putString(post.getId()).putString(")");
+               }
+               hash.putString(")");
+
+               List<PostReply> replies = new ArrayList<PostReply>(getReplies());
+               Collections.sort(replies, Reply.TIME_COMPARATOR);
+               hash.putString("Replies(");
+               for (PostReply reply : replies) {
+                       hash.putString("Reply(").putString(reply.getId()).putString(")");
+               }
+               hash.putString(")");
+
+               List<String> likedPostIds = new ArrayList<String>(getLikedPostIds());
+               Collections.sort(likedPostIds);
+               hash.putString("LikedPosts(");
+               for (String likedPostId : likedPostIds) {
+                       hash.putString("Post(").putString(likedPostId).putString(")");
+               }
+               hash.putString(")");
+
+               List<String> likedReplyIds = new ArrayList<String>(getLikedReplyIds());
+               Collections.sort(likedReplyIds);
+               hash.putString("LikedReplies(");
+               for (String likedReplyId : likedReplyIds) {
+                       hash.putString("Reply(").putString(likedReplyId).putString(")");
+               }
+               hash.putString(")");
+
+               hash.putString("Albums(");
+               for (Album album : rootAlbum.getAlbums()) {
+                       if (!Album.NOT_EMPTY.apply(album)) {
+                               continue;
+                       }
+                       hash.putString(album.getFingerprint());
+               }
+               hash.putString(")");
+
+               return hash.hash().toString();
+       }
+
+       //
+       // INTERFACE Comparable<Sone>
+       //
+
+       /** {@inheritDoc} */
+       @Override
+       public int compareTo(Sone sone) {
+               return NICE_NAME_COMPARATOR.compare(this, sone);
+       }
+
+       //
+       // OBJECT METHODS
+       //
+
+       /** {@inheritDoc} */
+       @Override
+       public int hashCode() {
+               return id.hashCode();
+       }
+
+       /** {@inheritDoc} */
+       @Override
+       public boolean equals(Object object) {
+               if (!(object instanceof Sone)) {
+                       return false;
+               }
+               return ((Sone) object).getId().equals(id);
+       }
+
+       /** {@inheritDoc} */
+       @Override
+       public String toString() {
+               return getClass().getName() + "[identity=" + identity + ",posts(" + posts.size() + "),replies(" + replies.size() + "),albums(" + getRootAlbum().getAlbums().size() + ")]";
+       }
+
+}
index b888828..a084ae7 100644 (file)
@@ -18,6 +18,7 @@
 package net.pterodactylus.sone.database;
 
 import net.pterodactylus.sone.data.Album;
+import net.pterodactylus.sone.data.Sone;
 
 /**
  * Builder for {@link Album} objects.
@@ -42,6 +43,8 @@ public interface AlbumBuilder {
         */
        AlbumBuilder withId(String id);
 
+       AlbumBuilder by(Sone sone);
+
        /**
         * Creates the album.
         *
diff --git a/src/main/java/net/pterodactylus/sone/database/BookmarkDatabase.java b/src/main/java/net/pterodactylus/sone/database/BookmarkDatabase.java
new file mode 100644 (file)
index 0000000..6eb9c38
--- /dev/null
@@ -0,0 +1,19 @@
+package net.pterodactylus.sone.database;
+
+import java.util.Set;
+
+import net.pterodactylus.sone.data.Post;
+
+/**
+ * Database interface for bookmark-related functionality.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public interface BookmarkDatabase {
+
+       void bookmarkPost(Post post);
+       void unbookmarkPost(Post post);
+       boolean isPostBookmarked(Post post);
+       Set<Post> getBookmarkedPosts();
+
+}
index ee7a9af..971a427 100644 (file)
 
 package net.pterodactylus.sone.database;
 
+import net.pterodactylus.sone.database.memory.MemoryDatabase;
+
 import com.google.common.util.concurrent.Service;
+import com.google.inject.ImplementedBy;
 
 /**
- * Database for Sone data. This interface combines the various provider, store,
- * and builder factory interfaces into a single interface and adds some methods
- * necessary for lifecycle management.
+ * Database for Sone data. This interface combines the various provider,
+ * store, and builder factory interfaces into a single interface.
  *
  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
  */
-public interface Database extends Service, PostDatabase, PostReplyDatabase, AlbumDatabase, ImageDatabase {
+@ImplementedBy(MemoryDatabase.class)
+public interface Database extends Service, SoneDatabase, FriendDatabase, PostDatabase, PostReplyDatabase, AlbumDatabase, ImageDatabase, BookmarkDatabase {
 
        /**
         * Saves the database.
diff --git a/src/main/java/net/pterodactylus/sone/database/FriendDatabase.java b/src/main/java/net/pterodactylus/sone/database/FriendDatabase.java
new file mode 100644 (file)
index 0000000..761d356
--- /dev/null
@@ -0,0 +1,10 @@
+package net.pterodactylus.sone.database;
+
+/**
+ * Combines a {@link FriendProvider} and a {@link FriendStore} into a friend database.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public interface FriendDatabase extends FriendProvider, FriendStore {
+
+}
diff --git a/src/main/java/net/pterodactylus/sone/database/FriendProvider.java b/src/main/java/net/pterodactylus/sone/database/FriendProvider.java
new file mode 100644 (file)
index 0000000..3665d1b
--- /dev/null
@@ -0,0 +1,17 @@
+package net.pterodactylus.sone.database;
+
+import java.util.Collection;
+
+import net.pterodactylus.sone.data.Sone;
+
+/**
+ * Provides information about {@link Sone#getFriends() friends} of a {@link Sone}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public interface FriendProvider {
+
+       Collection<String> getFriends(Sone localSone);
+       boolean isFriend(Sone localSone, String friendSoneId);
+
+}
diff --git a/src/main/java/net/pterodactylus/sone/database/FriendStore.java b/src/main/java/net/pterodactylus/sone/database/FriendStore.java
new file mode 100644 (file)
index 0000000..38c1c80
--- /dev/null
@@ -0,0 +1,15 @@
+package net.pterodactylus.sone.database;
+
+import net.pterodactylus.sone.data.Sone;
+
+/**
+ * Stores information about the {@link Sone#getFriends() friends} of a {@link Sone}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public interface FriendStore {
+
+       void addFriend(Sone localSone, String friendSoneId);
+       void removeFriend(Sone localSone, String friendSoneId);
+
+}
index b89ae28..e74a6bd 100644 (file)
 
 package net.pterodactylus.sone.database;
 
+import net.pterodactylus.sone.database.memory.MemoryDatabase;
+
+import com.google.inject.ImplementedBy;
+
 /**
  * Factory for {@link PostBuilder}s.
  *
  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
  */
+@ImplementedBy(MemoryDatabase.class)
 public interface PostBuilderFactory {
 
        /**
index 740373e..7d5437a 100644 (file)
@@ -20,14 +20,17 @@ package net.pterodactylus.sone.database;
 import java.util.Collection;
 
 import net.pterodactylus.sone.data.Post;
+import net.pterodactylus.sone.database.memory.MemoryDatabase;
 
 import com.google.common.base.Optional;
+import com.google.inject.ImplementedBy;
 
 /**
  * Interface for objects that can provide {@link Post}s by their ID.
  *
  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
  */
+@ImplementedBy(MemoryDatabase.class)
 public interface PostProvider {
 
        /**
index 7fd4ae1..cac3e30 100644 (file)
 
 package net.pterodactylus.sone.database;
 
+import net.pterodactylus.sone.database.memory.MemoryDatabase;
+
+import com.google.inject.ImplementedBy;
+
 /**
  * Factory for {@link PostReplyBuilder}s.
  *
  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
  */
+@ImplementedBy(MemoryDatabase.class)
 public interface PostReplyBuilderFactory {
 
        /**
index a3cefb3..30268b8 100644 (file)
@@ -38,19 +38,6 @@ public interface PostReplyStore {
        public void storePostReply(PostReply postReply);
 
        /**
-        * Stores the given post replies as exclusive collection of post replies for
-        * the given Sone. This will remove all other post replies from this Sone!
-        *
-        * @param sone
-        *            The Sone to store all post replies for
-        * @param postReplies
-        *            The post replies of the Sone
-        * @throws IllegalArgumentException
-        *             if one of the replies does not belong to the given Sone
-        */
-       public void storePostReplies(Sone sone, Collection<PostReply> postReplies) throws IllegalArgumentException;
-
-       /**
         * Removes the given post reply from this store.
         *
         * @param postReply
@@ -58,12 +45,4 @@ public interface PostReplyStore {
         */
        public void removePostReply(PostReply postReply);
 
-       /**
-        * Removes all post replies of the given Sone.
-        *
-        * @param sone
-        *            The Sone to remove all post replies for
-        */
-       public void removePostReplies(Sone sone);
-
 }
index 9c2ca42..402a647 100644 (file)
@@ -45,25 +45,4 @@ public interface PostStore {
         */
        public void removePost(Post post);
 
-       /**
-        * Stores the given posts as all posts of a single {@link Sone}. This method
-        * will removed all other posts from the Sone!
-        *
-        * @param sone
-        *            The Sone to store the posts for
-        * @param posts
-        *            The posts to store
-        * @throws IllegalArgumentException
-        *             if posts do not all belong to the same Sone
-        */
-       public void storePosts(Sone sone, Collection<Post> posts) throws IllegalArgumentException;
-
-       /**
-        * Removes all posts of the given {@link Sone}
-        *
-        * @param sone
-        *            The Sone to remove all posts for
-        */
-       public void removePosts(Sone sone);
-
 }
diff --git a/src/main/java/net/pterodactylus/sone/database/SoneBuilder.java b/src/main/java/net/pterodactylus/sone/database/SoneBuilder.java
new file mode 100644 (file)
index 0000000..d2047af
--- /dev/null
@@ -0,0 +1,18 @@
+package net.pterodactylus.sone.database;
+
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.sone.freenet.wot.Identity;
+
+/**
+ * Builder for {@link Sone} objects.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public interface SoneBuilder {
+
+       SoneBuilder from(Identity identity);
+       SoneBuilder local();
+
+       Sone build() throws IllegalStateException;
+
+}
diff --git a/src/main/java/net/pterodactylus/sone/database/SoneBuilderFactory.java b/src/main/java/net/pterodactylus/sone/database/SoneBuilderFactory.java
new file mode 100644 (file)
index 0000000..c95251f
--- /dev/null
@@ -0,0 +1,12 @@
+package net.pterodactylus.sone.database;
+
+/**
+ * Factory for {@link SoneBuilder}s.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public interface SoneBuilderFactory {
+
+       SoneBuilder newSoneBuilder();
+
+}
diff --git a/src/main/java/net/pterodactylus/sone/database/SoneDatabase.java b/src/main/java/net/pterodactylus/sone/database/SoneDatabase.java
new file mode 100644 (file)
index 0000000..f5c5cda
--- /dev/null
@@ -0,0 +1,11 @@
+package net.pterodactylus.sone.database;
+
+/**
+ * Combines a {@link SoneProvider} and a {@link SoneStore} into a Sone
+ * database.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public interface SoneDatabase extends SoneProvider, SoneBuilderFactory, SoneStore {
+
+}
index 993804f..73467a2 100644 (file)
@@ -19,17 +19,23 @@ package net.pterodactylus.sone.database;
 
 import java.util.Collection;
 
+import net.pterodactylus.sone.core.Core;
 import net.pterodactylus.sone.data.Sone;
 
+import com.google.common.base.Function;
 import com.google.common.base.Optional;
+import com.google.inject.ImplementedBy;
 
 /**
  * Interface for objects that can provide {@link Sone}s by their ID.
  *
  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
  */
+@ImplementedBy(Core.class)
 public interface SoneProvider {
 
+       Function<String, Optional<Sone>> soneLoader();
+
        /**
         * Returns the Sone with the given ID, or {@link Optional#absent()} if it
         * does not exist.
diff --git a/src/main/java/net/pterodactylus/sone/database/SoneStore.java b/src/main/java/net/pterodactylus/sone/database/SoneStore.java
new file mode 100644 (file)
index 0000000..3684d48
--- /dev/null
@@ -0,0 +1,15 @@
+package net.pterodactylus.sone.database;
+
+import net.pterodactylus.sone.data.Sone;
+
+/**
+ * Interface for a store for {@link Sone}s.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public interface SoneStore {
+
+       void storeSone(Sone sone);
+       void removeSone(Sone sone);
+
+}
diff --git a/src/main/java/net/pterodactylus/sone/database/memory/ConfigurationLoader.java b/src/main/java/net/pterodactylus/sone/database/memory/ConfigurationLoader.java
new file mode 100644 (file)
index 0000000..1691ddb
--- /dev/null
@@ -0,0 +1,84 @@
+package net.pterodactylus.sone.database.memory;
+
+import static java.util.logging.Level.WARNING;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.logging.Logger;
+
+import net.pterodactylus.util.config.Configuration;
+import net.pterodactylus.util.config.ConfigurationException;
+
+/**
+ * Helper class for interacting with a {@link Configuration}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class ConfigurationLoader {
+
+       private static final Logger logger =
+                       Logger.getLogger("Sone.Database.Memory.Configuration");
+       private final Configuration configuration;
+
+       public ConfigurationLoader(Configuration configuration) {
+               this.configuration = configuration;
+       }
+
+       public synchronized Set<String> loadFriends(String localSoneId) {
+               return loadIds("Sone/" + localSoneId + "/Friends");
+       }
+
+       public void saveFriends(String soneId, Collection<String> friends) {
+               saveIds("Sone/" + soneId + "/Friends", friends);
+       }
+
+       public synchronized Set<String> loadKnownPosts() {
+               return loadIds("KnownPosts");
+       }
+
+       public synchronized Set<String> loadKnownPostReplies() {
+               return loadIds("KnownReplies");
+       }
+
+       public synchronized Set<String> loadBookmarkedPosts() {
+               return loadIds("Bookmarks/Post");
+       }
+
+       private Set<String> loadIds(String prefix) {
+               Set<String> ids = new HashSet<String>();
+               int idCounter = 0;
+               while (true) {
+                       String id = configuration
+                                       .getStringValue(prefix + "/" + idCounter++ + "/ID")
+                                       .getValue(null);
+                       if (id == null) {
+                               break;
+                       }
+                       ids.add(id);
+               }
+               return ids;
+       }
+
+       public synchronized void saveBookmarkedPosts(
+                       Set<String> bookmarkedPosts) {
+               saveIds("Bookmarks/Post", bookmarkedPosts);
+       }
+
+       private void saveIds(String prefix, Collection<String> ids) {
+               try {
+                       int idCounter = 0;
+                       for (String id : ids) {
+                               configuration
+                                               .getStringValue(prefix + "/" + idCounter++ + "/ID")
+                                               .setValue(id);
+                       }
+                       configuration
+                                       .getStringValue(prefix + "/" + idCounter + "/ID")
+                                       .setValue(null);
+               } catch (ConfigurationException ce1) {
+                       logger.log(WARNING, "Could not save bookmarked posts!", ce1);
+               }
+       }
+
+}
diff --git a/src/main/java/net/pterodactylus/sone/database/memory/MemoryBookmarkDatabase.java b/src/main/java/net/pterodactylus/sone/database/memory/MemoryBookmarkDatabase.java
new file mode 100644 (file)
index 0000000..594cf2b
--- /dev/null
@@ -0,0 +1,111 @@
+package net.pterodactylus.sone.database.memory;
+
+import static com.google.common.collect.FluentIterable.from;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.locks.ReadWriteLock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+import net.pterodactylus.sone.data.Post;
+import net.pterodactylus.sone.data.Post.EmptyPost;
+import net.pterodactylus.sone.database.BookmarkDatabase;
+
+import com.google.common.base.Function;
+
+/**
+ * Memory-based {@link BookmarkDatabase} implementation.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class MemoryBookmarkDatabase implements BookmarkDatabase {
+
+       private final ReadWriteLock lock = new ReentrantReadWriteLock();
+       private final MemoryDatabase memoryDatabase;
+       private final ConfigurationLoader configurationLoader;
+       private final Set<String> bookmarkedPosts = new HashSet<String>();
+
+       public MemoryBookmarkDatabase(MemoryDatabase memoryDatabase,
+                       ConfigurationLoader configurationLoader) {
+               this.memoryDatabase = memoryDatabase;
+               this.configurationLoader = configurationLoader;
+       }
+
+       public void start() {
+               loadBookmarkedPosts();
+       }
+
+       private void loadBookmarkedPosts() {
+               Set<String> bookmarkedPosts = configurationLoader.loadBookmarkedPosts();
+               lock.writeLock().lock();
+               try {
+                       this.bookmarkedPosts.clear();
+                       this.bookmarkedPosts.addAll(bookmarkedPosts);
+               } finally {
+                       lock.writeLock().unlock();
+               }
+       }
+
+       public void stop() {
+               saveBookmarkedPosts();
+       }
+
+       private void saveBookmarkedPosts() {
+               lock.readLock().lock();
+               try {
+                       configurationLoader.saveBookmarkedPosts(this.bookmarkedPosts);
+               } finally {
+                       lock.readLock().unlock();
+               }
+       }
+
+       @Override
+       public void bookmarkPost(Post post) {
+               lock.writeLock().lock();
+               try {
+                       bookmarkedPosts.add(post.getId());
+                       saveBookmarkedPosts();
+               } finally {
+                       lock.writeLock().unlock();
+               }
+       }
+
+       @Override
+       public void unbookmarkPost(Post post) {
+               lock.writeLock().lock();
+               try {
+                       bookmarkedPosts.remove(post.getId());
+                       saveBookmarkedPosts();
+               } finally {
+                       lock.writeLock().unlock();
+               }
+       }
+
+       @Override
+       public boolean isPostBookmarked(Post post) {
+               lock.readLock().lock();
+               try {
+                       return bookmarkedPosts.contains(post.getId());
+               } finally {
+                       lock.readLock().unlock();
+               }
+       }
+
+       @Override
+       public Set<Post> getBookmarkedPosts() {
+               lock.readLock().lock();
+               try {
+                       return from(bookmarkedPosts).transform(
+                                       new Function<String, Post>() {
+                                               @Override
+                                               public Post apply(String postId) {
+                                                       return memoryDatabase.getPost(postId)
+                                                                       .or(new EmptyPost(postId));
+                                               }
+                                       }).toSet();
+               } finally {
+                       lock.readLock().unlock();
+               }
+       }
+
+}
index 8b25954..b830ba5 100644 (file)
@@ -19,8 +19,13 @@ package net.pterodactylus.sone.database.memory;
 
 import static com.google.common.base.Optional.fromNullable;
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Predicates.not;
+import static com.google.common.collect.FluentIterable.from;
+import static net.pterodactylus.sone.data.Reply.TIME_COMPARATOR;
+import static net.pterodactylus.sone.data.Sone.LOCAL_SONE_FILTER;
+import static net.pterodactylus.sone.data.Sone.toAllAlbums;
+import static net.pterodactylus.sone.data.Sone.toAllImages;
 
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Comparator;
@@ -29,8 +34,6 @@ import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import java.util.SortedSet;
-import java.util.TreeSet;
 import java.util.concurrent.locks.ReadWriteLock;
 import java.util.concurrent.locks.ReentrantReadWriteLock;
 
@@ -38,7 +41,6 @@ import net.pterodactylus.sone.data.Album;
 import net.pterodactylus.sone.data.Image;
 import net.pterodactylus.sone.data.Post;
 import net.pterodactylus.sone.data.PostReply;
-import net.pterodactylus.sone.data.Reply;
 import net.pterodactylus.sone.data.Sone;
 import net.pterodactylus.sone.data.impl.AlbumBuilderImpl;
 import net.pterodactylus.sone.data.impl.ImageBuilderImpl;
@@ -49,21 +51,28 @@ import net.pterodactylus.sone.database.ImageBuilder;
 import net.pterodactylus.sone.database.PostBuilder;
 import net.pterodactylus.sone.database.PostDatabase;
 import net.pterodactylus.sone.database.PostReplyBuilder;
+import net.pterodactylus.sone.database.SoneBuilder;
 import net.pterodactylus.sone.database.SoneProvider;
 import net.pterodactylus.util.config.Configuration;
 import net.pterodactylus.util.config.ConfigurationException;
 
+import com.google.common.base.Function;
 import com.google.common.base.Optional;
+import com.google.common.base.Predicate;
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.Multimap;
 import com.google.common.collect.SortedSetMultimap;
 import com.google.common.collect.TreeMultimap;
 import com.google.common.util.concurrent.AbstractService;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 
 /**
  * Memory-based {@link PostDatabase} implementation.
  *
  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
  */
+@Singleton
 public class MemoryDatabase extends AbstractService implements Database {
 
        /** The lock. */
@@ -74,15 +83,15 @@ public class MemoryDatabase extends AbstractService implements Database {
 
        /** The configuration. */
        private final Configuration configuration;
+       private final ConfigurationLoader configurationLoader;
+
+       private final Map<String, Sone> allSones = new HashMap<String, Sone>();
 
        /** All posts by their ID. */
        private final Map<String, Post> allPosts = new HashMap<String, Post>();
 
        /** All posts by their Sones. */
-       private final Map<String, Collection<Post>> sonePosts = new HashMap<String, Collection<Post>>();
-
-       /** All posts by their recipient. */
-       private final Map<String, Collection<Post>> recipientPosts = new HashMap<String, Collection<Post>>();
+       private final Multimap<String, Post> sonePosts = HashMultimap.create();
 
        /** Whether posts are known. */
        private final Set<String> knownPosts = new HashSet<String>();
@@ -97,17 +106,19 @@ public class MemoryDatabase extends AbstractService implements Database {
                public int compare(String leftString, String rightString) {
                        return leftString.compareTo(rightString);
                }
-       }, PostReply.TIME_COMPARATOR);
-
-       /** Replies by post. */
-       private final Map<String, SortedSet<PostReply>> postReplies = new HashMap<String, SortedSet<PostReply>>();
+       }, TIME_COMPARATOR);
 
        /** Whether post replies are known. */
        private final Set<String> knownPostReplies = new HashSet<String>();
 
        private final Map<String, Album> allAlbums = new HashMap<String, Album>();
+       private final Multimap<String, Album> soneAlbums = HashMultimap.create();
 
        private final Map<String, Image> allImages = new HashMap<String, Image>();
+       private final Multimap<String, Image> soneImages = HashMultimap.create();
+
+       private final MemoryBookmarkDatabase memoryBookmarkDatabase;
+       private final MemoryFriendDatabase memoryFriendDatabase;
 
        /**
         * Creates a new memory database.
@@ -121,6 +132,10 @@ public class MemoryDatabase extends AbstractService implements Database {
        public MemoryDatabase(SoneProvider soneProvider, Configuration configuration) {
                this.soneProvider = soneProvider;
                this.configuration = configuration;
+               this.configurationLoader = new ConfigurationLoader(configuration);
+               memoryBookmarkDatabase =
+                               new MemoryBookmarkDatabase(this, configurationLoader);
+               memoryFriendDatabase = new MemoryFriendDatabase(configurationLoader);
        }
 
        //
@@ -146,6 +161,7 @@ public class MemoryDatabase extends AbstractService implements Database {
        /** {@inheritDocs} */
        @Override
        protected void doStart() {
+               memoryBookmarkDatabase.start();
                loadKnownPosts();
                loadKnownPostReplies();
                notifyStarted();
@@ -155,6 +171,7 @@ public class MemoryDatabase extends AbstractService implements Database {
        @Override
        protected void doStop() {
                try {
+                       memoryBookmarkDatabase.stop();
                        save();
                        notifyStopped();
                } catch (DatabaseException de1) {
@@ -162,6 +179,151 @@ public class MemoryDatabase extends AbstractService implements Database {
                }
        }
 
+       @Override
+       public SoneBuilder newSoneBuilder() {
+               return new MemorySoneBuilder(this);
+       }
+
+       @Override
+       public void storeSone(Sone sone) {
+               lock.writeLock().lock();
+               try {
+                       removeSone(sone);
+
+                       allSones.put(sone.getId(), sone);
+                       sonePosts.putAll(sone.getId(), sone.getPosts());
+                       for (Post post : sone.getPosts()) {
+                               allPosts.put(post.getId(), post);
+                       }
+                       sonePostReplies.putAll(sone.getId(), sone.getReplies());
+                       for (PostReply postReply : sone.getReplies()) {
+                               allPostReplies.put(postReply.getId(), postReply);
+                       }
+                       soneAlbums.putAll(sone.getId(), toAllAlbums.apply(sone));
+                       for (Album album : toAllAlbums.apply(sone)) {
+                               allAlbums.put(album.getId(), album);
+                       }
+                       soneImages.putAll(sone.getId(), toAllImages.apply(sone));
+                       for (Image image : toAllImages.apply(sone)) {
+                               allImages.put(image.getId(), image);
+                       }
+               } finally {
+                       lock.writeLock().unlock();
+               }
+       }
+
+       @Override
+       public void removeSone(Sone sone) {
+               lock.writeLock().lock();
+               try {
+                       allSones.remove(sone.getId());
+                       Collection<Post> removedPosts = sonePosts.removeAll(sone.getId());
+                       for (Post removedPost : removedPosts) {
+                               allPosts.remove(removedPost.getId());
+                       }
+                       Collection<PostReply> removedPostReplies =
+                                       sonePostReplies.removeAll(sone.getId());
+                       for (PostReply removedPostReply : removedPostReplies) {
+                               allPostReplies.remove(removedPostReply.getId());
+                       }
+                       Collection<Album> removedAlbums =
+                                       soneAlbums.removeAll(sone.getId());
+                       for (Album removedAlbum : removedAlbums) {
+                               allAlbums.remove(removedAlbum.getId());
+                       }
+                       Collection<Image> removedImages =
+                                       soneImages.removeAll(sone.getId());
+                       for (Image removedImage : removedImages) {
+                               allImages.remove(removedImage.getId());
+                       }
+               } finally {
+                       lock.writeLock().unlock();
+               }
+       }
+
+       @Override
+       public Function<String, Optional<Sone>> soneLoader() {
+               return new Function<String, Optional<Sone>>() {
+                       @Override
+                       public Optional<Sone> apply(String soneId) {
+                               return getSone(soneId);
+                       }
+               };
+       }
+
+       @Override
+       public Optional<Sone> getSone(String soneId) {
+               lock.readLock().lock();
+               try {
+                       return fromNullable(allSones.get(soneId));
+               } finally {
+                       lock.readLock().unlock();
+               }
+       }
+
+       @Override
+       public Collection<Sone> getSones() {
+               lock.readLock().lock();
+               try {
+                       return new HashSet<Sone>(allSones.values());
+               } finally {
+                       lock.readLock().unlock();
+               }
+       }
+
+       @Override
+       public Collection<Sone> getLocalSones() {
+               lock.readLock().lock();
+               try {
+                       return from(allSones.values()).filter(LOCAL_SONE_FILTER).toSet();
+               } finally {
+                       lock.readLock().unlock();
+               }
+       }
+
+       @Override
+       public Collection<Sone> getRemoteSones() {
+               lock.readLock().lock();
+               try {
+                       return from(allSones.values())
+                                       .filter(not(LOCAL_SONE_FILTER)) .toSet();
+               } finally {
+                       lock.readLock().unlock();
+               }
+       }
+
+       @Override
+       public Collection<String> getFriends(Sone localSone) {
+               if (!localSone.isLocal()) {
+                       return Collections.emptySet();
+               }
+               return memoryFriendDatabase.getFriends(localSone.getId());
+       }
+
+       @Override
+       public boolean isFriend(Sone localSone, String friendSoneId) {
+               if (!localSone.isLocal()) {
+                       return false;
+               }
+               return memoryFriendDatabase.isFriend(localSone.getId(), friendSoneId);
+       }
+
+       @Override
+       public void addFriend(Sone localSone, String friendSoneId) {
+               if (!localSone.isLocal()) {
+                       return;
+               }
+               memoryFriendDatabase.addFriend(localSone.getId(), friendSoneId);
+       }
+
+       @Override
+       public void removeFriend(Sone localSone, String friendSoneId) {
+               if (!localSone.isLocal()) {
+                       return;
+               }
+               memoryFriendDatabase.removeFriend(localSone.getId(), friendSoneId);
+       }
+
        //
        // POSTPROVIDER METHODS
        //
@@ -185,11 +347,15 @@ public class MemoryDatabase extends AbstractService implements Database {
 
        /** {@inheritDocs} */
        @Override
-       public Collection<Post> getDirectedPosts(String recipientId) {
+       public Collection<Post> getDirectedPosts(final String recipientId) {
                lock.readLock().lock();
                try {
-                       Collection<Post> posts = recipientPosts.get(recipientId);
-                       return (posts == null) ? Collections.<Post>emptySet() : new HashSet<Post>(posts);
+                       return from(sonePosts.values()).filter(new Predicate<Post>() {
+                               @Override
+                               public boolean apply(Post post) {
+                                       return post.getRecipientId().asSet().contains(recipientId);
+                               }
+                       }).toSet();
                } finally {
                        lock.readLock().unlock();
                }
@@ -217,9 +383,6 @@ public class MemoryDatabase extends AbstractService implements Database {
                try {
                        allPosts.put(post.getId(), post);
                        getPostsFrom(post.getSone().getId()).add(post);
-                       if (post.getRecipientId().isPresent()) {
-                               getPostsTo(post.getRecipientId().get()).add(post);
-                       }
                } finally {
                        lock.writeLock().unlock();
                }
@@ -233,69 +396,12 @@ public class MemoryDatabase extends AbstractService implements Database {
                try {
                        allPosts.remove(post.getId());
                        getPostsFrom(post.getSone().getId()).remove(post);
-                       if (post.getRecipientId().isPresent()) {
-                               getPostsTo(post.getRecipientId().get()).remove(post);
-                       }
                        post.getSone().removePost(post);
                } finally {
                        lock.writeLock().unlock();
                }
        }
 
-       /** {@inheritDocs} */
-       @Override
-       public void storePosts(Sone sone, Collection<Post> posts) throws IllegalArgumentException {
-               checkNotNull(sone, "sone must not be null");
-               /* verify that all posts are from the same Sone. */
-               for (Post post : posts) {
-                       if (!sone.equals(post.getSone())) {
-                               throw new IllegalArgumentException(String.format("Post from different Sone found: %s", post));
-                       }
-               }
-
-               lock.writeLock().lock();
-               try {
-                       /* remove all posts by the Sone. */
-                       getPostsFrom(sone.getId()).clear();
-                       for (Post post : posts) {
-                               allPosts.remove(post.getId());
-                               if (post.getRecipientId().isPresent()) {
-                                       getPostsTo(post.getRecipientId().get()).remove(post);
-                               }
-                       }
-
-                       /* add new posts. */
-                       getPostsFrom(sone.getId()).addAll(posts);
-                       for (Post post : posts) {
-                               allPosts.put(post.getId(), post);
-                               if (post.getRecipientId().isPresent()) {
-                                       getPostsTo(post.getRecipientId().get()).add(post);
-                               }
-                       }
-               } finally {
-                       lock.writeLock().unlock();
-               }
-       }
-
-       /** {@inheritDocs} */
-       @Override
-       public void removePosts(Sone sone) {
-               checkNotNull(sone, "sone must not be null");
-               lock.writeLock().lock();
-               try {
-                       /* remove all posts by the Sone. */
-                       getPostsFrom(sone.getId()).clear();
-                       for (Post post : sone.getPosts()) {
-                               allPosts.remove(post.getId());
-                               if (post.getRecipientId().isPresent()) {
-                                       getPostsTo(post.getRecipientId().get()).remove(post);
-                               }
-                       }
-               } finally {
-                       lock.writeLock().unlock();
-               }
-       }
-
        //
        // POSTREPLYPROVIDER METHODS
        //
@@ -313,13 +419,16 @@ public class MemoryDatabase extends AbstractService implements Database {
 
        /** {@inheritDocs} */
        @Override
-       public List<PostReply> getReplies(String postId) {
+       public List<PostReply> getReplies(final String postId) {
                lock.readLock().lock();
                try {
-                       if (!postReplies.containsKey(postId)) {
-                               return Collections.emptyList();
-                       }
-                       return new ArrayList<PostReply>(postReplies.get(postId));
+                       return from(allPostReplies.values())
+                                       .filter(new Predicate<PostReply>() {
+                                               @Override
+                                               public boolean apply(PostReply postReply) {
+                                                       return postReply.getPostId().equals(postId);
+                                               }
+                                       }).toSortedList(TIME_COMPARATOR);
                } finally {
                        lock.readLock().unlock();
                }
@@ -345,46 +454,6 @@ public class MemoryDatabase extends AbstractService implements Database {
                lock.writeLock().lock();
                try {
                        allPostReplies.put(postReply.getId(), postReply);
-                       if (postReplies.containsKey(postReply.getPostId())) {
-                               postReplies.get(postReply.getPostId()).add(postReply);
-                       } else {
-                               TreeSet<PostReply> replies = new TreeSet<PostReply>(Reply.TIME_COMPARATOR);
-                               replies.add(postReply);
-                               postReplies.put(postReply.getPostId(), replies);
-                       }
-               } finally {
-                       lock.writeLock().unlock();
-               }
-       }
-
-       /** {@inheritDocs} */
-       @Override
-       public void storePostReplies(Sone sone, Collection<PostReply> postReplies) {
-               checkNotNull(sone, "sone must not be null");
-               /* verify that all posts are from the same Sone. */
-               for (PostReply postReply : postReplies) {
-                       if (!sone.equals(postReply.getSone())) {
-                               throw new IllegalArgumentException(String.format("PostReply from different Sone found: %s", postReply));
-                       }
-               }
-
-               lock.writeLock().lock();
-               try {
-                       /* remove all post replies of the Sone. */
-                       for (PostReply postReply : getRepliesFrom(sone.getId())) {
-                               removePostReply(postReply);
-                       }
-                       for (PostReply postReply : postReplies) {
-                               allPostReplies.put(postReply.getId(), postReply);
-                               sonePostReplies.put(postReply.getSone().getId(), postReply);
-                               if (this.postReplies.containsKey(postReply.getPostId())) {
-                                       this.postReplies.get(postReply.getPostId()).add(postReply);
-                               } else {
-                                       TreeSet<PostReply> replies = new TreeSet<PostReply>(Reply.TIME_COMPARATOR);
-                                       replies.add(postReply);
-                                       this.postReplies.put(postReply.getPostId(), replies);
-                               }
-                       }
                } finally {
                        lock.writeLock().unlock();
                }
@@ -396,27 +465,6 @@ public class MemoryDatabase extends AbstractService implements Database {
                lock.writeLock().lock();
                try {
                        allPostReplies.remove(postReply.getId());
-                       if (postReplies.containsKey(postReply.getPostId())) {
-                               postReplies.get(postReply.getPostId()).remove(postReply);
-                               if (postReplies.get(postReply.getPostId()).isEmpty()) {
-                                       postReplies.remove(postReply.getPostId());
-                               }
-                       }
-               } finally {
-                       lock.writeLock().unlock();
-               }
-       }
-
-       /** {@inheritDocs} */
-       @Override
-       public void removePostReplies(Sone sone) {
-               checkNotNull(sone, "sone must not be null");
-
-               lock.writeLock().lock();
-               try {
-                       for (PostReply postReply : sone.getReplies()) {
-                               removePostReply(postReply);
-                       }
                } finally {
                        lock.writeLock().unlock();
                }
@@ -454,6 +502,7 @@ public class MemoryDatabase extends AbstractService implements Database {
                lock.writeLock().lock();
                try {
                        allAlbums.put(album.getId(), album);
+                       soneAlbums.put(album.getSone().getId(), album);
                } finally {
                        lock.writeLock().unlock();
                }
@@ -464,6 +513,7 @@ public class MemoryDatabase extends AbstractService implements Database {
                lock.writeLock().lock();
                try {
                        allAlbums.remove(album.getId());
+                       soneAlbums.remove(album.getSone().getId(), album);
                } finally {
                        lock.writeLock().unlock();
                }
@@ -501,6 +551,7 @@ public class MemoryDatabase extends AbstractService implements Database {
                lock.writeLock().lock();
                try {
                        allImages.put(image.getId(), image);
+                       soneImages.put(image.getSone().getId(), image);
                } finally {
                        lock.writeLock().unlock();
                }
@@ -511,11 +562,32 @@ public class MemoryDatabase extends AbstractService implements Database {
                lock.writeLock().lock();
                try {
                        allImages.remove(image.getId());
+                       soneImages.remove(image.getSone().getId(), image);
                } finally {
                        lock.writeLock().unlock();
                }
        }
 
+       @Override
+       public void bookmarkPost(Post post) {
+               memoryBookmarkDatabase.bookmarkPost(post);
+       }
+
+       @Override
+       public void unbookmarkPost(Post post) {
+               memoryBookmarkDatabase.unbookmarkPost(post);
+       }
+
+       @Override
+       public boolean isPostBookmarked(Post post) {
+               return memoryBookmarkDatabase.isPostBookmarked(post);
+       }
+
+       @Override
+       public Set<Post> getBookmarkedPosts() {
+               return memoryBookmarkDatabase.getBookmarkedPosts();
+       }
+
        //
        // PACKAGE-PRIVATE METHODS
        //
@@ -608,71 +680,21 @@ public class MemoryDatabase extends AbstractService implements Database {
         * @return All posts
         */
        private Collection<Post> getPostsFrom(String soneId) {
-               Collection<Post> posts = null;
                lock.readLock().lock();
                try {
-                       posts = sonePosts.get(soneId);
+                       return sonePosts.get(soneId);
                } finally {
                        lock.readLock().unlock();
                }
-               if (posts != null) {
-                       return posts;
-               }
-
-               posts = new HashSet<Post>();
-               lock.writeLock().lock();
-               try {
-                       sonePosts.put(soneId, posts);
-               } finally {
-                       lock.writeLock().unlock();
-               }
-
-               return posts;
-       }
-
-       /**
-        * Gets all posts that are directed the given Sone, creating a new collection
-        * if there is none yet.
-        *
-        * @param recipientId
-        *              The ID of the Sone to get the posts for
-        * @return All posts
-        */
-       private Collection<Post> getPostsTo(String recipientId) {
-               Collection<Post> posts = null;
-               lock.readLock().lock();
-               try {
-                       posts = recipientPosts.get(recipientId);
-               } finally {
-                       lock.readLock().unlock();
-               }
-               if (posts != null) {
-                       return posts;
-               }
-
-               posts = new HashSet<Post>();
-               lock.writeLock().lock();
-               try {
-                       recipientPosts.put(recipientId, posts);
-               } finally {
-                       lock.writeLock().unlock();
-               }
-
-               return posts;
        }
 
        /** Loads the known posts. */
        private void loadKnownPosts() {
+               Set<String> knownPosts = configurationLoader.loadKnownPosts();
                lock.writeLock().lock();
                try {
-                       int postCounter = 0;
-                       while (true) {
-                               String knownPostId = configuration.getStringValue("KnownPosts/" + postCounter++ + "/ID").getValue(null);
-                               if (knownPostId == null) {
-                                       break;
-                               }
-                               knownPosts.add(knownPostId);
-                       }
+                       this.knownPosts.clear();
+                       this.knownPosts.addAll(knownPosts);
                } finally {
                        lock.writeLock().unlock();
                }
@@ -699,37 +721,13 @@ public class MemoryDatabase extends AbstractService implements Database {
                }
        }
 
-       /**
-        * Returns all replies by the given Sone.
-        *
-        * @param id
-        *              The ID of the Sone
-        * @return The post replies of the Sone, sorted by time (newest first)
-        */
-       private Collection<PostReply> getRepliesFrom(String id) {
-               lock.readLock().lock();
-               try {
-                       if (sonePostReplies.containsKey(id)) {
-                               return Collections.unmodifiableCollection(sonePostReplies.get(id));
-                       }
-                       return Collections.emptySet();
-               } finally {
-                       lock.readLock().unlock();
-               }
-       }
-
        /** Loads the known post replies. */
        private void loadKnownPostReplies() {
+               Set<String> knownPostReplies = configurationLoader.loadKnownPostReplies();
                lock.writeLock().lock();
                try {
-                       int replyCounter = 0;
-                       while (true) {
-                               String knownReplyId = configuration.getStringValue("KnownReplies/" + replyCounter++ + "/ID").getValue(null);
-                               if (knownReplyId == null) {
-                                       break;
-                               }
-                               knownPostReplies.add(knownReplyId);
-                       }
+                       this.knownPostReplies.clear();
+                       this.knownPostReplies.addAll(knownPostReplies);
                } finally {
                        lock.writeLock().unlock();
                }
diff --git a/src/main/java/net/pterodactylus/sone/database/memory/MemoryFriendDatabase.java b/src/main/java/net/pterodactylus/sone/database/memory/MemoryFriendDatabase.java
new file mode 100644 (file)
index 0000000..0be8738
--- /dev/null
@@ -0,0 +1,81 @@
+package net.pterodactylus.sone.database.memory;
+
+import java.util.Collection;
+import java.util.concurrent.locks.ReadWriteLock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.Multimap;
+
+/**
+ * In-memory implementation of friend-related functionality.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+class MemoryFriendDatabase {
+
+       private final ReadWriteLock lock = new ReentrantReadWriteLock();
+       private final ConfigurationLoader configurationLoader;
+       private final Multimap<String, String> soneFriends = HashMultimap.create();
+
+       MemoryFriendDatabase(ConfigurationLoader configurationLoader) {
+               this.configurationLoader = configurationLoader;
+       }
+
+       Collection<String> getFriends(String localSoneId) {
+               loadFriends(localSoneId);
+               lock.readLock().lock();
+               try {
+                       return soneFriends.get(localSoneId);
+               } finally {
+                       lock.readLock().unlock();
+               }
+       }
+
+       boolean isFriend(String localSoneId, String friendSoneId) {
+               loadFriends(localSoneId);
+               lock.readLock().lock();
+               try {
+                       return soneFriends.containsEntry(localSoneId, friendSoneId);
+               } finally {
+                       lock.readLock().unlock();
+               }
+       }
+
+       void addFriend(String localSoneId, String friendSoneId) {
+               loadFriends(localSoneId);
+               lock.writeLock().lock();
+               try {
+                       if (soneFriends.put(localSoneId, friendSoneId)) {
+                               configurationLoader.saveFriends(localSoneId, soneFriends.get(localSoneId));
+                       }
+               } finally {
+                       lock.writeLock().unlock();
+               }
+       }
+
+       void removeFriend(String localSoneId, String friendSoneId) {
+               loadFriends(localSoneId);
+               lock.writeLock().lock();
+               try {
+                       if (soneFriends.remove(localSoneId, friendSoneId)) {
+                               configurationLoader.saveFriends(localSoneId, soneFriends.get(localSoneId));
+                       }
+               } finally {
+                       lock.writeLock().unlock();
+               }
+       }
+
+       private void loadFriends(String localSoneId) {
+               lock.writeLock().lock();
+               try {
+                       if (soneFriends.containsKey(localSoneId)) {
+                               return;
+                       }
+                       soneFriends.putAll(localSoneId, configurationLoader.loadFriends(localSoneId));
+               } finally {
+                       lock.writeLock().unlock();
+               }
+       }
+
+}
index 22fa7e6..180cf6c 100644 (file)
@@ -94,6 +94,11 @@ class MemoryPost implements Post {
                return id.toString();
        }
 
+       @Override
+       public boolean isLoaded() {
+               return true;
+       }
+
        /**
         * {@inheritDoc}
         */
diff --git a/src/main/java/net/pterodactylus/sone/database/memory/MemorySoneBuilder.java b/src/main/java/net/pterodactylus/sone/database/memory/MemorySoneBuilder.java
new file mode 100644 (file)
index 0000000..49531a1
--- /dev/null
@@ -0,0 +1,27 @@
+package net.pterodactylus.sone.database.memory;
+
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.sone.data.impl.SoneImpl;
+import net.pterodactylus.sone.data.impl.AbstractSoneBuilder;
+import net.pterodactylus.sone.database.Database;
+
+/**
+ * Memory-based {@link AbstractSoneBuilder} implementation.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class MemorySoneBuilder extends AbstractSoneBuilder {
+
+       private final Database database;
+
+       public MemorySoneBuilder(Database database) {
+               this.database = database;
+       }
+
+       @Override
+       public Sone build() throws IllegalStateException {
+               validate();
+               return new SoneImpl(database, identity, local);
+       }
+
+}
index 02d84cc..839930c 100644 (file)
 package net.pterodactylus.sone.fcp;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.logging.Logger.getLogger;
 
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
 import net.pterodactylus.sone.core.Core;
+import net.pterodactylus.sone.fcp.event.FcpInterfaceActivatedEvent;
+import net.pterodactylus.sone.fcp.event.FcpInterfaceDeactivatedEvent;
+import net.pterodactylus.sone.fcp.event.FullAccessRequiredChanged;
 import net.pterodactylus.sone.freenet.fcp.Command.AccessType;
 import net.pterodactylus.sone.freenet.fcp.Command.ErrorResponse;
 import net.pterodactylus.sone.freenet.fcp.Command.Response;
-import net.pterodactylus.util.logging.Logging;
-
-import com.google.inject.Inject;
 
 import freenet.pluginmanager.FredPluginFCP;
 import freenet.pluginmanager.PluginNotFoundException;
@@ -39,12 +42,18 @@ import freenet.pluginmanager.PluginReplySender;
 import freenet.support.SimpleFieldSet;
 import freenet.support.api.Bucket;
 
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.eventbus.Subscribe;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
 /**
  * Implementation of an FCP interface for other clients or plugins to
  * communicate with Sone.
  *
  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
  */
+@Singleton
 public class FcpInterface {
 
        /**
@@ -66,13 +75,13 @@ public class FcpInterface {
        }
 
        /** The logger. */
-       private static final Logger logger = Logging.getLogger(FcpInterface.class);
+       private static final Logger logger = getLogger("Sone.External.Fcp");
 
        /** Whether the FCP interface is currently active. */
-       private volatile boolean active;
+       private final AtomicBoolean active = new AtomicBoolean();
 
        /** What function full access is required for. */
-       private volatile FullAccessRequired fullAccessRequired = FullAccessRequired.ALWAYS;
+       private final AtomicReference<FullAccessRequired> fullAccessRequired = new AtomicReference<FullAccessRequired>(FullAccessRequired.ALWAYS);
 
        /** All available FCP commands. */
        private final Map<String, AbstractSoneCommand> commands = Collections.synchronizedMap(new HashMap<String, AbstractSoneCommand>());
@@ -106,26 +115,22 @@ public class FcpInterface {
        // ACCESSORS
        //
 
-       /**
-        * Sets whether the FCP interface should handle requests. If {@code active}
-        * is {@code false}, all requests are answered with an error.
-        *
-        * @param active
-        *            {@code true} to activate the FCP interface, {@code false} to
-        *            deactivate the FCP interface
-        */
-       public void setActive(boolean active) {
-               this.active = active;
+       @VisibleForTesting
+       boolean isActive() {
+               return active.get();
        }
 
-       /**
-        * Sets the action level for which full FCP access is required.
-        *
-        * @param fullAccessRequired
-        *            The action level for which full FCP access is required
-        */
-       public void setFullAccessRequired(FullAccessRequired fullAccessRequired) {
-               this.fullAccessRequired = checkNotNull(fullAccessRequired, "fullAccessRequired must not be null");
+       private void setActive(boolean active) {
+               this.active.set(active);
+       }
+
+       @VisibleForTesting
+       FullAccessRequired getFullAccessRequired() {
+               return fullAccessRequired.get();
+       }
+
+       private void setFullAccessRequired(FullAccessRequired fullAccessRequired) {
+               this.fullAccessRequired.set(checkNotNull(fullAccessRequired, "fullAccessRequired must not be null"));
        }
 
        //
@@ -147,7 +152,7 @@ public class FcpInterface {
         *            {@link FredPluginFCP#ACCESS_FCP_RESTRICTED}
         */
        public void handle(PluginReplySender pluginReplySender, SimpleFieldSet parameters, Bucket data, int accessType) {
-               if (!active) {
+               if (!active.get()) {
                        try {
                                sendReply(pluginReplySender, null, new ErrorResponse(400, "FCP Interface deactivated"));
                        } catch (PluginNotFoundException pnfe1) {
@@ -156,7 +161,7 @@ public class FcpInterface {
                        return;
                }
                AbstractSoneCommand command = commands.get(parameters.get("Message"));
-               if ((accessType == FredPluginFCP.ACCESS_FCP_RESTRICTED) && (((fullAccessRequired == FullAccessRequired.WRITING) && command.requiresWriteAccess()) || (fullAccessRequired == FullAccessRequired.ALWAYS))) {
+               if ((accessType == FredPluginFCP.ACCESS_FCP_RESTRICTED) && (((fullAccessRequired.get() == FullAccessRequired.WRITING) && command.requiresWriteAccess()) || (fullAccessRequired.get() == FullAccessRequired.ALWAYS))) {
                        try {
                                sendReply(pluginReplySender, null, new ErrorResponse(401, "Not authorized"));
                        } catch (PluginNotFoundException pnfe1) {
@@ -216,4 +221,19 @@ public class FcpInterface {
                }
        }
 
+       @Subscribe
+       public void fcpInterfaceActivated(FcpInterfaceActivatedEvent fcpInterfaceActivatedEvent) {
+               setActive(true);
+       }
+
+       @Subscribe
+       public void fcpInterfaceDeactivated(FcpInterfaceDeactivatedEvent fcpInterfaceDeactivatedEvent) {
+               setActive(false);
+       }
+
+       @Subscribe
+       public void fullAccessRequiredChanged(FullAccessRequiredChanged fullAccessRequiredChanged) {
+               setFullAccessRequired(fullAccessRequiredChanged.getFullAccessRequired());
+       }
+
 }
diff --git a/src/main/java/net/pterodactylus/sone/fcp/event/FcpInterfaceActivatedEvent.java b/src/main/java/net/pterodactylus/sone/fcp/event/FcpInterfaceActivatedEvent.java
new file mode 100644 (file)
index 0000000..56b658a
--- /dev/null
@@ -0,0 +1,13 @@
+package net.pterodactylus.sone.fcp.event;
+
+import net.pterodactylus.sone.fcp.FcpInterface;
+
+/**
+ * Event that signals that the {@link FcpInterface} was activated in the
+ * configuration.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class FcpInterfaceActivatedEvent {
+
+}
diff --git a/src/main/java/net/pterodactylus/sone/fcp/event/FcpInterfaceDeactivatedEvent.java b/src/main/java/net/pterodactylus/sone/fcp/event/FcpInterfaceDeactivatedEvent.java
new file mode 100644 (file)
index 0000000..b97ef76
--- /dev/null
@@ -0,0 +1,13 @@
+package net.pterodactylus.sone.fcp.event;
+
+import net.pterodactylus.sone.fcp.FcpInterface;
+
+/**
+ * Event that signals that the {@link FcpInterface} was deactivated in the
+ * configuration.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class FcpInterfaceDeactivatedEvent {
+
+}
diff --git a/src/main/java/net/pterodactylus/sone/fcp/event/FullAccessRequiredChanged.java b/src/main/java/net/pterodactylus/sone/fcp/event/FullAccessRequiredChanged.java
new file mode 100644 (file)
index 0000000..9638880
--- /dev/null
@@ -0,0 +1,24 @@
+package net.pterodactylus.sone.fcp.event;
+
+import net.pterodactylus.sone.fcp.FcpInterface;
+import net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired;
+
+/**
+ * Event that signals that the {@link FcpInterface}’s {@link
+ * FullAccessRequired} parameter was changed in the configuration.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class FullAccessRequiredChanged {
+
+       private final FullAccessRequired fullAccessRequired;
+
+       public FullAccessRequiredChanged(FullAccessRequired fullAccessRequired) {
+               this.fullAccessRequired = fullAccessRequired;
+       }
+
+       public FullAccessRequired getFullAccessRequired() {
+               return fullAccessRequired;
+       }
+
+}
diff --git a/src/main/java/net/pterodactylus/sone/freenet/Key.java b/src/main/java/net/pterodactylus/sone/freenet/Key.java
new file mode 100644 (file)
index 0000000..f21e2f6
--- /dev/null
@@ -0,0 +1,67 @@
+package net.pterodactylus.sone.freenet;
+
+import static freenet.support.Base64.encode;
+import static java.lang.String.format;
+
+import freenet.keys.FreenetURI;
+
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * Encapsulates the parts of a {@link FreenetURI} that do not change while
+ * being converted from SSK to USK and/or back.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class Key {
+
+       private final byte[] routingKey;
+       private final byte[] cryptoKey;
+       private final byte[] extra;
+
+       private Key(byte[] routingKey, byte[] cryptoKey, byte[] extra) {
+               this.routingKey = routingKey;
+               this.cryptoKey = cryptoKey;
+               this.extra = extra;
+       }
+
+       @VisibleForTesting
+       public String getRoutingKey() {
+               return encode(routingKey);
+       }
+
+       @VisibleForTesting
+       public String getCryptoKey() {
+               return encode(cryptoKey);
+       }
+
+       @VisibleForTesting
+       public String getExtra() {
+               return encode(extra);
+       }
+
+       public FreenetURI toUsk(String docName, long edition, String... paths) {
+               return new FreenetURI("USK", docName, paths, routingKey, cryptoKey,
+                               extra, edition);
+       }
+
+       public FreenetURI toSsk(String docName, String... paths) {
+               return new FreenetURI("SSK", docName, paths, routingKey, cryptoKey,
+                               extra);
+       }
+
+       public FreenetURI toSsk(String docName, long edition, String... paths) {
+               return new FreenetURI("SSK", format("%s-%d", docName, edition), paths,
+                               routingKey, cryptoKey, extra, edition);
+       }
+
+       public static Key from(FreenetURI freenetURI) {
+               return new Key(freenetURI.getRoutingKey(), freenetURI.getCryptoKey(),
+                               freenetURI.getExtra());
+       }
+
+       public static String routingKey(FreenetURI freenetURI) {
+               return from(freenetURI).getRoutingKey();
+       }
+
+}
index 1e95389..eb16a09 100644 (file)
 
 package net.pterodactylus.sone.freenet;
 
+import static java.util.logging.Logger.getLogger;
+
 import java.util.logging.Logger;
 
 import net.pterodactylus.util.config.AttributeNotFoundException;
 import net.pterodactylus.util.config.Configuration;
 import net.pterodactylus.util.config.ConfigurationException;
 import net.pterodactylus.util.config.ExtendedConfigurationBackend;
-import net.pterodactylus.util.logging.Logging;
-import freenet.client.async.DatabaseDisabledException;
+import freenet.client.async.PersistenceDisabledException;
 import freenet.pluginmanager.PluginRespirator;
 import freenet.pluginmanager.PluginStore;
 
@@ -37,7 +38,7 @@ public class PluginStoreConfigurationBackend implements ExtendedConfigurationBac
 
        /** The logger. */
        @SuppressWarnings("unused")
-       private static final Logger logger = Logging.getLogger(PluginStoreConfigurationBackend.class);
+       private static final Logger logger = getLogger("Sone.Fred");
 
        /** The plugin respirator. */
        private final PluginRespirator pluginRespirator;
@@ -50,15 +51,12 @@ public class PluginStoreConfigurationBackend implements ExtendedConfigurationBac
         *
         * @param pluginRespirator
         *            The plugin respirator
-        * @throws DatabaseDisabledException
+        * @throws PersistenceDisabledException
         *             if the plugin store is not available
         */
-       public PluginStoreConfigurationBackend(PluginRespirator pluginRespirator) throws DatabaseDisabledException {
+       public PluginStoreConfigurationBackend(PluginRespirator pluginRespirator) throws PersistenceDisabledException {
                this.pluginRespirator = pluginRespirator;
                this.pluginStore = pluginRespirator.getStore();
-               if (this.pluginStore == null) {
-                       throw new DatabaseDisabledException();
-               }
        }
 
        /**
@@ -176,8 +174,8 @@ public class PluginStoreConfigurationBackend implements ExtendedConfigurationBac
        public void save() throws ConfigurationException {
                try {
                        pluginRespirator.putStore(pluginStore);
-               } catch (DatabaseDisabledException dde1) {
-                       throw new ConfigurationException("Could not store plugin store, database is disabled.", dde1);
+               } catch (PersistenceDisabledException pde1) {
+                       throw new ConfigurationException("Could not store plugin store, persistence is disabled.", pde1);
                }
        }
 
diff --git a/src/main/java/net/pterodactylus/sone/freenet/StringBucket.java b/src/main/java/net/pterodactylus/sone/freenet/StringBucket.java
deleted file mode 100644 (file)
index c96a3cd..0000000
+++ /dev/null
@@ -1,146 +0,0 @@
-/*
- * Sone - StringBucket.java - Copyright © 2010–2013 David Roden
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package net.pterodactylus.sone.freenet;
-
-import java.io.ByteArrayInputStream;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.nio.charset.Charset;
-
-import com.db4o.ObjectContainer;
-
-import freenet.support.api.Bucket;
-
-/**
- * {@link Bucket} implementation wrapped around a {@link String}.
- *
- * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
- */
-public class StringBucket implements Bucket {
-
-       /** The string to deliver. */
-       private final String string;
-
-       /** The encoding for the data. */
-       private final Charset encoding;
-
-       /**
-        * Creates a new string bucket using the default encoding.
-        *
-        * @param string
-        *            The string to wrap
-        */
-       public StringBucket(String string) {
-               this(string, Charset.defaultCharset());
-       }
-
-       /**
-        * Creates a new string bucket, using the given encoding to create a byte
-        * array from the string.
-        *
-        * @param string
-        *            The string to wrap
-        * @param encoding
-        *            The encoding of the data
-        */
-       public StringBucket(String string, Charset encoding) {
-               this.string = string;
-               this.encoding = encoding;
-       }
-
-       /**
-        * {@inheritDoc}
-        */
-       @Override
-       public Bucket createShadow() {
-               return new StringBucket(string);
-       }
-
-       /**
-        * {@inheritDoc}
-        */
-       @Override
-       public void free() {
-               /* ignore. */
-       }
-
-       /**
-        * {@inheritDoc}
-        */
-       @Override
-       public InputStream getInputStream() {
-               return new ByteArrayInputStream(string.getBytes(encoding));
-       }
-
-       /**
-        * {@inheritDoc}
-        */
-       @Override
-       public String getName() {
-               return getClass().getName() + "@" + hashCode();
-       }
-
-       /**
-        * {@inheritDoc}
-        */
-       @Override
-       public OutputStream getOutputStream() {
-               return null;
-       }
-
-       /**
-        * {@inheritDoc}
-        */
-       @Override
-       public boolean isReadOnly() {
-               return true;
-       }
-
-       /**
-        * {@inheritDoc}
-        */
-       @Override
-       public void removeFrom(ObjectContainer objectContainer) {
-               /* ignore. */
-       }
-
-       /**
-        * {@inheritDoc}
-        */
-       @Override
-       public void setReadOnly() {
-               /* ignore, it is already read-only. */
-       }
-
-       /**
-        * {@inheritDoc}
-        */
-       @Override
-       public long size() {
-               return string.getBytes(encoding).length;
-       }
-
-       /**
-        * {@inheritDoc}
-        */
-       @Override
-       public void storeTo(ObjectContainer objectContainer) {
-               /* ignore. */
-       }
-
-}
index 47330e8..56ac807 100644 (file)
@@ -21,6 +21,7 @@ import net.pterodactylus.sone.freenet.plugin.event.ReceivedReplyEvent;
 
 import com.google.common.eventbus.EventBus;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 
 import freenet.pluginmanager.FredPluginTalker;
 import freenet.pluginmanager.PluginNotFoundException;
@@ -35,6 +36,7 @@ import freenet.support.api.Bucket;
  *
  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
  */
+@Singleton
 public class PluginConnector implements FredPluginTalker {
 
        /** The event bus. */
diff --git a/src/main/java/net/pterodactylus/sone/freenet/wot/Context.java b/src/main/java/net/pterodactylus/sone/freenet/wot/Context.java
new file mode 100644 (file)
index 0000000..f72e56d
--- /dev/null
@@ -0,0 +1,50 @@
+/*
+ * Sone - Context.java - Copyright © 2014 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone.freenet.wot;
+
+import javax.annotation.Nullable;
+
+import com.google.common.base.Function;
+
+/**
+ * Custom container for the Web of Trust context. This allows easier
+ * configuration of dependency injection.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class Context {
+
+       public static final Function<Context, String> extractContext = new Function<Context, String>() {
+               @Nullable
+               @Override
+               public String apply(@Nullable Context context) {
+                       return (context == null) ? null : context.getContext();
+               }
+       };
+
+       private final String context;
+
+       public Context(String context) {
+               this.context = context;
+       }
+
+       public String getContext() {
+               return context;
+       }
+
+}
index 0cfb055..dc44aab 100644 (file)
@@ -69,151 +69,103 @@ public class DefaultIdentity implements Identity {
        // ACCESSORS
        //
 
-       /**
-        * {@inheritDoc}
-        */
        @Override
        public String getId() {
                return id;
        }
 
-       /**
-        * {@inheritDoc}
-        */
        @Override
        public String getNickname() {
                return nickname;
        }
 
-       /**
-        * {@inheritDoc}
-        */
        @Override
        public String getRequestUri() {
                return requestUri;
        }
 
-       /**
-        * {@inheritDoc}
-        */
        @Override
        public Set<String> getContexts() {
                return Collections.unmodifiableSet(contexts);
        }
 
-       /**
-        * {@inheritDoc}
-        */
        @Override
        public boolean hasContext(String context) {
                return contexts.contains(context);
        }
 
-       /**
-        * {@inheritDoc}
-        */
        @Override
        public void setContexts(Collection<String> contexts) {
                this.contexts.clear();
                this.contexts.addAll(contexts);
        }
 
-       /**
-        * {@inheritDoc}
-        */
        @Override
-       public void addContext(String context) {
+       public Identity addContext(String context) {
                contexts.add(context);
+               return this;
        }
 
-       /**
-        * {@inheritDoc}
-        */
        @Override
-       public void removeContext(String context) {
+       public Identity removeContext(String context) {
                contexts.remove(context);
+               return this;
        }
 
-       /**
-        * {@inheritDoc}
-        */
        @Override
        public Map<String, String> getProperties() {
                return Collections.unmodifiableMap(properties);
        }
 
-       /**
-        * {@inheritDoc}
-        */
        @Override
        public void setProperties(Map<String, String> properties) {
                this.properties.clear();
                this.properties.putAll(properties);
        }
 
-       /**
-        * {@inheritDoc}
-        */
        @Override
        public String getProperty(String name) {
                return properties.get(name);
        }
 
-       /**
-        * {@inheritDoc}
-        */
        @Override
-       public void setProperty(String name, String value) {
+       public Identity setProperty(String name, String value) {
                properties.put(name, value);
+               return this;
        }
 
-       /**
-        * {@inheritDoc}
-        */
        @Override
-       public void removeProperty(String name) {
+       public Identity removeProperty(String name) {
                properties.remove(name);
+               return this;
        }
 
-       /**
-        * {@inheritDoc}
-        */
        @Override
        public Trust getTrust(OwnIdentity ownIdentity) {
                return trustCache.get(ownIdentity);
        }
 
-       /**
-        * {@inheritDoc}
-        */
        @Override
-       public void setTrust(OwnIdentity ownIdentity, Trust trust) {
+       public Identity setTrust(OwnIdentity ownIdentity, Trust trust) {
                trustCache.put(ownIdentity, trust);
+               return this;
        }
 
-       /**
-        * {@inheritDoc}
-        */
        @Override
-       public void removeTrust(OwnIdentity ownIdentity) {
+       public Identity removeTrust(OwnIdentity ownIdentity) {
                trustCache.remove(ownIdentity);
+               return this;
        }
 
        //
        // OBJECT METHODS
        //
 
-       /**
-        * {@inheritDoc}
-        */
        @Override
        public int hashCode() {
                return getId().hashCode();
        }
 
-       /**
-        * {@inheritDoc}
-        */
        @Override
        public boolean equals(Object object) {
                if (!(object instanceof Identity)) {
@@ -223,9 +175,6 @@ public class DefaultIdentity implements Identity {
                return identity.getId().equals(getId());
        }
 
-       /**
-        * {@inheritDoc}
-        */
        @Override
        public String toString() {
                return getClass().getSimpleName() + "[id=" + id + ",nickname=" + nickname + ",contexts=" + contexts + ",properties=" + properties + "]";
index 348cd8c..4a842e9 100644 (file)
@@ -17,6 +17,8 @@
 
 package net.pterodactylus.sone.freenet.wot;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+
 /**
  * An own identity is an identity that the owner of the node has full control
  * over.
@@ -42,34 +44,38 @@ public class DefaultOwnIdentity extends DefaultIdentity implements OwnIdentity {
         */
        public DefaultOwnIdentity(String id, String nickname, String requestUri, String insertUri) {
                super(id, nickname, requestUri);
-               this.insertUri = insertUri;
-       }
-
-       /**
-        * Copy constructor for an own identity.
-        *
-        * @param ownIdentity
-        *            The own identity to copy
-        */
-       public DefaultOwnIdentity(OwnIdentity ownIdentity) {
-               super(ownIdentity.getId(), ownIdentity.getNickname(), ownIdentity.getRequestUri());
-               this.insertUri = ownIdentity.getInsertUri();
-               setContexts(ownIdentity.getContexts());
-               setProperties(ownIdentity.getProperties());
+               this.insertUri = checkNotNull(insertUri);
        }
 
        //
        // ACCESSORS
        //
 
-       /**
-        * {@inheritDoc}
-        */
        @Override
        public String getInsertUri() {
                return insertUri;
        }
 
+       @Override
+       public OwnIdentity addContext(String context) {
+               return (OwnIdentity) super.addContext(context);
+       }
+
+       @Override
+       public OwnIdentity removeContext(String context) {
+               return (OwnIdentity) super.removeContext(context);
+       }
+
+       @Override
+       public OwnIdentity setProperty(String name, String value) {
+               return (OwnIdentity) super.setProperty(name, value);
+       }
+
+       @Override
+       public OwnIdentity removeProperty(String name) {
+               return (OwnIdentity) super.removeProperty(name);
+       }
+
        //
        // OBJECT METHODS
        //
index bc594f8..d0cafc5 100644 (file)
 package net.pterodactylus.sone.freenet.wot;
 
 import java.util.Collection;
+import java.util.Collections;
 import java.util.Map;
 import java.util.Set;
 
+import com.google.common.base.Function;
+
 /**
  * Interface for web of trust identities, defining all functions that can be
  * performed on an identity. An identity is only a container for identity data
@@ -30,6 +33,20 @@ import java.util.Set;
  */
 public interface Identity {
 
+       public static final Function<Identity, Set<String>> TO_CONTEXTS = new Function<Identity, Set<String>>() {
+               @Override
+               public Set<String> apply(Identity identity) {
+                       return (identity == null) ? Collections.<String>emptySet() : identity.getContexts();
+               }
+       };
+
+       public static final Function<Identity, Map<String, String>> TO_PROPERTIES = new Function<Identity, Map<String, String>>() {
+               @Override
+               public Map<String, String> apply(Identity input) {
+                       return (input == null) ? Collections.<String, String>emptyMap() : input.getProperties();
+               }
+       };
+
        /**
         * Returns the ID of the identity.
         *
@@ -74,7 +91,7 @@ public interface Identity {
         * @param context
         *            The context to add
         */
-       public void addContext(String context);
+       public Identity addContext(String context);
 
        /**
         * Sets all contexts of this identity.
@@ -90,7 +107,7 @@ public interface Identity {
         * @param context
         *            The context to remove
         */
-       public void removeContext(String context);
+       public Identity removeContext(String context);
 
        /**
         * Returns all properties of this identity.
@@ -116,7 +133,7 @@ public interface Identity {
         * @param value
         *            The value of the property
         */
-       public void setProperty(String name, String value);
+       public Identity setProperty(String name, String value);
 
        /**
         * Sets all properties of this identity.
@@ -132,7 +149,7 @@ public interface Identity {
         * @param name
         *            The name of the property to remove
         */
-       public void removeProperty(String name);
+       public Identity removeProperty(String name);
 
        /**
         * Retrieves the trust that this identity receives from the given own
@@ -155,7 +172,7 @@ public interface Identity {
         * @param trust
         *            The trust given by the given own identity
         */
-       public void setTrust(OwnIdentity ownIdentity, Trust trust);
+       public Identity setTrust(OwnIdentity ownIdentity, Trust trust);
 
        /**
         * Removes trust assignment from the given own identity for this identity.
@@ -164,6 +181,6 @@ public interface Identity {
         *            The own identity that removed the trust assignment for this
         *            identity
         */
-       public void removeTrust(OwnIdentity ownIdentity);
+       public Identity removeTrust(OwnIdentity ownIdentity);
 
 }
diff --git a/src/main/java/net/pterodactylus/sone/freenet/wot/IdentityChangeDetector.java b/src/main/java/net/pterodactylus/sone/freenet/wot/IdentityChangeDetector.java
new file mode 100644 (file)
index 0000000..a8cb62b
--- /dev/null
@@ -0,0 +1,200 @@
+/*
+ * Sone - IdentityChangeDetector.java - Copyright © 2013 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone.freenet.wot;
+
+import static com.google.common.base.Optional.absent;
+import static com.google.common.base.Optional.fromNullable;
+import static com.google.common.base.Predicates.not;
+import static com.google.common.collect.FluentIterable.from;
+import static net.pterodactylus.sone.freenet.wot.Identity.TO_CONTEXTS;
+import static net.pterodactylus.sone.freenet.wot.Identity.TO_PROPERTIES;
+
+import java.util.Collection;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import com.google.common.base.Optional;
+import com.google.common.base.Predicate;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableMap;
+
+/**
+ * Detects changes between two lists of {@link Identity}s. The detector can find
+ * added and removed identities, and for identities that exist in both list
+ * their contexts and properties are checked for added, removed, or (in case of
+ * properties) changed values.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class IdentityChangeDetector {
+
+       private final Map<String, Identity> oldIdentities;
+       private Optional<IdentityProcessor> onNewIdentity = absent();
+       private Optional<IdentityProcessor> onRemovedIdentity = absent();
+       private Optional<IdentityProcessor> onChangedIdentity = absent();
+       private Optional<IdentityProcessor> onUnchangedIdentity = absent();
+
+       public IdentityChangeDetector(Collection<? extends Identity> oldIdentities) {
+               this.oldIdentities = convertToMap(oldIdentities);
+       }
+
+       public void onNewIdentity(IdentityProcessor onNewIdentity) {
+               this.onNewIdentity = fromNullable(onNewIdentity);
+       }
+
+       public void onRemovedIdentity(IdentityProcessor onRemovedIdentity) {
+               this.onRemovedIdentity = fromNullable(onRemovedIdentity);
+       }
+
+       public void onChangedIdentity(IdentityProcessor onChangedIdentity) {
+               this.onChangedIdentity = fromNullable(onChangedIdentity);
+       }
+
+       public void onUnchangedIdentity(IdentityProcessor onUnchangedIdentity) {
+               this.onUnchangedIdentity = fromNullable(onUnchangedIdentity);
+       }
+
+       public void detectChanges(final Collection<? extends Identity> newIdentities) {
+               notifyForRemovedIdentities(from(oldIdentities.values()).filter(notContainedIn(newIdentities)));
+               notifyForNewIdentities(from(newIdentities).filter(notContainedIn(oldIdentities.values())));
+               notifyForChangedIdentities(from(newIdentities).filter(containedIn(oldIdentities)).filter(hasChanged(oldIdentities)));
+               notifyForUnchangedIdentities(from(newIdentities).filter(containedIn(oldIdentities)).filter(not(hasChanged(oldIdentities))));
+       }
+
+       private void notifyForRemovedIdentities(Iterable<Identity> identities) {
+               notify(onRemovedIdentity, identities);
+       }
+
+       private void notifyForNewIdentities(FluentIterable<? extends Identity> newIdentities) {
+               notify(onNewIdentity, newIdentities);
+       }
+
+       private void notifyForChangedIdentities(FluentIterable<? extends Identity> identities) {
+               notify(onChangedIdentity, identities);
+       }
+
+       private void notifyForUnchangedIdentities(FluentIterable<? extends Identity> identities) {
+               notify(onUnchangedIdentity, identities);
+       }
+
+       private void notify(Optional<IdentityProcessor> identityProcessor, Iterable<? extends Identity> identities) {
+               if (!identityProcessor.isPresent()) {
+                       return;
+               }
+               for (Identity identity : identities) {
+                       identityProcessor.get().processIdentity(identity);
+               }
+       }
+
+       private static Predicate<Identity> hasChanged(final Map<String, Identity> oldIdentities) {
+               return new Predicate<Identity>() {
+                       @Override
+                       public boolean apply(Identity identity) {
+                               return (identity == null) ? false : identityHasChanged(oldIdentities.get(identity.getId()), identity);
+                       }
+               };
+       }
+
+       private static boolean identityHasChanged(Identity oldIdentity, Identity newIdentity) {
+               return identityHasNewContexts(oldIdentity, newIdentity)
+                               || identityHasRemovedContexts(oldIdentity, newIdentity)
+                               || identityHasNewProperties(oldIdentity, newIdentity)
+                               || identityHasRemovedProperties(oldIdentity, newIdentity)
+                               || identityHasChangedProperties(oldIdentity, newIdentity);
+       }
+
+       private static boolean identityHasNewContexts(Identity oldIdentity, Identity newIdentity) {
+               return from(TO_CONTEXTS.apply(newIdentity)).anyMatch(notAContextOf(oldIdentity));
+       }
+
+       private static boolean identityHasRemovedContexts(Identity oldIdentity, Identity newIdentity) {
+               return from(TO_CONTEXTS.apply(oldIdentity)).anyMatch(notAContextOf(newIdentity));
+       }
+
+       private static boolean identityHasNewProperties(Identity oldIdentity, Identity newIdentity) {
+               return from(TO_PROPERTIES.apply(newIdentity).entrySet()).anyMatch(notAPropertyOf(oldIdentity));
+       }
+
+       private static boolean identityHasRemovedProperties(Identity oldIdentity, Identity newIdentity) {
+               return from(TO_PROPERTIES.apply(oldIdentity).entrySet()).anyMatch(notAPropertyOf(newIdentity));
+       }
+
+       private static boolean identityHasChangedProperties(Identity oldIdentity, Identity newIdentity) {
+               return from(TO_PROPERTIES.apply(oldIdentity).entrySet()).anyMatch(hasADifferentValueThanIn(newIdentity));
+       }
+
+       private static Predicate<Identity> containedIn(final Map<String, Identity> identities) {
+               return new Predicate<Identity>() {
+                       @Override
+                       public boolean apply(Identity identity) {
+                               return identities.containsKey(identity.getId());
+                       }
+               };
+       }
+
+       private static Predicate<String> notAContextOf(final Identity identity) {
+               return new Predicate<String>() {
+                       @Override
+                       public boolean apply(String context) {
+                               return (identity == null) ? false : !identity.getContexts().contains(context);
+                       }
+               };
+       }
+
+       private static Predicate<Identity> notContainedIn(final Collection<? extends Identity> newIdentities) {
+               return new Predicate<Identity>() {
+                       @Override
+                       public boolean apply(Identity identity) {
+                               return (identity == null) ? false : !newIdentities.contains(identity);
+                       }
+               };
+       }
+
+       private static Predicate<Entry<String, String>> notAPropertyOf(final Identity identity) {
+               return new Predicate<Entry<String, String>>() {
+                       @Override
+                       public boolean apply(Entry<String, String> property) {
+                               return (property == null) ? false : !identity.getProperties().containsKey(property.getKey());
+                       }
+               };
+       }
+
+       private static Predicate<Entry<String, String>> hasADifferentValueThanIn(final Identity newIdentity) {
+               return new Predicate<Entry<String, String>>() {
+                       @Override
+                       public boolean apply(Entry<String, String> property) {
+                               return (property == null) ? false : !newIdentity.getProperty(property.getKey()).equals(property.getValue());
+                       }
+               };
+       }
+
+       private static Map<String, Identity> convertToMap(Collection<? extends Identity> identities) {
+               ImmutableMap.Builder<String, Identity> mapBuilder = ImmutableMap.builder();
+               for (Identity identity : identities) {
+                       mapBuilder.put(identity.getId(), identity);
+               }
+               return mapBuilder.build();
+       }
+
+       public interface IdentityProcessor {
+
+               void processIdentity(Identity identity);
+
+       }
+
+}
diff --git a/src/main/java/net/pterodactylus/sone/freenet/wot/IdentityChangeEventSender.java b/src/main/java/net/pterodactylus/sone/freenet/wot/IdentityChangeEventSender.java
new file mode 100644 (file)
index 0000000..d087dec
--- /dev/null
@@ -0,0 +1,133 @@
+/*
+ * Sone - IdentityChangeEventSender.java - Copyright © 2013 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone.freenet.wot;
+
+import java.util.Collection;
+import java.util.Map;
+
+import net.pterodactylus.sone.freenet.wot.IdentityChangeDetector.IdentityProcessor;
+import net.pterodactylus.sone.freenet.wot.event.IdentityAddedEvent;
+import net.pterodactylus.sone.freenet.wot.event.IdentityRemovedEvent;
+import net.pterodactylus.sone.freenet.wot.event.IdentityUpdatedEvent;
+import net.pterodactylus.sone.freenet.wot.event.OwnIdentityAddedEvent;
+import net.pterodactylus.sone.freenet.wot.event.OwnIdentityRemovedEvent;
+
+import com.google.common.eventbus.EventBus;
+
+/**
+ * Detects changes in {@link Identity}s trusted my multiple {@link
+ * OwnIdentity}s.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ * @see IdentityChangeDetector
+ */
+public class IdentityChangeEventSender {
+
+       private final EventBus eventBus;
+       private final Map<OwnIdentity, Collection<Identity>> oldIdentities;
+
+       public IdentityChangeEventSender(EventBus eventBus, Map<OwnIdentity, Collection<Identity>> oldIdentities) {
+               this.eventBus = eventBus;
+               this.oldIdentities = oldIdentities;
+       }
+
+       public void detectChanges(Map<OwnIdentity, Collection<Identity>> identities) {
+               IdentityChangeDetector identityChangeDetector = new IdentityChangeDetector(oldIdentities.keySet());
+               identityChangeDetector.onNewIdentity(addNewOwnIdentityAndItsTrustedIdentities(identities));
+               identityChangeDetector.onRemovedIdentity(removeOwnIdentityAndItsTrustedIdentities(oldIdentities));
+               identityChangeDetector.onUnchangedIdentity(detectChangesInTrustedIdentities(identities, oldIdentities));
+               identityChangeDetector.detectChanges(identities.keySet());
+       }
+
+       private IdentityProcessor addNewOwnIdentityAndItsTrustedIdentities(final Map<OwnIdentity, Collection<Identity>> newIdentities) {
+               return new IdentityProcessor() {
+                       @Override
+                       public void processIdentity(Identity identity) {
+                               eventBus.post(new OwnIdentityAddedEvent((OwnIdentity) identity));
+                               for (Identity newIdentity : newIdentities.get((OwnIdentity) identity)) {
+                                       eventBus.post(new IdentityAddedEvent((OwnIdentity) identity, newIdentity));
+                               }
+                       }
+               };
+       }
+
+       private IdentityProcessor removeOwnIdentityAndItsTrustedIdentities(final Map<OwnIdentity, Collection<Identity>> oldIdentities) {
+               return new IdentityProcessor() {
+                       @Override
+                       public void processIdentity(Identity identity) {
+                               eventBus.post(new OwnIdentityRemovedEvent((OwnIdentity) identity));
+                               for (Identity removedIdentity : oldIdentities.get((OwnIdentity) identity)) {
+                                       eventBus.post(new IdentityRemovedEvent((OwnIdentity) identity, removedIdentity));
+                               }
+                       }
+               };
+       }
+
+       private IdentityProcessor detectChangesInTrustedIdentities(Map<OwnIdentity, Collection<Identity>> newIdentities, Map<OwnIdentity, Collection<Identity>> oldIdentities) {
+               return new DefaultIdentityProcessor(oldIdentities, newIdentities);
+       }
+
+       private class DefaultIdentityProcessor implements IdentityProcessor {
+
+               private final Map<OwnIdentity, Collection<Identity>> oldIdentities;
+               private final Map<OwnIdentity, Collection<Identity>> newIdentities;
+
+               public DefaultIdentityProcessor(Map<OwnIdentity, Collection<Identity>> oldIdentities, Map<OwnIdentity, Collection<Identity>> newIdentities) {
+                       this.oldIdentities = oldIdentities;
+                       this.newIdentities = newIdentities;
+               }
+
+               @Override
+               public void processIdentity(Identity ownIdentity) {
+                       IdentityChangeDetector identityChangeDetector = new IdentityChangeDetector(oldIdentities.get((OwnIdentity) ownIdentity));
+                       identityChangeDetector.onNewIdentity(notifyForAddedIdentities((OwnIdentity) ownIdentity));
+                       identityChangeDetector.onRemovedIdentity(notifyForRemovedIdentities((OwnIdentity) ownIdentity));
+                       identityChangeDetector.onChangedIdentity(notifyForChangedIdentities((OwnIdentity) ownIdentity));
+                       identityChangeDetector.detectChanges(newIdentities.get((OwnIdentity) ownIdentity));
+               }
+
+               private IdentityProcessor notifyForChangedIdentities(final OwnIdentity ownIdentity) {
+                       return new IdentityProcessor() {
+                               @Override
+                               public void processIdentity(Identity identity) {
+                                       eventBus.post(new IdentityUpdatedEvent(ownIdentity, identity));
+                               }
+                       };
+               }
+
+               private IdentityProcessor notifyForRemovedIdentities(final OwnIdentity ownIdentity) {
+                       return new IdentityProcessor() {
+                               @Override
+                               public void processIdentity(Identity identity) {
+                                       eventBus.post(new IdentityRemovedEvent(ownIdentity, identity));
+                               }
+                       };
+               }
+
+               private IdentityProcessor notifyForAddedIdentities(final OwnIdentity ownIdentity) {
+                       return new IdentityProcessor() {
+                               @Override
+                               public void processIdentity(Identity identity) {
+                                       eventBus.post(new IdentityAddedEvent(ownIdentity, identity));
+                               }
+                       };
+               }
+
+       }
+
+}
diff --git a/src/main/java/net/pterodactylus/sone/freenet/wot/IdentityLoader.java b/src/main/java/net/pterodactylus/sone/freenet/wot/IdentityLoader.java
new file mode 100644 (file)
index 0000000..75ec828
--- /dev/null
@@ -0,0 +1,79 @@
+/*
+ * Sone - IdentityLoader.java - Copyright © 2013 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone.freenet.wot;
+
+import static java.util.Collections.emptySet;
+import static net.pterodactylus.sone.freenet.wot.Context.extractContext;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import net.pterodactylus.sone.freenet.plugin.PluginException;
+
+import com.google.common.base.Optional;
+import com.google.inject.Inject;
+
+/**
+ * Loads {@link OwnIdentity}s and the {@link Identity}s they trust.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class IdentityLoader {
+
+       private final WebOfTrustConnector webOfTrustConnector;
+       private final Optional<Context> context;
+
+       public IdentityLoader(WebOfTrustConnector webOfTrustConnector) {
+               this(webOfTrustConnector, Optional.<Context>absent());
+       }
+
+       @Inject
+       public IdentityLoader(WebOfTrustConnector webOfTrustConnector, Optional<Context> context) {
+               this.webOfTrustConnector = webOfTrustConnector;
+               this.context = context;
+       }
+
+       public Map<OwnIdentity, Collection<Identity>> loadIdentities() throws WebOfTrustException {
+               Collection<OwnIdentity> currentOwnIdentities = webOfTrustConnector.loadAllOwnIdentities();
+               return loadTrustedIdentitiesForOwnIdentities(currentOwnIdentities);
+       }
+
+       private Map<OwnIdentity, Collection<Identity>> loadTrustedIdentitiesForOwnIdentities(Collection<OwnIdentity> ownIdentities) throws PluginException {
+               Map<OwnIdentity, Collection<Identity>> currentIdentities = new HashMap<OwnIdentity, Collection<Identity>>();
+
+               for (OwnIdentity ownIdentity : ownIdentities) {
+                       if (identityDoesNotHaveTheCorrectContext(ownIdentity)) {
+                               currentIdentities.put(ownIdentity, Collections.<Identity>emptySet());
+                               continue;
+                       }
+
+                       Set<Identity> trustedIdentities = webOfTrustConnector.loadTrustedIdentities(ownIdentity, context.transform(extractContext));
+                       currentIdentities.put(ownIdentity, trustedIdentities);
+               }
+
+               return currentIdentities;
+       }
+
+       private boolean identityDoesNotHaveTheCorrectContext(OwnIdentity ownIdentity) {
+               return context.isPresent() && !ownIdentity.hasContext(context.transform(extractContext).get());
+       }
+
+}
index 77e0480..d3ba606 100644 (file)
-/*
- * Sone - IdentityManager.java - Copyright © 2010–2013 David Roden
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
 package net.pterodactylus.sone.freenet.wot;
 
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Map.Entry;
 import java.util.Set;
-import java.util.logging.Level;
-import java.util.logging.Logger;
 
-import net.pterodactylus.sone.freenet.plugin.PluginException;
-import net.pterodactylus.sone.freenet.wot.event.IdentityAddedEvent;
-import net.pterodactylus.sone.freenet.wot.event.IdentityRemovedEvent;
-import net.pterodactylus.sone.freenet.wot.event.IdentityUpdatedEvent;
-import net.pterodactylus.sone.freenet.wot.event.OwnIdentityAddedEvent;
-import net.pterodactylus.sone.freenet.wot.event.OwnIdentityRemovedEvent;
-import net.pterodactylus.util.logging.Logging;
-import net.pterodactylus.util.service.AbstractService;
+import net.pterodactylus.util.service.Service;
 
 import com.google.common.eventbus.EventBus;
-import com.google.inject.Inject;
-import com.google.inject.name.Named;
+import com.google.inject.ImplementedBy;
 
 /**
- * The identity manager takes care of loading and storing identities, their
- * contexts, and properties. It does so in a way that does not expose errors via
- * exceptions but it only logs them and tries to return sensible defaults.
- * <p>
- * It is also responsible for polling identities from the Web of Trust plugin
- * and sending events to the {@link EventBus} when {@link Identity}s and
- * {@link OwnIdentity}s are discovered or disappearing.
+ * Connects to a {@link WebOfTrustConnector} and sends identity events to an
+ * {@link EventBus}.
  *
  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
  */
-public class IdentityManager extends AbstractService {
-
-       /** Object used for synchronization. */
-       @SuppressWarnings("hiding")
-       private final Object syncObject = new Object() {
-               /* inner class for better lock names. */
-       };
-
-       /** The logger. */
-       private static final Logger logger = Logging.getLogger(IdentityManager.class);
-
-       /** The event bus. */
-       private final EventBus eventBus;
-
-       /** The Web of Trust connector. */
-       private final WebOfTrustConnector webOfTrustConnector;
-
-       /** The context to filter for. */
-       private final String context;
-
-       /** The currently known own identities. */
-       /* synchronize access on syncObject. */
-       private final Map<String, OwnIdentity> currentOwnIdentities = new HashMap<String, OwnIdentity>();
-
-       /** The last time all identities were loaded. */
-       private volatile long identitiesLastLoaded;
-
-       /**
-        * Creates a new identity manager.
-        *
-        * @param eventBus
-        *            The event bus
-        * @param webOfTrustConnector
-        *            The Web of Trust connector
-        * @param context
-        *            The context to focus on (may be {@code null} to ignore
-        *            contexts)
-        */
-       @Inject
-       public IdentityManager(EventBus eventBus, WebOfTrustConnector webOfTrustConnector, @Named("WebOfTrustContext") String context) {
-               super("Sone Identity Manager", false);
-               this.eventBus = eventBus;
-               this.webOfTrustConnector = webOfTrustConnector;
-               this.context = context;
-       }
-
-       //
-       // ACCESSORS
-       //
-
-       /**
-        * Returns the last time all identities were loaded.
-        *
-        * @return The last time all identities were loaded (in milliseconds since
-        *         Jan 1, 1970 UTC)
-        */
-       public long getIdentitiesLastLoaded() {
-               return identitiesLastLoaded;
-       }
-
-       /**
-        * Returns whether the Web of Trust plugin could be reached during the last
-        * try.
-        *
-        * @return {@code true} if the Web of Trust plugin is connected,
-        *         {@code false} otherwise
-        */
-       public boolean isConnected() {
-               try {
-                       webOfTrustConnector.ping();
-                       return true;
-               } catch (PluginException pe1) {
-                       /* not connected, ignore. */
-                       return false;
-               }
-       }
-
-       /**
-        * Returns the own identity with the given ID.
-        *
-        * @param id
-        *            The ID of the own identity
-        * @return The own identity, or {@code null} if there is no such identity
-        */
-       public OwnIdentity getOwnIdentity(String id) {
-               Set<OwnIdentity> allOwnIdentities = getAllOwnIdentities();
-               for (OwnIdentity ownIdentity : allOwnIdentities) {
-                       if (ownIdentity.getId().equals(id)) {
-                               return new DefaultOwnIdentity(ownIdentity);
-                       }
-               }
-               return null;
-       }
-
-       /**
-        * Returns all own identities.
-        *
-        * @return All own identities
-        */
-       public Set<OwnIdentity> getAllOwnIdentities() {
-               return new HashSet<OwnIdentity>(currentOwnIdentities.values());
-       }
-
-       //
-       // SERVICE METHODS
-       //
-
-       /**
-        * {@inheritDoc}
-        */
-       @Override
-       protected void serviceRun() {
-               Map<OwnIdentity, Map<String, Identity>> oldIdentities = Collections.emptyMap();
-               while (!shouldStop()) {
-                       Map<OwnIdentity, Map<String, Identity>> currentIdentities = new HashMap<OwnIdentity, Map<String, Identity>>();
-                       Map<String, OwnIdentity> currentOwnIdentities = new HashMap<String, OwnIdentity>();
-
-                       Set<OwnIdentity> ownIdentities = null;
-                       boolean identitiesLoaded = false;
-                       try {
-                               /* get all identities with the wanted context from WoT. */
-                               logger.finer("Getting all Own Identities from WoT...");
-                               ownIdentities = webOfTrustConnector.loadAllOwnIdentities();
-                               logger.finest(String.format("Loaded %d Own Identities.", ownIdentities.size()));
-
-                               /* load trusted identities. */
-                               for (OwnIdentity ownIdentity : ownIdentities) {
-                                       currentOwnIdentities.put(ownIdentity.getId(), ownIdentity);
-                                       Map<String, Identity> identities = new HashMap<String, Identity>();
-                                       currentIdentities.put(ownIdentity, identities);
-
-                                       /*
-                                        * if the context doesn’t match, skip getting trusted
-                                        * identities.
-                                        */
-                                       if ((context != null) && !ownIdentity.hasContext(context)) {
-                                               continue;
-                                       }
-
-                                       /* load trusted identities. */
-                                       logger.finer(String.format("Getting trusted identities for %s...", ownIdentity.getId()));
-                                       Set<Identity> trustedIdentities = webOfTrustConnector.loadTrustedIdentities(ownIdentity, context);
-                                       logger.finest(String.format("Got %d trusted identities.", trustedIdentities.size()));
-                                       for (Identity identity : trustedIdentities) {
-                                               identities.put(identity.getId(), identity);
-                                       }
-                               }
-                               identitiesLoaded = true;
-                               identitiesLastLoaded = System.currentTimeMillis();
-                       } catch (WebOfTrustException wote1) {
-                               logger.log(Level.WARNING, "WoT has disappeared!", wote1);
-                       }
-
-                       if (identitiesLoaded) {
-
-                               /* check for changes. */
-                               checkOwnIdentities(currentOwnIdentities);
-
-                               /* now check for changes in remote identities. */
-                               for (OwnIdentity ownIdentity : currentOwnIdentities.values()) {
-
-                                       /* find new identities. */
-                                       for (Identity currentIdentity : currentIdentities.get(ownIdentity).values()) {
-                                               if (!oldIdentities.containsKey(ownIdentity) || !oldIdentities.get(ownIdentity).containsKey(currentIdentity.getId())) {
-                                                       logger.finest(String.format("Identity added for %s: %s", ownIdentity.getId(), currentIdentity));
-                                                       eventBus.post(new IdentityAddedEvent(ownIdentity, currentIdentity));
-                                               }
-                                       }
-
-                                       /* find removed identities. */
-                                       if (oldIdentities.containsKey(ownIdentity)) {
-                                               for (Identity oldIdentity : oldIdentities.get(ownIdentity).values()) {
-                                                       if (!currentIdentities.get(ownIdentity).containsKey(oldIdentity.getId())) {
-                                                               logger.finest(String.format("Identity removed for %s: %s", ownIdentity.getId(), oldIdentity));
-                                                               eventBus.post(new IdentityRemovedEvent(ownIdentity, oldIdentity));
-                                                       }
-                                               }
-
-                                               /* check for changes in the contexts. */
-                                               for (Identity oldIdentity : oldIdentities.get(ownIdentity).values()) {
-                                                       if (!currentIdentities.get(ownIdentity).containsKey(oldIdentity.getId())) {
-                                                               continue;
-                                                       }
-                                                       Identity newIdentity = currentIdentities.get(ownIdentity).get(oldIdentity.getId());
-                                                       Set<String> oldContexts = oldIdentity.getContexts();
-                                                       Set<String> newContexts = newIdentity.getContexts();
-                                                       if (oldContexts.size() != newContexts.size()) {
-                                                               logger.finest(String.format("Contexts changed for %s: was: %s, is now: %s", ownIdentity.getId(), oldContexts, newContexts));
-                                                               eventBus.post(new IdentityUpdatedEvent(ownIdentity, newIdentity));
-                                                               continue;
-                                                       }
-                                                       for (String oldContext : oldContexts) {
-                                                               if (!newContexts.contains(oldContext)) {
-                                                                       logger.finest(String.format("Context was removed for %s: %s", ownIdentity.getId(), oldContext));
-                                                                       eventBus.post(new IdentityUpdatedEvent(ownIdentity, newIdentity));
-                                                                       break;
-                                                               }
-                                                       }
-                                               }
-
-                                               /* check for changes in the properties. */
-                                               for (Identity oldIdentity : oldIdentities.get(ownIdentity).values()) {
-                                                       if (!currentIdentities.get(ownIdentity).containsKey(oldIdentity.getId())) {
-                                                               continue;
-                                                       }
-                                                       Identity newIdentity = currentIdentities.get(ownIdentity).get(oldIdentity.getId());
-                                                       Map<String, String> oldProperties = oldIdentity.getProperties();
-                                                       Map<String, String> newProperties = newIdentity.getProperties();
-                                                       if (oldProperties.size() != newProperties.size()) {
-                                                               logger.finest(String.format("Properties changed for %s: was: %s, is now: %s", ownIdentity.getId(), oldProperties, newProperties));
-                                                               eventBus.post(new IdentityUpdatedEvent(ownIdentity, newIdentity));
-                                                               continue;
-                                                       }
-                                                       for (Entry<String, String> oldProperty : oldProperties.entrySet()) {
-                                                               if (!newProperties.containsKey(oldProperty.getKey()) || !newProperties.get(oldProperty.getKey()).equals(oldProperty.getValue())) {
-                                                                       logger.finest(String.format("Property was removed for %s: %s", ownIdentity.getId(), oldProperty));
-                                                                       eventBus.post(new IdentityUpdatedEvent(ownIdentity, newIdentity));
-                                                                       break;
-                                                               }
-                                                       }
-                                               }
-                                       }
-                               }
-
-                               /* remember the current set of identities. */
-                               oldIdentities = currentIdentities;
-                       }
-
-                       /* wait a minute before checking again. */
-                       sleep(60 * 1000);
-               }
-       }
-
-       //
-       // PRIVATE METHODS
-       //
-
-       /**
-        * Checks the given new list of own identities for added or removed own
-        * identities, as compared to {@link #currentOwnIdentities}.
-        *
-        * @param newOwnIdentities
-        *            The new own identities
-        */
-       private void checkOwnIdentities(Map<String, OwnIdentity> newOwnIdentities) {
-               synchronized (syncObject) {
-
-                       /* find removed own identities: */
-                       for (OwnIdentity oldOwnIdentity : currentOwnIdentities.values()) {
-                               OwnIdentity newOwnIdentity = newOwnIdentities.get(oldOwnIdentity.getId());
-                               if ((newOwnIdentity == null) || ((context != null) && oldOwnIdentity.hasContext(context) && !newOwnIdentity.hasContext(context))) {
-                                       logger.finest(String.format("Own Identity removed: %s", oldOwnIdentity));
-                                       eventBus.post(new OwnIdentityRemovedEvent(new DefaultOwnIdentity(oldOwnIdentity)));
-                               }
-                       }
-
-                       /* find added own identities. */
-                       for (OwnIdentity currentOwnIdentity : newOwnIdentities.values()) {
-                               OwnIdentity oldOwnIdentity = currentOwnIdentities.get(currentOwnIdentity.getId());
-                               if (((oldOwnIdentity == null) && ((context == null) || currentOwnIdentity.hasContext(context))) || ((oldOwnIdentity != null) && (context != null) && (!oldOwnIdentity.hasContext(context) && currentOwnIdentity.hasContext(context)))) {
-                                       logger.finest(String.format("Own Identity added: %s", currentOwnIdentity));
-                                       eventBus.post(new OwnIdentityAddedEvent(new DefaultOwnIdentity(currentOwnIdentity)));
-                               }
-                       }
+@ImplementedBy(IdentityManagerImpl.class)
+public interface IdentityManager extends Service {
 
-                       currentOwnIdentities.clear();
-                       currentOwnIdentities.putAll(newOwnIdentities);
-               }
-       }
+       boolean isConnected();
+       Set<OwnIdentity> getAllOwnIdentities();
 
 }
diff --git a/src/main/java/net/pterodactylus/sone/freenet/wot/IdentityManagerImpl.java b/src/main/java/net/pterodactylus/sone/freenet/wot/IdentityManagerImpl.java
new file mode 100644 (file)
index 0000000..4c94749
--- /dev/null
@@ -0,0 +1,149 @@
+/*
+ * Sone - IdentityManager.java - Copyright © 2010–2013 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone.freenet.wot;
+
+import static java.util.logging.Logger.getLogger;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import net.pterodactylus.sone.freenet.plugin.PluginException;
+import net.pterodactylus.util.service.AbstractService;
+
+import com.google.common.collect.Sets;
+import com.google.common.eventbus.EventBus;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/**
+ * The identity manager takes care of loading and storing identities, their
+ * contexts, and properties. It does so in a way that does not expose errors via
+ * exceptions but it only logs them and tries to return sensible defaults.
+ * <p>
+ * It is also responsible for polling identities from the Web of Trust plugin
+ * and sending events to the {@link EventBus} when {@link Identity}s and
+ * {@link OwnIdentity}s are discovered or disappearing.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+@Singleton
+public class IdentityManagerImpl extends AbstractService implements IdentityManager {
+
+       /** The logger. */
+       private static final Logger logger = getLogger("Sone.Identities");
+
+       /** The event bus. */
+       private final EventBus eventBus;
+
+       private final IdentityLoader identityLoader;
+
+       /** The Web of Trust connector. */
+       private final WebOfTrustConnector webOfTrustConnector;
+
+       /** The currently known own identities. */
+       private final Set<OwnIdentity> currentOwnIdentities = Sets.newHashSet();
+
+       /**
+        * Creates a new identity manager.
+        *
+        * @param eventBus
+        *            The event bus
+        * @param webOfTrustConnector
+        *            The Web of Trust connector
+        */
+       @Inject
+       public IdentityManagerImpl(EventBus eventBus, WebOfTrustConnector webOfTrustConnector, IdentityLoader identityLoader) {
+               super("Sone Identity Manager", false);
+               this.eventBus = eventBus;
+               this.webOfTrustConnector = webOfTrustConnector;
+               this.identityLoader = identityLoader;
+       }
+
+       //
+       // ACCESSORS
+       //
+
+       /**
+        * Returns whether the Web of Trust plugin could be reached during the last
+        * try.
+        *
+        * @return {@code true} if the Web of Trust plugin is connected,
+        *         {@code false} otherwise
+        */
+       @Override
+       public boolean isConnected() {
+               try {
+                       webOfTrustConnector.ping();
+                       return true;
+               } catch (PluginException pe1) {
+                       /* not connected, ignore. */
+                       return false;
+               }
+       }
+
+       /**
+        * Returns all own identities.
+        *
+        * @return All own identities
+        */
+       @Override
+       public Set<OwnIdentity> getAllOwnIdentities() {
+               synchronized (currentOwnIdentities) {
+                       return new HashSet<OwnIdentity>(currentOwnIdentities);
+               }
+       }
+
+       //
+       // SERVICE METHODS
+       //
+
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       protected void serviceRun() {
+               Map<OwnIdentity, Collection<Identity>> oldIdentities = new HashMap<OwnIdentity, Collection<Identity>>();
+
+               while (!shouldStop()) {
+                       try {
+                               Map<OwnIdentity, Collection<Identity>> currentIdentities = identityLoader.loadIdentities();
+
+                               IdentityChangeEventSender identityChangeEventSender = new IdentityChangeEventSender(eventBus, oldIdentities);
+                               identityChangeEventSender.detectChanges(currentIdentities);
+
+                               oldIdentities = currentIdentities;
+
+                               synchronized (currentOwnIdentities) {
+                                       currentOwnIdentities.clear();
+                                       currentOwnIdentities.addAll(currentIdentities.keySet());
+                               }
+                       } catch (WebOfTrustException wote1) {
+                               logger.log(Level.WARNING, "WoT has disappeared!", wote1);
+                       }
+
+                       /* wait a minute before checking again. */
+                       sleep(60 * 1000);
+               }
+       }
+
+}
index 6fc7044..da6409b 100644 (file)
@@ -32,4 +32,9 @@ public interface OwnIdentity extends Identity {
         */
        public String getInsertUri();
 
+       public OwnIdentity addContext(String context);
+       public OwnIdentity removeContext(String context);
+       public OwnIdentity setProperty(String name, String value);
+       public OwnIdentity removeProperty(String name);
+
 }
index 6fa37d0..fe76846 100644 (file)
@@ -17,6 +17,8 @@
 
 package net.pterodactylus.sone.freenet.wot;
 
+import static com.google.common.base.Objects.equal;
+
 /**
  * Container class for trust in the web of trust.
  *
@@ -79,9 +81,16 @@ public class Trust {
                return distance;
        }
 
-       /**
-        * {@inheritDoc}
-        */
+       @Override
+       public boolean equals(Object object) {
+               if (!(object instanceof Trust)) {
+                       return false;
+               }
+               Trust trust = (Trust) object;
+               return equal(getExplicit(), trust.getExplicit()) && equal(getImplicit(), trust.getImplicit()) && equal(getDistance(), trust.getDistance());
+       }
+
+       /** {@inheritDoc} */
        @Override
        public String toString() {
                return getClass().getName() + "[explicit=" + explicit + ",implicit=" + implicit + ",distance=" + distance + "]";
index 07628e3..3b30a4e 100644 (file)
@@ -17,6 +17,9 @@
 
 package net.pterodactylus.sone.freenet.wot;
 
+import static java.util.logging.Logger.getLogger;
+import static net.pterodactylus.sone.utils.NumberParsers.parseInt;
+
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
@@ -28,12 +31,12 @@ import java.util.logging.Logger;
 import net.pterodactylus.sone.freenet.plugin.PluginConnector;
 import net.pterodactylus.sone.freenet.plugin.PluginException;
 import net.pterodactylus.sone.freenet.plugin.event.ReceivedReplyEvent;
-import net.pterodactylus.util.logging.Logging;
-import net.pterodactylus.util.number.Numbers;
 
+import com.google.common.base.Optional;
 import com.google.common.collect.MapMaker;
 import com.google.common.eventbus.Subscribe;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 
 import freenet.support.SimpleFieldSet;
 import freenet.support.api.Bucket;
@@ -43,10 +46,11 @@ import freenet.support.api.Bucket;
  *
  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
  */
+@Singleton
 public class WebOfTrustConnector {
 
        /** The logger. */
-       private static final Logger logger = Logging.getLogger(WebOfTrustConnector.class);
+       private static final Logger logger = getLogger("Sone.WoT.Connector");
 
        /** The name of the WoT plugin. */
        private static final String WOT_PLUGIN_NAME = "plugins.WebOfTrust.WebOfTrust";
@@ -137,8 +141,8 @@ public class WebOfTrustConnector {
         * @throws PluginException
         *             if an error occured talking to the Web of Trust plugin
         */
-       public Set<Identity> loadTrustedIdentities(OwnIdentity ownIdentity, String context) throws PluginException {
-               Reply reply = performRequest(SimpleFieldSetConstructor.create().put("Message", "GetIdentitiesByScore").put("Truster", ownIdentity.getId()).put("Selection", "+").put("Context", (context == null) ? "" : context).put("WantTrustValues", "true").get());
+       public Set<Identity> loadTrustedIdentities(OwnIdentity ownIdentity, Optional<String> context) throws PluginException {
+               Reply reply = performRequest(SimpleFieldSetConstructor.create().put("Message", "GetIdentitiesByScore").put("Truster", ownIdentity.getId()).put("Selection", "+").put("Context", context.or("")).put("WantTrustValues", "true").get());
                SimpleFieldSet fields = reply.getFields();
                Set<Identity> identities = new HashSet<Identity>();
                int identityCounter = -1;
@@ -152,9 +156,9 @@ public class WebOfTrustConnector {
                        DefaultIdentity identity = new DefaultIdentity(id, nickname, requestUri);
                        identity.setContexts(parseContexts("Contexts" + identityCounter + ".", fields));
                        identity.setProperties(parseProperties("Properties" + identityCounter + ".", fields));
-                       Integer trust = Numbers.safeParseInteger(fields.get("Trust" + identityCounter), null);
-                       int score = Numbers.safeParseInteger(fields.get("Score" + identityCounter), 0);
-                       int rank = Numbers.safeParseInteger(fields.get("Rank" + identityCounter), 0);
+                       Integer trust = parseInt(fields.get("Trust" + identityCounter), null);
+                       int score = parseInt(fields.get("Score" + identityCounter), 0);
+                       int rank = parseInt(fields.get("Rank" + identityCounter), 0);
                        identity.setTrust(ownIdentity, new Trust(trust, score, rank));
                        identities.add(identity);
                }
@@ -566,8 +570,7 @@ public class WebOfTrustConnector {
                 * @return The created simple field set constructor
                 */
                public static SimpleFieldSetConstructor create(boolean shortLived) {
-                       SimpleFieldSetConstructor simpleFieldSetConstructor = new SimpleFieldSetConstructor(shortLived);
-                       return simpleFieldSetConstructor;
+                       return new SimpleFieldSetConstructor(shortLived);
                }
 
        }
index 2727226..c262513 100644 (file)
@@ -68,4 +68,18 @@ public abstract class IdentityEvent {
                return identity;
        }
 
+       @Override
+       public int hashCode() {
+               return ownIdentity().hashCode() ^ identity().hashCode();
+       }
+
+       @Override
+       public boolean equals(Object object) {
+               if ((object == null) || !object.getClass().equals(getClass())) {
+                       return false;
+               }
+               IdentityEvent identityEvent = (IdentityEvent) object;
+               return ownIdentity().equals(identityEvent.ownIdentity()) && identity().equals(identityEvent.identity());
+       }
+
 }
index 97179e8..1216273 100644 (file)
@@ -52,4 +52,18 @@ public abstract class OwnIdentityEvent {
                return ownIdentity;
        }
 
+       @Override
+       public int hashCode() {
+               return ownIdentity().hashCode();
+       }
+
+       @Override
+       public boolean equals(Object object) {
+               if ((object == null) || !object.getClass().equals(getClass())) {
+                       return false;
+               }
+               OwnIdentityEvent ownIdentityEvent = (OwnIdentityEvent) object;
+               return ownIdentity().equals(ownIdentityEvent.ownIdentity());
+       }
+
 }
index 42eeb45..392cc40 100644 (file)
 
 package net.pterodactylus.sone.main;
 
+import static com.google.common.base.Optional.of;
+import static java.util.logging.Logger.getLogger;
+
 import java.io.File;
+import java.util.logging.Handler;
 import java.util.logging.Level;
 import java.util.logging.LogRecord;
 import java.util.logging.Logger;
@@ -25,6 +29,7 @@ import java.util.logging.Logger;
 import net.pterodactylus.sone.core.Core;
 import net.pterodactylus.sone.core.FreenetInterface;
 import net.pterodactylus.sone.core.WebOfTrustUpdater;
+import net.pterodactylus.sone.core.WebOfTrustUpdaterImpl;
 import net.pterodactylus.sone.database.Database;
 import net.pterodactylus.sone.database.PostBuilderFactory;
 import net.pterodactylus.sone.database.PostProvider;
@@ -34,16 +39,17 @@ import net.pterodactylus.sone.database.memory.MemoryDatabase;
 import net.pterodactylus.sone.fcp.FcpInterface;
 import net.pterodactylus.sone.freenet.PluginStoreConfigurationBackend;
 import net.pterodactylus.sone.freenet.plugin.PluginConnector;
+import net.pterodactylus.sone.freenet.wot.Context;
 import net.pterodactylus.sone.freenet.wot.IdentityManager;
+import net.pterodactylus.sone.freenet.wot.IdentityManagerImpl;
 import net.pterodactylus.sone.freenet.wot.WebOfTrustConnector;
 import net.pterodactylus.sone.web.WebInterface;
 import net.pterodactylus.util.config.Configuration;
 import net.pterodactylus.util.config.ConfigurationException;
 import net.pterodactylus.util.config.MapConfigurationBackend;
-import net.pterodactylus.util.logging.Logging;
-import net.pterodactylus.util.logging.LoggingListener;
 import net.pterodactylus.util.version.Version;
 
+import com.google.common.base.Optional;
 import com.google.common.eventbus.EventBus;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
@@ -51,12 +57,11 @@ import com.google.inject.Injector;
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.matcher.Matchers;
-import com.google.inject.name.Names;
 import com.google.inject.spi.InjectionListener;
 import com.google.inject.spi.TypeEncounter;
 import com.google.inject.spi.TypeListener;
 
-import freenet.client.async.DatabaseDisabledException;
+import freenet.client.async.PersistenceDisabledException;
 import freenet.l10n.BaseL10n.LANGUAGE;
 import freenet.l10n.PluginL10n;
 import freenet.node.Node;
@@ -81,33 +86,40 @@ public class SonePlugin implements FredPlugin, FredPluginFCP, FredPluginL10n, Fr
 
        static {
                /* initialize logging. */
-               Logging.setup("sone");
-               Logging.addLoggingListener(new LoggingListener() {
-
+               Logger soneLogger = getLogger("Sone");
+               soneLogger.setUseParentHandlers(false);
+               soneLogger.addHandler(new Handler() {
                        @Override
-                       public void logged(LogRecord logRecord) {
-                               Class<?> loggerClass = Logging.getLoggerClass(logRecord.getLoggerName());
+                       public void publish(LogRecord logRecord) {
                                int recordLevel = logRecord.getLevel().intValue();
                                if (recordLevel < Level.FINE.intValue()) {
-                                       freenet.support.Logger.debug(loggerClass, logRecord.getMessage(), logRecord.getThrown());
+                                       freenet.support.Logger.debug(logRecord.getLoggerName(), logRecord.getMessage(), logRecord.getThrown());
                                } else if (recordLevel < Level.INFO.intValue()) {
-                                       freenet.support.Logger.minor(loggerClass, logRecord.getMessage(), logRecord.getThrown());
+                                       freenet.support.Logger.minor(logRecord.getLoggerName(), logRecord.getMessage(), logRecord.getThrown());
                                } else if (recordLevel < Level.WARNING.intValue()) {
-                                       freenet.support.Logger.normal(loggerClass, logRecord.getMessage(), logRecord.getThrown());
+                                       freenet.support.Logger.normal(logRecord.getLoggerName(), logRecord.getMessage(), logRecord.getThrown());
                                } else if (recordLevel < Level.SEVERE.intValue()) {
-                                       freenet.support.Logger.warning(loggerClass, logRecord.getMessage(), logRecord.getThrown());
+                                       freenet.support.Logger.warning(logRecord.getLoggerName(), logRecord.getMessage(), logRecord.getThrown());
                                } else {
-                                       freenet.support.Logger.error(loggerClass, logRecord.getMessage(), logRecord.getThrown());
+                                       freenet.support.Logger.error(logRecord.getLoggerName(), logRecord.getMessage(), logRecord.getThrown());
                                }
                        }
+
+                       @Override
+                       public void flush() {
+                       }
+
+                       @Override
+                       public void close() {
+                       }
                });
        }
 
        /** The version. */
-       public static final Version VERSION = new Version(0, 8, 9);
+       public static final Version VERSION = new Version("rc1", 0, 9);
 
        /** The logger. */
-       private static final Logger logger = Logging.getLogger(SonePlugin.class);
+       private static final Logger logger = getLogger("Sone.Plugin");
 
        /** The plugin respirator. */
        private PluginRespirator pluginRespirator;
@@ -189,13 +201,19 @@ public class SonePlugin implements FredPlugin, FredPluginFCP, FredPluginL10n, Fr
                        try {
                                oldConfiguration = new Configuration(new PluginStoreConfigurationBackend(pluginRespirator));
                                logger.log(Level.INFO, "Plugin store loaded.");
-                       } catch (DatabaseDisabledException dde1) {
+                       } catch (PersistenceDisabledException pde1) {
                                logger.log(Level.SEVERE, "Could not load any configuration, using empty configuration!");
                                oldConfiguration = new Configuration(new MapConfigurationBackend());
                        }
                }
 
-               final Configuration startConfiguration = oldConfiguration;
+               final Configuration startConfiguration;
+               if ((newConfiguration != null) && (oldConfiguration != newConfiguration)) {
+                       logger.log(Level.INFO, "Setting configuration to file-based configuration.");
+                       startConfiguration = newConfiguration;
+               } else {
+                       startConfiguration = oldConfiguration;
+               }
                final EventBus eventBus = new EventBus();
 
                /* Freenet injector configuration. */
@@ -213,23 +231,12 @@ public class SonePlugin implements FredPlugin, FredPluginFCP, FredPluginL10n, Fr
 
                        @Override
                        protected void configure() {
-                               bind(Core.class).in(Singleton.class);
-                               bind(MemoryDatabase.class).in(Singleton.class);
                                bind(EventBus.class).toInstance(eventBus);
                                bind(Configuration.class).toInstance(startConfiguration);
-                               bind(FreenetInterface.class).in(Singleton.class);
-                               bind(PluginConnector.class).in(Singleton.class);
-                               bind(WebOfTrustConnector.class).in(Singleton.class);
-                               bind(WebOfTrustUpdater.class).in(Singleton.class);
-                               bind(IdentityManager.class).in(Singleton.class);
-                               bind(String.class).annotatedWith(Names.named("WebOfTrustContext")).toInstance("Sone");
+                               Context context = new Context("Sone");
+                               bind(Context.class).toInstance(context);
+                               bind(getOptionalContextTypeLiteral()).toInstance(of(context));
                                bind(SonePlugin.class).toInstance(SonePlugin.this);
-                               bind(FcpInterface.class).in(Singleton.class);
-                               bind(Database.class).to(MemoryDatabase.class);
-                               bind(PostBuilderFactory.class).to(MemoryDatabase.class);
-                               bind(PostReplyBuilderFactory.class).to(MemoryDatabase.class);
-                               bind(SoneProvider.class).to(Core.class).in(Singleton.class);
-                               bind(PostProvider.class).to(MemoryDatabase.class);
                                bindListener(Matchers.any(), new TypeListener() {
 
                                        @Override
@@ -245,6 +252,11 @@ public class SonePlugin implements FredPlugin, FredPluginFCP, FredPluginL10n, Fr
                                });
                        }
 
+                       private TypeLiteral<Optional<Context>> getOptionalContextTypeLiteral() {
+                               return new TypeLiteral<Optional<Context>>() {
+                               };
+                       }
+
                };
                Injector injector = Guice.createInjector(freenetModule, soneModule);
                core = injector.getInstance(Core.class);
@@ -254,34 +266,15 @@ public class SonePlugin implements FredPlugin, FredPluginFCP, FredPluginL10n, Fr
 
                /* create FCP interface. */
                fcpInterface = injector.getInstance(FcpInterface.class);
-               core.setFcpInterface(fcpInterface);
 
                /* create the web interface. */
                webInterface = injector.getInstance(WebInterface.class);
 
-               boolean startupFailed = true;
-               try {
-
-                       /* start core! */
-                       core.start();
-                       if ((newConfiguration != null) && (oldConfiguration != newConfiguration)) {
-                               logger.log(Level.INFO, "Setting configuration to file-based configuration.");
-                               core.setConfiguration(newConfiguration);
-                       }
-                       webInterface.start();
-                       webInterface.setFirstStart(firstStart);
-                       webInterface.setNewConfig(newConfig);
-                       startupFailed = false;
-               } finally {
-                       if (startupFailed) {
-                               /*
-                                * we let the exception bubble up but shut the logging down so
-                                * that the logfile is not swamped by the installed logging
-                                * handlers of the failed instances.
-                                */
-                               Logging.shutdown();
-                       }
-               }
+               /* start core! */
+               core.start();
+               webInterface.start();
+               webInterface.setFirstStart(firstStart);
+               webInterface.setNewConfig(newConfig);
        }
 
        /**
@@ -300,9 +293,6 @@ public class SonePlugin implements FredPlugin, FredPluginFCP, FredPluginL10n, Fr
                        webOfTrustConnector.stop();
                } catch (Throwable t1) {
                        logger.log(Level.SEVERE, "Error while shutting down!", t1);
-               } finally {
-                       /* shutdown logger. */
-                       Logging.shutdown();
                }
        }
 
index f431bb7..f93e1f4 100644 (file)
@@ -59,12 +59,12 @@ public class ListNotificationFilters {
                List<Notification> filteredNotifications = new ArrayList<Notification>();
                for (Notification notification : notifications) {
                        if (notification.getId().equals("new-sone-notification")) {
-                               if ((currentSone != null) && (!currentSone.getOptions().getBooleanOption("ShowNotification/NewSones").get())) {
+                               if ((currentSone != null) && !currentSone.getOptions().isShowNewSoneNotifications()) {
                                        continue;
                                }
                                filteredNotifications.add(notification);
                        } else if (notification.getId().equals("new-post-notification")) {
-                               if ((currentSone != null) && (!currentSone.getOptions().getBooleanOption("ShowNotification/NewPosts").get())) {
+                               if ((currentSone != null) && !currentSone.getOptions().isShowNewPostNotifications()) {
                                        continue;
                                }
                                ListNotification<Post> filteredNotification = filterNewPostNotification((ListNotification<Post>) notification, currentSone, true);
@@ -72,7 +72,7 @@ public class ListNotificationFilters {
                                        filteredNotifications.add(filteredNotification);
                                }
                        } else if (notification.getId().equals("new-reply-notification")) {
-                               if ((currentSone != null) && (!currentSone.getOptions().getBooleanOption("ShowNotification/NewReplies").get())) {
+                               if ((currentSone != null) && !currentSone.getOptions().isShowNewReplyNotifications()) {
                                        continue;
                                }
                                ListNotification<PostReply> filteredNotification = filterNewReplyNotification((ListNotification<PostReply>) notification, currentSone);
@@ -222,10 +222,10 @@ public class ListNotificationFilters {
         */
        public static boolean isPostVisible(Sone sone, Post post) {
                checkNotNull(post, "post must not be null");
-               Sone postSone = post.getSone();
-               if (postSone == null) {
+               if (!post.isLoaded()) {
                        return false;
                }
+               Sone postSone = post.getSone();
                if (sone != null) {
                        Trust trust = postSone.getIdentity().getTrust((OwnIdentity) sone.getIdentity());
                        if (trust != null) {
index 12b4bc2..b5a4255 100644 (file)
@@ -45,9 +45,6 @@ public class CollectionAccessor extends ReflectionAccessor {
         */
        @Override
        public Object get(TemplateContext templateContext, Object object, String member) {
-               if (object == null) {
-                       return null;
-               }
                Collection<?> collection = (Collection<?>) object;
                if (member.equals("soneNames")) {
                        List<Sone> sones = new ArrayList<Sone>();
index c08a0a2..efb4d16 100644 (file)
@@ -55,9 +55,8 @@ public class IdentityAccessor extends ReflectionAccessor {
                Identity identity = (Identity) object;
                if ("uniqueNickname".equals(member)) {
                        int minLength = -1;
-                       boolean found = false;
-                       Set<OwnIdentity> ownIdentities = null;
-                       ownIdentities = core.getIdentityManager().getAllOwnIdentities();
+                       boolean found;
+                       Set<OwnIdentity> ownIdentities = core.getIdentityManager().getAllOwnIdentities();
                        do {
                                boolean unique = true;
                                String abbreviatedWantedNickname = getAbbreviatedNickname(identity, ++minLength);
index 7e3723c..9ede851 100644 (file)
 
 package net.pterodactylus.sone.template;
 
+import static java.lang.Integer.MAX_VALUE;
+import static java.lang.String.valueOf;
+import static net.pterodactylus.sone.utils.NumberParsers.parseInt;
+
 import java.io.StringReader;
 import java.io.StringWriter;
 import java.util.Map;
 
 import net.pterodactylus.sone.core.Core;
 import net.pterodactylus.sone.data.Image;
-import net.pterodactylus.util.number.Numbers;
 import net.pterodactylus.util.template.Filter;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
@@ -76,11 +79,11 @@ public class ImageLinkFilter implements Filter {
                if (image == null) {
                        return null;
                }
-               String imageClass = String.valueOf(parameters.get("class"));
-               int maxWidth = Numbers.safeParseInteger(parameters.get("max-width"), Integer.MAX_VALUE);
-               int maxHeight = Numbers.safeParseInteger(parameters.get("max-height"), Integer.MAX_VALUE);
-               String mode = String.valueOf(parameters.get("mode"));
-               String title = String.valueOf(parameters.get("title"));
+               String imageClass = valueOf(parameters.get("class"));
+               int maxWidth = parseInt(valueOf(parameters.get("max-width")), MAX_VALUE);
+               int maxHeight = parseInt(valueOf(parameters.get("max-height")), MAX_VALUE);
+               String mode = valueOf(parameters.get("mode"));
+               String title = valueOf(parameters.get("title"));
 
                TemplateContext linkTemplateContext = templateContextFactory.createTemplateContext();
                linkTemplateContext.set("class", imageClass);
index bd6903b..ec35e2e 100644 (file)
@@ -17,6 +17,9 @@
 
 package net.pterodactylus.sone.template;
 
+import static java.lang.String.valueOf;
+import static net.pterodactylus.sone.utils.NumberParsers.parseInt;
+
 import java.io.IOException;
 import java.io.StringReader;
 import java.io.StringWriter;
@@ -38,7 +41,6 @@ import net.pterodactylus.sone.text.SonePart;
 import net.pterodactylus.sone.text.SoneTextParser;
 import net.pterodactylus.sone.text.SoneTextParserContext;
 import net.pterodactylus.sone.web.page.FreenetRequest;
-import net.pterodactylus.util.number.Numbers;
 import net.pterodactylus.util.template.Filter;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
@@ -89,9 +91,9 @@ public class ParserFilter implements Filter {
         */
        @Override
        public Object format(TemplateContext templateContext, Object data, Map<String, Object> parameters) {
-               String text = String.valueOf(data);
-               int length = Numbers.safeParseInteger(parameters.get("length"), Numbers.safeParseInteger(templateContext.get(String.valueOf(parameters.get("length"))), -1));
-               int cutOffLength = Numbers.safeParseInteger(parameters.get("cut-off-length"), Numbers.safeParseInteger(templateContext.get(String.valueOf(parameters.get("cut-off-length"))), length));
+               String text = valueOf(data);
+               int length = parseInt(valueOf(parameters.get("length")), -1);
+               int cutOffLength = parseInt(valueOf(parameters.get("cut-off-length")), length);
                Object sone = parameters.get("sone");
                if (sone instanceof String) {
                        sone = core.getSone((String) sone).orNull();
index d593b51..78db678 100644 (file)
@@ -67,8 +67,6 @@ public class PostAccessor extends ReflectionAccessor {
                        return !post.isKnown();
                } else if (member.equals("bookmarked")) {
                        return core.isBookmarked(post);
-               } else if (member.equals("loaded")) {
-                       return post.getSone() != null;
                }
                return super.get(templateContext, object, member);
        }
index 30c0a52..762bc14 100644 (file)
@@ -74,7 +74,7 @@ public class ProfileAccessor extends ReflectionAccessor {
                                /* always show your own avatars. */
                                return avatarId;
                        }
-                       ShowCustomAvatars showCustomAvatars = currentSone.getOptions().<ShowCustomAvatars> getEnumOption("ShowCustomAvatars").get();
+                       ShowCustomAvatars showCustomAvatars = currentSone.getOptions().getShowCustomAvatars();
                        if (showCustomAvatars == ShowCustomAvatars.NEVER) {
                                return null;
                        }
index 0d4f556..a5a4edf 100644 (file)
@@ -68,8 +68,7 @@ public class RequestChangeFilter implements Filter {
                        if (questionMark == -1) {
                                questionMark = oldUri.length();
                        }
-                       URI u = new URI(oldUri.substring(0, questionMark) + query.toString());
-                       return u;
+                       return new URI(oldUri.substring(0, questionMark) + query.toString());
                } catch (UnsupportedEncodingException uee1) {
                        /* UTF-8 not supported? I don’t think so. */
                } catch (URISyntaxException use1) {
index 92be8e0..711332a 100644 (file)
@@ -19,6 +19,7 @@ package net.pterodactylus.sone.template;
 
 import static com.google.common.collect.FluentIterable.from;
 import static java.util.Arrays.asList;
+import static java.util.logging.Logger.getLogger;
 import static net.pterodactylus.sone.data.Album.FLATTENER;
 import static net.pterodactylus.sone.data.Album.IMAGES;
 
@@ -33,7 +34,6 @@ import net.pterodactylus.sone.freenet.wot.OwnIdentity;
 import net.pterodactylus.sone.freenet.wot.Trust;
 import net.pterodactylus.sone.web.WebInterface;
 import net.pterodactylus.sone.web.ajax.GetTimesAjaxPage;
-import net.pterodactylus.util.logging.Logging;
 import net.pterodactylus.util.template.Accessor;
 import net.pterodactylus.util.template.ReflectionAccessor;
 import net.pterodactylus.util.template.TemplateContext;
@@ -58,7 +58,7 @@ import net.pterodactylus.util.template.TemplateContext;
 public class SoneAccessor extends ReflectionAccessor {
 
        /** The logger. */
-       private static final Logger logger = Logging.getLogger(SoneAccessor.class);
+       private static final Logger logger = getLogger("Sone.Data");
 
        /** The core. */
        private final Core core;
index 0b1585a..cc5c59b 100644 (file)
@@ -17,6 +17,8 @@
 
 package net.pterodactylus.sone.text;
 
+import static java.util.logging.Logger.getLogger;
+
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.Reader;
@@ -28,11 +30,10 @@ import java.util.regex.Pattern;
 
 import net.pterodactylus.sone.data.Post;
 import net.pterodactylus.sone.data.Sone;
-import net.pterodactylus.sone.data.SoneImpl;
+import net.pterodactylus.sone.data.impl.IdOnlySone;
 import net.pterodactylus.sone.database.PostProvider;
 import net.pterodactylus.sone.database.SoneProvider;
 import net.pterodactylus.util.io.Closer;
-import net.pterodactylus.util.logging.Logging;
 
 import com.google.common.base.Optional;
 
@@ -46,7 +47,7 @@ import freenet.keys.FreenetURI;
 public class SoneTextParser implements Parser<SoneTextParserContext> {
 
        /** The logger. */
-       private static final Logger logger = Logging.getLogger(SoneTextParser.class);
+       private static final Logger logger = getLogger("Sone.Data.Parser");
 
        /** Pattern to detect whitespace. */
        private static final Pattern whitespacePattern = Pattern.compile("[\\u000a\u0020\u00a0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u200b\u200c\u200d\u202f\u205f\u2060\u2800\u3000]");
@@ -249,7 +250,7 @@ public class SoneTextParser implements Parser<SoneTextParserContext> {
                                                                 * don’t use create=true above, we don’t want
                                                                 * the empty shell.
                                                                 */
-                                                               sone = Optional.<Sone>of(new SoneImpl(soneId, false));
+                                                               sone = Optional.<Sone>of(new IdOnlySone(soneId));
                                                        }
                                                        parts.add(new SonePart(sone.get()));
                                                        line = line.substring(50);
diff --git a/src/main/java/net/pterodactylus/sone/utils/DefaultOption.java b/src/main/java/net/pterodactylus/sone/utils/DefaultOption.java
new file mode 100644 (file)
index 0000000..0939f21
--- /dev/null
@@ -0,0 +1,84 @@
+package net.pterodactylus.sone.utils;
+
+import com.google.common.base.Predicate;
+
+/**
+ * Basic implementation of an {@link Option}.
+ *
+ * @param <T>
+ *            The type of the option
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class DefaultOption<T> implements Option<T> {
+
+       /** The default value. */
+       private final T defaultValue;
+
+       /** The current value. */
+       private volatile T value;
+
+       /** The validator. */
+       private Predicate<T> validator;
+
+       /**
+        * Creates a new default option.
+        *
+        * @param defaultValue
+        *            The default value of the option
+        */
+       public DefaultOption(T defaultValue) {
+               this(defaultValue, null);
+       }
+
+       /**
+        * Creates a new default option.
+        *
+        * @param defaultValue
+        *            The default value of the option
+        * @param validator
+        *            The validator for value validation (may be {@code null})
+        */
+       public DefaultOption(T defaultValue, Predicate<T> validator) {
+               this.defaultValue = defaultValue;
+               this.validator = validator;
+       }
+
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public T get() {
+               return (value != null) ? value : defaultValue;
+       }
+
+       /**
+        * Returns the real value of the option. This will also return an unset
+        * value (usually {@code null})!
+        *
+        * @return The real value of the option
+        */
+       @Override
+       public T getReal() {
+               return value;
+       }
+
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public boolean validate(T value) {
+               return (validator == null) || (value == null) || validator.apply(value);
+       }
+
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public void set(T value) {
+               if ((value != null) && (validator != null) && (!validator.apply(value))) {
+                       throw new IllegalArgumentException("New Value (" + value + ") could not be validated.");
+               }
+               this.value = value;
+       }
+
+}
index db6a841..a4022de 100644 (file)
@@ -59,4 +59,8 @@ public class IntegerRangePredicate implements Predicate<Integer> {
                return (value != null) && (value >= lowerBound) && (value <= upperBound);
        }
 
+       public static IntegerRangePredicate range(int lowerBound, int upperBound) {
+               return new IntegerRangePredicate(lowerBound, upperBound);
+       }
+
 }
diff --git a/src/main/java/net/pterodactylus/sone/utils/NumberParsers.java b/src/main/java/net/pterodactylus/sone/utils/NumberParsers.java
new file mode 100644 (file)
index 0000000..9e8afb4
--- /dev/null
@@ -0,0 +1,36 @@
+package net.pterodactylus.sone.utils;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.google.common.primitives.Ints;
+import com.google.common.primitives.Longs;
+
+/**
+ * Parses numbers from strings.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class NumberParsers {
+
+       @Nonnull
+       public static Integer parseInt(@Nullable String text,
+                       @Nullable Integer defaultValue) {
+               if (text == null) {
+                       return defaultValue;
+               }
+               Integer value = Ints.tryParse(text);
+               return (value == null) ? defaultValue : value;
+       }
+
+       @Nonnull
+       public static Long parseLong(@Nullable String text,
+                       @Nullable Long defaultValue) {
+               if (text == null) {
+                       return defaultValue;
+               }
+               Long value = Longs.tryParse(text);
+               return (value == null) ? defaultValue : value;
+       }
+
+}
diff --git a/src/main/java/net/pterodactylus/sone/utils/Option.java b/src/main/java/net/pterodactylus/sone/utils/Option.java
new file mode 100644 (file)
index 0000000..9149c07
--- /dev/null
@@ -0,0 +1,49 @@
+package net.pterodactylus.sone.utils;
+
+/**
+ * Contains current and default value of an option.
+ *
+ * @param <T>
+ *            The type of the option
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public interface Option<T> {
+
+       /**
+        * Returns the current value of the option. If the current value is not
+        * set (usually {@code null}), the default value is returned.
+        *
+        * @return The current value of the option
+        */
+       public T get();
+
+       /**
+        * Returns the real value of the option. This will also return an unset
+        * value (usually {@code null})!
+        *
+        * @return The real value of the option
+        */
+       public T getReal();
+
+       /**
+        * Validates the given value. Note that {@code null} is always a valid
+        * value!
+        *
+        * @param value
+        *            The value to validate
+        * @return {@code true} if this option does not have a validator, or the
+        *         validator validates this object, {@code false} otherwise
+        */
+       public boolean validate(T value);
+
+       /**
+        * Sets the current value of the option.
+        *
+        * @param value
+        *            The new value of the option
+        * @throws IllegalArgumentException
+        *             if the value is not valid for this option
+        */
+       public void set(T value) throws IllegalArgumentException;
+
+}
diff --git a/src/main/java/net/pterodactylus/sone/utils/Optionals.java b/src/main/java/net/pterodactylus/sone/utils/Optionals.java
new file mode 100644 (file)
index 0000000..67132aa
--- /dev/null
@@ -0,0 +1,32 @@
+package net.pterodactylus.sone.utils;
+
+import com.google.common.base.Function;
+import com.google.common.base.Optional;
+import com.google.common.base.Predicate;
+
+/**
+ * Helper methods for dealing with {@link Optional}s.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class Optionals {
+
+       public static Predicate<Optional<?>> isPresent() {
+               return new Predicate<Optional<?>>() {
+                       @Override
+                       public boolean apply(Optional<?> input) {
+                               return input.isPresent();
+                       }
+               };
+       }
+
+       public static <T> Function<Optional<T>, T> get() {
+               return new Function<Optional<T>, T>() {
+                       @Override
+                       public T apply(Optional<T> input) {
+                               return input.get();
+                       }
+               };
+       }
+
+}
index 042a683..c0a8907 100644 (file)
 
 package net.pterodactylus.sone.web;
 
+import net.pterodactylus.sone.data.Post;
 import net.pterodactylus.sone.web.page.FreenetRequest;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
 import net.pterodactylus.util.web.Method;
 
+import com.google.common.base.Optional;
+
 /**
  * Page that lets the user bookmark a post.
  *
@@ -52,7 +55,10 @@ public class BookmarkPage extends SoneTemplatePage {
                if (request.getMethod() == Method.POST) {
                        String id = request.getHttpRequest().getPartAsStringFailsafe("post", 36);
                        String returnPage = request.getHttpRequest().getPartAsStringFailsafe("returnPage", 256);
-                       webInterface.getCore().bookmarkPost(id);
+                       Optional<Post> post = webInterface.getCore().getPost(id);
+                       if (post.isPresent()) {
+                               webInterface.getCore().bookmarkPost(post.get());
+                       }
                        throw new RedirectException(returnPage);
                }
        }
index 10b6dc3..0f53572 100644 (file)
@@ -17,6 +17,8 @@
 
 package net.pterodactylus.sone.web;
 
+import static net.pterodactylus.sone.utils.NumberParsers.parseInt;
+
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -26,7 +28,6 @@ import java.util.Set;
 import net.pterodactylus.sone.data.Post;
 import net.pterodactylus.sone.web.page.FreenetRequest;
 import net.pterodactylus.util.collection.Pagination;
-import net.pterodactylus.util.number.Numbers;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
 
@@ -67,12 +68,12 @@ public class BookmarksPage extends SoneTemplatePage {
 
                        @Override
                        public boolean apply(Post post) {
-                               return post.getSone() != null;
+                               return post.isLoaded();
                        }
                });
                List<Post> sortedPosts = new ArrayList<Post>(loadedPosts);
                Collections.sort(sortedPosts, Post.TIME_COMPARATOR);
-               Pagination<Post> pagination = new Pagination<Post>(sortedPosts, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("page"), 0));
+               Pagination<Post> pagination = new Pagination<Post>(sortedPosts, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(parseInt(request.getHttpRequest().getParam("page"), 0));
                templateContext.set("pagination", pagination);
                templateContext.set("posts", pagination.getItems());
                templateContext.set("postsNotLoaded", allPosts.size() != loadedPosts.size());
index a8d024a..1c599e4 100644 (file)
@@ -18,6 +18,7 @@
 package net.pterodactylus.sone.web;
 
 import net.pterodactylus.sone.data.Album;
+import net.pterodactylus.sone.data.Album.Modifier.AlbumTitleMustNotBeEmpty;
 import net.pterodactylus.sone.data.Sone;
 import net.pterodactylus.sone.text.TextFilter;
 import net.pterodactylus.sone.web.page.FreenetRequest;
@@ -63,12 +64,16 @@ public class CreateAlbumPage extends SoneTemplatePage {
                        String description = request.getHttpRequest().getPartAsStringFailsafe("description", 256).trim();
                        Sone currentSone = getCurrentSone(request.getToadletContext());
                        String parentId = request.getHttpRequest().getPartAsStringFailsafe("parent", 36);
-                       Album parent = webInterface.getCore().getAlbum(parentId, false);
+                       Album parent = webInterface.getCore().getAlbum(parentId);
                        if (parentId.equals("")) {
                                parent = currentSone.getRootAlbum();
                        }
                        Album album = webInterface.getCore().createAlbum(currentSone, parent);
-                       album.modify().setTitle(name).setDescription(TextFilter.filter(request.getHttpRequest().getHeader("host"), description)).update();
+                       try {
+                               album.modify().setTitle(name).setDescription(TextFilter.filter(request.getHttpRequest().getHeader("host"), description)).update();
+                       } catch (AlbumTitleMustNotBeEmpty atmnbe) {
+                               throw new RedirectException("emptyAlbumTitle.html");
+                       }
                        webInterface.getCore().touchConfiguration();
                        throw new RedirectException("imageBrowser.html?album=" + album.getId());
                }
index 83913b4..6530a5b 100644 (file)
@@ -63,13 +63,13 @@ public class CreatePostPage extends SoneTemplatePage {
                                String senderId = request.getHttpRequest().getPartAsStringFailsafe("sender", 43);
                                String recipientId = request.getHttpRequest().getPartAsStringFailsafe("recipient", 43);
                                Sone currentSone = getCurrentSone(request.getToadletContext());
-                               Sone sender = webInterface.getCore().getLocalSone(senderId, false);
+                               Sone sender = webInterface.getCore().getLocalSone(senderId);
                                if (sender == null) {
                                        sender = currentSone;
                                }
                                Optional<Sone> recipient = webInterface.getCore().getSone(recipientId);
                                text = TextFilter.filter(request.getHttpRequest().getHeader("host"), text);
-                               webInterface.getCore().createPost(sender, recipient, System.currentTimeMillis(), text);
+                               webInterface.getCore().createPost(sender, recipient, text);
                                throw new RedirectException(returnPage);
                        }
                        templateContext.set("errorTextEmpty", true);
index c8979c1..55903d8 100644 (file)
@@ -66,7 +66,7 @@ public class CreateReplyPage extends SoneTemplatePage {
                        }
                        if (text.length() > 0) {
                                String senderId = request.getHttpRequest().getPartAsStringFailsafe("sender", 43);
-                               Sone sender = webInterface.getCore().getLocalSone(senderId, false);
+                               Sone sender = webInterface.getCore().getLocalSone(senderId);
                                if (sender == null) {
                                        sender = getCurrentSone(request.getToadletContext());
                                }
index fe8c32e..b2d4578 100644 (file)
@@ -17,6 +17,8 @@
 
 package net.pterodactylus.sone.web;
 
+import static java.util.logging.Logger.getLogger;
+
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
@@ -29,7 +31,6 @@ import net.pterodactylus.sone.core.Core;
 import net.pterodactylus.sone.data.Sone;
 import net.pterodactylus.sone.freenet.wot.OwnIdentity;
 import net.pterodactylus.sone.web.page.FreenetRequest;
-import net.pterodactylus.util.logging.Logging;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
 import net.pterodactylus.util.web.Method;
@@ -43,7 +44,7 @@ import freenet.clients.http.ToadletContext;
 public class CreateSonePage extends SoneTemplatePage {
 
        /** The logger. */
-       private static final Logger logger = Logging.getLogger(CreateSonePage.class);
+       private static final Logger logger = getLogger("Sone.Web.CreateSone");
 
        /**
         * Creates a new “create Sone” page.
index 391e9ff..98e8909 100644 (file)
@@ -50,7 +50,7 @@ public class DeleteAlbumPage extends SoneTemplatePage {
                super.processTemplate(request, templateContext);
                if (request.getMethod() == Method.POST) {
                        String albumId = request.getHttpRequest().getPartAsStringFailsafe("album", 36);
-                       Album album = webInterface.getCore().getAlbum(albumId, false);
+                       Album album = webInterface.getCore().getAlbum(albumId);
                        if (album == null) {
                                throw new RedirectException("invalid.html");
                        }
@@ -68,7 +68,7 @@ public class DeleteAlbumPage extends SoneTemplatePage {
                        throw new RedirectException("imageBrowser.html?album=" + parentAlbum.getId());
                }
                String albumId = request.getHttpRequest().getParam("album");
-               Album album = webInterface.getCore().getAlbum(albumId, false);
+               Album album = webInterface.getCore().getAlbum(albumId);
                if (album == null) {
                        throw new RedirectException("invalid.html");
                }
index 6ef86f0..5a309fa 100644 (file)
@@ -63,7 +63,6 @@ public class DeletePostPage extends SoneTemplatePage {
                        }
                        templateContext.set("post", post.get());
                        templateContext.set("returnPage", returnPage);
-                       return;
                } else if (request.getMethod() == Method.POST) {
                        String postId = request.getHttpRequest().getPartAsStringFailsafe("post", 36);
                        String returnPage = request.getHttpRequest().getPartAsStringFailsafe("returnPage", 256);
index 9e4aae9..66434e5 100644 (file)
@@ -18,6 +18,7 @@
 package net.pterodactylus.sone.web;
 
 import net.pterodactylus.sone.data.Album;
+import net.pterodactylus.sone.data.Album.Modifier.AlbumTitleMustNotBeEmpty;
 import net.pterodactylus.sone.text.TextFilter;
 import net.pterodactylus.sone.web.page.FreenetRequest;
 import net.pterodactylus.util.template.Template;
@@ -51,7 +52,7 @@ public class EditAlbumPage extends SoneTemplatePage {
                super.processTemplate(request, templateContext);
                if (request.getMethod() == Method.POST) {
                        String albumId = request.getHttpRequest().getPartAsStringFailsafe("album", 36);
-                       Album album = webInterface.getCore().getAlbum(albumId, false);
+                       Album album = webInterface.getCore().getAlbum(albumId);
                        if (album == null) {
                                throw new RedirectException("invalid.html");
                        }
@@ -73,12 +74,12 @@ public class EditAlbumPage extends SoneTemplatePage {
                        }
                        album.modify().setAlbumImage(albumImageId).update();
                        String title = request.getHttpRequest().getPartAsStringFailsafe("title", 100).trim();
-                       if (title.length() == 0) {
-                               templateContext.set("titleMissing", true);
-                               return;
-                       }
                        String description = request.getHttpRequest().getPartAsStringFailsafe("description", 1000).trim();
-                       album.modify().setTitle(title).setDescription(TextFilter.filter(request.getHttpRequest().getHeader("host"), description)).update();
+                       try {
+                               album.modify().setTitle(title).setDescription(TextFilter.filter(request.getHttpRequest().getHeader("host"), description)).update();
+                       } catch (AlbumTitleMustNotBeEmpty atmnbe) {
+                               throw new RedirectException("emptyAlbumTitle.html");
+                       }
                        webInterface.getCore().touchConfiguration();
                        throw new RedirectException("imageBrowser.html?album=" + album.getId());
                }
index 9a29c85..178add1 100644 (file)
@@ -71,7 +71,7 @@ public class EditImagePage extends SoneTemplatePage {
                                String title = request.getHttpRequest().getPartAsStringFailsafe("title", 100).trim();
                                String description = request.getHttpRequest().getPartAsStringFailsafe("description", 1024).trim();
                                if (title.length() == 0) {
-                                       templateContext.set("titleMissing", true);
+                                       throw new RedirectException("emptyImageTitle.html");
                                }
                                image.modify().setTitle(title).setDescription(TextFilter.filter(request.getHttpRequest().getHeader("host"), description)).update();
                        }
index d0ddb26..162d637 100644 (file)
 package net.pterodactylus.sone.web;
 
 import static net.pterodactylus.sone.text.TextFilter.filter;
+import static net.pterodactylus.sone.utils.NumberParsers.parseInt;
 
 import java.util.List;
 
 import net.pterodactylus.sone.data.Profile;
+import net.pterodactylus.sone.data.Profile.DuplicateField;
 import net.pterodactylus.sone.data.Profile.Field;
 import net.pterodactylus.sone.data.Sone;
-import net.pterodactylus.sone.text.TextFilter;
 import net.pterodactylus.sone.web.page.FreenetRequest;
-import net.pterodactylus.util.number.Numbers;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
 import net.pterodactylus.util.web.Method;
@@ -77,9 +77,9 @@ public class EditProfilePage extends SoneTemplatePage {
                                firstName = request.getHttpRequest().getPartAsStringFailsafe("first-name", 256).trim();
                                middleName = request.getHttpRequest().getPartAsStringFailsafe("middle-name", 256).trim();
                                lastName = request.getHttpRequest().getPartAsStringFailsafe("last-name", 256).trim();
-                               birthDay = Numbers.safeParseInteger(request.getHttpRequest().getPartAsStringFailsafe("birth-day", 256).trim());
-                               birthMonth = Numbers.safeParseInteger(request.getHttpRequest().getPartAsStringFailsafe("birth-month", 256).trim());
-                               birthYear = Numbers.safeParseInteger(request.getHttpRequest().getPartAsStringFailsafe("birth-year", 256).trim());
+                               birthDay = parseInt(request.getHttpRequest().getPartAsStringFailsafe("birth-day", 256).trim(), null);
+                               birthMonth = parseInt(request.getHttpRequest().getPartAsStringFailsafe("birth-month", 256).trim(), null);
+                               birthYear = parseInt(request.getHttpRequest().getPartAsStringFailsafe("birth-year", 256).trim(), null);
                                avatarId = request.getHttpRequest().getPartAsStringFailsafe("avatarId", 36);
                                profile.setFirstName(firstName.length() > 0 ? firstName : null);
                                profile.setMiddleName(middleName.length() > 0 ? middleName : null);
@@ -99,10 +99,9 @@ public class EditProfilePage extends SoneTemplatePage {
                                try {
                                        profile.addField(fieldName);
                                        currentSone.setProfile(profile);
-                                       fields = profile.getFields();
                                        webInterface.getCore().touchConfiguration();
                                        throw new RedirectException("editProfile.html#profile-fields");
-                               } catch (IllegalArgumentException iae1) {
+                               } catch (DuplicateField df1) {
                                        templateContext.set("fieldName", fieldName);
                                        templateContext.set("duplicateFieldName", true);
                                }
index 766f018..60b22d5 100644 (file)
@@ -21,6 +21,7 @@ import static com.google.common.collect.FluentIterable.from;
 import static net.pterodactylus.sone.data.Album.FLATTENER;
 import static net.pterodactylus.sone.data.Album.NOT_EMPTY;
 import static net.pterodactylus.sone.data.Album.TITLE_COMPARATOR;
+import static net.pterodactylus.sone.utils.NumberParsers.parseInt;
 
 import java.net.URI;
 import java.util.ArrayList;
@@ -34,7 +35,6 @@ import net.pterodactylus.sone.data.Image;
 import net.pterodactylus.sone.data.Sone;
 import net.pterodactylus.sone.web.page.FreenetRequest;
 import net.pterodactylus.util.collection.Pagination;
-import net.pterodactylus.util.number.Numbers;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
 
@@ -69,7 +69,7 @@ public class ImageBrowserPage extends SoneTemplatePage {
                super.processTemplate(request, templateContext);
                String albumId = request.getHttpRequest().getParam("album", null);
                if (albumId != null) {
-                       Album album = webInterface.getCore().getAlbum(albumId, false);
+                       Album album = webInterface.getCore().getAlbum(albumId);
                        templateContext.set("albumRequested", true);
                        templateContext.set("album", album);
                        templateContext.set("page", request.getHttpRequest().getParam("page"));
@@ -97,7 +97,7 @@ public class ImageBrowserPage extends SoneTemplatePage {
                                albums.addAll(from(sone.getRootAlbum().getAlbums()).transformAndConcat(FLATTENER).filter(NOT_EMPTY).toList());
                        }
                        Collections.sort(albums, TITLE_COMPARATOR);
-                       Pagination<Album> albumPagination = new Pagination<Album>(albums, 12).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("page"), 0));
+                       Pagination<Album> albumPagination = new Pagination<Album>(albums, 12).setPage(parseInt(request.getHttpRequest().getParam("page"), 0));
                        templateContext.set("albumPagination", albumPagination);
                        templateContext.set("albums", albumPagination.getItems());
                        return;
index e1a52b9..e242f52 100644 (file)
@@ -17,6 +17,8 @@
 
 package net.pterodactylus.sone.web;
 
+import static net.pterodactylus.sone.utils.NumberParsers.parseInt;
+
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -27,7 +29,6 @@ import net.pterodactylus.sone.data.Sone;
 import net.pterodactylus.sone.notify.ListNotificationFilters;
 import net.pterodactylus.sone.web.page.FreenetRequest;
 import net.pterodactylus.util.collection.Pagination;
-import net.pterodactylus.util.number.Numbers;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
 
@@ -90,7 +91,7 @@ public class IndexPage extends SoneTemplatePage {
                allPosts = Collections2.filter(allPosts, Post.FUTURE_POSTS_FILTER);
                List<Post> sortedPosts = new ArrayList<Post>(allPosts);
                Collections.sort(sortedPosts, Post.TIME_COMPARATOR);
-               Pagination<Post> pagination = new Pagination<Post>(sortedPosts, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("page"), 0));
+               Pagination<Post> pagination = new Pagination<Post>(sortedPosts, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(parseInt(request.getHttpRequest().getParam("page"), 0));
                templateContext.set("pagination", pagination);
                templateContext.set("posts", pagination.getItems());
        }
index dd1b1fc..d2e7abe 100644 (file)
@@ -17,6 +17,8 @@
 
 package net.pterodactylus.sone.web;
 
+import static net.pterodactylus.sone.utils.NumberParsers.parseInt;
+
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -25,7 +27,6 @@ import java.util.List;
 import net.pterodactylus.sone.data.Sone;
 import net.pterodactylus.sone.web.page.FreenetRequest;
 import net.pterodactylus.util.collection.Pagination;
-import net.pterodactylus.util.number.Numbers;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
 
@@ -41,6 +42,9 @@ import com.google.common.collect.Ordering;
  */
 public class KnownSonesPage extends SoneTemplatePage {
 
+       private static final String defaultSortField = "activity";
+       private static final String defaultSortOrder = "desc";
+
        /**
         * Creates a “known Sones” page.
         *
@@ -63,11 +67,11 @@ public class KnownSonesPage extends SoneTemplatePage {
        @Override
        protected void processTemplate(FreenetRequest request, TemplateContext templateContext) throws RedirectException {
                super.processTemplate(request, templateContext);
-               String sortField = request.getHttpRequest().getParam("sort");
-               String sortOrder = request.getHttpRequest().getParam("order");
+               String sortField = request.getHttpRequest().getParam("sort", defaultSortField);
+               String sortOrder = request.getHttpRequest().getParam("order", defaultSortOrder);
                String filter = request.getHttpRequest().getParam("filter");
-               templateContext.set("sort", (sortField != null) ? sortField : "name");
-               templateContext.set("order", (sortOrder != null) ? sortOrder : "asc");
+               templateContext.set("sort", sortField);
+               templateContext.set("order", sortOrder);
                templateContext.set("filter", filter);
                final Sone currentSone = getCurrentSone(request.getToadletContext(), false);
                Collection<Sone> knownSones = Collections2.filter(webInterface.getCore().getSones(), Sone.EMPTY_SONE_FILTER);
@@ -140,7 +144,7 @@ public class KnownSonesPage extends SoneTemplatePage {
                                Collections.sort(sortedSones, Sone.NICE_NAME_COMPARATOR);
                        }
                }
-               Pagination<Sone> sonePagination = new Pagination<Sone>(sortedSones, 25).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("page"), 0));
+               Pagination<Sone> sonePagination = new Pagination<Sone>(sortedSones, 25).setPage(parseInt(request.getHttpRequest().getParam("page"), 0));
                templateContext.set("pagination", sonePagination);
                templateContext.set("knownSones", sonePagination.getItems());
        }
index 604b176..f72252f 100644 (file)
@@ -53,7 +53,7 @@ public class LockSonePage extends SoneTemplatePage {
        protected void processTemplate(FreenetRequest request, TemplateContext templateContext) throws RedirectException {
                super.processTemplate(request, templateContext);
                String soneId = request.getHttpRequest().getPartAsStringFailsafe("sone", 44);
-               Sone sone = webInterface.getCore().getLocalSone(soneId, false);
+               Sone sone = webInterface.getCore().getLocalSone(soneId);
                if (sone != null) {
                        webInterface.getCore().lockSone(sone);
                }
index a837bd1..58e33a1 100644 (file)
@@ -17,6 +17,8 @@
 
 package net.pterodactylus.sone.web;
 
+import static java.util.logging.Logger.getLogger;
+
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -25,7 +27,6 @@ import java.util.logging.Logger;
 import net.pterodactylus.sone.data.Sone;
 import net.pterodactylus.sone.freenet.wot.OwnIdentity;
 import net.pterodactylus.sone.web.page.FreenetRequest;
-import net.pterodactylus.util.logging.Logging;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
 import net.pterodactylus.util.web.Method;
@@ -40,7 +41,7 @@ public class LoginPage extends SoneTemplatePage {
 
        /** The logger. */
        @SuppressWarnings("unused")
-       private static final Logger logger = Logging.getLogger(LoginPage.class);
+       private static final Logger logger = getLogger("Sone.Web.Login");
 
        /**
         * Creates a new login page.
@@ -70,7 +71,7 @@ public class LoginPage extends SoneTemplatePage {
                templateContext.set("sones", localSones);
                if (request.getMethod() == Method.POST) {
                        String soneId = request.getHttpRequest().getPartAsStringFailsafe("sone-id", 100);
-                       Sone selectedSone = webInterface.getCore().getLocalSone(soneId, false);
+                       Sone selectedSone = webInterface.getCore().getLocalSone(soneId);
                        if (selectedSone != null) {
                                setCurrentSone(request.getToadletContext(), selectedSone);
                                String target = request.getHttpRequest().getParam("target");
index 71200d8..5977349 100644 (file)
@@ -17,6 +17,8 @@
 
 package net.pterodactylus.sone.web;
 
+import static net.pterodactylus.sone.utils.NumberParsers.parseInt;
+
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashSet;
@@ -30,7 +32,6 @@ import net.pterodactylus.sone.data.PostReply;
 import net.pterodactylus.sone.notify.ListNotificationFilters;
 import net.pterodactylus.sone.web.page.FreenetRequest;
 import net.pterodactylus.util.collection.Pagination;
-import net.pterodactylus.util.number.Numbers;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
 
@@ -77,7 +78,7 @@ public class NewPage extends SoneTemplatePage {
                Collections.sort(sortedPosts, Post.TIME_COMPARATOR);
 
                /* paginate them. */
-               Pagination<Post> pagination = new Pagination<Post>(sortedPosts, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("page"), 0));
+               Pagination<Post> pagination = new Pagination<Post>(sortedPosts, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(parseInt(request.getHttpRequest().getParam("page"), 0));
                templateContext.set("pagination", pagination);
                templateContext.set("posts", pagination.getItems());
        }
index 094a9cf..c38f97b 100644 (file)
@@ -17,6 +17,8 @@
 
 package net.pterodactylus.sone.web;
 
+import static net.pterodactylus.sone.utils.NumberParsers.parseInt;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -25,7 +27,6 @@ import net.pterodactylus.sone.data.Sone;
 import net.pterodactylus.sone.data.Sone.ShowCustomAvatars;
 import net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired;
 import net.pterodactylus.sone.web.page.FreenetRequest;
-import net.pterodactylus.util.number.Numbers;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
 import net.pterodactylus.util.web.Method;
@@ -65,44 +66,44 @@ public class OptionsPage extends SoneTemplatePage {
                        List<String> fieldErrors = new ArrayList<String>();
                        if (currentSone != null) {
                                boolean autoFollow = request.getHttpRequest().isPartSet("auto-follow");
-                               currentSone.getOptions().getBooleanOption("AutoFollow").set(autoFollow);
+                               currentSone.getOptions().setAutoFollow(autoFollow);
                                boolean enableSoneInsertNotifications = request.getHttpRequest().isPartSet("enable-sone-insert-notifications");
-                               currentSone.getOptions().getBooleanOption("EnableSoneInsertNotifications").set(enableSoneInsertNotifications);
+                               currentSone.getOptions().setSoneInsertNotificationEnabled(enableSoneInsertNotifications);
                                boolean showNotificationNewSones = request.getHttpRequest().isPartSet("show-notification-new-sones");
-                               currentSone.getOptions().getBooleanOption("ShowNotification/NewSones").set(showNotificationNewSones);
+                               currentSone.getOptions().setShowNewSoneNotifications(showNotificationNewSones);
                                boolean showNotificationNewPosts = request.getHttpRequest().isPartSet("show-notification-new-posts");
-                               currentSone.getOptions().getBooleanOption("ShowNotification/NewPosts").set(showNotificationNewPosts);
+                               currentSone.getOptions().setShowNewPostNotifications(showNotificationNewPosts);
                                boolean showNotificationNewReplies = request.getHttpRequest().isPartSet("show-notification-new-replies");
-                               currentSone.getOptions().getBooleanOption("ShowNotification/NewReplies").set(showNotificationNewReplies);
+                               currentSone.getOptions().setShowNewReplyNotifications(showNotificationNewReplies);
                                String showCustomAvatars = request.getHttpRequest().getPartAsStringFailsafe("show-custom-avatars", 32);
-                               currentSone.getOptions().<ShowCustomAvatars> getEnumOption("ShowCustomAvatars").set(ShowCustomAvatars.valueOf(showCustomAvatars));
+                               currentSone.getOptions().setShowCustomAvatars(ShowCustomAvatars.valueOf(showCustomAvatars));
                                webInterface.getCore().touchConfiguration();
                        }
-                       Integer insertionDelay = Numbers.safeParseInteger(request.getHttpRequest().getPartAsStringFailsafe("insertion-delay", 16));
+                       Integer insertionDelay = parseInt(request.getHttpRequest().getPartAsStringFailsafe("insertion-delay", 16), null);
                        if (!preferences.validateInsertionDelay(insertionDelay)) {
                                fieldErrors.add("insertion-delay");
                        } else {
                                preferences.setInsertionDelay(insertionDelay);
                        }
-                       Integer postsPerPage = Numbers.safeParseInteger(request.getHttpRequest().getPartAsStringFailsafe("posts-per-page", 4), null);
+                       Integer postsPerPage = parseInt(request.getHttpRequest().getPartAsStringFailsafe("posts-per-page", 4), null);
                        if (!preferences.validatePostsPerPage(postsPerPage)) {
                                fieldErrors.add("posts-per-page");
                        } else {
                                preferences.setPostsPerPage(postsPerPage);
                        }
-                       Integer imagesPerPage = Numbers.safeParseInteger(request.getHttpRequest().getPartAsStringFailsafe("images-per-page", 4), null);
+                       Integer imagesPerPage = parseInt(request.getHttpRequest().getPartAsStringFailsafe("images-per-page", 4), null);
                        if (!preferences.validateImagesPerPage(imagesPerPage)) {
                                fieldErrors.add("images-per-page");
                        } else {
                                preferences.setImagesPerPage(imagesPerPage);
                        }
-                       Integer charactersPerPost = Numbers.safeParseInteger(request.getHttpRequest().getPartAsStringFailsafe("characters-per-post", 10), null);
+                       Integer charactersPerPost = parseInt(request.getHttpRequest().getPartAsStringFailsafe("characters-per-post", 10), null);
                        if (!preferences.validateCharactersPerPost(charactersPerPost)) {
                                fieldErrors.add("characters-per-post");
                        } else {
                                preferences.setCharactersPerPost(charactersPerPost);
                        }
-                       Integer postCutOffLength = Numbers.safeParseInteger(request.getHttpRequest().getPartAsStringFailsafe("post-cut-off-length", 10), null);
+                       Integer postCutOffLength = parseInt(request.getHttpRequest().getPartAsStringFailsafe("post-cut-off-length", 10), null);
                        if (!preferences.validatePostCutOffLength(postCutOffLength)) {
                                fieldErrors.add("post-cut-off-length");
                        } else {
@@ -110,13 +111,13 @@ public class OptionsPage extends SoneTemplatePage {
                        }
                        boolean requireFullAccess = request.getHttpRequest().isPartSet("require-full-access");
                        preferences.setRequireFullAccess(requireFullAccess);
-                       Integer positiveTrust = Numbers.safeParseInteger(request.getHttpRequest().getPartAsStringFailsafe("positive-trust", 3));
+                       Integer positiveTrust = parseInt(request.getHttpRequest().getPartAsStringFailsafe("positive-trust", 3), null);
                        if (!preferences.validatePositiveTrust(positiveTrust)) {
                                fieldErrors.add("positive-trust");
                        } else {
                                preferences.setPositiveTrust(positiveTrust);
                        }
-                       Integer negativeTrust = Numbers.safeParseInteger(request.getHttpRequest().getPartAsStringFailsafe("negative-trust", 4));
+                       Integer negativeTrust = parseInt(request.getHttpRequest().getPartAsStringFailsafe("negative-trust", 4), null);
                        if (!preferences.validateNegativeTrust(negativeTrust)) {
                                fieldErrors.add("negative-trust");
                        } else {
@@ -129,7 +130,7 @@ public class OptionsPage extends SoneTemplatePage {
                        preferences.setTrustComment(trustComment);
                        boolean fcpInterfaceActive = request.getHttpRequest().isPartSet("fcp-interface-active");
                        preferences.setFcpInterfaceActive(fcpInterfaceActive);
-                       Integer fcpFullAccessRequiredInteger = Numbers.safeParseInteger(request.getHttpRequest().getPartAsStringFailsafe("fcp-full-access-required", 1), preferences.getFcpFullAccessRequired().ordinal());
+                       Integer fcpFullAccessRequiredInteger = parseInt(request.getHttpRequest().getPartAsStringFailsafe("fcp-full-access-required", 1), preferences.getFcpFullAccessRequired().ordinal());
                        FullAccessRequired fcpFullAccessRequired = FullAccessRequired.values()[fcpFullAccessRequiredInteger];
                        preferences.setFcpFullAccessRequired(fcpFullAccessRequired);
                        webInterface.getCore().touchConfiguration();
@@ -139,12 +140,12 @@ public class OptionsPage extends SoneTemplatePage {
                        templateContext.set("fieldErrors", fieldErrors);
                }
                if (currentSone != null) {
-                       templateContext.set("auto-follow", currentSone.getOptions().getBooleanOption("AutoFollow").get());
-                       templateContext.set("enable-sone-insert-notifications", currentSone.getOptions().getBooleanOption("EnableSoneInsertNotifications").get());
-                       templateContext.set("show-notification-new-sones", currentSone.getOptions().getBooleanOption("ShowNotification/NewSones").get());
-                       templateContext.set("show-notification-new-posts", currentSone.getOptions().getBooleanOption("ShowNotification/NewPosts").get());
-                       templateContext.set("show-notification-new-replies", currentSone.getOptions().getBooleanOption("ShowNotification/NewReplies").get());
-                       templateContext.set("show-custom-avatars", currentSone.getOptions().<ShowCustomAvatars> getEnumOption("ShowCustomAvatars").get().name());
+                       templateContext.set("auto-follow", currentSone.getOptions().isAutoFollow());
+                       templateContext.set("enable-sone-insert-notifications", currentSone.getOptions().isSoneInsertNotificationEnabled());
+                       templateContext.set("show-notification-new-sones", currentSone.getOptions().isShowNewSoneNotifications());
+                       templateContext.set("show-notification-new-posts", currentSone.getOptions().isShowNewPostNotifications());
+                       templateContext.set("show-notification-new-replies", currentSone.getOptions().isShowNewReplyNotifications());
+                       templateContext.set("show-custom-avatars", currentSone.getOptions().getShowCustomAvatars().name());
                }
                templateContext.set("insertion-delay", preferences.getInsertionDelay());
                templateContext.set("posts-per-page", preferences.getPostsPerPage());
index 6888277..8c325d2 100644 (file)
 
 package net.pterodactylus.sone.web;
 
+import static net.pterodactylus.sone.utils.NumberParsers.parseLong;
+
 import net.pterodactylus.sone.core.SoneRescuer;
 import net.pterodactylus.sone.data.Sone;
 import net.pterodactylus.sone.web.page.FreenetRequest;
-import net.pterodactylus.util.number.Numbers;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
 import net.pterodactylus.util.web.Method;
@@ -59,7 +60,7 @@ public class RescuePage extends SoneTemplatePage {
                SoneRescuer soneRescuer = webInterface.getCore().getSoneRescuer(currentSone);
                if (request.getMethod() == Method.POST) {
                        if ("true".equals(request.getHttpRequest().getPartAsStringFailsafe("fetch", 4))) {
-                               long edition = Numbers.safeParseLong(request.getHttpRequest().getPartAsStringFailsafe("edition", 8), -1L);
+                               long edition = parseLong(request.getHttpRequest().getPartAsStringFailsafe("edition", 8), -1L);
                                if (edition > -1) {
                                        soneRescuer.setEdition(edition);
                                }
index e9241a1..3efba95 100644 (file)
 
 package net.pterodactylus.sone.web;
 
+import static com.google.common.base.Optional.fromNullable;
+import static com.google.common.primitives.Ints.tryParse;
+import static java.util.logging.Logger.getLogger;
+
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -36,8 +40,6 @@ import net.pterodactylus.sone.data.Reply;
 import net.pterodactylus.sone.data.Sone;
 import net.pterodactylus.sone.web.page.FreenetRequest;
 import net.pterodactylus.util.collection.Pagination;
-import net.pterodactylus.util.logging.Logging;
-import net.pterodactylus.util.number.Numbers;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
 import net.pterodactylus.util.text.StringEscaper;
@@ -62,7 +64,7 @@ import com.google.common.collect.Ordering;
 public class SearchPage extends SoneTemplatePage {
 
        /** The logger. */
-       private static final Logger logger = Logging.getLogger(SearchPage.class);
+       private static final Logger logger = getLogger("Sone.Web.Search");
 
        /** Short-term cache. */
        private final LoadingCache<List<Phrase>, Set<Hit<Post>>> hitCache = CacheBuilder.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES).build(new CacheLoader<List<Phrase>, Set<Hit<Post>>>() {
@@ -149,8 +151,8 @@ public class SearchPage extends SoneTemplatePage {
                List<Post> resultPosts = FluentIterable.from(sortedPostHits).transform(new HitMapper<Post>()).toList();
 
                /* pagination. */
-               Pagination<Sone> sonePagination = new Pagination<Sone>(resultSones, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("sonePage"), 0));
-               Pagination<Post> postPagination = new Pagination<Post>(resultPosts, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("postPage"), 0));
+               Pagination<Sone> sonePagination = new Pagination<Sone>(resultSones, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(fromNullable(tryParse(request.getHttpRequest().getParam("sonePage"))).or(0));
+               Pagination<Post> postPagination = new Pagination<Post>(resultPosts, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(fromNullable(tryParse(request.getHttpRequest().getParam("postPage"))).or(0));
 
                templateContext.set("sonePagination", sonePagination);
                templateContext.set("soneHits", sonePagination.getItems());
@@ -201,7 +203,7 @@ public class SearchPage extends SoneTemplatePage {
         * @return The parsed phrases
         */
        private static List<Phrase> parseSearchPhrases(String query) {
-               List<String> parsedPhrases = null;
+               List<String> parsedPhrases;
                try {
                        parsedPhrases = StringEscaper.parseLine(query);
                } catch (TextException te1) {
@@ -354,7 +356,7 @@ public class SearchPage extends SoneTemplatePage {
         */
        private String getAlbumId(String phrase) {
                String albumId = phrase.startsWith("album://") ? phrase.substring(8) : phrase;
-               return (webInterface.getCore().getAlbum(albumId, false) != null) ? albumId : null;
+               return (webInterface.getCore().getAlbum(albumId) != null) ? albumId : null;
        }
 
        /**
@@ -581,7 +583,7 @@ public class SearchPage extends SoneTemplatePage {
 
                        @Override
                        public boolean apply(Hit<?> hit) {
-                               return (hit == null) ? false : hit.getScore() > 0;
+                               return (hit != null) && (hit.getScore() > 0);
                        }
 
                };
index b568be1..72ff2fc 100644 (file)
@@ -25,6 +25,8 @@ import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
 import net.pterodactylus.util.web.Method;
 
+import com.google.common.base.Optional;
+
 /**
  * Page that lets the user unbookmark a post.
  *
@@ -55,15 +57,18 @@ public class UnbookmarkPage extends SoneTemplatePage {
                if (request.getMethod() == Method.POST) {
                        String id = request.getHttpRequest().getPartAsStringFailsafe("post", 36);
                        String returnPage = request.getHttpRequest().getPartAsStringFailsafe("returnPage", 256);
-                       webInterface.getCore().unbookmarkPost(id);
+                       Optional<Post> post = webInterface.getCore().getPost(id);
+                       if (post.isPresent()) {
+                               webInterface.getCore().unbookmarkPost(post.get());
+                       }
                        throw new RedirectException(returnPage);
                }
                String id = request.getHttpRequest().getParam("post");
                if (id.equals("allNotLoaded")) {
                        Set<Post> posts = webInterface.getCore().getBookmarkedPosts();
                        for (Post post : posts) {
-                               if (post.getSone() == null) {
-                                       webInterface.getCore().unbookmark(post);
+                               if (post.isLoaded()) {
+                                       webInterface.getCore().unbookmarkPost(post);
                                }
                        }
                        throw new RedirectException("bookmarks.html");
index ed92246..8491163 100644 (file)
@@ -52,7 +52,7 @@ public class UnlockSonePage extends SoneTemplatePage {
        protected void processTemplate(FreenetRequest request, TemplateContext templateContext) throws RedirectException {
                super.processTemplate(request, templateContext);
                String soneId = request.getHttpRequest().getPartAsStringFailsafe("sone", 44);
-               Sone sone = webInterface.getCore().getLocalSone(soneId, false);
+               Sone sone = webInterface.getCore().getLocalSone(soneId);
                if (sone != null) {
                        webInterface.getCore().unlockSone(sone);
                }
index 3844082..5e23a78 100644 (file)
@@ -17,6 +17,8 @@
 
 package net.pterodactylus.sone.web;
 
+import static java.util.logging.Logger.getLogger;
+
 import java.awt.Image;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
@@ -31,12 +33,12 @@ import javax.imageio.ImageReader;
 import javax.imageio.stream.ImageInputStream;
 
 import net.pterodactylus.sone.data.Album;
+import net.pterodactylus.sone.data.Image.Modifier.ImageTitleMustNotBeEmpty;
 import net.pterodactylus.sone.data.Sone;
 import net.pterodactylus.sone.data.TemporaryImage;
 import net.pterodactylus.sone.text.TextFilter;
 import net.pterodactylus.sone.web.page.FreenetRequest;
 import net.pterodactylus.util.io.Closer;
-import net.pterodactylus.util.logging.Logging;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
 import net.pterodactylus.util.web.Method;
@@ -54,7 +56,7 @@ import freenet.support.api.HTTPUploadedFile;
 public class UploadImagePage extends SoneTemplatePage {
 
        /** The logger. */
-       private static final Logger logger = Logging.getLogger(UploadImagePage.class);
+       private static final Logger logger = getLogger("Sone.Web.UploadImage");
 
        /**
         * Creates a new “upload image” page.
@@ -81,14 +83,12 @@ public class UploadImagePage extends SoneTemplatePage {
                if (request.getMethod() == Method.POST) {
                        Sone currentSone = getCurrentSone(request.getToadletContext());
                        String parentId = request.getHttpRequest().getPartAsStringFailsafe("parent", 36);
-                       Album parent = webInterface.getCore().getAlbum(parentId, false);
+                       Album parent = webInterface.getCore().getAlbum(parentId);
                        if (parent == null) {
-                               /* TODO - signal error */
-                               return;
+                               throw new RedirectException("noPermission.html");
                        }
                        if (!currentSone.equals(parent.getSone())) {
-                               /* TODO - signal error. */
-                               return;
+                               throw new RedirectException("noPermission.html");
                        }
                        String name = request.getHttpRequest().getPartAsStringFailsafe("title", 200);
                        String description = request.getHttpRequest().getPartAsStringFailsafe("description", 4000);
@@ -96,7 +96,6 @@ public class UploadImagePage extends SoneTemplatePage {
                        Bucket fileBucket = uploadedFile.getData();
                        InputStream imageInputStream = null;
                        ByteArrayOutputStream imageDataOutputStream = null;
-                       net.pterodactylus.sone.data.Image image = null;
                        try {
                                imageInputStream = fileBucket.getInputStream();
                                /* TODO - check length */
@@ -122,11 +121,13 @@ public class UploadImagePage extends SoneTemplatePage {
                                }
                                String mimeType = getMimeType(imageData);
                                TemporaryImage temporaryImage = webInterface.getCore().createTemporaryImage(mimeType, imageData);
-                               image = webInterface.getCore().createImage(currentSone, parent, temporaryImage);
+                               net.pterodactylus.sone.data.Image image = webInterface.getCore().createImage(currentSone, parent, temporaryImage);
                                image.modify().setTitle(name).setDescription(TextFilter.filter(request.getHttpRequest().getHeader("host"), description)).setWidth(uploadedImage.getWidth(null)).setHeight(uploadedImage.getHeight(null)).update();
                        } catch (IOException ioe1) {
                                logger.log(Level.WARNING, "Could not read uploaded image!", ioe1);
                                return;
+                       } catch (ImageTitleMustNotBeEmpty itmnbe) {
+                               throw new RedirectException("emptyImageTitle.html");
                        } finally {
                                Closer.close(imageDataInputStream);
                                Closer.flush(uploadedImage);
index a94b4af..74461e3 100644 (file)
@@ -17,6 +17,8 @@
 
 package net.pterodactylus.sone.web;
 
+import static net.pterodactylus.sone.utils.NumberParsers.parseInt;
+
 import java.net.URI;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -32,7 +34,6 @@ import net.pterodactylus.sone.data.Sone;
 import net.pterodactylus.sone.template.SoneAccessor;
 import net.pterodactylus.sone.web.page.FreenetRequest;
 import net.pterodactylus.util.collection.Pagination;
-import net.pterodactylus.util.number.Numbers;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
 
@@ -91,7 +92,7 @@ public class ViewSonePage extends SoneTemplatePage {
                List<Post> sonePosts = sone.get().getPosts();
                sonePosts.addAll(webInterface.getCore().getDirectedPosts(sone.get().getId()));
                Collections.sort(sonePosts, Post.TIME_COMPARATOR);
-               Pagination<Post> postPagination = new Pagination<Post>(sonePosts, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("postPage"), 0));
+               Pagination<Post> postPagination = new Pagination<Post>(sonePosts, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(parseInt(request.getHttpRequest().getParam("postPage"), 0));
                templateContext.set("postPagination", postPagination);
                templateContext.set("posts", postPagination.getItems());
                Set<PostReply> replies = sone.get().getReplies();
@@ -113,7 +114,7 @@ public class ViewSonePage extends SoneTemplatePage {
 
                });
 
-               Pagination<Post> repliedPostPagination = new Pagination<Post>(posts, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("repliedPostPage"), 0));
+               Pagination<Post> repliedPostPagination = new Pagination<Post>(posts, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(parseInt(request.getHttpRequest().getParam("repliedPostPage"), 0));
                templateContext.set("repliedPostPagination", repliedPostPagination);
                templateContext.set("repliedPosts", repliedPostPagination.getItems());
        }
index 593193c..9fe5bba 100644 (file)
@@ -17,6 +17,9 @@
 
 package net.pterodactylus.sone.web;
 
+import static java.util.logging.Logger.getLogger;
+import static net.pterodactylus.util.template.TemplateParser.parse;
+
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
@@ -125,7 +128,7 @@ import net.pterodactylus.sone.web.ajax.UntrustAjaxPage;
 import net.pterodactylus.sone.web.page.FreenetRequest;
 import net.pterodactylus.sone.web.page.PageToadlet;
 import net.pterodactylus.sone.web.page.PageToadletFactory;
-import net.pterodactylus.util.logging.Logging;
+import net.pterodactylus.util.io.Closer;
 import net.pterodactylus.util.notify.Notification;
 import net.pterodactylus.util.notify.NotificationManager;
 import net.pterodactylus.util.notify.TemplateNotification;
@@ -143,7 +146,6 @@ import net.pterodactylus.util.template.ReplaceFilter;
 import net.pterodactylus.util.template.StoreFilter;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContextFactory;
-import net.pterodactylus.util.template.TemplateParser;
 import net.pterodactylus.util.template.TemplateProvider;
 import net.pterodactylus.util.template.XmlFilter;
 import net.pterodactylus.util.web.RedirectPage;
@@ -171,7 +173,7 @@ import freenet.support.api.HTTPRequest;
 public class WebInterface {
 
        /** The logger. */
-       private static final Logger logger = Logging.getLogger(WebInterface.class);
+       private static final Logger logger = getLogger("Sone.Web.Main");
 
        /** The notification manager. */
        private final NotificationManager notificationManager = new NotificationManager();
@@ -287,40 +289,55 @@ public class WebInterface {
                templateContextFactory.addTemplateObject("formPassword", formPassword);
 
                /* create notifications. */
-               Template newSoneNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/newSoneNotification.html"));
+               Template newSoneNotificationTemplate = parseTemplate("/templates/notify/newSoneNotification.html");
                newSoneNotification = new ListNotification<Sone>("new-sone-notification", "sones", newSoneNotificationTemplate, false);
 
-               Template newPostNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/newPostNotification.html"));
+               Template newPostNotificationTemplate = parseTemplate("/templates/notify/newPostNotification.html");
                newPostNotification = new ListNotification<Post>("new-post-notification", "posts", newPostNotificationTemplate, false);
 
-               Template localPostNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/newPostNotification.html"));
+               Template localPostNotificationTemplate = parseTemplate("/templates/notify/newPostNotification.html");
                localPostNotification = new ListNotification<Post>("local-post-notification", "posts", localPostNotificationTemplate, false);
 
-               Template newReplyNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/newReplyNotification.html"));
+               Template newReplyNotificationTemplate = parseTemplate("/templates/notify/newReplyNotification.html");
                newReplyNotification = new ListNotification<PostReply>("new-reply-notification", "replies", newReplyNotificationTemplate, false);
 
-               Template localReplyNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/newReplyNotification.html"));
+               Template localReplyNotificationTemplate = parseTemplate("/templates/notify/newReplyNotification.html");
                localReplyNotification = new ListNotification<PostReply>("local-reply-notification", "replies", localReplyNotificationTemplate, false);
 
-               Template mentionNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/mentionNotification.html"));
+               Template mentionNotificationTemplate = parseTemplate("/templates/notify/mentionNotification.html");
                mentionNotification = new ListNotification<Post>("mention-notification", "posts", mentionNotificationTemplate, false);
 
-               Template lockedSonesTemplate = TemplateParser.parse(createReader("/templates/notify/lockedSonesNotification.html"));
+               Template lockedSonesTemplate = parseTemplate("/templates/notify/lockedSonesNotification.html");
                lockedSonesNotification = new ListNotification<Sone>("sones-locked-notification", "sones", lockedSonesTemplate);
 
-               Template newVersionTemplate = TemplateParser.parse(createReader("/templates/notify/newVersionNotification.html"));
+               Template newVersionTemplate = parseTemplate("/templates/notify/newVersionNotification.html");
                newVersionNotification = new TemplateNotification("new-version-notification", newVersionTemplate);
 
-               Template insertingImagesTemplate = TemplateParser.parse(createReader("/templates/notify/inserting-images-notification.html"));
+               Template insertingImagesTemplate = parseTemplate("/templates/notify/inserting-images-notification.html");
                insertingImagesNotification = new ListNotification<Image>("inserting-images-notification", "images", insertingImagesTemplate);
 
-               Template insertedImagesTemplate = TemplateParser.parse(createReader("/templates/notify/inserted-images-notification.html"));
+               Template insertedImagesTemplate = parseTemplate("/templates/notify/inserted-images-notification.html");
                insertedImagesNotification = new ListNotification<Image>("inserted-images-notification", "images", insertedImagesTemplate);
 
-               Template imageInsertFailedTemplate = TemplateParser.parse(createReader("/templates/notify/image-insert-failed-notification.html"));
+               Template imageInsertFailedTemplate = parseTemplate("/templates/notify/image-insert-failed-notification.html");
                imageInsertFailedNotification = new ListNotification<Image>("image-insert-failed-notification", "images", imageInsertFailedTemplate);
        }
 
+       private Template parseTemplate(String resourceName) {
+               InputStream templateInputStream = null;
+               Reader reader = null;
+               try {
+                       templateInputStream = getClass().getResourceAsStream(resourceName);
+                       reader = new InputStreamReader(templateInputStream, "UTF-8");
+                       return parse(reader);
+               } catch (UnsupportedEncodingException uee1) {
+                       throw new RuntimeException("UTF-8 not supported.");
+               } finally {
+                       Closer.close(reader);
+                       Closer.close(templateInputStream);
+               }
+       }
+
        //
        // ACCESSORS
        //
@@ -423,7 +440,7 @@ public class WebInterface {
                if (soneId == null) {
                        return null;
                }
-               return getCore().getLocalSone(soneId, false);
+               return getCore().getLocalSone(soneId);
        }
 
        /**
@@ -509,7 +526,7 @@ public class WebInterface {
         */
        public void setFirstStart(boolean firstStart) {
                if (firstStart) {
-                       Template firstStartNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/firstStartNotification.html"));
+                       Template firstStartNotificationTemplate = parseTemplate("/templates/notify/firstStartNotification.html");
                        Notification firstStartNotification = new TemplateNotification("first-start-notification", firstStartNotificationTemplate);
                        notificationManager.addNotification(firstStartNotification);
                }
@@ -524,7 +541,7 @@ public class WebInterface {
         */
        public void setNewConfig(boolean newConfig) {
                if (newConfig && !hasFirstStartNotification()) {
-                       Template configNotReadNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/configNotReadNotification.html"));
+                       Template configNotReadNotificationTemplate = parseTemplate("/templates/notify/configNotReadNotification.html");
                        Notification configNotReadNotification = new TemplateNotification("config-not-read-notification", configNotReadNotificationTemplate);
                        notificationManager.addNotification(configNotReadNotification);
                }
@@ -555,7 +572,7 @@ public class WebInterface {
                registerToadlets();
 
                /* notification templates. */
-               Template startupNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/startupNotification.html"));
+               Template startupNotificationTemplate = parseTemplate("/templates/notify/startupNotification.html");
 
                final TemplateNotification startupNotification = new TemplateNotification("startup-notification", startupNotificationTemplate);
                notificationManager.addNotification(startupNotification);
@@ -568,7 +585,7 @@ public class WebInterface {
                        }
                }, 2, TimeUnit.MINUTES);
 
-               Template wotMissingNotificationTemplate = TemplateParser.parse(createReader("/templates/notify/wotMissingNotification.html"));
+               Template wotMissingNotificationTemplate = parseTemplate("/templates/notify/wotMissingNotification.html");
                final TemplateNotification wotMissingNotification = new TemplateNotification("wot-missing-notification", wotMissingNotificationTemplate);
                ticker.scheduleAtFixedRate(new Runnable() {
 
@@ -601,36 +618,38 @@ public class WebInterface {
         * Register all toadlets.
         */
        private void registerToadlets() {
-               Template emptyTemplate = TemplateParser.parse(new StringReader(""));
-               Template loginTemplate = TemplateParser.parse(createReader("/templates/login.html"));
-               Template indexTemplate = TemplateParser.parse(createReader("/templates/index.html"));
-               Template newTemplate = TemplateParser.parse(createReader("/templates/new.html"));
-               Template knownSonesTemplate = TemplateParser.parse(createReader("/templates/knownSones.html"));
-               Template createSoneTemplate = TemplateParser.parse(createReader("/templates/createSone.html"));
-               Template createPostTemplate = TemplateParser.parse(createReader("/templates/createPost.html"));
-               Template createReplyTemplate = TemplateParser.parse(createReader("/templates/createReply.html"));
-               Template bookmarksTemplate = TemplateParser.parse(createReader("/templates/bookmarks.html"));
-               Template searchTemplate = TemplateParser.parse(createReader("/templates/search.html"));
-               Template editProfileTemplate = TemplateParser.parse(createReader("/templates/editProfile.html"));
-               Template editProfileFieldTemplate = TemplateParser.parse(createReader("/templates/editProfileField.html"));
-               Template deleteProfileFieldTemplate = TemplateParser.parse(createReader("/templates/deleteProfileField.html"));
-               Template viewSoneTemplate = TemplateParser.parse(createReader("/templates/viewSone.html"));
-               Template viewPostTemplate = TemplateParser.parse(createReader("/templates/viewPost.html"));
-               Template deletePostTemplate = TemplateParser.parse(createReader("/templates/deletePost.html"));
-               Template deleteReplyTemplate = TemplateParser.parse(createReader("/templates/deleteReply.html"));
-               Template deleteSoneTemplate = TemplateParser.parse(createReader("/templates/deleteSone.html"));
-               Template imageBrowserTemplate = TemplateParser.parse(createReader("/templates/imageBrowser.html"));
-               Template createAlbumTemplate = TemplateParser.parse(createReader("/templates/createAlbum.html"));
-               Template deleteAlbumTemplate = TemplateParser.parse(createReader("/templates/deleteAlbum.html"));
-               Template deleteImageTemplate = TemplateParser.parse(createReader("/templates/deleteImage.html"));
-               Template noPermissionTemplate = TemplateParser.parse(createReader("/templates/noPermission.html"));
-               Template optionsTemplate = TemplateParser.parse(createReader("/templates/options.html"));
-               Template rescueTemplate = TemplateParser.parse(createReader("/templates/rescue.html"));
-               Template aboutTemplate = TemplateParser.parse(createReader("/templates/about.html"));
-               Template invalidTemplate = TemplateParser.parse(createReader("/templates/invalid.html"));
-               Template postTemplate = TemplateParser.parse(createReader("/templates/include/viewPost.html"));
-               Template replyTemplate = TemplateParser.parse(createReader("/templates/include/viewReply.html"));
-               Template openSearchTemplate = TemplateParser.parse(createReader("/templates/xml/OpenSearch.xml"));
+               Template emptyTemplate = parse(new StringReader(""));
+               Template loginTemplate = parseTemplate("/templates/login.html");
+               Template indexTemplate = parseTemplate("/templates/index.html");
+               Template newTemplate = parseTemplate("/templates/new.html");
+               Template knownSonesTemplate = parseTemplate("/templates/knownSones.html");
+               Template createSoneTemplate = parseTemplate("/templates/createSone.html");
+               Template createPostTemplate = parseTemplate("/templates/createPost.html");
+               Template createReplyTemplate = parseTemplate("/templates/createReply.html");
+               Template bookmarksTemplate = parseTemplate("/templates/bookmarks.html");
+               Template searchTemplate = parseTemplate("/templates/search.html");
+               Template editProfileTemplate = parseTemplate("/templates/editProfile.html");
+               Template editProfileFieldTemplate = parseTemplate("/templates/editProfileField.html");
+               Template deleteProfileFieldTemplate = parseTemplate("/templates/deleteProfileField.html");
+               Template viewSoneTemplate = parseTemplate("/templates/viewSone.html");
+               Template viewPostTemplate = parseTemplate("/templates/viewPost.html");
+               Template deletePostTemplate = parseTemplate("/templates/deletePost.html");
+               Template deleteReplyTemplate = parseTemplate("/templates/deleteReply.html");
+               Template deleteSoneTemplate = parseTemplate("/templates/deleteSone.html");
+               Template imageBrowserTemplate = parseTemplate("/templates/imageBrowser.html");
+               Template createAlbumTemplate = parseTemplate("/templates/createAlbum.html");
+               Template deleteAlbumTemplate = parseTemplate("/templates/deleteAlbum.html");
+               Template deleteImageTemplate = parseTemplate("/templates/deleteImage.html");
+               Template noPermissionTemplate = parseTemplate("/templates/noPermission.html");
+               Template emptyImageTitleTemplate = parseTemplate("/templates/emptyImageTitle.html");
+               Template emptyAlbumTitleTemplate = parseTemplate("/templates/emptyAlbumTitle.html");
+               Template optionsTemplate = parseTemplate("/templates/options.html");
+               Template rescueTemplate = parseTemplate("/templates/rescue.html");
+               Template aboutTemplate = parseTemplate("/templates/about.html");
+               Template invalidTemplate = parseTemplate("/templates/invalid.html");
+               Template postTemplate = parseTemplate("/templates/include/viewPost.html");
+               Template replyTemplate = parseTemplate("/templates/include/viewReply.html");
+               Template openSearchTemplate = parseTemplate("/templates/xml/OpenSearch.xml");
 
                PageToadletFactory pageToadletFactory = new PageToadletFactory(sonePlugin.pluginRespirator().getHLSimpleClient(), "/Sone/");
                pageToadlets.add(pageToadletFactory.createPageToadlet(new RedirectPage<FreenetRequest>("", "index.html")));
@@ -675,6 +694,8 @@ public class WebInterface {
                pageToadlets.add(pageToadletFactory.createPageToadlet(new RescuePage(rescueTemplate, this), "Rescue"));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new AboutPage(aboutTemplate, this, SonePlugin.VERSION), "About"));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new SoneTemplatePage("noPermission.html", noPermissionTemplate, "Page.NoPermission.Title", this)));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new SoneTemplatePage("emptyImageTitle.html", emptyImageTitleTemplate, "Page.EmptyImageTitle.Title", this)));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new SoneTemplatePage("emptyAlbumTitle.html", emptyAlbumTitleTemplate, "Page.EmptyAlbumTitle.Title", this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new DismissNotificationPage(emptyTemplate, this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new SoneTemplatePage("invalid.html", invalidTemplate, "Page.Invalid.Title", this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new StaticPage<FreenetRequest>("css/", "/static/css/", "text/css")));
@@ -736,22 +757,6 @@ public class WebInterface {
        }
 
        /**
-        * Creates a {@link Reader} from the {@link InputStream} for the resource
-        * with the given name.
-        *
-        * @param resourceName
-        *            The name of the resource
-        * @return A {@link Reader} for the resource
-        */
-       private Reader createReader(String resourceName) {
-               try {
-                       return new InputStreamReader(getClass().getResourceAsStream(resourceName), "UTF-8");
-               } catch (UnsupportedEncodingException uee1) {
-                       return null;
-               }
-       }
-
-       /**
         * Returns all {@link Sone#isLocal() local Sone}s that are referenced by
         * {@link SonePart}s in the given text (after parsing it using
         * {@link SoneTextParser}).
@@ -788,7 +793,7 @@ public class WebInterface {
                synchronized (soneInsertNotifications) {
                        TemplateNotification templateNotification = soneInsertNotifications.get(sone);
                        if (templateNotification == null) {
-                               templateNotification = new TemplateNotification(TemplateParser.parse(createReader("/templates/notify/soneInsertNotification.html")));
+                               templateNotification = new TemplateNotification(parseTemplate("/templates/notify/soneInsertNotification.html"));
                                templateNotification.set("insertSone", sone);
                                soneInsertNotifications.put(sone, templateNotification);
                        }
@@ -796,6 +801,23 @@ public class WebInterface {
                }
        }
 
+       private boolean localSoneMentionedInNewPostOrReply(Post post) {
+               if (!post.getSone().isLocal()) {
+                       if (!getMentionedSones(post.getText()).isEmpty() && !post.isKnown()) {
+                               return true;
+                       }
+               }
+               for (PostReply postReply : getCore().getReplies(post.getId())) {
+                       if (postReply.getSone().isLocal()) {
+                               continue;
+                       }
+                       if (!getMentionedSones(postReply.getText()).isEmpty() && !postReply.isKnown()) {
+                               return true;
+                       }
+               }
+               return false;
+       }
+
        //
        // EVENT HANDLERS
        //
@@ -857,7 +879,7 @@ public class WebInterface {
                }
                if (!hasFirstStartNotification()) {
                        notificationManager.addNotification(isLocal ? localReplyNotification : newReplyNotification);
-                       if (!getMentionedSones(reply.getText()).isEmpty() && !isLocal && reply.getPost().isPresent() && (reply.getTime() <= System.currentTimeMillis())) {
+                       if (reply.getPost().isPresent() && localSoneMentionedInNewPostOrReply(reply.getPost().get())) {
                                mentionNotification.add(reply.getPost().get());
                                notificationManager.addNotification(mentionNotification);
                        }
@@ -887,7 +909,9 @@ public class WebInterface {
        public void markPostKnown(MarkPostKnownEvent markPostKnownEvent) {
                newPostNotification.remove(markPostKnownEvent.post());
                localPostNotification.remove(markPostKnownEvent.post());
-               mentionNotification.remove(markPostKnownEvent.post());
+               if (!localSoneMentionedInNewPostOrReply(markPostKnownEvent.post())) {
+                       mentionNotification.remove(markPostKnownEvent.post());
+               }
        }
 
        /**
@@ -898,9 +922,12 @@ public class WebInterface {
         */
        @Subscribe
        public void markReplyKnown(MarkPostReplyKnownEvent markPostReplyKnownEvent) {
-               newReplyNotification.remove(markPostReplyKnownEvent.postReply());
-               localReplyNotification.remove(markPostReplyKnownEvent.postReply());
-               mentionNotification.remove(markPostReplyKnownEvent.postReply().getPost().get());
+               PostReply postReply = markPostReplyKnownEvent.postReply();
+               newReplyNotification.remove(postReply);
+               localReplyNotification.remove(postReply);
+               if (postReply.getPost().isPresent() && !localSoneMentionedInNewPostOrReply(postReply.getPost().get())) {
+                       mentionNotification.remove(postReply.getPost().get());
+               }
        }
 
        /**
@@ -924,7 +951,9 @@ public class WebInterface {
        public void postRemoved(PostRemovedEvent postRemovedEvent) {
                newPostNotification.remove(postRemovedEvent.post());
                localPostNotification.remove(postRemovedEvent.post());
-               mentionNotification.remove(postRemovedEvent.post());
+               if (!localSoneMentionedInNewPostOrReply(postRemovedEvent.post())) {
+                       mentionNotification.remove(postRemovedEvent.post());
+               }
        }
 
        /**
@@ -938,14 +967,8 @@ public class WebInterface {
                PostReply reply = postReplyRemovedEvent.postReply();
                newReplyNotification.remove(reply);
                localReplyNotification.remove(reply);
-               if (!getMentionedSones(reply.getText()).isEmpty() && reply.getPost().isPresent()) {
-                       boolean isMentioned = false;
-                       for (PostReply existingReply : getCore().getReplies(reply.getPostId())) {
-                               isMentioned |= !reply.isKnown() && !getMentionedSones(existingReply.getText()).isEmpty();
-                       }
-                       if (!isMentioned) {
-                               mentionNotification.remove(reply.getPost().get());
-                       }
+               if (reply.getPost().isPresent() && !localSoneMentionedInNewPostOrReply(reply.getPost().get())) {
+                       mentionNotification.remove(reply.getPost().get());
                }
        }
 
@@ -992,7 +1015,7 @@ public class WebInterface {
        public void soneInserting(SoneInsertingEvent soneInsertingEvent) {
                TemplateNotification soneInsertNotification = getSoneInsertNotification(soneInsertingEvent.sone());
                soneInsertNotification.set("soneStatus", "inserting");
-               if (soneInsertingEvent.sone().getOptions().getBooleanOption("EnableSoneInsertNotifications").get()) {
+               if (soneInsertingEvent.sone().getOptions().isSoneInsertNotificationEnabled()) {
                        notificationManager.addNotification(soneInsertNotification);
                }
        }
@@ -1008,7 +1031,7 @@ public class WebInterface {
                TemplateNotification soneInsertNotification = getSoneInsertNotification(soneInsertedEvent.sone());
                soneInsertNotification.set("soneStatus", "inserted");
                soneInsertNotification.set("insertDuration", soneInsertedEvent.insertDuration() / 1000);
-               if (soneInsertedEvent.sone().getOptions().getBooleanOption("EnableSoneInsertNotifications").get()) {
+               if (soneInsertedEvent.sone().getOptions().isSoneInsertNotificationEnabled()) {
                        notificationManager.addNotification(soneInsertNotification);
                }
        }
@@ -1024,7 +1047,7 @@ public class WebInterface {
                TemplateNotification soneInsertNotification = getSoneInsertNotification(soneInsertAbortedEvent.sone());
                soneInsertNotification.set("soneStatus", "insert-aborted");
                soneInsertNotification.set("insert-error", soneInsertAbortedEvent.cause());
-               if (soneInsertAbortedEvent.sone().getOptions().getBooleanOption("EnableSoneInsertNotifications").get()) {
+               if (soneInsertAbortedEvent.sone().getOptions().isSoneInsertNotificationEnabled()) {
                        notificationManager.addNotification(soneInsertNotification);
                }
        }
index acee365..42719d3 100644 (file)
 
 package net.pterodactylus.sone.web.ajax;
 
+import net.pterodactylus.sone.data.Post;
 import net.pterodactylus.sone.web.WebInterface;
 import net.pterodactylus.sone.web.page.FreenetRequest;
 
+import com.google.common.base.Optional;
+
 /**
  * AJAX page that lets the user bookmark a post.
  *
@@ -50,7 +53,10 @@ public class BookmarkAjaxPage extends JsonPage {
                if ((id == null) || (id.length() == 0)) {
                        return createErrorJsonObject("invalid-post-id");
                }
-               webInterface.getCore().bookmarkPost(id);
+               Optional<Post> post = webInterface.getCore().getPost(id);
+               if (post.isPresent()) {
+                       webInterface.getCore().bookmarkPost(post.get());
+               }
                return createSuccessJsonObject();
        }
 
index 423d2df..5cb1047 100644 (file)
@@ -54,7 +54,7 @@ public class CreatePostAjaxPage extends JsonPage {
                String recipientId = request.getHttpRequest().getParam("recipient");
                Optional<Sone> recipient = webInterface.getCore().getSone(recipientId);
                String senderId = request.getHttpRequest().getParam("sender");
-               Sone sender = webInterface.getCore().getLocalSone(senderId, false);
+               Sone sender = webInterface.getCore().getLocalSone(senderId);
                if (sender == null) {
                        sender = sone;
                }
index ea2a2c2..3449dec 100644 (file)
@@ -55,7 +55,7 @@ public class CreateReplyAjaxPage extends JsonPage {
                String postId = request.getHttpRequest().getParam("post");
                String text = request.getHttpRequest().getParam("text").trim();
                String senderId = request.getHttpRequest().getParam("sender");
-               Sone sender = webInterface.getCore().getLocalSone(senderId, false);
+               Sone sender = webInterface.getCore().getLocalSone(senderId);
                if (sender == null) {
                        sender = getCurrentSone(request.getToadletContext());
                }
index 74a73b8..238206a 100644 (file)
@@ -49,7 +49,7 @@ public class EditAlbumAjaxPage extends JsonPage {
        @Override
        protected JsonReturnObject createJsonObject(FreenetRequest request) {
                String albumId = request.getHttpRequest().getParam("album");
-               Album album = webInterface.getCore().getAlbum(albumId, false);
+               Album album = webInterface.getCore().getAlbum(albumId);
                if (album == null) {
                        return createErrorJsonObject("invalid-album-id");
                }
@@ -68,9 +68,13 @@ public class EditAlbumAjaxPage extends JsonPage {
                }
                String title = request.getHttpRequest().getParam("title").trim();
                String description = request.getHttpRequest().getParam("description").trim();
-               album.modify().setTitle(title).setDescription(TextFilter.filter(request.getHttpRequest().getHeader("host"), description)).update();
-               webInterface.getCore().touchConfiguration();
-               return createSuccessJsonObject().put("albumId", album.getId()).put("title", album.getTitle()).put("description", album.getDescription());
+               try {
+                       album.modify().setTitle(title).setDescription(TextFilter.filter(request.getHttpRequest().getHeader("host"), description)).update();
+                       webInterface.getCore().touchConfiguration();
+                       return createSuccessJsonObject().put("albumId", album.getId()).put("title", album.getTitle()).put("description", album.getDescription());
+               } catch (IllegalStateException e) {
+                       return createErrorJsonObject("invalid-album-title");
+               }
        }
 
 }
index 0c45225..79de200 100644 (file)
@@ -77,6 +77,9 @@ public class EditImageAjaxPage extends JsonPage {
                        return createSuccessJsonObject().put("sourceImageId", image.getId()).put("destinationImageId", swappedImage.getId());
                }
                String title = request.getHttpRequest().getParam("title").trim();
+               if (title.isEmpty()) {
+                       return createErrorJsonObject("invalid-image-title");
+               }
                String description = request.getHttpRequest().getParam("description").trim();
                image.modify().setTitle(title).setDescription(TextFilter.filter(request.getHttpRequest().getHeader("host"), description)).update();
                webInterface.getCore().touchConfiguration();
index e8107ef..a25e665 100644 (file)
@@ -147,9 +147,9 @@ public class GetNotificationsAjaxPage extends JsonPage {
        private static JsonNode createJsonOptions(Sone currentSone) {
                ObjectNode options = new ObjectNode(instance);
                if (currentSone != null) {
-                       options.put("ShowNotification/NewSones", currentSone.getOptions().getBooleanOption("ShowNotification/NewSones").get());
-                       options.put("ShowNotification/NewPosts", currentSone.getOptions().getBooleanOption("ShowNotification/NewPosts").get());
-                       options.put("ShowNotification/NewReplies", currentSone.getOptions().getBooleanOption("ShowNotification/NewReplies").get());
+                       options.put("ShowNotification/NewSones", currentSone.getOptions().isShowNewSoneNotifications());
+                       options.put("ShowNotification/NewPosts", currentSone.getOptions().isShowNewPostNotifications());
+                       options.put("ShowNotification/NewReplies", currentSone.getOptions().isShowNewReplyNotifications());
                }
                return options;
        }
index e43910c..cc095a2 100644 (file)
@@ -192,9 +192,9 @@ public class GetStatusAjaxPage extends JsonPage {
        private static JsonNode createJsonOptions(Sone currentSone) {
                ObjectNode options = new ObjectNode(instance);
                if (currentSone != null) {
-                       options.put("ShowNotification/NewSones", currentSone.getOptions().getBooleanOption("ShowNotification/NewSones").get());
-                       options.put("ShowNotification/NewPosts", currentSone.getOptions().getBooleanOption("ShowNotification/NewPosts").get());
-                       options.put("ShowNotification/NewReplies", currentSone.getOptions().getBooleanOption("ShowNotification/NewReplies").get());
+                       options.put("ShowNotification/NewSones", currentSone.getOptions().isShowNewSoneNotifications());
+                       options.put("ShowNotification/NewPosts", currentSone.getOptions().isShowNewPostNotifications());
+                       options.put("ShowNotification/NewReplies", currentSone.getOptions().isShowNewReplyNotifications());
                }
                return options;
        }
index 3c7f587..6d6d92a 100644 (file)
@@ -17,6 +17,8 @@
 
 package net.pterodactylus.sone.web.ajax;
 
+import static java.util.logging.Logger.getLogger;
+
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.OutputStreamWriter;
@@ -30,7 +32,6 @@ import net.pterodactylus.sone.web.WebInterface;
 import net.pterodactylus.sone.web.page.FreenetPage;
 import net.pterodactylus.sone.web.page.FreenetRequest;
 import net.pterodactylus.util.io.Closer;
-import net.pterodactylus.util.logging.Logging;
 import net.pterodactylus.util.web.Page;
 import net.pterodactylus.util.web.Response;
 
@@ -47,7 +48,7 @@ import freenet.clients.http.ToadletContext;
 public abstract class JsonPage implements FreenetPage {
 
        /** The logger. */
-       private static final Logger logger = Logging.getLogger(JsonPage.class);
+       private static final Logger logger = getLogger("Sone.Web.Ajax");
 
        /** The JSON serializer. */
        private static final ObjectMapper objectMapper = new ObjectMapper();
index 68668b7..cc04338 100644 (file)
@@ -45,7 +45,7 @@ public class LockSoneAjaxPage extends JsonPage {
        @Override
        protected JsonReturnObject createJsonObject(FreenetRequest request) {
                String soneId = request.getHttpRequest().getParam("sone");
-               Sone sone = webInterface.getCore().getLocalSone(soneId, false);
+               Sone sone = webInterface.getCore().getLocalSone(soneId);
                if (sone == null) {
                        return createErrorJsonObject("invalid-sone-id");
                }
index 65bb14d..064bedc 100644 (file)
 
 package net.pterodactylus.sone.web.ajax;
 
+import net.pterodactylus.sone.data.Post;
 import net.pterodactylus.sone.web.WebInterface;
 import net.pterodactylus.sone.web.page.FreenetRequest;
 
+import com.google.common.base.Optional;
+
 /**
  * AJAX page that lets the user unbookmark a post.
  *
@@ -50,7 +53,10 @@ public class UnbookmarkAjaxPage extends JsonPage {
                if ((id == null) || (id.length() == 0)) {
                        return createErrorJsonObject("invalid-post-id");
                }
-               webInterface.getCore().unbookmarkPost(id);
+               Optional<Post> post = webInterface.getCore().getPost(id);
+               if (post.isPresent()) {
+                       webInterface.getCore().unbookmarkPost(post.get());
+               }
                return createSuccessJsonObject();
        }
 
index f92132d..3a91f81 100644 (file)
@@ -45,7 +45,7 @@ public class UnlockSoneAjaxPage extends JsonPage {
        @Override
        protected JsonReturnObject createJsonObject(FreenetRequest request) {
                String soneId = request.getHttpRequest().getParam("sone");
-               Sone sone = webInterface.getCore().getLocalSone(soneId, false);
+               Sone sone = webInterface.getCore().getLocalSone(soneId);
                if (sone == null) {
                        return createErrorJsonObject("invalid-sone-id");
                }
index 625978b..40e19b1 100644 (file)
@@ -17,6 +17,8 @@
 
 package net.pterodactylus.sone.web.page;
 
+import static java.util.logging.Logger.getLogger;
+
 import java.io.IOException;
 import java.io.StringWriter;
 import java.net.URI;
@@ -28,7 +30,6 @@ import java.util.Map.Entry;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
-import net.pterodactylus.util.logging.Logging;
 import net.pterodactylus.util.template.Template;
 import net.pterodactylus.util.template.TemplateContext;
 import net.pterodactylus.util.template.TemplateContextFactory;
@@ -51,7 +52,7 @@ import freenet.support.HTMLNode;
 public class FreenetTemplatePage implements FreenetPage, LinkEnabledCallback {
 
        /** The logger. */
-       private static final Logger logger = Logging.getLogger(FreenetTemplatePage.class);
+       private static final Logger logger = getLogger("Sone.Web.Freenet");
 
        /** The path of the page. */
        private final String path;
@@ -156,7 +157,7 @@ public class FreenetTemplatePage implements FreenetPage, LinkEnabledCallback {
                        long start = System.nanoTime();
                        processTemplate(request, templateContext);
                        long finish = System.nanoTime();
-                       logger.log(Level.FINEST, String.format("Template was rendered in %.2fms.", ((finish - start) / 1000) / 1000.0));
+                       logger.log(Level.FINEST, String.format("Template was rendered in %.2fms.", (finish - start) / 1000000.0));
                } catch (RedirectException re1) {
                        return new RedirectResponse(re1.getTarget());
                }
index a40a543..fc58339 100644 (file)
@@ -193,7 +193,7 @@ public class PageToadlet extends Toadlet implements LinkEnabledCallback, LinkFil
         */
        @Override
        public boolean isLinkExcepted(URI link) {
-               return (page instanceof FreenetPage) ? ((FreenetPage) page).isLinkExcepted(link) : false;
+               return (page instanceof FreenetPage) && ((FreenetPage) page).isLinkExcepted(link);
        }
 
 }
index 1d53d8a..6e871f9 100644 (file)
@@ -57,7 +57,7 @@ Page.Options.Option.InsertionDelay.Description=Anzahl der Sekunden, die vor dem
 Page.Options.Option.PostsPerPage.Description=Anzahl der Nachrichten pro Seite.
 Page.Options.Option.ImagesPerPage.Description=Anzahl der Bilder pro Seite.
 Page.Options.Option.CharactersPerPost.Description=Die Anzahl der Zeichen, die eine Nachricht enthalten muss, damit sie gekürzt angezeigt wird (-1 für „nie kürzen“). Die Anzahl der tatsächlich angezeigten Zeichen wird in der nächsten Option konfiguriert.
-Page.Options.Option.PostCutOffLength.Description=Die Anzahl der Zeichen, die von einer gekürzten Nachricht sichtbar sind (siehe Option hierüber).
+Page.Options.Option.PostCutOffLength.Description=Die Anzahl der Zeichen, die von einer gekürzten Nachricht sichtbar sind (siehe Option hierüber). Wird ignoriert, wenn die Option hierüber deaktiviert ist, bzw. auf -1 steht.
 Page.Options.Option.RequireFullAccess.Description=Zugriff auf Sone für alle Rechner, die keinen vollen Zugriff haben, unterbinden.
 Page.Options.Section.TrustOptions.Title=Vertrauenseinstellungen
 Page.Options.Option.PositiveTrust.Description=Die Menge an positivem Vertrauen, die bei einem Klick auf den Haken unter einer Nachricht zugewiesen werden soll.
@@ -305,6 +305,14 @@ Page.NoPermission.Title=Unberechtigter Zugriff - Sone
 Page.NoPermission.Page.Title=Unberechtigter Zugriff
 Page.NoPermission.Text.NoPermission=Sie haben versucht, etwas zu tun, zu dem Sie nicht berechtigt sind. Bitte unterlassen Sie das, da wir sonst gezwungen sind, Gegenmaßnahmen zu ergreifen!
 
+Page.EmptyImageTitle.Title=Bildtitel muss gesetzt sein - Sone
+Page.EmptyImageTitle.Page.Title=Bildtitel muss gesetzt sein
+Page.EmptyImageTitle.Text.EmptyImageTitle=Das Bild muss einen Titel haben. Bitte gehen Sie zur vorherigen Seite zurück und geben Sie einen Titel ein.
+
+Page.EmptyAlbumTitle.Title=Albumtitel muss gesetzt sein - Sone
+Page.EmptyAlbumTitle.Page.Title=Albumtitel muss gesetzt sein
+Page.EmptyAlbumTitle.Text.EmptyAlbumTitle=Das Album muss einen Titel haben. Bitte gehen Sie zur vorherigen Seite zurück und geben Sie einen Titel ein.
+
 Page.DismissNotification.Title=Benachrichtigung ausblenden - Sone
 
 Page.WotPluginMissing.Text.WotRequired=Da das „Web of Trust“ ein integraler Bestandteil von Sone ist, muss das „Web of Trust“ Plugin geladen sein, damit Sone funktionieren kann.
index a7b9ab4..40a12b1 100644 (file)
@@ -57,7 +57,7 @@ Page.Options.Option.InsertionDelay.Description=The number of seconds the Sone in
 Page.Options.Option.PostsPerPage.Description=The number of posts to display on a page before pagination controls are being shown.
 Page.Options.Option.ImagesPerPage.Description=The number of images to display on a page before pagination controls are being shown.
 Page.Options.Option.CharactersPerPost.Description=The number of characters to display from a post before cutting it off and showing a link to expand it (-1 to disable). The actual length of the snippet is determined by the option below.
-Page.Options.Option.PostCutOffLength.Description=The number of characters that are displayed if a post is deemed to long (see option above).
+Page.Options.Option.PostCutOffLength.Description=The number of characters that are displayed if a post is deemed too long (see option above). Ignored if “number of characters to display” is disabled (set to -1).
 Page.Options.Option.RequireFullAccess.Description=Whether to deny access to Sone to any host that has not been granted full access.
 Page.Options.Section.TrustOptions.Title=Trust Settings
 Page.Options.Option.PositiveTrust.Description=The amount of positive trust you want to assign to other Sones by clicking the checkmark below a post or reply.
@@ -305,6 +305,14 @@ Page.NoPermission.Title=Unauthorized Access - Sone
 Page.NoPermission.Page.Title=Unauthorized Access
 Page.NoPermission.Text.NoPermission=You tried to do something that you do not have sufficient authorization for. Please refrain from such actions in the future or we will be forced to take counter-measures!
 
+Page.EmptyImageTitle.Title=Title Must Not Be Empty - Sone
+Page.EmptyImageTitle.Page.Title=Title Must Not Be Empty
+Page.EmptyImageTitle.Text.EmptyImageTitle=You have to give your image a title. Please go back to the previous page and enter a title.
+
+Page.EmptyAlbumTitle.Title=Title Must Not Be Empty - Sone
+Page.EmptyAlbumTitle.Page.Title=Title Must Not Be Empty
+Page.EmptyAlbumTitle.Text.EmptyAlbumTitle=You have to give your album a title. Please go back to the previous page and enter a title.
+
 Page.DismissNotification.Title=Dismiss Notification - Sone
 
 Page.WotPluginMissing.Text.WotRequired=Because the Web of Trust is an integral part of Sone, the Web of Trust plugin has to be loaded in order to run Sone.
index d6dd410..ac8fa69 100644 (file)
@@ -305,6 +305,14 @@ Page.NoPermission.Title=Accès non autorisé - Sone
 Page.NoPermission.Page.Title=Accès non autorisé
 Page.NoPermission.Text.NoPermission=Vous avez tenté une action pour laquelle vous n'avez pas les droits suffisants. Veuillez vous abstenir de ces actions dans le futur ou nous serons forcés de prendre des contre-mesures!
 
+Page.EmptyImageTitle.Title=Title Must Not Be Empty - Sone
+Page.EmptyImageTitle.Page.Title=Title Must Not Be Empty
+Page.EmptyImageTitle.Text.EmptyImageTitle=You have to give your image a title. Please go back to the previous page and enter a title.
+
+Page.EmptyAlbumTitle.Title=Title Must Not Be Empty - Sone
+Page.EmptyAlbumTitle.Page.Title=Title Must Not Be Empty
+Page.EmptyAlbumTitle.Text.EmptyAlbumTitle=You have to give your album a title. Please go back to the previous page and enter a title.
+
 Page.DismissNotification.Title=Effacer la notification - Sone
 
 Page.WotPluginMissing.Text.LoadPlugin=Veuillez charger le plugin Web of Trust dans le {link}plugin manager{/link}.
@@ -455,4 +463,4 @@ Notification.Mention.Text=Vous avez été mentionné dans les messages suivants:
 Notification.SoneIsInserting.Text=Your Sone sone://{0} is now being inserted.
 Notification.SoneIsInserted.Text=Your Sone sone://{0} has been inserted in {1,number} {1,choice,0#seconds|1#second|1<seconds}.
 Notification.SoneInsertAborted.Text=Your Sone sone://{0} could not be inserted.
-# 100, 454-456
+# 60, 100, 308-310, 312-314, 463-465
index 783e2c9..0cfd250 100644 (file)
@@ -97,7 +97,7 @@ Page.Index.Button.Post=投稿!
 Page.Index.PostList.Title=ポストフィード
 Page.Index.PostList.Text.NoPostYet=誰も投稿していません。ぜひ投稿をスタートしましょう!
 Page.Index.PostList.Text.FollowSomeSones=もしくは誰もフォローしてないですか? {link}既知のSone{/link}を表示し、面白そうなSoneをフォローしましょう。
-Page.Index.PostList.Text.AutoFollowOption=You also have the option of automatically following newly discovered Sones. Take a look at the {link}options{/link} to activate the auto-follow feature!
+Page.Index.PostList.Text.AutoFollowOption=新しく発見したSoneを自動的にフォローすることができます。{link}オプション{/link}から設定が可能です。
 
 Page.New.Title=新しい投稿と返信 - Sone
 Page.New.Page.Title=新しい投稿と返信
@@ -305,6 +305,14 @@ Page.NoPermission.Title=不正なアクセス - Sone
 Page.NoPermission.Page.Title=不正なアクセス
 Page.NoPermission.Text.NoPermission=許可されていないアクセスが検知されました。これ以上続行すると防衛機構が作動するかも知れません!
 
+Page.EmptyImageTitle.Title=Title Must Not Be Empty - Sone
+Page.EmptyImageTitle.Page.Title=Title Must Not Be Empty
+Page.EmptyImageTitle.Text.EmptyImageTitle=You have to give your image a title. Please go back to the previous page and enter a title.
+
+Page.EmptyAlbumTitle.Title=Title Must Not Be Empty - Sone
+Page.EmptyAlbumTitle.Page.Title=Title Must Not Be Empty
+Page.EmptyAlbumTitle.Text.EmptyAlbumTitle=You have to give your album a title. Please go back to the previous page and enter a title.
+
 Page.DismissNotification.Title=通知を消す - Sone
 
 Page.WotPluginMissing.Text.WotRequired=Web of TrustはSoneで必要な機構であるため、Soneの読み込みにはまずはWeb of Trustが読み出されている必要があります。
@@ -455,4 +463,4 @@ Notification.Mention.Text=次の投稿でメンションされています:
 Notification.SoneIsInserting.Text=あなたのSone sone://{0}は現在インサート中です。
 Notification.SoneIsInserted.Text=あなたのSone sone://{0}は{1,number}{1,choice,0#秒|1#秒|1<秒}でインサートされました。
 Notification.SoneInsertAborted.Text=あなたのSone sone://{0}のインサートに失敗しました。
-# 100
+# 60, 100, 308-310, 312-314
index b1ce306..27a5a2e 100644 (file)
@@ -305,6 +305,14 @@ Page.NoPermission.Title=Ikke-autorisert tilgang - Sone
 Page.NoPermission.Page.Title=Ikke-autorisert tilgang
 Page.NoPermission.Text.NoPermission=Du prøvde å gjøre noe som du ikke har tilstrekkelige rettigheter til. Vennligst avstå fra slike handlinger i framtiden ellers vil vi bli tvunget til å ta til motgrep!
 
+Page.EmptyImageTitle.Title=Title Must Not Be Empty - Sone
+Page.EmptyImageTitle.Page.Title=Title Must Not Be Empty
+Page.EmptyImageTitle.Text.EmptyImageTitle=You have to give your image a title. Please go back to the previous page and enter a title.
+
+Page.EmptyAlbumTitle.Title=Title Must Not Be Empty - Sone
+Page.EmptyAlbumTitle.Page.Title=Title Must Not Be Empty
+Page.EmptyAlbumTitle.Text.EmptyAlbumTitle=You have to give your album a title. Please go back to the previous page and enter a title.
+
 Page.DismissNotification.Title=Fjern varsel - Sone
 
 Page.WotPluginMissing.Text.WotRequired=Fordi 'Web Of Trust' er en integrert del av Sone, må 'Web Of Trust'-tillegget bli lastet for å kunne kjøre Sone.
@@ -455,4 +463,4 @@ Notification.Mention.Text=Du har blitt nevnt i følgende innlegg:
 Notification.SoneIsInserting.Text=Your Sone sone://{0} is now being inserted.
 Notification.SoneIsInserted.Text=Your Sone sone://{0} has been inserted in {1,number} {1,choice,0#seconds|1#second|1<seconds}.
 Notification.SoneInsertAborted.Text=Your Sone sone://{0} could not be inserted.
-# 100, 120-121, 454-456
+# 60, 100, 120-121, 308-310, 312-314, 463-465
index 5d14335..da5ccda 100644 (file)
@@ -305,6 +305,14 @@ Page.NoPermission.Title=Nieupoważniony dostęp- Sone
 Page.NoPermission.Page.Title=Nieupoważniony dostęp
 Page.NoPermission.Text.NoPermission=Próbowałeś zrobić coś do czego nie masz wystarczającej autoryzacji. Na przyszłość nie podejmuj tego typu działań, albo będziemy zmuszeni podjać odpowiednie kroki.
 
+Page.EmptyImageTitle.Title=Title Must Not Be Empty - Sone
+Page.EmptyImageTitle.Page.Title=Title Must Not Be Empty
+Page.EmptyImageTitle.Text.EmptyImageTitle=You have to give your image a title. Please go back to the previous page and enter a title.
+
+Page.EmptyAlbumTitle.Title=Title Must Not Be Empty - Sone
+Page.EmptyAlbumTitle.Page.Title=Title Must Not Be Empty
+Page.EmptyAlbumTitle.Text.EmptyAlbumTitle=You have to give your album a title. Please go back to the previous page and enter a title.
+
 Page.DismissNotification.Title=Odrzuć powiadomienie - Sone
 
 Page.WotPluginMissing.Text.WotRequired=Sieć Zaufania jest integralną częścią Sone. Należy załądować wtyczkę Sieci Zaufania, aby móc korzystać z Sone.
@@ -455,4 +463,4 @@ Notification.Mention.Text=Zostałeś oznaczony w następujących postach:
 Notification.SoneIsInserting.Text=Twoje Sone sone://{0} jest w tej chili wysyłane.
 Notification.SoneIsInserted.Text=Twoje sone://{0} zostało wysłane w {1,number} {1,choice,0#seconds|1#second|1<seconds}.
 Notification.SoneInsertAborted.Text=Twoje Sone sone://{0} nie mogło zostać wysłane.
-# 100
+# 60, 100, 308-310, 312-314
index c40bd8d..7ef039d 100644 (file)
@@ -305,6 +305,14 @@ Page.NoPermission.Title=Неавторизованный доступ - Sone
 Page.NoPermission.Page.Title=Неавторизованный доступ
 Page.NoPermission.Text.NoPermission=Вы пытались сделать что-то, для чего у вас недостаточно авторизации. Пожалуйска воздержитесь от подобных действий в будущем или мы будем вынуждены применить контр-меры!
 
+Page.EmptyImageTitle.Title=Title Must Not Be Empty - Sone
+Page.EmptyImageTitle.Page.Title=Title Must Not Be Empty
+Page.EmptyImageTitle.Text.EmptyImageTitle=You have to give your image a title. Please go back to the previous page and enter a title.
+
+Page.EmptyAlbumTitle.Title=Title Must Not Be Empty - Sone
+Page.EmptyAlbumTitle.Page.Title=Title Must Not Be Empty
+Page.EmptyAlbumTitle.Text.EmptyAlbumTitle=You have to give your album a title. Please go back to the previous page and enter a title.
+
 Page.DismissNotification.Title=Скрыть уведомление - Sone
 
 Page.WotPluginMissing.Text.WotRequired=Так как Web of Trust - неотъемлимая часть Sone, дополнение Web of Trust должно быть запущего для работы Sone.
@@ -455,4 +463,4 @@ Notification.Mention.Text=Вас упомянули в следующих соо
 Notification.SoneIsInserting.Text=Your Sone sone://{0} is now being inserted.
 Notification.SoneIsInserted.Text=Your Sone sone://{0} has been inserted in {1,number} {1,choice,0#seconds|1#second|1<seconds}.
 Notification.SoneInsertAborted.Text=Your Sone sone://{0} could not be inserted.
-# 100, 120-121, 454-456
+# 60, 100, 120-121, 308-310, 312-314, 463-465
diff --git a/src/main/resources/templates/emptyAlbumTitle.html b/src/main/resources/templates/emptyAlbumTitle.html
new file mode 100644 (file)
index 0000000..0df7c51
--- /dev/null
@@ -0,0 +1,7 @@
+<%include include/head.html>
+
+       <h1><%= Page.EmptyAlbumTitle.Page.Title|l10n|html></h1>
+
+       <p><%= Page.EmptyAlbumTitle.Text.EmptyAlbumTitle|l10n|html></p>
+
+<%include include/tail.html>
diff --git a/src/main/resources/templates/emptyImageTitle.html b/src/main/resources/templates/emptyImageTitle.html
new file mode 100644 (file)
index 0000000..59f15e5
--- /dev/null
@@ -0,0 +1,7 @@
+<%include include/head.html>
+
+       <h1><%= Page.EmptyImageTitle.Page.Title|l10n|html></h1>
+
+       <p><%= Page.EmptyImageTitle.Text.EmptyImageTitle|l10n|html></p>
+
+<%include include/tail.html>
index da8d032..7dd4c29 100644 (file)
                                                title = $(":input[name='title']:enabled", this.form).val();
                                                description = $(":input[name='description']:enabled", this.form).val();
                                                ajaxGet("editImage.ajax", { "formPassword": getFormPassword(), "image": imageId, "title": title, "description": description }, function(data) {
-                                                       if (data && data.success) {
-                                                               getImage(data.imageId).find(".image-title").text(data.title);
-                                                               getImage(data.imageId).find(".image-description").html(data.parsedDescription);
-                                                               getImage(data.imageId).find(":input[name='title']").attr("defaultValue", title);
-                                                               getImage(data.imageId).find(":input[name='description']").attr("defaultValue", data.description);
+                            var imageElement = getImage(data.imageId);
+                            var imageTitleInput = imageElement.find(":input[name='title']");
+                            var imageDescriptionInput = imageElement.find(":input[name='description']");
+                            if (data && data.success) {
+                                                               imageElement.find(".image-title").text(data.title);
+                                                               imageElement.find(".image-description").html(data.parsedDescription);
+                                                               imageTitleInput.attr("defaultValue", data.title);
+                                                               imageDescriptionInput.attr("defaultValue", data.description);
                                                                cancelImageEditing();
-                                                       }
+                                                       } else if (data && !data.success) {
+                                                               imageTitleInput.attr("value", imageTitleInput.attr("defaultValue"));
+                                imageDescriptionInput.attr("value", imageDescriptionInput.attr("defaultValue"));
+                                cancelImageEditing();
+                            }
                                                });
                                                return false;
                                        });
                                                title = $(":input[name='title']:enabled", this.form).val();
                                                description = $(":input[name='description']:enabled", this.form).val();
                                                ajaxGet("editAlbum.ajax", { "formPassword": getFormPassword(), "album": albumId, "title": title, "description": description }, function(data) {
-                                                       if (data && data.success) {
-                                                               getAlbum(data.albumId).find(".album-title").text(data.title);
-                                                               getAlbum(data.albumId).find(".album-description").text(data.description);
-                                                               getAlbum(data.albumId).find(":input[name='title']").attr("defaultValue", title);
-                                                               getAlbum(data.albumId).find(":input[name='description']").attr("defaultValue", description);
-                                                               cancelAlbumEditing();
-                                                       }
+                                                       if (data) {
+                                var albumTitleField = getAlbum(data.albumId).find(".album-title");
+                                var albumDescriptionField = getAlbum(data.albumId).find(".album-description");
+                                if (data.success) {
+                                    albumTitleField.text(data.title);
+                                    albumDescriptionField.text(data.description);
+                                    getAlbum(data.albumId).find(":input[name='title']").attr("defaultValue", title);
+                                    getAlbum(data.albumId).find(":input[name='description']").attr("defaultValue", description);
+                                } else {
+                                    albumTitleField.attr("value", albumTitleField.attr("defaultValue"));
+                                    albumDescriptionField.attr("value", albumDescriptionField.attr("defaultValue"));
+                                }
+                                cancelAlbumEditing();
+                            }
                                                });
                                                return false;
                                        });
index 1ca0023..60b8ff7 100644 (file)
@@ -10,7 +10,7 @@
                <div class="profile-link"><a href="viewSone.html?sone=<% sone.id|html>" title="<% sone.requestUri|html>"><% sone.niceName|html></a></div>
                <div class="sone-stats">(<%= View.Sone.Stats.Posts|l10n 0=sone.posts.size>, <%= View.Sone.Stats.Replies|l10n 0=sone.replies.size><%if ! sone.allImages.size|match value==0>, <%= View.Sone.Stats.Images|l10n 0=sone.allImages.size><%/if>)</div>
        </div>
-       <div class="short-request-uri"><% sone.requestUri|substring start==4 length==43|html></div>
+       <div class="short-request-uri"><% sone.id|html></div>
        <div class="hidden"><% sone.blacklisted></div>
        <%if sone.local>
                <form class="lock<%if sone.locked> hidden<%/if>" action="lockSone.html" method="post">
diff --git a/src/test/java/net/pterodactylus/sone/Matchers.java b/src/test/java/net/pterodactylus/sone/Matchers.java
new file mode 100644 (file)
index 0000000..c16322c
--- /dev/null
@@ -0,0 +1,396 @@
+/*
+ * Sone - Matchers.java - Copyright © 2013 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone;
+
+import static java.util.regex.Pattern.compile;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import net.pterodactylus.sone.data.Album;
+import net.pterodactylus.sone.data.Image;
+import net.pterodactylus.sone.data.Post;
+import net.pterodactylus.sone.data.PostReply;
+
+import com.google.common.base.Optional;
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+import org.hamcrest.TypeSafeDiagnosingMatcher;
+import org.hamcrest.TypeSafeMatcher;
+
+/**
+ * Matchers used throughout the tests.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class Matchers {
+
+       public static Matcher<String> matchesRegex(final String regex) {
+               return new TypeSafeMatcher<String>() {
+                       @Override
+                       protected boolean matchesSafely(String item) {
+                               return compile(regex).matcher(item).matches();
+                       }
+
+                       @Override
+                       public void describeTo(Description description) {
+                               description.appendText("matches: ").appendValue(regex);
+                       }
+               };
+       }
+
+       public static Matcher<InputStream> delivers(final byte[] data) {
+               return new TypeSafeMatcher<InputStream>() {
+                       byte[] readData = new byte[data.length];
+
+                       @Override
+                       protected boolean matchesSafely(InputStream inputStream) {
+                               int offset = 0;
+                               try {
+                                       while (true) {
+                                               int r = inputStream.read();
+                                               if (r == -1) {
+                                                       return offset == data.length;
+                                               }
+                                               if (offset == data.length) {
+                                                       return false;
+                                               }
+                                               if (data[offset] != (readData[offset] = (byte) r)) {
+                                                       return false;
+                                               }
+                                               offset++;
+                                       }
+                               } catch (IOException ioe1) {
+                                       return false;
+                               }
+                       }
+
+                       @Override
+                       public void describeTo(Description description) {
+                               description.appendValue(data);
+                       }
+
+                       @Override
+                       protected void describeMismatchSafely(InputStream item,
+                                       Description mismatchDescription) {
+                               mismatchDescription.appendValue(readData);
+                       }
+               };
+       }
+
+       public static Matcher<Post> isPost(String postId, long time,
+                       String text, Optional<String> recipient) {
+               return new PostMatcher(postId, time, text, recipient);
+       }
+
+       public static Matcher<Post> isPostWithId(String postId) {
+               return new PostIdMatcher(postId);
+       }
+
+       public static Matcher<PostReply> isPostReply(String postReplyId,
+                       String postId, long time, String text) {
+               return new PostReplyMatcher(postReplyId, postId, time, text);
+       }
+
+       public static Matcher<Album> isAlbum(final String albumId,
+                       final String parentAlbumId,
+                       final String title, final String albumDescription,
+                       final String imageId) {
+               return new TypeSafeDiagnosingMatcher<Album>() {
+                       @Override
+                       protected boolean matchesSafely(Album album,
+                                       Description mismatchDescription) {
+                               if (!album.getId().equals(albumId)) {
+                                       mismatchDescription.appendText("ID is ")
+                                                       .appendValue(album.getId());
+                                       return false;
+                               }
+                               if (parentAlbumId == null) {
+                                       if (album.getParent() != null) {
+                                               mismatchDescription.appendText("has parent album");
+                                               return false;
+                                       }
+                               } else {
+                                       if (album.getParent() == null) {
+                                               mismatchDescription.appendText("has no parent album");
+                                               return false;
+                                       }
+                                       if (!album.getParent().getId().equals(parentAlbumId)) {
+                                               mismatchDescription.appendText("parent album is ")
+                                                               .appendValue(album.getParent().getId());
+                                               return false;
+                                       }
+                               }
+                               if (!title.equals(album.getTitle())) {
+                                       mismatchDescription.appendText("has title ")
+                                                       .appendValue(album.getTitle());
+                                       return false;
+                               }
+                               if (!albumDescription.equals(album.getDescription())) {
+                                       mismatchDescription.appendText("has description ")
+                                                       .appendValue(album.getDescription());
+                                       return false;
+                               }
+                               if (imageId == null) {
+                                       if (album.getAlbumImage() != null) {
+                                               mismatchDescription.appendText("has album image");
+                                               return false;
+                                       }
+                               } else {
+                                       if (album.getAlbumImage() == null) {
+                                               mismatchDescription.appendText("has no album image");
+                                               return false;
+                                       }
+                                       if (!album.getAlbumImage().getId().equals(imageId)) {
+                                               mismatchDescription.appendText("has album image ")
+                                                               .appendValue(album.getAlbumImage().getId());
+                                               return false;
+                                       }
+                               }
+                               return true;
+                       }
+
+                       @Override
+                       public void describeTo(Description description) {
+                               description.appendText("is album ").appendValue(albumId);
+                               if (parentAlbumId == null) {
+                                       description.appendText(", has no parent");
+                               } else {
+                                       description.appendText(", has parent ")
+                                                       .appendValue(parentAlbumId);
+                               }
+                               description.appendText(", has title ").appendValue(title);
+                               description.appendText(", has description ")
+                                               .appendValue(albumDescription);
+                               if (imageId == null) {
+                                       description.appendText(", has no album image");
+                               } else {
+                                       description.appendText(", has album image ")
+                                                       .appendValue(imageId);
+                               }
+                       }
+               };
+       }
+
+       public static Matcher<Image> isImage(final String id,
+                       final long creationTime,
+                       final String key, final String title,
+                       final String imageDescription,
+                       final int width, final int height) {
+               return new TypeSafeDiagnosingMatcher<Image>() {
+                       @Override
+                       protected boolean matchesSafely(Image image,
+                                       Description mismatchDescription) {
+                               if (!image.getId().equals(id)) {
+                                       mismatchDescription.appendText("ID is ")
+                                                       .appendValue(image.getId());
+                                       return false;
+                               }
+                               if (image.getCreationTime() != creationTime) {
+                                       mismatchDescription.appendText("created at @")
+                                                       .appendValue(image.getCreationTime());
+                                       return false;
+                               }
+                               if (!image.getKey().equals(key)) {
+                                       mismatchDescription.appendText("key is ")
+                                                       .appendValue(image.getKey());
+                                       return false;
+                               }
+                               if (!image.getTitle().equals(title)) {
+                                       mismatchDescription.appendText("title is ")
+                                                       .appendValue(image.getTitle());
+                                       return false;
+                               }
+                               if (!image.getDescription().equals(imageDescription)) {
+                                       mismatchDescription.appendText("description is ")
+                                                       .appendValue(image.getDescription());
+                                       return false;
+                               }
+                               if (image.getWidth() != width) {
+                                       mismatchDescription.appendText("width is ")
+                                                       .appendValue(image.getWidth());
+                                       return false;
+                               }
+                               if (image.getHeight() != height) {
+                                       mismatchDescription.appendText("height is ")
+                                                       .appendValue(image.getHeight());
+                                       return false;
+                               }
+                               return true;
+                       }
+
+                       @Override
+                       public void describeTo(Description description) {
+                               description.appendText("image with ID ").appendValue(id);
+                               description.appendText(", created at @")
+                                               .appendValue(creationTime);
+                               description.appendText(", has key ").appendValue(key);
+                               description.appendText(", has title ").appendValue(title);
+                               description.appendText(", has description ")
+                                               .appendValue(imageDescription);
+                               description.appendText(", has width ").appendValue(width);
+                               description.appendText(", has height ").appendValue(height);
+                       }
+               };
+       }
+
+       private static class PostMatcher extends TypeSafeDiagnosingMatcher<Post> {
+
+               private final String postId;
+               private final long time;
+               private final String text;
+               private final Optional<String> recipient;
+
+               private PostMatcher(String postId, long time, String text,
+                               Optional<String> recipient) {
+                       this.postId = postId;
+                       this.time = time;
+                       this.text = text;
+                       this.recipient = recipient;
+               }
+
+               @Override
+               protected boolean matchesSafely(Post post,
+                               Description mismatchDescription) {
+                       if (!post.getId().equals(postId)) {
+                               mismatchDescription.appendText("ID is not ")
+                                               .appendValue(postId);
+                               return false;
+                       }
+                       if (post.getTime() != time) {
+                               mismatchDescription.appendText("Time is not @")
+                                               .appendValue(time);
+                               return false;
+                       }
+                       if (!post.getText().equals(text)) {
+                               mismatchDescription.appendText("Text is not ")
+                                               .appendValue(text);
+                               return false;
+                       }
+                       if (recipient.isPresent()) {
+                               if (!post.getRecipientId().isPresent()) {
+                                       mismatchDescription.appendText(
+                                                       "Recipient not present");
+                                       return false;
+                               }
+                               if (!post.getRecipientId().get().equals(recipient.get())) {
+                                       mismatchDescription.appendText("Recipient is not ")
+                                                       .appendValue(recipient.get());
+                                       return false;
+                               }
+                       } else {
+                               if (post.getRecipientId().isPresent()) {
+                                       mismatchDescription.appendText("Recipient is present");
+                                       return false;
+                               }
+                       }
+                       return true;
+               }
+
+               @Override
+               public void describeTo(Description description) {
+                       description.appendText("is post with ID ")
+                                       .appendValue(postId);
+                       description.appendText(", created at @").appendValue(time);
+                       description.appendText(", text ").appendValue(text);
+                       if (recipient.isPresent()) {
+                               description.appendText(", directed at ")
+                                               .appendValue(recipient.get());
+                       }
+               }
+
+       }
+
+       private static class PostIdMatcher extends TypeSafeDiagnosingMatcher<Post> {
+
+               private final String id;
+
+               private PostIdMatcher(String id) {
+                       this.id = id;
+               }
+
+               @Override
+               protected boolean matchesSafely(Post item,
+                               Description mismatchDescription) {
+                       if (!item.getId().equals(id)) {
+                               mismatchDescription.appendText("post has ID ").appendValue(item.getId());
+                               return false;
+                       }
+                       return true;
+               }
+
+               @Override
+               public void describeTo(Description description) {
+                       description.appendText("post with ID ").appendValue(id);
+               }
+
+       }
+
+       private static class PostReplyMatcher
+                       extends TypeSafeDiagnosingMatcher<PostReply> {
+
+               private final String postReplyId;
+               private final String postId;
+               private final long time;
+               private final String text;
+
+               private PostReplyMatcher(String postReplyId, String postId, long time,
+                               String text) {
+                       this.postReplyId = postReplyId;
+                       this.postId = postId;
+                       this.time = time;
+                       this.text = text;
+               }
+
+               @Override
+               protected boolean matchesSafely(PostReply postReply,
+                               Description mismatchDescription) {
+                       if (!postReply.getId().equals(postReplyId)) {
+                               mismatchDescription.appendText("is post reply ")
+                                               .appendValue(postReply.getId());
+                               return false;
+                       }
+                       if (!postReply.getPostId().equals(postId)) {
+                               mismatchDescription.appendText("is reply to ")
+                                               .appendValue(postReply.getPostId());
+                               return false;
+                       }
+                       if (postReply.getTime() != time) {
+                               mismatchDescription.appendText("is created at @").appendValue(
+                                               postReply.getTime());
+                               return false;
+                       }
+                       if (!postReply.getText().equals(text)) {
+                               mismatchDescription.appendText("says ")
+                                               .appendValue(postReply.getText());
+                               return false;
+                       }
+                       return true;
+               }
+
+               @Override
+               public void describeTo(Description description) {
+                       description.appendText("is post reply ").appendValue(postReplyId);
+                       description.appendText(", replies to post ").appendValue(postId);
+                       description.appendText(", is created at @").appendValue(time);
+                       description.appendText(", says ").appendValue(text);
+               }
+
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/TestAlbumBuilder.java b/src/test/java/net/pterodactylus/sone/TestAlbumBuilder.java
new file mode 100644 (file)
index 0000000..9890a70
--- /dev/null
@@ -0,0 +1,139 @@
+package net.pterodactylus.sone;
+
+import static java.util.UUID.randomUUID;
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import net.pterodactylus.sone.data.Album;
+import net.pterodactylus.sone.data.Album.Modifier;
+import net.pterodactylus.sone.data.Image;
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.sone.database.AlbumBuilder;
+
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+
+/**
+ * {@link AlbumBuilder} that returns a mocked {@link Album}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class TestAlbumBuilder implements AlbumBuilder {
+
+       private final Album album = mock(Album.class);
+       private final List<Album> albums = new ArrayList<Album>();
+       private final List<Image> images = new ArrayList<Image>();
+       private Album parentAlbum;
+       private String title;
+       private String description;
+       private String imageId;
+
+       public TestAlbumBuilder() {
+               when(album.getTitle()).thenAnswer(new Answer<String>() {
+                       @Override
+                       public String answer(InvocationOnMock invocation) {
+                               return title;
+                       }
+               });
+               when(album.getDescription()).thenAnswer(new Answer<String>() {
+                       @Override
+                       public String answer(InvocationOnMock invocation) {
+                               return description;
+                       }
+               });
+               when(album.getAlbumImage()).thenAnswer(new Answer<Image>() {
+                       @Override
+                       public Image answer(InvocationOnMock invocation) {
+                               if (imageId == null) {
+                                       return null;
+                               }
+                               Image image = mock(Image.class);
+                               when(image.getId()).thenReturn(imageId);
+                               return image;
+                       }
+               });
+               when(album.getAlbums()).thenReturn(albums);
+               when(album.getImages()).thenReturn(images);
+               doAnswer(new Answer<Void>() {
+                       @Override
+                       public Void answer(InvocationOnMock invocation) {
+                               albums.add((Album) invocation.getArguments()[0]);
+                               ((Album) invocation.getArguments()[0]).setParent(album);
+                               return null;
+                       }
+               }).when(album).addAlbum(any(Album.class));
+               doAnswer(new Answer<Void>() {
+                       @Override
+                       public Void answer(InvocationOnMock invocation) {
+                               images.add((Image) invocation.getArguments()[0]);
+                               return null;
+                       }
+               }).when(album).addImage(any(Image.class));
+               doAnswer(new Answer<Void>() {
+                       @Override
+                       public Void answer(InvocationOnMock invocation) {
+                               parentAlbum = (Album) invocation.getArguments()[0];
+                               return null;
+                       }
+               }).when(album).setParent(any(Album.class));
+               when(album.getParent()).thenAnswer(new Answer<Album>() {
+                       @Override
+                       public Album answer(InvocationOnMock invocation) {
+                               return parentAlbum;
+                       }
+               });
+               when(album.modify()).thenReturn(new Modifier() {
+                       @Override
+                       public Modifier setTitle(String title) {
+                               TestAlbumBuilder.this.title = title;
+                               return this;
+                       }
+
+                       @Override
+                       public Modifier setDescription(String description) {
+                               TestAlbumBuilder.this.description = description;
+                               return this;
+                       }
+
+                       @Override
+                       public Modifier setAlbumImage(String imageId) {
+                               TestAlbumBuilder.this.imageId = imageId;
+                               return this;
+                       }
+
+                       @Override
+                       public Album update() throws IllegalStateException {
+                               return album;
+                       }
+               });
+       }
+
+       @Override
+       public AlbumBuilder randomId() {
+               when(album.getId()).thenReturn(randomUUID().toString());
+               return this;
+       }
+
+       @Override
+       public AlbumBuilder withId(String id) {
+               when(album.getId()).thenReturn(id);
+               return this;
+       }
+
+       @Override
+       public AlbumBuilder by(Sone sone) {
+               when(album.getSone()).thenReturn(sone);
+               return this;
+       }
+
+       @Override
+       public Album build() throws IllegalStateException {
+               return album;
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/TestImageBuilder.java b/src/test/java/net/pterodactylus/sone/TestImageBuilder.java
new file mode 100644 (file)
index 0000000..48a8fad
--- /dev/null
@@ -0,0 +1,105 @@
+package net.pterodactylus.sone;
+
+import static java.util.UUID.randomUUID;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import net.pterodactylus.sone.data.Image;
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.sone.database.ImageBuilder;
+
+/**
+ * {@link ImageBuilder} implementation that returns a mocked {@link Image}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class TestImageBuilder implements ImageBuilder {
+
+       private final Image image;
+
+       public TestImageBuilder() {
+               image = mock(Image.class);
+               Image.Modifier imageModifier = new Image.Modifier() {
+                       private Sone sone = image.getSone();
+                       private long creationTime = image.getCreationTime();
+                       private String key = image.getKey();
+                       private String title = image.getTitle();
+                       private String description = image.getDescription();
+                       private int width = image.getWidth();
+                       private int height = image.getHeight();
+
+                       @Override
+                       public Image.Modifier setSone(Sone sone) {
+                               this.sone = sone;
+                               return this;
+                       }
+
+                       @Override
+                       public Image.Modifier setCreationTime(long creationTime) {
+                               this.creationTime = creationTime;
+                               return this;
+                       }
+
+                       @Override
+                       public Image.Modifier setKey(String key) {
+                               this.key = key;
+                               return this;
+                       }
+
+                       @Override
+                       public Image.Modifier setTitle(String title) {
+                               this.title = title;
+                               return this;
+                       }
+
+                       @Override
+                       public Image.Modifier setDescription(String description) {
+                               this.description = description;
+                               return this;
+                       }
+
+                       @Override
+                       public Image.Modifier setWidth(int width) {
+                               this.width = width;
+                               return this;
+                       }
+
+                       @Override
+                       public Image.Modifier setHeight(int height) {
+                               this.height = height;
+                               return this;
+                       }
+
+                       @Override
+                       public Image update() throws IllegalStateException {
+                               when(image.getSone()).thenReturn(sone);
+                               when(image.getCreationTime()).thenReturn(creationTime);
+                               when(image.getKey()).thenReturn(key);
+                               when(image.getTitle()).thenReturn(title);
+                               when(image.getDescription()).thenReturn(description);
+                               when(image.getWidth()).thenReturn(width);
+                               when(image.getHeight()).thenReturn(height);
+                               return image;
+                       }
+               };
+               when(image.modify()).thenReturn(imageModifier);
+       }
+
+       @Override
+       public ImageBuilder randomId() {
+               when(image.getId()).thenReturn(randomUUID().toString());
+               return this;
+       }
+
+       @Override
+       public ImageBuilder withId(String id) {
+               when(image.getId()).thenReturn(id);
+               return this;
+       }
+
+       @Override
+       public Image build() throws IllegalStateException {
+               return image;
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/TestPostBuilder.java b/src/test/java/net/pterodactylus/sone/TestPostBuilder.java
new file mode 100644 (file)
index 0000000..29c4c40
--- /dev/null
@@ -0,0 +1,78 @@
+package net.pterodactylus.sone;
+
+import static com.google.common.base.Optional.fromNullable;
+import static java.lang.System.currentTimeMillis;
+import static java.util.UUID.randomUUID;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import net.pterodactylus.sone.data.Post;
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.sone.database.PostBuilder;
+
+/**
+ * {@link PostBuilder} implementation that returns a mocked {@link Post}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class TestPostBuilder implements PostBuilder {
+
+       private final Post post = mock(Post.class);
+       private String recipientId = null;
+
+       @Override
+       public PostBuilder copyPost(Post post) throws NullPointerException {
+               return this;
+       }
+
+       @Override
+       public PostBuilder from(String senderId) {
+               final Sone sone = mock(Sone.class);
+               when(sone.getId()).thenReturn(senderId);
+               when(post.getSone()).thenReturn(sone);
+               return this;
+       }
+
+       @Override
+       public PostBuilder randomId() {
+               when(post.getId()).thenReturn(randomUUID().toString());
+               return this;
+       }
+
+       @Override
+       public PostBuilder withId(String id) {
+               when(post.getId()).thenReturn(id);
+               return this;
+       }
+
+       @Override
+       public PostBuilder currentTime() {
+               when(post.getTime()).thenReturn(currentTimeMillis());
+               return this;
+       }
+
+       @Override
+       public PostBuilder withTime(long time) {
+               when(post.getTime()).thenReturn(time);
+               return this;
+       }
+
+       @Override
+       public PostBuilder withText(String text) {
+               when(post.getText()).thenReturn(text);
+               return this;
+       }
+
+       @Override
+       public PostBuilder to(String recipientId) {
+               this.recipientId = recipientId;
+               return this;
+       }
+
+       @Override
+       public Post build() throws IllegalStateException {
+               when(post.getRecipientId()).thenReturn(fromNullable(recipientId));
+               return post;
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/TestPostReplyBuilder.java b/src/test/java/net/pterodactylus/sone/TestPostReplyBuilder.java
new file mode 100644 (file)
index 0000000..6b929c7
--- /dev/null
@@ -0,0 +1,70 @@
+package net.pterodactylus.sone;
+
+import static java.lang.System.currentTimeMillis;
+import static java.util.UUID.randomUUID;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import net.pterodactylus.sone.data.PostReply;
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.sone.database.PostReplyBuilder;
+
+/**
+ * {@link PostReplyBuilder} that returns a mocked {@link PostReply}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class TestPostReplyBuilder implements PostReplyBuilder {
+
+       private final PostReply postReply = mock(PostReply.class);
+
+       @Override
+       public PostReplyBuilder to(String postId) {
+               when(postReply.getPostId()).thenReturn(postId);
+               return this;
+       }
+
+       @Override
+       public PostReply build() throws IllegalStateException {
+               return postReply;
+       }
+
+       @Override
+       public PostReplyBuilder randomId() {
+               when(postReply.getId()).thenReturn(randomUUID().toString());
+               return this;
+       }
+
+       @Override
+       public PostReplyBuilder withId(String id) {
+               when(postReply.getId()).thenReturn(id);
+               return this;
+       }
+
+       @Override
+       public PostReplyBuilder from(String senderId) {
+               Sone sone = mock(Sone.class);
+               when(sone.getId()).thenReturn(senderId);
+               when(postReply.getSone()).thenReturn(sone);
+               return this;
+       }
+
+       @Override
+       public PostReplyBuilder currentTime() {
+               when(postReply.getTime()).thenReturn(currentTimeMillis());
+               return this;
+       }
+
+       @Override
+       public PostReplyBuilder withTime(long time) {
+               when(postReply.getTime()).thenReturn(time);
+               return this;
+       }
+
+       @Override
+       public PostReplyBuilder withText(String text) {
+               when(postReply.getText()).thenReturn(text);
+               return this;
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/TestUtil.java b/src/test/java/net/pterodactylus/sone/TestUtil.java
new file mode 100644 (file)
index 0000000..fa7879d
--- /dev/null
@@ -0,0 +1,56 @@
+package net.pterodactylus.sone;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+
+/**
+ * Utilities for testing.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class TestUtil {
+
+       public static void setFinalField(Object object, String fieldName, Object value) {
+               try {
+                       Field clientCoreField = object.getClass().getField(fieldName);
+                       clientCoreField.setAccessible(true);
+                       Field modifiersField = Field.class.getDeclaredField("modifiers");
+                       modifiersField.setAccessible(true);
+                       modifiersField.setInt(clientCoreField, clientCoreField.getModifiers() & ~Modifier.FINAL);
+                       clientCoreField.set(object, value);
+               } catch (NoSuchFieldException e) {
+                       throw new RuntimeException(e);
+               } catch (IllegalAccessException e) {
+                       throw new RuntimeException(e);
+               }
+       }
+
+       public static <T> T getPrivateField(Object object, String fieldName) {
+               try {
+                       Field field = object.getClass().getDeclaredField(fieldName);
+                       field.setAccessible(true);
+                       return (T) field.get(object);
+               } catch (NoSuchFieldException e) {
+                       throw new RuntimeException(e);
+               } catch (IllegalAccessException e) {
+                       throw new RuntimeException(e);
+               }
+       }
+
+       public static <T> T callPrivateMethod(Object object, String methodName) {
+               try {
+                       Method method = object.getClass().getDeclaredMethod(methodName, new Class[0]);
+                       method.setAccessible(true);
+                       return (T) method.invoke(object);
+               } catch (NoSuchMethodException e) {
+                       throw new RuntimeException(e);
+               } catch (InvocationTargetException e) {
+                       throw new RuntimeException(e);
+               } catch (IllegalAccessException e) {
+                       throw new RuntimeException(e);
+               }
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/TestValue.java b/src/test/java/net/pterodactylus/sone/TestValue.java
new file mode 100644 (file)
index 0000000..43cf0a6
--- /dev/null
@@ -0,0 +1,59 @@
+package net.pterodactylus.sone;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+import net.pterodactylus.util.config.ConfigurationException;
+import net.pterodactylus.util.config.Value;
+
+import com.google.common.base.Objects;
+
+/**
+ * Simple {@link Value} implementation.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class TestValue<T> implements Value<T> {
+
+       private final AtomicReference<T> value = new AtomicReference<T>();
+
+       public TestValue(T originalValue) {
+               value.set(originalValue);
+       }
+
+       @Override
+       public T getValue() throws ConfigurationException {
+               return value.get();
+       }
+
+       @Override
+       public T getValue(T defaultValue) {
+               final T realValue = value.get();
+               return (realValue != null) ? realValue : defaultValue;
+       }
+
+       @Override
+       public void setValue(T newValue) throws ConfigurationException {
+               value.set(newValue);
+       }
+
+       @Override
+       public int hashCode() {
+               return value.hashCode();
+       }
+
+       @Override
+       public boolean equals(Object obj) {
+               return (obj instanceof TestValue) && Objects.equal(value.get(),
+                               ((TestValue) obj).value.get());
+       }
+
+       @Override
+       public String toString() {
+               return String.valueOf(value.get());
+       }
+
+       public static <T> Value<T> from(T value) {
+               return new TestValue<T>(value);
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/core/ConfigurationSoneParserTest.java b/src/test/java/net/pterodactylus/sone/core/ConfigurationSoneParserTest.java
new file mode 100644 (file)
index 0000000..7bbfae8
--- /dev/null
@@ -0,0 +1,524 @@
+package net.pterodactylus.sone.core;
+
+import static com.google.common.base.Optional.of;
+import static net.pterodactylus.sone.Matchers.isAlbum;
+import static net.pterodactylus.sone.Matchers.isImage;
+import static net.pterodactylus.sone.Matchers.isPost;
+import static net.pterodactylus.sone.Matchers.isPostReply;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.emptyIterable;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.nullValue;
+import static org.mockito.Matchers.anyString;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import net.pterodactylus.sone.TestAlbumBuilder;
+import net.pterodactylus.sone.TestImageBuilder;
+import net.pterodactylus.sone.TestPostBuilder;
+import net.pterodactylus.sone.TestPostReplyBuilder;
+import net.pterodactylus.sone.TestValue;
+import net.pterodactylus.sone.core.ConfigurationSoneParser.InvalidAlbumFound;
+import net.pterodactylus.sone.core.ConfigurationSoneParser.InvalidImageFound;
+import net.pterodactylus.sone.core.ConfigurationSoneParser.InvalidParentAlbumFound;
+import net.pterodactylus.sone.core.ConfigurationSoneParser.InvalidPostFound;
+import net.pterodactylus.sone.core.ConfigurationSoneParser.InvalidPostReplyFound;
+import net.pterodactylus.sone.data.Album;
+import net.pterodactylus.sone.data.Post;
+import net.pterodactylus.sone.data.PostReply;
+import net.pterodactylus.sone.data.Profile;
+import net.pterodactylus.sone.data.Profile.Field;
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.sone.database.AlbumBuilder;
+import net.pterodactylus.sone.database.AlbumBuilderFactory;
+import net.pterodactylus.sone.database.ImageBuilder;
+import net.pterodactylus.sone.database.ImageBuilderFactory;
+import net.pterodactylus.sone.database.PostBuilder;
+import net.pterodactylus.sone.database.PostBuilderFactory;
+import net.pterodactylus.sone.database.PostReplyBuilder;
+import net.pterodactylus.sone.database.PostReplyBuilderFactory;
+import net.pterodactylus.util.config.Configuration;
+
+import com.google.common.base.Optional;
+import org.hamcrest.Matchers;
+import org.junit.Test;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+
+/**
+ * Unit test for {@link ConfigurationSoneParser}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class ConfigurationSoneParserTest {
+
+       private final Configuration configuration = mock(Configuration.class);
+       private final Sone sone = mock(Sone.class);
+       private final ConfigurationSoneParser configurationSoneParser;
+
+       public ConfigurationSoneParserTest() {
+               when(sone.getId()).thenReturn("1");
+               configurationSoneParser =
+                               new ConfigurationSoneParser(configuration, sone);
+       }
+
+       @Test
+       public void emptyProfileIsLoadedCorrectly() {
+               setupEmptyProfile();
+               Profile profile = configurationSoneParser.parseProfile();
+               assertThat(profile, notNullValue());
+               assertThat(profile.getFirstName(), nullValue());
+               assertThat(profile.getMiddleName(), nullValue());
+               assertThat(profile.getLastName(), nullValue());
+               assertThat(profile.getBirthDay(), nullValue());
+               assertThat(profile.getBirthMonth(), nullValue());
+               assertThat(profile.getBirthYear(), nullValue());
+               assertThat(profile.getFields(), emptyIterable());
+       }
+
+       private void setupEmptyProfile() {
+               when(configuration.getStringValue(anyString())).thenReturn(
+                               TestValue.<String>from(null));
+               when(configuration.getIntValue(anyString())).thenReturn(
+                               TestValue.<Integer>from(null));
+       }
+
+       @Test
+       public void filledProfileWithFieldsIsParsedCorrectly() {
+               setupFilledProfile();
+               Profile profile = configurationSoneParser.parseProfile();
+               assertThat(profile, notNullValue());
+               assertThat(profile.getFirstName(), is("First"));
+               assertThat(profile.getMiddleName(), is("M."));
+               assertThat(profile.getLastName(), is("Last"));
+               assertThat(profile.getBirthDay(), is(18));
+               assertThat(profile.getBirthMonth(), is(12));
+               assertThat(profile.getBirthYear(), is(1976));
+               final List<Field> fields = profile.getFields();
+               assertThat(fields, hasSize(2));
+               assertThat(fields.get(0).getName(), is("Field1"));
+               assertThat(fields.get(0).getValue(), is("Value1"));
+               assertThat(fields.get(1).getName(), is("Field2"));
+               assertThat(fields.get(1).getValue(), is("Value2"));
+       }
+
+       private void setupFilledProfile() {
+               setupString("Sone/1/Profile/FirstName", "First");
+               setupString("Sone/1/Profile/MiddleName", "M.");
+               setupString("Sone/1/Profile/LastName", "Last");
+               setupInteger("Sone/1/Profile/BirthDay", 18);
+               setupInteger("Sone/1/Profile/BirthMonth", 12);
+               setupInteger("Sone/1/Profile/BirthYear", 1976);
+               setupString("Sone/1/Profile/Fields/0/Name", "Field1");
+               setupString("Sone/1/Profile/Fields/0/Value", "Value1");
+               setupString("Sone/1/Profile/Fields/1/Name", "Field2");
+               setupString("Sone/1/Profile/Fields/1/Value", "Value2");
+               setupString("Sone/1/Profile/Fields/2/Name", null);
+       }
+
+       private void setupString(String nodeName, String value) {
+               when(configuration.getStringValue(eq(nodeName))).thenReturn(
+                               TestValue.from(value));
+       }
+
+       private void setupInteger(String nodeName, Integer value) {
+               when(configuration.getIntValue(eq(nodeName))).thenReturn(
+                               TestValue.from(value));
+       }
+
+       @Test
+       public void postsAreParsedCorrectly() {
+               setupCompletePosts();
+               PostBuilderFactory postBuilderFactory = createPostBuilderFactory();
+               Collection<Post> posts =
+                               configurationSoneParser.parsePosts(postBuilderFactory);
+               assertThat(posts,
+                               Matchers.<Post>containsInAnyOrder(
+                                               isPost("P0", 1000L, "T0", Optional.<String>absent()),
+                                               isPost("P1", 1001L, "T1",
+                                                               of("1234567890123456789012345678901234567890123"))));
+       }
+
+       private PostBuilderFactory createPostBuilderFactory() {
+               PostBuilderFactory postBuilderFactory =
+                               mock(PostBuilderFactory.class);
+               when(postBuilderFactory.newPostBuilder()).thenAnswer(
+                               new Answer<PostBuilder>() {
+                                       @Override
+                                       public PostBuilder answer(InvocationOnMock invocation)
+                                       throws Throwable {
+                                               return new TestPostBuilder();
+                                       }
+                               });
+               return postBuilderFactory;
+       }
+
+       private void setupCompletePosts() {
+               setupPost("0", "P0", 1000L, "T0", null);
+               setupPost("1", "P1", 1001L, "T1",
+                               "1234567890123456789012345678901234567890123");
+               setupPost("2", null, 0L, null, null);
+       }
+
+       private void setupPost(String postNumber, String postId, long time,
+                       String text, String recipientId) {
+               setupString("Sone/1/Posts/" + postNumber + "/ID", postId);
+               setupLong("Sone/1/Posts/" + postNumber + "/Time", time);
+               setupString("Sone/1/Posts/" + postNumber + "/Text", text);
+               setupString("Sone/1/Posts/" + postNumber + "/Recipient", recipientId);
+       }
+
+       private void setupLong(String nodeName, Long value) {
+               when(configuration.getLongValue(eq(nodeName))).thenReturn(
+                               TestValue.from(value));
+       }
+
+       @Test(expected = InvalidPostFound.class)
+       public void postWithoutTimeIsRecognized() {
+               setupPostWithoutTime();
+               configurationSoneParser.parsePosts(createPostBuilderFactory());
+       }
+
+       private void setupPostWithoutTime() {
+               setupPost("0", "P0", 0L, "T0", null);
+       }
+
+       @Test(expected = InvalidPostFound.class)
+       public void postWithoutTextIsRecognized() {
+               setupPostWithoutText();
+               configurationSoneParser.parsePosts(createPostBuilderFactory());
+       }
+
+       private void setupPostWithoutText() {
+               setupPost("0", "P0", 1000L, null, null);
+       }
+
+       @Test
+       public void postWithInvalidRecipientIdIsRecognized() {
+               setupPostWithInvalidRecipientId();
+               Collection<Post> posts = configurationSoneParser.parsePosts(
+                               createPostBuilderFactory());
+               assertThat(posts, contains(
+                               isPost("P0", 1000L, "T0", Optional.<String>absent())));
+       }
+
+       private void setupPostWithInvalidRecipientId() {
+               setupPost("0", "P0", 1000L, "T0", "123");
+               setupPost("1", null, 0L, null, null);
+       }
+
+       @Test
+       public void postRepliesAreParsedCorrectly() {
+               setupPostReplies();
+               PostReplyBuilderFactory postReplyBuilderFactory =
+                               new PostReplyBuilderFactory() {
+                                       @Override
+                                       public PostReplyBuilder newPostReplyBuilder() {
+                                               return new TestPostReplyBuilder();
+                                       }
+                               };
+               Collection<PostReply> postReplies =
+                               configurationSoneParser.parsePostReplies(
+                                               postReplyBuilderFactory);
+               assertThat(postReplies, hasSize(2));
+               assertThat(postReplies,
+                               containsInAnyOrder(isPostReply("R0", "P0", 1000L, "T0"),
+                                               isPostReply("R1", "P1", 1001L, "T1")));
+       }
+
+       private void setupPostReplies() {
+               setupPostReply("0", "R0", "P0", 1000L, "T0");
+               setupPostReply("1", "R1", "P1", 1001L, "T1");
+               setupPostReply("2", null, null, 0L, null);
+       }
+
+       private void setupPostReply(String postReplyNumber, String postReplyId,
+                       String postId, long time, String text) {
+               setupString("Sone/1/Replies/" + postReplyNumber + "/ID", postReplyId);
+               setupString("Sone/1/Replies/" + postReplyNumber + "/Post/ID", postId);
+               setupLong("Sone/1/Replies/" + postReplyNumber + "/Time", time);
+               setupString("Sone/1/Replies/" + postReplyNumber + "/Text", text);
+       }
+
+       @Test(expected = InvalidPostReplyFound.class)
+       public void missingPostIdIsRecognized() {
+               setupPostReplyWithMissingPostId();
+               configurationSoneParser.parsePostReplies(null);
+       }
+
+       private void setupPostReplyWithMissingPostId() {
+               setupPostReply("0", "R0", null, 1000L, "T0");
+       }
+
+       @Test(expected = InvalidPostReplyFound.class)
+       public void missingPostReplyTimeIsRecognized() {
+               setupPostReplyWithMissingPostReplyTime();
+               configurationSoneParser.parsePostReplies(null);
+       }
+
+       private void setupPostReplyWithMissingPostReplyTime() {
+               setupPostReply("0", "R0", "P0", 0L, "T0");
+       }
+
+       @Test(expected = InvalidPostReplyFound.class)
+       public void missingPostReplyTextIsRecognized() {
+               setupPostReplyWithMissingPostReplyText();
+               configurationSoneParser.parsePostReplies(null);
+       }
+
+       private void setupPostReplyWithMissingPostReplyText() {
+               setupPostReply("0", "R0", "P0", 1000L, null);
+       }
+
+       @Test
+       public void likedPostIdsParsedCorrectly() {
+               setupLikedPostIds();
+               Set<String> likedPostIds =
+                               configurationSoneParser.parseLikedPostIds();
+               assertThat(likedPostIds, containsInAnyOrder("P1", "P2", "P3"));
+       }
+
+       private void setupLikedPostIds() {
+               setupString("Sone/1/Likes/Post/0/ID", "P1");
+               setupString("Sone/1/Likes/Post/1/ID", "P2");
+               setupString("Sone/1/Likes/Post/2/ID", "P3");
+               setupString("Sone/1/Likes/Post/3/ID", null);
+       }
+
+       @Test
+       public void likedPostReplyIdsAreParsedCorrectly() {
+               setupLikedPostReplyIds();
+               Set<String> likedPostReplyIds =
+                               configurationSoneParser.parseLikedPostReplyIds();
+               assertThat(likedPostReplyIds, containsInAnyOrder("R1", "R2", "R3"));
+       }
+
+       private void setupLikedPostReplyIds() {
+               setupString("Sone/1/Likes/Reply/0/ID", "R1");
+               setupString("Sone/1/Likes/Reply/1/ID", "R2");
+               setupString("Sone/1/Likes/Reply/2/ID", "R3");
+               setupString("Sone/1/Likes/Reply/3/ID", null);
+       }
+
+       @Test
+       public void friendsAreParsedCorrectly() {
+               setupFriends();
+               Set<String> friends = configurationSoneParser.parseFriends();
+               assertThat(friends, containsInAnyOrder("F1", "F2", "F3"));
+       }
+
+       private void setupFriends() {
+               setupString("Sone/1/Friends/0/ID", "F1");
+               setupString("Sone/1/Friends/1/ID", "F2");
+               setupString("Sone/1/Friends/2/ID", "F3");
+               setupString("Sone/1/Friends/3/ID", null);
+       }
+
+       @Test
+       public void topLevelAlbumsAreParsedCorrectly() {
+               setupTopLevelAlbums();
+               AlbumBuilderFactory albumBuilderFactory = createAlbumBuilderFactory();
+               List<Album> topLevelAlbums =
+                               configurationSoneParser.parseTopLevelAlbums(
+                                               albumBuilderFactory);
+               assertThat(topLevelAlbums, hasSize(2));
+               Album firstAlbum = topLevelAlbums.get(0);
+               assertThat(firstAlbum, isAlbum("A1", null, "T1", "D1", "I1"));
+               assertThat(firstAlbum.getAlbums(), emptyIterable());
+               assertThat(firstAlbum.getImages(), emptyIterable());
+               Album secondAlbum = topLevelAlbums.get(1);
+               assertThat(secondAlbum, isAlbum("A2", null, "T2", "D2", null));
+               assertThat(secondAlbum.getAlbums(), hasSize(1));
+               assertThat(secondAlbum.getImages(), emptyIterable());
+               Album thirdAlbum = secondAlbum.getAlbums().get(0);
+               assertThat(thirdAlbum, isAlbum("A3", "A2", "T3", "D3", "I3"));
+               assertThat(thirdAlbum.getAlbums(), emptyIterable());
+               assertThat(thirdAlbum.getImages(), emptyIterable());
+       }
+
+       private void setupTopLevelAlbums() {
+               setupAlbum(0, "A1", null, "T1", "D1", "I1");
+               setupAlbum(1, "A2", null, "T2", "D2", null);
+               setupAlbum(2, "A3", "A2", "T3", "D3", "I3");
+               setupAlbum(3, null, null, null, null, null);
+       }
+
+       private void setupAlbum(int albumNumber, String albumId,
+                       String parentAlbumId,
+                       String title, String description, String imageId) {
+               final String albumPrefix = "Sone/1/Albums/" + albumNumber;
+               setupString(albumPrefix + "/ID", albumId);
+               setupString(albumPrefix + "/Title", title);
+               setupString(albumPrefix + "/Description", description);
+               setupString(albumPrefix + "/Parent", parentAlbumId);
+               setupString(albumPrefix + "/AlbumImage", imageId);
+       }
+
+       private AlbumBuilderFactory createAlbumBuilderFactory() {
+               AlbumBuilderFactory albumBuilderFactory =
+                               mock(AlbumBuilderFactory.class);
+               when(albumBuilderFactory.newAlbumBuilder()).thenAnswer(
+                               new Answer<AlbumBuilder>() {
+                                       @Override
+                                       public AlbumBuilder answer(InvocationOnMock invocation) {
+                                               return new TestAlbumBuilder();
+                                       }
+                               });
+               return albumBuilderFactory;
+       }
+
+       @Test(expected = InvalidAlbumFound.class)
+       public void albumWithInvalidTitleIsRecognized() {
+               setupAlbum(0, "A1", null, null, "D1", "I1");
+               configurationSoneParser.parseTopLevelAlbums(
+                               createAlbumBuilderFactory());
+       }
+
+       @Test(expected = InvalidAlbumFound.class)
+       public void albumWithInvalidDescriptionIsRecognized() {
+               setupAlbum(0, "A1", null, "T1", null, "I1");
+               configurationSoneParser.parseTopLevelAlbums(
+                               createAlbumBuilderFactory());
+       }
+
+       @Test(expected = InvalidParentAlbumFound.class)
+       public void albumWithInvalidParentIsRecognized() {
+               setupAlbum(0, "A1", "A0", "T1", "D1", "I1");
+               configurationSoneParser.parseTopLevelAlbums(
+                               createAlbumBuilderFactory());
+       }
+
+       @Test
+       public void imagesAreParsedCorrectly() {
+               setupTopLevelAlbums();
+               configurationSoneParser.parseTopLevelAlbums(
+                               createAlbumBuilderFactory());
+               setupImages();
+               configurationSoneParser.parseImages(createImageBuilderFactory());
+               Map<String, Album> albums = configurationSoneParser.getAlbums();
+               assertThat(albums.get("A1").getImages(),
+                               contains(isImage("I1", 1000L, "K1", "T1", "D1", 16, 9)));
+               assertThat(albums.get("A2").getImages(), contains(
+                               isImage("I2", 2000L, "K2", "T2", "D2", 16 * 2, 9 * 2)));
+               assertThat(albums.get("A3").getImages(), contains(
+                               isImage("I3", 3000L, "K3", "T3", "D3", 16 * 3, 9 * 3)));
+       }
+
+       private void setupImages() {
+               setupImage(0, "I1", "A1", 1000L, "K1", "T1", "D1", 16, 9);
+               setupImage(1, "I2", "A2", 2000L, "K2", "T2", "D2", 16 * 2, 9 * 2);
+               setupImage(2, "I3", "A3", 3000L, "K3", "T3", "D3", 16 * 3, 9 * 3);
+               setupImage(3, null, null, 0L, null, null, null, 0, 0);
+       }
+
+       private void setupImage(int imageNumber, String id,
+                       String parentAlbumId, Long creationTime, String key, String title,
+                       String description, Integer width, Integer height) {
+               final String imagePrefix = "Sone/1/Images/" + imageNumber;
+               setupString(imagePrefix + "/ID", id);
+               setupString(imagePrefix + "/Album", parentAlbumId);
+               setupLong(imagePrefix + "/CreationTime", creationTime);
+               setupString(imagePrefix + "/Key", key);
+               setupString(imagePrefix + "/Title", title);
+               setupString(imagePrefix + "/Description", description);
+               setupInteger(imagePrefix + "/Width", width);
+               setupInteger(imagePrefix + "/Height", height);
+       }
+
+       private ImageBuilderFactory createImageBuilderFactory() {
+               ImageBuilderFactory imageBuilderFactory =
+                               mock(ImageBuilderFactory.class);
+               when(imageBuilderFactory.newImageBuilder()).thenAnswer(
+                               new Answer<ImageBuilder>() {
+                                       @Override
+                                       public ImageBuilder answer(InvocationOnMock invocation)
+                                       throws Throwable {
+                                               return new TestImageBuilder();
+                                       }
+                               });
+               return imageBuilderFactory;
+       }
+
+       @Test(expected = InvalidImageFound.class)
+       public void missingAlbumIdIsRecognized() {
+               setupTopLevelAlbums();
+               configurationSoneParser.parseTopLevelAlbums(
+                               createAlbumBuilderFactory());
+               setupImage(0, "I1", null, 1000L, "K1", "T1", "D1", 16, 9);
+               configurationSoneParser.parseImages(createImageBuilderFactory());
+       }
+
+       @Test(expected = InvalidParentAlbumFound.class)
+       public void invalidAlbumIdIsRecognized() {
+               setupTopLevelAlbums();
+               configurationSoneParser.parseTopLevelAlbums(
+                               createAlbumBuilderFactory());
+               setupImage(0, "I1", "A4", 1000L, "K1", "T1", "D1", 16, 9);
+               configurationSoneParser.parseImages(createImageBuilderFactory());
+       }
+
+       @Test(expected = InvalidImageFound.class)
+       public void missingCreationTimeIsRecognized() {
+               setupTopLevelAlbums();
+               configurationSoneParser.parseTopLevelAlbums(
+                               createAlbumBuilderFactory());
+               setupImage(0, "I1", "A1", null, "K1", "T1", "D1", 16, 9);
+               configurationSoneParser.parseImages(createImageBuilderFactory());
+       }
+
+       @Test(expected = InvalidImageFound.class)
+       public void missingKeyIsRecognized() {
+               setupTopLevelAlbums();
+               configurationSoneParser.parseTopLevelAlbums(
+                               createAlbumBuilderFactory());
+               setupImage(0, "I1", "A1", 1000L, null, "T1", "D1", 16, 9);
+               configurationSoneParser.parseImages(createImageBuilderFactory());
+       }
+
+       @Test(expected = InvalidImageFound.class)
+       public void missingTitleIsRecognized() {
+               setupTopLevelAlbums();
+               configurationSoneParser.parseTopLevelAlbums(
+                               createAlbumBuilderFactory());
+               setupImage(0, "I1", "A1", 1000L, "K1", null, "D1", 16, 9);
+               configurationSoneParser.parseImages(createImageBuilderFactory());
+       }
+
+       @Test(expected = InvalidImageFound.class)
+       public void missingDescriptionIsRecognized() {
+               setupTopLevelAlbums();
+               configurationSoneParser.parseTopLevelAlbums(
+                               createAlbumBuilderFactory());
+               setupImage(0, "I1", "A1", 1000L, "K1", "T1", null, 16, 9);
+               configurationSoneParser.parseImages(createImageBuilderFactory());
+       }
+
+       @Test(expected = InvalidImageFound.class)
+       public void missingWidthIsRecognized() {
+               setupTopLevelAlbums();
+               configurationSoneParser.parseTopLevelAlbums(
+                               createAlbumBuilderFactory());
+               setupImage(0, "I1", "A1", 1000L, "K1", "T1", "D1", null, 9);
+               configurationSoneParser.parseImages(createImageBuilderFactory());
+       }
+
+       @Test(expected = InvalidImageFound.class)
+       public void missingHeightIsRecognized() {
+               setupTopLevelAlbums();
+               configurationSoneParser.parseTopLevelAlbums(
+                               createAlbumBuilderFactory());
+               setupImage(0, "I1", "A1", 1000L, "K1", "T1", "D1", 16, null);
+               configurationSoneParser.parseImages(createImageBuilderFactory());
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/core/CoreTest.java b/src/test/java/net/pterodactylus/sone/core/CoreTest.java
new file mode 100644 (file)
index 0000000..263c2d0
--- /dev/null
@@ -0,0 +1,39 @@
+package net.pterodactylus.sone.core;
+
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import net.pterodactylus.sone.core.Core.MarkPostKnown;
+import net.pterodactylus.sone.core.Core.MarkReplyKnown;
+import net.pterodactylus.sone.data.Post;
+import net.pterodactylus.sone.data.PostReply;
+
+import org.junit.Test;
+
+/**
+ * Unit test for {@link Core} and its subclasses.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class CoreTest {
+
+       @Test
+       public void markPostKnownMarksPostAsKnown() {
+               Core core = mock(Core.class);
+               Post post = mock(Post.class);
+               MarkPostKnown markPostKnown = core.new MarkPostKnown(post);
+               markPostKnown.run();
+               verify(core).markPostKnown(eq(post));
+       }
+
+       @Test
+       public void markReplyKnownMarksReplyAsKnown() {
+               Core core = mock(Core.class);
+               PostReply postReply = mock(PostReply.class);
+               MarkReplyKnown markReplyKnown = core.new MarkReplyKnown(postReply);
+               markReplyKnown.run();
+               verify(core).markReplyKnown(eq(postReply));
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/core/FreenetInterfaceTest.java b/src/test/java/net/pterodactylus/sone/core/FreenetInterfaceTest.java
new file mode 100644 (file)
index 0000000..091c93f
--- /dev/null
@@ -0,0 +1,401 @@
+package net.pterodactylus.sone.core;
+
+import static freenet.keys.InsertableClientSSK.createRandom;
+import static freenet.node.RequestStarter.INTERACTIVE_PRIORITY_CLASS;
+import static freenet.node.RequestStarter.PREFETCH_PRIORITY_CLASS;
+import static net.pterodactylus.sone.Matchers.delivers;
+import static net.pterodactylus.sone.TestUtil.setFinalField;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.nullValue;
+import static org.mockito.ArgumentCaptor.forClass;
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyBoolean;
+import static org.mockito.Matchers.anyShort;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.withSettings;
+
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.util.HashMap;
+
+import net.pterodactylus.sone.TestUtil;
+import net.pterodactylus.sone.core.FreenetInterface.Callback;
+import net.pterodactylus.sone.core.FreenetInterface.Fetched;
+import net.pterodactylus.sone.core.FreenetInterface.InsertToken;
+import net.pterodactylus.sone.core.FreenetInterface.InsertTokenSupplier;
+import net.pterodactylus.sone.core.event.ImageInsertAbortedEvent;
+import net.pterodactylus.sone.core.event.ImageInsertFailedEvent;
+import net.pterodactylus.sone.core.event.ImageInsertFinishedEvent;
+import net.pterodactylus.sone.core.event.ImageInsertStartedEvent;
+import net.pterodactylus.sone.data.Image;
+import net.pterodactylus.sone.data.impl.ImageImpl;
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.sone.data.TemporaryImage;
+
+import freenet.client.ClientMetadata;
+import freenet.client.FetchException;
+import freenet.client.FetchException.FetchExceptionMode;
+import freenet.client.FetchResult;
+import freenet.client.HighLevelSimpleClient;
+import freenet.client.InsertBlock;
+import freenet.client.InsertContext;
+import freenet.client.InsertException;
+import freenet.client.InsertException.InsertExceptionMode;
+import freenet.client.async.ClientPutter;
+import freenet.client.async.USKCallback;
+import freenet.client.async.USKManager;
+import freenet.crypt.DummyRandomSource;
+import freenet.crypt.RandomSource;
+import freenet.keys.FreenetURI;
+import freenet.keys.InsertableClientSSK;
+import freenet.keys.USK;
+import freenet.node.Node;
+import freenet.node.NodeClientCore;
+import freenet.node.RequestClient;
+import freenet.support.Base64;
+import freenet.support.api.Bucket;
+import freenet.support.io.ArrayBucket;
+import freenet.support.io.ResumeFailedException;
+
+import com.google.common.eventbus.EventBus;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+/**
+ * Unit test for {@link FreenetInterface}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class FreenetInterfaceTest {
+
+       private final EventBus eventBus = mock(EventBus.class);
+       private final Node node = mock(Node.class);
+       private final NodeClientCore nodeClientCore = mock(NodeClientCore.class);
+       private final HighLevelSimpleClient highLevelSimpleClient = mock(HighLevelSimpleClient.class, withSettings().extraInterfaces(RequestClient.class));
+       private final RandomSource randomSource = new DummyRandomSource();
+       private final USKManager uskManager = mock(USKManager.class);
+       private FreenetInterface freenetInterface;
+       private final Sone sone = mock(Sone.class);
+       private final ArgumentCaptor<USKCallback> callbackCaptor = forClass(USKCallback.class);
+       private final Image image = mock(Image.class);
+       private InsertToken insertToken;
+       private final Bucket bucket = mock(Bucket.class);
+
+       @Before
+       public void setupFreenetInterface() {
+               when(nodeClientCore.makeClient(anyShort(), anyBoolean(), anyBoolean())).thenReturn(highLevelSimpleClient);
+               setFinalField(node, "clientCore", nodeClientCore);
+               setFinalField(node, "random", randomSource);
+               setFinalField(nodeClientCore, "uskManager", uskManager);
+               freenetInterface = new FreenetInterface(eventBus, node);
+               insertToken = freenetInterface.new InsertToken(image);
+               insertToken.setBucket(bucket);
+       }
+
+       @Before
+       public void setupSone() {
+               InsertableClientSSK insertSsk = createRandom(randomSource, "test-0");
+               when(sone.getId()).thenReturn(Base64.encode(insertSsk.getURI().getRoutingKey()));
+               when(sone.getRequestUri()).thenReturn(insertSsk.getURI().uskForSSK());
+       }
+
+       @Before
+       public void setupCallbackCaptorAndUskManager() {
+               doNothing().when(uskManager).subscribe(any(USK.class), callbackCaptor.capture(), anyBoolean(), any(RequestClient.class));
+       }
+
+       @Test
+       public void canFetchUri() throws MalformedURLException, FetchException {
+               FreenetURI freenetUri = new FreenetURI("KSK@GPLv3.txt");
+               FetchResult fetchResult = createFetchResult();
+               when(highLevelSimpleClient.fetch(freenetUri)).thenReturn(fetchResult);
+               Fetched fetched = freenetInterface.fetchUri(freenetUri);
+               assertThat(fetched, notNullValue());
+               assertThat(fetched.getFetchResult(), is(fetchResult));
+               assertThat(fetched.getFreenetUri(), is(freenetUri));
+       }
+
+       @Test
+       public void fetchFollowsRedirect() throws MalformedURLException, FetchException {
+               FreenetURI freenetUri = new FreenetURI("KSK@GPLv2.txt");
+               FreenetURI newFreenetUri = new FreenetURI("KSK@GPLv3.txt");
+               FetchResult fetchResult = createFetchResult();
+               FetchException fetchException = new FetchException(FetchExceptionMode.PERMANENT_REDIRECT, newFreenetUri);
+               when(highLevelSimpleClient.fetch(freenetUri)).thenThrow(fetchException);
+               when(highLevelSimpleClient.fetch(newFreenetUri)).thenReturn(fetchResult);
+               Fetched fetched = freenetInterface.fetchUri(freenetUri);
+               assertThat(fetched.getFetchResult(), is(fetchResult));
+               assertThat(fetched.getFreenetUri(), is(newFreenetUri));
+       }
+
+       @Test
+       public void fetchReturnsNullOnFetchExceptions() throws MalformedURLException, FetchException {
+               FreenetURI freenetUri = new FreenetURI("KSK@GPLv2.txt");
+               FetchException fetchException = new FetchException(FetchExceptionMode.ALL_DATA_NOT_FOUND);
+               when(highLevelSimpleClient.fetch(freenetUri)).thenThrow(fetchException);
+               Fetched fetched = freenetInterface.fetchUri(freenetUri);
+               assertThat(fetched, nullValue());
+       }
+
+       private FetchResult createFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("text/plain");
+               Bucket bucket = new ArrayBucket("Some Data.".getBytes());
+               return new FetchResult(clientMetadata, bucket);
+       }
+
+       @Test
+       public void insertingAnImage() throws SoneException, InsertException, IOException {
+               TemporaryImage temporaryImage = new TemporaryImage("image-id");
+               temporaryImage.setMimeType("image/png");
+               byte[] imageData = new byte[] { 1, 2, 3, 4 };
+               temporaryImage.setImageData(imageData);
+               Image image = new ImageImpl("image-id");
+               InsertToken insertToken = freenetInterface.new InsertToken(image);
+               InsertContext insertContext = mock(InsertContext.class);
+               when(highLevelSimpleClient.getInsertContext(anyBoolean())).thenReturn(insertContext);
+               ClientPutter clientPutter = mock(ClientPutter.class);
+               ArgumentCaptor<InsertBlock> insertBlockCaptor = forClass(InsertBlock.class);
+               when(highLevelSimpleClient.insert(insertBlockCaptor.capture(), eq((String) null), eq(false), eq(insertContext), eq(insertToken), anyShort())).thenReturn(clientPutter);
+               freenetInterface.insertImage(temporaryImage, image, insertToken);
+               assertThat(insertBlockCaptor.getValue().getData().getInputStream(), delivers(new byte[] { 1, 2, 3, 4 }));
+               assertThat(TestUtil.<ClientPutter>getPrivateField(insertToken, "clientPutter"), is(clientPutter));
+               verify(eventBus).post(any(ImageInsertStartedEvent.class));
+       }
+
+       @Test(expected = SoneInsertException.class)
+       public void insertExceptionCausesASoneException() throws InsertException, SoneException, IOException {
+               TemporaryImage temporaryImage = new TemporaryImage("image-id");
+               temporaryImage.setMimeType("image/png");
+               byte[] imageData = new byte[] { 1, 2, 3, 4 };
+               temporaryImage.setImageData(imageData);
+               Image image = new ImageImpl("image-id");
+               InsertToken insertToken = freenetInterface.new InsertToken(image);
+               InsertContext insertContext = mock(InsertContext.class);
+               when(highLevelSimpleClient.getInsertContext(anyBoolean())).thenReturn(insertContext);
+               ArgumentCaptor<InsertBlock> insertBlockCaptor = forClass(InsertBlock.class);
+               when(highLevelSimpleClient.insert(insertBlockCaptor.capture(), eq((String) null), eq(false), eq(insertContext), eq(insertToken), anyShort())).thenThrow(InsertException.class);
+               freenetInterface.insertImage(temporaryImage, image, insertToken);
+       }
+
+       @Test
+       public void insertingADirectory() throws InsertException, SoneException {
+               FreenetURI freenetUri = mock(FreenetURI.class);
+               HashMap<String, Object> manifestEntries = new HashMap<String, Object>();
+               String defaultFile = "index.html";
+               FreenetURI resultingUri = mock(FreenetURI.class);
+               when(highLevelSimpleClient.insertManifest(eq(freenetUri), eq(manifestEntries), eq(defaultFile))).thenReturn(resultingUri);
+               assertThat(freenetInterface.insertDirectory(freenetUri, manifestEntries, defaultFile), is(resultingUri));
+       }
+
+       @Test(expected = SoneException.class)
+       public void insertExceptionIsForwardedAsSoneException() throws InsertException, SoneException {
+               when(highLevelSimpleClient.insertManifest(any(FreenetURI.class), any(HashMap.class), any(String.class))).thenThrow(InsertException.class);
+               freenetInterface.insertDirectory(null, null, null);
+       }
+
+       @Test
+       public void soneWithWrongRequestUriWillNotBeSubscribed() throws MalformedURLException {
+               when(sone.getRequestUri()).thenReturn(new FreenetURI("KSK@GPLv3.txt"));
+               freenetInterface.registerUsk(new FreenetURI("KSK@GPLv3.txt"), null);
+               verify(uskManager, never()).subscribe(any(USK.class), any(USKCallback.class), anyBoolean(), any(RequestClient.class));
+       }
+
+       @Test
+       public void registeringAUsk() {
+               FreenetURI freenetUri = createRandom(randomSource, "test-0").getURI().uskForSSK();
+               Callback callback = mock(Callback.class);
+               freenetInterface.registerUsk(freenetUri, callback);
+               verify(uskManager).subscribe(any(USK.class), any(USKCallback.class), anyBoolean(), eq((RequestClient) highLevelSimpleClient));
+       }
+
+       @Test
+       public void registeringANonUskKeyWillNotBeSubscribed() throws MalformedURLException {
+               FreenetURI freenetUri = new FreenetURI("KSK@GPLv3.txt");
+               Callback callback = mock(Callback.class);
+               freenetInterface.registerUsk(freenetUri, callback);
+               verify(uskManager, never()).subscribe(any(USK.class), any(USKCallback.class), anyBoolean(), eq((RequestClient) highLevelSimpleClient));
+       }
+
+       @Test
+       public void registeringAnActiveUskWillSubscribeToItCorrectly() {
+               FreenetURI freenetUri = createRandom(randomSource, "test-0").getURI().uskForSSK();
+               final USKCallback uskCallback = mock(USKCallback.class);
+               freenetInterface.registerActiveUsk(freenetUri, uskCallback);
+               verify(uskManager).subscribe(any(USK.class), eq(uskCallback), eq(true), any(RequestClient.class));
+       }
+
+       @Test
+       public void registeringAnInactiveUskWillSubscribeToItCorrectly() {
+               FreenetURI freenetUri = createRandom(randomSource, "test-0").getURI().uskForSSK();
+               final USKCallback uskCallback = mock(USKCallback.class);
+               freenetInterface.registerPassiveUsk(freenetUri, uskCallback);
+               verify(uskManager).subscribe(any(USK.class), eq(uskCallback), eq(false), any(RequestClient.class));
+       }
+
+       @Test
+       public void registeringAnActiveNonUskWillNotSubscribeToAUsk()
+       throws MalformedURLException {
+               FreenetURI freenetUri = createRandom(randomSource, "test-0").getURI();
+           freenetInterface.registerActiveUsk(freenetUri, null);
+               verify(uskManager, never()).subscribe(any(USK.class),
+                               any(USKCallback.class), anyBoolean(),
+                               eq((RequestClient) highLevelSimpleClient));
+       }
+
+       @Test
+       public void registeringAnInactiveNonUskWillNotSubscribeToAUsk()
+       throws MalformedURLException {
+               FreenetURI freenetUri = createRandom(randomSource, "test-0").getURI();
+           freenetInterface.registerPassiveUsk(freenetUri, null);
+               verify(uskManager, never()).subscribe(any(USK.class),
+                               any(USKCallback.class), anyBoolean(),
+                               eq((RequestClient) highLevelSimpleClient));
+       }
+
+       @Test
+       public void unregisteringANotRegisteredUskDoesNothing() {
+               FreenetURI freenetURI = createRandom(randomSource, "test-0").getURI().uskForSSK();
+               freenetInterface.unregisterUsk(freenetURI);
+               verify(uskManager, never()).unsubscribe(any(USK.class), any(USKCallback.class));
+       }
+
+       @Test
+       public void unregisteringARegisteredUsk() {
+               FreenetURI freenetURI = createRandom(randomSource, "test-0").getURI().uskForSSK();
+               Callback callback = mock(Callback.class);
+               freenetInterface.registerUsk(freenetURI, callback);
+               freenetInterface.unregisterUsk(freenetURI);
+               verify(uskManager).unsubscribe(any(USK.class), any(USKCallback.class));
+       }
+
+       @Test
+       public void unregisteringANotRegisteredSoneDoesNothing() {
+               freenetInterface.unregisterUsk(sone);
+               verify(uskManager, never()).unsubscribe(any(USK.class), any(USKCallback.class));
+       }
+
+       @Test
+       public void unregisteringARegisteredSoneUnregistersTheSone()
+       throws MalformedURLException {
+               freenetInterface.registerActiveUsk(sone.getRequestUri(), mock(USKCallback.class));
+               freenetInterface.unregisterUsk(sone);
+               verify(uskManager).unsubscribe(any(USK.class), any(USKCallback.class));
+       }
+
+       @Test
+       public void unregisteringASoneWithAWrongRequestKeyWillNotUnsubscribe() throws MalformedURLException {
+               when(sone.getRequestUri()).thenReturn(new FreenetURI("KSK@GPLv3.txt"));
+               freenetInterface.registerUsk(sone.getRequestUri(), null);
+               freenetInterface.unregisterUsk(sone);
+               verify(uskManager, never()).unsubscribe(any(USK.class), any(USKCallback.class));
+       }
+
+       @Test
+       public void callbackForNormalUskUsesDifferentPriorities() {
+               Callback callback = mock(Callback.class);
+               FreenetURI soneUri = createRandom(randomSource, "test-0").getURI().uskForSSK();
+               freenetInterface.registerUsk(soneUri, callback);
+               assertThat(callbackCaptor.getValue().getPollingPriorityNormal(), is(PREFETCH_PRIORITY_CLASS));
+               assertThat(callbackCaptor.getValue().getPollingPriorityProgress(), is(INTERACTIVE_PRIORITY_CLASS));
+       }
+
+       @Test
+       public void callbackForNormalUskForwardsImportantParameters() throws MalformedURLException {
+               Callback callback = mock(Callback.class);
+               FreenetURI uri = createRandom(randomSource, "test-0").getURI().uskForSSK();
+               freenetInterface.registerUsk(uri, callback);
+               USK key = mock(USK.class);
+               when(key.getURI()).thenReturn(uri);
+               callbackCaptor.getValue().onFoundEdition(3, key, null, false, (short) 0, null, true, true);
+               verify(callback).editionFound(eq(uri), eq(3L), eq(true), eq(true));
+       }
+
+       @Test
+       public void fetchedRetainsUriAndFetchResult() {
+               FreenetURI freenetUri = mock(FreenetURI.class);
+               FetchResult fetchResult = mock(FetchResult.class);
+               Fetched fetched = new Fetched(freenetUri, fetchResult);
+               assertThat(fetched.getFreenetUri(), is(freenetUri));
+               assertThat(fetched.getFetchResult(), is(fetchResult));
+       }
+
+       @Test
+       public void cancellingAnInsertWillFireImageInsertAbortedEvent() {
+               ClientPutter clientPutter = mock(ClientPutter.class);
+               insertToken.setClientPutter(clientPutter);
+               ArgumentCaptor<ImageInsertStartedEvent> imageInsertStartedEvent = forClass(ImageInsertStartedEvent.class);
+               verify(eventBus).post(imageInsertStartedEvent.capture());
+               assertThat(imageInsertStartedEvent.getValue().image(), is(image));
+               insertToken.cancel();
+               ArgumentCaptor<ImageInsertAbortedEvent> imageInsertAbortedEvent = forClass(ImageInsertAbortedEvent.class);
+               verify(eventBus, times(2)).post(imageInsertAbortedEvent.capture());
+               verify(bucket).free();
+               assertThat(imageInsertAbortedEvent.getValue().image(), is(image));
+       }
+
+       @Test
+       public void failureWithoutExceptionSendsFailedEvent() {
+               insertToken.onFailure(null, null);
+               ArgumentCaptor<ImageInsertFailedEvent> imageInsertFailedEvent = forClass(ImageInsertFailedEvent.class);
+               verify(eventBus).post(imageInsertFailedEvent.capture());
+               verify(bucket).free();
+               assertThat(imageInsertFailedEvent.getValue().image(), is(image));
+               assertThat(imageInsertFailedEvent.getValue().cause(), nullValue());
+       }
+
+       @Test
+       public void failureSendsFailedEventWithException() {
+               InsertException insertException = new InsertException(InsertExceptionMode.INTERNAL_ERROR, "Internal error", null);
+               insertToken.onFailure(insertException, null);
+               ArgumentCaptor<ImageInsertFailedEvent> imageInsertFailedEvent = forClass(ImageInsertFailedEvent.class);
+               verify(eventBus).post(imageInsertFailedEvent.capture());
+               verify(bucket).free();
+               assertThat(imageInsertFailedEvent.getValue().image(), is(image));
+               assertThat(imageInsertFailedEvent.getValue().cause(), is((Throwable) insertException));
+       }
+
+       @Test
+       public void failureBecauseCancelledByUserSendsAbortedEvent() {
+               InsertException insertException = new InsertException(InsertExceptionMode.CANCELLED, null);
+               insertToken.onFailure(insertException, null);
+               ArgumentCaptor<ImageInsertAbortedEvent> imageInsertAbortedEvent = forClass(ImageInsertAbortedEvent.class);
+               verify(eventBus).post(imageInsertAbortedEvent.capture());
+               verify(bucket).free();
+               assertThat(imageInsertAbortedEvent.getValue().image(), is(image));
+       }
+
+       @Test
+       public void ignoredMethodsDoNotThrowExceptions() throws ResumeFailedException {
+               insertToken.onResume(null);
+               insertToken.onFetchable(null);
+               insertToken.onGeneratedMetadata(null, null);
+       }
+
+       @Test
+       public void generatedUriIsPostedOnSuccess() {
+               FreenetURI generatedUri = mock(FreenetURI.class);
+               insertToken.onGeneratedURI(generatedUri, null);
+               insertToken.onSuccess(null);
+               ArgumentCaptor<ImageInsertFinishedEvent> imageInsertFinishedEvent = forClass(ImageInsertFinishedEvent.class);
+               verify(eventBus).post(imageInsertFinishedEvent.capture());
+               verify(bucket).free();
+               assertThat(imageInsertFinishedEvent.getValue().image(), is(image));
+               assertThat(imageInsertFinishedEvent.getValue().resultingUri(), is(generatedUri));
+       }
+
+       @Test
+       public void insertTokenSupplierSuppliesInsertTokens() {
+               InsertTokenSupplier insertTokenSupplier = freenetInterface.new InsertTokenSupplier();
+               assertThat(insertTokenSupplier.apply(image), notNullValue());
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/core/ImageInserterTest.java b/src/test/java/net/pterodactylus/sone/core/ImageInserterTest.java
new file mode 100644 (file)
index 0000000..0912391
--- /dev/null
@@ -0,0 +1,58 @@
+package net.pterodactylus.sone.core;
+
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import net.pterodactylus.sone.core.FreenetInterface.InsertToken;
+import net.pterodactylus.sone.data.Image;
+import net.pterodactylus.sone.data.TemporaryImage;
+
+import com.google.common.base.Function;
+import org.junit.Test;
+
+/**
+ * Unit test for {@link ImageInserter}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class ImageInserterTest {
+
+       private final TemporaryImage temporaryImage = when(mock(TemporaryImage.class).getId()).thenReturn("image-id").getMock();
+       private final Image image = when(mock(Image.class).getId()).thenReturn("image-id").getMock();
+       private final FreenetInterface freenetInterface = mock(FreenetInterface.class);
+       private final InsertToken insertToken = mock(InsertToken.class);
+       private final Function<Image, InsertToken> insertTokenSupplier = when(mock(Function.class).apply(any(Image.class))).thenReturn(insertToken).getMock();
+       private final ImageInserter imageInserter = new ImageInserter(freenetInterface, insertTokenSupplier);
+
+       @Test
+       public void inserterInsertsImage() throws SoneException {
+               imageInserter.insertImage(temporaryImage, image);
+               verify(freenetInterface).insertImage(eq(temporaryImage), eq(image), any(InsertToken.class));
+       }
+
+       @Test
+       public void exceptionWhenInsertingImageIsIgnored() throws SoneException {
+               doThrow(SoneException.class).when(freenetInterface).insertImage(eq(temporaryImage), eq(image), any(InsertToken.class));
+               imageInserter.insertImage(temporaryImage, image);
+               verify(freenetInterface).insertImage(eq(temporaryImage), eq(image), any(InsertToken.class));
+       }
+
+       @Test
+       public void cancellingImageInsertThatIsNotRunningDoesNothing() {
+               imageInserter.cancelImageInsert(image);
+               verify(insertToken, never()).cancel();
+       }
+
+       @Test
+       public void cancellingImage() {
+               imageInserter.insertImage(temporaryImage, image);
+               imageInserter.cancelImageInsert(image);
+               verify(insertToken).cancel();
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/core/OptionsTest.java b/src/test/java/net/pterodactylus/sone/core/OptionsTest.java
new file mode 100644 (file)
index 0000000..cce4df0
--- /dev/null
@@ -0,0 +1,55 @@
+package net.pterodactylus.sone.core;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+import static org.mockito.Mockito.mock;
+
+import net.pterodactylus.sone.utils.Option;
+
+import org.junit.Test;
+
+/**
+ * Unit test for {@link Options}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class OptionsTest {
+
+       private final Options options = new Options();
+
+       @Test
+       public void booleanOptionIsAdded() {
+               Option<Boolean> booleanOption = mock(Option.class);
+               options.addBooleanOption("test", booleanOption);
+               assertThat(options.getBooleanOption("test"), is(booleanOption));
+               assertThat(options.getBooleanOption("not-test"), nullValue());
+       }
+
+       @Test
+       public void integerOptionIsAdded() {
+               Option<Integer> integerOption = mock(Option.class);
+               options.addIntegerOption("test", integerOption);
+               assertThat(options.getIntegerOption("test"), is(integerOption));
+               assertThat(options.getIntegerOption("not-test"), nullValue());
+       }
+
+       @Test
+       public void stringOptionIsAdded() {
+               Option<String> stringOption = mock(Option.class);
+               options.addStringOption("test", stringOption);
+               assertThat(options.getStringOption("test"), is(stringOption));
+               assertThat(options.getStringOption("not-test"), nullValue());
+       }
+
+       @Test
+       public void enumOptionIsAdded() {
+               Option<TestEnum> enumOption = mock(Option.class);
+               options.addEnumOption("test", enumOption);
+               assertThat(options.<TestEnum>getEnumOption("test"), is(enumOption));
+               assertThat(options.<TestEnum>getEnumOption("not-test"), nullValue());
+       }
+
+       private enum TestEnum {TEST, NOT_TEST}
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/core/PreferencesLoaderTest.java b/src/test/java/net/pterodactylus/sone/core/PreferencesLoaderTest.java
new file mode 100644 (file)
index 0000000..e12fe93
--- /dev/null
@@ -0,0 +1,82 @@
+package net.pterodactylus.sone.core;
+
+import static net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired.WRITING;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import net.pterodactylus.sone.TestValue;
+import net.pterodactylus.util.config.Configuration;
+
+import com.google.common.eventbus.EventBus;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit test for {@link PreferencesLoader}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class PreferencesLoaderTest {
+
+       private final EventBus eventBus = mock(EventBus.class);
+       private final Preferences preferences = new Preferences(eventBus);
+       private final Configuration configuration = mock(Configuration.class);
+       private final PreferencesLoader preferencesLoader =
+                       new PreferencesLoader(preferences);
+
+       @Before
+       public void setupConfiguration() {
+               setupIntValue("InsertionDelay", 15);
+               setupIntValue("PostsPerPage", 25);
+               setupIntValue("ImagesPerPage", 12);
+               setupIntValue("CharactersPerPost", 150);
+               setupIntValue("PostCutOffLength", 300);
+               setupBooleanValue("RequireFullAccess", true);
+               setupIntValue("PositiveTrust", 50);
+               setupIntValue("NegativeTrust", -50);
+               when(configuration.getStringValue("Option/TrustComment")).thenReturn(
+                               TestValue.from("Trusted"));
+               setupBooleanValue("ActivateFcpInterface", true);
+               setupIntValue("FcpFullAccessRequired", 1);
+       }
+
+       private void setupIntValue(String optionName, int value) {
+               when(configuration.getIntValue("Option/" + optionName)).thenReturn(
+                               TestValue.from(value));
+       }
+
+       private void setupBooleanValue(String optionName, boolean value) {
+               when(configuration.getBooleanValue(
+                               "Option/" + optionName)).thenReturn(
+                               TestValue.from(value));
+       }
+
+       @Test
+       public void configurationIsLoadedCorrectly() {
+               setupConfiguration();
+               preferencesLoader.loadFrom(configuration);
+               assertThat(preferences.getInsertionDelay(), is(15));
+               assertThat(preferences.getPostsPerPage(), is(25));
+               assertThat(preferences.getImagesPerPage(), is(12));
+               assertThat(preferences.getCharactersPerPost(), is(150));
+               assertThat(preferences.getPostCutOffLength(), is(300));
+               assertThat(preferences.isRequireFullAccess(), is(true));
+               assertThat(preferences.getPositiveTrust(), is(50));
+               assertThat(preferences.getNegativeTrust(), is(-50));
+               assertThat(preferences.getTrustComment(), is("Trusted"));
+               assertThat(preferences.isFcpInterfaceActive(), is(true));
+               assertThat(preferences.getFcpFullAccessRequired(), is(WRITING));
+       }
+
+       @Test
+       public void configurationIsLoadedCorrectlyWithCutOffLengthMinusOne() {
+           setupConfiguration();
+               setupIntValue("PostCutOffLength", -1);
+               preferencesLoader.loadFrom(configuration);
+               assertThat(preferences.getPostCutOffLength(), not(is(-1)));
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/core/PreferencesTest.java b/src/test/java/net/pterodactylus/sone/core/PreferencesTest.java
new file mode 100644 (file)
index 0000000..a19fe2e
--- /dev/null
@@ -0,0 +1,311 @@
+package net.pterodactylus.sone.core;
+
+import static net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired.ALWAYS;
+import static net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired.NO;
+import static net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired.WRITING;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+import net.pterodactylus.sone.core.event.InsertionDelayChangedEvent;
+import net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired;
+import net.pterodactylus.sone.fcp.event.FcpInterfaceActivatedEvent;
+import net.pterodactylus.sone.fcp.event.FcpInterfaceDeactivatedEvent;
+import net.pterodactylus.sone.fcp.event.FullAccessRequiredChanged;
+
+import com.google.common.eventbus.EventBus;
+import org.junit.After;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+/**
+ * Unit test for {@link Preferences}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class PreferencesTest {
+
+       private final EventBus eventBus = mock(EventBus.class);
+       private final Preferences preferences = new Preferences(eventBus);
+
+       @After
+       public void tearDown() {
+               verifyNoMoreInteractions(eventBus);
+       }
+
+       @Test
+       public void preferencesRetainInsertionDelay() {
+               preferences.setInsertionDelay(15);
+               assertThat(preferences.getInsertionDelay(), is(15));
+               verify(eventBus).post(any(InsertionDelayChangedEvent.class));
+       }
+
+       @Test(expected = IllegalArgumentException.class)
+       public void invalidInsertionDelayIsRejected() {
+               preferences.setInsertionDelay(-15);
+       }
+
+       @Test
+       public void preferencesReturnDefaultValueWhenInsertionDelayIsSetToNull() {
+               preferences.setInsertionDelay(null);
+               assertThat(preferences.getInsertionDelay(), is(60));
+               verify(eventBus).post(any(InsertionDelayChangedEvent.class));
+       }
+
+       @Test
+       public void preferencesStartWithInsertionDelayDefaultValue() {
+               assertThat(preferences.getInsertionDelay(), is(60));
+       }
+
+       @Test
+       public void preferencesRetainPostsPerPage() {
+               preferences.setPostsPerPage(15);
+               assertThat(preferences.getPostsPerPage(), is(15));
+       }
+
+       @Test(expected = IllegalArgumentException.class)
+       public void invalidPostsPerPageIsRejected() {
+               preferences.setPostsPerPage(-15);
+       }
+
+       @Test
+       public void preferencesReturnDefaultValueWhenPostsPerPageIsSetToNull() {
+               preferences.setPostsPerPage(null);
+               assertThat(preferences.getPostsPerPage(), is(10));
+       }
+
+       @Test
+       public void preferencesStartWithPostsPerPageDefaultValue() {
+               assertThat(preferences.getPostsPerPage(), is(10));
+       }
+
+       @Test
+       public void preferencesRetainImagesPerPage() {
+               preferences.setImagesPerPage(15);
+               assertThat(preferences.getImagesPerPage(), is(15));
+       }
+
+       @Test(expected = IllegalArgumentException.class)
+       public void invalidImagesPerPageIsRejected() {
+               preferences.setImagesPerPage(-15);
+       }
+
+       @Test
+       public void preferencesReturnDefaultValueWhenImagesPerPageIsSetToNull() {
+               preferences.setImagesPerPage(null);
+               assertThat(preferences.getImagesPerPage(), is(9));
+       }
+
+       @Test
+       public void preferencesStartWithImagesPerPageDefaultValue() {
+               assertThat(preferences.getImagesPerPage(), is(9));
+       }
+
+       @Test
+       public void preferencesRetainCharactersPerPost() {
+               preferences.setCharactersPerPost(150);
+               assertThat(preferences.getCharactersPerPost(), is(150));
+       }
+
+       @Test(expected = IllegalArgumentException.class)
+       public void invalidCharactersPerPostIsRejected() {
+               preferences.setCharactersPerPost(-15);
+       }
+
+       @Test
+       public void preferencesReturnDefaultValueWhenCharactersPerPostIsSetToNull() {
+               preferences.setCharactersPerPost(null);
+               assertThat(preferences.getCharactersPerPost(), is(400));
+       }
+
+       @Test
+       public void preferencesStartWithCharactersPerPostDefaultValue() {
+               assertThat(preferences.getCharactersPerPost(), is(400));
+       }
+
+       @Test
+       public void preferencesRetainPostCutOffLength() {
+               preferences.setPostCutOffLength(150);
+               assertThat(preferences.getPostCutOffLength(), is(150));
+       }
+
+       @Test(expected = IllegalArgumentException.class)
+       public void invalidPostCutOffLengthIsRejected() {
+               preferences.setPostCutOffLength(-15);
+       }
+
+       @Test(expected = IllegalArgumentException.class)
+       public void cutOffLengthOfMinusOneIsNotAllowed() {
+               preferences.setPostCutOffLength(-1);
+       }
+
+       @Test
+       public void preferencesReturnDefaultValueWhenPostCutOffLengthIsSetToNull() {
+               preferences.setPostCutOffLength(null);
+               assertThat(preferences.getPostCutOffLength(), is(200));
+       }
+
+       @Test
+       public void preferencesStartWithPostCutOffLengthDefaultValue() {
+               assertThat(preferences.getPostCutOffLength(), is(200));
+       }
+
+       @Test
+       public void preferencesRetainRequireFullAccessOfTrue() {
+               preferences.setRequireFullAccess(true);
+               assertThat(preferences.isRequireFullAccess(), is(true));
+       }
+
+       @Test
+       public void preferencesRetainRequireFullAccessOfFalse() {
+               preferences.setRequireFullAccess(false);
+               assertThat(preferences.isRequireFullAccess(), is(false));
+       }
+
+       @Test
+       public void preferencesReturnDefaultValueWhenRequireFullAccessIsSetToNull() {
+               preferences.setRequireFullAccess(null);
+               assertThat(preferences.isRequireFullAccess(), is(false));
+       }
+
+       @Test
+       public void preferencesStartWithRequireFullAccessDefaultValue() {
+               assertThat(preferences.isRequireFullAccess(), is(false));
+       }
+
+       @Test
+       public void preferencesRetainPositiveTrust() {
+               preferences.setPositiveTrust(15);
+               assertThat(preferences.getPositiveTrust(), is(15));
+       }
+
+       @Test(expected = IllegalArgumentException.class)
+       public void invalidPositiveTrustIsRejected() {
+               preferences.setPositiveTrust(-15);
+       }
+
+       @Test
+       public void preferencesReturnDefaultValueWhenPositiveTrustIsSetToNull() {
+               preferences.setPositiveTrust(null);
+               assertThat(preferences.getPositiveTrust(), is(75));
+       }
+
+       @Test
+       public void preferencesStartWithPositiveTrustDefaultValue() {
+               assertThat(preferences.getPositiveTrust(), is(75));
+       }
+
+       @Test
+       public void preferencesRetainNegativeTrust() {
+               preferences.setNegativeTrust(-15);
+               assertThat(preferences.getNegativeTrust(), is(-15));
+       }
+
+       @Test(expected = IllegalArgumentException.class)
+       public void invalidNegativeTrustIsRejected() {
+               preferences.setNegativeTrust(150);
+       }
+
+       @Test
+       public void preferencesReturnDefaultValueWhenNegativeTrustIsSetToNull() {
+               preferences.setNegativeTrust(null);
+               assertThat(preferences.getNegativeTrust(), is(-25));
+       }
+
+       @Test
+       public void preferencesStartWithNegativeTrustDefaultValue() {
+               assertThat(preferences.getNegativeTrust(), is(-25));
+       }
+
+       @Test
+       public void preferencesRetainTrustComment() {
+               preferences.setTrustComment("Trust");
+               assertThat(preferences.getTrustComment(), is("Trust"));
+       }
+
+       @Test
+       public void preferencesReturnDefaultValueWhenTrustCommentIsSetToNull() {
+               preferences.setTrustComment(null);
+               assertThat(preferences.getTrustComment(),
+                               is("Set from Sone Web Interface"));
+       }
+
+       @Test
+       public void preferencesStartWithTrustCommentDefaultValue() {
+               assertThat(preferences.getTrustComment(),
+                               is("Set from Sone Web Interface"));
+       }
+
+       @Test
+       public void preferencesRetainFcpInterfaceActiveOfTrue() {
+               preferences.setFcpInterfaceActive(true);
+               assertThat(preferences.isFcpInterfaceActive(), is(true));
+               verify(eventBus).post(any(FcpInterfaceActivatedEvent.class));
+       }
+
+       @Test
+       public void preferencesRetainFcpInterfaceActiveOfFalse() {
+               preferences.setFcpInterfaceActive(false);
+               assertThat(preferences.isFcpInterfaceActive(), is(false));
+               verify(eventBus).post(any(FcpInterfaceDeactivatedEvent.class));
+       }
+
+       @Test
+       public void preferencesReturnDefaultValueWhenFcpInterfaceActiveIsSetToNull() {
+               preferences.setFcpInterfaceActive(null);
+               assertThat(preferences.isFcpInterfaceActive(), is(false));
+               verify(eventBus).post(any(FcpInterfaceDeactivatedEvent.class));
+       }
+
+       @Test
+       public void preferencesStartWithFcpInterfaceActiveDefaultValue() {
+               assertThat(preferences.isFcpInterfaceActive(), is(false));
+       }
+
+       @Test
+       public void preferencesRetainFcpFullAccessRequiredOfNo() {
+               preferences.setFcpFullAccessRequired(NO);
+               assertThat(preferences.getFcpFullAccessRequired(), is(NO));
+               verifyFullAccessRequiredChangedEvent(NO);
+       }
+
+       private void verifyFullAccessRequiredChangedEvent(
+                       FullAccessRequired fullAccessRequired) {
+               ArgumentCaptor<FullAccessRequiredChanged> fullAccessRequiredCaptor =
+                               ArgumentCaptor.forClass(FullAccessRequiredChanged.class);
+               verify(eventBus).post(fullAccessRequiredCaptor.capture());
+               assertThat(
+                               fullAccessRequiredCaptor.getValue().getFullAccessRequired(),
+                               is(fullAccessRequired));
+       }
+
+       @Test
+       public void preferencesRetainFcpFullAccessRequiredOfWriting() {
+               preferences.setFcpFullAccessRequired(WRITING);
+               assertThat(preferences.getFcpFullAccessRequired(), is(WRITING));
+               verifyFullAccessRequiredChangedEvent(WRITING);
+       }
+
+       @Test
+       public void preferencesRetainFcpFullAccessRequiredOfAlways() {
+               preferences.setFcpFullAccessRequired(ALWAYS);
+               assertThat(preferences.getFcpFullAccessRequired(), is(ALWAYS));
+               verifyFullAccessRequiredChangedEvent(ALWAYS);
+       }
+
+       @Test
+       public void preferencesReturnDefaultValueWhenFcpFullAccessRequiredIsSetToNull() {
+               preferences.setFcpFullAccessRequired(null);
+               assertThat(preferences.getFcpFullAccessRequired(), is(ALWAYS));
+               verifyFullAccessRequiredChangedEvent(ALWAYS);
+       }
+
+       @Test
+       public void preferencesStartWithFcpFullAccessRequiredDefaultValue() {
+               assertThat(preferences.getFcpFullAccessRequired(), is(ALWAYS));
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/core/SoneChangeDetectorTest.java b/src/test/java/net/pterodactylus/sone/core/SoneChangeDetectorTest.java
new file mode 100644 (file)
index 0000000..f070eff
--- /dev/null
@@ -0,0 +1,108 @@
+package net.pterodactylus.sone.core;
+
+import static java.util.Arrays.asList;
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.HashSet;
+
+import net.pterodactylus.sone.core.SoneChangeDetector.PostProcessor;
+import net.pterodactylus.sone.core.SoneChangeDetector.PostReplyProcessor;
+import net.pterodactylus.sone.data.Post;
+import net.pterodactylus.sone.data.PostReply;
+import net.pterodactylus.sone.data.Sone;
+
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit test for {@link SoneChangeDetector}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class SoneChangeDetectorTest {
+
+       private final Sone oldSone = mock(Sone.class);
+       private final Sone newSone = mock(Sone.class);
+       private final SoneChangeDetector soneChangeDetector =
+                       new SoneChangeDetector(oldSone);
+       private final Post oldPost = mock(Post.class);
+       private final Post removedPost = mock(Post.class);
+       private final Post newPost = mock(Post.class);
+       private final PostProcessor newPostProcessor = mock(PostProcessor.class);
+       private final PostProcessor removedPostProcessor =
+                       mock(PostProcessor.class);
+       private final PostReply oldPostReply = mock(PostReply.class);
+       private final PostReply removedPostReply = mock(PostReply.class);
+       private final PostReply newPostReply = mock(PostReply.class);
+       private final PostReplyProcessor newPostReplyProcessor =
+                       mock(PostReplyProcessor.class);
+       private final PostReplyProcessor removedPostReplyProcessor =
+                       mock(PostReplyProcessor.class);
+
+       @Before
+       public void setupPosts() {
+               when(oldSone.getPosts()).thenReturn(asList(oldPost, removedPost));
+               when(newSone.getPosts()).thenReturn(asList(oldPost, newPost));
+       }
+
+       @Before
+       public void setupPostProcessors() {
+               soneChangeDetector.onNewPosts(newPostProcessor);
+               soneChangeDetector.onRemovedPosts(removedPostProcessor);
+       }
+
+       @Before
+       public void setupPostReplies() {
+               when(oldSone.getReplies()).thenReturn(
+                               new HashSet<PostReply>(
+                                               asList(oldPostReply, removedPostReply)));
+               when(newSone.getReplies()).thenReturn(
+                               new HashSet<PostReply>(asList(oldPostReply, newPostReply)));
+       }
+
+       @Before
+       public void setupPostReplyProcessors() {
+               soneChangeDetector.onNewPostReplies(newPostReplyProcessor);
+               soneChangeDetector.onRemovedPostReplies(removedPostReplyProcessor);
+       }
+
+       @Test
+       public void changeDetectorDetectsChanges() {
+               soneChangeDetector.detectChanges(newSone);
+
+               verify(newPostProcessor).processPost(newPost);
+               verify(newPostProcessor, never()).processPost(oldPost);
+               verify(newPostProcessor, never()).processPost(removedPost);
+               verify(removedPostProcessor).processPost(removedPost);
+               verify(removedPostProcessor, never()).processPost(oldPost);
+               verify(removedPostProcessor, never()).processPost(newPost);
+
+               verify(newPostReplyProcessor).processPostReply(newPostReply);
+               verify(newPostReplyProcessor, never()).processPostReply(oldPostReply);
+               verify(newPostReplyProcessor, never()).processPostReply(
+                               removedPostReply);
+               verify(removedPostReplyProcessor).processPostReply(removedPostReply);
+               verify(removedPostReplyProcessor, never()).processPostReply(
+                               oldPostReply);
+               verify(removedPostReplyProcessor, never()).processPostReply(
+                               newPostReply);
+       }
+
+       @Test
+       public void changeDetectorDoesNotNotifyAnyProcessorIfProcessorsUnset() {
+           soneChangeDetector.onNewPosts(null);
+           soneChangeDetector.onRemovedPosts(null);
+           soneChangeDetector.onNewPostReplies(null);
+           soneChangeDetector.onRemovedPostReplies(null);
+               soneChangeDetector.detectChanges(newSone);
+               verify(newPostProcessor, never()).processPost(any(Post.class));
+               verify(removedPostProcessor, never()).processPost(any(Post.class));
+               verify(newPostReplyProcessor, never()).processPostReply(any(PostReply.class));
+               verify(removedPostReplyProcessor, never()).processPostReply(any(PostReply.class));
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/core/SoneDownloaderTest.java b/src/test/java/net/pterodactylus/sone/core/SoneDownloaderTest.java
new file mode 100644 (file)
index 0000000..33c8ead
--- /dev/null
@@ -0,0 +1,196 @@
+package net.pterodactylus.sone.core;
+
+import static freenet.keys.InsertableClientSSK.createRandom;
+import static java.lang.System.currentTimeMillis;
+import static java.util.concurrent.TimeUnit.DAYS;
+import static net.pterodactylus.sone.data.Sone.SoneStatus.downloading;
+import static net.pterodactylus.sone.data.Sone.SoneStatus.idle;
+import static net.pterodactylus.sone.data.Sone.SoneStatus.unknown;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.ArgumentCaptor.forClass;
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import net.pterodactylus.sone.core.FreenetInterface.Fetched;
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.sone.data.Sone.SoneStatus;
+import net.pterodactylus.sone.freenet.wot.Identity;
+
+import freenet.client.ClientMetadata;
+import freenet.client.FetchResult;
+import freenet.client.async.USKCallback;
+import freenet.crypt.DummyRandomSource;
+import freenet.keys.FreenetURI;
+import freenet.keys.InsertableClientSSK;
+import freenet.support.api.Bucket;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+
+/**
+ * Unit test for {@link SoneDownloaderImpl} and its subclasses.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class SoneDownloaderTest {
+
+       private final Core core = mock(Core.class);
+       private final FreenetInterface freenetInterface = mock(FreenetInterface.class);
+       private final SoneParser soneParser = mock(SoneParser.class);
+       private final SoneDownloaderImpl soneDownloader = new SoneDownloaderImpl(core, freenetInterface, soneParser);
+       private FreenetURI requestUri = mock(FreenetURI.class);
+       private Sone sone = mock(Sone.class);
+
+       @Before
+       public void setupSone() {
+               Sone sone = SoneDownloaderTest.this.sone;
+               Identity identity = mock(Identity.class);
+               InsertableClientSSK clientSSK = createRandom(new DummyRandomSource(), "WoT");
+               when(identity.getRequestUri()).thenReturn(clientSSK.getURI().toString());
+               when(identity.getId()).thenReturn("identity");
+               when(sone.getId()).thenReturn("identity");
+               when(sone.getIdentity()).thenReturn(identity);
+               requestUri = clientSSK.getURI().setKeyType("USK").setDocName("Sone");
+               when(sone.getRequestUri()).thenAnswer(new Answer<FreenetURI>() {
+                       @Override
+                       public FreenetURI answer(InvocationOnMock invocation)
+                       throws Throwable {
+                               return requestUri;
+                       }
+               });
+               when(sone.getTime()).thenReturn(currentTimeMillis() - DAYS.toMillis(1));
+       }
+
+       private void setupSoneAsUnknown() {
+               when(sone.getTime()).thenReturn(0L);
+       }
+
+       @Test
+       public void addingASoneWillRegisterItsKey() {
+               soneDownloader.addSone(sone);
+               verify(freenetInterface).registerActiveUsk(eq(sone.getRequestUri()), any(
+                               USKCallback.class));
+               verify(freenetInterface, never()).unregisterUsk(sone);
+       }
+
+       @Test
+       public void addingASoneTwiceWillAlsoDeregisterItsKey() {
+               soneDownloader.addSone(sone);
+               soneDownloader.addSone(sone);
+               verify(freenetInterface, times(2)).registerActiveUsk(eq(
+                               sone.getRequestUri()), any(USKCallback.class));
+               verify(freenetInterface).unregisterUsk(sone);
+       }
+
+
+       @Test
+       public void stoppingTheSoneDownloaderUnregistersTheSone() {
+               soneDownloader.addSone(sone);
+               soneDownloader.stop();
+               verify(freenetInterface).unregisterUsk(sone);
+       }
+
+       @Test
+       public void notBeingAbleToFetchAnUnknownSoneDoesNotUpdateCore() {
+               FreenetURI finalRequestUri = requestUri.sskForUSK()
+                               .setMetaString(new String[] { "sone.xml" });
+               setupSoneAsUnknown();
+               soneDownloader.fetchSoneAction(sone).run();
+               verify(freenetInterface).fetchUri(finalRequestUri);
+               verifyThatSoneStatusWasChangedToDownloadingAndBackTo(unknown);
+               verify(core, never()).updateSone(any(Sone.class));
+       }
+
+       private void verifyThatSoneStatusWasChangedToDownloadingAndBackTo(SoneStatus soneStatus) {
+               ArgumentCaptor<SoneStatus> soneStatuses = forClass(SoneStatus.class);
+               verify(sone, times(2)).setStatus(soneStatuses.capture());
+               assertThat(soneStatuses.getAllValues().get(0), is(downloading));
+               assertThat(soneStatuses.getAllValues().get(1), is(soneStatus));
+       }
+
+       @Test
+       public void notBeingAbleToFetchAKnownSoneDoesNotUpdateCore() {
+               FreenetURI finalRequestUri = requestUri.sskForUSK()
+                               .setMetaString(new String[] { "sone.xml" });
+               soneDownloader.fetchSoneAction(sone).run();
+               verify(freenetInterface).fetchUri(finalRequestUri);
+               verifyThatSoneStatusWasChangedToDownloadingAndBackTo(idle);
+               verify(core, never()).updateSone(any(Sone.class));
+       }
+
+       @Test(expected = NullPointerException.class)
+       public void exceptionWhileFetchingAnUnknownSoneDoesNotUpdateCore() {
+               FreenetURI finalRequestUri = requestUri.sskForUSK()
+                               .setMetaString(new String[] { "sone.xml" });
+               setupSoneAsUnknown();
+               when(freenetInterface.fetchUri(finalRequestUri)).thenThrow(NullPointerException.class);
+               try {
+                       soneDownloader.fetchSoneAction(sone).run();
+               } finally {
+                       verify(freenetInterface).fetchUri(finalRequestUri);
+                       verifyThatSoneStatusWasChangedToDownloadingAndBackTo(unknown);
+                       verify(core, never()).updateSone(any(Sone.class));
+               }
+       }
+
+       @Test(expected = NullPointerException.class)
+       public void exceptionWhileFetchingAKnownSoneDoesNotUpdateCore() {
+               FreenetURI finalRequestUri = requestUri.sskForUSK()
+                               .setMetaString(new String[] { "sone.xml" });
+               when(freenetInterface.fetchUri(finalRequestUri)).thenThrow( NullPointerException.class);
+               try {
+                       soneDownloader.fetchSoneAction(sone).run();
+               } finally {
+                       verify(freenetInterface).fetchUri(finalRequestUri);
+                       verifyThatSoneStatusWasChangedToDownloadingAndBackTo(idle);
+                       verify(core, never()).updateSone(any(Sone.class));
+               }
+       }
+
+       @Test
+       public void fetchingSoneWithInvalidXmlWillNotUpdateTheCore() throws IOException {
+               final Fetched fetchResult = createFetchResult(requestUri, getClass().getResourceAsStream("sone-parser-not-xml.xml"));
+               when(freenetInterface.fetchUri(requestUri)).thenReturn(fetchResult);
+               soneDownloader.fetchSoneAction(sone).run();
+               verify(core, never()).updateSone(any(Sone.class));
+       }
+
+       @Test
+       public void exceptionWhileFetchingSoneWillNotUpdateTheCore() throws IOException {
+               final Fetched fetchResult = createFetchResult(requestUri, getClass().getResourceAsStream("sone-parser-no-payload.xml"));
+               when(core.soneBuilder()).thenReturn(null);
+               when(freenetInterface.fetchUri(requestUri)).thenReturn(fetchResult);
+               soneDownloader.fetchSoneAction(sone).run();
+               verify(core, never()).updateSone(any(Sone.class));
+       }
+
+       @Test
+       public void onlyFetchingASoneWillNotUpdateTheCore() throws IOException {
+               final Fetched fetchResult = createFetchResult(requestUri, getClass().getResourceAsStream("sone-parser-no-payload.xml"));
+               when(freenetInterface.fetchUri(requestUri)).thenReturn(fetchResult);
+               soneDownloader.fetchSone(sone, sone.getRequestUri(), true);
+               verify(core, never()).updateSone(any(Sone.class));
+               verifyThatSoneStatusWasChangedToDownloadingAndBackTo(idle);
+       }
+
+       private Fetched createFetchResult(FreenetURI uri, InputStream inputStream) throws IOException {
+               ClientMetadata clientMetadata = new ClientMetadata("application/xml");
+               Bucket bucket = mock(Bucket.class);
+               when(bucket.getInputStream()).thenReturn(inputStream);
+               FetchResult fetchResult = new FetchResult(clientMetadata, bucket);
+               return new Fetched(uri, fetchResult);
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/core/SoneInserterTest.java b/src/test/java/net/pterodactylus/sone/core/SoneInserterTest.java
new file mode 100644 (file)
index 0000000..552746e
--- /dev/null
@@ -0,0 +1,289 @@
+package net.pterodactylus.sone.core;
+
+import static com.google.common.base.Optional.of;
+import static com.google.common.io.ByteStreams.toByteArray;
+import static com.google.common.util.concurrent.MoreExecutors.sameThreadExecutor;
+import static java.lang.System.currentTimeMillis;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyString;
+import static org.mockito.Matchers.argThat;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+import net.pterodactylus.sone.core.SoneInserter.ManifestCreator;
+import net.pterodactylus.sone.core.event.InsertionDelayChangedEvent;
+import net.pterodactylus.sone.core.event.SoneEvent;
+import net.pterodactylus.sone.core.event.SoneInsertAbortedEvent;
+import net.pterodactylus.sone.core.event.SoneInsertedEvent;
+import net.pterodactylus.sone.core.event.SoneInsertingEvent;
+import net.pterodactylus.sone.data.Album;
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.sone.main.SonePlugin;
+
+import freenet.keys.FreenetURI;
+import freenet.support.api.ManifestElement;
+
+import com.google.common.base.Charsets;
+import com.google.common.base.Optional;
+import com.google.common.eventbus.AsyncEventBus;
+import com.google.common.eventbus.EventBus;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+
+/**
+ * Unit test for {@link SoneInserter} and its subclasses.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class SoneInserterTest {
+
+       private final Core core = mock(Core.class);
+       private final EventBus eventBus = mock(EventBus.class);
+       private final FreenetInterface freenetInterface = mock(FreenetInterface.class);
+
+       @Before
+       public void setupCore() {
+               UpdateChecker updateChecker = mock(UpdateChecker.class);
+               when(core.getUpdateChecker()).thenReturn(updateChecker);
+               when(core.getSone(anyString())).thenReturn(Optional.<Sone>absent());
+       }
+
+       @Test
+       public void insertionDelayIsForwardedToSoneInserter() {
+               EventBus eventBus = new AsyncEventBus(sameThreadExecutor());
+               eventBus.register(new SoneInserter(core, eventBus, freenetInterface, "SoneId"));
+               eventBus.post(new InsertionDelayChangedEvent(15));
+               assertThat(SoneInserter.getInsertionDelay().get(), is(15));
+       }
+
+       private Sone createSone(FreenetURI insertUri, String fingerprint) {
+               Sone sone = mock(Sone.class);
+               when(sone.getInsertUri()).thenReturn(insertUri);
+               when(sone.getFingerprint()).thenReturn(fingerprint);
+               when(sone.getRootAlbum()).thenReturn(mock(Album.class));
+               when(core.getSone(anyString())).thenReturn(of(sone));
+               return sone;
+       }
+
+       @Test
+       public void isModifiedIsTrueIfModificationDetectorSaysSo() {
+               SoneModificationDetector soneModificationDetector = mock(SoneModificationDetector.class);
+               when(soneModificationDetector.isModified()).thenReturn(true);
+               SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId", soneModificationDetector, 1);
+               assertThat(soneInserter.isModified(), is(true));
+       }
+
+       @Test
+       public void isModifiedIsFalseIfModificationDetectorSaysSo() {
+               SoneModificationDetector soneModificationDetector = mock(SoneModificationDetector.class);
+               SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId", soneModificationDetector, 1);
+               assertThat(soneInserter.isModified(), is(false));
+       }
+
+       @Test
+       public void lastFingerprintIsStoredCorrectly() {
+               SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId");
+               soneInserter.setLastInsertFingerprint("last-fingerprint");
+               assertThat(soneInserter.getLastInsertFingerprint(), is("last-fingerprint"));
+       }
+
+       @Test
+       public void soneInserterStopsWhenItShould() {
+               SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId");
+               soneInserter.stop();
+               soneInserter.serviceRun();
+       }
+
+       @Test
+       public void soneInserterInsertsASoneIfItIsEligible() throws SoneException {
+               FreenetURI insertUri = mock(FreenetURI.class);
+               final FreenetURI finalUri = mock(FreenetURI.class);
+               String fingerprint = "fingerprint";
+               Sone sone = createSone(insertUri, fingerprint);
+               SoneModificationDetector soneModificationDetector = mock(SoneModificationDetector.class);
+               when(soneModificationDetector.isEligibleForInsert()).thenReturn(true);
+               when(freenetInterface.insertDirectory(eq(insertUri), any(HashMap.class), eq("index.html"))).thenReturn(finalUri);
+               final SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId", soneModificationDetector, 1);
+               doAnswer(new Answer<Void>() {
+                       @Override
+                       public Void answer(InvocationOnMock invocation) throws Throwable {
+                               soneInserter.stop();
+                               return null;
+                       }
+               }).when(core).touchConfiguration();
+               soneInserter.serviceRun();
+               ArgumentCaptor<SoneEvent> soneEvents = ArgumentCaptor.forClass(SoneEvent.class);
+               verify(freenetInterface).insertDirectory(eq(insertUri), any(HashMap.class), eq("index.html"));
+               verify(eventBus, times(2)).post(soneEvents.capture());
+               assertThat(soneEvents.getAllValues().get(0), instanceOf(SoneInsertingEvent.class));
+               assertThat(soneEvents.getAllValues().get(0).sone(), is(sone));
+               assertThat(soneEvents.getAllValues().get(1), instanceOf(SoneInsertedEvent.class));
+               assertThat(soneEvents.getAllValues().get(1).sone(), is(sone));
+       }
+
+       @Test
+       public void soneInserterBailsOutIfItIsStoppedWhileInserting() throws SoneException {
+               FreenetURI insertUri = mock(FreenetURI.class);
+               final FreenetURI finalUri = mock(FreenetURI.class);
+               String fingerprint = "fingerprint";
+               Sone sone = createSone(insertUri, fingerprint);
+               SoneModificationDetector soneModificationDetector = mock(SoneModificationDetector.class);
+               when(soneModificationDetector.isEligibleForInsert()).thenReturn(true);
+               final SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId", soneModificationDetector, 1);
+               when(freenetInterface.insertDirectory(eq(insertUri), any(HashMap.class), eq("index.html"))).thenAnswer(new Answer<FreenetURI>() {
+                       @Override
+                       public FreenetURI answer(InvocationOnMock invocation) throws Throwable {
+                               soneInserter.stop();
+                               return finalUri;
+                       }
+               });
+               soneInserter.serviceRun();
+               ArgumentCaptor<SoneEvent> soneEvents = ArgumentCaptor.forClass(SoneEvent.class);
+               verify(freenetInterface).insertDirectory(eq(insertUri), any(HashMap.class), eq("index.html"));
+               verify(eventBus, times(2)).post(soneEvents.capture());
+               assertThat(soneEvents.getAllValues().get(0), instanceOf(SoneInsertingEvent.class));
+               assertThat(soneEvents.getAllValues().get(0).sone(), is(sone));
+               assertThat(soneEvents.getAllValues().get(1), instanceOf(SoneInsertedEvent.class));
+               assertThat(soneEvents.getAllValues().get(1).sone(), is(sone));
+               verify(core, never()).touchConfiguration();
+       }
+
+       @Test
+       public void soneInserterDoesNotInsertSoneIfItIsNotEligible() throws SoneException {
+               FreenetURI insertUri = mock(FreenetURI.class);
+               String fingerprint = "fingerprint";
+               Sone sone = createSone(insertUri, fingerprint);
+               SoneModificationDetector soneModificationDetector = mock(SoneModificationDetector.class);
+               final SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId", soneModificationDetector, 1);
+               new Thread(new Runnable() {
+                       @Override
+                       public void run() {
+                               try {
+                                       Thread.sleep(500);
+                               } catch (InterruptedException ie1) {
+                                       throw new RuntimeException(ie1);
+                               }
+                               soneInserter.stop();
+                       }
+               }).start();
+               soneInserter.serviceRun();
+               verify(freenetInterface, never()).insertDirectory(eq(insertUri), any(HashMap.class), eq("index.html"));
+               verify(eventBus, never()).post(argThat(org.hamcrest.Matchers.any(SoneEvent.class)));
+       }
+
+       @Test
+       public void soneInserterPostsAbortedEventIfAnExceptionOccurs() throws SoneException {
+               FreenetURI insertUri = mock(FreenetURI.class);
+               String fingerprint = "fingerprint";
+               Sone sone = createSone(insertUri, fingerprint);
+               SoneModificationDetector soneModificationDetector = mock(SoneModificationDetector.class);
+               when(soneModificationDetector.isEligibleForInsert()).thenReturn(true);
+               final SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId", soneModificationDetector, 1);
+               final SoneException soneException = new SoneException(new Exception());
+               when(freenetInterface.insertDirectory(eq(insertUri), any(HashMap.class), eq("index.html"))).thenAnswer(new Answer<FreenetURI>() {
+                       @Override
+                       public FreenetURI answer(InvocationOnMock invocation) throws Throwable {
+                               soneInserter.stop();
+                               throw soneException;
+                       }
+               });
+               soneInserter.serviceRun();
+               ArgumentCaptor<SoneEvent> soneEvents = ArgumentCaptor.forClass(SoneEvent.class);
+               verify(freenetInterface).insertDirectory(eq(insertUri), any(HashMap.class), eq("index.html"));
+               verify(eventBus, times(2)).post(soneEvents.capture());
+               assertThat(soneEvents.getAllValues().get(0), instanceOf(SoneInsertingEvent.class));
+               assertThat(soneEvents.getAllValues().get(0).sone(), is(sone));
+               assertThat(soneEvents.getAllValues().get(1), instanceOf(SoneInsertAbortedEvent.class));
+               assertThat(soneEvents.getAllValues().get(1).sone(), is(sone));
+               verify(core, never()).touchConfiguration();
+       }
+
+       @Test
+       public void soneInserterExitsIfSoneIsUnknown() {
+               SoneModificationDetector soneModificationDetector =
+                               mock(SoneModificationDetector.class);
+               SoneInserter soneInserter =
+                               new SoneInserter(core, eventBus, freenetInterface, "SoneId",
+                                               soneModificationDetector, 1);
+               when(soneModificationDetector.isEligibleForInsert()).thenReturn(true);
+               when(core.getSone("SoneId")).thenReturn(Optional.<Sone>absent());
+               soneInserter.serviceRun();
+       }
+
+       @Test
+       public void soneInserterCatchesExceptionAndContinues() {
+               SoneModificationDetector soneModificationDetector =
+                               mock(SoneModificationDetector.class);
+               final SoneInserter soneInserter =
+                               new SoneInserter(core, eventBus, freenetInterface, "SoneId",
+                                               soneModificationDetector, 1);
+               Answer<Optional<Sone>> stopInserterAndThrowException =
+                               new Answer<Optional<Sone>>() {
+                                       @Override
+                                       public Optional<Sone> answer(
+                                                       InvocationOnMock invocation) {
+                                               soneInserter.stop();
+                                               throw new NullPointerException();
+                                       }
+                               };
+               when(soneModificationDetector.isEligibleForInsert()).thenAnswer(
+                               stopInserterAndThrowException);
+               soneInserter.serviceRun();
+       }
+
+       @Test
+       public void templateIsRenderedCorrectlyForManifestElement()
+       throws IOException {
+               Map<String, Object> soneProperties = new HashMap<String, Object>();
+               soneProperties.put("id", "SoneId");
+               ManifestCreator manifestCreator = new ManifestCreator(core, soneProperties);
+               long now = currentTimeMillis();
+               when(core.getStartupTime()).thenReturn(now);
+               ManifestElement manifestElement = manifestCreator.createManifestElement("test.txt", "plain/text; charset=utf-8", "sone-inserter-manifest.txt");
+               assertThat(manifestElement.getName(), is("test.txt"));
+               assertThat(manifestElement.getMimeTypeOverride(), is("plain/text; charset=utf-8"));
+               String templateContent = new String(toByteArray(manifestElement.getData().getInputStream()), Charsets.UTF_8);
+               assertThat(templateContent, containsString("Sone Version: " + SonePlugin.VERSION.toString() + "\n"));
+               assertThat(templateContent, containsString("Core Startup: " + now + "\n"));
+               assertThat(templateContent, containsString("Sone ID: " + "SoneId" + "\n"));
+       }
+
+       @Test
+       public void invalidTemplateReturnsANullManifestElement() {
+               Map<String, Object> soneProperties = new HashMap<String, Object>();
+               ManifestCreator manifestCreator = new ManifestCreator(core, soneProperties);
+               assertThat(manifestCreator.createManifestElement("test.txt",
+                               "plain/text; charset=utf-8",
+                               "sone-inserter-invalid-manifest.txt"),
+                               nullValue());
+       }
+
+       @Test
+       public void errorWhileRenderingTemplateReturnsANullManifestElement() {
+               Map<String, Object> soneProperties = new HashMap<String, Object>();
+               ManifestCreator manifestCreator = new ManifestCreator(core, soneProperties);
+               when(core.toString()).thenThrow(NullPointerException.class);
+               assertThat(manifestCreator.createManifestElement("test.txt",
+                               "plain/text; charset=utf-8",
+                               "sone-inserter-faulty-manifest.txt"),
+                               nullValue());
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/core/SoneModificationDetectorTest.java b/src/test/java/net/pterodactylus/sone/core/SoneModificationDetectorTest.java
new file mode 100644 (file)
index 0000000..d70cc5c
--- /dev/null
@@ -0,0 +1,155 @@
+package net.pterodactylus.sone.core;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import net.pterodactylus.sone.core.SoneModificationDetector.LockableFingerprintProvider;
+import net.pterodactylus.sone.data.Sone;
+
+import com.google.common.base.Ticker;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit test for {@link SoneModificationDetector}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class SoneModificationDetectorTest {
+
+       private final Ticker ticker = mock(Ticker.class);
+       private final AtomicInteger insertionDelay = new AtomicInteger(60);
+       private final SoneModificationDetector soneModificationDetector;
+       private final LockableFingerprintProvider lockableFingerprintProvider = mock(LockableFingerprintProvider.class);
+
+       public SoneModificationDetectorTest() {
+               when(lockableFingerprintProvider.getFingerprint()).thenReturn("original");
+               when(lockableFingerprintProvider.isLocked()).thenReturn(false);
+               soneModificationDetector = new SoneModificationDetector(ticker, lockableFingerprintProvider, insertionDelay);
+       }
+
+       private void modifySone() {
+               modifySone("");
+       }
+
+       private void modifySone(String uniqueValue) {
+               when(lockableFingerprintProvider.getFingerprint()).thenReturn("modified" + uniqueValue);
+       }
+
+       private void passTime(int seconds) {
+               when(ticker.read()).thenReturn(SECONDS.toNanos(seconds));
+       }
+
+       private void lockSone() {
+               when(lockableFingerprintProvider.isLocked()).thenReturn(true);
+       }
+
+       private void unlockSone() {
+               when(lockableFingerprintProvider.isLocked()).thenReturn(false);
+       }
+
+       @Before
+       public void setupOriginalFingerprint() {
+           soneModificationDetector.setFingerprint("original");
+       }
+
+       @Test
+       public void normalConstructorCanBeCalled() {
+               new SoneModificationDetector(lockableFingerprintProvider, insertionDelay);
+       }
+
+       @Test
+       public void sonesStartOutAsNotEligible() {
+               assertThat(soneModificationDetector.isModified(), is(false));
+               assertThat(soneModificationDetector.isEligibleForInsert(), is(false));
+       }
+
+       @Test
+       public void originalFingerprintIsRetained() {
+               assertThat(soneModificationDetector.getOriginalFingerprint(), is("original"));
+       }
+
+       @Test
+       public void modifiedSoneIsEligibleAfter60Seconds() {
+               modifySone();
+               assertThat(soneModificationDetector.isModified(), is(true));
+               assertThat(soneModificationDetector.isEligibleForInsert(), is(false));
+               passTime(100);
+               assertThat(soneModificationDetector.isModified(), is(true));
+               assertThat(soneModificationDetector.isEligibleForInsert(), is(true));
+       }
+
+       @Test
+       public void modifiedAndRemodifiedSoneIsEligibleAfter90Seconds() {
+               modifySone();
+               assertThat(soneModificationDetector.isModified(), is(true));
+               assertThat(soneModificationDetector.isEligibleForInsert(), is(false));
+               passTime(30);
+               modifySone("2");
+               assertThat(soneModificationDetector.isModified(), is(true));
+               assertThat(soneModificationDetector.isEligibleForInsert(), is(false));
+               passTime(61);
+               assertThat(soneModificationDetector.isModified(), is(true));
+               assertThat(soneModificationDetector.isEligibleForInsert(), is(false));
+               passTime(91);
+               assertThat(soneModificationDetector.isModified(), is(true));
+               assertThat(soneModificationDetector.isEligibleForInsert(), is(true));
+       }
+
+       @Test
+       public void modifiedSoneIsNotEligibleAfter30Seconds() {
+               modifySone();
+               passTime(30);
+               assertThat(soneModificationDetector.isEligibleForInsert(), is(false));
+       }
+
+       @Test
+       public void lockedAndModifiedSoneIsNotEligibleAfter60Seconds() {
+               lockSone();
+               assertThat(soneModificationDetector.isEligibleForInsert(), is(false));
+               modifySone();
+               assertThat(soneModificationDetector.isEligibleForInsert(), is(false));
+               passTime(100);
+               assertThat(soneModificationDetector.isEligibleForInsert(), is(false));
+       }
+
+       @Test
+       public void lockingAndUnlockingASoneRestartsTheWaitPeriod() {
+               modifySone();
+               lockSone();
+               passTime(30);
+               assertThat(soneModificationDetector.isEligibleForInsert(), is(false));
+               unlockSone();
+               assertThat(soneModificationDetector.isEligibleForInsert(), is(false));
+               passTime(60);
+               assertThat(soneModificationDetector.isEligibleForInsert(), is(false));
+               passTime(90);
+               assertThat(soneModificationDetector.isEligibleForInsert(), is(true));
+       }
+
+       @Test
+       public void settingFingerprintWillResetTheEligibility() {
+               modifySone();
+               assertThat(soneModificationDetector.isEligibleForInsert(), is(false));
+               passTime(100);
+               assertThat(soneModificationDetector.isEligibleForInsert(), is(true));
+               soneModificationDetector.setFingerprint("modified");
+               assertThat(soneModificationDetector.isEligibleForInsert(), is(false));
+       }
+
+       @Test
+       public void changingInsertionDelayWillInfluenceEligibility() {
+               modifySone();
+               assertThat(soneModificationDetector.isEligibleForInsert(), is(false));
+               passTime(100);
+               assertThat(soneModificationDetector.isEligibleForInsert(), is(true));
+               insertionDelay.set(120);
+               assertThat(soneModificationDetector.isEligibleForInsert(), is(false));
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/core/SoneParserTest.java b/src/test/java/net/pterodactylus/sone/core/SoneParserTest.java
new file mode 100644 (file)
index 0000000..dc19195
--- /dev/null
@@ -0,0 +1,832 @@
+package net.pterodactylus.sone.core;
+
+import static com.google.common.base.Optional.of;
+import static freenet.keys.InsertableClientSSK.createRandom;
+import static java.lang.System.currentTimeMillis;
+import static java.util.UUID.randomUUID;
+import static java.util.concurrent.TimeUnit.DAYS;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.nullValue;
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyLong;
+import static org.mockito.Matchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import net.pterodactylus.sone.data.Album;
+import net.pterodactylus.sone.data.Album.Modifier;
+import net.pterodactylus.sone.data.Client;
+import net.pterodactylus.sone.data.Image;
+import net.pterodactylus.sone.data.Post;
+import net.pterodactylus.sone.data.PostReply;
+import net.pterodactylus.sone.data.Profile;
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.sone.database.AlbumBuilder;
+import net.pterodactylus.sone.database.ImageBuilder;
+import net.pterodactylus.sone.database.PostBuilder;
+import net.pterodactylus.sone.database.PostReplyBuilder;
+import net.pterodactylus.sone.database.SoneBuilder;
+import net.pterodactylus.sone.database.memory.MemorySoneBuilder;
+import net.pterodactylus.sone.freenet.wot.Identity;
+import net.pterodactylus.sone.freenet.wot.OwnIdentity;
+
+import freenet.crypt.DummyRandomSource;
+import freenet.keys.FreenetURI;
+import freenet.keys.InsertableClientSSK;
+
+import com.google.common.base.Optional;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+
+/**
+ * Unit test for {@link SoneParser}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class SoneParserTest {
+
+       private final Core core = mock(Core.class);
+       private final SoneParser soneParser = new SoneParser(core);
+       private final Sone sone = mock(Sone.class);
+       private FreenetURI requestUri = mock(FreenetURI.class);
+       private final PostBuilder postBuilder = mock(PostBuilder.class);
+       private final List<Post> createdPosts = new ArrayList<Post>();
+       private Post post = mock(Post.class);
+       private final PostReplyBuilder postReplyBuilder = mock(PostReplyBuilder.class);
+       private final Set<PostReply> createdPostReplies = new HashSet<PostReply>();
+       private PostReply postReply = mock(PostReply.class);
+       private final AlbumBuilder albumBuilder = mock(AlbumBuilder.class);
+       private final ListMultimap<Album, Album>
+                       nestedAlbums = ArrayListMultimap.create();
+       private final ListMultimap<Album, Image> albumImages = ArrayListMultimap.create();
+       private Album album = mock(Album.class);
+       private final Map<String, Album> albums = new HashMap<String, Album>();
+       private final ImageBuilder imageBuilder = mock(ImageBuilder.class);
+       private Image image = mock(Image.class);
+       private final Map<String, Image> images = new HashMap<String, Image>();
+
+       @Before
+       public void setupSone() {
+               setupSone(this.sone, Identity.class);
+       }
+
+       private void setupSone(Sone sone, Class<? extends Identity> identityClass) {
+               Identity identity = mock(identityClass);
+               InsertableClientSSK clientSSK =
+                               createRandom(new DummyRandomSource(), "WoT");
+               when(identity.getRequestUri()).thenReturn(clientSSK.getURI().toString());
+               when(identity.getId()).thenReturn("identity");
+               when(sone.getId()).thenReturn("identity");
+               when(sone.getIdentity()).thenReturn(identity);
+               requestUri = clientSSK.getURI().setKeyType("USK").setDocName("Sone");
+               when(sone.getRequestUri()).thenAnswer(new Answer<FreenetURI>() {
+                       @Override
+                       public FreenetURI answer(InvocationOnMock invocation)
+                       throws Throwable {
+                               return requestUri;
+                       }
+               });
+               when(sone.getTime())
+                               .thenReturn(currentTimeMillis() - DAYS.toMillis(1));
+       }
+
+       @Before
+       public void setupSoneBuilder() {
+               when(core.soneBuilder()).thenAnswer(new Answer<SoneBuilder>() {
+                       @Override
+                       public SoneBuilder answer(InvocationOnMock invocation) {
+                               return new MemorySoneBuilder(null);
+                       }
+               });
+       }
+
+       @Before
+       public void setupPost() {
+               when(post.getRecipientId()).thenReturn(Optional.<String>absent());
+       }
+
+       @Before
+       public void setupPostBuilder() {
+               when(postBuilder.withId(anyString())).thenAnswer(new Answer<PostBuilder>() {
+                       @Override
+                       public PostBuilder answer(InvocationOnMock invocation) throws Throwable {
+                               when(post.getId()).thenReturn((String) invocation.getArguments()[0]);
+                               return postBuilder;
+                       }
+               });
+               when(postBuilder.from(anyString())).thenAnswer(new Answer<PostBuilder>() {
+                       @Override
+                       public PostBuilder answer(InvocationOnMock invocation) throws Throwable {
+                               final Sone sone = mock(Sone.class);
+                               when(sone.getId()).thenReturn((String) invocation.getArguments()[0]);
+                               when(post.getSone()).thenReturn(sone);
+                               return postBuilder;
+                       }
+               });
+               when(postBuilder.withTime(anyLong())).thenAnswer(new Answer<PostBuilder>() {
+                       @Override
+                       public PostBuilder answer(InvocationOnMock invocation) throws Throwable {
+                               when(post.getTime()).thenReturn((Long) invocation.getArguments()[0]);
+                               return postBuilder;
+                       }
+               });
+               when(postBuilder.withText(anyString())).thenAnswer(new Answer<PostBuilder>() {
+                       @Override
+                       public PostBuilder answer(InvocationOnMock invocation) throws Throwable {
+                               when(post.getText()).thenReturn((String) invocation.getArguments()[0]);
+                               return postBuilder;
+                       }
+               });
+               when(postBuilder.to(anyString())).thenAnswer(new Answer<PostBuilder>() {
+                       @Override
+                       public PostBuilder answer(InvocationOnMock invocation) throws Throwable {
+                               when(post.getRecipientId()).thenReturn(of((String) invocation.getArguments()[0]));
+                               return postBuilder;
+                       }
+               });
+               when(postBuilder.build()).thenAnswer(new Answer<Post>() {
+                       @Override
+                       public Post answer(InvocationOnMock invocation) throws Throwable {
+                               Post post = SoneParserTest.this.post;
+                               SoneParserTest.this.post = mock(Post.class);
+                               setupPost();
+                               createdPosts.add(post);
+                               return post;
+                       }
+               });
+               when(core.postBuilder()).thenReturn(postBuilder);
+       }
+
+       @Before
+       public void setupPostReplyBuilder() {
+               when(postReplyBuilder.withId(anyString())).thenAnswer(new Answer<PostReplyBuilder>() {
+                       @Override
+                       public PostReplyBuilder answer(InvocationOnMock invocation) throws Throwable {
+                               when(postReply.getId()).thenReturn((String) invocation.getArguments()[0]);
+                               return postReplyBuilder;
+                       }
+               });
+               when(postReplyBuilder.from(anyString())).thenAnswer(
+                               new Answer<PostReplyBuilder>() {
+                                       @Override
+                                       public PostReplyBuilder answer(
+                                                       InvocationOnMock invocation) throws Throwable {
+                                               Sone sone = when(mock(Sone.class).getId()).thenReturn(
+                                                               (String) invocation.getArguments()[0])
+                                                               .getMock();
+                                               when(postReply.getSone()).thenReturn(sone);
+                                               return postReplyBuilder;
+                                       }
+                               });
+               when(postReplyBuilder.to(anyString())).thenAnswer(
+                               new Answer<PostReplyBuilder>() {
+                                       @Override
+                                       public PostReplyBuilder answer(
+                                                       InvocationOnMock invocation) throws Throwable {
+                                               when(postReply.getPostId()).thenReturn(
+                                                               (String) invocation.getArguments()[0]);
+                                               Post post = when(mock(Post.class).getId()).thenReturn(
+                                                               (String) invocation.getArguments()[0])
+                                                               .getMock();
+                                               when(postReply.getPost()).thenReturn(of(post));
+                                               return postReplyBuilder;
+                                       }
+                               });
+               when(postReplyBuilder.withTime(anyLong())).thenAnswer(
+                               new Answer<PostReplyBuilder>() {
+                                       @Override
+                                       public PostReplyBuilder answer(
+                                                       InvocationOnMock invocation) throws Throwable {
+                                               when(postReply.getTime()).thenReturn(
+                                                               (Long) invocation.getArguments()[0]);
+                                               return postReplyBuilder;
+                                       }
+                               });
+               when(postReplyBuilder.withText(anyString())).thenAnswer(new Answer<PostReplyBuilder>() {
+                       @Override
+                       public PostReplyBuilder answer(InvocationOnMock invocation) throws Throwable {
+                               when(postReply.getText()).thenReturn((String) invocation.getArguments()[0]);
+                               return postReplyBuilder;
+                       }
+               });
+               when(postReplyBuilder.build()).thenAnswer(new Answer<PostReply>() {
+                       @Override
+                       public PostReply answer(InvocationOnMock invocation) throws Throwable {
+                               PostReply postReply = SoneParserTest.this.postReply;
+                               createdPostReplies.add(postReply);
+                               SoneParserTest.this.postReply = mock(PostReply.class);
+                               return postReply;
+                       }
+               });
+               when(core.postReplyBuilder()).thenReturn(postReplyBuilder);
+       }
+
+       @Before
+       public void setupAlbum() {
+               final Album album = SoneParserTest.this.album;
+               when(album.getAlbumImage()).thenReturn(mock(Image.class));
+               doAnswer(new Answer<Void>() {
+                       @Override
+                       public Void answer(InvocationOnMock invocation) {
+                               nestedAlbums.put(album, (Album) invocation.getArguments()[0]);
+                               return null;
+                       }
+               }).when(album).addAlbum(any(Album.class));
+               doAnswer(new Answer<Void>() {
+                       @Override
+                       public Void answer(InvocationOnMock invocation) {
+                               albumImages.put(album, (Image) invocation.getArguments()[0]);
+                               return null;
+                       }
+               }).when(album).addImage(any(Image.class));
+               when(album.getAlbums()).thenAnswer(new Answer<List<Album>>() {
+                       @Override
+                       public List<Album> answer(InvocationOnMock invocation) {
+                               return nestedAlbums.get(album);
+                       }
+               });
+               when(album.getImages()).thenAnswer(new Answer<List<Image>>() {
+                       @Override
+                       public List<Image> answer(InvocationOnMock invocation) {
+                               return albumImages.get(album);
+                       }
+               });
+               final Modifier albumModifier = new Modifier() {
+                       private String title = album.getTitle();
+                       private String description = album.getDescription();
+                       private String imageId = album.getAlbumImage().getId();
+
+                       @Override
+                       public Modifier setTitle(String title) {
+                               this.title = title;
+                               return this;
+                       }
+
+                       @Override
+                       public Modifier setDescription(String description) {
+                               this.description = description;
+                               return this;
+                       }
+
+                       @Override
+                       public Modifier setAlbumImage(String imageId) {
+                               this.imageId = imageId;
+                               return this;
+                       }
+
+                       @Override
+                       public Album update() throws IllegalStateException {
+                               when(album.getTitle()).thenReturn(title);
+                               when(album.getDescription()).thenReturn(description);
+                               Image image = mock(Image.class);
+                               when(image.getId()).thenReturn(imageId);
+                               when(album.getAlbumImage()).thenReturn(image);
+                               return album;
+                       }
+               };
+               when(album.modify()).thenReturn(albumModifier);
+       }
+
+       @Before
+       public void setupAlbumBuilder() {
+               when(albumBuilder.withId(anyString())).thenAnswer(new Answer<AlbumBuilder>() {
+                       @Override
+                       public AlbumBuilder answer(InvocationOnMock invocation) {
+                               when(album.getId()).thenReturn((String) invocation.getArguments()[0]);
+                               return albumBuilder;
+                       }
+               });
+               when(albumBuilder.randomId()).thenAnswer(new Answer<AlbumBuilder>() {
+                       @Override
+                       public AlbumBuilder answer(InvocationOnMock invocation) {
+                               when(album.getId()).thenReturn(randomUUID().toString());
+                               return albumBuilder;
+                       }
+               });
+               when(albumBuilder.by(any(Sone.class))).thenAnswer(new Answer<AlbumBuilder>() {
+                       @Override
+                       public AlbumBuilder answer(InvocationOnMock invocation) {
+                               when(album.getSone()).thenReturn((Sone) invocation.getArguments()[0]);
+                               return albumBuilder;
+                       }
+               });
+               when(albumBuilder.build()).thenAnswer(new Answer<Album>() {
+                       @Override
+                       public Album answer(InvocationOnMock invocation) {
+                               Album album = SoneParserTest.this.album;
+                               albums.put(album.getId(), album);
+                               SoneParserTest.this.album = mock(Album.class);
+                               setupAlbum();
+                               return album;
+                       }
+               });
+               when(core.albumBuilder()).thenReturn(albumBuilder);
+       }
+
+       @Before
+       public void setupAlbums() {
+               when(core.getAlbum(anyString())).thenAnswer(new Answer<Album>() {
+                       @Override
+                       public Album answer(InvocationOnMock invocation)
+                       throws Throwable {
+                               return albums.get(invocation.getArguments()[0]);
+                       }
+               });
+       }
+
+       @Before
+       public void setupImage() {
+               final Image image = SoneParserTest.this.image;
+               Image.Modifier modifier = new Image.Modifier() {
+                       private Sone sone = image.getSone();
+                       private long creationTime = image.getCreationTime();
+                       private String key = image.getKey();
+                       private String title = image.getTitle();
+                       private String description = image.getDescription();
+                       private int width = image.getWidth();
+                       private int height = image.getHeight();
+
+                       @Override
+                       public Image.Modifier setSone(Sone sone) {
+                               this.sone = sone;
+                               return this;
+                       }
+
+                       @Override
+                       public Image.Modifier setCreationTime(long creationTime) {
+                               this.creationTime = creationTime;
+                               return this;
+                       }
+
+                       @Override
+                       public Image.Modifier setKey(String key) {
+                               this.key = key;
+                               return this;
+                       }
+
+                       @Override
+                       public Image.Modifier setTitle(String title) {
+                               this.title = title;
+                               return this;
+                       }
+
+                       @Override
+                       public Image.Modifier setDescription(String description) {
+                               this.description = description;
+                               return this;
+                       }
+
+                       @Override
+                       public Image.Modifier setWidth(int width) {
+                               this.width = width;
+                               return this;
+                       }
+
+                       @Override
+                       public Image.Modifier setHeight(int height) {
+                               this.height = height;
+                               return this;
+                       }
+
+                       @Override
+                       public Image update() throws IllegalStateException {
+                               when(image.getSone()).thenReturn(sone);
+                               when(image.getCreationTime()).thenReturn(creationTime);
+                               when(image.getKey()).thenReturn(key);
+                               when(image.getTitle()).thenReturn(title);
+                               when(image.getDescription()).thenReturn(description);
+                               when(image.getWidth()).thenReturn(width);
+                               when(image.getHeight()).thenReturn(height);
+                               return image;
+                       }
+               };
+               when(image.getSone()).thenReturn(sone);
+               when(image.modify()).thenReturn(modifier);
+       }
+
+       @Before
+       public void setupImageBuilder() {
+               when(imageBuilder.randomId()).thenAnswer(new Answer<ImageBuilder>() {
+                       @Override
+                       public ImageBuilder answer(InvocationOnMock invocation) {
+                               when(image.getId()).thenReturn(randomUUID().toString());
+                               return imageBuilder;
+                       }
+               });
+               when(imageBuilder.withId(anyString())).thenAnswer(new Answer<ImageBuilder>() {
+                       @Override
+                       public ImageBuilder answer(InvocationOnMock invocation) {
+                               when(image.getId()).thenReturn(
+                                               (String) invocation.getArguments()[0]);
+                               return imageBuilder;
+                       }
+               });
+               when(imageBuilder.build()).thenAnswer(new Answer<Image>() {
+                       @Override
+                       public Image answer(InvocationOnMock invocation) {
+                               Image image = SoneParserTest.this.image;
+                               images.put(image.getId(), image);
+                               SoneParserTest.this.image = mock(Image.class);
+                               setupImage();
+                               return image;
+                       }
+               });
+               when(core.imageBuilder()).thenReturn(imageBuilder);
+       }
+
+       @Before
+       public void setupImages() {
+               when(core.getImage(anyString())).thenAnswer(new Answer<Image>() {
+                       @Override
+                       public Image answer(InvocationOnMock invocation)
+                       throws Throwable {
+                               return images.get(invocation.getArguments()[0]);
+                       }
+               });
+       }
+       @Test
+       public void parsingASoneFailsWhenDocumentIsNotXml() throws SoneException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-not-xml.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWhenDocumentHasNegativeProtocolVersion() throws SoneException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-negative-protocol-version.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWhenProtocolVersionIsTooLarge() throws SoneException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-too-large-protocol-version.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWhenThereIsNoTime() throws SoneException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-no-time.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWhenTimeIsNotNumeric() throws SoneException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-time-not-numeric.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWhenProfileIsMissing() throws SoneException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-no-profile.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWhenProfileFieldIsMissingAFieldName() throws SoneException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-profile-missing-field-name.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWhenProfileFieldNameIsEmpty() throws SoneException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-profile-empty-field-name.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWhenProfileFieldNameIsNotUnique() throws SoneException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-profile-duplicate-field-name.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneSucceedsWithoutPayload() throws SoneException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-no-payload.xml");
+               assertThat(soneParser.parseSone(sone, inputStream).getTime(), is(
+                               1407197508000L));
+       }
+
+       @Test
+       public void parsingALocalSoneSucceedsWithoutPayload() throws SoneException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-no-payload.xml");
+               Sone localSone = mock(Sone.class);
+               setupSone(localSone, OwnIdentity.class);
+               when(localSone.isLocal()).thenReturn(true);
+               Sone parsedSone = soneParser.parseSone(localSone, inputStream);
+               assertThat(parsedSone.getTime(), is(1407197508000L));
+               assertThat(parsedSone.isLocal(), is(true));
+       }
+
+       @Test
+       public void parsingASoneSucceedsWithoutProtocolVersion() throws SoneException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-missing-protocol-version.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), not(
+                               nullValue()));
+       }
+
+       @Test
+       public void parsingASoneFailsWithMissingClientName() throws SoneException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-missing-client-name.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWithMissingClientVersion() throws SoneException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-missing-client-version.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneSucceedsWithClientInfo() throws SoneException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-with-client-info.xml");
+               assertThat(soneParser.parseSone(sone, inputStream).getClient(), is(new Client("some-client", "some-version")));
+       }
+
+       @Test
+       public void parsingASoneSucceedsWithProfile() throws SoneException,
+       MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-with-profile.xml");
+               final Profile profile = soneParser.parseSone(sone, inputStream).getProfile();
+               assertThat(profile.getFirstName(), is("first"));
+               assertThat(profile.getMiddleName(), is("middle"));
+               assertThat(profile.getLastName(), is("last"));
+               assertThat(profile.getBirthDay(), is(18));
+               assertThat(profile.getBirthMonth(), is(12));
+               assertThat(profile.getBirthYear(), is(1976));
+       }
+
+       @Test
+       public void parsingASoneSucceedsWithoutProfileFields() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-without-fields.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), notNullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWithoutPostId() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-without-post-id.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWithoutPostTime() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-without-post-time.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWithoutPostText() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-without-post-text.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWithInvalidPostTime() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-with-invalid-post-time.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneSucceedsWithValidPostTime() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-with-valid-post-time.xml");
+               final List<Post> posts = soneParser.parseSone(sone, inputStream).getPosts();
+               assertThat(posts, is(createdPosts));
+               assertThat(posts.get(0).getSone().getId(), is(sone.getId()));
+               assertThat(posts.get(0).getId(), is("post-id"));
+               assertThat(posts.get(0).getTime(), is(1407197508000L));
+               assertThat(posts.get(0).getRecipientId(), is(Optional.<String>absent()));
+               assertThat(posts.get(0).getText(), is("text"));
+       }
+
+       @Test
+       public void parsingASoneSucceedsWithRecipient() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-with-recipient.xml");
+               final List<Post> posts = soneParser.parseSone(sone, inputStream).getPosts();
+               assertThat(posts, is(createdPosts));
+               assertThat(posts.get(0).getSone().getId(), is(sone.getId()));
+               assertThat(posts.get(0).getId(), is("post-id"));
+               assertThat(posts.get(0).getTime(), is(1407197508000L));
+               assertThat(posts.get(0).getRecipientId(), is(of(
+                               "1234567890123456789012345678901234567890123")));
+               assertThat(posts.get(0).getText(), is("text"));
+       }
+
+       @Test
+       public void parsingASoneSucceedsWithInvalidRecipient() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-with-invalid-recipient.xml");
+               final List<Post> posts = soneParser.parseSone(sone, inputStream).getPosts();
+               assertThat(posts, is(createdPosts));
+               assertThat(posts.get(0).getSone().getId(), is(sone.getId()));
+               assertThat(posts.get(0).getId(), is("post-id"));
+               assertThat(posts.get(0).getTime(), is(1407197508000L));
+               assertThat(posts.get(0).getRecipientId(), is(Optional.<String>absent()));
+               assertThat(posts.get(0).getText(), is("text"));
+       }
+
+       @Test
+       public void parsingASoneFailsWithoutPostReplyId() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-without-post-reply-id.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWithoutPostReplyPostId() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-without-post-reply-post-id.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWithoutPostReplyTime() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-without-post-reply-time.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWithoutPostReplyText() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-without-post-reply-text.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWithInvalidPostReplyTime() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-with-invalid-post-reply-time.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneSucceedsWithValidPostReplyTime() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-with-valid-post-reply-time.xml");
+               final Set<PostReply> postReplies = soneParser.parseSone(sone, inputStream).getReplies();
+               assertThat(postReplies, is(createdPostReplies));
+               PostReply postReply = createdPostReplies.iterator().next();
+               assertThat(postReply.getId(), is("reply-id"));
+               assertThat(postReply.getPostId(), is("post-id"));
+               assertThat(postReply.getPost().get().getId(), is("post-id"));
+               assertThat(postReply.getSone().getId(), is("identity"));
+               assertThat(postReply.getTime(), is(1407197508000L));
+               assertThat(postReply.getText(), is("reply-text"));
+       }
+
+       @Test
+       public void parsingASoneSucceedsWithoutLikedPostIds() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-without-liked-post-ids.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), not(
+                               nullValue()));
+       }
+
+       @Test
+       public void parsingASoneSucceedsWithLikedPostIds() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-with-liked-post-ids.xml");
+               assertThat(soneParser.parseSone(sone, inputStream).getLikedPostIds(), is(
+                               (Set<String>) ImmutableSet.of("liked-post-id")));
+       }
+
+       @Test
+       public void parsingASoneSucceedsWithoutLikedPostReplyIds() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-without-liked-post-reply-ids.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), not(
+                               nullValue()));
+       }
+
+       @Test
+       public void parsingASoneSucceedsWithLikedPostReplyIds() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-with-liked-post-reply-ids.xml");
+               assertThat(soneParser.parseSone(sone, inputStream).getLikedReplyIds(), is(
+                               (Set<String>) ImmutableSet.of("liked-post-reply-id")));
+       }
+
+       @Test
+       public void parsingASoneSucceedsWithoutAlbums() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-without-albums.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), not(
+                               nullValue()));
+       }
+
+       @Test
+       public void parsingASoneFailsWithoutAlbumId() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-without-album-id.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWithoutAlbumTitle() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-without-album-title.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneSucceedsWithNestedAlbums() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-with-multiple-albums.xml");
+               final Sone parsedSone = soneParser.parseSone(sone, inputStream);
+               assertThat(parsedSone, not(nullValue()));
+               assertThat(parsedSone.getRootAlbum().getAlbums(), hasSize(1));
+               Album album = parsedSone.getRootAlbum().getAlbums().get(0);
+               assertThat(album.getId(), is("album-id-1"));
+               assertThat(album.getTitle(), is("album-title"));
+               assertThat(album.getDescription(), is("album-description"));
+               assertThat(album.getAlbums(), hasSize(1));
+               Album nestedAlbum = album.getAlbums().get(0);
+               assertThat(nestedAlbum.getId(), is("album-id-2"));
+               assertThat(nestedAlbum.getTitle(), is("album-title-2"));
+               assertThat(nestedAlbum.getDescription(), is("album-description-2"));
+               assertThat(nestedAlbum.getAlbums(), hasSize(0));
+       }
+
+       @Test
+       public void parsingASoneFailsWithInvalidParentAlbumId() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-with-invalid-parent-album-id.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneSucceedsWithoutImages() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-without-images.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), not(
+                               nullValue()));
+       }
+
+       @Test
+       public void parsingASoneFailsWithoutImageId() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-without-image-id.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWithoutImageTime() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-without-image-time.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWithoutImageKey() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-without-image-key.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWithoutImageTitle() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-without-image-title.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWithoutImageWidth() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-without-image-width.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWithoutImageHeight() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-without-image-height.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWithInvalidImageWidth() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-with-invalid-image-width.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneFailsWithInvalidImageHeight() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-with-invalid-image-height.xml");
+               assertThat(soneParser.parseSone(sone, inputStream), nullValue());
+       }
+
+       @Test
+       public void parsingASoneSucceedsWithImage() throws SoneException, MalformedURLException {
+               InputStream inputStream = getClass().getResourceAsStream("sone-parser-with-image.xml");
+               final Sone sone = soneParser.parseSone(this.sone, inputStream);
+               assertThat(sone, not(nullValue()));
+               assertThat(sone.getRootAlbum().getAlbums(), hasSize(1));
+               assertThat(sone.getRootAlbum().getAlbums().get(0).getImages(), hasSize(1));
+               Image image = sone.getRootAlbum().getAlbums().get(0).getImages().get(0);
+               assertThat(image.getId(), is("image-id"));
+               assertThat(image.getCreationTime(), is(1407197508000L));
+               assertThat(image.getKey(), is("KSK@GPLv3.txt"));
+               assertThat(image.getTitle(), is("image-title"));
+               assertThat(image.getDescription(), is("image-description"));
+               assertThat(image.getWidth(), is(1920));
+               assertThat(image.getHeight(), is(1080));
+               assertThat(sone.getProfile().getAvatar(), is("image-id"));
+       }
+
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/core/SoneRescuerTest.java b/src/test/java/net/pterodactylus/sone/core/SoneRescuerTest.java
new file mode 100644 (file)
index 0000000..ede6f13
--- /dev/null
@@ -0,0 +1,136 @@
+package net.pterodactylus.sone.core;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import net.pterodactylus.sone.data.Sone;
+
+import freenet.keys.FreenetURI;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+
+/**
+ * Unit test for {@link SoneRescuer}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class SoneRescuerTest {
+
+       private static final long CURRENT_EDITION = 12L;
+       private static final long SOME_OTHER_EDITION = 15L;
+       private final Core core = mock(Core.class);
+       private final SoneDownloader soneDownloader = mock(SoneDownloader.class);
+       private final Sone sone = mock(Sone.class);
+       private SoneRescuer soneRescuer;
+
+       @Before
+       public void setupSone() {
+               FreenetURI soneUri = mock(FreenetURI.class);
+               when(soneUri.getEdition()).thenReturn(CURRENT_EDITION);
+               when(sone.getRequestUri()).thenReturn(soneUri);
+       }
+
+       @Before
+       public void setupSoneRescuer() {
+               soneRescuer = new SoneRescuer(core, soneDownloader, sone);
+       }
+
+       @Test
+       public void newSoneRescuerIsNotFetchingAnything() {
+               assertThat(soneRescuer.isFetching(), is(false));
+       }
+
+       @Test
+       public void newSoneRescuerStartsAtCurrentEditionOfSone() {
+               assertThat(soneRescuer.getCurrentEdition(), is(CURRENT_EDITION));
+       }
+
+       @Test
+       public void newSoneRescuerHasANextEditionToGet() {
+               assertThat(soneRescuer.hasNextEdition(), is(true));
+       }
+
+       @Test
+       public void soneRescuerDoesNotHaveANextEditionIfCurrentEditionIsZero() {
+               when(sone.getRequestUri().getEdition()).thenReturn(0L);
+               soneRescuer = new SoneRescuer(core, soneDownloader, sone);
+               assertThat(soneRescuer.hasNextEdition(), is(false));
+       }
+
+       @Test
+       public void nextEditionIsOneSmallerThanTheCurrentEdition() {
+               assertThat(soneRescuer.getNextEdition(), is(CURRENT_EDITION - 1));
+       }
+
+       @Test
+       public void currentEditionCanBeSet() {
+               soneRescuer.setEdition(SOME_OTHER_EDITION);
+               assertThat(soneRescuer.getCurrentEdition(), is(SOME_OTHER_EDITION));
+       }
+
+       @Test
+       public void lastFetchOfANewSoneRescuerWasSuccessful() {
+               assertThat(soneRescuer.isLastFetchSuccessful(), is(true));
+       }
+
+       @Test
+       public void mainLoopStopsWhenItShould() {
+               soneRescuer.stop();
+               soneRescuer.serviceRun();
+       }
+
+       @Test
+       public void successfulInsert() {
+               final Sone fetchedSone = mock(Sone.class);
+               returnUriOnInsert(fetchedSone);
+               soneRescuer.startNextFetch();
+               soneRescuer.serviceRun();
+               verify(core).lockSone(eq(sone));
+               verify(core).updateSone(eq(fetchedSone), eq(true));
+               assertThat(soneRescuer.isLastFetchSuccessful(), is(true));
+               assertThat(soneRescuer.isFetching(), is(false));
+       }
+
+       @Test
+       public void nonSuccessfulInsertIsRecognized() {
+               returnUriOnInsert(null);
+               soneRescuer.startNextFetch();
+               soneRescuer.serviceRun();
+               verify(core).lockSone(eq(sone));
+               verify(core, never()).updateSone(any(Sone.class), eq(true));
+               assertThat(soneRescuer.isLastFetchSuccessful(), is(false));
+               assertThat(soneRescuer.isFetching(), is(false));
+       }
+
+       private void returnUriOnInsert(final Sone fetchedSone) {
+               FreenetURI keyWithMetaStrings = setupFreenetUri();
+               doAnswer(new Answer<Sone>() {
+                       @Override
+                       public Sone answer(InvocationOnMock invocation) throws Throwable {
+                               soneRescuer.stop();
+                               return fetchedSone;
+                       }
+               }).when(soneDownloader).fetchSone(eq(sone), eq(keyWithMetaStrings), eq(true));
+       }
+
+       private FreenetURI setupFreenetUri() {
+               FreenetURI sskKey = mock(FreenetURI.class);
+               FreenetURI keyWithDocName = mock(FreenetURI.class);
+               FreenetURI keyWithMetaStrings = mock(FreenetURI.class);
+               when(keyWithDocName.setMetaString(eq(new String[] { "sone.xml" }))).thenReturn(keyWithMetaStrings);
+               when(sskKey.setDocName(eq("Sone-" + CURRENT_EDITION))).thenReturn(keyWithDocName);
+               when(sone.getRequestUri().setKeyType(eq("SSK"))).thenReturn(sskKey);
+               return keyWithMetaStrings;
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/core/SoneUriTest.java b/src/test/java/net/pterodactylus/sone/core/SoneUriTest.java
new file mode 100644 (file)
index 0000000..879da4a
--- /dev/null
@@ -0,0 +1,38 @@
+package net.pterodactylus.sone.core;
+
+import static freenet.keys.InsertableClientSSK.createRandom;
+import static net.pterodactylus.sone.core.SoneUri.create;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+
+import freenet.crypt.DummyRandomSource;
+import freenet.keys.FreenetURI;
+
+import org.junit.Test;
+
+/**
+ * Unit test for {@link SoneUri}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class SoneUriTest {
+
+       @Test
+       public void callConstructorForIncreasedTestCoverage() {
+               new SoneUri();
+       }
+
+       @Test
+       public void returnedUriHasCorrectDocNameAndMetaStrings() {
+               FreenetURI uri = createRandom(new DummyRandomSource(), "test-0").getURI().uskForSSK();
+               assertThat(create(uri.toString()).getDocName(), is("Sone"));
+               assertThat(create(uri.toString()).getAllMetaStrings(), is(new String[0]));
+       }
+
+       @Test
+       public void malformedUriReturnsNull() {
+               assertThat(create("not a key"), nullValue());
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/core/UpdateCheckerTest.java b/src/test/java/net/pterodactylus/sone/core/UpdateCheckerTest.java
new file mode 100644 (file)
index 0000000..a5e3b2a
--- /dev/null
@@ -0,0 +1,233 @@
+package net.pterodactylus.sone.core;
+
+import static java.lang.Long.MAX_VALUE;
+import static net.pterodactylus.sone.main.SonePlugin.VERSION;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.ArgumentCaptor.forClass;
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.argThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import net.pterodactylus.sone.core.FreenetInterface.Callback;
+import net.pterodactylus.sone.core.FreenetInterface.Fetched;
+import net.pterodactylus.sone.core.event.UpdateFoundEvent;
+import net.pterodactylus.util.version.Version;
+
+import freenet.client.ClientMetadata;
+import freenet.client.FetchResult;
+import freenet.keys.FreenetURI;
+import freenet.support.api.Bucket;
+import freenet.support.io.ArrayBucket;
+
+import com.google.common.eventbus.EventBus;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+
+/**
+ * Unit test for {@link UpdateChecker}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class UpdateCheckerTest {
+
+       private final EventBus eventBus = mock(EventBus.class);
+       private final FreenetInterface freenetInterface = mock(FreenetInterface.class);
+       private final UpdateChecker updateChecker = new UpdateChecker(eventBus, freenetInterface);
+
+       @Before
+       public void startUpdateChecker() {
+               updateChecker.start();
+       }
+
+       @Test
+       public void newUpdateCheckerDoesNotHaveALatestVersion() {
+               assertThat(updateChecker.hasLatestVersion(), is(false));
+               assertThat(updateChecker.getLatestVersion(), is(VERSION));
+       }
+
+       @Test
+       public void startingAnUpdateCheckerRegisterAUsk() {
+               verify(freenetInterface).registerUsk(any(FreenetURI.class), any(Callback.class));
+       }
+
+       @Test
+       public void stoppingAnUpdateCheckerUnregistersAUsk() {
+               updateChecker.stop();
+               verify(freenetInterface).unregisterUsk(any(FreenetURI.class));
+       }
+
+       @Test
+       public void callbackDoesNotDownloadIfNewEditionIsNotFound() {
+               setupCallbackWithEdition(MAX_VALUE, false, false);
+               verify(freenetInterface, never()).fetchUri(any(FreenetURI.class));
+               verify(eventBus, never()).post(argThat(instanceOf(UpdateFoundEvent.class)));
+       }
+
+       private void setupCallbackWithEdition(long edition, boolean newKnownGood, boolean newSlot) {
+               ArgumentCaptor<FreenetURI> uri = forClass(FreenetURI.class);
+               ArgumentCaptor<Callback> callback = forClass(Callback.class);
+               verify(freenetInterface).registerUsk(uri.capture(), callback.capture());
+               callback.getValue().editionFound(uri.getValue(), edition, newKnownGood, newSlot);
+       }
+
+       @Test
+       public void callbackStartsIfNewEditionIsFound() {
+               setupFetchResult(createFutureFetchResult());
+               setupCallbackWithEdition(MAX_VALUE, true, false);
+               verifyAFreenetUriIsFetched();
+               ArgumentCaptor<UpdateFoundEvent> updateFoundEvent = forClass(UpdateFoundEvent.class);
+               verify(eventBus, times(1)).post(updateFoundEvent.capture());
+               assertThat(updateFoundEvent.getValue().version(), is(new Version(99, 0, 0)));
+               assertThat(updateFoundEvent.getValue().releaseTime(), is(11865368297000L));
+               assertThat(updateChecker.getLatestVersion(), is(new Version(99, 0, 0)));
+               assertThat(updateChecker.getLatestVersionDate(), is(11865368297000L));
+               assertThat(updateChecker.hasLatestVersion(), is(true));
+       }
+
+       private FetchResult createFutureFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("application/xml");
+               Bucket fetched = new ArrayBucket(("# MapConfigurationBackendVersion=1\n" +
+                               "CurrentVersion/Version: 99.0.0\n" +
+                               "CurrentVersion/ReleaseTime: 11865368297000").getBytes());
+               return new FetchResult(clientMetadata, fetched);
+       }
+
+       @Test
+       public void callbackDoesNotStartIfNoNewEditionIsFound() {
+               setupFetchResult(createPastFetchResult());
+               setupCallbackWithEdition(updateChecker.getLatestEdition(), true, false);
+               verifyAFreenetUriIsFetched();
+               verifyNoUpdateFoundEventIsFired();
+       }
+
+       private void setupFetchResult(final FetchResult pastFetchResult) {
+               when(freenetInterface.fetchUri(any(FreenetURI.class))).thenAnswer(new Answer<Fetched>() {
+                       @Override
+                       public Fetched answer(InvocationOnMock invocation) throws Throwable {
+                               FreenetURI freenetUri = (FreenetURI) invocation.getArguments()[0];
+                               return new Fetched(freenetUri, pastFetchResult);
+                       }
+               });
+       }
+
+       private FetchResult createPastFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("application/xml");
+               Bucket fetched = new ArrayBucket(("# MapConfigurationBackendVersion=1\n" +
+                               "CurrentVersion/Version: 0.2\n" +
+                               "CurrentVersion/ReleaseTime: 1289417883000").getBytes());
+               return new FetchResult(clientMetadata, fetched);
+       }
+
+       @Test
+       public void invalidUpdateFileDoesNotStartCallback() {
+               setupFetchResult(createInvalidFetchResult());
+               setupCallbackWithEdition(MAX_VALUE, true, false);
+               verifyAFreenetUriIsFetched();
+               verifyNoUpdateFoundEventIsFired();
+       }
+
+       private FetchResult createInvalidFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("text/plain");
+               Bucket fetched = new ArrayBucket("Some other data.".getBytes());
+               return new FetchResult(clientMetadata, fetched);
+       }
+
+       @Test
+       public void nonExistingPropertiesWillNotCauseUpdateToBeFound() {
+               setupCallbackWithEdition(MAX_VALUE, true, false);
+               verifyAFreenetUriIsFetched();
+               verifyNoUpdateFoundEventIsFired();
+       }
+
+       private void verifyNoUpdateFoundEventIsFired() {
+               verify(eventBus, never()).post(any(UpdateFoundEvent.class));
+       }
+
+       private void verifyAFreenetUriIsFetched() {
+               verify(freenetInterface).fetchUri(any(FreenetURI.class));
+       }
+
+       @Test
+       public void brokenBucketDoesNotCauseUpdateToBeFound() {
+               setupFetchResult(createBrokenBucketFetchResult());
+               setupCallbackWithEdition(MAX_VALUE, true, false);
+               verifyAFreenetUriIsFetched();
+               verifyNoUpdateFoundEventIsFired();
+       }
+
+       private FetchResult createBrokenBucketFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("text/plain");
+               Bucket fetched = new ArrayBucket("Some other data.".getBytes()) {
+                       @Override
+                       public InputStream getInputStream() {
+                               try {
+                                       return when(mock(InputStream.class).read()).thenThrow(IOException.class).getMock();
+                               } catch (IOException ioe1) {
+                                       /* won’t throw here. */
+                                       return null;
+                               }
+                       }
+               };
+               return new FetchResult(clientMetadata, fetched);
+       }
+
+       @Test
+       public void invalidTimeDoesNotCauseAnUpdateToBeFound() {
+               setupFetchResult(createInvalidTimeFetchResult());
+               setupCallbackWithEdition(MAX_VALUE, true, false);
+               verifyAFreenetUriIsFetched();
+               verifyNoUpdateFoundEventIsFired();
+       }
+
+       private FetchResult createInvalidTimeFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("application/xml");
+               Bucket fetched = new ArrayBucket(("# MapConfigurationBackendVersion=1\n" +
+                               "CurrentVersion/Version: 0.2\n" +
+                               "CurrentVersion/ReleaseTime: invalid").getBytes());
+               return new FetchResult(clientMetadata, fetched);
+       }
+
+       @Test
+       public void invalidPropertiesDoesNotCauseAnUpdateToBeFound() {
+               setupFetchResult(createMissingTimeFetchResult());
+               setupCallbackWithEdition(MAX_VALUE, true, false);
+               verifyAFreenetUriIsFetched();
+               verifyNoUpdateFoundEventIsFired();
+       }
+
+       private FetchResult createMissingTimeFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("application/xml");
+               Bucket fetched = new ArrayBucket(("# MapConfigurationBackendVersion=1\n" +
+                               "CurrentVersion/Version: 0.2\n").getBytes());
+               return new FetchResult(clientMetadata, fetched);
+       }
+
+       @Test
+       public void invalidVersionDoesNotCauseAnUpdateToBeFound() {
+               setupFetchResult(createInvalidVersionFetchResult());
+               setupCallbackWithEdition(MAX_VALUE, true, false);
+               verifyAFreenetUriIsFetched();
+               verifyNoUpdateFoundEventIsFired();
+       }
+
+       private FetchResult createInvalidVersionFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("application/xml");
+               Bucket fetched = new ArrayBucket(("# MapConfigurationBackendVersion=1\n" +
+                               "CurrentVersion/Version: foo\n" +
+                               "CurrentVersion/ReleaseTime: 1289417883000").getBytes());
+               return new FetchResult(clientMetadata, fetched);
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/core/WebOfTrustUpdaterTest.java b/src/test/java/net/pterodactylus/sone/core/WebOfTrustUpdaterTest.java
new file mode 100644 (file)
index 0000000..aa810ee
--- /dev/null
@@ -0,0 +1,453 @@
+package net.pterodactylus.sone.core;
+
+import static java.lang.Thread.sleep;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.concurrent.CountDownLatch;
+
+import net.pterodactylus.sone.core.WebOfTrustUpdaterImpl.AddContextJob;
+import net.pterodactylus.sone.core.WebOfTrustUpdaterImpl.RemoveContextJob;
+import net.pterodactylus.sone.core.WebOfTrustUpdaterImpl.SetPropertyJob;
+import net.pterodactylus.sone.core.WebOfTrustUpdaterImpl.SetTrustJob;
+import net.pterodactylus.sone.core.WebOfTrustUpdaterImpl.WebOfTrustContextUpdateJob;
+import net.pterodactylus.sone.core.WebOfTrustUpdaterImpl.WebOfTrustUpdateJob;
+import net.pterodactylus.sone.freenet.plugin.PluginException;
+import net.pterodactylus.sone.freenet.wot.Identity;
+import net.pterodactylus.sone.freenet.wot.OwnIdentity;
+import net.pterodactylus.sone.freenet.wot.Trust;
+import net.pterodactylus.sone.freenet.wot.WebOfTrustConnector;
+import net.pterodactylus.sone.freenet.wot.WebOfTrustException;
+
+import org.junit.Test;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+
+/**
+ * Unit test for {@link WebOfTrustUpdaterImpl} and its subclasses.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class WebOfTrustUpdaterTest {
+
+       private static final String CONTEXT = "test-context";
+       private static final Integer SCORE = 50;
+       private static final Integer OTHER_SCORE = 25;
+       private static final String TRUST_COMMENT = "set in a test";
+       private static final String PROPERTY_NAME = "test-property";
+       private final WebOfTrustConnector webOfTrustConnector = mock(WebOfTrustConnector.class);
+       private final WebOfTrustUpdaterImpl webOfTrustUpdater = new WebOfTrustUpdaterImpl(webOfTrustConnector);
+       private final OwnIdentity ownIdentity = when(mock(OwnIdentity.class).getId()).thenReturn("own-identity-id").getMock();
+       private final WebOfTrustUpdateJob successfulWebOfTrustUpdateJob = createWebOfTrustUpdateJob(true);
+       private final WebOfTrustUpdateJob failingWebOfTrustUpdateJob = createWebOfTrustUpdateJob(false);
+       private final WebOfTrustContextUpdateJob contextUpdateJob = webOfTrustUpdater.new WebOfTrustContextUpdateJob(ownIdentity, CONTEXT);
+       private final AddContextJob addContextJob = webOfTrustUpdater.new AddContextJob(ownIdentity, CONTEXT);
+       private final RemoveContextJob removeContextJob = webOfTrustUpdater.new RemoveContextJob(ownIdentity, CONTEXT);
+       private final Identity trustee = when(mock(Identity.class).getId()).thenReturn("trustee-id").getMock();
+
+       private WebOfTrustUpdateJob createWebOfTrustUpdateJob(final boolean success) {
+               return webOfTrustUpdater.new WebOfTrustUpdateJob() {
+                       @Override
+                       public void run() {
+                               super.run();
+                               try {
+                                       sleep(100);
+                               } catch (InterruptedException ie1) {
+                                       throw new RuntimeException(ie1);
+                               }
+                               finish(success);
+                       }
+               };
+       }
+
+       @Test
+       public void webOfTrustUpdateJobWaitsUntilFinishedHasBeenCalledAndReturnsSuccess() throws InterruptedException {
+               new Thread(successfulWebOfTrustUpdateJob).start();
+               assertThat(successfulWebOfTrustUpdateJob.waitForCompletion(), is(true));
+       }
+
+       @Test
+       public void webOfTrustUpdateJobWaitsUntilFinishedHasBeenCalledAndReturnsFailure() throws InterruptedException {
+               new Thread(failingWebOfTrustUpdateJob).start();
+               assertThat(failingWebOfTrustUpdateJob.waitForCompletion(), is(false));
+       }
+
+       @Test
+       public void webOfTrustContextUpdateJobsAreEqualIfTheirClassOwnIdentityAndContextAreEqual() {
+               WebOfTrustContextUpdateJob secondContextUpdateJob = webOfTrustUpdater.new WebOfTrustContextUpdateJob(ownIdentity, CONTEXT);
+               assertThat(contextUpdateJob.equals(secondContextUpdateJob), is(true));
+               assertThat(secondContextUpdateJob.equals(contextUpdateJob), is(true));
+               assertThat(contextUpdateJob.hashCode(), is(secondContextUpdateJob.hashCode()));
+       }
+
+       @Test
+       public void webOfTrustContextUpdatesJobsAreNotEqualIfTheirClassDiffers() {
+               assertThat(contextUpdateJob.equals(addContextJob), is(false));
+       }
+
+       @Test
+       public void webOfTrustContextUpdateJobToStringContainsIdentityAndContext() {
+               assertThat(contextUpdateJob.toString(), containsString(ownIdentity.toString()));
+               assertThat(contextUpdateJob.toString(), containsString(CONTEXT));
+       }
+
+       @Test
+       public void webOfTrustContextUpdateJobsAreNotEqualIfTheIdentitiesDiffer() {
+               OwnIdentity ownIdentity = mock(OwnIdentity.class);
+               WebOfTrustContextUpdateJob secondContextUpdateJob = webOfTrustUpdater.new WebOfTrustContextUpdateJob(ownIdentity, CONTEXT);
+               assertThat(contextUpdateJob.equals(secondContextUpdateJob), is(false));
+               assertThat(secondContextUpdateJob.equals(contextUpdateJob), is(false));
+       }
+
+       @Test
+       public void webOfTrustContextUpdateJobsAreNotEqualIfTheirContextsDiffer() {
+               WebOfTrustContextUpdateJob secondContextUpdateJob = webOfTrustUpdater.new WebOfTrustContextUpdateJob(ownIdentity, CONTEXT + CONTEXT);
+               assertThat(contextUpdateJob.equals(secondContextUpdateJob), is(false));
+               assertThat(secondContextUpdateJob.equals(contextUpdateJob), is(false));
+       }
+
+       @Test
+       public void webOfTrustContextUpdateJobsAreNotEqualToNull() {
+               assertThat(contextUpdateJob.equals(null), is(false));
+       }
+
+       @Test
+       public void addContextJobAddsTheContext() throws PluginException {
+               addContextJob.run();
+               verify(webOfTrustConnector).addContext(eq(ownIdentity), eq(CONTEXT));
+               verify(ownIdentity).addContext(eq(CONTEXT));
+               assertThat(addContextJob.waitForCompletion(), is(true));
+       }
+
+       @Test
+       public void exceptionWhileAddingAContextIsExposed() throws PluginException {
+               doThrow(PluginException.class).when(webOfTrustConnector).addContext(eq(ownIdentity), eq(CONTEXT));
+               addContextJob.run();
+               verify(webOfTrustConnector).addContext(eq(ownIdentity), eq(CONTEXT));
+               verify(ownIdentity, never()).addContext(eq(CONTEXT));
+               assertThat(addContextJob.waitForCompletion(), is(false));
+       }
+
+       @Test
+       public void removeContextJobRemovesTheContext() throws PluginException {
+               removeContextJob.run();
+               verify(webOfTrustConnector).removeContext(eq(ownIdentity), eq(CONTEXT));
+               verify(ownIdentity).removeContext(eq(CONTEXT));
+               assertThat(removeContextJob.waitForCompletion(), is(true));
+       }
+
+       @Test
+       public void exceptionWhileRemovingAContextIsExposed() throws PluginException {
+               doThrow(PluginException.class).when(webOfTrustConnector).removeContext(eq(ownIdentity), eq(CONTEXT));
+               removeContextJob.run();
+               verify(webOfTrustConnector).removeContext(eq(ownIdentity), eq(CONTEXT));
+               verify(ownIdentity, never()).removeContext(eq(CONTEXT));
+               assertThat(removeContextJob.waitForCompletion(), is(false));
+       }
+
+       @Test
+       public void settingAPropertySetsTheProperty() throws PluginException {
+               String propertyName = "property-name";
+               String propertyValue = "property-value";
+               SetPropertyJob setPropertyJob = webOfTrustUpdater.new SetPropertyJob(ownIdentity, propertyName, propertyValue);
+               setPropertyJob.run();
+               verify(webOfTrustConnector).setProperty(eq(ownIdentity), eq(propertyName), eq(propertyValue));
+               verify(ownIdentity).setProperty(eq(propertyName), eq(propertyValue));
+               assertThat(setPropertyJob.waitForCompletion(), is(true));
+       }
+
+       @Test
+       public void settingAPropertyToNullRemovesTheProperty() throws PluginException {
+               String propertyName = "property-name";
+               SetPropertyJob setPropertyJob = webOfTrustUpdater.new SetPropertyJob(ownIdentity, propertyName, null);
+               setPropertyJob.run();
+               verify(webOfTrustConnector).removeProperty(eq(ownIdentity), eq(propertyName));
+               verify(ownIdentity).removeProperty(eq(propertyName));
+               assertThat(setPropertyJob.waitForCompletion(), is(true));
+       }
+
+       @Test
+       public void pluginExceptionWhileSettingAPropertyIsHandled() throws PluginException {
+               String propertyName = "property-name";
+               String propertyValue = "property-value";
+               doThrow(PluginException.class).when(webOfTrustConnector).setProperty(eq(ownIdentity), eq(propertyName), eq(propertyValue));
+               SetPropertyJob setPropertyJob = webOfTrustUpdater.new SetPropertyJob(ownIdentity, propertyName, propertyValue);
+               setPropertyJob.run();
+               verify(webOfTrustConnector).setProperty(eq(ownIdentity), eq(propertyName), eq(propertyValue));
+               verify(ownIdentity, never()).setProperty(eq(propertyName), eq(propertyValue));
+               assertThat(setPropertyJob.waitForCompletion(), is(false));
+       }
+
+       @Test
+       public void setPropertyJobsWithSameClassPropertyAndValueAreEqual() {
+               String propertyName = "property-name";
+               String propertyValue = "property-value";
+               SetPropertyJob firstSetPropertyJob = webOfTrustUpdater.new SetPropertyJob(ownIdentity, propertyName, propertyValue);
+               SetPropertyJob secondSetPropertyJob = webOfTrustUpdater.new SetPropertyJob(ownIdentity, propertyName, propertyValue);
+               assertThat(firstSetPropertyJob, is(secondSetPropertyJob));
+               assertThat(secondSetPropertyJob, is(firstSetPropertyJob));
+               assertThat(firstSetPropertyJob.hashCode(), is(secondSetPropertyJob.hashCode()));
+       }
+
+       @Test
+       public void setPropertyJobsWithDifferentClassesAreNotEqual() {
+               String propertyName = "property-name";
+               String propertyValue = "property-value";
+               SetPropertyJob firstSetPropertyJob = webOfTrustUpdater.new SetPropertyJob(ownIdentity, propertyName, propertyValue);
+               SetPropertyJob secondSetPropertyJob = webOfTrustUpdater.new SetPropertyJob(ownIdentity, propertyName, propertyValue) {
+               };
+               assertThat(firstSetPropertyJob, not(is(secondSetPropertyJob)));
+       }
+
+       @Test
+       public void nullIsNotASetProjectJobEither() {
+               String propertyName = "property-name";
+               String propertyValue = "property-value";
+               SetPropertyJob setPropertyJob = webOfTrustUpdater.new SetPropertyJob(ownIdentity, propertyName, propertyValue);
+               assertThat(setPropertyJob, not(is((Object) null)));
+       }
+
+       @Test
+       public void setPropertyJobsWithDifferentPropertiesAreNotEqual() {
+               String propertyName = "property-name";
+               String propertyValue = "property-value";
+               SetPropertyJob firstSetPropertyJob = webOfTrustUpdater.new SetPropertyJob(ownIdentity, propertyName, propertyValue);
+               SetPropertyJob secondSetPropertyJob = webOfTrustUpdater.new SetPropertyJob(ownIdentity, propertyName + "2", propertyValue);
+               assertThat(firstSetPropertyJob, not(is(secondSetPropertyJob)));
+       }
+
+       @Test
+       public void setPropertyJobsWithDifferentOwnIdentitiesAreNotEqual() {
+               OwnIdentity otherOwnIdentity = mock(OwnIdentity.class);
+               String propertyName = "property-name";
+               String propertyValue = "property-value";
+               SetPropertyJob firstSetPropertyJob = webOfTrustUpdater.new SetPropertyJob(ownIdentity, propertyName, propertyValue);
+               SetPropertyJob secondSetPropertyJob = webOfTrustUpdater.new SetPropertyJob(otherOwnIdentity, propertyName, propertyValue);
+               assertThat(firstSetPropertyJob, not(is(secondSetPropertyJob)));
+       }
+
+       @Test
+       public void setTrustJobSetsTrust() throws PluginException {
+               SetTrustJob setTrustJob = webOfTrustUpdater.new SetTrustJob(ownIdentity, trustee, SCORE, TRUST_COMMENT);
+               setTrustJob.run();
+               verify(webOfTrustConnector).setTrust(eq(ownIdentity), eq(trustee), eq(SCORE), eq(TRUST_COMMENT));
+               verify(trustee).setTrust(eq(ownIdentity), eq(new Trust(SCORE, null, 0)));
+               assertThat(setTrustJob.waitForCompletion(), is(true));
+       }
+
+       @Test
+       public void settingNullTrustRemovesTrust() throws WebOfTrustException {
+               SetTrustJob setTrustJob = webOfTrustUpdater.new SetTrustJob(ownIdentity, trustee, null, TRUST_COMMENT);
+               setTrustJob.run();
+               verify(webOfTrustConnector).removeTrust(eq(ownIdentity), eq(trustee));
+               verify(trustee).removeTrust(eq(ownIdentity));
+               assertThat(setTrustJob.waitForCompletion(), is(true));
+       }
+
+       @Test
+       public void exceptionWhileSettingTrustIsCaught() throws PluginException {
+               doThrow(PluginException.class).when(webOfTrustConnector).setTrust(eq(ownIdentity), eq(trustee), eq(SCORE), eq(TRUST_COMMENT));
+               SetTrustJob setTrustJob = webOfTrustUpdater.new SetTrustJob(ownIdentity, trustee, SCORE, TRUST_COMMENT);
+               setTrustJob.run();
+               verify(webOfTrustConnector).setTrust(eq(ownIdentity), eq(trustee), eq(SCORE), eq(TRUST_COMMENT));
+               verify(trustee, never()).setTrust(eq(ownIdentity), eq(new Trust(SCORE, null, 0)));
+               assertThat(setTrustJob.waitForCompletion(), is(false));
+       }
+
+       @Test
+       public void setTrustJobsWithDifferentClassesAreNotEqual() {
+               SetTrustJob firstSetTrustJob = webOfTrustUpdater.new SetTrustJob(ownIdentity, trustee, SCORE, TRUST_COMMENT);
+               SetTrustJob secondSetTrustJob = webOfTrustUpdater.new SetTrustJob(ownIdentity, trustee, SCORE, TRUST_COMMENT) {
+               };
+               assertThat(firstSetTrustJob, not(is(secondSetTrustJob)));
+               assertThat(secondSetTrustJob, not(is(firstSetTrustJob)));
+       }
+
+       @Test
+       public void setTrustJobsWithDifferentTrustersAreNotEqual() {
+               SetTrustJob firstSetTrustJob = webOfTrustUpdater.new SetTrustJob(ownIdentity, trustee, SCORE, TRUST_COMMENT);
+               SetTrustJob secondSetTrustJob = webOfTrustUpdater.new SetTrustJob(mock(OwnIdentity.class), trustee, SCORE, TRUST_COMMENT);
+               assertThat(firstSetTrustJob, not(is(secondSetTrustJob)));
+               assertThat(secondSetTrustJob, not(is(firstSetTrustJob)));
+       }
+
+       @Test
+       public void setTrustJobsWithDifferentTrusteesAreNotEqual() {
+               SetTrustJob firstSetTrustJob = webOfTrustUpdater.new SetTrustJob(ownIdentity, trustee, SCORE, TRUST_COMMENT);
+               SetTrustJob secondSetTrustJob = webOfTrustUpdater.new SetTrustJob(ownIdentity, mock(Identity.class), SCORE, TRUST_COMMENT);
+               assertThat(firstSetTrustJob, not(is(secondSetTrustJob)));
+               assertThat(secondSetTrustJob, not(is(firstSetTrustJob)));
+       }
+
+       @Test
+       public void setTrustJobsWithDifferentScoreAreEqual() {
+               SetTrustJob firstSetTrustJob = webOfTrustUpdater.new SetTrustJob(ownIdentity, trustee, SCORE, TRUST_COMMENT);
+               SetTrustJob secondSetTrustJob = webOfTrustUpdater.new SetTrustJob(ownIdentity, trustee, OTHER_SCORE, TRUST_COMMENT);
+               assertThat(firstSetTrustJob, is(secondSetTrustJob));
+               assertThat(secondSetTrustJob, is(firstSetTrustJob));
+               assertThat(firstSetTrustJob.hashCode(), is(secondSetTrustJob.hashCode()));
+       }
+
+       @Test
+       public void setTrustJobDoesNotEqualNull() {
+               SetTrustJob setTrustJob = webOfTrustUpdater.new SetTrustJob(ownIdentity, trustee, SCORE, TRUST_COMMENT);
+               assertThat(setTrustJob, not(is((Object) null)));
+       }
+
+       @Test
+       public void toStringOfSetTrustJobContainsIdsOfTrusterAndTrustee() {
+               SetTrustJob setTrustJob = webOfTrustUpdater.new SetTrustJob(ownIdentity, trustee, SCORE, TRUST_COMMENT);
+               assertThat(setTrustJob.toString(), containsString(ownIdentity.getId()));
+               assertThat(setTrustJob.toString(), containsString(trustee.getId()));
+       }
+
+       @Test
+       public void webOfTrustUpdaterStopsWhenItShould() {
+               webOfTrustUpdater.stop();
+               webOfTrustUpdater.serviceRun();
+       }
+
+       @Test
+       public void webOfTrustUpdaterStopsAfterItWasStarted() {
+               webOfTrustUpdater.start();
+               webOfTrustUpdater.stop();
+       }
+
+       @Test
+       public void removePropertyRemovesProperty() throws InterruptedException, PluginException {
+               final CountDownLatch wotCallTriggered = new CountDownLatch(1);
+               doAnswer(new Answer<Void>() {
+                       @Override
+                       public Void answer(InvocationOnMock invocation) throws Throwable {
+                               wotCallTriggered.countDown();
+                               return null;
+                       }
+               }).when(webOfTrustConnector).removeProperty(eq(ownIdentity), eq(PROPERTY_NAME));
+               webOfTrustUpdater.removeProperty(ownIdentity, PROPERTY_NAME);
+               webOfTrustUpdater.start();
+               assertThat(wotCallTriggered.await(1, SECONDS), is(true));
+       }
+
+       @Test
+       public void multipleCallsToSetPropertyAreCollapsed() throws InterruptedException, PluginException {
+               final CountDownLatch wotCallTriggered = new CountDownLatch(1);
+               doAnswer(new Answer<Void>() {
+                       @Override
+                       public Void answer(InvocationOnMock invocation) throws Throwable {
+                               wotCallTriggered.countDown();
+                               return null;
+                       }
+               }).when(webOfTrustConnector).removeProperty(eq(ownIdentity), eq(PROPERTY_NAME));
+               webOfTrustUpdater.removeProperty(ownIdentity, PROPERTY_NAME);
+               webOfTrustUpdater.removeProperty(ownIdentity, PROPERTY_NAME);
+               webOfTrustUpdater.start();
+               assertThat(wotCallTriggered.await(1, SECONDS), is(true));
+               verify(webOfTrustConnector).removeProperty(eq(ownIdentity), eq(PROPERTY_NAME));
+       }
+
+       @Test
+       public void addContextWaitWaitsForTheContextToBeAdded() {
+               webOfTrustUpdater.start();
+               assertThat(webOfTrustUpdater.addContextWait(ownIdentity, CONTEXT), is(true));
+               verify(ownIdentity).addContext(eq(CONTEXT));
+       }
+
+       @Test
+       public void removeContextRemovesAContext() throws InterruptedException, PluginException {
+               webOfTrustUpdater.start();
+               final CountDownLatch removeContextTrigger = new CountDownLatch(1);
+               doAnswer(new Answer<Void>() {
+                       @Override
+                       public Void answer(InvocationOnMock invocation) throws Throwable {
+                               removeContextTrigger.countDown();
+                               return null;
+                       }
+               }).when(ownIdentity).removeContext(eq(CONTEXT));
+               webOfTrustUpdater.removeContext(ownIdentity, CONTEXT);
+               removeContextTrigger.await(1, SECONDS);
+               verify(webOfTrustConnector).removeContext(eq(ownIdentity), eq(CONTEXT));
+               verify(ownIdentity).removeContext(eq(CONTEXT));
+       }
+
+       @Test
+       public void removeContextRequestsAreCoalesced() throws InterruptedException, PluginException {
+               final CountDownLatch contextRemovedTrigger = new CountDownLatch(1);
+               doAnswer(new Answer<Void>() {
+                       @Override
+                       public Void answer(InvocationOnMock invocation) throws Throwable {
+                               contextRemovedTrigger.countDown();
+                               return null;
+                       }
+               }).when(ownIdentity).removeContext(eq(CONTEXT));
+               for (int i = 1; i <= 2; i++) {
+                       /* this is so fucking volatile. */
+                       if (i > 1) {
+                               sleep(200);
+                       }
+                       new Thread(new Runnable() {
+                               public void run() {
+                                       webOfTrustUpdater.removeContext(ownIdentity, CONTEXT);
+                               }
+                       }).start();
+               }
+               webOfTrustUpdater.start();
+               assertThat(contextRemovedTrigger.await(1, SECONDS), is(true));
+               verify(webOfTrustConnector).removeContext(eq(ownIdentity), eq(CONTEXT));
+               verify(ownIdentity).removeContext(eq(CONTEXT));
+       }
+
+       @Test
+       public void setTrustSetsTrust() throws InterruptedException, PluginException {
+               final CountDownLatch trustSetTrigger = new CountDownLatch(1);
+               doAnswer(new Answer<Void>() {
+                       @Override
+                       public Void answer(InvocationOnMock invocation) throws Throwable {
+                               trustSetTrigger.countDown();
+                               return null;
+                       }
+               }).when(trustee).setTrust(eq(ownIdentity), eq(new Trust(SCORE, null, 0)));
+               webOfTrustUpdater.start();
+               webOfTrustUpdater.setTrust(ownIdentity, trustee, SCORE, TRUST_COMMENT);
+               assertThat(trustSetTrigger.await(1, SECONDS), is(true));
+               verify(trustee).setTrust(eq(ownIdentity), eq(new Trust(SCORE, null, 0)));
+               verify(webOfTrustConnector).setTrust(eq(ownIdentity), eq(trustee), eq(SCORE), eq(TRUST_COMMENT));
+       }
+
+       @Test
+       public void setTrustRequestsAreCoalesced() throws InterruptedException, PluginException {
+               final CountDownLatch trustSetTrigger = new CountDownLatch(1);
+               doAnswer(new Answer<Void>() {
+                       @Override
+                       public Void answer(InvocationOnMock invocation) throws Throwable {
+                               trustSetTrigger.countDown();
+                               return null;
+                       }
+               }).when(trustee).setTrust(eq(ownIdentity), eq(new Trust(SCORE, null, 0)));
+               for (int i = 1; i <= 2; i++) {
+                       /* this is so fucking volatile. */
+                       if (i > 1) {
+                               sleep(200);
+                       }
+                       new Thread(new Runnable() {
+                               public void run() {
+                                       webOfTrustUpdater.setTrust(ownIdentity, trustee, SCORE, TRUST_COMMENT);
+                               }
+                       }).start();
+               }
+               webOfTrustUpdater.start();
+               assertThat(trustSetTrigger.await(1, SECONDS), is(true));
+               verify(trustee).setTrust(eq(ownIdentity), eq(new Trust(SCORE, null, 0)));
+               verify(webOfTrustConnector).setTrust(eq(ownIdentity), eq(trustee), eq(SCORE), eq(TRUST_COMMENT));
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/data/ProfileTest.java b/src/test/java/net/pterodactylus/sone/data/ProfileTest.java
new file mode 100644 (file)
index 0000000..b06e2f4
--- /dev/null
@@ -0,0 +1,26 @@
+package net.pterodactylus.sone.data;
+
+import net.pterodactylus.sone.data.Profile.Field;
+
+import org.hamcrest.MatcherAssert;
+import org.hamcrest.Matchers;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+/**
+ * Unit test for {@link Profile}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class ProfileTest {
+
+       private final Sone sone = Mockito.mock(Sone.class);
+       private final Profile profile = new Profile(sone);
+
+       @Test
+       public void newFieldsAreInitializedWithAnEmptyString() {
+               Field newField = profile.addField("testField");
+               MatcherAssert.assertThat(newField.getValue(), Matchers.is(""));
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/data/impl/AbstractSoneBuilderTest.java b/src/test/java/net/pterodactylus/sone/data/impl/AbstractSoneBuilderTest.java
new file mode 100644 (file)
index 0000000..b2d86dd
--- /dev/null
@@ -0,0 +1,54 @@
+package net.pterodactylus.sone.data.impl;
+
+import static org.mockito.Mockito.mock;
+
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.sone.freenet.wot.Identity;
+import net.pterodactylus.sone.freenet.wot.OwnIdentity;
+
+import org.junit.Test;
+
+/**
+ * Unit test for {@link AbstractSoneBuilder}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class AbstractSoneBuilderTest {
+
+       private final AbstractSoneBuilder soneBuilder = new AbstractSoneBuilder() {
+               @Override
+               public Sone build() throws IllegalStateException {
+                       validate();
+                       return null;
+               }
+       };
+
+       @Test
+       public void localSoneIsValidated() {
+               Identity ownIdentity = mock(OwnIdentity.class);
+               soneBuilder.local().from(ownIdentity).build();
+       }
+
+       @Test(expected = IllegalStateException.class)
+       public void localSoneIsNotValidatedIfIdentityIsNotAnOwnIdentity() {
+               Identity identity = mock(Identity.class);
+               soneBuilder.local().from(identity).build();
+       }
+
+       @Test(expected = IllegalStateException.class)
+       public void localSoneIsNotValidatedIfIdentityIsNull() {
+               soneBuilder.local().build();
+       }
+
+       @Test
+       public void removeSoneIsValidate() {
+               Identity identity = mock(Identity.class);
+               soneBuilder.from(identity).build();
+       }
+
+       @Test(expected = IllegalStateException.class)
+       public void remoteSoneIsNotValidatedIfIdentityIsNull() {
+               soneBuilder.build();
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/data/impl/ImageImplTest.java b/src/test/java/net/pterodactylus/sone/data/impl/ImageImplTest.java
new file mode 100644 (file)
index 0000000..b78f0f6
--- /dev/null
@@ -0,0 +1,22 @@
+package net.pterodactylus.sone.data.impl;
+
+import net.pterodactylus.sone.data.Image;
+import net.pterodactylus.sone.data.Image.Modifier.ImageTitleMustNotBeEmpty;
+
+import org.junit.Test;
+
+/**
+ * Unit test for {@link ImageImpl}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class ImageImplTest {
+
+       private final Image image = new ImageImpl();
+
+       @Test(expected = ImageTitleMustNotBeEmpty.class)
+       public void modifierDoesNotAllowTitleDoBeEmpty() {
+               image.modify().setTitle("").update();
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/database/memory/ConfigurationLoaderTest.java b/src/test/java/net/pterodactylus/sone/database/memory/ConfigurationLoaderTest.java
new file mode 100644 (file)
index 0000000..47e26b7
--- /dev/null
@@ -0,0 +1,84 @@
+package net.pterodactylus.sone.database.memory;
+
+import static java.util.Arrays.asList;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.nullValue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import net.pterodactylus.sone.TestValue;
+import net.pterodactylus.util.config.Configuration;
+import net.pterodactylus.util.config.ConfigurationException;
+import net.pterodactylus.util.config.Value;
+
+import org.junit.Test;
+
+/**
+ * Unit test for {@link ConfigurationLoader}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class ConfigurationLoaderTest {
+
+       private final Configuration configuration = mock(Configuration.class);
+       private final ConfigurationLoader configurationLoader =
+                       new ConfigurationLoader(configuration);
+
+       @Test
+       public void loaderCanLoadKnownPosts() {
+               when(configuration.getStringValue("KnownPosts/0/ID"))
+                               .thenReturn(TestValue.from("Post2"));
+               when(configuration.getStringValue("KnownPosts/1/ID"))
+                               .thenReturn(TestValue.from("Post1"));
+               when(configuration.getStringValue("KnownPosts/2/ID"))
+                               .thenReturn(TestValue.<String>from(null));
+               Set<String> knownPosts = configurationLoader.loadKnownPosts();
+               assertThat(knownPosts, containsInAnyOrder("Post1", "Post2"));
+       }
+
+       @Test
+       public void loaderCanLoadKnownPostReplies() {
+               when(configuration.getStringValue("KnownReplies/0/ID"))
+                               .thenReturn(TestValue.from("PostReply2"));
+               when(configuration.getStringValue("KnownReplies/1/ID"))
+                               .thenReturn(TestValue.from("PostReply1"));
+               when(configuration.getStringValue("KnownReplies/2/ID"))
+                               .thenReturn(TestValue.<String>from(null));
+               Set<String> knownPosts = configurationLoader.loadKnownPostReplies();
+               assertThat(knownPosts,
+                               containsInAnyOrder("PostReply1", "PostReply2"));
+       }
+
+       @Test
+       public void loaderCanLoadBookmarkedPosts() {
+               when(configuration.getStringValue("Bookmarks/Post/0/ID"))
+                               .thenReturn(TestValue.from("Post2"));
+               when(configuration.getStringValue("Bookmarks/Post/1/ID"))
+                               .thenReturn(TestValue.from("Post1"));
+               when(configuration.getStringValue("Bookmarks/Post/2/ID"))
+                               .thenReturn(TestValue.<String>from(null));
+               Set<String> knownPosts = configurationLoader.loadBookmarkedPosts();
+               assertThat(knownPosts, containsInAnyOrder("Post1", "Post2"));
+       }
+
+       @Test
+       public void loaderCanSaveBookmarkedPosts() throws ConfigurationException {
+               final Value<String> post1 = TestValue.<String>from(null);
+               final Value<String> post2 = TestValue.<String>from(null);
+               final Value<String> post3 = TestValue.<String>from(null);
+               when(configuration.getStringValue("Bookmarks/Post/0/ID")).thenReturn(post1);
+               when(configuration.getStringValue("Bookmarks/Post/1/ID")).thenReturn(post2);
+               when(configuration.getStringValue("Bookmarks/Post/2/ID")).thenReturn(post3);
+               HashSet<String> originalPosts = new HashSet<String>(asList("Post1", "Post2"));
+               configurationLoader.saveBookmarkedPosts(originalPosts);
+               HashSet<String> extractedPosts =
+                               new HashSet<String>(asList(post1.getValue(), post2.getValue()));
+               assertThat(extractedPosts, containsInAnyOrder("Post1", "Post2"));
+               assertThat(post3.getValue(), nullValue());
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/database/memory/MemoryBookmarkDatabaseTest.java b/src/test/java/net/pterodactylus/sone/database/memory/MemoryBookmarkDatabaseTest.java
new file mode 100644 (file)
index 0000000..06c5b96
--- /dev/null
@@ -0,0 +1,142 @@
+package net.pterodactylus.sone.database.memory;
+
+import static com.google.common.base.Optional.fromNullable;
+import static net.pterodactylus.sone.Matchers.isPostWithId;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import net.pterodactylus.sone.data.Post;
+
+import com.google.common.base.Optional;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+
+/**
+ * Unit test for {@link MemoryBookmarkDatabase}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class MemoryBookmarkDatabaseTest {
+
+       private final MemoryDatabase memoryDatabase = mock(MemoryDatabase.class);
+       private final ConfigurationLoader configurationLoader =
+                       mock(ConfigurationLoader.class);
+       private final MemoryBookmarkDatabase bookmarkDatabase =
+                       new MemoryBookmarkDatabase(memoryDatabase, configurationLoader);
+       private final Map<String, Post> posts = new HashMap<String, Post>();
+
+       @Before
+       public void setupMemoryDatabase() {
+               when(memoryDatabase.getPost(anyString())).thenAnswer(
+                               new Answer<Optional<Post>>() {
+                                       @Override
+                                       public Optional<Post> answer(
+                                                       InvocationOnMock invocation) {
+                                               return fromNullable(
+                                                               posts.get(invocation.getArguments()[0]));
+                                       }
+                               });
+       }
+
+       @Before
+       public void setupPosts() {
+               createAndRegisterPost("PostId1");
+               createAndRegisterPost("PostId2");
+       }
+
+       private Post createAndRegisterPost(String postId) {
+               Post post = createPost(postId);
+               posts.put(postId, post);
+               return post;
+       }
+
+       private Post createPost(String postId) {
+               Post post = mock(Post.class);
+               when(post.getId()).thenReturn(postId);
+               return post;
+       }
+
+       @Test
+       public void bookmarkDatabaseRetainsBookmarkedPosts() {
+               Set<Post> allPosts = new HashSet<Post>(posts.values());
+               for (Post post : allPosts) {
+                       bookmarkDatabase.bookmarkPost(post);
+               }
+               assertThat(bookmarkDatabase.getBookmarkedPosts(), is(allPosts));
+               for (Post post : allPosts) {
+                       assertThat(bookmarkDatabase.isPostBookmarked(post), is(true));
+               }
+       }
+
+       @Test
+       public void bookmarkingAPostSavesTheDatabase() {
+               for (Post post : posts.values()) {
+                       bookmarkDatabase.bookmarkPost(post);
+               }
+               verify(configurationLoader, times(posts.size()))
+                               .saveBookmarkedPosts(any(Set.class));
+       }
+
+       @Test
+       public void unbookmarkingAPostSavesTheDatabase() {
+               for (Post post : posts.values()) {
+                       bookmarkDatabase.bookmarkPost(post);
+                       bookmarkDatabase.unbookmarkPost(post);
+               }
+               verify(configurationLoader, times(posts.size() * 2))
+                               .saveBookmarkedPosts(any(Set.class));
+       }
+
+       @Test
+       public void removingABookmarkRemovesTheCorrectBookmark() {
+               Set<Post> allPosts = new HashSet<Post>(posts.values());
+               for (Post post : allPosts) {
+                       bookmarkDatabase.bookmarkPost(post);
+               }
+               Post randomPost = posts.values().iterator().next();
+               bookmarkDatabase.unbookmarkPost(randomPost);
+               allPosts.remove(randomPost);
+               assertThat(bookmarkDatabase.getBookmarkedPosts(), is(allPosts));
+               for (Post post : posts.values()) {
+                       assertThat(bookmarkDatabase.isPostBookmarked(post),
+                                       is(!post.equals(randomPost)));
+               }
+       }
+
+       @Test
+       public void startingTheDatabaseLoadsBookmarkedPosts() {
+               bookmarkDatabase.start();
+               verify(configurationLoader).loadBookmarkedPosts();
+       }
+
+       @Test
+       public void stoppingTheDatabaseSavesTheBookmarkedPosts() {
+               bookmarkDatabase.stop();
+               verify(configurationLoader).saveBookmarkedPosts(any(Set.class));
+       }
+
+       @Test
+       public void bookmarkedPostsIncludeNotYetLoadedPosts() {
+               bookmarkDatabase.bookmarkPost(posts.get("PostId1"));
+               bookmarkDatabase.bookmarkPost(createPost("PostId3"));
+               final Set<Post> bookmarkedPosts =
+                               bookmarkDatabase.getBookmarkedPosts();
+               assertThat(bookmarkedPosts,
+                               contains(isPostWithId("PostId1"), isPostWithId("PostId3")));
+       }
+
+}
index 056a06d..cc5babb 100644 (file)
 package net.pterodactylus.sone.database.memory;
 
 import static com.google.common.base.Optional.of;
+import static java.util.Arrays.asList;
+import static java.util.UUID.randomUUID;
+import static net.pterodactylus.sone.Matchers.isAlbum;
+import static net.pterodactylus.sone.Matchers.isImage;
+import static net.pterodactylus.sone.Matchers.isPost;
+import static net.pterodactylus.sone.Matchers.isPostReply;
 import static org.hamcrest.CoreMatchers.is;
 import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.emptyIterable;
+import static org.mockito.Matchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import net.pterodactylus.sone.TestAlbumBuilder;
+import net.pterodactylus.sone.TestImageBuilder;
+import net.pterodactylus.sone.TestPostBuilder;
+import net.pterodactylus.sone.TestPostReplyBuilder;
+import net.pterodactylus.sone.TestValue;
 import net.pterodactylus.sone.data.Album;
-import net.pterodactylus.sone.data.AlbumImpl;
+import net.pterodactylus.sone.data.impl.AlbumImpl;
+import net.pterodactylus.sone.data.Image;
+import net.pterodactylus.sone.data.Post;
+import net.pterodactylus.sone.data.PostReply;
+import net.pterodactylus.sone.data.Sone;
+import net.pterodactylus.util.config.Configuration;
+import net.pterodactylus.util.config.Value;
 
 import com.google.common.base.Optional;
+import org.junit.Before;
 import org.junit.Test;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
 
 /**
  * Tests for {@link MemoryDatabase}.
@@ -34,11 +71,204 @@ import org.junit.Test;
  */
 public class MemoryDatabaseTest {
 
-       private final MemoryDatabase memoryDatabase = new MemoryDatabase(null, null);
+       private static final String SONE_ID = "sone";
+       private static final String RECIPIENT_ID = "recipient";
+       private final Configuration configuration = mock(Configuration.class);
+       private final MemoryDatabase memoryDatabase = new MemoryDatabase(null, configuration);
+       private final Sone sone = mock(Sone.class);
+
+       @Before
+       public void setupSone() {
+               when(sone.getId()).thenReturn(SONE_ID);
+       }
+
+       @Test
+       public void storedSoneIsMadeAvailable() {
+               Post firstPost = new TestPostBuilder().withId("post1")
+                               .from(SONE_ID)
+                               .withTime(1000L)
+                               .withText("post1")
+                               .build();
+               Post secondPost = new TestPostBuilder().withId("post2")
+                               .from(SONE_ID)
+                               .withTime(2000L)
+                               .withText("post2")
+                               .to(RECIPIENT_ID)
+                               .build();
+               List<Post> posts = asList(firstPost, secondPost);
+               when(sone.getPosts()).thenReturn(posts);
+               PostReply firstPostFirstReply =
+                               new TestPostReplyBuilder().withId("reply1")
+                                               .from(SONE_ID)
+                                               .to(firstPost.getId())
+                                               .withTime(3000L)
+                                               .withText("reply1")
+                                               .build();
+               PostReply firstPostSecondReply =
+                               new TestPostReplyBuilder().withId("reply3")
+                                               .from(RECIPIENT_ID)
+                                               .to(firstPost.getId())
+                                               .withTime(5000L)
+                                               .withText("reply3")
+                                               .build();
+               PostReply secondPostReply =
+                               new TestPostReplyBuilder().withId("reply2")
+                                               .from(SONE_ID)
+                                               .to(secondPost.getId())
+                                               .withTime(4000L)
+                                               .withText("reply2")
+                                               .build();
+               Set<PostReply> postReplies = new HashSet<PostReply>(
+                               asList(firstPostFirstReply, firstPostSecondReply,
+                                               secondPostReply));
+               when(sone.getReplies()).thenReturn(postReplies);
+               Album firstAlbum = new TestAlbumBuilder().withId("album1")
+                               .by(sone)
+                               .build()
+                               .modify()
+                               .setTitle("album1")
+                               .setDescription("album-description1")
+                               .update();
+               Album secondAlbum = new TestAlbumBuilder().withId("album2").by(
+                               sone).build().modify().setTitle("album2").setDescription(
+                               "album-description2").setAlbumImage("image1").update();
+               Album thirdAlbum = new TestAlbumBuilder().withId("album3").by(
+                               sone).build().modify().setTitle("album3").setDescription(
+                               "album-description3").update();
+               firstAlbum.addAlbum(thirdAlbum);
+               Album rootAlbum = mock(Album.class);
+               when(rootAlbum.getAlbums()).thenReturn(
+                               asList(firstAlbum, secondAlbum));
+               when(sone.getRootAlbum()).thenReturn(rootAlbum);
+               Image firstImage = new TestImageBuilder().withId("image1")
+                               .build()
+                               .modify()
+                               .setSone(sone)
+                               .setCreationTime(1000L)
+                               .setKey("KSK@image1")
+                               .setTitle("image1")
+                               .setDescription("image-description1")
+                               .setWidth(16)
+                               .setHeight(9)
+                               .update();
+               Image secondImage = new TestImageBuilder().withId("image2")
+                               .build()
+                               .modify()
+                               .setSone(sone)
+                               .setCreationTime(2000L)
+                               .setKey("KSK@image2")
+                               .setTitle("image2")
+                               .setDescription("image-description2")
+                               .setWidth(32)
+                               .setHeight(18)
+                               .update();
+               Image thirdImage = new TestImageBuilder().withId("image3")
+                               .build()
+                               .modify()
+                               .setSone(sone)
+                               .setCreationTime(3000L)
+                               .setKey("KSK@image3")
+                               .setTitle("image3")
+                               .setDescription("image-description3")
+                               .setWidth(48)
+                               .setHeight(27)
+                               .update();
+               firstAlbum.addImage(firstImage);
+               firstAlbum.addImage(thirdImage);
+               secondAlbum.addImage(secondImage);
+               memoryDatabase.storeSone(sone);
+               assertThat(memoryDatabase.getPost("post1").get(),
+                               isPost(firstPost.getId(), 1000L, "post1",
+                                               Optional.<String>absent()));
+               assertThat(memoryDatabase.getPost("post2").get(),
+                               isPost(secondPost.getId(), 2000L, "post2", of(RECIPIENT_ID)));
+               assertThat(memoryDatabase.getPost("post3").isPresent(), is(false));
+               assertThat(memoryDatabase.getPostReply("reply1").get(),
+                               isPostReply("reply1", "post1", 3000L, "reply1"));
+               assertThat(memoryDatabase.getPostReply("reply2").get(),
+                               isPostReply("reply2", "post2", 4000L, "reply2"));
+               assertThat(memoryDatabase.getPostReply("reply3").get(),
+                               isPostReply("reply3", "post1", 5000L, "reply3"));
+               assertThat(memoryDatabase.getPostReply("reply4").isPresent(),
+                               is(false));
+               assertThat(memoryDatabase.getAlbum("album1").get(),
+                               isAlbum("album1", null, "album1", "album-description1",
+                                               null));
+               assertThat(memoryDatabase.getAlbum("album2").get(),
+                               isAlbum("album2", null, "album2", "album-description2",
+                                               "image1"));
+               assertThat(memoryDatabase.getAlbum("album3").get(),
+                               isAlbum("album3", "album1", "album3", "album-description3",
+                                               null));
+               assertThat(memoryDatabase.getAlbum("album4").isPresent(), is(false));
+               assertThat(memoryDatabase.getImage("image1").get(),
+                               isImage("image1", 1000L, "KSK@image1", "image1",
+                                               "image-description1", 16, 9));
+               assertThat(memoryDatabase.getImage("image2").get(),
+                               isImage("image2", 2000L, "KSK@image2", "image2",
+                                               "image-description2", 32, 18));
+               assertThat(memoryDatabase.getImage("image3").get(),
+                               isImage("image3", 3000L, "KSK@image3", "image3",
+                                               "image-description3", 48, 27));
+               assertThat(memoryDatabase.getImage("image4").isPresent(), is(false));
+       }
+
+       @Test
+       public void storedAndRemovedSoneIsNotAvailable() {
+           storedSoneIsMadeAvailable();
+               memoryDatabase.removeSone(sone);
+               assertThat(memoryDatabase.getSones(), empty());
+       }
+
+       @Test
+       public void postRecipientsAreDetectedCorrectly() {
+               Post postWithRecipient = createPost(of(RECIPIENT_ID));
+               memoryDatabase.storePost(postWithRecipient);
+               Post postWithoutRecipient = createPost(Optional.<String>absent());
+               memoryDatabase.storePost(postWithoutRecipient);
+               assertThat(memoryDatabase.getDirectedPosts(RECIPIENT_ID),
+                               contains(postWithRecipient));
+       }
+
+       private Post createPost(Optional<String> recipient) {
+               Post postWithRecipient = mock(Post.class);
+               when(postWithRecipient.getId()).thenReturn(randomUUID().toString());
+               when(postWithRecipient.getSone()).thenReturn(sone);
+               when(postWithRecipient.getRecipientId()).thenReturn(recipient);
+               return postWithRecipient;
+       }
+
+       @Test
+       public void postRepliesAreManagedCorrectly() {
+               Post firstPost = createPost(Optional.<String>absent());
+               PostReply firstPostFirstReply = createPostReply(firstPost, 1000L);
+               Post secondPost = createPost(Optional.<String>absent());
+               PostReply secondPostFirstReply = createPostReply(secondPost, 1000L);
+               PostReply secondPostSecondReply = createPostReply(secondPost, 2000L);
+               memoryDatabase.storePost(firstPost);
+               memoryDatabase.storePost(secondPost);
+               memoryDatabase.storePostReply(firstPostFirstReply);
+               memoryDatabase.storePostReply(secondPostFirstReply);
+               memoryDatabase.storePostReply(secondPostSecondReply);
+               assertThat(memoryDatabase.getReplies(firstPost.getId()),
+                               contains(firstPostFirstReply));
+               assertThat(memoryDatabase.getReplies(secondPost.getId()),
+                               contains(secondPostFirstReply, secondPostSecondReply));
+       }
+
+       private PostReply createPostReply(Post post, long time) {
+               PostReply postReply = mock(PostReply.class);
+               when(postReply.getId()).thenReturn(randomUUID().toString());
+               when(postReply.getTime()).thenReturn(time);
+               when(postReply.getPost()).thenReturn(of(post));
+               final String postId = post.getId();
+               when(postReply.getPostId()).thenReturn(postId);
+               return postReply;
+       }
 
        @Test
        public void testBasicAlbumFunctionality() {
-               Album newAlbum = new AlbumImpl();
+               Album newAlbum = new AlbumImpl(mock(Sone.class));
                assertThat(memoryDatabase.getAlbum(newAlbum.getId()), is(Optional.<Album>absent()));
                memoryDatabase.storeAlbum(newAlbum);
                assertThat(memoryDatabase.getAlbum(newAlbum.getId()), is(of(newAlbum)));
@@ -46,4 +276,114 @@ public class MemoryDatabaseTest {
                assertThat(memoryDatabase.getAlbum(newAlbum.getId()), is(Optional.<Album>absent()));
        }
 
+       private void initializeFriends() {
+               when(configuration.getStringValue("Sone/" + SONE_ID + "/Friends/0/ID")).thenReturn(
+                               TestValue.from("Friend1"));
+               when(configuration.getStringValue("Sone/" + SONE_ID + "/Friends/1/ID")).thenReturn(
+                               TestValue.from("Friend2"));
+               when(configuration.getStringValue("Sone/" + SONE_ID + "/Friends/2/ID")).thenReturn(
+                               TestValue.<String>from(null));
+       }
+
+       @Test
+       public void friendsAreReturnedCorrectly() {
+               initializeFriends();
+               when(sone.isLocal()).thenReturn(true);
+               Collection<String> friends = memoryDatabase.getFriends(sone);
+               assertThat(friends, containsInAnyOrder("Friend1", "Friend2"));
+       }
+
+       @Test
+       public void friendsAreOnlyLoadedOnceFromConfiguration() {
+               friendsAreReturnedCorrectly();
+               memoryDatabase.getFriends(sone);
+               verify(configuration).getStringValue("Sone/" + SONE_ID + "/Friends/0/ID");
+       }
+
+       @Test
+       public void friendsAreOnlyReturnedForLocalSones() {
+               Collection<String> friends = memoryDatabase.getFriends(sone);
+               assertThat(friends, emptyIterable());
+               verify(configuration, never()).getStringValue("Sone/" + SONE_ID + "/Friends/0/ID");
+       }
+
+       @Test
+       public void checkingForAFriendReturnsTrue() {
+               initializeFriends();
+               when(sone.isLocal()).thenReturn(true);
+               assertThat(memoryDatabase.isFriend(sone, "Friend1"), is(true));
+       }
+
+       @Test
+       public void checkingForAFriendThatIsNotAFriendReturnsFalse() {
+               initializeFriends();
+               when(sone.isLocal()).thenReturn(true);
+               assertThat(memoryDatabase.isFriend(sone, "FriendX"), is(false));
+       }
+
+       @Test
+       public void checkingForAFriendOfRemoteSoneReturnsFalse() {
+               initializeFriends();
+               assertThat(memoryDatabase.isFriend(sone, "Friend1"), is(false));
+       }
+
+       private Map<String, Value<String>> prepareConfigurationValues() {
+               final Map<String, Value<String>> configurationValues = new HashMap<String, Value<String>>();
+               when(configuration.getStringValue(anyString())).thenAnswer(new Answer<Value<String>>() {
+                       @Override
+                       public Value<String> answer(InvocationOnMock invocation) throws Throwable {
+                               Value<String> stringValue = TestValue.from(null);
+                               configurationValues.put((String) invocation.getArguments()[0], stringValue);
+                               return stringValue;
+                       }
+               });
+               return configurationValues;
+       }
+
+       @Test
+       public void friendIsAddedCorrectlyToLocalSone() {
+               Map<String, Value<String>> configurationValues = prepareConfigurationValues();
+               when(sone.isLocal()).thenReturn(true);
+               memoryDatabase.addFriend(sone, "Friend1");
+               assertThat(configurationValues.get("Sone/" + SONE_ID + "/Friends/0/ID"),
+                               is(TestValue.from("Friend1")));
+               assertThat(configurationValues.get("Sone/" + SONE_ID + "/Friends/1/ID"),
+                               is(TestValue.<String>from(null)));
+       }
+
+       @Test
+       public void friendIsNotAddedToRemoteSone() {
+               memoryDatabase.addFriend(sone, "Friend1");
+               verify(configuration, never()).getStringValue(anyString());
+       }
+
+       @Test
+       public void configurationIsWrittenOnceIfFriendIsAddedTwice() {
+               prepareConfigurationValues();
+               when(sone.isLocal()).thenReturn(true);
+               memoryDatabase.addFriend(sone, "Friend1");
+               memoryDatabase.addFriend(sone, "Friend1");
+               verify(configuration, times(3)).getStringValue(anyString());
+       }
+
+       @Test
+       public void friendIsRemovedCorrectlyFromLocalSone() {
+               Map<String, Value<String>> configurationValues = prepareConfigurationValues();
+               when(sone.isLocal()).thenReturn(true);
+               memoryDatabase.addFriend(sone, "Friend1");
+               memoryDatabase.removeFriend(sone, "Friend1");
+               assertThat(configurationValues.get("Sone/" + SONE_ID + "/Friends/0/ID"),
+                               is(TestValue.<String>from(null)));
+               assertThat(configurationValues.get("Sone/" + SONE_ID + "/Friends/1/ID"),
+                               is(TestValue.<String>from(null)));
+       }
+
+       @Test
+       public void configurationIsNotWrittenWhenANonFriendIsRemoved() {
+               prepareConfigurationValues();
+               when(sone.isLocal()).thenReturn(true);
+               memoryDatabase.removeFriend(sone, "Friend1");
+               verify(configuration).getStringValue(anyString());
+       }
+
 }
diff --git a/src/test/java/net/pterodactylus/sone/fcp/FcpInterfaceTest.java b/src/test/java/net/pterodactylus/sone/fcp/FcpInterfaceTest.java
new file mode 100644 (file)
index 0000000..2312907
--- /dev/null
@@ -0,0 +1,57 @@
+package net.pterodactylus.sone.fcp;
+
+import static net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired.ALWAYS;
+import static net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired.NO;
+import static net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired.WRITING;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+import net.pterodactylus.sone.fcp.event.FcpInterfaceActivatedEvent;
+import net.pterodactylus.sone.fcp.event.FcpInterfaceDeactivatedEvent;
+import net.pterodactylus.sone.fcp.event.FullAccessRequiredChanged;
+
+import org.junit.Test;
+
+/**
+ * Unit test for {@link FcpInterface} and its subclasses.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class FcpInterfaceTest {
+
+       private final FcpInterface fcpInterface = new FcpInterface(null);
+
+       @Test
+       public void fcpInterfaceCanBeActivated() {
+               fcpInterface.fcpInterfaceActivated(new FcpInterfaceActivatedEvent());
+               assertThat(fcpInterface.isActive(), is(true));
+       }
+
+       @Test
+       public void fcpInterfaceCanBeDeactivated() {
+               fcpInterface.fcpInterfaceDeactivated(new FcpInterfaceDeactivatedEvent());
+               assertThat(fcpInterface.isActive(), is(false));
+       }
+
+       @Test
+       public void setFullAccessRequiredCanSetAccessToNo() {
+               fcpInterface.fullAccessRequiredChanged(
+                               new FullAccessRequiredChanged(NO));
+               assertThat(fcpInterface.getFullAccessRequired(), is(NO));
+       }
+
+       @Test
+       public void setFullAccessRequiredCanSetAccessToWriting() {
+               fcpInterface.fullAccessRequiredChanged(
+                               new FullAccessRequiredChanged(WRITING));
+               assertThat(fcpInterface.getFullAccessRequired(), is(WRITING));
+       }
+
+       @Test
+       public void setFullAccessRequiredCanSetAccessToAlways() {
+               fcpInterface.fullAccessRequiredChanged(
+                               new FullAccessRequiredChanged(ALWAYS));
+               assertThat(fcpInterface.getFullAccessRequired(), is(ALWAYS));
+       }
+
+}
index ae1993a..2306931 100644 (file)
@@ -20,7 +20,6 @@ package net.pterodactylus.sone.fcp;
 import static org.hamcrest.CoreMatchers.is;
 import static org.hamcrest.CoreMatchers.notNullValue;
 import static org.hamcrest.MatcherAssert.assertThat;
-import static org.mockito.Matchers.anyBoolean;
 import static org.mockito.Matchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
@@ -51,7 +50,7 @@ public class LockSoneCommandTest {
                when(localSone.isLocal()).thenReturn(true);
                Core core = mock(Core.class);
                when(core.getSone(eq("LocalSone"))).thenReturn(Optional.of(localSone));
-               when(core.getLocalSone(eq("LocalSone"), anyBoolean())).thenReturn(localSone);
+               when(core.getLocalSone(eq("LocalSone"))).thenReturn(localSone);
                SimpleFieldSet fields = new SimpleFieldSetBuilder().put("Sone", "LocalSone").get();
 
                LockSoneCommand lockSoneCommand = new LockSoneCommand(core);
index b966b19..ca615ee 100644 (file)
@@ -20,7 +20,6 @@ package net.pterodactylus.sone.fcp;
 import static org.hamcrest.CoreMatchers.is;
 import static org.hamcrest.CoreMatchers.notNullValue;
 import static org.hamcrest.MatcherAssert.assertThat;
-import static org.mockito.Matchers.anyBoolean;
 import static org.mockito.Matchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
@@ -51,7 +50,7 @@ public class UnlockSoneCommandTest {
                when(localSone.isLocal()).thenReturn(true);
                Core core = mock(Core.class);
                when(core.getSone(eq("LocalSone"))).thenReturn(Optional.of(localSone));
-               when(core.getLocalSone(eq("LocalSone"), anyBoolean())).thenReturn(localSone);
+               when(core.getLocalSone(eq("LocalSone"))).thenReturn(localSone);
                SimpleFieldSet fields = new SimpleFieldSetBuilder().put("Sone", "LocalSone").get();
 
                UnlockSoneCommand unlockSoneCommand = new UnlockSoneCommand(core);
diff --git a/src/test/java/net/pterodactylus/sone/freenet/KeyTest.java b/src/test/java/net/pterodactylus/sone/freenet/KeyTest.java
new file mode 100644 (file)
index 0000000..8fff7bd
--- /dev/null
@@ -0,0 +1,69 @@
+package net.pterodactylus.sone.freenet;
+
+import static freenet.support.Base64.encode;
+import static net.pterodactylus.sone.freenet.Key.from;
+import static net.pterodactylus.sone.freenet.Key.routingKey;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+import java.net.MalformedURLException;
+
+import freenet.keys.FreenetURI;
+
+import org.junit.Test;
+
+/**
+ * Unit test for {@link Key}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class KeyTest {
+
+       private final FreenetURI uri;
+       private final Key key;
+
+       public KeyTest() throws MalformedURLException {
+               uri = new FreenetURI(
+                               "SSK@NfUYvxDwU9vqb2mh-qdT~DYJ6U0XNbxMGGoLe0aCHJs,Miglsgix0VR56ZiPl4NgjnUd~UdrnHqIvXJ3KKHmxmI,AQACAAE/some-site-12/foo/bar.html");
+               key = from(uri);
+       }
+
+       @Test
+       public void keyCanBeCreatedFromFreenetUri() throws MalformedURLException {
+               assertThat(key.getRoutingKey(),
+                               is("NfUYvxDwU9vqb2mh-qdT~DYJ6U0XNbxMGGoLe0aCHJs"));
+               assertThat(key.getCryptoKey(),
+                               is("Miglsgix0VR56ZiPl4NgjnUd~UdrnHqIvXJ3KKHmxmI"));
+               assertThat(key.getExtra(), is("AQACAAE"));
+       }
+
+       @Test
+       public void keyCanBeConvertedToUsk() throws MalformedURLException {
+               FreenetURI uskUri = key.toUsk("other-site", 15, "some", "path.html");
+               assertThat(uskUri.toString(),
+                               is("USK@NfUYvxDwU9vqb2mh-qdT~DYJ6U0XNbxMGGoLe0aCHJs,Miglsgix0VR56ZiPl4NgjnUd~UdrnHqIvXJ3KKHmxmI,AQACAAE/other-site/15/some/path.html"));
+       }
+
+       @Test
+       public void keyCanBeConvertedToSskWithoutEdition()
+       throws MalformedURLException {
+               FreenetURI uskUri = key.toSsk("other-site", "some", "path.html");
+               assertThat(uskUri.toString(),
+                               is("SSK@NfUYvxDwU9vqb2mh-qdT~DYJ6U0XNbxMGGoLe0aCHJs,Miglsgix0VR56ZiPl4NgjnUd~UdrnHqIvXJ3KKHmxmI,AQACAAE/other-site/some/path.html"));
+       }
+
+       @Test
+       public void keyCanBeConvertedToSskWithEdition()
+       throws MalformedURLException {
+               FreenetURI uskUri = key.toSsk("other-site", 15, "some", "path.html");
+               assertThat(uskUri.toString(),
+                               is("SSK@NfUYvxDwU9vqb2mh-qdT~DYJ6U0XNbxMGGoLe0aCHJs,Miglsgix0VR56ZiPl4NgjnUd~UdrnHqIvXJ3KKHmxmI,AQACAAE/other-site-15/some/path.html"));
+       }
+
+       @Test
+       public void routingKeyIsExtractCorrectly() {
+               assertThat(routingKey(uri),
+                               is("NfUYvxDwU9vqb2mh-qdT~DYJ6U0XNbxMGGoLe0aCHJs"));
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/freenet/wot/DefaultIdentityTest.java b/src/test/java/net/pterodactylus/sone/freenet/wot/DefaultIdentityTest.java
new file mode 100644 (file)
index 0000000..165d5fd
--- /dev/null
@@ -0,0 +1,152 @@
+/*
+ * Sone - DefaultIdentityTest.java - Copyright © 2013 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone.freenet.wot;
+
+import static com.google.common.collect.ImmutableMap.of;
+import static java.util.Arrays.asList;
+import static net.pterodactylus.sone.Matchers.matchesRegex;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.hasEntry;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
+import static org.hamcrest.Matchers.nullValue;
+import static org.hamcrest.collection.IsIterableContainingInOrder.contains;
+import static org.mockito.Mockito.mock;
+
+import java.util.Collections;
+
+import org.junit.Test;
+
+/**
+ * Unit test for {@link DefaultIdentity}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class DefaultIdentityTest {
+
+       protected final DefaultIdentity identity = createIdentity();
+
+       protected DefaultIdentity createIdentity() {
+               return new DefaultIdentity("Id", "Nickname", "RequestURI");
+       }
+
+       @Test
+       public void identityCanBeCreated() {
+               assertThat(identity.getId(), is("Id"));
+               assertThat(identity.getNickname(), is("Nickname"));
+               assertThat(identity.getRequestUri(), is("RequestURI"));
+               assertThat(identity.getContexts(), empty());
+               assertThat(identity.getProperties(), is(Collections.<String, String>emptyMap()));
+       }
+
+       @Test
+       public void contextsAreAddedCorrectly() {
+               identity.addContext("Test");
+               assertThat(identity.getContexts(), contains("Test"));
+               assertThat(identity.hasContext("Test"), is(true));
+       }
+
+       @Test
+       public void contextsAreRemovedCorrectly() {
+               identity.addContext("Test");
+               identity.removeContext("Test");
+               assertThat(identity.getContexts(), empty());
+               assertThat(identity.hasContext("Test"), is(false));
+       }
+
+       @Test
+       public void contextsAreSetCorrectlyInBulk() {
+               identity.addContext("Test");
+               identity.setContexts(asList("Test1", "Test2"));
+               assertThat(identity.getContexts(), containsInAnyOrder("Test1", "Test2"));
+               assertThat(identity.hasContext("Test"), is(false));
+               assertThat(identity.hasContext("Test1"), is(true));
+               assertThat(identity.hasContext("Test2"), is(true));
+       }
+
+       @Test
+       public void propertiesAreAddedCorrectly() {
+               identity.setProperty("Key", "Value");
+               assertThat(identity.getProperties().size(), is(1));
+               assertThat(identity.getProperties(), hasEntry("Key", "Value"));
+               assertThat(identity.getProperty("Key"), is("Value"));
+       }
+
+       @Test
+       public void propertiesAreRemovedCorrectly() {
+               identity.setProperty("Key", "Value");
+               identity.removeProperty("Key");
+               assertThat(identity.getProperties(), is(Collections.<String, String>emptyMap()));
+               assertThat(identity.getProperty("Key"), nullValue());
+       }
+
+       @Test
+       public void propertiesAreSetCorrectlyInBulk() {
+               identity.setProperty("Key", "Value");
+               identity.setProperties(of("Key1", "Value1", "Key2", "Value2"));
+               assertThat(identity.getProperties().size(), is(2));
+               assertThat(identity.getProperty("Key"), nullValue());
+               assertThat(identity.getProperty("Key1"), is("Value1"));
+               assertThat(identity.getProperty("Key2"), is("Value2"));
+       }
+
+       @Test
+       public void trustRelationshipsAreAddedCorrectly() {
+               OwnIdentity ownIdentity = mock(OwnIdentity.class);
+               Trust trust = mock(Trust.class);
+               identity.setTrust(ownIdentity, trust);
+               assertThat(identity.getTrust(ownIdentity), is(trust));
+       }
+
+       @Test
+       public void trustRelationshipsAreRemovedCorrectly() {
+               OwnIdentity ownIdentity = mock(OwnIdentity.class);
+               Trust trust = mock(Trust.class);
+               identity.setTrust(ownIdentity, trust);
+               identity.removeTrust(ownIdentity);
+               assertThat(identity.getTrust(ownIdentity), nullValue());
+       }
+
+       @Test
+       public void identitiesWithTheSameIdAreEqual() {
+               DefaultIdentity identity2 = new DefaultIdentity("Id", "Nickname2", "RequestURI2");
+               assertThat(identity2, is(identity));
+               assertThat(identity, is(identity2));
+       }
+
+       @Test
+       public void twoEqualIdentitiesHaveTheSameHashCode() {
+               DefaultIdentity identity2 = new DefaultIdentity("Id", "Nickname2", "RequestURI2");
+               assertThat(identity.hashCode(), is(identity2.hashCode()));
+       }
+
+       @Test
+       public void nullDoesNotMatchAnIdentity() {
+               assertThat(identity, not(is((Object) null)));
+       }
+
+       @Test
+       public void toStringContainsIdAndNickname() {
+               String identityString = identity.toString();
+               assertThat(identityString, matchesRegex(".*\\bId\\b.*"));
+               assertThat(identityString, matchesRegex(".*\\bNickname\\b.*"));
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/freenet/wot/DefaultOwnIdentityTest.java b/src/test/java/net/pterodactylus/sone/freenet/wot/DefaultOwnIdentityTest.java
new file mode 100644 (file)
index 0000000..4e2728b
--- /dev/null
@@ -0,0 +1,42 @@
+/*
+ * Sone - DefaultOwnIdentityTest.java - Copyright © 2013 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone.freenet.wot;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+import org.junit.Test;
+
+/**
+ * Unit test for {@link DefaultOwnIdentity}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class DefaultOwnIdentityTest extends DefaultIdentityTest {
+
+       @Override
+       protected DefaultIdentity createIdentity() {
+               return new DefaultOwnIdentity("Id", "Nickname", "RequestURI", "InsertURI");
+       }
+
+       @Test
+       public void ownIdentityCanBeCreated() {
+               assertThat(((OwnIdentity) identity).getInsertUri(), is("InsertURI"));
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/freenet/wot/Identities.java b/src/test/java/net/pterodactylus/sone/freenet/wot/Identities.java
new file mode 100644 (file)
index 0000000..fe3625e
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * Sone - Identities.java - Copyright © 2013 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone.freenet.wot;
+
+import java.util.Collection;
+import java.util.Map;
+
+/**
+ * Creates {@link Identity}s and {@link OwnIdentity}s.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class Identities {
+
+       public static OwnIdentity createOwnIdentity(String id, Collection<String> contexts, Map<String, String> properties) {
+               DefaultOwnIdentity ownIdentity = new DefaultOwnIdentity(id, "Nickname" + id, "Request" + id, "Insert" + id);
+               setContextsAndPropertiesOnIdentity(ownIdentity, contexts, properties);
+               return ownIdentity;
+       }
+
+       public static Identity createIdentity(String id, Collection<String> contexts, Map<String, String> properties) {
+               DefaultIdentity identity = new DefaultIdentity(id, "Nickname" + id, "Request" + id);
+               setContextsAndPropertiesOnIdentity(identity, contexts, properties);
+               return identity;
+       }
+
+       private static void setContextsAndPropertiesOnIdentity(Identity identity, Collection<String> contexts, Map<String, String> properties) {
+               identity.setContexts(contexts);
+               identity.setProperties(properties);
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/freenet/wot/IdentityChangeDetectorTest.java b/src/test/java/net/pterodactylus/sone/freenet/wot/IdentityChangeDetectorTest.java
new file mode 100644 (file)
index 0000000..b2d4ed1
--- /dev/null
@@ -0,0 +1,190 @@
+/*
+ * Sone - IdentityChangeDetectorTest.java - Copyright © 2013 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone.freenet.wot;
+
+import static com.google.common.collect.ImmutableMap.of;
+import static com.google.common.collect.Lists.newArrayList;
+import static java.util.Arrays.asList;
+import static net.pterodactylus.sone.freenet.wot.Identities.createIdentity;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.empty;
+
+import java.util.Collection;
+
+import net.pterodactylus.sone.freenet.wot.IdentityChangeDetector.IdentityProcessor;
+
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit test for {@link IdentityChangeDetector}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class IdentityChangeDetectorTest {
+
+       private final IdentityChangeDetector identityChangeDetector = new IdentityChangeDetector(createOldIdentities());
+       private final Collection<Identity> newIdentities = newArrayList();
+       private final Collection<Identity> removedIdentities = newArrayList();
+       private final Collection<Identity> changedIdentities = newArrayList();
+       private final Collection<Identity> unchangedIdentities = newArrayList();
+
+       @Before
+       public void setup() {
+               identityChangeDetector.onNewIdentity(new IdentityProcessor() {
+                       @Override
+                       public void processIdentity(Identity identity) {
+                               newIdentities.add(identity);
+                       }
+               });
+               identityChangeDetector.onRemovedIdentity(new IdentityProcessor() {
+                       @Override
+                       public void processIdentity(Identity identity) {
+                               removedIdentities.add(identity);
+                       }
+               });
+               identityChangeDetector.onChangedIdentity(new IdentityProcessor() {
+                       @Override
+                       public void processIdentity(Identity identity) {
+                               changedIdentities.add(identity);
+                       }
+               });
+               identityChangeDetector.onUnchangedIdentity(new IdentityProcessor() {
+                       @Override
+                       public void processIdentity(Identity identity) {
+                               unchangedIdentities.add(identity);
+                       }
+               });
+       }
+
+       @Test
+       public void noDifferencesAreDetectedWhenSendingTheOldIdentitiesAgain() {
+               identityChangeDetector.detectChanges(createOldIdentities());
+               assertThat(newIdentities, empty());
+               assertThat(removedIdentities, empty());
+               assertThat(changedIdentities, empty());
+               assertThat(unchangedIdentities, containsInAnyOrder(createIdentity1(), createIdentity2(), createIdentity3()));
+       }
+
+       @Test
+       public void detectThatAnIdentityWasRemoved() {
+               identityChangeDetector.detectChanges(asList(createIdentity1(), createIdentity3()));
+               assertThat(newIdentities, empty());
+               assertThat(removedIdentities, containsInAnyOrder(createIdentity2()));
+               assertThat(changedIdentities, empty());
+               assertThat(unchangedIdentities, containsInAnyOrder(createIdentity1(), createIdentity3()));
+       }
+
+       @Test
+       public void detectThatAnIdentityWasAdded() {
+               identityChangeDetector.detectChanges(asList(createIdentity1(), createIdentity2(), createIdentity3(), createIdentity4()));
+               assertThat(newIdentities, containsInAnyOrder(createIdentity4()));
+               assertThat(removedIdentities, empty());
+               assertThat(changedIdentities, empty());
+               assertThat(unchangedIdentities, containsInAnyOrder(createIdentity1(), createIdentity2(), createIdentity3()));
+       }
+
+       @Test
+       public void detectThatAContextWasRemoved() {
+               Identity identity2 = createIdentity2();
+               identity2.removeContext("Context C");
+               identityChangeDetector.detectChanges(asList(createIdentity1(), identity2, createIdentity3()));
+               assertThat(newIdentities, empty());
+               assertThat(removedIdentities, empty());
+               assertThat(changedIdentities, containsInAnyOrder(identity2));
+               assertThat(unchangedIdentities, containsInAnyOrder(createIdentity1(), createIdentity3()));
+       }
+
+       @Test
+       public void detectThatAContextWasAdded() {
+               Identity identity2 = createIdentity2();
+               identity2.addContext("Context C1");
+               identityChangeDetector.detectChanges(asList(createIdentity1(), identity2, createIdentity3()));
+               assertThat(newIdentities, empty());
+               assertThat(removedIdentities, empty());
+               assertThat(changedIdentities, containsInAnyOrder(identity2));
+               assertThat(unchangedIdentities, containsInAnyOrder(createIdentity1(), createIdentity3()));
+       }
+
+       @Test
+       public void detectThatAPropertyWasRemoved() {
+               Identity identity1 = createIdentity1();
+               identity1.removeProperty("Key A");
+               identityChangeDetector.detectChanges(asList(identity1, createIdentity2(), createIdentity3()));
+               assertThat(newIdentities, empty());
+               assertThat(removedIdentities, empty());
+               assertThat(changedIdentities, containsInAnyOrder(identity1));
+               assertThat(unchangedIdentities, containsInAnyOrder(createIdentity2(), createIdentity3()));
+       }
+
+       @Test
+       public void detectThatAPropertyWasAdded() {
+               Identity identity3 = createIdentity3();
+               identity3.setProperty("Key A", "Value A");
+               identityChangeDetector.detectChanges(asList(createIdentity1(), createIdentity2(), identity3));
+               assertThat(newIdentities, empty());
+               assertThat(removedIdentities, empty());
+               assertThat(changedIdentities, containsInAnyOrder(identity3));
+               assertThat(unchangedIdentities, containsInAnyOrder(createIdentity1(), createIdentity2()));
+       }
+
+       @Test
+       public void detectThatAPropertyWasChanged() {
+               Identity identity3 = createIdentity3();
+               identity3.setProperty("Key E", "Value F");
+               identityChangeDetector.detectChanges(asList(createIdentity1(), createIdentity2(), identity3));
+               assertThat(newIdentities, empty());
+               assertThat(removedIdentities, empty());
+               assertThat(changedIdentities, containsInAnyOrder(identity3));
+               assertThat(unchangedIdentities, containsInAnyOrder(createIdentity1(), createIdentity2()));
+       }
+
+       @Test
+       public void noRemovedIdentitiesAreDetectedWithoutAnIdentityProcessor() {
+               identityChangeDetector.onRemovedIdentity(null);
+               identityChangeDetector.detectChanges(asList(createIdentity1(), createIdentity3()));
+       }
+
+       @Test
+       public void noAddedIdentitiesAreDetectedWithoutAnIdentityProcessor() {
+               identityChangeDetector.onNewIdentity(null);
+               identityChangeDetector.detectChanges(asList(createIdentity1(), createIdentity2(), createIdentity3(), createIdentity4()));
+       }
+
+       private static Collection<Identity> createOldIdentities() {
+               return asList(createIdentity1(), createIdentity2(), createIdentity3());
+       }
+
+       private static Identity createIdentity1() {
+               return createIdentity("Test1", asList("Context A", "Context B"), of("Key A", "Value A", "Key B", "Value B"));
+       }
+
+       private static Identity createIdentity2() {
+               return createIdentity("Test2", asList("Context C", "Context D"), of("Key C", "Value C", "Key D", "Value D"));
+       }
+
+       private static Identity createIdentity3() {
+               return createIdentity("Test3", asList("Context E", "Context F"), of("Key E", "Value E", "Key F", "Value F"));
+       }
+
+       private static Identity createIdentity4() {
+               return createIdentity("Test4", asList("Context G", "Context H"), of("Key G", "Value G", "Key H", "Value H"));
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/freenet/wot/IdentityChangeEventSenderTest.java b/src/test/java/net/pterodactylus/sone/freenet/wot/IdentityChangeEventSenderTest.java
new file mode 100644 (file)
index 0000000..8e1cf6a
--- /dev/null
@@ -0,0 +1,93 @@
+/*
+ * Sone - IdentityChangeEventSenderTest.java - Copyright © 2013 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone.freenet.wot;
+
+import static com.google.common.collect.ImmutableMap.of;
+import static java.util.Arrays.asList;
+import static net.pterodactylus.sone.freenet.wot.Identities.createIdentity;
+import static net.pterodactylus.sone.freenet.wot.Identities.createOwnIdentity;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import net.pterodactylus.sone.freenet.wot.event.IdentityAddedEvent;
+import net.pterodactylus.sone.freenet.wot.event.IdentityRemovedEvent;
+import net.pterodactylus.sone.freenet.wot.event.IdentityUpdatedEvent;
+import net.pterodactylus.sone.freenet.wot.event.OwnIdentityAddedEvent;
+import net.pterodactylus.sone.freenet.wot.event.OwnIdentityRemovedEvent;
+
+import com.google.common.eventbus.EventBus;
+import org.junit.Test;
+
+/**
+ * Unit test for {@link IdentityChangeEventSender}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class IdentityChangeEventSenderTest {
+
+       private final EventBus eventBus = mock(EventBus.class);
+       private final List<OwnIdentity> ownIdentities = asList(
+                       createOwnIdentity("O1", asList("Test"), of("KeyA", "ValueA")),
+                       createOwnIdentity("O2", asList("Test2"), of("KeyB", "ValueB")),
+                       createOwnIdentity("O3", asList("Test3"), of("KeyC", "ValueC"))
+       );
+       private final List<Identity> identities = asList(
+                       createIdentity("I1", Collections.<String>emptyList(), Collections.<String, String>emptyMap()),
+                       createIdentity("I2", Collections.<String>emptyList(), Collections.<String, String>emptyMap()),
+                       createIdentity("I3", Collections.<String>emptyList(), Collections.<String, String>emptyMap()),
+                       createIdentity("I2", asList("Test"), Collections.<String, String>emptyMap())
+       );
+       private final IdentityChangeEventSender identityChangeEventSender = new IdentityChangeEventSender(eventBus, createOldIdentities());
+
+       @Test
+       public void addingAnOwnIdentityIsDetectedAndReportedCorrectly() {
+               Map<OwnIdentity, Collection<Identity>> newIdentities = createNewIdentities();
+               identityChangeEventSender.detectChanges(newIdentities);
+               verify(eventBus).post(eq(new OwnIdentityRemovedEvent(ownIdentities.get(0))));
+               verify(eventBus).post(eq(new IdentityRemovedEvent(ownIdentities.get(0), identities.get(0))));
+               verify(eventBus).post(eq(new IdentityRemovedEvent(ownIdentities.get(0), identities.get(1))));
+               verify(eventBus).post(eq(new OwnIdentityAddedEvent(ownIdentities.get(2))));
+               verify(eventBus).post(eq(new IdentityAddedEvent(ownIdentities.get(2), identities.get(1))));
+               verify(eventBus).post(eq(new IdentityAddedEvent(ownIdentities.get(2), identities.get(2))));
+               verify(eventBus).post(eq(new IdentityRemovedEvent(ownIdentities.get(1), identities.get(0))));
+               verify(eventBus).post(eq(new IdentityAddedEvent(ownIdentities.get(1), identities.get(2))));
+               verify(eventBus).post(eq(new IdentityUpdatedEvent(ownIdentities.get(1), identities.get(1))));
+       }
+
+       private Map<OwnIdentity, Collection<Identity>> createNewIdentities() {
+               Map<OwnIdentity, Collection<Identity>> oldIdentities = new HashMap<OwnIdentity, Collection<Identity>>();
+               oldIdentities.put(ownIdentities.get(1), asList(identities.get(3), identities.get(2)));
+               oldIdentities.put(ownIdentities.get(2), asList(identities.get(1), identities.get(2)));
+               return oldIdentities;
+       }
+
+       private Map<OwnIdentity, Collection<Identity>> createOldIdentities() {
+               Map<OwnIdentity, Collection<Identity>> oldIdentities = new HashMap<OwnIdentity, Collection<Identity>>();
+               oldIdentities.put(ownIdentities.get(0), asList(identities.get(0), identities.get(1)));
+               oldIdentities.put(ownIdentities.get(1), asList(identities.get(0), identities.get(1)));
+               return oldIdentities;
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/freenet/wot/IdentityLoaderTest.java b/src/test/java/net/pterodactylus/sone/freenet/wot/IdentityLoaderTest.java
new file mode 100644 (file)
index 0000000..98e187d
--- /dev/null
@@ -0,0 +1,156 @@
+/*
+ * Sone - IdentityLoaderTest.java - Copyright © 2013 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone.freenet.wot;
+
+import static com.google.common.base.Optional.of;
+import static com.google.common.collect.Lists.newArrayList;
+import static com.google.common.collect.Sets.newHashSet;
+import static java.util.Arrays.asList;
+import static java.util.Collections.emptySet;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableMap;
+import org.hamcrest.Matchers;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit test for {@link IdentityLoader}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class IdentityLoaderTest {
+
+       private final WebOfTrustConnector webOfTrustConnector = mock(WebOfTrustConnector.class);
+       private final IdentityLoader identityLoader = new IdentityLoader(webOfTrustConnector, of(new Context("Test")));
+       private final IdentityLoader identityLoaderWithoutContext = new IdentityLoader(webOfTrustConnector);
+
+       @Before
+       public void setup() throws WebOfTrustException {
+               List<OwnIdentity> ownIdentities = createOwnIdentities();
+               when(webOfTrustConnector.loadAllOwnIdentities()).thenReturn(newHashSet(ownIdentities));
+               when(webOfTrustConnector.loadTrustedIdentities(eq(ownIdentities.get(0)), any(Optional.class))).thenReturn(createTrustedIdentitiesForFirstOwnIdentity());
+               when(webOfTrustConnector.loadTrustedIdentities(eq(ownIdentities.get(1)), any(Optional.class))).thenReturn(createTrustedIdentitiesForSecondOwnIdentity());
+               when(webOfTrustConnector.loadTrustedIdentities(eq(ownIdentities.get(2)), any(Optional.class))).thenReturn(createTrustedIdentitiesForThirdOwnIdentity());
+               when(webOfTrustConnector.loadTrustedIdentities(eq(ownIdentities.get(3)), any(Optional.class))).thenReturn(createTrustedIdentitiesForFourthOwnIdentity());
+       }
+
+       private List<OwnIdentity> createOwnIdentities() {
+               return newArrayList(
+                               createOwnIdentity("O1", "ON1", "OR1", "OI1", asList("Test", "Test2"), ImmutableMap.of("KeyA", "ValueA", "KeyB", "ValueB")),
+                               createOwnIdentity("O2", "ON2", "OR2", "OI2", asList("Test"), ImmutableMap.of("KeyC", "ValueC")),
+                               createOwnIdentity("O3", "ON3", "OR3", "OI3", asList("Test2"), ImmutableMap.of("KeyE", "ValueE", "KeyD", "ValueD")),
+                               createOwnIdentity("O4", "ON4", "OR$", "OI4", asList("Test"), ImmutableMap.of("KeyA", "ValueA", "KeyD", "ValueD"))
+               );
+       }
+
+       private Set<Identity> createTrustedIdentitiesForFirstOwnIdentity() {
+               return newHashSet(
+                               createIdentity("I11", "IN11", "IR11", asList("Test"), ImmutableMap.of("KeyA", "ValueA"))
+               );
+       }
+
+       private Set<Identity> createTrustedIdentitiesForSecondOwnIdentity() {
+               return newHashSet(
+                               createIdentity("I21", "IN21", "IR21", asList("Test", "Test2"), ImmutableMap.of("KeyB", "ValueB"))
+               );
+       }
+
+       private Set<Identity> createTrustedIdentitiesForThirdOwnIdentity() {
+               return newHashSet(
+                               createIdentity("I31", "IN31", "IR31", asList("Test", "Test3"), ImmutableMap.of("KeyC", "ValueC"))
+               );
+       }
+
+       private Set<Identity> createTrustedIdentitiesForFourthOwnIdentity() {
+               return emptySet();
+       }
+
+       private OwnIdentity createOwnIdentity(String id, String nickname, String requestUri, String insertUri, List<String> contexts, ImmutableMap<String, String> properties) {
+               OwnIdentity ownIdentity = new DefaultOwnIdentity(id, nickname, requestUri, insertUri);
+               ownIdentity.setContexts(contexts);
+               ownIdentity.setProperties(properties);
+               return ownIdentity;
+       }
+
+       private Identity createIdentity(String id, String nickname, String requestUri, List<String> contexts, ImmutableMap<String, String> properties) {
+               Identity identity = new DefaultIdentity(id, nickname, requestUri);
+               identity.setContexts(contexts);
+               identity.setProperties(properties);
+               return identity;
+       }
+
+       @Test
+       public void loadingIdentities() throws WebOfTrustException {
+               List<OwnIdentity> ownIdentities = createOwnIdentities();
+               Map<OwnIdentity, Collection<Identity>> identities = identityLoader.loadIdentities();
+               verify(webOfTrustConnector).loadAllOwnIdentities();
+               verify(webOfTrustConnector).loadTrustedIdentities(eq(ownIdentities.get(0)), eq(of("Test")));
+               verify(webOfTrustConnector).loadTrustedIdentities(eq(ownIdentities.get(1)), eq(of("Test")));
+               verify(webOfTrustConnector, never()).loadTrustedIdentities(eq(ownIdentities.get(2)), any(Optional.class));
+               verify(webOfTrustConnector).loadTrustedIdentities(eq(ownIdentities.get(3)), eq(of("Test")));
+               assertThat(identities.keySet(), hasSize(4));
+               assertThat(identities.keySet(), containsInAnyOrder(ownIdentities.get(0), ownIdentities.get(1), ownIdentities.get(2), ownIdentities.get(3)));
+               verifyIdentitiesForOwnIdentity(identities, ownIdentities.get(0), createTrustedIdentitiesForFirstOwnIdentity());
+               verifyIdentitiesForOwnIdentity(identities, ownIdentities.get(1), createTrustedIdentitiesForSecondOwnIdentity());
+               verifyIdentitiesForOwnIdentity(identities, ownIdentities.get(2), Collections.<Identity>emptySet());
+               verifyIdentitiesForOwnIdentity(identities, ownIdentities.get(3), createTrustedIdentitiesForFourthOwnIdentity());
+       }
+
+       @Test
+       public void loadingIdentitiesWithoutContext() throws WebOfTrustException {
+               List<OwnIdentity> ownIdentities = createOwnIdentities();
+               Map<OwnIdentity, Collection<Identity>> identities = identityLoaderWithoutContext.loadIdentities();
+               verify(webOfTrustConnector).loadAllOwnIdentities();
+               verify(webOfTrustConnector).loadTrustedIdentities(eq(ownIdentities.get(0)), eq(Optional.<String>absent()));
+               verify(webOfTrustConnector).loadTrustedIdentities(eq(ownIdentities.get(1)), eq(Optional.<String>absent()));
+               verify(webOfTrustConnector).loadTrustedIdentities(eq(ownIdentities.get(2)), eq(Optional.<String>absent()));
+               verify(webOfTrustConnector).loadTrustedIdentities(eq(ownIdentities.get(3)), eq(Optional.<String>absent()));
+               assertThat(identities.keySet(), hasSize(4));
+               OwnIdentity firstOwnIdentity = ownIdentities.get(0);
+               OwnIdentity secondOwnIdentity = ownIdentities.get(1);
+               OwnIdentity thirdOwnIdentity = ownIdentities.get(2);
+               OwnIdentity fourthOwnIdentity = ownIdentities.get(3);
+               assertThat(identities.keySet(), containsInAnyOrder(firstOwnIdentity, secondOwnIdentity, thirdOwnIdentity, fourthOwnIdentity));
+               verifyIdentitiesForOwnIdentity(identities, firstOwnIdentity, createTrustedIdentitiesForFirstOwnIdentity());
+               verifyIdentitiesForOwnIdentity(identities, secondOwnIdentity, createTrustedIdentitiesForSecondOwnIdentity());
+               verifyIdentitiesForOwnIdentity(identities, thirdOwnIdentity, createTrustedIdentitiesForThirdOwnIdentity());
+               verifyIdentitiesForOwnIdentity(identities, fourthOwnIdentity, createTrustedIdentitiesForFourthOwnIdentity());
+       }
+
+       private void verifyIdentitiesForOwnIdentity(Map<OwnIdentity, Collection<Identity>> identities, OwnIdentity ownIdentity, Set<Identity> trustedIdentities) {
+               assertThat(identities.get(ownIdentity), Matchers.<Collection<Identity>>is(trustedIdentities));
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/freenet/wot/IdentityManagerTest.java b/src/test/java/net/pterodactylus/sone/freenet/wot/IdentityManagerTest.java
new file mode 100644 (file)
index 0000000..83c696d
--- /dev/null
@@ -0,0 +1,39 @@
+package net.pterodactylus.sone.freenet.wot;
+
+import static com.google.common.base.Optional.of;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import net.pterodactylus.sone.freenet.plugin.PluginException;
+
+import com.google.common.eventbus.EventBus;
+import org.junit.Test;
+
+/**
+ * Unit test for {@link IdentityManagerImpl}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class IdentityManagerTest {
+
+       private final EventBus eventBus = mock(EventBus.class);
+       private final WebOfTrustConnector webOfTrustConnector = mock(WebOfTrustConnector.class);
+       private final IdentityManager identityManager = new IdentityManagerImpl(eventBus, webOfTrustConnector, new IdentityLoader(webOfTrustConnector, of(new Context("Test"))));
+
+       @Test
+       public void identityManagerPingsWotConnector() throws PluginException {
+               assertThat(identityManager.isConnected(), is(true));
+               verify(webOfTrustConnector).ping();
+       }
+
+       @Test
+       public void disconnectedWotConnectorIsRecognized() throws PluginException {
+               doThrow(PluginException.class).when(webOfTrustConnector).ping();
+               assertThat(identityManager.isConnected(), is(false));
+               verify(webOfTrustConnector).ping();
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/freenet/wot/event/IdentityEventTest.java b/src/test/java/net/pterodactylus/sone/freenet/wot/event/IdentityEventTest.java
new file mode 100644 (file)
index 0000000..461e303
--- /dev/null
@@ -0,0 +1,54 @@
+package net.pterodactylus.sone.freenet.wot.event;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
+import static org.mockito.Mockito.mock;
+
+import net.pterodactylus.sone.freenet.wot.Identity;
+import net.pterodactylus.sone.freenet.wot.OwnIdentity;
+
+import org.junit.Test;
+
+/**
+ * Unit test for {@link IdentityEvent}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class IdentityEventTest {
+
+       private final OwnIdentity ownIdentity = mock(OwnIdentity.class);
+       private final Identity identity = mock(Identity.class);
+       private final IdentityEvent identityEvent = createIdentityEvent(ownIdentity, identity);
+
+       private IdentityEvent createIdentityEvent(final OwnIdentity ownIdentity, final Identity identity) {
+               return new IdentityEvent(ownIdentity, identity) {
+               };
+       }
+
+       @Test
+       public void identityEventRetainsIdentities() {
+               assertThat(identityEvent.ownIdentity(), is(ownIdentity));
+               assertThat(identityEvent.identity(), is(identity));
+       }
+
+       @Test
+       public void eventsWithTheSameIdentityHaveTheSameHashCode() {
+               IdentityEvent secondIdentityEvent = createIdentityEvent(ownIdentity, identity);
+               assertThat(identityEvent.hashCode(), is(secondIdentityEvent.hashCode()));
+       }
+
+       @Test
+       public void eventsWithTheSameIdentitiesAreEqual() {
+               IdentityEvent secondIdentityEvent = createIdentityEvent(ownIdentity, identity);
+               assertThat(identityEvent, is(secondIdentityEvent));
+               assertThat(secondIdentityEvent, is(identityEvent));
+       }
+
+       @Test
+       public void nullDoesNotEqualIdentityEvent() {
+               assertThat(identityEvent, not(is((Object) null)));
+       }
+
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/freenet/wot/event/OwnIdentityEventTest.java b/src/test/java/net/pterodactylus/sone/freenet/wot/event/OwnIdentityEventTest.java
new file mode 100644 (file)
index 0000000..c4ec43b
--- /dev/null
@@ -0,0 +1,48 @@
+package net.pterodactylus.sone.freenet.wot.event;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
+import static org.mockito.Mockito.mock;
+
+import net.pterodactylus.sone.freenet.wot.OwnIdentity;
+
+import org.junit.Test;
+
+/**
+ * Unit test for {@link OwnIdentityEvent}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class OwnIdentityEventTest {
+
+       private final OwnIdentity ownIdentity = mock(OwnIdentity.class);
+       private final OwnIdentityEvent ownIdentityEvent = createOwnIdentityEvent(ownIdentity);
+
+       @Test
+       public void eventRetainsOwnIdentity() {
+               assertThat(ownIdentityEvent.ownIdentity(), is(ownIdentity));
+       }
+
+       protected OwnIdentityEvent createOwnIdentityEvent(final OwnIdentity ownIdentity) {
+               return new OwnIdentityEvent(ownIdentity) {
+               };
+       }
+
+       @Test
+       public void twoOwnIdentityEventsWithTheSameIdentityHaveTheSameHashCode() {
+               OwnIdentityEvent secondOwnIdentityEvent = createOwnIdentityEvent(ownIdentity);
+               assertThat(secondOwnIdentityEvent.hashCode(), is(ownIdentityEvent.hashCode()));
+       }
+
+       @Test
+       public void ownIdentityEventDoesNotMatchNull() {
+               assertThat(ownIdentityEvent, not(is((Object) null)));
+       }
+
+       @Test
+       public void ownIdentityEventDoesNotMatchObjectWithADifferentClass() {
+               assertThat(ownIdentityEvent, not(is(new Object())));
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/template/AlbumAccessorTest.java b/src/test/java/net/pterodactylus/sone/template/AlbumAccessorTest.java
new file mode 100644 (file)
index 0000000..afc2a54
--- /dev/null
@@ -0,0 +1,93 @@
+package net.pterodactylus.sone.template;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+
+import net.pterodactylus.sone.TestUtil;
+import net.pterodactylus.sone.data.Album;
+import net.pterodactylus.sone.data.Profile;
+import net.pterodactylus.sone.data.Sone;
+
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+import org.hamcrest.TypeSafeDiagnosingMatcher;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit test for {@link AlbumAccessor}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class AlbumAccessorTest {
+
+       private final AlbumAccessor albumAccessor = new AlbumAccessor();
+       private final Album album = mock(Album.class);
+
+       @Before
+       public void setupAlbum() {
+               when(album.getId()).thenReturn("Album");
+               when(album.getTitle()).thenReturn("Album Title");
+       }
+
+       @Test
+       public void backlinksAreGenerated() {
+               Sone sone = mock(Sone.class);
+               Profile profile = new Profile(sone);
+               when(sone.getId()).thenReturn("Sone");
+               when(sone.getName()).thenReturn("Sone Name");
+               when(sone.getProfile()).thenReturn(profile);
+               Album parentAlbum = mock(Album.class);
+               when(parentAlbum.isRoot()).thenReturn(true);
+               when(album.getSone()).thenReturn(sone);
+               when(album.getParent()).thenReturn(parentAlbum);
+               List<Object> backlinks =
+                               (List<Object>) albumAccessor.get(null, album, "backlinks");
+               assertThat(backlinks, contains(isLink("sone=Sone", "Sone Name"),
+                               isLink("album=Album", "Album Title")));
+       }
+
+       @Test
+       public void nameIsGenerated() {
+               assertThat((String) albumAccessor.get(null, album, "id"),
+                               is("Album"));
+               assertThat((String) albumAccessor.get(null, album, "title"),
+                               is("Album Title"));
+       }
+
+       private static Matcher<Object> isLink(final String target,
+                       final String name) {
+               return new TypeSafeDiagnosingMatcher<Object>() {
+                       @Override
+                       protected boolean matchesSafely(Object item,
+                                       Description mismatchDescription) {
+                               if (!TestUtil.<String>callPrivateMethod(item, "getTarget")
+                                               .contains(target)) {
+                                       mismatchDescription.appendText("link does not contain ")
+                                                       .appendValue(target);
+                                       return false;
+                               }
+                               if (!TestUtil.<String>callPrivateMethod(item, "getName")
+                                               .equals(name)) {
+                                       mismatchDescription.appendText("is not named ")
+                                                       .appendValue(name);
+                                       return false;
+                               }
+                               return true;
+                       }
+
+                       @Override
+                       public void describeTo(Description description) {
+                               description.appendText("link containing ")
+                                               .appendValue(target);
+                               description.appendText(", named ").appendValue(name);
+                       }
+               };
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/template/CollectionAccessorTest.java b/src/test/java/net/pterodactylus/sone/template/CollectionAccessorTest.java
new file mode 100644 (file)
index 0000000..d0e5057
--- /dev/null
@@ -0,0 +1,57 @@
+package net.pterodactylus.sone.template;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.Collection;
+
+import net.pterodactylus.sone.data.Profile;
+import net.pterodactylus.sone.data.Sone;
+
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit test for {@link CollectionAccessor}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class CollectionAccessorTest {
+
+       private final CollectionAccessor accessor = new CollectionAccessor();
+       private final Collection<Object> collection = new ArrayList<Object>();
+
+       @Before
+       public void setupCollection() {
+               collection.add(new Object());
+               collection.add(createSone("One", "1.", "First"));
+               collection.add(new Object());
+               collection.add(createSone("Two", "2.", "Second"));
+       }
+
+       private Sone createSone(String firstName, String middleName,
+                       String lastName) {
+               Sone sone = mock(Sone.class);
+               Profile profile = new Profile(sone);
+               profile.setFirstName(firstName).setMiddleName(middleName).setLastName(
+                               lastName);
+               when(sone.getProfile()).thenReturn(profile);
+               return sone;
+       }
+
+       @Test
+       public void soneNamesAreConcatenatedCorrectly() {
+               assertThat(accessor.get(null, collection, "soneNames"),
+                               is((Object) "One 1. First, Two 2. Second"));
+       }
+
+       @Test
+       public void sizeIsReportedCorrectly() {
+               assertThat(accessor.get(null, collection, "size"),
+                               is((Object) Integer.valueOf(4)));
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/template/CssClassNameFilterTest.java b/src/test/java/net/pterodactylus/sone/template/CssClassNameFilterTest.java
new file mode 100644 (file)
index 0000000..7b47567
--- /dev/null
@@ -0,0 +1,35 @@
+package net.pterodactylus.sone.template;
+
+import static java.util.Collections.emptyMap;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import java.util.Map;
+
+import org.hamcrest.Matchers;
+import org.junit.Test;
+
+/**
+ * Unit test for {@link CssClassNameFilter}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class CssClassNameFilterTest {
+
+       private static final Map<String, Object> EMPTY_MAP = emptyMap();
+       private final CssClassNameFilter filter = new CssClassNameFilter();
+
+       @Test
+       public void stringsAreFiltered() {
+               String allCharacters = "name with äöü";
+               String filteredCharacters = "name_with____";
+               assertThat(filter.format(null, allCharacters, EMPTY_MAP),
+                               Matchers.<Object>is(filteredCharacters));
+       }
+
+       @Test
+       public void nullIsFiltered() {
+               assertThat(filter.format(null, null, EMPTY_MAP),
+                               Matchers.<Object>is("null"));
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/template/GetPagePluginTest.java b/src/test/java/net/pterodactylus/sone/template/GetPagePluginTest.java
new file mode 100644 (file)
index 0000000..febd06a
--- /dev/null
@@ -0,0 +1,79 @@
+package net.pterodactylus.sone.template;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import net.pterodactylus.sone.web.page.FreenetRequest;
+import net.pterodactylus.util.template.TemplateContext;
+
+import freenet.support.api.HTTPRequest;
+
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit test for {@link GetPagePlugin}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class GetPagePluginTest {
+
+       private final GetPagePlugin plugin = new GetPagePlugin();
+       private final TemplateContext context = mock(TemplateContext.class);
+       private final FreenetRequest request = mock(FreenetRequest.class);
+       private final Map<String, String> parameters =
+                       new HashMap<String, String>();
+       private HTTPRequest httpRequest = mock(HTTPRequest.class);
+
+       @Before
+       public void setupTemplateContext() {
+               when(context.get("request")).thenReturn(request);
+               when(request.getHttpRequest()).thenReturn(httpRequest);
+               when(httpRequest.getParam("page")).thenReturn("1");
+       }
+
+       @Test
+       public void fullySpecifiedPluginCallSetsCorrectValue() {
+               parameters.put("request", "request");
+               parameters.put("parameter", "page");
+               parameters.put("key", "page-key");
+               plugin.execute(context, parameters);
+               verify(context).set("page-key", 1);
+       }
+
+       @Test
+       public void missingRequestParameterStillSetsCorrectValue() {
+               parameters.put("parameter", "page");
+               parameters.put("key", "page-key");
+               plugin.execute(context, parameters);
+               verify(context).set("page-key", 1);
+       }
+
+       @Test
+       public void missingParameterParameterStillSetsCorrectValue() {
+               parameters.put("request", "request");
+               parameters.put("key", "page-key");
+               plugin.execute(context, parameters);
+               verify(context).set("page-key", 1);
+       }
+
+       @Test
+       public void missingKeyParameterStillSetsCorrectValue() {
+               parameters.put("request", "request");
+               parameters.put("parameter", "page");
+               plugin.execute(context, parameters);
+               verify(context).set("page", 1);
+       }
+
+       @Test
+       public void unparseablePageSetsPageZero() {
+               parameters.put("parameter", "wrong-parameter");
+               plugin.execute(context, parameters);
+               verify(context).set("page", 0);
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/template/HttpRequestAccessorTest.java b/src/test/java/net/pterodactylus/sone/template/HttpRequestAccessorTest.java
new file mode 100644 (file)
index 0000000..37c6260
--- /dev/null
@@ -0,0 +1,51 @@
+package net.pterodactylus.sone.template;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import net.pterodactylus.util.template.TemplateContext;
+
+import freenet.support.api.HTTPRequest;
+
+import org.hamcrest.Matchers;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit test for {@link HttpRequestAccessor}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class HttpRequestAccessorTest {
+
+       private static final String REQUEST_PATH = "/the/real/path";
+       private static final String USER_AGENT = "Test/1.0";
+       private static final String HEADER_PATH = "/some/path";
+       private final HttpRequestAccessor accessor = new HttpRequestAccessor();
+       private final TemplateContext context = mock(TemplateContext.class);
+       private final HTTPRequest httpRequest = mock(HTTPRequest.class);
+
+       @Before
+       public void setupHttpRequest() {
+               when(httpRequest.getPath()).thenReturn(REQUEST_PATH);
+               when(httpRequest.getHeader("User-Agent")).thenReturn(USER_AGENT);
+               when(httpRequest.getHeader("Path")).thenReturn(HEADER_PATH);
+       }
+
+       @Test
+       public void preferCallingMethodsInsteadOfReturningHeaders() {
+               assertThat(accessor.get(context, httpRequest, "path"),
+                               Matchers.<Object>is(REQUEST_PATH));
+               verify(httpRequest, never()).getHeader("Path");
+       }
+
+       @Test
+       public void headerIsReturnedCorrectly() {
+               assertThat(accessor.get(context, httpRequest, "User-Agent"),
+                               Matchers.<Object>is(USER_AGENT));
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/template/IdentityAccessorTest.java b/src/test/java/net/pterodactylus/sone/template/IdentityAccessorTest.java
new file mode 100644 (file)
index 0000000..33149e0
--- /dev/null
@@ -0,0 +1,81 @@
+package net.pterodactylus.sone.template;
+
+import static java.util.Arrays.asList;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import net.pterodactylus.sone.core.Core;
+import net.pterodactylus.sone.freenet.wot.Identity;
+import net.pterodactylus.sone.freenet.wot.IdentityManager;
+import net.pterodactylus.sone.freenet.wot.OwnIdentity;
+
+import org.hamcrest.Matchers;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit test for {@link IdentityAccessor}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class IdentityAccessorTest {
+
+       private static final String TEST_ID =
+                       "LrNQbyBBZW-7pHqChtp9lfPA7eXFPW~FLbJ2WrvEx5g";
+       private static final String TEST_ID_WITH_CHANGED_LETTER =
+                       "LrMQbyBBZW-7pHqChtp9lfPA7eXFPW~FLbJ2WrvEx5g";
+       private final Core core = mock(Core.class);
+       private final IdentityAccessor accessor = new IdentityAccessor(core);
+       private final IdentityManager identityManager =
+                       mock(IdentityManager.class);
+       private final OwnIdentity identity = mock(OwnIdentity.class);
+
+       @Before
+       public void setupCore() {
+               when(core.getIdentityManager()).thenReturn(identityManager);
+       }
+
+       @Before
+       public void setupIdentity() {
+               setupIdentity(identity, TEST_ID, "Test");
+       }
+
+       private void setupIdentity(Identity identity, String id,
+                       String nickname) {
+               when(identity.getId()).thenReturn(id);
+               when(identity.getNickname()).thenReturn(nickname);
+       }
+
+       private void serveIdentities(Set<OwnIdentity> identities) {
+               when(identityManager.getAllOwnIdentities()).thenReturn(identities);
+       }
+
+       @Test
+       public void accessorReturnsTheCorrectlyAbbreviatedNickname() {
+               OwnIdentity ownIdentity = mock(OwnIdentity.class);
+               setupIdentity(ownIdentity, TEST_ID_WITH_CHANGED_LETTER, "Test");
+               serveIdentities(new HashSet(asList(identity, ownIdentity)));
+               assertThat(accessor.get(null, identity, "uniqueNickname"),
+                               Matchers.<Object>is("Test@LrN"));
+       }
+
+       @Test
+       public void accessorComparesTheFullLengthIfNecessary() {
+               OwnIdentity ownIdentity = mock(OwnIdentity.class);
+               setupIdentity(ownIdentity, TEST_ID, "Test");
+               serveIdentities(new HashSet(asList(identity, ownIdentity)));
+               assertThat(accessor.get(null, identity, "uniqueNickname"),
+                               Matchers.<Object>is("Test@" + TEST_ID));
+       }
+
+       @Test
+       public void reflectionAccessorIsUsedForOtherMembers() {
+               assertThat(accessor.get(null, identity, "hashCode"),
+                               Matchers.<Object>is(identity.hashCode()));
+       }
+
+}
index 2c04b23..2ac6db7 100644 (file)
@@ -22,13 +22,14 @@ import java.io.StringReader;
 import java.util.Arrays;
 import java.util.Collection;
 
-import com.google.common.base.Optional;
-
-import junit.framework.TestCase;
 import net.pterodactylus.sone.data.Sone;
-import net.pterodactylus.sone.data.SoneImpl;
+import net.pterodactylus.sone.data.impl.IdOnlySone;
 import net.pterodactylus.sone.database.SoneProvider;
 
+import com.google.common.base.Function;
+import com.google.common.base.Optional;
+import junit.framework.TestCase;
+
 /**
  * JUnit test case for {@link SoneTextParser}.
  *
@@ -181,21 +182,22 @@ public class SoneTextParserTest extends TestCase {
         */
        private static class TestSoneProvider implements SoneProvider {
 
+               @Override
+               public Function<String, Optional<Sone>> soneLoader() {
+                       return new Function<String, Optional<Sone>>() {
+                               @Override
+                               public Optional<Sone> apply(String soneId) {
+                                       return getSone(soneId);
+                               }
+                       };
+               }
+
                /**
                 * {@inheritDoc}
                 */
                @Override
                public Optional<Sone> getSone(final String soneId) {
-                       return Optional.<Sone>of(new SoneImpl(soneId, false) {
-
-                               /**
-                                * {@inheritDoc}
-                                */
-                               @Override
-                               public String getName() {
-                                       return soneId;
-                               }
-                       });
+                       return Optional.<Sone>of(new IdOnlySone(soneId));
                }
 
                /**
diff --git a/src/test/java/net/pterodactylus/sone/utils/DefaultOptionTest.java b/src/test/java/net/pterodactylus/sone/utils/DefaultOptionTest.java
new file mode 100644 (file)
index 0000000..065e1d4
--- /dev/null
@@ -0,0 +1,87 @@
+package net.pterodactylus.sone.utils;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+
+import javax.annotation.Nullable;
+
+import com.google.common.base.Predicate;
+import org.junit.Test;
+
+/**
+ * Unit test for {@link DefaultOption}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class DefaultOptionTest {
+
+       private final Object defaultValue = new Object();
+       private final Object acceptedValue = new Object();
+       private final Predicate<Object> matchesAcceptedValue = new Predicate<Object>() {
+               @Override
+               public boolean apply(@Nullable Object object) {
+                       return acceptedValue.equals(object);
+               }
+       };
+
+       @Test
+       public void defaultOptionReturnsDefaultValueWhenUnset() {
+               DefaultOption<Object> defaultOption = new DefaultOption<Object>(defaultValue);
+               assertThat(defaultOption.get(), is(defaultValue));
+       }
+
+       @Test
+       public void defaultOptionReturnsNullForRealWhenUnset() {
+               DefaultOption<Object> defaultOption = new DefaultOption<Object>(defaultValue);
+               assertThat(defaultOption.getReal(), nullValue());
+       }
+
+       @Test
+       public void defaultOptionWillReturnSetValue() {
+               DefaultOption<Object> defaultOption = new DefaultOption<Object>(defaultValue);
+               Object newValue = new Object();
+               defaultOption.set(newValue);
+               assertThat(defaultOption.get(), is(newValue));
+       }
+
+       @Test
+       public void defaultOptionWithValidatorAcceptsValidValues() {
+               DefaultOption<Object> defaultOption = new DefaultOption<Object>(defaultValue, matchesAcceptedValue);
+               defaultOption.set(acceptedValue);
+               assertThat(defaultOption.get(), is(acceptedValue));
+       }
+
+       @Test(expected = IllegalArgumentException.class)
+       public void defaultOptionWithValidatorRejectsInvalidValues() {
+               DefaultOption<Object> defaultOption = new DefaultOption<Object>(defaultValue, matchesAcceptedValue);
+               defaultOption.set(new Object());
+       }
+
+       @Test
+       public void defaultOptionValidatesObjectsCorrectly() {
+               DefaultOption<Object> defaultOption = new DefaultOption<Object>(defaultValue, matchesAcceptedValue);
+               assertThat(defaultOption.validate(acceptedValue), is(true));
+               assertThat(defaultOption.validate(new Object()), is(false));
+       }
+
+       @Test
+       public void settingToNullWillRestoreDefaultValue() {
+               DefaultOption<Object> defaultOption = new DefaultOption<Object>(defaultValue);
+               defaultOption.set(null);
+               assertThat(defaultOption.get(), is(defaultValue));
+       }
+
+       @Test
+       public void validateWithoutValidatorWillValidateNull() {
+               DefaultOption<Object> defaultOption = new DefaultOption<Object>(defaultValue);
+               assertThat(defaultOption.validate(null), is(true));
+       }
+
+       @Test
+       public void validateWithValidatorWillValidateNull() {
+               DefaultOption<Object> defaultOption = new DefaultOption<Object>(defaultValue, matchesAcceptedValue);
+               assertThat(defaultOption.validate(null), is(true));
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/utils/IntegerRangePredicateTest.java b/src/test/java/net/pterodactylus/sone/utils/IntegerRangePredicateTest.java
new file mode 100644 (file)
index 0000000..b2d078e
--- /dev/null
@@ -0,0 +1,55 @@
+package net.pterodactylus.sone.utils;
+
+import static net.pterodactylus.sone.utils.IntegerRangePredicate.range;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+import net.pterodactylus.sone.TestUtil;
+
+import org.junit.Test;
+
+/**
+ * Unit test for {@link IntegerRangePredicate}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class IntegerRangePredicateTest {
+
+       private final IntegerRangePredicate predicate =
+                       new IntegerRangePredicate(-50, 50);
+
+       @Test
+       public void predicateMatchesNumberWithinBounds() {
+               assertThat(predicate.apply(17), is(true));
+       }
+
+       @Test
+       public void predicateMatchesLowerBoundary() {
+               assertThat(predicate.apply(-50), is(true));
+       }
+
+       @Test
+       public void predicateDoesNotMatchOneBelowLowerBoundary() {
+               assertThat(predicate.apply(-51), is(false));
+       }
+
+       @Test
+       public void predicateMatchesUpperBoundary() {
+               assertThat(predicate.apply(50), is(true));
+       }
+
+       @Test
+       public void predicateDoesNotMatchesOneAboveUpperBoundary() {
+               assertThat(predicate.apply(51), is(false));
+       }
+
+       @Test
+       public void staticCreatorMethodCreatesPredicate() {
+               IntegerRangePredicate predicate = range(-50, 50);
+               assertThat(TestUtil.<Integer>getPrivateField(predicate, "lowerBound"),
+                               is(-50));
+               assertThat(TestUtil.<Integer>getPrivateField(predicate, "upperBound"),
+                               is(50));
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/utils/NumberParsersTest.java b/src/test/java/net/pterodactylus/sone/utils/NumberParsersTest.java
new file mode 100644 (file)
index 0000000..00c2263
--- /dev/null
@@ -0,0 +1,84 @@
+package net.pterodactylus.sone.utils;
+
+import static net.pterodactylus.sone.utils.NumberParsers.parseInt;
+import static net.pterodactylus.sone.utils.NumberParsers.parseLong;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+
+import org.junit.Test;
+
+/**
+ * Unit test for {@link NumberParsers}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class NumberParsersTest {
+
+       @Test
+       // yes, this test is for coverage only.
+       public void constructorCanBeCalled() {
+               new NumberParsers();
+       }
+
+       @Test
+       public void nullIsParsedToDefaultInt() {
+               assertThat(parseInt(null, 17), is(17));
+       }
+
+       @Test
+       public void notANumberIsParsedToDefaultInt() {
+               assertThat(parseInt("not a number", 18), is(18));
+       }
+
+       @Test
+       public void intIsCorrectlyParsed() {
+               assertThat(parseInt("19", 0), is(19));
+       }
+
+       @Test
+       public void valueTooLargeForIntIsParsedToDefault() {
+               assertThat(parseInt("2147483648", 20), is(20));
+       }
+
+       @Test
+       public void valueTooSmallForIntIsParsedToDefault() {
+               assertThat(parseInt("-2147483649", 20), is(20));
+       }
+
+       @Test
+       public void nullCanBeDefaultIntValue() {
+               assertThat(parseInt("not a number", null), nullValue());
+       }
+
+       @Test
+       public void nullIsParsedToDefaultLong() {
+               assertThat(parseLong(null, 17L), is(17L));
+       }
+
+       @Test
+       public void notANumberIsParsedToDefaultLong() {
+               assertThat(parseLong("not a number", 18L), is(18L));
+       }
+
+       @Test
+       public void LongIsCorrectlyParsed() {
+               assertThat(parseLong("19", 0L), is(19L));
+       }
+
+       @Test
+       public void valueTooLargeForLongIsParsedToDefault() {
+               assertThat(parseLong("9223372036854775808", 20L), is(20L));
+       }
+
+       @Test
+       public void valueTooSmallForLongIsParsedToDefault() {
+               assertThat(parseLong("-9223372036854775809", 20L), is(20L));
+       }
+
+       @Test
+       public void nullCanBeDefaultLongValue() {
+               assertThat(parseLong("not a number", null), nullValue());
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sone/utils/OptionalsTest.java b/src/test/java/net/pterodactylus/sone/utils/OptionalsTest.java
new file mode 100644 (file)
index 0000000..0f7dee3
--- /dev/null
@@ -0,0 +1,52 @@
+package net.pterodactylus.sone.utils;
+
+import java.util.Arrays;
+import java.util.List;
+
+import com.google.common.base.Optional;
+import com.google.common.collect.FluentIterable;
+import org.hamcrest.MatcherAssert;
+import org.hamcrest.Matchers;
+import org.junit.Test;
+
+/**
+ * Unit test for {@link Optionals}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class OptionalsTest {
+
+       private final Object object1 = new Object();
+       private final Object object2 = new Object();
+       private final Object object3 = new Object();
+
+       @Test
+       public void canCreateOptionals() {
+               new Optionals();
+       }
+
+       @Test
+       public void isPresentFiltersCorrectOptionals() {
+               List<Optional<Object>> optionals = Arrays.asList(
+                               Optional.of(object1), Optional.absent(),
+                               Optional.of(object2), Optional.absent(),
+                               Optional.of(object3), Optional.absent()
+               );
+               List<Optional<Object>> filteredOptionals =
+                               FluentIterable.from(optionals).filter(Optionals.isPresent()).toList();
+               MatcherAssert.assertThat(filteredOptionals, Matchers.contains(
+                               Optional.of(object1), Optional.of(object2), Optional.of(object3)));
+       }
+
+       @Test
+       public void getReturnsCorrectValues() {
+               List<Optional<Object>> optionals = Arrays.asList(
+                               Optional.of(object1),
+                               Optional.of(object2),
+                               Optional.of(object3)
+               );
+               List<Object> objects = FluentIterable.from(optionals).transform(Optionals.get()).toList();
+               MatcherAssert.assertThat(objects, Matchers.contains(object1, object2, object3));
+       }
+
+}
index 1db89d0..5f2025f 100644 (file)
@@ -4,10 +4,13 @@
 
 package net.pterodactylus.sone.web.ajax;
 
+import static com.google.common.base.Optional.of;
 import static org.hamcrest.CoreMatchers.is;
 import static org.hamcrest.CoreMatchers.notNullValue;
 import static org.junit.Assert.assertThat;
+import static org.mockito.Matchers.any;
 import static org.mockito.Matchers.anyString;
+import static org.mockito.Matchers.argThat;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
@@ -18,6 +21,7 @@ import java.net.URI;
 import java.net.URISyntaxException;
 
 import net.pterodactylus.sone.core.Core;
+import net.pterodactylus.sone.data.Post;
 import net.pterodactylus.sone.web.WebInterface;
 import net.pterodactylus.sone.web.page.FreenetRequest;
 
@@ -36,6 +40,8 @@ public class BookmarkAjaxPageTest {
        public void testBookmarkingExistingPost() throws URISyntaxException {
                /* create mocks. */
                Core core = mock(Core.class);
+               Post post = mock(Post.class);
+               when(core.getPost("abc")).thenReturn(of(post));
                WebInterface webInterface = mock(WebInterface.class);
                when(webInterface.getCore()).thenReturn(core);
                HTTPRequest httpRequest = new HTTPRequestImpl(new URI("/ajax/bookmark.ajax?post=abc"), "GET");
@@ -51,8 +57,7 @@ public class BookmarkAjaxPageTest {
                assertThat(jsonReturnObject.isSuccess(), is(true));
 
                /* verify behaviour. */
-               verify(core, times(1)).bookmarkPost(anyString());
-               verify(core).bookmarkPost("abc");
+               verify(core).bookmarkPost(post);
        }
 
        @Test
@@ -75,7 +80,7 @@ public class BookmarkAjaxPageTest {
                assertThat(((JsonErrorReturnObject) jsonReturnObject).getError(), is("invalid-post-id"));
 
                /* verify behaviour. */
-               verify(core, never()).bookmarkPost(anyString());
+               verify(core, never()).bookmarkPost(any(Post.class));
        }
 
 }
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-inserter-faulty-manifest.txt b/src/test/resources/net/pterodactylus/sone/core/sone-inserter-faulty-manifest.txt
new file mode 100644 (file)
index 0000000..7d39bdc
--- /dev/null
@@ -0,0 +1 @@
+<%include stuff>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-inserter-invalid-manifest.txt b/src/test/resources/net/pterodactylus/sone/core/sone-inserter-invalid-manifest.txt
new file mode 100644 (file)
index 0000000..625c86f
--- /dev/null
@@ -0,0 +1 @@
+Sone Version: <% version %>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-inserter-manifest.txt b/src/test/resources/net/pterodactylus/sone/core/sone-inserter-manifest.txt
new file mode 100644 (file)
index 0000000..a5818bf
--- /dev/null
@@ -0,0 +1,3 @@
+Sone Version: <% version>
+Core Startup: <% core.startupTime>
+Sone ID: <% currentSone.id>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-missing-client-name.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-missing-client-name.xml
new file mode 100644 (file)
index 0000000..04aab22
--- /dev/null
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <client></client>
+       <time>1407197508000</time>
+       <profile></profile>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-missing-client-version.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-missing-client-version.xml
new file mode 100644 (file)
index 0000000..cfcb3e6
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <client>
+               <name>some-client</name>
+       </client>
+       <time>1407197508000</time>
+       <profile></profile>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-missing-protocol-version.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-missing-protocol-version.xml
new file mode 100644 (file)
index 0000000..9a60f4f
--- /dev/null
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <time>1407197508000</time>
+       <profile></profile>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-negative-protocol-version.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-negative-protocol-version.xml
new file mode 100644 (file)
index 0000000..2907965
--- /dev/null
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>-1</protocol-version>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-no-payload.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-no-payload.xml
new file mode 100644 (file)
index 0000000..62db3ab
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-no-profile.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-no-profile.xml
new file mode 100644 (file)
index 0000000..b178772
--- /dev/null
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-no-time.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-no-time.xml
new file mode 100644 (file)
index 0000000..0ce3565
--- /dev/null
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-not-xml.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-not-xml.xml
new file mode 100644 (file)
index 0000000..0ced647
--- /dev/null
@@ -0,0 +1 @@
+Not an XML file.
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-profile-duplicate-field-name.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-profile-duplicate-field-name.xml
new file mode 100644 (file)
index 0000000..600886f
--- /dev/null
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile>
+               <fields>
+                       <field>
+                               <field-name>field</field-name>
+                               <field-name>value</field-name>
+                       </field>
+                       <field>
+                               <field-name>field</field-name>
+                               <field-name>value</field-name>
+                       </field>
+               </fields>
+       </profile>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-profile-empty-field-name.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-profile-empty-field-name.xml
new file mode 100644 (file)
index 0000000..a4abec4
--- /dev/null
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile>
+               <fields>
+                       <field>
+                               <field-name> </field-name>
+                       </field>
+               </fields>
+       </profile>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-profile-missing-field-name.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-profile-missing-field-name.xml
new file mode 100644 (file)
index 0000000..87ccb9f
--- /dev/null
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile>
+               <fields>
+                       <field></field>
+               </fields>
+       </profile>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-time-not-numeric.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-time-not-numeric.xml
new file mode 100644 (file)
index 0000000..367ef55
--- /dev/null
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>not-a-number</time>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-too-large-protocol-version.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-too-large-protocol-version.xml
new file mode 100644 (file)
index 0000000..c43c607
--- /dev/null
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>99999</protocol-version>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-client-info.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-client-info.xml
new file mode 100644 (file)
index 0000000..0cf4377
--- /dev/null
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <client>
+               <name>some-client</name>
+               <version>some-version</version>
+       </client>
+       <time>1407197508000</time>
+       <profile></profile>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-image.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-image.xml
new file mode 100644 (file)
index 0000000..40b221f
--- /dev/null
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile>
+               <avatar>image-id</avatar>
+       </profile>
+       <albums>
+               <album>
+                       <id>album-id-1</id>
+                       <title>album-title</title>
+                       <description>album-description</description>
+                       <images>
+                               <image>
+                                       <id>image-id</id>
+                                       <creation-time>1407197508000</creation-time>
+                                       <key>KSK@GPLv3.txt</key>
+                                       <title>image-title</title>
+                                       <description>image-description</description>
+                                       <width>1920</width>
+                                       <height>1080</height>
+                               </image>
+                       </images>
+               </album>
+               <album>
+                       <id>album-id-2</id>
+                       <parent>album-id-1</parent>
+                       <title>album-title-2</title>
+                       <description>album-description-2</description>
+               </album>
+       </albums>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-invalid-image-height.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-invalid-image-height.xml
new file mode 100644 (file)
index 0000000..5344499
--- /dev/null
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <albums>
+               <album>
+                       <id>album-id-1</id>
+                       <title>album-title</title>
+                       <description>album-description</description>
+                       <images>
+                               <image>
+                                       <id>image-id</id>
+                                       <creation-time>1407197508000</creation-time>
+                                       <key>KSK@GPLv3.txt</key>
+                                       <title>image-title</title>
+                                       <width>1920</width>
+                                       <height>-1080</height>
+                               </image>
+                       </images>
+               </album>
+               <album>
+                       <id>album-id-2</id>
+                       <parent>album-id-1</parent>
+                       <title>album-title-2</title>
+                       <description>album-description-2</description>
+               </album>
+       </albums>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-invalid-image-width.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-invalid-image-width.xml
new file mode 100644 (file)
index 0000000..5a0ae77
--- /dev/null
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <albums>
+               <album>
+                       <id>album-id-1</id>
+                       <title>album-title</title>
+                       <description>album-description</description>
+                       <images>
+                               <image>
+                                       <id>image-id</id>
+                                       <creation-time>1407197508000</creation-time>
+                                       <key>KSK@GPLv3.txt</key>
+                                       <title>image-title</title>
+                                       <width>-1920</width>
+                                       <height>1080</height>
+                               </image>
+                       </images>
+               </album>
+               <album>
+                       <id>album-id-2</id>
+                       <parent>album-id-1</parent>
+                       <title>album-title-2</title>
+                       <description>album-description-2</description>
+               </album>
+       </albums>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-invalid-parent-album-id.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-invalid-parent-album-id.xml
new file mode 100644 (file)
index 0000000..af2a7eb
--- /dev/null
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <albums>
+               <album>
+                       <id>album-id-1</id>
+                       <parent>album-id-7</parent>
+                       <title>album-title</title>
+                       <description>album-description</description>
+               </album>
+       </albums>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-invalid-post-reply-time.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-invalid-post-reply-time.xml
new file mode 100644 (file)
index 0000000..751407a
--- /dev/null
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <replies>
+               <reply>
+                       <id>reply-id</id>
+                       <post-id>post-id</post-id>
+                       <time>not-a-time</time>
+                       <text>reply-text</text>
+               </reply>
+       </replies>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-invalid-post-time.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-invalid-post-time.xml
new file mode 100644 (file)
index 0000000..ad4df33
--- /dev/null
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <posts>
+               <post>
+                       <id>post-id</id>
+                       <time>invalid-time</time>
+                       <text>text</text>
+               </post>
+       </posts>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-invalid-recipient.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-invalid-recipient.xml
new file mode 100644 (file)
index 0000000..e627651
--- /dev/null
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <posts>
+               <post>
+                       <id>post-id</id>
+                       <time>1407197508000</time>
+                       <text>text</text>
+                       <recipient>123456789012345678901234567890123456789012</recipient>
+               </post>
+       </posts>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-liked-post-ids.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-liked-post-ids.xml
new file mode 100644 (file)
index 0000000..16a5af0
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <post-likes>
+               <post-like>liked-post-id</post-like>
+       </post-likes>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-liked-post-reply-ids.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-liked-post-reply-ids.xml
new file mode 100644 (file)
index 0000000..592da4c
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <reply-likes>
+               <reply-like>liked-post-reply-id</reply-like>
+       </reply-likes>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-multiple-albums.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-multiple-albums.xml
new file mode 100644 (file)
index 0000000..f27edab
--- /dev/null
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <albums>
+               <album>
+                       <id>album-id-1</id>
+                       <title>album-title</title>
+                       <description>album-description</description>
+               </album>
+               <album>
+                       <id>album-id-2</id>
+                       <parent>album-id-1</parent>
+                       <title>album-title-2</title>
+                       <description>album-description-2</description>
+               </album>
+       </albums>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-profile.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-profile.xml
new file mode 100644 (file)
index 0000000..a57a698
--- /dev/null
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile>
+               <first-name>first</first-name>
+               <middle-name>middle</middle-name>
+               <last-name>last</last-name>
+               <birth-day>18</birth-day>
+               <birth-month>12</birth-month>
+               <birth-year>1976</birth-year>
+       </profile>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-recipient.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-recipient.xml
new file mode 100644 (file)
index 0000000..14dabe1
--- /dev/null
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <posts>
+               <post>
+                       <id>post-id</id>
+                       <time>1407197508000</time>
+                       <text>text</text>
+                       <recipient>1234567890123456789012345678901234567890123</recipient>
+               </post>
+       </posts>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-valid-post-reply-time.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-valid-post-reply-time.xml
new file mode 100644 (file)
index 0000000..4d77cb2
--- /dev/null
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <replies>
+               <reply>
+                       <id>reply-id</id>
+                       <post-id>post-id</post-id>
+                       <time>1407197508000</time>
+                       <text>reply-text</text>
+               </reply>
+       </replies>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-valid-post-time.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-valid-post-time.xml
new file mode 100644 (file)
index 0000000..fdfb493
--- /dev/null
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <posts>
+               <post>
+                       <id>post-id</id>
+                       <time>1407197508000</time>
+                       <text>text</text>
+               </post>
+       </posts>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-zero-time.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-with-zero-time.xml
new file mode 100644 (file)
index 0000000..90a4834
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>0</time>
+       <profile></profile>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-album-id.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-album-id.xml
new file mode 100644 (file)
index 0000000..f925496
--- /dev/null
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <albums>
+               <album>
+               </album>
+       </albums>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-album-title.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-album-title.xml
new file mode 100644 (file)
index 0000000..bacddeb
--- /dev/null
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <albums>
+               <album>
+                       <id>album-id-1</id>
+               </album>
+       </albums>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-albums.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-albums.xml
new file mode 100644 (file)
index 0000000..ecf5d63
--- /dev/null
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <albums>
+       </albums>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-fields.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-fields.xml
new file mode 100644 (file)
index 0000000..09e8fab
--- /dev/null
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile>
+               <fields></fields>
+       </profile>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-image-height.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-image-height.xml
new file mode 100644 (file)
index 0000000..62d49cd
--- /dev/null
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <albums>
+               <album>
+                       <id>album-id-1</id>
+                       <title>album-title</title>
+                       <description>album-description</description>
+                       <images>
+                               <image>
+                                       <id>image-id</id>
+                                       <creation-time>1407197508000</creation-time>
+                                       <key>KSK@GPLv3.txt</key>
+                                       <title>image-title</title>
+                                       <width>1920</width>
+                               </image>
+                       </images>
+               </album>
+               <album>
+                       <id>album-id-2</id>
+                       <parent>album-id-1</parent>
+                       <title>album-title-2</title>
+                       <description>album-description-2</description>
+               </album>
+       </albums>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-image-id.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-image-id.xml
new file mode 100644 (file)
index 0000000..d40c071
--- /dev/null
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <albums>
+               <album>
+                       <id>album-id-1</id>
+                       <title>album-title</title>
+                       <description>album-description</description>
+                       <images>
+                               <image>
+                               </image>
+                       </images>
+               </album>
+               <album>
+                       <id>album-id-2</id>
+                       <parent>album-id-1</parent>
+                       <title>album-title-2</title>
+                       <description>album-description-2</description>
+               </album>
+       </albums>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-image-key.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-image-key.xml
new file mode 100644 (file)
index 0000000..ec53bca
--- /dev/null
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <albums>
+               <album>
+                       <id>album-id-1</id>
+                       <title>album-title</title>
+                       <description>album-description</description>
+                       <images>
+                               <image>
+                                       <id>image-id</id>
+                                       <creation-time>1407197508000</creation-time>
+                               </image>
+                       </images>
+               </album>
+               <album>
+                       <id>album-id-2</id>
+                       <parent>album-id-1</parent>
+                       <title>album-title-2</title>
+                       <description>album-description-2</description>
+               </album>
+       </albums>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-image-time.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-image-time.xml
new file mode 100644 (file)
index 0000000..271e341
--- /dev/null
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <albums>
+               <album>
+                       <id>album-id-1</id>
+                       <title>album-title</title>
+                       <description>album-description</description>
+                       <images>
+                               <image>
+                                       <id>image-id</id>
+                               </image>
+                       </images>
+               </album>
+               <album>
+                       <id>album-id-2</id>
+                       <parent>album-id-1</parent>
+                       <title>album-title-2</title>
+                       <description>album-description-2</description>
+               </album>
+       </albums>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-image-title.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-image-title.xml
new file mode 100644 (file)
index 0000000..bc1ce10
--- /dev/null
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <albums>
+               <album>
+                       <id>album-id-1</id>
+                       <title>album-title</title>
+                       <description>album-description</description>
+                       <images>
+                               <image>
+                                       <id>image-id</id>
+                                       <creation-time>1407197508000</creation-time>
+                                       <key>KSK@GPLv3.txt</key>
+                               </image>
+                       </images>
+               </album>
+               <album>
+                       <id>album-id-2</id>
+                       <parent>album-id-1</parent>
+                       <title>album-title-2</title>
+                       <description>album-description-2</description>
+               </album>
+       </albums>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-image-width.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-image-width.xml
new file mode 100644 (file)
index 0000000..8426101
--- /dev/null
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <albums>
+               <album>
+                       <id>album-id-1</id>
+                       <title>album-title</title>
+                       <description>album-description</description>
+                       <images>
+                               <image>
+                                       <id>image-id</id>
+                                       <creation-time>1407197508000</creation-time>
+                                       <key>KSK@GPLv3.txt</key>
+                                       <title>image-title</title>
+                               </image>
+                       </images>
+               </album>
+               <album>
+                       <id>album-id-2</id>
+                       <parent>album-id-1</parent>
+                       <title>album-title-2</title>
+                       <description>album-description-2</description>
+               </album>
+       </albums>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-images.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-images.xml
new file mode 100644 (file)
index 0000000..2b201e8
--- /dev/null
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <albums>
+               <album>
+                       <id>album-id-1</id>
+                       <title>album-title</title>
+                       <description>album-description</description>
+                       <images>
+                       </images>
+               </album>
+               <album>
+                       <id>album-id-2</id>
+                       <parent>album-id-1</parent>
+                       <title>album-title-2</title>
+                       <description>album-description-2</description>
+               </album>
+       </albums>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-liked-post-ids.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-liked-post-ids.xml
new file mode 100644 (file)
index 0000000..a8b9a6c
--- /dev/null
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <post-likes>
+       </post-likes>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-liked-post-reply-ids.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-liked-post-reply-ids.xml
new file mode 100644 (file)
index 0000000..511e214
--- /dev/null
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <reply-likes>
+       </reply-likes>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-post-id.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-post-id.xml
new file mode 100644 (file)
index 0000000..637a51f
--- /dev/null
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <posts>
+               <post>
+               </post>
+       </posts>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-post-reply-id.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-post-reply-id.xml
new file mode 100644 (file)
index 0000000..c54fca0
--- /dev/null
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <replies>
+               <reply>
+               </reply>
+       </replies>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-post-reply-post-id.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-post-reply-post-id.xml
new file mode 100644 (file)
index 0000000..321d857
--- /dev/null
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <replies>
+               <reply>
+                       <id>reply-id</id>
+               </reply>
+       </replies>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-post-reply-text.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-post-reply-text.xml
new file mode 100644 (file)
index 0000000..0ec68aa
--- /dev/null
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <replies>
+               <reply>
+                       <id>reply-id</id>
+                       <post-id>post-id</post-id>
+                       <time>1407197508000</time>
+               </reply>
+       </replies>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-post-reply-time.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-post-reply-time.xml
new file mode 100644 (file)
index 0000000..ae6ffcb
--- /dev/null
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <replies>
+               <reply>
+                       <id>reply-id</id>
+                       <post-id>post-id</post-id>
+               </reply>
+       </replies>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-post-text.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-post-text.xml
new file mode 100644 (file)
index 0000000..062e2f8
--- /dev/null
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <posts>
+               <post>
+                       <id>post-id</id>
+                       <time>1407197508000</time>
+               </post>
+       </posts>
+</sone>
diff --git a/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-post-time.xml b/src/test/resources/net/pterodactylus/sone/core/sone-parser-without-post-time.xml
new file mode 100644 (file)
index 0000000..83477e5
--- /dev/null
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sone>
+       <protocol-version>0</protocol-version>
+       <time>1407197508000</time>
+       <profile></profile>
+       <posts>
+               <post>
+                       <id>post-id</id>
+               </post>
+       </posts>
+</sone>