From b36b3bfcabd4a33826b455e1cdfc06fde110a452 Mon Sep 17 00:00:00 2001 From: zaghdaneh <46049558+zaghdaneh@users.noreply.github.com> Date: Fri, 23 Jun 2023 04:06:43 +0200 Subject: [PATCH] Remove manga from trackers (#9535) * Dialog for service tracker removal added, anilist query prepared * added API delete requests for Mal and Kitsu * implement and fix tracker delete for anilist, shikimori, mangaupdates * implement and test mal delete request * Update to dialog text to reflect current tracker * finish kitsu api request and block bangumi tracker removal * Change delete flag into interface, localise strings, clean up logs * Add shikimori delete compatibility for already existing entries * update track delete dialog prompt to include checkbox, update strings * Update i18n/src/main/res/values/strings.xml Co-authored-by: stevenyomi <95685115+stevenyomi@users.noreply.github.com> * Update i18n/src/main/res/values/strings.xml --------- Co-authored-by: unknown Co-authored-by: arkon Co-authored-by: stevenyomi <95685115+stevenyomi@users.noreply.github.com> --- .../data/track/DeletableTrackService.kt | 11 ++ .../tachiyomi/data/track/anilist/Anilist.kt | 12 +- .../data/track/anilist/AnilistApi.kt | 21 ++++ .../tachiyomi/data/track/kitsu/Kitsu.kt | 7 +- .../tachiyomi/data/track/kitsu/KitsuApi.kt | 16 +++ .../data/track/mangaupdates/MangaUpdates.kt | 8 +- .../track/mangaupdates/MangaUpdatesApi.kt | 13 ++ .../data/track/myanimelist/MyAnimeList.kt | 7 +- .../data/track/myanimelist/MyAnimeListApi.kt | 14 +++ .../data/track/shikimori/Shikimori.kt | 8 +- .../data/track/shikimori/ShikimoriApi.kt | 45 +++++-- .../ui/manga/track/TrackInfoDialog.kt | 116 +++++++++++++++++- i18n/src/main/res/values/strings.xml | 4 + 13 files changed, 258 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/DeletableTrackService.kt diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/DeletableTrackService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/DeletableTrackService.kt new file mode 100644 index 000000000..7f1494707 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/DeletableTrackService.kt @@ -0,0 +1,11 @@ +package eu.kanade.tachiyomi.data.track + +import eu.kanade.tachiyomi.data.database.models.Track + +/** + * For track services api that support deleting a manga entry for a user's list + */ +interface DeletableTrackService { + + suspend fun delete(track: Track): Track +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt index e93d83817..54b260ef1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt @@ -4,6 +4,7 @@ import android.graphics.Color import androidx.annotation.StringRes import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.DeletableTrackService import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.model.TrackSearch import kotlinx.serialization.decodeFromString @@ -12,7 +13,7 @@ import kotlinx.serialization.json.Json import uy.kohesive.injekt.injectLazy import tachiyomi.domain.track.model.Track as DomainTrack -class Anilist(id: Long) : TrackService(id) { +class Anilist(id: Long) : TrackService(id), DeletableTrackService { companion object { const val READING = 1 @@ -167,6 +168,15 @@ class Anilist(id: Long) : TrackService(id) { return api.updateLibManga(track) } + override suspend fun delete(track: Track): Track { + if (track.library_id == null || track.library_id!! == 0L) { + val libManga = api.findLibManga(track, getUsername().toInt()) ?: return track + track.library_id = libManga.library_id + } + + return api.deleteLibManga(track) + } + override suspend fun bind(track: Track, hasReadChapters: Boolean): Track { val remoteTrack = api.findLibManga(track, getUsername().toInt()) return if (remoteTrack != null) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt index ca48444ca..d18312e66 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt @@ -110,6 +110,27 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { } } + suspend fun deleteLibManga(track: Track): Track { + return withIOContext { + val query = """ + |mutation DeleteManga(${'$'}listId: Int) { + |DeleteMediaListEntry(id: ${'$'}listId) { + |deleted + |} + |} + | + """.trimMargin() + val payload = buildJsonObject { + put("query", query) + putJsonObject("variables") { + put("listId", track.library_id) + } + } + authClient.newCall(POST(apiUrl, body = payload.toString().toRequestBody(jsonMime))) + .awaitSuccess() + track + } + } suspend fun search(search: String): List { return withIOContext { val query = """ diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt index 7a0c8eef6..10089fc2c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt @@ -4,6 +4,7 @@ import android.graphics.Color import androidx.annotation.StringRes import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.DeletableTrackService import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.model.TrackSearch import kotlinx.serialization.decodeFromString @@ -12,7 +13,7 @@ import kotlinx.serialization.json.Json import uy.kohesive.injekt.injectLazy import java.text.DecimalFormat -class Kitsu(id: Long) : TrackService(id) { +class Kitsu(id: Long) : TrackService(id), DeletableTrackService { companion object { const val READING = 1 @@ -93,6 +94,10 @@ class Kitsu(id: Long) : TrackService(id) { return api.updateLibManga(track) } + override suspend fun delete(track: Track): Track { + return api.removeLibManga(track) + } + override suspend fun bind(track: Track, hasReadChapters: Boolean): Track { val remoteTrack = api.findLibManga(track, getUserId()) return if (remoteTrack != null) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt index 96b952b97..1764c0290 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.track.kitsu import androidx.core.net.toUri import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.network.DELETE import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.awaitSuccess @@ -123,6 +124,21 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) } } + suspend fun removeLibManga(track: Track): Track { + return withIOContext { + authClient.newCall( + DELETE( + "${baseUrl}library-entries/${track.media_id}", + headers = headersOf( + "Content-Type", + "application/vnd.api+json", + ), + ), + ) + .awaitSuccess() + track + } + } suspend fun search(query: String): List { return withIOContext { with(json) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdates.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdates.kt index 1afe7914e..87cab2512 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdates.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdates.kt @@ -4,12 +4,13 @@ import android.graphics.Color import androidx.annotation.StringRes import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.DeletableTrackService import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.mangaupdates.dto.copyTo import eu.kanade.tachiyomi.data.track.mangaupdates.dto.toTrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch -class MangaUpdates(id: Long) : TrackService(id) { +class MangaUpdates(id: Long) : TrackService(id), DeletableTrackService { companion object { const val READING_LIST = 0 @@ -66,6 +67,11 @@ class MangaUpdates(id: Long) : TrackService(id) { return track } + override suspend fun delete(track: Track): Track { + api.deleteSeriesFromList(track) + return track + } + override suspend fun bind(track: Track, hasReadChapters: Boolean): Track { return try { val (series, rating) = api.getSeriesListItem(track) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt index 1fabe7d4d..ab6b8a309 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt @@ -106,6 +106,19 @@ class MangaUpdatesApi( updateSeriesRating(track) } + suspend fun deleteSeriesFromList(track: Track) { + val body = buildJsonArray { + add(track.media_id) + } + authClient.newCall( + POST( + url = "$baseUrl/v1/lists/series/delete", + body = body.toString().toRequestBody(contentType), + ), + ) + .awaitSuccess() + } + private suspend fun getSeriesRating(track: Track): Rating? { return try { with(json) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt index 3fca9e478..80396afde 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt @@ -4,6 +4,7 @@ import android.graphics.Color import androidx.annotation.StringRes import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.DeletableTrackService import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.model.TrackSearch import kotlinx.serialization.decodeFromString @@ -11,7 +12,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import uy.kohesive.injekt.injectLazy -class MyAnimeList(id: Long) : TrackService(id) { +class MyAnimeList(id: Long) : TrackService(id), DeletableTrackService { companion object { const val READING = 1 @@ -90,6 +91,10 @@ class MyAnimeList(id: Long) : TrackService(id) { return api.updateItem(track) } + override suspend fun delete(track: Track): Track { + return api.deleteItem(track) + } + override suspend fun bind(track: Track, hasReadChapters: Boolean): Track { val remoteTrack = api.findListItem(track) return if (remoteTrack != null) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt index 2333519cc..1bfb4e581 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt @@ -158,6 +158,20 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI } } + suspend fun deleteItem(track: Track): Track { + return withIOContext { + val request = Request.Builder() + .url(mangaUrl(track.media_id).toString()) + .delete() + .build() + with(json) { + authClient.newCall(request) + .awaitSuccess() + track + } + } + } + suspend fun findListItem(track: Track): Track? { return withIOContext { val uri = "$baseApiUrl/manga".toUri().buildUpon() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt index 07d80a2e8..cce9311f8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt @@ -4,6 +4,7 @@ import android.graphics.Color import androidx.annotation.StringRes import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.DeletableTrackService import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.model.TrackSearch import kotlinx.serialization.decodeFromString @@ -11,7 +12,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import uy.kohesive.injekt.injectLazy -class Shikimori(id: Long) : TrackService(id) { +class Shikimori(id: Long) : TrackService(id), DeletableTrackService { companion object { const val READING = 1 @@ -57,6 +58,10 @@ class Shikimori(id: Long) : TrackService(id) { return api.updateLibManga(track, getUsername()) } + override suspend fun delete(track: Track): Track { + return api.deleteLibManga(track) + } + override suspend fun bind(track: Track, hasReadChapters: Boolean): Track { val remoteTrack = api.findLibManga(track, getUsername()) return if (remoteTrack != null) { @@ -83,6 +88,7 @@ class Shikimori(id: Long) : TrackService(id) { override suspend fun refresh(track: Track): Track { api.findLibManga(track, getUsername())?.let { remoteTrack -> + track.library_id = remoteTrack.library_id track.copyPersonalFrom(remoteTrack) track.total_chapters = remoteTrack.total_chapters } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt index 040817fe9..63b01af64 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt @@ -4,6 +4,7 @@ import androidx.core.net.toUri import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.network.DELETE import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.awaitSuccess @@ -35,28 +36,45 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter suspend fun addLibManga(track: Track, user_id: String): Track { return withIOContext { - val payload = buildJsonObject { - putJsonObject("user_rate") { - put("user_id", user_id) - put("target_id", track.media_id) - put("target_type", "Manga") - put("chapters", track.last_chapter_read.toInt()) - put("score", track.score.toInt()) - put("status", track.toShikimoriStatus()) + with(json) { + val payload = buildJsonObject { + putJsonObject("user_rate") { + put("user_id", user_id) + put("target_id", track.media_id) + put("target_type", "Manga") + put("chapters", track.last_chapter_read.toInt()) + put("score", track.score.toInt()) + put("status", track.toShikimoriStatus()) + } } + authClient.newCall( + POST( + "$apiUrl/v2/user_rates", + body = payload.toString().toRequestBody(jsonMime), + ), + ).awaitSuccess() + .parseAs() + .let { + track.library_id = it["id"]!!.jsonPrimitive.long // save id of the entry for possible future delete request + } + track } + } + } + + suspend fun updateLibManga(track: Track, user_id: String): Track = addLibManga(track, user_id) + + suspend fun deleteLibManga(track: Track): Track { + return withIOContext { authClient.newCall( - POST( - "$apiUrl/v2/user_rates", - body = payload.toString().toRequestBody(jsonMime), + DELETE( + "$apiUrl/v2/user_rates/${track.library_id}", ), ).awaitSuccess() track } } - suspend fun updateLibManga(track: Track, user_id: String): Track = addLibManga(track, user_id) - suspend fun search(search: String): List { return withIOContext { val url = "$apiUrl/mangas".toUri().buildUpon() @@ -96,6 +114,7 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter title = mangas["name"]!!.jsonPrimitive.content media_id = obj["id"]!!.jsonPrimitive.long total_chapters = mangas["chapters"]!!.jsonPrimitive.int + library_id = obj["id"]!!.jsonPrimitive.long last_chapter_read = obj["chapters"]!!.jsonPrimitive.float score = (obj["score"]!!.jsonPrimitive.int).toFloat() status = toTrackStatus(obj["status"]!!.jsonPrimitive.content) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt index deb3a1a1e..1ed1895a9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.manga.track import android.app.Application import android.content.Context import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth @@ -11,6 +12,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -48,6 +50,7 @@ import eu.kanade.presentation.track.TrackServiceSearch import eu.kanade.presentation.track.TrackStatusSelector import eu.kanade.presentation.util.Screen import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.track.DeletableTrackService import eu.kanade.tachiyomi.data.track.EnhancedTrackService import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackService @@ -157,7 +160,16 @@ data class TrackInfoDialogHomeScreen( } }, onOpenInBrowser = { openTrackerInBrowser(context, it) }, - ) { sm.unregisterTracking(it.service.id) } + onRemoved = { + navigator.push( + TrackServiceRemoveScreen( + mangaId = mangaId, + track = it.track!!, + serviceId = it.service.id, + ), + ) + }, + ) } /** @@ -174,7 +186,6 @@ data class TrackInfoDialogHomeScreen( private val mangaId: Long, private val sourceId: Long, private val getTracks: GetTracks = Injekt.get(), - private val deleteTrack: DeleteTrack = Injekt.get(), ) : StateScreenModel(State()) { init { @@ -204,10 +215,6 @@ data class TrackInfoDialogHomeScreen( } } - fun unregisterTracking(serviceId: Long) { - coroutineScope.launchNonCancellable { deleteTrack.await(mangaId, serviceId) } - } - private suspend fun refreshTrackers() { val insertTrack = Injekt.get() val getMangaWithChapters = Injekt.get() @@ -723,3 +730,100 @@ data class TrackServiceSearchScreen( ) } } + +private data class TrackServiceRemoveScreen( + private val mangaId: Long, + private val track: Track, + private val serviceId: Long, +) : Screen() { + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val sm = rememberScreenModel { + Model( + mangaId = mangaId, + track = track, + service = Injekt.get().getService(serviceId)!!, + ) + } + val serviceName = stringResource(sm.getServiceNameRes()) + var removeRemoteTrack by remember { mutableStateOf(false) } + AlertDialogContent( + modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars), + icon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + ) + }, + title = { + Text( + text = stringResource(R.string.track_delete_title, serviceName), + textAlign = TextAlign.Center, + ) + }, + text = { + Column { + Text( + text = stringResource(R.string.track_delete_text, serviceName), + ) + if (sm.isServiceDeletable()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = removeRemoteTrack, onCheckedChange = { removeRemoteTrack = it }) + Text(text = stringResource(R.string.track_delete_remote_text, serviceName)) + } + } + } + }, + buttons = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.padding.small, + Alignment.End, + ), + ) { + TextButton(onClick = navigator::pop) { + Text(text = stringResource(R.string.action_cancel)) + } + FilledTonalButton( + onClick = { + sm.unregisterTracking(serviceId) + if (removeRemoteTrack) sm.deleteMangaFromService() + navigator.pop() + }, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { + Text(text = stringResource(R.string.action_ok)) + } + } + }, + ) + } + + private class Model( + private val mangaId: Long, + private val track: Track, + private val service: TrackService, + private val deleteTrack: DeleteTrack = Injekt.get(), + ) : ScreenModel { + + fun getServiceNameRes() = service.nameRes() + + fun isServiceDeletable() = service is DeletableTrackService + + fun deleteMangaFromService() { + coroutineScope.launchNonCancellable { + (service as DeletableTrackService).delete(track.toDbTrack()) + } + } + + fun unregisterTracking(serviceId: Long) { + coroutineScope.launchNonCancellable { deleteTrack.await(mangaId, serviceId) } + } + } +} diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index 7dddc5ca6..b6c5c726a 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -124,6 +124,7 @@ Pin Unpin Cancel + OK Cancel all Cancel all for this series Sort @@ -754,6 +755,9 @@ Remove date? This will remove your previously selected start date from %s This will remove your previously selected finish date from %s + Remove %s tracking? + This will remove the tracking locally. + Also remove from %s A category with this name already exists!