@datagouv/components-next 1.2.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/README.md +7 -6
- package/dist/{Control-ZFh5ta_U.js → Control-B9WQxlpJ.js} +1 -1
- package/dist/{Datafair.client-rf4T1IkA.js → Datafair.client-D_6yzJUG.js} +9 -9
- package/dist/{Event-DSQcW7OF.js → Event-iXDrp-fy.js} +11 -11
- package/dist/{Image-BijNEG0p.js → Image-mNMBOofs.js} +5 -5
- package/dist/ImagePreview.client-B0iPhFm3.js +36 -0
- package/dist/{JsonPreview.client-dzar6iuh.js → JsonPreview.client-DSl1kiwC.js} +5 -5
- package/dist/{Map-BUtPf5GN.js → Map-BfqKMw0S.js} +1306 -1166
- package/dist/{MapContainer.client-D-MoRNhG.js → MapContainer.client-BSfpk_rT.js} +15 -15
- package/dist/{OSM-D4MTdBtk.js → OSM-CcrUhItr.js} +15 -23
- package/dist/{PdfPreview.client-DoDYLmJD.js → PdfPreview.client-BANFeM6K.js} +441 -434
- package/dist/Pmtiles.client-DH9ZrsZR.js +26333 -0
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-I8EZCgzp.js +60 -0
- package/dist/{ScaleLine-hJQIqcZm.js → ScaleLine-VGQ7nkMS.js} +2 -2
- package/dist/{Tile-Dcl7oIVu.js → Tile-CSpn0Ca8.js} +153 -156
- package/dist/{TileImage-BJeHipMX.js → TileImage-CpbaEFwd.js} +143 -131
- package/dist/{View-xp_P_OHw.js → View-BYBpt-AU.js} +167 -117
- package/dist/{XmlPreview.client-cOhwff6P.js → XmlPreview.client-KCB5mwVf.js} +5 -5
- package/dist/{common-BjQlan3k.js → common-BoeTUnDZ.js} +26 -26
- package/dist/components-next.css +1 -52
- package/dist/components-next.js +201 -170
- package/dist/components.css +2 -2
- package/dist/{index-NofRBuyf.js → index-DHWzX5vK.js} +5999 -5472
- package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-pVQfonBI.js} +162 -164
- package/dist/{main-Iz1ZCL6k.js → main-BDcYkhOc.js} +36671 -29402
- package/dist/{proj-CsNo9yH1.js → proj-DxGbobp7.js} +312 -296
- package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
- package/dist/{tilecoord-A0fLnBZr.js → tilecoord-DvX7WWiA.js} +264 -255
- package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
- package/dist/{vue3-xml-viewer.common-tVI9uXUz.js → vue3-xml-viewer.common-Bdcn54ST.js} +1 -1
- package/package.json +40 -39
- 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 +30 -19
- package/src/components/ResourceAccordion/Preview.vue +8 -4
- package/src/components/ResourceAccordion/PreviewWrapper.vue +17 -10
- package/src/components/ResourceAccordion/ResourceAccordion.vue +31 -11
- 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 +140 -184
- 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 +28 -10
- 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 +21 -5
- 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 +127 -780
- package/src/components/TabularExplorer/TabularFilterContent.vue +48 -38
- package/src/components/TabularExplorer/TabularFilterPopover.vue +27 -32
- 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 +40 -28
- 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 +48 -40
- package/src/composables/useSearchFilter.ts +7 -5
- package/src/composables/useTranslation.ts +24 -9
- package/src/config.ts +1 -0
- package/src/functions/api.ts +13 -7
- 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 +88 -0
- package/src/functions/schemas.ts +27 -0
- package/src/functions/scroll.ts +20 -0
- package/src/functions/tabular.ts +222 -5
- 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/resources.ts +0 -1
- package/src/types/reuses.ts +12 -1
- package/src/types/search.ts +2 -2
- package/src/types/visualizations.ts +5 -0
- package/dist/Pmtiles.client-Dzm01Zfm.js +0 -25185
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-BRNYswg3.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
|
@@ -14,24 +14,39 @@ const translationModules = import.meta.glob<Record<string, string>>('../../../lo
|
|
|
14
14
|
import: 'default',
|
|
15
15
|
})
|
|
16
16
|
|
|
17
|
+
const LANGUAGE_SUBTAG_REGEX = /^[a-z]{2,3}$/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extract the language subtag of an `accept-language` header or of
|
|
21
|
+
* `navigator.language`, and return it only if it is a well-formed one.
|
|
22
|
+
*
|
|
23
|
+
* Both are user-controlled, and the locale is handed over to `Intl` formatters,
|
|
24
|
+
* which throw a `RangeError` on anything that is not a valid language tag. The
|
|
25
|
+
* wildcard `*` is rejected here too: it means "any language", so it must fall
|
|
26
|
+
* back to the next detection step.
|
|
27
|
+
*/
|
|
28
|
+
function parseLanguage(value: string | undefined | null): string | null {
|
|
29
|
+
if (!value) {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
const language = value.split(';')[0]!.split(',')[0]!.split('-')[0]!.toLowerCase()
|
|
33
|
+
return LANGUAGE_SUBTAG_REGEX.test(language) ? language : null
|
|
34
|
+
}
|
|
35
|
+
|
|
17
36
|
function detectLanguage(): string {
|
|
18
37
|
// Server-side (Nuxt)
|
|
19
38
|
try {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
if (acceptLanguage) {
|
|
23
|
-
const primaryLang = acceptLanguage.split(';')[0]!.split(',')[0]!.split('-')[0]!.toLowerCase()
|
|
24
|
-
// Ignore wildcard * language, that should fallback to client side detection or default language
|
|
25
|
-
if (primaryLang !== '*') return primaryLang
|
|
26
|
-
}
|
|
39
|
+
const language = parseLanguage(useRequestHeader?.('accept-language'))
|
|
40
|
+
if (language) return language
|
|
27
41
|
}
|
|
28
42
|
catch {
|
|
29
43
|
// useRequestHeaders not available, continue to client-side detection
|
|
30
44
|
}
|
|
31
45
|
|
|
32
46
|
// Client-side
|
|
33
|
-
if (typeof window !== 'undefined'
|
|
34
|
-
|
|
47
|
+
if (typeof window !== 'undefined') {
|
|
48
|
+
const language = parseLanguage(navigator.language)
|
|
49
|
+
if (language) return language
|
|
35
50
|
}
|
|
36
51
|
|
|
37
52
|
return 'fr'
|
package/src/config.ts
CHANGED
|
@@ -12,6 +12,7 @@ export type PluginConfig = {
|
|
|
12
12
|
datasetQualityGuideUrl?: string
|
|
13
13
|
maxJsonPreviewCharSize?: number // Maximum size of JSON to preview in characters. JSON preview module is partly collapsed by default so we can have a preview for large files.
|
|
14
14
|
maxPdfPreviewByteSize?: number // Maximum size of PDF to preview in bytes
|
|
15
|
+
maxImagePreviewByteSize?: number // Maximum size of image to preview in bytes
|
|
15
16
|
maxXmlPreviewCharSize?: number // Maximum size of XML to preview in characters. XML preview module can NOT be collapsed by default so we should not have a preview for large files.
|
|
16
17
|
pmtilesViewerBaseUrl?: string | null // Base URL of a pmtiles viewer (ex: https://pmtiles.io/#url=)
|
|
17
18
|
metricsApiUrl?: string
|
package/src/functions/api.ts
CHANGED
|
@@ -26,7 +26,7 @@ export async function useFetch<DataT, ErrorT = never>(
|
|
|
26
26
|
const error: Ref<ErrorT | null> = ref(null)
|
|
27
27
|
const status = ref<AsyncDataRequestStatus>('idle')
|
|
28
28
|
|
|
29
|
-
const execute = async () => {
|
|
29
|
+
const execute = async (signal?: AbortSignal) => {
|
|
30
30
|
const urlValue = toValue(url)
|
|
31
31
|
if (!urlValue) return
|
|
32
32
|
const params = deepToValue(options?.params)
|
|
@@ -36,13 +36,15 @@ export async function useFetch<DataT, ErrorT = never>(
|
|
|
36
36
|
data.value = isRaw
|
|
37
37
|
// Raw targets another data.gouv service (the Tabular API in TabularExplorer) via its own
|
|
38
38
|
// absolute URL, so it must stay bare ofetch: no datagouv apiBase, no datagouv auth attached.
|
|
39
|
-
? await ofetch<DataT | null>(urlValue, { params: params ?? query })
|
|
39
|
+
? await ofetch<DataT | null>(urlValue, { params: params ?? query, signal })
|
|
40
40
|
// The configured `$fetch` carries baseURL + auth + headers (see the `datagouv` plugin install
|
|
41
41
|
// for the default one). We only forward the URL and params here.
|
|
42
|
-
: await config.$fetch<DataT | null>(urlValue, { baseURL: config.apiBase, params: params ?? query })
|
|
42
|
+
: await config.$fetch<DataT | null>(urlValue, { baseURL: config.apiBase, params: params ?? query, signal })
|
|
43
43
|
status.value = 'success'
|
|
44
44
|
}
|
|
45
45
|
catch (e) {
|
|
46
|
+
// Ignore aborted requests — they're superseded by a newer call, not a real error.
|
|
47
|
+
if (signal?.aborted) return
|
|
46
48
|
error.value = e as ErrorT
|
|
47
49
|
status.value = 'error'
|
|
48
50
|
}
|
|
@@ -51,14 +53,18 @@ export async function useFetch<DataT, ErrorT = never>(
|
|
|
51
53
|
// When server is false, only start watching after mount (client-side only)
|
|
52
54
|
if (options?.server === false) {
|
|
53
55
|
onMounted(() => {
|
|
54
|
-
watchEffect(
|
|
55
|
-
|
|
56
|
+
watchEffect((onCleanup) => {
|
|
57
|
+
const controller = new AbortController()
|
|
58
|
+
onCleanup(() => controller.abort())
|
|
59
|
+
void execute(controller.signal)
|
|
56
60
|
})
|
|
57
61
|
})
|
|
58
62
|
}
|
|
59
63
|
else {
|
|
60
|
-
watchEffect(
|
|
61
|
-
|
|
64
|
+
watchEffect((onCleanup) => {
|
|
65
|
+
const controller = new AbortController()
|
|
66
|
+
onCleanup(() => controller.abort())
|
|
67
|
+
void execute(controller.signal)
|
|
62
68
|
})
|
|
63
69
|
}
|
|
64
70
|
|
package/src/functions/charts.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type { Chart, ChartForm, ChartForApi, Filter } from '../types/visualizations'
|
|
1
|
+
import type { Chart, ChartForm, ChartForApi, Filter, ColumnDefinition } from '../types/visualizations'
|
|
2
|
+
import type { ColumnType, TabularProfile } from '../components/TabularExplorer/types'
|
|
3
|
+
import { resolveColumnType } from './tabular'
|
|
2
4
|
|
|
3
5
|
export function toChartForm(chart: Chart) {
|
|
4
6
|
const seriesFilter = chart.series[0]?.filters as Filter | null
|
|
@@ -66,3 +68,32 @@ export function toChartApi(chartForm: ChartForm): ChartForApi {
|
|
|
66
68
|
extras: chartForm.extras,
|
|
67
69
|
}
|
|
68
70
|
}
|
|
71
|
+
|
|
72
|
+
export function buildColumnsFromProfile(profile: { profile: TabularProfile }): Array<ColumnDefinition> {
|
|
73
|
+
return profile.profile.header.map((name) => {
|
|
74
|
+
const colInfo = profile.profile.columns[name]
|
|
75
|
+
const isCategorical = profile.profile.categorical?.includes(name) ?? false
|
|
76
|
+
// The chart stack only branches on 'number' and 'date' (continuous axis,
|
|
77
|
+
// aggregatable series, time formatting): it has no notion of a year column,
|
|
78
|
+
// and on an axis a year is a number.
|
|
79
|
+
const resolvedType = resolveColumnType(colInfo ?? { python_type: 'unknown', format: undefined }, isCategorical)
|
|
80
|
+
const colType = resolvedType === 'year' ? 'number' : resolvedType
|
|
81
|
+
const colProfile = profile.profile.profile?.[name]
|
|
82
|
+
return {
|
|
83
|
+
name,
|
|
84
|
+
type: colType,
|
|
85
|
+
min: normalizeProfileNumber(colProfile?.min, colType),
|
|
86
|
+
max: normalizeProfileNumber(colProfile?.max, colType),
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeProfileNumber(value: number | string | undefined, type: ColumnType): number | undefined {
|
|
92
|
+
if (value === undefined || value === null) return undefined
|
|
93
|
+
if (type === 'date' && typeof value === 'string') {
|
|
94
|
+
const timestamp = new Date(value).getTime()
|
|
95
|
+
return Number.isNaN(timestamp) ? undefined : timestamp
|
|
96
|
+
}
|
|
97
|
+
const parsed = Number(value)
|
|
98
|
+
return Number.isNaN(parsed) ? undefined : parsed
|
|
99
|
+
}
|
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
|
}
|
|
@@ -91,6 +110,11 @@ export function getResourceTitleId(resource: Resource) {
|
|
|
91
110
|
export const RESOURCE_TYPE = readonly(['main', 'documentation', 'update', 'api', 'code', 'other'] as const)
|
|
92
111
|
export type ResourceType = typeof RESOURCE_TYPE[number]
|
|
93
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
|
+
|
|
94
118
|
export const getResourceLabel = (type: ResourceType, count?: number) => {
|
|
95
119
|
const { t } = useTranslation()
|
|
96
120
|
switch (type) {
|
|
@@ -100,14 +124,29 @@ export const getResourceLabel = (type: ResourceType, count?: number) => {
|
|
|
100
124
|
}
|
|
101
125
|
return t('Fichiers principaux')
|
|
102
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
|
+
}
|
|
103
130
|
return t('Documentation')
|
|
104
131
|
case 'update':
|
|
132
|
+
if (typeof count === 'number') {
|
|
133
|
+
return t('Aucune mise à jour | 1 mise à jour | {n} mises à jour', count)
|
|
134
|
+
}
|
|
105
135
|
return t('Mise à jour')
|
|
106
136
|
case 'api':
|
|
137
|
+
if (typeof count === 'number') {
|
|
138
|
+
return t('Aucune API | 1 API | {n} API', count)
|
|
139
|
+
}
|
|
107
140
|
return t('API')
|
|
108
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
|
+
}
|
|
109
145
|
return t('Code source')
|
|
110
146
|
case 'other':
|
|
147
|
+
if (typeof count === 'number') {
|
|
148
|
+
return t('Aucun autre fichier | 1 autre fichier | {n} autres fichiers', count)
|
|
149
|
+
}
|
|
111
150
|
return t('Autre')
|
|
112
151
|
}
|
|
113
152
|
}
|
|
@@ -140,6 +179,14 @@ export function getResourceExternalUrl(dataset: Dataset | DatasetV2 | Omit<Datas
|
|
|
140
179
|
return `${dataset.page}${isCommunityResource(resource) ? '/community-resources' : ''}?resource_id=${resource.id}`
|
|
141
180
|
}
|
|
142
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
|
+
|
|
143
190
|
export function getResourceFilesize(resource: Resource): null | number {
|
|
144
191
|
if (resource.filesize) return resource.filesize
|
|
145
192
|
if ('analysis:content-length' in resource.extras) return resource.extras['analysis:content-length'] as number
|
|
@@ -147,6 +194,47 @@ export function getResourceFilesize(resource: Resource): null | number {
|
|
|
147
194
|
return null
|
|
148
195
|
}
|
|
149
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
|
+
|
|
150
238
|
type CorsStatus = 'allowed' | 'blocked' | 'unknown'
|
|
151
239
|
|
|
152
240
|
export const getResourceCorsStatus = (resource: Resource): CorsStatus => {
|