@datagouv/components-next 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{Control-b-43UT2B.js → Control-B9WQxlpJ.js} +10 -10
- package/dist/{Datafair.client-BrjISlaZ.js → Datafair.client-D_6yzJUG.js} +9 -9
- package/dist/{Event-py_AAwJY.js → Event-iXDrp-fy.js} +15 -15
- package/dist/{Image-BHQYZWzH.js → Image-mNMBOofs.js} +22 -22
- package/dist/ImagePreview.client-B0iPhFm3.js +36 -0
- package/dist/{JsonPreview.client-DivFAnbN.js → JsonPreview.client-DSl1kiwC.js} +2 -2
- package/dist/{Map-CtP7leHl.js → Map-BfqKMw0S.js} +165 -165
- package/dist/{MapContainer.client-Cq8tNLZZ.js → MapContainer.client-BSfpk_rT.js} +17 -17
- package/dist/{OSM-gdDSIsDA.js → OSM-CcrUhItr.js} +1 -1
- package/dist/{PdfPreview.client-BNBgLsK2.js → PdfPreview.client-BANFeM6K.js} +438 -427
- package/dist/{Pmtiles.client-CQi5Rw8W.js → Pmtiles.client-DH9ZrsZR.js} +6177 -5967
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-I8EZCgzp.js +60 -0
- package/dist/{ScaleLine-hG-FhNqy.js → ScaleLine-VGQ7nkMS.js} +2 -2
- package/dist/{Tile-D6rFonpT.js → Tile-CSpn0Ca8.js} +47 -47
- package/dist/{TileImage-cfrEgz5y.js → TileImage-CpbaEFwd.js} +44 -44
- package/dist/{View-vp7vDb9q.js → View-BYBpt-AU.js} +58 -58
- package/dist/{XmlPreview.client-XCsNvnsZ.js → XmlPreview.client-KCB5mwVf.js} +3 -3
- package/dist/{common-C8efyyuc.js → common-BoeTUnDZ.js} +58 -58
- package/dist/components-next.css +1 -1
- package/dist/components-next.js +199 -172
- package/dist/components.css +1 -1
- package/dist/{index-CXUamc8Z.js → index-DHWzX5vK.js} +4668 -4466
- package/dist/{leaflet-src-BRKv9Ksv.js → leaflet-src-pVQfonBI.js} +1 -1
- package/dist/{main-DKu5SOAl.js → main-BDcYkhOc.js} +41684 -33432
- package/dist/{proj-CsjVeojN.js → proj-DxGbobp7.js} +110 -110
- package/dist/{tilecoord-CPcQJnrF.js → tilecoord-DvX7WWiA.js} +94 -94
- package/dist/{vue3-xml-viewer.common-BIaEIFYG.js → vue3-xml-viewer.common-Bdcn54ST.js} +1 -1
- package/package.json +28 -27
- package/src/components/ActivityList/ActivityList.vue +21 -12
- package/src/components/AppLink.vue +3 -0
- package/src/components/BannerAction.vue +6 -2
- package/src/components/Chart/ChartViewer.vue +111 -17
- package/src/components/Chart/ChartViewerWrapper.vue +28 -16
- package/src/components/ChartCard.vue +112 -0
- package/src/components/CopyButton.vue +6 -2
- package/src/components/DataserviceCard.vue +23 -6
- package/src/components/DataserviceQuality.vue +82 -0
- package/src/components/DataserviceQualityInline.vue +31 -0
- package/src/components/DataserviceQualityTooltipContent.vue +79 -0
- package/src/components/DatasetCard.vue +17 -6
- package/src/components/DatasetInformation/DatasetTemporalitySection.vue +7 -4
- package/src/components/DatasetQuality.vue +11 -11
- package/src/components/DatasetQualityInline.vue +2 -2
- package/src/components/DatasetQualityTooltipContent.vue +1 -1
- package/src/components/DiscussionMessageCard.vue +14 -15
- package/src/components/Form/SchemaSelect.vue +2 -0
- package/src/components/FormattedDate.vue +69 -0
- package/src/components/LoadingBlock.vue +4 -1
- package/src/components/ObjectCard.vue +4 -1
- package/src/components/ObjectCardOwner.vue +13 -1
- package/src/components/Pagination.vue +18 -58
- package/src/components/Placeholder.vue +3 -2
- package/src/components/PostCard.vue +13 -15
- package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
- package/src/components/ResourceAccordion/Downloads.vue +19 -7
- package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
- package/src/components/ResourceAccordion/MapContainer.client.vue +1 -8
- package/src/components/ResourceAccordion/Metadata.vue +21 -5
- package/src/components/ResourceAccordion/PdfPreview.client.vue +36 -12
- package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
- package/src/components/ResourceAccordion/Pmtiles.client.vue +27 -18
- package/src/components/ResourceAccordion/Preview.vue +8 -4
- package/src/components/ResourceAccordion/PreviewWrapper.vue +17 -10
- package/src/components/ResourceAccordion/ResourceAccordion.vue +29 -9
- package/src/components/ResourceExplorer/ResourceExplorer.vue +105 -209
- package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +79 -0
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +104 -71
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +136 -183
- package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
- package/src/components/ResourceExplorer/ResourceSelector.vue +24 -86
- package/src/components/ResourceExplorer/ResourceViewerHeader.vue +124 -0
- package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +96 -0
- package/src/components/ResourceListItem.vue +168 -0
- package/src/components/ReuseCard.vue +18 -6
- package/src/components/ReuseDetails.vue +2 -2
- package/src/components/ReuseHorizontalCard.vue +4 -3
- package/src/components/SchemaCard.vue +10 -1
- package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
- package/src/components/Search/GlobalSearch.vue +12 -8
- package/src/components/Search/SearchInput.vue +1 -1
- package/src/components/Search/Sidemenu.vue +4 -3
- package/src/components/SmallChart.vue +11 -10
- package/src/components/StatBox.vue +13 -5
- package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
- package/src/components/TabularExplorer/TabularCell.vue +7 -8
- package/src/components/TabularExplorer/TabularCellPopover.vue +19 -3
- package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
- package/src/components/TabularExplorer/TabularDateField.vue +41 -0
- package/src/components/TabularExplorer/TabularDateFilter.vue +403 -0
- package/src/components/TabularExplorer/TabularExplorer.vue +122 -913
- package/src/components/TabularExplorer/TabularFilterContent.vue +33 -25
- package/src/components/TabularExplorer/TabularFilterPopover.vue +7 -7
- package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
- package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
- package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
- package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
- package/src/components/TabularExplorer/TabularTable.vue +484 -0
- package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
- package/src/components/TabularExplorer/types.ts +32 -20
- package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
- package/src/components/TabularExplorer/useTabularContext.ts +82 -0
- package/src/components/TopicCard.vue +15 -14
- package/src/composables/useActiveDescendant.ts +5 -0
- package/src/composables/useDatasetResources.ts +164 -0
- package/src/composables/useHasTabularData.ts +2 -1
- package/src/composables/useResizable.ts +46 -0
- package/src/composables/useResourceCapabilities.ts +47 -39
- package/src/composables/useTranslation.ts +24 -9
- package/src/config.ts +1 -0
- package/src/functions/charts.ts +32 -1
- package/src/functions/dates.ts +26 -7
- package/src/functions/helpers.ts +15 -0
- package/src/functions/metrics.ts +57 -56
- package/src/functions/owned.ts +21 -0
- package/src/functions/paginate.ts +46 -0
- package/src/functions/resourceCapabilities.ts +34 -0
- package/src/functions/resources.ts +83 -0
- package/src/functions/schemas.ts +27 -0
- package/src/functions/scroll.ts +20 -0
- package/src/functions/tabular.ts +119 -4
- package/src/functions/tabularApi.ts +1 -43
- package/src/main.ts +32 -6
- package/src/types/dataservices.ts +15 -0
- package/src/types/organizations.ts +10 -0
- package/src/types/reuses.ts +12 -1
- package/src/types/search.ts +2 -2
- package/src/types/visualizations.ts +5 -0
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-C3dDzrIr.js +0 -61
- /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
- /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
- /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
package/src/functions/dates.ts
CHANGED
|
@@ -2,6 +2,26 @@ import { useTranslation } from '../composables/useTranslation'
|
|
|
2
2
|
|
|
3
3
|
const SECONDS_IN_A_DAY = 3600 * 24
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* `2026`, `2026-04` and `2026-04-24` carry no time of day. `new Date()` reads them as
|
|
7
|
+
* midnight UTC, an instant they never claimed, and then converts it: a reader west of
|
|
8
|
+
* UTC gets the day before — and with it the month, and sometimes the year.
|
|
9
|
+
*/
|
|
10
|
+
export const PLAIN_DATE = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A date value read as the day it names. A plain date is built in local time, where no
|
|
14
|
+
* conversion can move it; anything else is an instant and keeps being read as one.
|
|
15
|
+
*/
|
|
16
|
+
export function parseDateValue(value: Date | string | null | undefined): Date | null {
|
|
17
|
+
if (!value) return null
|
|
18
|
+
const plain = typeof value === 'string' ? value.match(PLAIN_DATE) : null
|
|
19
|
+
const date = plain
|
|
20
|
+
? new Date(Number(plain[1]), Number(plain[2] ?? 1) - 1, Number(plain[3] ?? 1))
|
|
21
|
+
: new Date(value)
|
|
22
|
+
return isNaN(date.getTime()) ? null : date
|
|
23
|
+
}
|
|
24
|
+
|
|
5
25
|
export function useFormatDate() {
|
|
6
26
|
const { t, locale } = useTranslation()
|
|
7
27
|
|
|
@@ -9,11 +29,9 @@ export function useFormatDate() {
|
|
|
9
29
|
if (!date) {
|
|
10
30
|
return ''
|
|
11
31
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
return new Intl.DateTimeFormat(locale, options).format(date)
|
|
32
|
+
// An explicit `dateStyle: undefined` overrides the default, which is how callers ask
|
|
33
|
+
// for `year`/`month` components — `dateStyle` and those cannot be combined.
|
|
34
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'long', ...options }).format(new Date(date))
|
|
17
35
|
}
|
|
18
36
|
|
|
19
37
|
/**
|
|
@@ -77,8 +95,9 @@ export function useFormatDate() {
|
|
|
77
95
|
dateWithoutTime.setHours(0)
|
|
78
96
|
dateWithoutTime.setMinutes(0)
|
|
79
97
|
dateWithoutTime.setSeconds(0)
|
|
80
|
-
|
|
81
|
-
|
|
98
|
+
// `getTime()` returns milliseconds, `SECONDS_IN_A_DAY` is in seconds
|
|
99
|
+
const diffInSeconds = Math.abs(dateWithoutTime.getTime() - today.getTime()) / 1000
|
|
100
|
+
if (diffInSeconds >= SECONDS_IN_A_DAY * 30) {
|
|
82
101
|
return t('le {date}', { date: formatDate(date, options) })
|
|
83
102
|
}
|
|
84
103
|
return formatFromNow(date)
|
package/src/functions/helpers.ts
CHANGED
|
@@ -38,6 +38,21 @@ export const summarize = (val: number, fractionDigits = 0) => {
|
|
|
38
38
|
return `${toFixedIfNotZero(val)}Y`
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Guards any value that ends up in an `href`: only absolute http(s) URLs are allowed.
|
|
42
|
+
// Some of these URLs come from resource extras, which are freely writable by the
|
|
43
|
+
// resource owner, so an unguarded value could carry a `javascript:` or `data:` scheme
|
|
44
|
+
// and execute in the platform origin when a visitor clicks the link.
|
|
45
|
+
export const isSafeHttpUrl = (value: unknown): value is string => {
|
|
46
|
+
if (typeof value !== 'string') return false
|
|
47
|
+
try {
|
|
48
|
+
const { protocol } = new URL(value)
|
|
49
|
+
return protocol === 'http:' || protocol === 'https:'
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
41
56
|
export const escapeCsvValue = (value: string | number | null | undefined): string => {
|
|
42
57
|
if (value === null || value === undefined || value === '') {
|
|
43
58
|
return ''
|
package/src/functions/metrics.ts
CHANGED
|
@@ -31,49 +31,73 @@ export type ReuseMetrics = {
|
|
|
31
31
|
visitsTotal: number
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
type MonthlyVisitsRow = { metric_month: string, monthly_visit: number }
|
|
35
|
+
type MonthlyDatasetRow = MonthlyVisitsRow & { monthly_download_resource: number }
|
|
36
|
+
type MonthlyOrganizationRow = {
|
|
37
|
+
metric_month: string
|
|
38
|
+
monthly_download_resource: number
|
|
39
|
+
monthly_visit_dataservice: number
|
|
40
|
+
monthly_visit_dataset: number
|
|
41
|
+
monthly_visit_reuse: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type TotalVisitsRow = { visit: number }
|
|
45
|
+
type TotalDatasetRow = TotalVisitsRow & { download_resource: number }
|
|
46
|
+
type TotalOrganizationRow = {
|
|
47
|
+
download_resource: number
|
|
48
|
+
visit_dataservice: number
|
|
49
|
+
visit_dataset: number
|
|
50
|
+
visit_reuse: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The metrics API is a separate service that answers over the network, so a call fails in
|
|
55
|
+
* three ways: the request itself, an error status, or a body that isn't the expected page
|
|
56
|
+
* of rows. Rejecting on the last two turns them into the same failure as the first, which
|
|
57
|
+
* callers already hand to `useAsyncData` — reading `page.data` straight would instead throw
|
|
58
|
+
* a `TypeError` a few lines later, naming a symptom rather than the service.
|
|
59
|
+
*/
|
|
60
|
+
async function fetchMetrics<Row>(url: string): Promise<Array<Row>> {
|
|
61
|
+
const response = await fetch(url)
|
|
62
|
+
if (!response.ok) throw new Error(`The metrics API answered ${response.status} for ${url}`)
|
|
63
|
+
|
|
64
|
+
const page: { data?: Array<Row> } = await response.json()
|
|
65
|
+
if (!Array.isArray(page?.data)) throw new Error(`The metrics API answered an unexpected body for ${url}`)
|
|
66
|
+
|
|
67
|
+
return page.data
|
|
68
|
+
}
|
|
69
|
+
|
|
34
70
|
/**
|
|
35
71
|
* There is only one metrics API endpoint to get these 3 values.
|
|
36
72
|
* The rest of the metrics aren't stored yet at the organization level
|
|
37
73
|
*/
|
|
38
74
|
export async function getOrganizationMetrics(oid: string, metricsApi: string): Promise<OrganizationMetrics> {
|
|
39
75
|
// Fetching last 12 months
|
|
40
|
-
const
|
|
41
|
-
const page = await response.json()
|
|
76
|
+
const rows = await fetchMetrics<MonthlyOrganizationRow>(`${metricsApi}/api/organizations/data/?organization_id__exact=${oid}&metric_month__sort=desc&page_size=12`)
|
|
42
77
|
|
|
43
78
|
const dataservicesViews: Record<string, number> = {}
|
|
44
79
|
const datasetsViews: Record<string, number> = {}
|
|
45
80
|
const downloads: Record<string, number> = {}
|
|
46
81
|
const reusesViews: Record<string, number> = {}
|
|
47
82
|
|
|
48
|
-
for (const { metric_month, monthly_download_resource, monthly_visit_dataservice, monthly_visit_dataset, monthly_visit_reuse } of
|
|
83
|
+
for (const { metric_month, monthly_download_resource, monthly_visit_dataservice, monthly_visit_dataset, monthly_visit_reuse } of rows) {
|
|
49
84
|
dataservicesViews[metric_month] = monthly_visit_dataservice
|
|
50
85
|
datasetsViews[metric_month] = monthly_visit_dataset
|
|
51
86
|
downloads[metric_month] = monthly_download_resource
|
|
52
87
|
reusesViews[metric_month] = monthly_visit_reuse
|
|
53
88
|
}
|
|
54
89
|
// Fetching totals
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
let dataservicesViewsTotal = 0
|
|
59
|
-
let datasetsViewsTotal = 0
|
|
60
|
-
let downloadsTotal = 0
|
|
61
|
-
let reusesViewsTotal = 0
|
|
62
|
-
if (page.data[0]) {
|
|
63
|
-
dataservicesViewsTotal = totalPage.data[0].visit_dataservice
|
|
64
|
-
datasetsViewsTotal = totalPage.data[0].visit_dataset
|
|
65
|
-
downloadsTotal = totalPage.data[0].download_resource
|
|
66
|
-
reusesViewsTotal = totalPage.data[0].visit_reuse
|
|
67
|
-
}
|
|
90
|
+
const total = (await fetchMetrics<TotalOrganizationRow>(`${metricsApi}/api/organizations_total/data/?organization_id__exact=${oid}`))[0]
|
|
91
|
+
|
|
68
92
|
return {
|
|
69
93
|
downloads,
|
|
70
|
-
downloadsTotal,
|
|
94
|
+
downloadsTotal: total?.download_resource ?? 0,
|
|
71
95
|
reusesViews,
|
|
72
|
-
reusesViewsTotal,
|
|
96
|
+
reusesViewsTotal: total?.visit_reuse ?? 0,
|
|
73
97
|
dataservicesViews,
|
|
74
|
-
dataservicesViewsTotal,
|
|
98
|
+
dataservicesViewsTotal: total?.visit_dataservice ?? 0,
|
|
75
99
|
datasetsViews,
|
|
76
|
-
datasetsViewsTotal,
|
|
100
|
+
datasetsViewsTotal: total?.visit_dataset ?? 0,
|
|
77
101
|
}
|
|
78
102
|
}
|
|
79
103
|
|
|
@@ -89,33 +113,24 @@ export function createOrganizationMetricsUrl(datasetsViews: Record<string, numbe
|
|
|
89
113
|
|
|
90
114
|
export async function getDatasetMetrics(datasetId: string, metricsApi: string): Promise<DatasetMetrics> {
|
|
91
115
|
// Fetching last 12 months
|
|
92
|
-
const
|
|
93
|
-
const page = await response.json()
|
|
116
|
+
const rows = await fetchMetrics<MonthlyDatasetRow>(`${metricsApi}/api/datasets/data/?dataset_id__exact=${datasetId}&metric_month__sort=desc&page_size=12`)
|
|
94
117
|
|
|
95
118
|
const visits: Record<string, number> = {}
|
|
96
119
|
const downloads: Record<string, number> = {}
|
|
97
120
|
|
|
98
|
-
for (const { metric_month, monthly_visit, monthly_download_resource } of
|
|
121
|
+
for (const { metric_month, monthly_visit, monthly_download_resource } of rows) {
|
|
99
122
|
visits[metric_month] = monthly_visit
|
|
100
123
|
downloads[metric_month] = monthly_download_resource
|
|
101
124
|
}
|
|
102
125
|
|
|
103
126
|
// Fetching totals
|
|
104
|
-
const
|
|
105
|
-
const totalPage = await totalResponse.json()
|
|
106
|
-
|
|
107
|
-
let visitsTotal = 0
|
|
108
|
-
let downloadsTotal = 0
|
|
109
|
-
if (totalPage.data[0]) {
|
|
110
|
-
visitsTotal = totalPage.data[0].visit
|
|
111
|
-
downloadsTotal = totalPage.data[0].download_resource
|
|
112
|
-
}
|
|
127
|
+
const total = (await fetchMetrics<TotalDatasetRow>(`${metricsApi}/api/datasets_total/data/?dataset_id__exact=${datasetId}`))[0]
|
|
113
128
|
|
|
114
129
|
return {
|
|
115
130
|
visits,
|
|
116
|
-
visitsTotal,
|
|
131
|
+
visitsTotal: total?.visit ?? 0,
|
|
117
132
|
downloads,
|
|
118
|
-
downloadsTotal,
|
|
133
|
+
downloadsTotal: total?.download_resource ?? 0,
|
|
119
134
|
}
|
|
120
135
|
}
|
|
121
136
|
|
|
@@ -151,52 +166,38 @@ export async function createDatasetsForOrganizationMetricsUrl(organizationId: st
|
|
|
151
166
|
|
|
152
167
|
export async function getDataserviceMetrics(dataserviceId: string, metricsApi: string): Promise<DataserviceMetrics> {
|
|
153
168
|
// Fetching last 12 months
|
|
154
|
-
const
|
|
155
|
-
const page = await response.json()
|
|
169
|
+
const rows = await fetchMetrics<MonthlyVisitsRow>(`${metricsApi}/api/dataservices/data/?dataservice_id__exact=${dataserviceId}&metric_month__sort=desc&page_size=12`)
|
|
156
170
|
|
|
157
171
|
const visits: Record<string, number> = {}
|
|
158
172
|
|
|
159
|
-
for (const { metric_month, monthly_visit } of
|
|
173
|
+
for (const { metric_month, monthly_visit } of rows) {
|
|
160
174
|
visits[metric_month] = monthly_visit
|
|
161
175
|
}
|
|
162
176
|
|
|
163
177
|
// Fetching totals
|
|
164
|
-
const
|
|
165
|
-
const totalPage = await totalResponse.json()
|
|
166
|
-
|
|
167
|
-
let visitsTotal = 0
|
|
168
|
-
if (totalPage.data[0]) {
|
|
169
|
-
visitsTotal = totalPage.data[0].visit
|
|
170
|
-
}
|
|
178
|
+
const total = (await fetchMetrics<TotalVisitsRow>(`${metricsApi}/api/dataservices_total/data/?dataservice_id__exact=${dataserviceId}`))[0]
|
|
171
179
|
|
|
172
180
|
return {
|
|
173
181
|
visits,
|
|
174
|
-
visitsTotal,
|
|
182
|
+
visitsTotal: total?.visit ?? 0,
|
|
175
183
|
}
|
|
176
184
|
}
|
|
177
185
|
|
|
178
186
|
export async function getReuseMetrics(reuseId: string, metricsApi: string): Promise<ReuseMetrics> {
|
|
179
187
|
// Fetching last 12 months
|
|
180
|
-
const
|
|
181
|
-
const page = await response.json()
|
|
188
|
+
const rows = await fetchMetrics<MonthlyVisitsRow>(`${metricsApi}/api/reuses/data/?reuse_id__exact=${reuseId}&metric_month__sort=desc&page_size=12`)
|
|
182
189
|
|
|
183
190
|
const visits: Record<string, number> = {}
|
|
184
191
|
|
|
185
|
-
for (const { metric_month, monthly_visit } of
|
|
192
|
+
for (const { metric_month, monthly_visit } of rows) {
|
|
186
193
|
visits[metric_month] = monthly_visit
|
|
187
194
|
}
|
|
188
195
|
|
|
189
196
|
// Fetching totals
|
|
190
|
-
const
|
|
191
|
-
const totalPage = await totalResponse.json()
|
|
192
|
-
|
|
193
|
-
let visitsTotal = 0
|
|
194
|
-
if (totalPage.data[0]) {
|
|
195
|
-
visitsTotal = totalPage.data[0].visit
|
|
196
|
-
}
|
|
197
|
+
const total = (await fetchMetrics<TotalVisitsRow>(`${metricsApi}/api/reuses_total/data/?reuse_id__exact=${reuseId}`))[0]
|
|
197
198
|
|
|
198
199
|
return {
|
|
199
200
|
visits,
|
|
200
|
-
visitsTotal,
|
|
201
|
+
visitsTotal: total?.visit ?? 0,
|
|
201
202
|
}
|
|
202
203
|
}
|
package/src/functions/owned.ts
CHANGED
|
@@ -19,3 +19,24 @@ export function getOwnerPage(owned: Owned): string | null {
|
|
|
19
19
|
}
|
|
20
20
|
return null // Not supposed to exist but it does...
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
function escapeHtml(value: string): string {
|
|
24
|
+
return value
|
|
25
|
+
.replace(/&/g, '&')
|
|
26
|
+
.replace(/</g, '<')
|
|
27
|
+
.replace(/>/g, '>')
|
|
28
|
+
.replace(/"/g, '"')
|
|
29
|
+
.replace(/'/g, ''')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getMapAttribution(owned: Owned): string {
|
|
33
|
+
const name = getOwnerName(owned)
|
|
34
|
+
const page = getOwnerPage(owned)
|
|
35
|
+
if (!name && !page)
|
|
36
|
+
return ''
|
|
37
|
+
// The owner name is free text any user controls (organization name, or a
|
|
38
|
+
// profile's first/last name). maplibre-gl injects this attribution string
|
|
39
|
+
// into the DOM as HTML, and its own attribution sanitizer is bypassable via
|
|
40
|
+
// mutation XSS, so every interpolated value has to be escaped here.
|
|
41
|
+
return `© <a href="${escapeHtml(page ?? '')}" target="_blank">${escapeHtml(name)}</a>`
|
|
42
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export const PAGES_AROUND = 3
|
|
2
|
+
|
|
3
|
+
function range(size: number, startAt: number) {
|
|
4
|
+
return [...Array(size).keys()].map(i => i + startAt)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Brings whatever the caller holds as a current page back to a page that exists.
|
|
9
|
+
* The page number usually comes from a query string, so it can be anything:
|
|
10
|
+
* `?page=abc` (NaN), `?page=-5`, `?page=1.5`, or a stale link to a page far past
|
|
11
|
+
* the last one.
|
|
12
|
+
*/
|
|
13
|
+
export function clampPage(page: number, pageCount: number) {
|
|
14
|
+
if (!Number.isFinite(page)) {
|
|
15
|
+
return 1
|
|
16
|
+
}
|
|
17
|
+
return Math.min(Math.max(Math.trunc(page), 1), Math.max(pageCount, 1))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The middle window of the pagination: the pages around the current one, with
|
|
22
|
+
* `null` marking an ellipsis. The first and last pages are rendered by the
|
|
23
|
+
* component itself and are never part of this window.
|
|
24
|
+
*/
|
|
25
|
+
export function getVisiblePages(currentPage: number, pageCount: number) {
|
|
26
|
+
if (pageCount <= 2) {
|
|
27
|
+
return []
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const page = clampPage(currentPage, pageCount)
|
|
31
|
+
const size = Math.min(
|
|
32
|
+
PAGES_AROUND * 2 + 1,
|
|
33
|
+
pageCount - 2, // the first and last pages are rendered outside of the window
|
|
34
|
+
PAGES_AROUND + page - 1, // don't reach below page 2
|
|
35
|
+
PAGES_AROUND + pageCount - page, // don't reach past the last page
|
|
36
|
+
)
|
|
37
|
+
const pagination: Array<number | null> = range(size, Math.max(page - PAGES_AROUND, 2))
|
|
38
|
+
|
|
39
|
+
if (!pagination.includes(2)) {
|
|
40
|
+
pagination.unshift(null)
|
|
41
|
+
}
|
|
42
|
+
if (!pagination.includes(pageCount - 1)) {
|
|
43
|
+
pagination.push(null)
|
|
44
|
+
}
|
|
45
|
+
return pagination
|
|
46
|
+
}
|
|
@@ -1,4 +1,35 @@
|
|
|
1
1
|
import type { Resource, WfsMetadata, OgcLayerInfo } from '../types/resources'
|
|
2
|
+
import { isSafeHttpUrl } from './helpers'
|
|
3
|
+
|
|
4
|
+
const GENERATED_FORMATS = ['parquet', 'pmtiles', 'geojson']
|
|
5
|
+
|
|
6
|
+
export type GeneratedFormat = {
|
|
7
|
+
url: string
|
|
8
|
+
size: number | undefined
|
|
9
|
+
format: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getGeneratedFormats(resource: Pick<Resource, 'extras' | 'id'>, tabularApiUrl: string | undefined): GeneratedFormat[] {
|
|
13
|
+
const formats: GeneratedFormat[] = GENERATED_FORMATS.flatMap((format) => {
|
|
14
|
+
const url = resource.extras[`analysis:parsing:${format}_url`]
|
|
15
|
+
// The URL is read verbatim from owner-writable extras: drop any entry that is not
|
|
16
|
+
// a plain http(s) URL so a `javascript:`/`data:` value never reaches an href.
|
|
17
|
+
if (!isSafeHttpUrl(url)) return []
|
|
18
|
+
return [{
|
|
19
|
+
url,
|
|
20
|
+
size: resource.extras[`analysis:parsing:${format}_size`] as number | undefined,
|
|
21
|
+
format,
|
|
22
|
+
}]
|
|
23
|
+
})
|
|
24
|
+
if ('analysis:parsing:parsing_table' in resource.extras) {
|
|
25
|
+
formats.push({
|
|
26
|
+
url: `${tabularApiUrl}/api/resources/${resource.id}/data/json/`,
|
|
27
|
+
size: undefined,
|
|
28
|
+
format: 'json',
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
return formats
|
|
32
|
+
}
|
|
2
33
|
|
|
3
34
|
const WFS_EXPORT_FORMATS = [
|
|
4
35
|
{
|
|
@@ -46,6 +77,9 @@ export function getWfsExportFormats(resource: Pick<Resource, 'extras' | 'url'>)
|
|
|
46
77
|
const outputFormats = wfsMetadata.output_formats.map((format: string) => format.toLowerCase())
|
|
47
78
|
const layer = wfsMetadata.detected_layer
|
|
48
79
|
if (!layer) return []
|
|
80
|
+
// Every download URL below is built on top of `resource.url`, so its scheme is the one
|
|
81
|
+
// of `resource.url`: guard it once here rather than on each of the built URLs.
|
|
82
|
+
if (!isSafeHttpUrl(resource.url)) return []
|
|
49
83
|
const formats = WFS_EXPORT_FORMATS.filter(format => outputFormats.includes(format.mimetype.toLowerCase()))
|
|
50
84
|
.map(format => ({
|
|
51
85
|
url: buildWfsDownloadUrl(resource.url, wfsMetadata, format, layer),
|
|
@@ -84,6 +84,25 @@ export function getResourceFormatIcon(format: string): Component | null {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// Badge colors for the resource icon, keyed on the same format categorization as
|
|
88
|
+
// getResourceFormatIcon so tabular/geo/code/documentation stay consistent with the icon.
|
|
89
|
+
export function getResourceIconColor(format?: string | null): string {
|
|
90
|
+
const icon = format ? getResourceFormatIcon(format) : null
|
|
91
|
+
switch (icon) {
|
|
92
|
+
case Table:
|
|
93
|
+
return 'bg-[#c3fad5] text-[#18753c]'
|
|
94
|
+
case RiMap2Line:
|
|
95
|
+
case RiEarthLine:
|
|
96
|
+
return 'bg-[#e6eefe] text-[#0063cb]'
|
|
97
|
+
case Code:
|
|
98
|
+
return 'bg-[#fce164] text-[#716043]'
|
|
99
|
+
case Documentation:
|
|
100
|
+
return 'bg-[#fee7fc] text-[#6e445a]'
|
|
101
|
+
default:
|
|
102
|
+
return 'bg-gray-100 text-gray-plain'
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
87
106
|
export function getResourceTitleId(resource: Resource) {
|
|
88
107
|
return 'resource-' + resource.id + '-title'
|
|
89
108
|
}
|
|
@@ -105,14 +124,29 @@ export const getResourceLabel = (type: ResourceType, count?: number) => {
|
|
|
105
124
|
}
|
|
106
125
|
return t('Fichiers principaux')
|
|
107
126
|
case 'documentation':
|
|
127
|
+
if (typeof count === 'number') {
|
|
128
|
+
return t('Aucun fichier de documentation | 1 fichier de documentation | {n} fichiers de documentation', count)
|
|
129
|
+
}
|
|
108
130
|
return t('Documentation')
|
|
109
131
|
case 'update':
|
|
132
|
+
if (typeof count === 'number') {
|
|
133
|
+
return t('Aucune mise à jour | 1 mise à jour | {n} mises à jour', count)
|
|
134
|
+
}
|
|
110
135
|
return t('Mise à jour')
|
|
111
136
|
case 'api':
|
|
137
|
+
if (typeof count === 'number') {
|
|
138
|
+
return t('Aucune API | 1 API | {n} API', count)
|
|
139
|
+
}
|
|
112
140
|
return t('API')
|
|
113
141
|
case 'code':
|
|
142
|
+
if (typeof count === 'number') {
|
|
143
|
+
return t('Aucun fichier de code source | 1 fichier de code source | {n} fichiers de code source', count)
|
|
144
|
+
}
|
|
114
145
|
return t('Code source')
|
|
115
146
|
case 'other':
|
|
147
|
+
if (typeof count === 'number') {
|
|
148
|
+
return t('Aucun autre fichier | 1 autre fichier | {n} autres fichiers', count)
|
|
149
|
+
}
|
|
116
150
|
return t('Autre')
|
|
117
151
|
}
|
|
118
152
|
}
|
|
@@ -145,6 +179,14 @@ export function getResourceExternalUrl(dataset: Dataset | DatasetV2 | Omit<Datas
|
|
|
145
179
|
return `${dataset.page}${isCommunityResource(resource) ? '/community-resources' : ''}?resource_id=${resource.id}`
|
|
146
180
|
}
|
|
147
181
|
|
|
182
|
+
export function resolveResourceExternalUrl<R extends Resource | CommunityResource>(
|
|
183
|
+
dataset: Dataset | DatasetV2 | Omit<Dataset, 'resources' | 'community_resources'>,
|
|
184
|
+
resource: R,
|
|
185
|
+
override?: (resource: R) => string,
|
|
186
|
+
): string {
|
|
187
|
+
return override ? override(resource) : getResourceExternalUrl(dataset, resource)
|
|
188
|
+
}
|
|
189
|
+
|
|
148
190
|
export function getResourceFilesize(resource: Resource): null | number {
|
|
149
191
|
if (resource.filesize) return resource.filesize
|
|
150
192
|
if ('analysis:content-length' in resource.extras) return resource.extras['analysis:content-length'] as number
|
|
@@ -152,6 +194,47 @@ export function getResourceFilesize(resource: Resource): null | number {
|
|
|
152
194
|
return null
|
|
153
195
|
}
|
|
154
196
|
|
|
197
|
+
const IMAGE_PREVIEW_FORMATS = ['png', 'jpg', 'jpeg', 'jpe', 'gif', 'webp'] as const
|
|
198
|
+
|
|
199
|
+
export function isImagePreviewFormat(format: string | undefined | null): boolean {
|
|
200
|
+
if (!format) return false
|
|
201
|
+
return IMAGE_PREVIEW_FORMATS.includes(format.toLowerCase() as typeof IMAGE_PREVIEW_FORMATS[number])
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Parsing errors are stored as `<step>:<message>`, where `<step>` names the
|
|
205
|
+
// analysis step that failed (e.g. `pmtiles_export`, `geojson_export`).
|
|
206
|
+
|
|
207
|
+
/** The analysis step that failed, or null when there is no parsing error. */
|
|
208
|
+
export function getParsingErrorStep(resource: Resource): string | null {
|
|
209
|
+
const error = resource.extras['analysis:parsing:error']
|
|
210
|
+
if (typeof error !== 'string' || !error) return null
|
|
211
|
+
const [step] = error.split(':', 1)
|
|
212
|
+
return step ?? null
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The parsing error message without its `<step>:` prefix, or null when there is none. */
|
|
216
|
+
export function getParsingErrorMessage(resource: Resource): string | null {
|
|
217
|
+
const error = resource.extras['analysis:parsing:error']
|
|
218
|
+
if (typeof error !== 'string' || !error) return null
|
|
219
|
+
const colon = error.indexOf(':')
|
|
220
|
+
return colon === -1 ? error : error.slice(colon + 1)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Geo/format export steps run *after* the tabular table has been built, so a
|
|
224
|
+
// failure here (e.g. a huge geometry timing out the pmtiles export) leaves the
|
|
225
|
+
// tabular data fully usable and must not disable the tabular explorer.
|
|
226
|
+
const NON_TABULAR_PARSING_ERROR_STEPS = ['pmtiles_export', 'geojson_export']
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Whether the resource's parsing error prevents reading its tabular data.
|
|
230
|
+
* Only errors from the tabular parsing itself are fatal; downstream geo/format
|
|
231
|
+
* export failures are not.
|
|
232
|
+
*/
|
|
233
|
+
export function hasTabularParsingError(resource: Resource): boolean {
|
|
234
|
+
const step = getParsingErrorStep(resource)
|
|
235
|
+
return step !== null && !NON_TABULAR_PARSING_ERROR_STEPS.includes(step)
|
|
236
|
+
}
|
|
237
|
+
|
|
155
238
|
type CorsStatus = 'allowed' | 'blocked' | 'unknown'
|
|
156
239
|
|
|
157
240
|
export const getResourceCorsStatus = (resource: Resource): CorsStatus => {
|
package/src/functions/schemas.ts
CHANGED
|
@@ -31,6 +31,33 @@ export function getSchemaVersion(schema: RegisteredSchema | null) {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// Accents and separators must not stand between a query and a schema: people type
|
|
35
|
+
// "lave linge" for « Lave-linge », or « reparabilite » without its accents
|
|
36
|
+
function normalizeForSearch(value: string): string {
|
|
37
|
+
return value
|
|
38
|
+
.toLowerCase()
|
|
39
|
+
.normalize('NFD')
|
|
40
|
+
.replace(/\p{Diacritic}/gu, '')
|
|
41
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
42
|
+
.trim()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The name is searched along with the title and the description: it is the identifier
|
|
46
|
+
// people know and paste, such as `etalab/indice-durabilite-lave-linge`
|
|
47
|
+
const searchableText = (schema: RegisteredSchema) => normalizeForSearch(`${schema.name} ${schema.title ?? ''} ${schema.description ?? ''}`)
|
|
48
|
+
|
|
49
|
+
const queryTokens = (query: string) => normalizeForSearch(query).split(' ').filter(Boolean)
|
|
50
|
+
|
|
51
|
+
export function schemaMatchesQuery(schema: RegisteredSchema, query: string): boolean {
|
|
52
|
+
// A query made only of punctuation holds no word to match. Callers short-circuit the
|
|
53
|
+
// empty query, so matching everything here would only ever answer that case.
|
|
54
|
+
const tokens = queryTokens(query)
|
|
55
|
+
if (!tokens.length) return false
|
|
56
|
+
|
|
57
|
+
const haystack = searchableText(schema)
|
|
58
|
+
return tokens.every(token => haystack.includes(token))
|
|
59
|
+
}
|
|
60
|
+
|
|
34
61
|
export function findSchemaInCatalog(catalog: Array<RegisteredSchema>, schema: Schema | null): RegisteredSchema | null {
|
|
35
62
|
if (!schema) return null
|
|
36
63
|
return catalog.find(registeredSchema => schema.name === registeredSchema.name) || null
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Leaves room for the sticky elements sitting at the top of the viewport.
|
|
2
|
+
const SCROLL_MARGIN = 100
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bring the top of a block back into view after its content changed (paging,
|
|
6
|
+
* searching, filtering), so the reader restarts at the first result.
|
|
7
|
+
*
|
|
8
|
+
* Does nothing when the top of the block is already on screen: aligning the
|
|
9
|
+
* viewport on a block the reader is already looking at moves the page under
|
|
10
|
+
* them for no reason.
|
|
11
|
+
*/
|
|
12
|
+
export function scrollToBlockTop(block: HTMLElement | null | undefined) {
|
|
13
|
+
if (!block) return
|
|
14
|
+
|
|
15
|
+
const { top } = block.getBoundingClientRect()
|
|
16
|
+
if (top >= 0 && top <= window.innerHeight) return
|
|
17
|
+
|
|
18
|
+
block.style.scrollMarginTop = `${SCROLL_MARGIN}px`
|
|
19
|
+
block.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
20
|
+
}
|