From 7d96e9468983d3d482c341208b8e0836feca09ff Mon Sep 17 00:00:00 2001 From: Dmitriy Date: Thu, 16 Oct 2025 14:56:41 +0400 Subject: [PATCH 1/7] Fix crash for bookmarks without descriptions/thumbnails (#6488) Co-authored-by: Nicolas Raoul --- .../bookmarks/items/BookmarkItemsDao.kt | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.kt index bfb3ec764..e21e1ac8f 100644 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.kt +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.kt @@ -173,19 +173,13 @@ class BookmarkItemsDao @Inject constructor( categoryNameList: List, categoryDescriptionList: List, categoryThumbnailList: List - ): List { - return buildList { - for (i in categoryNameList.indices) { - add( - CategoryItem( - categoryNameList[i], - categoryDescriptionList[i], - categoryThumbnailList[i], - false - ) - ) - } - } + ): List = categoryNameList.mapIndexed { index, name -> + CategoryItem( + name = name, + description = categoryDescriptionList.getOrNull(index), + thumbnail = categoryThumbnailList.getOrNull(index), + isSelected = false + ) } /** From 714e5f8a4bdf49ceea0d5dbc3e32749273c274b1 Mon Sep 17 00:00:00 2001 From: Aneesh Hebbar Date: Fri, 17 Oct 2025 08:10:48 +0530 Subject: [PATCH 2/7] fix(i18n): Correct capitalization for 'Sending thanks' status messages (#6515) (#6518) --- app/src/main/res/values/strings.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 996d0edba..fc1af4e20 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,7 +2,7 @@ Commons Facebook Page - Commons Github Source Code + Commons GitHub Source Code Commons Logo Commons Website Exit location picker @@ -493,12 +493,12 @@ Upload your first media by tapping on the add button. Could not request category check for %1$s Requesting category check for %1$s Done - Sending Thanks: Success + Sending thanks: Success Sent thanks to %1$s Failed to send thanks to %1$s - Sending Thanks: Failure + Sending thanks: Failure - Sending Thanks for %1$s + Sending thanks for %1$s Does this follow the rules of copyright? Is this correctly categorized? Is this in-scope? From b5b5d8a8e4ea13656595f872ad38ffb88d68ccf8 Mon Sep 17 00:00:00 2001 From: Xinyu Yang Date: Sat, 18 Oct 2025 13:31:49 +1100 Subject: [PATCH 3/7] =?UTF-8?q?I=20didn=E2=80=99t=20look=20at=20the=20code?= =?UTF-8?q?=20carefully=20before=20and=20directly=20modified=20the=20conte?= =?UTF-8?q?nts=20of=20strings.xml.=20After=20reviewing=20it,=20I=20found?= =?UTF-8?q?=20that=20the=20issue=20was=20actually=20in=20WikidataItemDetai?= =?UTF-8?q?lsActivity.kt,=20where=20the=20wrong=20label=20was=20selected.?= =?UTF-8?q?=20After=20correcting=20this,=20there=20should=20no=20longer=20?= =?UTF-8?q?be=20any=20problems.=20(#6524)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: frank --- .../commons/explore/depictions/WikidataItemDetailsActivity.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/WikidataItemDetailsActivity.kt b/app/src/main/java/fr/free/nrw/commons/explore/depictions/WikidataItemDetailsActivity.kt index 32af67e95..d025fdfe1 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/depictions/WikidataItemDetailsActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/WikidataItemDetailsActivity.kt @@ -112,8 +112,8 @@ class WikidataItemDetailsActivity : BaseActivity(), MediaDetailProvider, Categor viewPagerAdapter!!.setTabs( R.string.title_for_media to depictionImagesListFragment!!, - R.string.title_for_subcategories to childDepictionsFragment, - R.string.title_for_parent_categories to parentDepictionsFragment + R.string.title_for_child_classes to childDepictionsFragment, + R.string.title_for_parent_classes to parentDepictionsFragment ) binding!!.viewPager.offscreenPageLimit = 2 viewPagerAdapter!!.notifyDataSetChanged() From 29b6d0f8fe35fc13c23f9be65353ab25e9ace709 Mon Sep 17 00:00:00 2001 From: "Amir E. Aharoni" Date: Sat, 18 Oct 2025 00:43:37 -0400 Subject: [PATCH 4/7] Replace Switch with SwitchMaterial (#6522) Lint recommended replacing Switch with SwitchMaterial. This was a very simple replacement, and I tested it in the custom selector, where it is used, and it works as it worked previously. --- .../nrw/commons/customselector/ui/selector/ImageFragment.kt | 4 ++-- app/src/main/res/layout/fragment_custom_selector.xml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt index db250dea9..a5182fe62 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt @@ -9,7 +9,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ProgressBar -import android.widget.Switch import androidx.appcompat.app.AlertDialog import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.isVisible @@ -20,6 +19,7 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.switchmaterial.SwitchMaterial import fr.free.nrw.commons.contributions.Contribution import fr.free.nrw.commons.contributions.ContributionDao import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao @@ -82,7 +82,7 @@ class ImageFragment : */ private var selectorRV: RecyclerView? = null private var loader: ProgressBar? = null - private var switch: Switch? = null + private var switch: SwitchMaterial? = null lateinit var filteredImages: ArrayList /** diff --git a/app/src/main/res/layout/fragment_custom_selector.xml b/app/src/main/res/layout/fragment_custom_selector.xml index b016b6605..f1f95035b 100644 --- a/app/src/main/res/layout/fragment_custom_selector.xml +++ b/app/src/main/res/layout/fragment_custom_selector.xml @@ -6,7 +6,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:background="?attr/mainBackground"> - Date: Sat, 18 Oct 2025 00:45:35 -0400 Subject: [PATCH 5/7] Fix lint warning about Timber (#6521) Change trivial string formatting and function calls for Timber logging. This resolves all the lint warnings in the Android/Lint/Correctness/Messages group. --- .../java/fr/free/nrw/commons/contributions/MainActivity.kt | 2 +- .../main/java/fr/free/nrw/commons/profile/ProfileActivity.kt | 3 ++- app/src/main/java/fr/free/nrw/commons/utils/LocationUtils.kt | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt index ac7068cc7..d481017b2 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt @@ -324,7 +324,7 @@ after opening the app. ) .subscribeOn(Schedulers.io()) .blockingGet() - Timber.d("Resuming " + stuckUploads.size + " uploads...") + Timber.d("Resuming %d uploads...", stuckUploads.size) if (!stuckUploads.isEmpty()) { for (contribution in stuckUploads) { contribution.state = Contribution.STATE_QUEUED diff --git a/app/src/main/java/fr/free/nrw/commons/profile/ProfileActivity.kt b/app/src/main/java/fr/free/nrw/commons/profile/ProfileActivity.kt index c368d6cd4..8567d37ae 100644 --- a/app/src/main/java/fr/free/nrw/commons/profile/ProfileActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/profile/ProfileActivity.kt @@ -27,6 +27,7 @@ import java.io.File import java.io.FileOutputStream import java.util.Locale import javax.inject.Inject +import timber.log.Timber /** * This activity will set two tabs, achievements and @@ -122,7 +123,7 @@ class ProfileActivity : BaseActivity() { val rootView = window.decorView.findViewById(android.R.id.content) val screenShot = getScreenShot(rootView) if (screenShot == null) { - Log.e("ERROR", "ScreenShot is null") + Timber.e("ScreenShot is null") return false } showAlert(screenShot) diff --git a/app/src/main/java/fr/free/nrw/commons/utils/LocationUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/LocationUtils.kt index 1fbd87581..cef137f43 100644 --- a/app/src/main/java/fr/free/nrw/commons/utils/LocationUtils.kt +++ b/app/src/main/java/fr/free/nrw/commons/utils/LocationUtils.kt @@ -37,7 +37,7 @@ object LocationUtils { latLng = LatLng(latLngArray[1].trim().toDouble(), latLngArray[0].trim().toDouble(), 1f) } catch (e: Exception) { - Timber.e("Error while parsing user entered lat long: %s", e) + Timber.e(e, "Error while parsing user entered lat long") } return latLng From 3a55583460d3294efc7ae2000616d1cb4ec6ad68 Mon Sep 17 00:00:00 2001 From: "Amir E. Aharoni" Date: Sat, 18 Oct 2025 00:51:46 -0400 Subject: [PATCH 6/7] Disable linting for icon hiding code in preferences (#6519) --- .../java/fr/free/nrw/commons/settings/SettingsFragment.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt b/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt index 387dd4672..c38ed1ecb 100644 --- a/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt @@ -1,6 +1,7 @@ package fr.free.nrw.commons.settings import android.Manifest.permission +import android.annotation.SuppressLint import android.app.Activity import android.app.Dialog import android.content.Context.MODE_PRIVATE @@ -303,6 +304,11 @@ class SettingsFragment : PreferenceFragmentCompat() { ) } + // Remove the space for icons in the settings menu. + // This uses an internal API that shouldn't be used in app code, + // but it appears to be the most robust way to do this at the moment, + // disable the warning. + @SuppressLint("RestrictedApi") override fun onCreateAdapter(preferenceScreen: PreferenceScreen): Adapter { return object : PreferenceGroupAdapter(preferenceScreen) { From def33552f9c7bacea492633db5373c7d6184a670 Mon Sep 17 00:00:00 2001 From: VoidRaven Date: Sun, 19 Oct 2025 19:28:14 +0530 Subject: [PATCH 7/7] Test/2819 add campaigns api tests (#6529) * test:add mock JSON resource files for campaigns API responses * feat:make campaign model fields mutable to allow for correct deserialization * test:implement unit tests for fetching campaigns and fix DTO mocking logic * test:implement unit tests for fetching campaigns and fix DTO mocking logic --------- Co-authored-by: Nicolas Raoul --- .../nrw/commons/campaigns/CampaignConfig.kt | 6 +- .../commons/campaigns/CampaignResponseDTO.kt | 6 +- .../nrw/commons/OkHttpJsonApiClientTests.kt | 167 +++++++++++++++--- .../raw/campaigns_response_empty.json | 7 + .../raw/campaigns_response_with_data.json | 24 +++ 5 files changed, 182 insertions(+), 28 deletions(-) create mode 100644 app/src/test/resources/raw/campaigns_response_empty.json create mode 100644 app/src/test/resources/raw/campaigns_response_with_data.json diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt index 6bf0bc0ed..9f94e8592 100644 --- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt @@ -7,8 +7,8 @@ import com.google.gson.annotations.SerializedName */ class CampaignConfig { @SerializedName("showOnlyLiveCampaigns") - private val showOnlyLiveCampaigns = false + var showOnlyLiveCampaigns = false @SerializedName("sortBy") - private val sortBy: String? = null -} + var sortBy: String? = null +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignResponseDTO.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignResponseDTO.kt index 767732eb7..1656109e7 100644 --- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignResponseDTO.kt +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignResponseDTO.kt @@ -8,8 +8,8 @@ import fr.free.nrw.commons.campaigns.models.Campaign */ class CampaignResponseDTO { @SerializedName("config") - val campaignConfig: CampaignConfig? = null + var campaignConfig: CampaignConfig? = null @SerializedName("campaigns") - val campaigns: List? = null -} + var campaigns: List? = null +} \ No newline at end of file diff --git a/app/src/test/kotlin/fr/free/nrw/commons/OkHttpJsonApiClientTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/OkHttpJsonApiClientTests.kt index 9e7891560..1a9d1500b 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/OkHttpJsonApiClientTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/OkHttpJsonApiClientTests.kt @@ -3,6 +3,10 @@ package fr.free.nrw.commons import com.google.gson.Gson import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.times +import fr.free.nrw.commons.campaigns.CampaignResponseDTO +import fr.free.nrw.commons.campaigns.CampaignConfig +import fr.free.nrw.commons.campaigns.models.Campaign import fr.free.nrw.commons.explore.depictions.DepictsClient import fr.free.nrw.commons.location.LatLng import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient @@ -10,13 +14,22 @@ import fr.free.nrw.commons.nearby.model.NearbyQueryParams import okhttp3.Call import okhttp3.HttpUrl import okhttp3.OkHttpClient +import okhttp3.Request import okhttp3.Response +import okhttp3.ResponseBody +import okhttp3.mockwebserver.MockResponse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.mockito.Mock import org.mockito.Mockito -import org.mockito.Mockito.times +import org.mockito.Mockito.eq import org.mockito.MockitoAnnotations +import java.io.BufferedReader +import java.io.InputStreamReader import java.lang.Exception class OkHttpJsonApiClientTests { @@ -45,34 +58,43 @@ class OkHttpJsonApiClientTests { @Mock lateinit var response: Response + @Mock + lateinit var responseBody: ResponseBody + + private lateinit var mockWebServer: TestWebServer + @Before fun setUp() { MockitoAnnotations.openMocks(this) - okHttpJsonApiClient = - OkHttpJsonApiClient( - okhttpClient, - depictsClient, - wikiMediaToolforgeUrl, - sparqlQueryUrl, - campaignsUrl, - gson, - ) + mockWebServer = TestWebServer() + mockWebServer.setUp() + okHttpJsonApiClient = OkHttpJsonApiClient( + okhttpClient, + depictsClient, + wikiMediaToolforgeUrl, + sparqlQueryUrl, + mockWebServer.getUrl(), //use the mock server for the campaignsUrl + gson + ) Mockito.`when`(okhttpClient.newCall(any())).thenReturn(call) Mockito.`when`(call.execute()).thenReturn(response) + Mockito.`when`(response.isSuccessful).thenReturn(false) + Mockito.`when`(response.message).thenReturn("test") + Mockito.`when`(response.body).thenReturn(responseBody) + Mockito.`when`(responseBody.string()).thenReturn("{\"error\": \"test\"}") } @Test fun testGetNearbyPlacesCustomQuery() { - Mockito.`when`(response.message).thenReturn("test") try { okHttpJsonApiClient.getNearbyPlaces(latLng, "test", 10.0, "test") } catch (e: Exception) { - assert(e.message.equals("test")) + assertEquals("test", e.message) } try { okHttpJsonApiClient.getNearbyPlaces(NearbyQueryParams.Rectangular(latLng, latLng), "test", true, "test") } catch (e: Exception) { - assert(e.message.equals("test")) + assertEquals("test", e.message) } verify(okhttpClient, times(2)).newCall(any()) verify(call, times(2)).execute() @@ -80,11 +102,10 @@ class OkHttpJsonApiClientTests { @Test fun testGetNearbyPlaces() { - Mockito.`when`(response.message).thenReturn("test") try { okHttpJsonApiClient.getNearbyPlaces(latLng, "test", 10.0, null) } catch (e: Exception) { - assert(e.message.equals("test")) + assertEquals("test", e.message) } try { okHttpJsonApiClient.getNearbyPlaces( @@ -93,9 +114,8 @@ class OkHttpJsonApiClientTests { true, null ) - } catch (e: Exception) { - assert(e.message.equals("test")) + assertEquals("test", e.message) } try { okHttpJsonApiClient.getNearbyPlaces( @@ -105,7 +125,7 @@ class OkHttpJsonApiClientTests { null ) } catch (e: Exception) { - assert(e.message.equals("test")) + assertEquals("test", e.message) } verify(okhttpClient, times(3)).newCall(any()) verify(call, times(3)).execute() @@ -113,18 +133,121 @@ class OkHttpJsonApiClientTests { @Test fun testGetNearbyItemCount() { - Mockito.`when`(response.message).thenReturn("test") try { okHttpJsonApiClient.getNearbyItemCount(NearbyQueryParams.Radial(latLng, 10f)) } catch (e: Exception) { - assert(e.message.equals("test")) + assertEquals("test", e.message) } try { okHttpJsonApiClient.getNearbyItemCount(NearbyQueryParams.Rectangular(latLng, latLng)) } catch (e: Exception) { - assert(e.message.equals("test")) + assertEquals("test", e.message) } verify(okhttpClient, times(2)).newCall(any()) verify(call, times(2)).execute() } -} + + @Test + fun testGetCampaignsWithData() { + //loads the json response from resources + val jsonResponse = loadJsonFromResource("campaigns_response_with_data.json") + + //mocks the succesfull response chain + Mockito.`when`(response.isSuccessful).thenReturn(true) + Mockito.`when`(response.message).thenReturn("OK") + Mockito.`when`(response.body).thenReturn(responseBody) + Mockito.`when`(responseBody.string()).thenReturn(jsonResponse) + + val campaignResponse = CampaignResponseDTO().apply { + campaignConfig = CampaignConfig().apply { + showOnlyLiveCampaigns = false + sortBy = "startDate" + } + campaigns = listOf( + Campaign().apply { + title = "Wiki Loves Monuments" + isWLMCampaign = true + }, + Campaign().apply { + title = "Wiki Loves Nature" + isWLMCampaign = false + } + ) + } + + //any() for the string argument and eq() for the class argument. + Mockito.`when`( + gson.fromJson( + any(), + eq(CampaignResponseDTO::class.java) + ) + ).thenReturn(campaignResponse) + + //call the getCampaigns + val result: CampaignResponseDTO? = okHttpJsonApiClient.getCampaigns().blockingGet() + + //verify the results + assertNotNull(result) + assertNotNull(result?.campaigns) + assertEquals(2, result?.campaigns!!.size) + assertEquals("Wiki Loves Monuments", result.campaigns!![0].title) + assertTrue(result.campaigns!![0].isWLMCampaign) + assertEquals("Wiki Loves Nature", result.campaigns!![1].title) + assertEquals(false, result.campaigns!![1].isWLMCampaign) + assertNotNull(result.campaignConfig) + assertFalse(result.campaignConfig!!.showOnlyLiveCampaigns) + assertEquals("startDate", result.campaignConfig!!.sortBy) + } + + @Test + fun testGetCampaignsEmpty() { + //loads the empty json response + val jsonResponse = loadJsonFromResource("campaigns_response_empty.json") + + //mocks the successful response chain + Mockito.`when`(response.isSuccessful).thenReturn(true) + Mockito.`when`(response.message).thenReturn("OK") + Mockito.`when`(response.body).thenReturn(responseBody) + Mockito.`when`(responseBody.string()).thenReturn(jsonResponse) + + val campaignResponse = CampaignResponseDTO().apply { + campaignConfig = CampaignConfig().apply { + showOnlyLiveCampaigns = false + sortBy = "startDate" + } + campaigns = emptyList() + } + + //use any() for the string argument and eq() for the class argument. + Mockito.`when`( + gson.fromJson( + any(), + eq(CampaignResponseDTO::class.java) + ) + ).thenReturn(campaignResponse) + + //calls getCampaigns + val result: CampaignResponseDTO? = okHttpJsonApiClient.getCampaigns().blockingGet() + + //verify the results + assertNotNull(result) + assertNotNull(result?.campaigns) + assertTrue(result?.campaigns!!.isEmpty()) + assertNotNull(result.campaignConfig) + assertFalse(result.campaignConfig!!.showOnlyLiveCampaigns) + assertEquals("startDate", result.campaignConfig!!.sortBy) + } + + fun loadJsonFromResource(fileName: String): String { + val resourcePath = "raw/$fileName" + //uses the classloader to find the resource in the test environment + val inputStream = javaClass.classLoader?.getResourceAsStream(resourcePath) + + if (inputStream != null) { + //reads the entire stream content + return BufferedReader(InputStreamReader(inputStream)).use { it.readText() } + } + //throws an exception with the correct expected path + throw IllegalArgumentException("Resource $fileName not found. Please ensure the file is located in app/src/test/resources/raw/") + } +} \ No newline at end of file diff --git a/app/src/test/resources/raw/campaigns_response_empty.json b/app/src/test/resources/raw/campaigns_response_empty.json new file mode 100644 index 000000000..61903e24c --- /dev/null +++ b/app/src/test/resources/raw/campaigns_response_empty.json @@ -0,0 +1,7 @@ +{ + "config": { + "showOnlyLiveCampaigns": false, + "sortBy": "startDate" + }, + "campaigns": [] +} \ No newline at end of file diff --git a/app/src/test/resources/raw/campaigns_response_with_data.json b/app/src/test/resources/raw/campaigns_response_with_data.json new file mode 100644 index 000000000..dab818e2a --- /dev/null +++ b/app/src/test/resources/raw/campaigns_response_with_data.json @@ -0,0 +1,24 @@ +{ + "config": { + "showOnlyLiveCampaigns": false, + "sortBy": "startDate" + }, + "campaigns": [ + { + "title": "Wiki Loves Monuments", + "description": "A campaign to photograph monuments", + "startDate": "2025-09-01", + "endDate": "2025-09-30", + "link": "https://commons.wikimedia.org/wiki/Campaign:Wiki_Loves_Monuments", + "isWLMCampaign": true + }, + { + "title": "Wiki Loves Nature", + "description": "A campaign to photograph nature", + "startDate": "2025-06-01", + "endDate": "2025-06-30", + "link": "https://commons.wikimedia.org/wiki/Campaign:Wiki_Loves_Nature", + "isWLMCampaign": false + } + ] +} \ No newline at end of file