@datagouv/components-next 1.0.2-dev.20 → 1.0.2-dev.200
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/README.md +7 -6
- package/assets/main.css +4 -0
- package/dist/{Control-DuZJdKV_.js → Control-B9WQxlpJ.js} +1 -1
- package/dist/{Datafair.client-Ds3VQrRi.js → Datafair.client-CFTmBFoY.js} +9 -9
- package/dist/{Event--kp8kMdJ.js → Event-iXDrp-fy.js} +31 -31
- package/dist/{Image-34hvypZI.js → Image-mNMBOofs.js} +9 -9
- package/dist/ImagePreview.client-BvDM3qjE.js +36 -0
- package/dist/JsonPreview.client-BP6gPRxf.js +40 -0
- package/dist/{Map-BjUnLyj8.js → Map-BfqKMw0S.js} +1739 -1599
- package/dist/{MapContainer.client-B2BijMx5.js → MapContainer.client-D9N3664r.js} +41 -42
- package/dist/{OSM-s40W6sQ2.js → OSM-CcrUhItr.js} +15 -23
- package/dist/{PdfPreview.client-DJBMzrlC.js → PdfPreview.client-C6A6ZiKl.js} +835 -856
- package/dist/Pmtiles.client-B_sDZoqu.js +26333 -0
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-DGLXBys_.js +60 -0
- package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-VGQ7nkMS.js} +2 -2
- package/dist/{Tile-DbNFNPfU.js → Tile-CSpn0Ca8.js} +160 -163
- package/dist/{TileImage-BsXBxMtq.js → TileImage-CpbaEFwd.js} +143 -131
- package/dist/{View-BR92hTWP.js → View-BYBpt-AU.js} +483 -422
- package/dist/XmlPreview.client-DFQ1T-LN.js +34 -0
- package/dist/{common-PJfpC179.js → common-BoeTUnDZ.js} +52 -52
- package/dist/components-next.css +1 -52
- package/dist/components-next.js +196 -150
- package/dist/components.css +2 -2
- package/dist/{index-BFQX7Ccz.js → index-B_tPYi5Z.js} +34094 -27864
- package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-PeylHk0o.js} +162 -164
- package/dist/{main-vqhkVhVS.js → main-CVOZp_Nx.js} +41939 -73132
- package/dist/{proj-DsetBcW7.js → proj-DxGbobp7.js} +662 -626
- package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
- package/dist/{tilecoord-Db24Px13.js → tilecoord-DvX7WWiA.js} +273 -264
- package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
- package/dist/{vue3-xml-viewer.common-BcNJo35o.js → vue3-xml-viewer.common-CcF3Xd23.js} +1 -1
- package/package.json +47 -38
- package/src/chart.ts +5 -0
- package/src/components/ActivityList/ActivityList.vue +24 -14
- package/src/components/AppLink.vue +3 -0
- package/src/components/BannerAction.vue +6 -2
- package/src/components/Chart/ChartViewer.vue +320 -0
- package/src/components/Chart/ChartViewerWrapper.vue +182 -0
- package/src/components/CopyButton.vue +6 -2
- package/src/components/DataserviceCard.vue +24 -5
- 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 +24 -9
- 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/Listbox.vue +101 -0
- package/src/components/Form/SchemaSelect.vue +2 -0
- package/src/components/FormattedDate.vue +81 -0
- package/src/components/InfiniteLoader.vue +53 -0
- package/src/components/LoadingBlock.vue +4 -1
- package/src/components/ObjectCardHeader.vue +11 -4
- package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
- package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
- package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
- package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
- package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
- package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
- package/src/components/OpenApiViewer/openapi.ts +150 -0
- package/src/components/OrganizationNameWithCertificate.vue +3 -2
- package/src/components/Pagination.vue +25 -62
- package/src/components/PostCard.vue +13 -15
- package/src/components/RadioInput.vue +7 -2
- package/src/components/ResourceAccordion/DataStructure.vue +11 -33
- package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
- package/src/components/ResourceAccordion/Downloads.vue +172 -0
- package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
- package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
- package/src/components/ResourceAccordion/MapContainer.client.vue +2 -11
- package/src/components/ResourceAccordion/Metadata.vue +4 -6
- package/src/components/ResourceAccordion/PdfPreview.client.vue +58 -97
- package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
- package/src/components/ResourceAccordion/Pmtiles.client.vue +28 -13
- package/src/components/ResourceAccordion/Preview.vue +19 -15
- package/src/components/ResourceAccordion/PreviewWrapper.vue +89 -0
- package/src/components/ResourceAccordion/ResourceAccordion.vue +36 -117
- package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
- package/src/components/ResourceExplorer/ResourceExplorer.vue +105 -210
- package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +79 -0
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +104 -71
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +143 -291
- package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
- package/src/components/ResourceExplorer/ResourceSelector.vue +51 -0
- package/src/components/ResourceExplorer/ResourceViewerHeader.vue +122 -0
- package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +94 -0
- package/src/components/ResourceListItem.vue +168 -0
- package/src/components/ReuseCard.vue +28 -9
- package/src/components/ReuseDetails.vue +2 -2
- package/src/components/ReuseHorizontalCard.vue +2 -2
- package/src/components/SchemaCard.vue +10 -1
- package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
- package/src/components/Search/GlobalSearch.vue +229 -123
- package/src/components/Search/SearchInput.vue +5 -4
- 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 +50 -0
- package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
- package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
- package/src/components/TabularExplorer/TabularExplorer.vue +299 -0
- package/src/components/TabularExplorer/TabularFilterContent.vue +362 -0
- package/src/components/TabularExplorer/TabularFilterPopover.vue +106 -0
- 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 +481 -0
- package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
- package/src/components/TabularExplorer/types.ts +84 -0
- package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
- package/src/components/TabularExplorer/useTabularContext.ts +82 -0
- package/src/components/TopicCard.vue +13 -14
- package/src/composables/useActiveDescendant.ts +5 -0
- package/src/composables/useDatasetResources.ts +164 -0
- package/src/composables/useHasTabularData.ts +15 -1
- package/src/composables/useMetrics.ts +1 -1
- package/src/composables/useResizable.ts +46 -0
- package/src/composables/useResourceCapabilities.ts +48 -40
- package/src/composables/useSearchFilter.ts +120 -0
- package/src/composables/useStableQueryParams.ts +38 -6
- package/src/composables/useTabularProfile.ts +70 -0
- package/src/composables/useTranslation.ts +24 -9
- package/src/config.ts +21 -3
- package/src/functions/activities.ts +3 -3
- package/src/functions/api.ts +20 -42
- package/src/functions/api.types.ts +1 -0
- package/src/functions/charts.ts +99 -0
- package/src/functions/datasets.ts +0 -17
- package/src/functions/dates.ts +6 -7
- package/src/functions/helpers.ts +15 -0
- package/src/functions/metrics.ts +63 -60
- package/src/functions/paginate.ts +46 -0
- package/src/functions/resourceCapabilities.ts +34 -0
- package/src/functions/resources.ts +136 -1
- package/src/functions/schemas.ts +27 -0
- package/src/functions/scroll.ts +20 -0
- package/src/functions/tabular.ts +214 -0
- package/src/functions/tabularApi.ts +96 -11
- package/src/main.ts +119 -14
- package/src/types/dataservices.ts +17 -0
- package/src/types/organizations.ts +10 -0
- package/src/types/pages.ts +0 -5
- package/src/types/posts.ts +2 -2
- package/src/types/reports.ts +5 -1
- package/src/types/resources.ts +0 -1
- package/src/types/reuses.ts +12 -1
- package/src/types/search.ts +63 -1
- package/src/types/site.ts +5 -3
- package/src/types/ui.ts +2 -0
- package/src/types/users.ts +2 -1
- package/src/types/visualizations.ts +93 -0
- package/assets/swagger-themes/newspaper.css +0 -1670
- package/dist/JsonPreview.client-CYxmz0yV.js +0 -72
- package/dist/Pmtiles.client-C_hvRDuy.js +0 -25185
- package/dist/Swagger.client-B4y0hXME.js +0 -4
- package/dist/XmlPreview.client-LETEfNBH.js +0 -64
- package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
- package/src/functions/pagination.ts +0 -9
- /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/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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { escapeCsvValue } from './helpers'
|
|
2
|
-
import { ofetch } from 'ofetch'
|
|
2
|
+
import { ofetch, type $Fetch } from 'ofetch'
|
|
3
3
|
import type { DatasetV2 } from '../types/datasets'
|
|
4
4
|
import type { PaginatedArray } from '../types/api'
|
|
5
5
|
|
|
@@ -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,44 +113,37 @@ 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
|
|
|
122
|
-
export async function createDatasetsForOrganizationMetricsUrl(organizationId: string, metricsApi: string, apiBase: string) {
|
|
137
|
+
export async function createDatasetsForOrganizationMetricsUrl(organizationId: string, metricsApi: string, apiBase: string, apiFetch: $Fetch) {
|
|
123
138
|
let data = 'dataset_title,dataset_id,month,monthly_visit,monthly_download_resource\n'
|
|
124
139
|
|
|
125
|
-
// fetch datasets info from organization datasets
|
|
140
|
+
// fetch datasets info from organization datasets through the configured fetch, so it carries the
|
|
141
|
+
// consumer's auth (cookie for cdata via `$api`, Bearer for verticals) instead of a hardcoded
|
|
142
|
+
// `credentials: 'include'`, which breaks CORS cross-origin on the verticals.
|
|
126
143
|
const datasets: Record<string, Record<string, string>> = {}
|
|
127
144
|
let datasetsUrl: string | null = `/api/2/datasets/?organization=${organizationId}&page_size=200`
|
|
128
145
|
while (datasetsUrl) {
|
|
129
|
-
const body: PaginatedArray<DatasetV2> = await
|
|
146
|
+
const body: PaginatedArray<DatasetV2> = await apiFetch(datasetsUrl, { baseURL: apiBase })
|
|
130
147
|
datasetsUrl = body.next_page
|
|
131
148
|
for (const row of body.data) {
|
|
132
149
|
datasets[row.id] = { title: row.title }
|
|
@@ -149,52 +166,38 @@ export async function createDatasetsForOrganizationMetricsUrl(organizationId: st
|
|
|
149
166
|
|
|
150
167
|
export async function getDataserviceMetrics(dataserviceId: string, metricsApi: string): Promise<DataserviceMetrics> {
|
|
151
168
|
// Fetching last 12 months
|
|
152
|
-
const
|
|
153
|
-
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`)
|
|
154
170
|
|
|
155
171
|
const visits: Record<string, number> = {}
|
|
156
172
|
|
|
157
|
-
for (const { metric_month, monthly_visit } of
|
|
173
|
+
for (const { metric_month, monthly_visit } of rows) {
|
|
158
174
|
visits[metric_month] = monthly_visit
|
|
159
175
|
}
|
|
160
176
|
|
|
161
177
|
// Fetching totals
|
|
162
|
-
const
|
|
163
|
-
const totalPage = await totalResponse.json()
|
|
164
|
-
|
|
165
|
-
let visitsTotal = 0
|
|
166
|
-
if (totalPage.data[0]) {
|
|
167
|
-
visitsTotal = totalPage.data[0].visit
|
|
168
|
-
}
|
|
178
|
+
const total = (await fetchMetrics<TotalVisitsRow>(`${metricsApi}/api/dataservices_total/data/?dataservice_id__exact=${dataserviceId}`))[0]
|
|
169
179
|
|
|
170
180
|
return {
|
|
171
181
|
visits,
|
|
172
|
-
visitsTotal,
|
|
182
|
+
visitsTotal: total?.visit ?? 0,
|
|
173
183
|
}
|
|
174
184
|
}
|
|
175
185
|
|
|
176
186
|
export async function getReuseMetrics(reuseId: string, metricsApi: string): Promise<ReuseMetrics> {
|
|
177
187
|
// Fetching last 12 months
|
|
178
|
-
const
|
|
179
|
-
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`)
|
|
180
189
|
|
|
181
190
|
const visits: Record<string, number> = {}
|
|
182
191
|
|
|
183
|
-
for (const { metric_month, monthly_visit } of
|
|
192
|
+
for (const { metric_month, monthly_visit } of rows) {
|
|
184
193
|
visits[metric_month] = monthly_visit
|
|
185
194
|
}
|
|
186
195
|
|
|
187
196
|
// Fetching totals
|
|
188
|
-
const
|
|
189
|
-
const totalPage = await totalResponse.json()
|
|
190
|
-
|
|
191
|
-
let visitsTotal = 0
|
|
192
|
-
if (totalPage.data[0]) {
|
|
193
|
-
visitsTotal = totalPage.data[0].visit
|
|
194
|
-
}
|
|
197
|
+
const total = (await fetchMetrics<TotalVisitsRow>(`${metricsApi}/api/reuses_total/data/?reuse_id__exact=${reuseId}`))[0]
|
|
195
198
|
|
|
196
199
|
return {
|
|
197
200
|
visits,
|
|
198
|
-
visitsTotal,
|
|
201
|
+
visitsTotal: total?.visit ?? 0,
|
|
199
202
|
}
|
|
200
203
|
}
|
|
@@ -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),
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { readonly, type Component } from 'vue'
|
|
2
2
|
|
|
3
3
|
import { RiEarthLine, RiMap2Line } from '@remixicon/vue'
|
|
4
|
+
import { useComponentsConfig } from '../config'
|
|
4
5
|
import Archive from '../components/Icons/Archive.vue'
|
|
5
6
|
import Code from '../components/Icons/Code.vue'
|
|
7
|
+
import type { Dataset, DatasetV2 } from '../types/datasets'
|
|
6
8
|
import Documentation from '../components/Icons/Documentation.vue'
|
|
7
9
|
import Image from '../components/Icons/Image.vue'
|
|
8
10
|
import Link from '../components/Icons/Link.vue'
|
|
9
11
|
import Table from '../components/Icons/Table.vue'
|
|
10
|
-
import type { Resource } from '../types/resources'
|
|
12
|
+
import type { CommunityResource, Resource } from '../types/resources'
|
|
11
13
|
import { useTranslation } from '../composables/useTranslation'
|
|
12
14
|
|
|
13
15
|
export function getResourceFormatIcon(format: string): Component | null {
|
|
@@ -82,6 +84,25 @@ export function getResourceFormatIcon(format: string): Component | null {
|
|
|
82
84
|
}
|
|
83
85
|
}
|
|
84
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
|
+
|
|
85
106
|
export function getResourceTitleId(resource: Resource) {
|
|
86
107
|
return 'resource-' + resource.id + '-title'
|
|
87
108
|
}
|
|
@@ -89,6 +110,11 @@ export function getResourceTitleId(resource: Resource) {
|
|
|
89
110
|
export const RESOURCE_TYPE = readonly(['main', 'documentation', 'update', 'api', 'code', 'other'] as const)
|
|
90
111
|
export type ResourceType = typeof RESOURCE_TYPE[number]
|
|
91
112
|
|
|
113
|
+
// Page size used by ResourceExplorer for its per-type resource fetches.
|
|
114
|
+
// Exported so consumers (e.g. the dataset page's `main` prefetch) can build a
|
|
115
|
+
// byte-for-byte identical cache key and let Nuxt dedupe the request.
|
|
116
|
+
export const RESOURCE_EXPLORER_PAGE_SIZE = 10
|
|
117
|
+
|
|
92
118
|
export const getResourceLabel = (type: ResourceType, count?: number) => {
|
|
93
119
|
const { t } = useTranslation()
|
|
94
120
|
switch (type) {
|
|
@@ -98,14 +124,29 @@ export const getResourceLabel = (type: ResourceType, count?: number) => {
|
|
|
98
124
|
}
|
|
99
125
|
return t('Fichiers principaux')
|
|
100
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
|
+
}
|
|
101
130
|
return t('Documentation')
|
|
102
131
|
case 'update':
|
|
132
|
+
if (typeof count === 'number') {
|
|
133
|
+
return t('Aucune mise à jour | 1 mise à jour | {n} mises à jour', count)
|
|
134
|
+
}
|
|
103
135
|
return t('Mise à jour')
|
|
104
136
|
case 'api':
|
|
137
|
+
if (typeof count === 'number') {
|
|
138
|
+
return t('Aucune API | 1 API | {n} API', count)
|
|
139
|
+
}
|
|
105
140
|
return t('API')
|
|
106
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
|
+
}
|
|
107
145
|
return t('Code source')
|
|
108
146
|
case 'other':
|
|
147
|
+
if (typeof count === 'number') {
|
|
148
|
+
return t('Aucun autre fichier | 1 autre fichier | {n} autres fichiers', count)
|
|
149
|
+
}
|
|
109
150
|
return t('Autre')
|
|
110
151
|
}
|
|
111
152
|
}
|
|
@@ -129,3 +170,97 @@ export const detectOgcService = (resource: Resource) => {
|
|
|
129
170
|
}
|
|
130
171
|
return false
|
|
131
172
|
}
|
|
173
|
+
|
|
174
|
+
export function isCommunityResource(resource: Resource | CommunityResource): boolean {
|
|
175
|
+
return 'organization' in resource || 'owner' in resource
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function getResourceExternalUrl(dataset: Dataset | DatasetV2 | Omit<Dataset, 'resources' | 'community_resources'>, resource: Resource | CommunityResource): string {
|
|
179
|
+
return `${dataset.page}${isCommunityResource(resource) ? '/community-resources' : ''}?resource_id=${resource.id}`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function getResourceFilesize(resource: Resource): null | number {
|
|
183
|
+
if (resource.filesize) return resource.filesize
|
|
184
|
+
if ('analysis:content-length' in resource.extras) return resource.extras['analysis:content-length'] as number
|
|
185
|
+
|
|
186
|
+
return null
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const IMAGE_PREVIEW_FORMATS = ['png', 'jpg', 'jpeg', 'jpe', 'gif', 'webp'] as const
|
|
190
|
+
|
|
191
|
+
export function isImagePreviewFormat(format: string | undefined | null): boolean {
|
|
192
|
+
if (!format) return false
|
|
193
|
+
return IMAGE_PREVIEW_FORMATS.includes(format.toLowerCase() as typeof IMAGE_PREVIEW_FORMATS[number])
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Parsing errors are stored as `<step>:<message>`, where `<step>` names the
|
|
197
|
+
// analysis step that failed (e.g. `pmtiles_export`, `geojson_export`).
|
|
198
|
+
|
|
199
|
+
/** The analysis step that failed, or null when there is no parsing error. */
|
|
200
|
+
export function getParsingErrorStep(resource: Resource): string | null {
|
|
201
|
+
const error = resource.extras['analysis:parsing:error']
|
|
202
|
+
if (typeof error !== 'string' || !error) return null
|
|
203
|
+
const [step] = error.split(':', 1)
|
|
204
|
+
return step ?? null
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The parsing error message without its `<step>:` prefix, or null when there is none. */
|
|
208
|
+
export function getParsingErrorMessage(resource: Resource): string | null {
|
|
209
|
+
const error = resource.extras['analysis:parsing:error']
|
|
210
|
+
if (typeof error !== 'string' || !error) return null
|
|
211
|
+
const colon = error.indexOf(':')
|
|
212
|
+
return colon === -1 ? error : error.slice(colon + 1)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Geo/format export steps run *after* the tabular table has been built, so a
|
|
216
|
+
// failure here (e.g. a huge geometry timing out the pmtiles export) leaves the
|
|
217
|
+
// tabular data fully usable and must not disable the tabular explorer.
|
|
218
|
+
const NON_TABULAR_PARSING_ERROR_STEPS = ['pmtiles_export', 'geojson_export']
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Whether the resource's parsing error prevents reading its tabular data.
|
|
222
|
+
* Only errors from the tabular parsing itself are fatal; downstream geo/format
|
|
223
|
+
* export failures are not.
|
|
224
|
+
*/
|
|
225
|
+
export function hasTabularParsingError(resource: Resource): boolean {
|
|
226
|
+
const step = getParsingErrorStep(resource)
|
|
227
|
+
return step !== null && !NON_TABULAR_PARSING_ERROR_STEPS.includes(step)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
type CorsStatus = 'allowed' | 'blocked' | 'unknown'
|
|
231
|
+
|
|
232
|
+
export const getResourceCorsStatus = (resource: Resource): CorsStatus => {
|
|
233
|
+
const extras = resource.extras
|
|
234
|
+
if (!extras || !('check:cors:allow-origin' in extras)) {
|
|
235
|
+
return 'unknown'
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const allowOrigin = extras['check:cors:allow-origin'] as string | undefined
|
|
239
|
+
const rawMethods = extras['check:cors:allow-methods'] as string | undefined
|
|
240
|
+
|
|
241
|
+
// Check if allow-origin is '*' or contains one of our trusted domains
|
|
242
|
+
const config = useComponentsConfig()
|
|
243
|
+
const trustedDomains = config.trustedDomains ?? []
|
|
244
|
+
const hasPublicCors = allowOrigin === '*'
|
|
245
|
+
const hasSpecificCors = allowOrigin
|
|
246
|
+
? trustedDomains.some((domain) => {
|
|
247
|
+
try {
|
|
248
|
+
const hostname = new URL(allowOrigin).hostname
|
|
249
|
+
return hostname === domain || hostname.endsWith(`.${domain}`)
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return false
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
: false
|
|
256
|
+
|
|
257
|
+
const isOriginAllowed = hasPublicCors || hasSpecificCors
|
|
258
|
+
|
|
259
|
+
// Ensure GET method is allowed
|
|
260
|
+
const allowedMethods = rawMethods
|
|
261
|
+
? rawMethods.split(',').map(m => m.trim().toUpperCase())
|
|
262
|
+
: []
|
|
263
|
+
const supportsGet = allowedMethods.length === 0 || allowedMethods.includes('GET')
|
|
264
|
+
|
|
265
|
+
return isOriginAllowed && supportsGet ? 'allowed' : 'blocked'
|
|
266
|
+
}
|
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
|
+
}
|