@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.
Files changed (165) hide show
  1. package/README.md +7 -6
  2. package/assets/main.css +4 -0
  3. package/dist/{Control-DuZJdKV_.js → Control-B9WQxlpJ.js} +1 -1
  4. package/dist/{Datafair.client-Ds3VQrRi.js → Datafair.client-CFTmBFoY.js} +9 -9
  5. package/dist/{Event--kp8kMdJ.js → Event-iXDrp-fy.js} +31 -31
  6. package/dist/{Image-34hvypZI.js → Image-mNMBOofs.js} +9 -9
  7. package/dist/ImagePreview.client-BvDM3qjE.js +36 -0
  8. package/dist/JsonPreview.client-BP6gPRxf.js +40 -0
  9. package/dist/{Map-BjUnLyj8.js → Map-BfqKMw0S.js} +1739 -1599
  10. package/dist/{MapContainer.client-B2BijMx5.js → MapContainer.client-D9N3664r.js} +41 -42
  11. package/dist/{OSM-s40W6sQ2.js → OSM-CcrUhItr.js} +15 -23
  12. package/dist/{PdfPreview.client-DJBMzrlC.js → PdfPreview.client-C6A6ZiKl.js} +835 -856
  13. package/dist/Pmtiles.client-B_sDZoqu.js +26333 -0
  14. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-DGLXBys_.js +60 -0
  15. package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-VGQ7nkMS.js} +2 -2
  16. package/dist/{Tile-DbNFNPfU.js → Tile-CSpn0Ca8.js} +160 -163
  17. package/dist/{TileImage-BsXBxMtq.js → TileImage-CpbaEFwd.js} +143 -131
  18. package/dist/{View-BR92hTWP.js → View-BYBpt-AU.js} +483 -422
  19. package/dist/XmlPreview.client-DFQ1T-LN.js +34 -0
  20. package/dist/{common-PJfpC179.js → common-BoeTUnDZ.js} +52 -52
  21. package/dist/components-next.css +1 -52
  22. package/dist/components-next.js +196 -150
  23. package/dist/components.css +2 -2
  24. package/dist/{index-BFQX7Ccz.js → index-B_tPYi5Z.js} +34094 -27864
  25. package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-PeylHk0o.js} +162 -164
  26. package/dist/{main-vqhkVhVS.js → main-CVOZp_Nx.js} +41939 -73132
  27. package/dist/{proj-DsetBcW7.js → proj-DxGbobp7.js} +662 -626
  28. package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
  29. package/dist/{tilecoord-Db24Px13.js → tilecoord-DvX7WWiA.js} +273 -264
  30. package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
  31. package/dist/{vue3-xml-viewer.common-BcNJo35o.js → vue3-xml-viewer.common-CcF3Xd23.js} +1 -1
  32. package/package.json +47 -38
  33. package/src/chart.ts +5 -0
  34. package/src/components/ActivityList/ActivityList.vue +24 -14
  35. package/src/components/AppLink.vue +3 -0
  36. package/src/components/BannerAction.vue +6 -2
  37. package/src/components/Chart/ChartViewer.vue +320 -0
  38. package/src/components/Chart/ChartViewerWrapper.vue +182 -0
  39. package/src/components/CopyButton.vue +6 -2
  40. package/src/components/DataserviceCard.vue +24 -5
  41. package/src/components/DataserviceQuality.vue +82 -0
  42. package/src/components/DataserviceQualityInline.vue +31 -0
  43. package/src/components/DataserviceQualityTooltipContent.vue +79 -0
  44. package/src/components/DatasetCard.vue +24 -9
  45. package/src/components/DatasetInformation/DatasetTemporalitySection.vue +7 -4
  46. package/src/components/DatasetQuality.vue +11 -11
  47. package/src/components/DatasetQualityInline.vue +2 -2
  48. package/src/components/DatasetQualityTooltipContent.vue +1 -1
  49. package/src/components/DiscussionMessageCard.vue +14 -15
  50. package/src/components/Form/Listbox.vue +101 -0
  51. package/src/components/Form/SchemaSelect.vue +2 -0
  52. package/src/components/FormattedDate.vue +81 -0
  53. package/src/components/InfiniteLoader.vue +53 -0
  54. package/src/components/LoadingBlock.vue +4 -1
  55. package/src/components/ObjectCardHeader.vue +11 -4
  56. package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
  57. package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
  58. package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
  59. package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
  60. package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
  61. package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
  62. package/src/components/OpenApiViewer/openapi.ts +150 -0
  63. package/src/components/OrganizationNameWithCertificate.vue +3 -2
  64. package/src/components/Pagination.vue +25 -62
  65. package/src/components/PostCard.vue +13 -15
  66. package/src/components/RadioInput.vue +7 -2
  67. package/src/components/ResourceAccordion/DataStructure.vue +11 -33
  68. package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
  69. package/src/components/ResourceAccordion/Downloads.vue +172 -0
  70. package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
  71. package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
  72. package/src/components/ResourceAccordion/MapContainer.client.vue +2 -11
  73. package/src/components/ResourceAccordion/Metadata.vue +4 -6
  74. package/src/components/ResourceAccordion/PdfPreview.client.vue +58 -97
  75. package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
  76. package/src/components/ResourceAccordion/Pmtiles.client.vue +28 -13
  77. package/src/components/ResourceAccordion/Preview.vue +19 -15
  78. package/src/components/ResourceAccordion/PreviewWrapper.vue +89 -0
  79. package/src/components/ResourceAccordion/ResourceAccordion.vue +36 -117
  80. package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
  81. package/src/components/ResourceExplorer/ResourceExplorer.vue +105 -210
  82. package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +79 -0
  83. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +104 -71
  84. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +143 -291
  85. package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
  86. package/src/components/ResourceExplorer/ResourceSelector.vue +51 -0
  87. package/src/components/ResourceExplorer/ResourceViewerHeader.vue +122 -0
  88. package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +94 -0
  89. package/src/components/ResourceListItem.vue +168 -0
  90. package/src/components/ReuseCard.vue +28 -9
  91. package/src/components/ReuseDetails.vue +2 -2
  92. package/src/components/ReuseHorizontalCard.vue +2 -2
  93. package/src/components/SchemaCard.vue +10 -1
  94. package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
  95. package/src/components/Search/GlobalSearch.vue +229 -123
  96. package/src/components/Search/SearchInput.vue +5 -4
  97. package/src/components/Search/Sidemenu.vue +4 -3
  98. package/src/components/SmallChart.vue +11 -10
  99. package/src/components/StatBox.vue +13 -5
  100. package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
  101. package/src/components/TabularExplorer/TabularCell.vue +50 -0
  102. package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
  103. package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
  104. package/src/components/TabularExplorer/TabularExplorer.vue +299 -0
  105. package/src/components/TabularExplorer/TabularFilterContent.vue +362 -0
  106. package/src/components/TabularExplorer/TabularFilterPopover.vue +106 -0
  107. package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
  108. package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
  109. package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
  110. package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
  111. package/src/components/TabularExplorer/TabularTable.vue +481 -0
  112. package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
  113. package/src/components/TabularExplorer/types.ts +84 -0
  114. package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
  115. package/src/components/TabularExplorer/useTabularContext.ts +82 -0
  116. package/src/components/TopicCard.vue +13 -14
  117. package/src/composables/useActiveDescendant.ts +5 -0
  118. package/src/composables/useDatasetResources.ts +164 -0
  119. package/src/composables/useHasTabularData.ts +15 -1
  120. package/src/composables/useMetrics.ts +1 -1
  121. package/src/composables/useResizable.ts +46 -0
  122. package/src/composables/useResourceCapabilities.ts +48 -40
  123. package/src/composables/useSearchFilter.ts +120 -0
  124. package/src/composables/useStableQueryParams.ts +38 -6
  125. package/src/composables/useTabularProfile.ts +70 -0
  126. package/src/composables/useTranslation.ts +24 -9
  127. package/src/config.ts +21 -3
  128. package/src/functions/activities.ts +3 -3
  129. package/src/functions/api.ts +20 -42
  130. package/src/functions/api.types.ts +1 -0
  131. package/src/functions/charts.ts +99 -0
  132. package/src/functions/datasets.ts +0 -17
  133. package/src/functions/dates.ts +6 -7
  134. package/src/functions/helpers.ts +15 -0
  135. package/src/functions/metrics.ts +63 -60
  136. package/src/functions/paginate.ts +46 -0
  137. package/src/functions/resourceCapabilities.ts +34 -0
  138. package/src/functions/resources.ts +136 -1
  139. package/src/functions/schemas.ts +27 -0
  140. package/src/functions/scroll.ts +20 -0
  141. package/src/functions/tabular.ts +214 -0
  142. package/src/functions/tabularApi.ts +96 -11
  143. package/src/main.ts +119 -14
  144. package/src/types/dataservices.ts +17 -0
  145. package/src/types/organizations.ts +10 -0
  146. package/src/types/pages.ts +0 -5
  147. package/src/types/posts.ts +2 -2
  148. package/src/types/reports.ts +5 -1
  149. package/src/types/resources.ts +0 -1
  150. package/src/types/reuses.ts +12 -1
  151. package/src/types/search.ts +63 -1
  152. package/src/types/site.ts +5 -3
  153. package/src/types/ui.ts +2 -0
  154. package/src/types/users.ts +2 -1
  155. package/src/types/visualizations.ts +93 -0
  156. package/assets/swagger-themes/newspaper.css +0 -1670
  157. package/dist/JsonPreview.client-CYxmz0yV.js +0 -72
  158. package/dist/Pmtiles.client-C_hvRDuy.js +0 -25185
  159. package/dist/Swagger.client-B4y0hXME.js +0 -4
  160. package/dist/XmlPreview.client-LETEfNBH.js +0 -64
  161. package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
  162. package/src/functions/pagination.ts +0 -9
  163. /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
  164. /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
  165. /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
