Skip to content

Commit 56a00dd

Browse files
authored
feat: Improve announcements API (#192)
Announcements can have tags now instead of being grouped into a single channel. You can get an announcement using its ID. You can page announcements and filter them by tags and whether they are archived. You can see a list of all available tags. Some route API overhaul.
1 parent 50b81fd commit 56a00dd

File tree

14 files changed

+599
-410
lines changed

14 files changed

+599
-410
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ with updates and ReVanced Patches.
7575

7676
Some of the features ReVanced API include:
7777

78-
- 📢 **Announcements**: Post and get announcements grouped by channels
78+
- 📢 **Announcements**: Post and get announcements
7979
- ℹ️ **About**: Get more information such as a description, ways to donate to,
8080
and links of the hoster of ReVanced API
8181
- 🧩 **Patches**: Get the latest updates of ReVanced Patches, directly from ReVanced API

build.gradle.kts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ kotlin {
4848
}
4949
}
5050

51+
tasks {
52+
test {
53+
useJUnitPlatform()
54+
}
55+
}
56+
5157
repositories {
5258
mavenCentral()
5359
google()
@@ -98,6 +104,8 @@ dependencies {
98104
implementation(libs.caffeine)
99105
implementation(libs.bouncy.castle.provider)
100106
implementation(libs.bouncy.castle.pgp)
107+
108+
testImplementation(kotlin("test"))
101109
}
102110

103111
// The maven-publish plugin is necessary to make signing work.
Lines changed: 169 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
package app.revanced.api.configuration.repository
22

3-
import app.revanced.api.configuration.schema.APIAnnouncement
4-
import app.revanced.api.configuration.schema.APIResponseAnnouncement
5-
import app.revanced.api.configuration.schema.APIResponseAnnouncementId
3+
import app.revanced.api.configuration.schema.ApiAnnouncement
4+
import app.revanced.api.configuration.schema.ApiAnnouncementTag
5+
import app.revanced.api.configuration.schema.ApiResponseAnnouncement
6+
import app.revanced.api.configuration.schema.ApiResponseAnnouncementId
67
import kotlinx.coroutines.Dispatchers
7-
import kotlinx.coroutines.awaitAll
88
import kotlinx.coroutines.runBlocking
99
import kotlinx.datetime.*
1010
import org.jetbrains.exposed.dao.IntEntity
@@ -15,126 +15,175 @@ import org.jetbrains.exposed.sql.*
1515
import org.jetbrains.exposed.sql.kotlin.datetime.CurrentDateTime
1616
import org.jetbrains.exposed.sql.kotlin.datetime.datetime
1717
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
18-
import org.jetbrains.exposed.sql.transactions.experimental.suspendedTransactionAsync
18+
import java.time.LocalDateTime
1919

2020
internal class AnnouncementRepository {
21-
// This is better than doing a maxByOrNull { it.id }.
21+
// This is better than doing a maxByOrNull { it.id } on every request.
2222
private var latestAnnouncement: Announcement? = null
23-
private val latestAnnouncementByChannel = mutableMapOf<String, Announcement>()
24-
25-
private fun updateLatestAnnouncement(new: Announcement) {
26-
if (latestAnnouncement?.id?.value == new.id.value) {
27-
latestAnnouncement = new
28-
latestAnnouncementByChannel[new.channel ?: return] = new
29-
}
30-
}
23+
private val latestAnnouncementByTag = mutableMapOf<Int, Announcement>()
3124

3225
init {
3326
runBlocking {
3427
transaction {
35-
SchemaUtils.create(Announcements, Attachments)
28+
SchemaUtils.create(
29+
Announcements,
30+
Attachments,
31+
Tags,
32+
AnnouncementTags,
33+
)
3634

37-
// Initialize the latest announcement.
38-
latestAnnouncement = Announcement.all().onEach {
39-
latestAnnouncementByChannel[it.channel ?: return@onEach] = it
40-
}.maxByOrNull { it.id } ?: return@transaction
35+
initializeLatestAnnouncements()
4136
}
4237
}
4338
}
4439

45-
suspend fun all() = transaction {
46-
Announcement.all().map { it.toApi() }
40+
private fun initializeLatestAnnouncements() {
41+
latestAnnouncement = Announcement.all().orderBy(Announcements.id to SortOrder.DESC).firstOrNull()
42+
43+
Tag.all().map { it.id.value }.forEach(::updateLatestAnnouncementForTag)
4744
}
4845

49-
suspend fun all(channel: String) = transaction {
50-
Announcement.find { Announcements.channel eq channel }.map { it.toApi() }
46+
private fun updateLatestAnnouncement(new: Announcement) {
47+
if (latestAnnouncement == null || latestAnnouncement!!.id.value <= new.id.value) {
48+
latestAnnouncement = new
49+
new.tags.forEach { tag -> latestAnnouncementByTag[tag.id.value] = new }
50+
}
5151
}
5252

53-
suspend fun delete(id: Int) = transaction {
54-
val announcement = Announcement.findById(id) ?: return@transaction
53+
private fun updateLatestAnnouncementForTag(tag: Int) {
54+
val latestAnnouncementForTag = AnnouncementTags.select(AnnouncementTags.announcement)
55+
.where { AnnouncementTags.tag eq tag }
56+
.map { it[AnnouncementTags.announcement] }
57+
.mapNotNull { Announcement.findById(it) }
58+
.maxByOrNull { it.id }
5559

56-
announcement.delete()
60+
latestAnnouncementForTag?.let { latestAnnouncementByTag[tag] = it }
61+
}
5762

58-
// In case the latest announcement was deleted, query the new latest announcement again.
59-
if (latestAnnouncement?.id?.value == id) {
60-
latestAnnouncement = Announcement.all().maxByOrNull { it.id }
63+
suspend fun latest() = transaction {
64+
latestAnnouncement.toApiResponseAnnouncement()
65+
}
6166

62-
// If no latest announcement was found, remove it from the channel map.
63-
if (latestAnnouncement == null) {
64-
latestAnnouncementByChannel.remove(announcement.channel)
65-
} else {
66-
latestAnnouncementByChannel[latestAnnouncement!!.channel ?: return@transaction] = latestAnnouncement!!
67-
}
68-
}
67+
suspend fun latest(tags: Set<Int>) = transaction {
68+
tags.mapNotNull { tag -> latestAnnouncementByTag[tag] }.toApiAnnouncement()
6969
}
7070

71-
fun latest() = latestAnnouncement?.toApi()
71+
fun latestId() = latestAnnouncement?.id?.value.toApiResponseAnnouncementId()
7272

73-
fun latest(channel: String) = latestAnnouncementByChannel[channel]?.toApi()
73+
fun latestId(tags: Set<Int>) =
74+
tags.map { tag -> latestAnnouncementByTag[tag]?.id?.value }.toApiResponseAnnouncementId()
7475

75-
fun latestId() = latest()?.id?.toApi()
76+
suspend fun paged(cursor: Int, count: Int, tags: Set<Int>?, archived: Boolean) = transaction {
77+
Announcement.find {
78+
fun idLessEq() = Announcements.id lessEq cursor
79+
fun archivedAtIsNull() = Announcements.archivedAt.isNull()
80+
fun archivedAtGreaterNow() = Announcements.archivedAt greater LocalDateTime.now().toKotlinLocalDateTime()
81+
82+
if (tags == null) {
83+
if (archived) {
84+
idLessEq()
85+
} else {
86+
idLessEq() and (archivedAtIsNull() or archivedAtGreaterNow())
87+
}
88+
} else {
89+
fun archivedAtGreaterOrNullOrTrue() = if (archived) {
90+
Op.TRUE
91+
} else {
92+
archivedAtIsNull() or archivedAtGreaterNow()
93+
}
7694

77-
fun latestId(channel: String) = latest(channel)?.id?.toApi()
95+
fun hasTags() = tags.mapNotNull { Tag.findById(it)?.id }.let { tags ->
96+
Announcements.id inSubQuery Announcements.leftJoin(AnnouncementTags)
97+
.select(AnnouncementTags.announcement)
98+
.where { AnnouncementTags.tag inList tags }
99+
.withDistinct()
100+
}
78101

79-
suspend fun archive(
80-
id: Int,
81-
archivedAt: LocalDateTime?,
82-
) = transaction {
83-
Announcement.findByIdAndUpdate(id) {
84-
it.archivedAt = archivedAt ?: java.time.LocalDateTime.now().toKotlinLocalDateTime()
85-
}?.also(::updateLatestAnnouncement)
102+
idLessEq() and archivedAtGreaterOrNullOrTrue() and hasTags()
103+
}
104+
}.orderBy(Announcements.id to SortOrder.DESC).limit(count).toApiAnnouncement()
86105
}
87106

88-
suspend fun unarchive(id: Int) = transaction {
89-
Announcement.findByIdAndUpdate(id) {
90-
it.archivedAt = null
91-
}?.also(::updateLatestAnnouncement)
107+
suspend fun get(id: Int) = transaction {
108+
Announcement.findById(id).toApiResponseAnnouncement()
92109
}
93110

94-
suspend fun new(new: APIAnnouncement) = transaction {
111+
suspend fun new(new: ApiAnnouncement) = transaction {
95112
Announcement.new {
96113
author = new.author
97114
title = new.title
98115
content = new.content
99-
channel = new.channel
100116
archivedAt = new.archivedAt
101117
level = new.level
102-
}.also { newAnnouncement ->
103-
new.attachmentUrls.map { newUrl ->
104-
suspendedTransactionAsync {
105-
Attachment.new {
106-
url = newUrl
107-
announcement = newAnnouncement
108-
}
118+
tags = SizedCollection(
119+
new.tags.map { tag -> Tag.find { Tags.name eq tag }.firstOrNull() ?: Tag.new { name = tag } },
120+
)
121+
}.apply {
122+
new.attachments.map { attachmentUrl ->
123+
Attachment.new {
124+
url = attachmentUrl
125+
announcement = this@apply
109126
}
110-
}.awaitAll()
111-
}.also(::updateLatestAnnouncement)
127+
}
128+
}.let(::updateLatestAnnouncement)
112129
}
113130

114-
suspend fun update(id: Int, new: APIAnnouncement) = transaction {
131+
suspend fun update(id: Int, new: ApiAnnouncement) = transaction {
115132
Announcement.findByIdAndUpdate(id) {
116133
it.author = new.author
117134
it.title = new.title
118135
it.content = new.content
119-
it.channel = new.channel
120136
it.archivedAt = new.archivedAt
121137
it.level = new.level
122-
}?.also { newAnnouncement ->
123-
newAnnouncement.attachments.map {
124-
suspendedTransactionAsync {
125-
it.delete()
126-
}
127-
}.awaitAll()
128-
129-
new.attachmentUrls.map { newUrl ->
130-
suspendedTransactionAsync {
131-
Attachment.new {
132-
url = newUrl
133-
announcement = newAnnouncement
134-
}
138+
139+
// Get the old tags, create new tags if they don't exist,
140+
// and delete tags that are not in the new tags, after updating the announcement.
141+
val oldTags = it.tags.toList()
142+
val updatedTags = new.tags.map { name ->
143+
Tag.find { Tags.name eq name }.firstOrNull() ?: Tag.new { this.name = name }
144+
}
145+
it.tags = SizedCollection(updatedTags)
146+
oldTags.forEach { tag ->
147+
if (tag in updatedTags || !tag.announcements.empty()) return@forEach
148+
tag.delete()
149+
}
150+
151+
// Delete old attachments and create new attachments.
152+
it.attachments.forEach { attachment -> attachment.delete() }
153+
new.attachments.map { attachment ->
154+
Attachment.new {
155+
url = attachment
156+
announcement = it
135157
}
136-
}.awaitAll()
137-
}?.also(::updateLatestAnnouncement)
158+
}
159+
}?.let(::updateLatestAnnouncement) ?: Unit
160+
}
161+
162+
suspend fun delete(id: Int) = transaction {
163+
val announcement = Announcement.findById(id) ?: return@transaction
164+
165+
// Delete the tag if no other announcements are referencing it.
166+
// One count means that the announcement is the only one referencing the tag.
167+
announcement.tags.filter { tag -> tag.announcements.count() == 1L }.forEach { tag ->
168+
latestAnnouncementByTag -= tag.id.value
169+
tag.delete()
170+
}
171+
172+
announcement.delete()
173+
174+
// If the deleted announcement is the latest announcement, set the new latest announcement.
175+
if (latestAnnouncement?.id?.value == id) {
176+
latestAnnouncement = Announcement.all().orderBy(Announcements.id to SortOrder.DESC).firstOrNull()
177+
}
178+
179+
// The new announcement may be the latest for a specific tag. Set the new latest announcement for that tag.
180+
latestAnnouncementByTag.keys.forEach { tag ->
181+
updateLatestAnnouncementForTag(tag)
182+
}
183+
}
184+
185+
suspend fun tags() = transaction {
186+
Tag.all().toList().toApiTag()
138187
}
139188

140189
private suspend fun <T> transaction(statement: suspend Transaction.() -> T) =
@@ -144,7 +193,6 @@ internal class AnnouncementRepository {
144193
val author = varchar("author", 32).nullable()
145194
val title = varchar("title", 64)
146195
val content = text("content").nullable()
147-
val channel = varchar("channel", 16).nullable()
148196
val createdAt = datetime("createdAt").defaultExpression(CurrentDateTime)
149197
val archivedAt = datetime("archivedAt").nullable()
150198
val level = integer("level")
@@ -155,14 +203,27 @@ internal class AnnouncementRepository {
155203
val announcement = reference("announcement", Announcements, onDelete = ReferenceOption.CASCADE)
156204
}
157205

206+
private object Tags : IntIdTable() {
207+
val name = varchar("name", 16).uniqueIndex()
208+
}
209+
210+
private object AnnouncementTags : Table() {
211+
val tag = reference("tag", Tags, onDelete = ReferenceOption.CASCADE)
212+
val announcement = reference("announcement", Announcements, onDelete = ReferenceOption.CASCADE)
213+
214+
init {
215+
uniqueIndex(tag, announcement)
216+
}
217+
}
218+
158219
class Announcement(id: EntityID<Int>) : IntEntity(id) {
159220
companion object : IntEntityClass<Announcement>(Announcements)
160221

161222
var author by Announcements.author
162223
var title by Announcements.title
163224
var content by Announcements.content
164225
val attachments by Attachment referrersOn Attachments.announcement
165-
var channel by Announcements.channel
226+
var tags by Tag via AnnouncementTags
166227
var createdAt by Announcements.createdAt
167228
var archivedAt by Announcements.archivedAt
168229
var level by Announcements.level
@@ -175,17 +236,32 @@ internal class AnnouncementRepository {
175236
var announcement by Announcement referencedOn Attachments.announcement
176237
}
177238

178-
private fun Announcement.toApi() = APIResponseAnnouncement(
179-
id.value,
180-
author,
181-
title,
182-
content,
183-
attachments.map { it.url },
184-
channel,
185-
createdAt,
186-
archivedAt,
187-
level,
188-
)
239+
class Tag(id: EntityID<Int>) : IntEntity(id) {
240+
companion object : IntEntityClass<Tag>(Tags)
241+
242+
var name by Tags.name
243+
var announcements by Announcement via AnnouncementTags
244+
}
245+
246+
private fun Announcement?.toApiResponseAnnouncement() = this?.let {
247+
ApiResponseAnnouncement(
248+
id.value,
249+
author,
250+
title,
251+
content,
252+
attachments.map { it.url },
253+
tags.map { it.id.value },
254+
createdAt,
255+
archivedAt,
256+
level,
257+
)
258+
}
259+
260+
private fun Iterable<Announcement>.toApiAnnouncement() = map { it.toApiResponseAnnouncement()!! }
261+
262+
private fun Iterable<Tag>.toApiTag() = map { ApiAnnouncementTag(it.id.value, it.name) }
263+
264+
private fun Int?.toApiResponseAnnouncementId() = this?.let { ApiResponseAnnouncementId(this) }
189265

190-
private fun Int.toApi() = APIResponseAnnouncementId(this)
266+
private fun Iterable<Int?>.toApiResponseAnnouncementId() = map { it.toApiResponseAnnouncementId() }
191267
}

0 commit comments

Comments
 (0)