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 <zaghdane@fireflow.de>
Co-authored-by: arkon <arkon@users.noreply.github.com>
Co-authored-by: stevenyomi <95685115+stevenyomi@users.noreply.github.com>
This commit is contained in:
zaghdaneh 2023-06-23 04:06:43 +02:00 committed by GitHub
parent 7f0ed58b54
commit b36b3bfcab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 258 additions and 24 deletions

View File

@ -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
}

View File

@ -4,6 +4,7 @@ import android.graphics.Color
import androidx.annotation.StringRes import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track 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.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
@ -12,7 +13,7 @@ import kotlinx.serialization.json.Json
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack import tachiyomi.domain.track.model.Track as DomainTrack
class Anilist(id: Long) : TrackService(id) { class Anilist(id: Long) : TrackService(id), DeletableTrackService {
companion object { companion object {
const val READING = 1 const val READING = 1
@ -167,6 +168,15 @@ class Anilist(id: Long) : TrackService(id) {
return api.updateLibManga(track) 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 { override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
val remoteTrack = api.findLibManga(track, getUsername().toInt()) val remoteTrack = api.findLibManga(track, getUsername().toInt())
return if (remoteTrack != null) { return if (remoteTrack != null) {

View File

@ -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<TrackSearch> { suspend fun search(search: String): List<TrackSearch> {
return withIOContext { return withIOContext {
val query = """ val query = """

View File

@ -4,6 +4,7 @@ import android.graphics.Color
import androidx.annotation.StringRes import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track 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.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
@ -12,7 +13,7 @@ import kotlinx.serialization.json.Json
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.text.DecimalFormat import java.text.DecimalFormat
class Kitsu(id: Long) : TrackService(id) { class Kitsu(id: Long) : TrackService(id), DeletableTrackService {
companion object { companion object {
const val READING = 1 const val READING = 1
@ -93,6 +94,10 @@ class Kitsu(id: Long) : TrackService(id) {
return api.updateLibManga(track) return api.updateLibManga(track)
} }
override suspend fun delete(track: Track): Track {
return api.removeLibManga(track)
}
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track { override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
val remoteTrack = api.findLibManga(track, getUserId()) val remoteTrack = api.findLibManga(track, getUserId())
return if (remoteTrack != null) { return if (remoteTrack != null) {

View File

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.track.kitsu
import androidx.core.net.toUri import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch 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.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.awaitSuccess 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<TrackSearch> { suspend fun search(query: String): List<TrackSearch> {
return withIOContext { return withIOContext {
with(json) { with(json) {

View File

@ -4,12 +4,13 @@ import android.graphics.Color
import androidx.annotation.StringRes import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track 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.TrackService
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.copyTo import eu.kanade.tachiyomi.data.track.mangaupdates.dto.copyTo
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.toTrackSearch import eu.kanade.tachiyomi.data.track.mangaupdates.dto.toTrackSearch
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
class MangaUpdates(id: Long) : TrackService(id) { class MangaUpdates(id: Long) : TrackService(id), DeletableTrackService {
companion object { companion object {
const val READING_LIST = 0 const val READING_LIST = 0
@ -66,6 +67,11 @@ class MangaUpdates(id: Long) : TrackService(id) {
return track return track
} }
override suspend fun delete(track: Track): Track {
api.deleteSeriesFromList(track)
return track
}
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track { override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
return try { return try {
val (series, rating) = api.getSeriesListItem(track) val (series, rating) = api.getSeriesListItem(track)

View File

@ -106,6 +106,19 @@ class MangaUpdatesApi(
updateSeriesRating(track) 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? { private suspend fun getSeriesRating(track: Track): Rating? {
return try { return try {
with(json) { with(json) {

View File

@ -4,6 +4,7 @@ import android.graphics.Color
import androidx.annotation.StringRes import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track 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.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
@ -11,7 +12,7 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class MyAnimeList(id: Long) : TrackService(id) { class MyAnimeList(id: Long) : TrackService(id), DeletableTrackService {
companion object { companion object {
const val READING = 1 const val READING = 1
@ -90,6 +91,10 @@ class MyAnimeList(id: Long) : TrackService(id) {
return api.updateItem(track) return api.updateItem(track)
} }
override suspend fun delete(track: Track): Track {
return api.deleteItem(track)
}
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track { override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
val remoteTrack = api.findListItem(track) val remoteTrack = api.findListItem(track)
return if (remoteTrack != null) { return if (remoteTrack != null) {

View File

@ -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? { suspend fun findListItem(track: Track): Track? {
return withIOContext { return withIOContext {
val uri = "$baseApiUrl/manga".toUri().buildUpon() val uri = "$baseApiUrl/manga".toUri().buildUpon()

View File

@ -4,6 +4,7 @@ import android.graphics.Color
import androidx.annotation.StringRes import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track 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.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
@ -11,7 +12,7 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class Shikimori(id: Long) : TrackService(id) { class Shikimori(id: Long) : TrackService(id), DeletableTrackService {
companion object { companion object {
const val READING = 1 const val READING = 1
@ -57,6 +58,10 @@ class Shikimori(id: Long) : TrackService(id) {
return api.updateLibManga(track, getUsername()) 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 { override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
val remoteTrack = api.findLibManga(track, getUsername()) val remoteTrack = api.findLibManga(track, getUsername())
return if (remoteTrack != null) { return if (remoteTrack != null) {
@ -83,6 +88,7 @@ class Shikimori(id: Long) : TrackService(id) {
override suspend fun refresh(track: Track): Track { override suspend fun refresh(track: Track): Track {
api.findLibManga(track, getUsername())?.let { remoteTrack -> api.findLibManga(track, getUsername())?.let { remoteTrack ->
track.library_id = remoteTrack.library_id
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters track.total_chapters = remoteTrack.total_chapters
} }

View File

@ -4,6 +4,7 @@ import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch 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.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.awaitSuccess
@ -35,6 +36,7 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
suspend fun addLibManga(track: Track, user_id: String): Track { suspend fun addLibManga(track: Track, user_id: String): Track {
return withIOContext { return withIOContext {
with(json) {
val payload = buildJsonObject { val payload = buildJsonObject {
putJsonObject("user_rate") { putJsonObject("user_rate") {
put("user_id", user_id) put("user_id", user_id)
@ -51,12 +53,28 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
body = payload.toString().toRequestBody(jsonMime), body = payload.toString().toRequestBody(jsonMime),
), ),
).awaitSuccess() ).awaitSuccess()
.parseAs<JsonObject>()
.let {
track.library_id = it["id"]!!.jsonPrimitive.long // save id of the entry for possible future delete request
}
track track
} }
} }
}
suspend fun updateLibManga(track: Track, user_id: String): Track = addLibManga(track, user_id) suspend fun updateLibManga(track: Track, user_id: String): Track = addLibManga(track, user_id)
suspend fun deleteLibManga(track: Track): Track {
return withIOContext {
authClient.newCall(
DELETE(
"$apiUrl/v2/user_rates/${track.library_id}",
),
).awaitSuccess()
track
}
}
suspend fun search(search: String): List<TrackSearch> { suspend fun search(search: String): List<TrackSearch> {
return withIOContext { return withIOContext {
val url = "$apiUrl/mangas".toUri().buildUpon() val url = "$apiUrl/mangas".toUri().buildUpon()
@ -96,6 +114,7 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
title = mangas["name"]!!.jsonPrimitive.content title = mangas["name"]!!.jsonPrimitive.content
media_id = obj["id"]!!.jsonPrimitive.long media_id = obj["id"]!!.jsonPrimitive.long
total_chapters = mangas["chapters"]!!.jsonPrimitive.int total_chapters = mangas["chapters"]!!.jsonPrimitive.int
library_id = obj["id"]!!.jsonPrimitive.long
last_chapter_read = obj["chapters"]!!.jsonPrimitive.float last_chapter_read = obj["chapters"]!!.jsonPrimitive.float
score = (obj["score"]!!.jsonPrimitive.int).toFloat() score = (obj["score"]!!.jsonPrimitive.int).toFloat()
status = toTrackStatus(obj["status"]!!.jsonPrimitive.content) status = toTrackStatus(obj["status"]!!.jsonPrimitive.content)

View File

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.manga.track
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth 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.Icons
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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.track.TrackStatusSelector
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.R 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.EnhancedTrackService
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
@ -157,7 +160,16 @@ data class TrackInfoDialogHomeScreen(
} }
}, },
onOpenInBrowser = { openTrackerInBrowser(context, it) }, 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 mangaId: Long,
private val sourceId: Long, private val sourceId: Long,
private val getTracks: GetTracks = Injekt.get(), private val getTracks: GetTracks = Injekt.get(),
private val deleteTrack: DeleteTrack = Injekt.get(),
) : StateScreenModel<Model.State>(State()) { ) : StateScreenModel<Model.State>(State()) {
init { init {
@ -204,10 +215,6 @@ data class TrackInfoDialogHomeScreen(
} }
} }
fun unregisterTracking(serviceId: Long) {
coroutineScope.launchNonCancellable { deleteTrack.await(mangaId, serviceId) }
}
private suspend fun refreshTrackers() { private suspend fun refreshTrackers() {
val insertTrack = Injekt.get<InsertTrack>() val insertTrack = Injekt.get<InsertTrack>()
val getMangaWithChapters = Injekt.get<GetMangaWithChapters>() val getMangaWithChapters = Injekt.get<GetMangaWithChapters>()
@ -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<TrackManager>().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) }
}
}
}

View File

@ -124,6 +124,7 @@
<string name="action_pin">Pin</string> <string name="action_pin">Pin</string>
<string name="action_unpin">Unpin</string> <string name="action_unpin">Unpin</string>
<string name="action_cancel">Cancel</string> <string name="action_cancel">Cancel</string>
<string name="action_ok">OK</string>
<string name="action_cancel_all">Cancel all</string> <string name="action_cancel_all">Cancel all</string>
<string name="cancel_all_for_series">Cancel all for this series</string> <string name="cancel_all_for_series">Cancel all for this series</string>
<string name="action_sort">Sort</string> <string name="action_sort">Sort</string>
@ -754,6 +755,9 @@
<string name="track_remove_date_conf_title">Remove date?</string> <string name="track_remove_date_conf_title">Remove date?</string>
<string name="track_remove_start_date_conf_text">This will remove your previously selected start date from %s</string> <string name="track_remove_start_date_conf_text">This will remove your previously selected start date from %s</string>
<string name="track_remove_finish_date_conf_text">This will remove your previously selected finish date from %s</string> <string name="track_remove_finish_date_conf_text">This will remove your previously selected finish date from %s</string>
<string name="track_delete_title">Remove %s tracking?</string>
<string name="track_delete_text">This will remove the tracking locally.</string>
<string name="track_delete_remote_text">Also remove from %s</string>
<!-- Category activity --> <!-- Category activity -->
<string name="error_category_exists">A category with this name already exists!</string> <string name="error_category_exists">A category with this name already exists!</string>