@@ -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 ''
@@ -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 response = await fetch(`${metricsApi}/api/organizations/data/?organization_id__exact=${oid}&metric_month__sort=desc&page_size=12`)
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 page.data) {
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 totalResponse = await fetch(`${metricsApi}/api/organizations_total/data/?organization_id__exact=${oid}`)
56
- const totalPage = await totalResponse.json()
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 response = await fetch(`${metricsApi}/api/datasets/data/?dataset_id__exact=${datasetId}&metric_month__sort=desc&page_size=12`)
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 page.data) {
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 totalResponse = await fetch(`${metricsApi}/api/datasets_total/data/?dataset_id__exact=${datasetId}`)
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 ofetch(datasetsUrl, { baseURL: apiBase, credentials: 'include' })
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 response = await fetch(`${metricsApi}/api/dataservices/data/?dataservice_id__exact=${dataserviceId}&metric_month__sort=desc&page_size=12`)
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 page.data) {
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 totalResponse = await fetch(`${metricsApi}/api/dataservices_total/data/?dataservice_id__exact=${dataserviceId}`)
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 response = await fetch(`${metricsApi}/api/reuses/data/?reuse_id__exact=${reuseId}&metric_month__sort=desc&page_size=12`)
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 page.data) {
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 totalResponse = await fetch(`${metricsApi}/api/reuses_total/data/?reuse_id__exact=${reuseId}`)
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
+ }
@@ -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
+ }