diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 13ab29d5e..529c18086 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -148,7 +148,9 @@ jobs:
with:
repository: opendatateam/udata
path: ${{ env.UDATA_WORKING_DIR }}
- ref: main
+ # FIXME: geopf lives on this branch, not yet merged to main. Point back
+ # to `main` once the udata geopf PR (feat/geopf/init) merges.
+ ref: feat/geopf/init
- name: Set up uv
uses: astral-sh/setup-uv@v6
@@ -248,6 +250,7 @@ jobs:
NUXT_SITE_URL: http://localhost:3000
NUXT_PUBLIC_CHARTS_API_BASE: http://localhost:7000
NUXT_PUBLIC_CADA_RESOURCE_ID: 3a6d6fe3-8548-43ed-ad09-72052771447c
+ NUXT_PUBLIC_GEOPF_ENABLED: "true"
run: |
# Start cdata server in background using pre-built artifacts
PORT=3000 node .output/server/index.mjs > cdata.log 2>&1 &
diff --git a/components/Datasets/AdminDatasetResourcesPage.vue b/components/Datasets/AdminDatasetResourcesPage.vue
index 08fa3258d..6b5fffd51 100644
--- a/components/Datasets/AdminDatasetResourcesPage.vue
+++ b/components/Datasets/AdminDatasetResourcesPage.vue
@@ -78,7 +78,7 @@
:schemas="schemas ?? []"
:dataset
@submit="updateResource"
- @delete="refreshResources"
+ @delete="refreshAfterMutation"
/>
@@ -214,6 +214,7 @@ import AdminTableTh from '../AdminTable/Table/AdminTableTh.vue'
import UploadResourceModal from './UploadResourceModal.vue'
import FileEditModal from './FileEditModal.vue'
import FileEditModalFromQueryStringClient from './FileEditModalFromQueryString.client.vue'
+import { geopfEligibilityRefreshKey } from './geopfEligibilityRefreshKey'
import type { AdminBadgeType, CommunityResourceForm, PaginatedArray, ResourceForm } from '~/types/types'
const route = useRoute()
@@ -247,6 +248,14 @@ const refreshResources = async () => {
}
watchEffect(async () => await refreshResources())
+// Kept off `refreshResources`, which the watchEffect above also runs on every
+// pagination change.
+const refreshGeopfEligibility = inject(geopfEligibilityRefreshKey, () => {})
+const refreshAfterMutation = async () => {
+ await refreshResources()
+ refreshGeopfEligibility()
+}
+
const { t } = useTranslation()
const resourceForms = ref>([])
@@ -271,7 +280,7 @@ const saveFirstNewFile = async (closeModal: () => void, form: ResourceForm | Com
removeFirstNewFile()
page.value = 1
- refreshResources()
+ refreshAfterMutation()
}
const updateResource = async (closeModal: () => void, resourceForm: ResourceForm | CommunityResourceForm) => {
if (!dataset.value) return
@@ -279,7 +288,7 @@ const updateResource = async (closeModal: () => void, resourceForm: ResourceForm
try {
await saveResourceForm(dataset.value, resourceForm)
- await refreshResources()
+ await refreshAfterMutation()
closeModal()
}
finally {
diff --git a/components/Datasets/FileEditModal.vue b/components/Datasets/FileEditModal.vue
index d239e0603..2065589eb 100644
--- a/components/Datasets/FileEditModal.vue
+++ b/components/Datasets/FileEditModal.vue
@@ -16,7 +16,8 @@
:icon="RiPencilLine"
:color="buttonColor"
:size="buttonSize"
- :title="$t('Éditer le fichier')"
+ :disabled="isSyncedWithGeopf"
+ :title="isSyncedWithGeopf ? $t('Vous ne pouvez pas modifier cette ressource car elle est synchronisée avec cartes.gouv.fr') : $t('Éditer le fichier')"
keep-margins-even-without-borders
v-on="listeners"
/>
@@ -201,6 +202,7 @@ import DescribeResource from './DescribeResource.vue'
import CdataLink from '../CdataLink.vue'
import type { CommunityResourceForm, ResourceForm } from '~/types/types'
import { useComponentsConfig } from '../../datagouv-components/src/config'
+import { isGeopfSynced } from '~/utils/geopf'
const { t } = useTranslation()
const { $api } = useNuxtApp()
@@ -232,6 +234,8 @@ const resourceForm = ref(cloneDeep(props.resource))
const open = ref(false)
const hasFileChanged = ref(false)
+const isSyncedWithGeopf = computed(() => props.resource.resource ? isGeopfSynced(props.resource.resource) : false)
+
const hasTabularData = useHasTabularData()
// Check if resource has tabular API
diff --git a/components/Datasets/GeopfDatastoreSelector.vue b/components/Datasets/GeopfDatastoreSelector.vue
new file mode 100644
index 000000000..51d8c694d
--- /dev/null
+++ b/components/Datasets/GeopfDatastoreSelector.vue
@@ -0,0 +1,55 @@
+
+
+
+ {{ pinnedDatastoreName ? t('Entrepôt cartes.gouv.fr : {name} ({id})', { name: pinnedDatastoreName, id: pinnedDatastoreId }) : t('Entrepôt cartes.gouv.fr : {id}', { id: pinnedDatastoreId }) }}
+
+
+
+
+
+
+ {{ t("Connectez-vous à cartes.gouv.fr pour choisir l'entrepôt de ce jeu de données.") }}
+
+
+
+
diff --git a/components/Datasets/GeopfPanel.vue b/components/Datasets/GeopfPanel.vue
new file mode 100644
index 000000000..100020e04
--- /dev/null
+++ b/components/Datasets/GeopfPanel.vue
@@ -0,0 +1,84 @@
+
+
+
+ {{ t('Votre connexion a expiré ou a été révoquée.') }}
+
+
+ {{ t('Connectez-vous pour envoyer des fichiers et récupérer les services publiés sur cartes.gouv.fr.') }}
+
+
+ {{ t('Vous pouvez envoyer des fichiers vers cartes.gouv.fr depuis la liste ci-dessous.') }}
+
+
+
+
+ {{ t('Se connecter') }}
+
+
+ {{ t('Se déconnecter') }}
+
+
+
+
+
+
diff --git a/components/Datasets/GeopfPullButton.vue b/components/Datasets/GeopfPullButton.vue
new file mode 100644
index 000000000..2925c5f13
--- /dev/null
+++ b/components/Datasets/GeopfPullButton.vue
@@ -0,0 +1,81 @@
+
+
+
+
+ {{ t('Récupérer les services') }}
+
+
+
+ {{ disabledReason }}
+
+
+
+
+ {{ t('Dernière synchronisation : {date}', { date: formatDate(pull.last_synced_at) }) }}
+
+
+ {{ pull.error }}
+
+
+
+
+
diff --git a/components/Datasets/GeopfPushStatus.vue b/components/Datasets/GeopfPushStatus.vue
new file mode 100644
index 000000000..871f567e2
--- /dev/null
+++ b/components/Datasets/GeopfPushStatus.vue
@@ -0,0 +1,129 @@
+
+
+
+ {{ t('Envoyer vers cartes.gouv.fr') }}
+
+
+
+ {{ disabledReason }}
+
+
+
+
+ {{ t('Envoi en cours…') }}
+
+
+
+
+ {{ t('Synchronisé') }}
+
+
+ {{ formatDate(push.last_synced_at) }}
+
+
+
+
+
+
+ {{ push.status === 'timeout' ? t('Délai dépassé') : t("Échec de l'envoi") }}
+
+
+
+ {{ push.error }}
+
+
+
+
+ {{ t('Réessayer') }}
+
+
+
+
+
diff --git a/components/Datasets/GeopfSyncPage.vue b/components/Datasets/GeopfSyncPage.vue
new file mode 100644
index 000000000..431c73ad1
--- /dev/null
+++ b/components/Datasets/GeopfSyncPage.vue
@@ -0,0 +1,192 @@
+
+
+
+
+
+
+
+
+
+ {{ t('Fichiers à envoyer') }}
+
+
+
+
+
+ {{ t('Nom du fichier') }}
+
+
+ {{ t('Format') }}
+
+
+ {{ t('Envoi') }}
+
+
+
+
+
+ |
+
+ |
+
+ {{ resource.format }}
+ |
+
+
+ |
+
+
+
+
+ {{ t("Aucun fichier éligible n'a été trouvé dans ce jeu de données.") }}
+
+
+
+
+ {{ t('Services récupérés depuis cartes.gouv.fr') }}
+
+
+
+
+
+
+
+ {{ t('Nom') }}
+
+
+ {{ t('Format') }}
+
+
+ {{ t('Dernière synchronisation') }}
+
+
+
+
+
+ |
+
+ |
+
+ {{ resource.format }}
+ |
+
+ {{ formatDate(resource.offering.last_synced_at) }}
+ |
+
+
+
+
+ {{ t("Aucun service n'a encore été synchronisé depuis cartes.gouv.fr.") }}
+
+
+
+ {{ t('Voir la fiche sur cartes.gouv.fr') }}
+
+
+
+
+ {{ t('Vous n\'avez pas la permission de synchroniser ce jeu de données avec cartes.gouv.fr.') }}
+
+
+
+
+
diff --git a/components/Datasets/geopfEligibilityRefreshKey.ts b/components/Datasets/geopfEligibilityRefreshKey.ts
new file mode 100644
index 000000000..0511bd9c2
--- /dev/null
+++ b/components/Datasets/geopfEligibilityRefreshKey.ts
@@ -0,0 +1,7 @@
+import type { InjectionKey } from 'vue'
+
+// Lets the Files tab (which mutates resources) tell the admin dataset layout
+// (which decides whether the geopf tab is shown) to recheck eligibility, so a
+// newly-added resource surfaces the tab without a full page reload.
+export type GeopfEligibilityRefresh = () => void
+export const geopfEligibilityRefreshKey = Symbol() as InjectionKey
diff --git a/components/SiteHeader/SiteHeader.vue b/components/SiteHeader/SiteHeader.vue
index 87334b0fb..a443f0de4 100644
--- a/components/SiteHeader/SiteHeader.vue
+++ b/components/SiteHeader/SiteHeader.vue
@@ -631,6 +631,9 @@ onMounted(() => {
if (message) {
toast[message.type](message.text)
}
+
+ // Consume the flash: strip it from the URL so it can't resurface on a later reload.
+ router.replace({ query: { ...route.query, flash: undefined, info: undefined, error: undefined } })
}
})
diff --git a/composables/useGeopfPolling.ts b/composables/useGeopfPolling.ts
new file mode 100644
index 000000000..2e8f121d3
--- /dev/null
+++ b/composables/useGeopfPolling.ts
@@ -0,0 +1,12 @@
+import { useIntervalFn } from '@vueuse/core'
+import type { ComputedRef } from 'vue'
+
+const POLL_INTERVAL_MS = 8000
+
+// Polls for as long as a push or pull is pending. `useIntervalFn` pauses itself on
+// scope dispose.
+export function useGeopfPolling(pending: ComputedRef, tick: () => Promise) {
+ const { pause, resume } = useIntervalFn(tick, POLL_INTERVAL_MS, { immediate: false })
+
+ watch(pending, isPending => isPending ? resume() : pause(), { immediate: true })
+}
diff --git a/datagouv-components/src/components/ResourceAccordion/EditButton.vue b/datagouv-components/src/components/ResourceAccordion/EditButton.vue
index 4f8554255..b785d63a4 100644
--- a/datagouv-components/src/components/ResourceAccordion/EditButton.vue
+++ b/datagouv-components/src/components/ResourceAccordion/EditButton.vue
@@ -4,7 +4,8 @@
icon-only
:icon="RiPencilLine"
color="warning"
- :title="t('Éditer le fichier')"
+ :disabled="!!disabledReason"
+ :title="disabledReason ?? t('Éditer le fichier')"
data-testid="edit-button"
/>
@@ -19,10 +20,12 @@ type Props = {
datasetId: string
isCommunityResource?: boolean
resourceId: string
+ disabledReason?: string | null
}
const props = withDefaults(defineProps(), {
isCommunityResource: false,
+ disabledReason: null,
})
const { t } = useTranslation()
diff --git a/datagouv-components/src/components/ResourceAccordion/ResourceAccordion.vue b/datagouv-components/src/components/ResourceAccordion/ResourceAccordion.vue
index af35894a3..b922a28c3 100644
--- a/datagouv-components/src/components/ResourceAccordion/ResourceAccordion.vue
+++ b/datagouv-components/src/components/ResourceAccordion/ResourceAccordion.vue
@@ -173,6 +173,7 @@
:dataset-id="dataset.id"
:resource-id="resource.id"
:is-community-resource="isCommunityResource"
+ :disabled-reason="editDisabledReason"
size="xs"
/>
@@ -327,7 +328,7 @@ import { trackEvent } from '../../functions/matomo'
import CopyButton from '../CopyButton.vue'
import { useComponentsConfig } from '../../config'
import { getOwnerName } from '../../functions/owned'
-import { getResourceFormatIcon, getResourceTitleId, detectOgcService, getResourceExternalUrl, getResourceFilesize, isImagePreviewFormat } from '../../functions/resources'
+import { getResourceFormatIcon, getResourceTitleId, detectOgcService, getResourceExternalUrl, getResourceFilesize, isGeopfSynced, isImagePreviewFormat } from '../../functions/resources'
import BrandedButton from '../BrandedButton.vue'
import { useTranslation } from '../../composables/useTranslation'
import { useHasTabularData } from '../../composables/useHasTabularData'
@@ -382,6 +383,10 @@ const hasPmtiles = computed(() => {
return props.resource.extras['analysis:parsing:pmtiles_url'] || props.resource.format === 'pmtiles'
})
+const editDisabledReason = computed(() => {
+ return isGeopfSynced(props.resource) ? t('Vous ne pouvez pas modifier cette ressource car elle est synchronisée avec cartes.gouv.fr') : null
+})
+
const hasDatafairPreview = computed(() => {
// Checks if there are the corresponding extras for a datafair preview.
// Limited only to datasets published by certified organizations since it will load an iframe.
diff --git a/datagouv-components/src/functions/resources.ts b/datagouv-components/src/functions/resources.ts
index 3e0b17c1e..b3f95f0b1 100644
--- a/datagouv-components/src/functions/resources.ts
+++ b/datagouv-components/src/functions/resources.ts
@@ -179,6 +179,10 @@ export function getResourceExternalUrl(dataset: Dataset | DatasetV2 | Omit
metrics: { views: number }
harvest: Harvest
+ geopf: GeopfResourceMetadata | null
filesize: number | null
filetype: ResourceFileType
format: string
diff --git a/nuxt.config.ts b/nuxt.config.ts
index cc0a6ff23..58b7e532a 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -185,6 +185,8 @@ export default defineNuxtConfig({
resourceFileUploadChunk: 20 * 1000 * 1000,
maxSortableFiles: 50,
+ geopfEnabled: false,
+
maxNumberOfDatasetsForDataserviceUpdate: 200,
captcheta: {
diff --git a/pages/admin.vue b/pages/admin.vue
index 70e150617..11be0fc78 100644
--- a/pages/admin.vue
+++ b/pages/admin.vue
@@ -44,7 +44,7 @@
@@ -54,6 +54,7 @@
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import AdminSidebarMenu from '~/components/AdminSidebar/AdminSidebarMenu/AdminSidebarMenu.vue'
import type { OrganizationReference } from '@datagouv/components-next'
+import type { RouteLocationNormalizedLoaded } from 'vue-router'
definePageMeta({
layout: 'fluid',
@@ -78,6 +79,12 @@ useSeoMeta({ title: 'Admin' })
const { organizations, users } = useCurrentOwned()
const isSiteAdmin = computed(() => me.value.roles?.includes('admin') || false)
+
+// Stable reference: NuxtPage compares page-key by reference, so an inline function here
+// would re-trigger the loading indicator on every render of this component.
+function pageKey(route: RouteLocationNormalizedLoaded) {
+ return route.fullPath
+}