diff --git a/internal/engine/template.go b/internal/engine/template.go index a07f78b0..76028859 100644 --- a/internal/engine/template.go +++ b/internal/engine/template.go @@ -9,18 +9,10 @@ import ( "path/filepath" "strings" texttemplate "text/template" - "time" "github.com/PDOK/gokoala/config" - "github.com/docker/go-units" - "github.com/PDOK/gokoala/internal/engine/util" - sprig "github.com/go-task/slim-sprig" - gomarkdown "github.com/gomarkdown/markdown" - gomarkdownhtml "github.com/gomarkdown/markdown/html" - gomarkdownparser "github.com/gomarkdown/markdown/parser" "github.com/nicksnyder/go-i18n/v2/i18n" - stripmd "github.com/writeas/go-strip-markdown/v2" "golang.org/x/text/language" ) @@ -28,23 +20,6 @@ const ( layoutFile = "layout.go.html" ) -var ( - globalTemplateFuncs texttemplate.FuncMap -) - -func init() { - customFuncs := texttemplate.FuncMap{ - // custom template functions - "markdown": markdown, - "unmarkdown": unmarkdown, - "humansize": humanSize, - "bytessize": bytesSize, - "isdate": isDate, - } - sprigFuncs := sprig.FuncMap() // we also support https://github.com/go-task/slim-sprig functions - globalTemplateFuncs = combineFuncMaps(customFuncs, sprigFuncs) -} - // TemplateKey unique key to register and lookup Go templates type TemplateKey struct { // Name of the template, the filename including extension @@ -265,68 +240,3 @@ func (t *Templates) createTemplateFuncs(lang language.Tag) map[string]any { }, }) } - -// combine given FuncMaps -func combineFuncMaps(funcMaps ...map[string]any) map[string]any { - result := make(map[string]any) - for _, funcMap := range funcMaps { - for k, v := range funcMap { - result[k] = v - } - } - return result -} - -// markdown turn Markdown into HTML -func markdown(s *string) htmltemplate.HTML { - if s == nil { - return "" - } - // always normalize newlines, this library only supports Unix LF newlines - md := gomarkdown.NormalizeNewlines([]byte(*s)) - - // create Markdown parser - extensions := gomarkdownparser.CommonExtensions - parser := gomarkdownparser.NewWithExtensions(extensions) - - // parse Markdown into AST tree - doc := parser.Parse(md) - - // create HTML renderer - htmlFlags := gomarkdownhtml.CommonFlags | gomarkdownhtml.HrefTargetBlank | gomarkdownhtml.SkipHTML - renderer := gomarkdownhtml.NewRenderer(gomarkdownhtml.RendererOptions{Flags: htmlFlags}) - - return htmltemplate.HTML(gomarkdown.Render(doc, renderer)) //nolint:gosec -} - -// unmarkdown remove Markdown, so we can use the given string in non-HTML (JSON) output -func unmarkdown(s *string) string { - if s == nil { - return "" - } - withoutMarkdown := stripmd.Strip(*s) - withoutLinebreaks := strings.ReplaceAll(withoutMarkdown, "\n", " ") - return withoutLinebreaks -} - -// humanSize converts size in bytes to a human-readable size -func humanSize(i int64) string { - return units.HumanSize(float64(i)) -} - -// bytesSize converts human-readable size to size in bytes (base-10, not base-2) -func bytesSize(s string) int64 { - i, err := units.FromHumanSize(s) - if err != nil { - log.Printf("cannot convert '%s' to bytes", s) - return 0 - } - return i -} - -func isDate(v any) bool { - if _, ok := v.(time.Time); ok { - return true - } - return false -} diff --git a/internal/engine/templatefuncs.go b/internal/engine/templatefuncs.go new file mode 100644 index 00000000..70557250 --- /dev/null +++ b/internal/engine/templatefuncs.go @@ -0,0 +1,113 @@ +package engine + +import ( + htmltemplate "html/template" + "log" + "strconv" + "strings" + texttemplate "text/template" + "time" + + "github.com/docker/go-units" + + sprig "github.com/go-task/slim-sprig" + gomarkdown "github.com/gomarkdown/markdown" + gomarkdownhtml "github.com/gomarkdown/markdown/html" + gomarkdownparser "github.com/gomarkdown/markdown/parser" + stripmd "github.com/writeas/go-strip-markdown/v2" +) + +var ( + globalTemplateFuncs texttemplate.FuncMap +) + +// Initialize functions to be used in html/json/etc templates +func init() { + customFuncs := texttemplate.FuncMap{ + // custom template functions + "markdown": markdown, + "unmarkdown": unmarkdown, + "humansize": humanSize, + "bytessize": bytesSize, + "isdate": isDate, + } + sprigFuncs := sprig.FuncMap() // we also support https://github.com/go-task/slim-sprig functions + globalTemplateFuncs = combineFuncMaps(customFuncs, sprigFuncs) +} + +// combine given FuncMaps +func combineFuncMaps(funcMaps ...map[string]any) map[string]any { + result := make(map[string]any) + for _, funcMap := range funcMaps { + for k, v := range funcMap { + result[k] = v + } + } + return result +} + +// markdown turn Markdown into HTML +func markdown(s *string) htmltemplate.HTML { + if s == nil { + return "" + } + // always normalize newlines, this library only supports Unix LF newlines + md := gomarkdown.NormalizeNewlines([]byte(*s)) + + // create Markdown parser + extensions := gomarkdownparser.CommonExtensions + parser := gomarkdownparser.NewWithExtensions(extensions) + + // parse Markdown into AST tree + doc := parser.Parse(md) + + // create HTML renderer + htmlFlags := gomarkdownhtml.CommonFlags | gomarkdownhtml.HrefTargetBlank | gomarkdownhtml.SkipHTML + renderer := gomarkdownhtml.NewRenderer(gomarkdownhtml.RendererOptions{Flags: htmlFlags}) + + return htmltemplate.HTML(gomarkdown.Render(doc, renderer)) //nolint:gosec +} + +// unmarkdown remove Markdown, so we can use the given string in non-HTML (JSON) output +func unmarkdown(s *string) string { + if s == nil { + return "" + } + withoutMarkdown := stripmd.Strip(*s) + withoutLinebreaks := strings.ReplaceAll(withoutMarkdown, "\n", " ") + return withoutLinebreaks +} + +// humanSize converts size in bytes to a human-readable size +func humanSize(a any) string { + if i, ok := a.(int64); ok { + return units.HumanSize(float64(i)) + } else if f, ok := a.(float64); ok { + return units.HumanSize(f) + } else if s, ok := a.(string); ok { + fs, err := strconv.ParseFloat(s, 64) + if err == nil { + return units.HumanSize(fs) + } + } + log.Printf("cannot convert '%v' to float", a) + return "0" +} + +// bytesSize converts human-readable size to size in bytes (base-10, not base-2) +func bytesSize(s string) int64 { + i, err := units.FromHumanSize(s) + if err != nil { + log.Printf("cannot convert '%s' to bytes", s) + return 0 + } + return i +} + +// isDate true when given input is a date, false otherwise +func isDate(v any) bool { + if _, ok := v.(time.Time); ok { + return true + } + return false +} diff --git a/internal/engine/templatefuncs_test.go b/internal/engine/templatefuncs_test.go new file mode 100644 index 00000000..fd56c34e --- /dev/null +++ b/internal/engine/templatefuncs_test.go @@ -0,0 +1,104 @@ +package engine + +import ( + "html/template" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestMarkdown(t *testing.T) { + tests := []struct { + input *string + expected template.HTML + }{ + {nil, ""}, + {ptrTo("**bold**"), "
bold
\n"}, + {ptrTo("# Heading"), "Some link
\n"}, + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + assert.Equal(t, tt.expected, markdown(tt.input)) + }) + } +} + +func TestUnmarkdown(t *testing.T) { + tests := []struct { + input *string + expected string + }{ + {nil, ""}, + {ptrTo("**bold**"), "bold"}, + {ptrTo("# Heading"), "Heading"}, + {ptrTo("Some [link](https://example.com)"), "Some link"}, + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + assert.Equal(t, tt.expected, unmarkdown(tt.input)) + }) + } +} + +func TestHumanSize(t *testing.T) { + tests := []struct { + input any + expected string + }{ + {int64(1000), "1kB"}, + {float64(1000), "1kB"}, + {1000.00, "1kB"}, + {"1000", "1kB"}, + {"1000000", "1MB"}, + {"invalid", "0"}, + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + assert.Equal(t, tt.expected, humanSize(tt.input)) + }) + } +} + +func TestBytesSize(t *testing.T) { + tests := []struct { + input string + expected int64 + }{ + {"1 kB", 1000}, + {"1 MB", 1000000}, + {"1.1 GB", 1100000000}, + {"invalid", 0}, + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + assert.Equal(t, tt.expected, bytesSize(tt.input)) + }) + } +} + +func TestIsDate(t *testing.T) { + tests := []struct { + input any + expected bool + }{ + {time.Now(), true}, + {"not a date", false}, + {12345, false}, + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + assert.Equal(t, tt.expected, isDate(tt.input)) + }) + } +} + +func ptrTo(s string) *string { + return &s +} diff --git a/internal/ogc/features/main_test.go b/internal/ogc/features/main_test.go index dda106c7..2f834c2b 100644 --- a/internal/ogc/features/main_test.go +++ b/internal/ogc/features/main_test.go @@ -29,7 +29,7 @@ func init() { } } -func TestFeatures_CollectionContent(t *testing.T) { +func TestFeatures(t *testing.T) { type fields struct { configFile string url string @@ -78,7 +78,7 @@ func TestFeatures_CollectionContent(t *testing.T) { name: "Request GeoJSON for 'foo' collection using limit of 2 and cursor to next page", fields: fields{ configFile: "internal/ogc/features/testdata/config_features_bag.yaml", - url: "http://localhost:8080/collections/tunneldelen/items?f=json&cursor=Dv4%7CNwyr1Q&limit=2", + url: "http://localhost:8080/collections/tunneldelen/items?cursor=Dv4%7CNwyr1Q&limit=2", collectionID: "foo", contentCrs: "<" + domain.WGS84CrsURI + ">", format: "json", @@ -461,6 +461,34 @@ func TestFeatures_CollectionContent(t *testing.T) { statusCode: http.StatusOK, }, }, + { + name: "Request mapsheets as JSON", + fields: fields{ + configFile: "internal/ogc/features/testdata/config_mapsheets.yaml", + url: "http://localhost:8080/collections/:collectionId/items?limit=2", + collectionID: "example_mapsheets", + contentCrs: "<" + domain.WGS84CrsURI + ">", + format: "json", + }, + want: want{ + body: "internal/ogc/features/testdata/expected_mapsheets.json", + statusCode: http.StatusOK, + }, + }, + { + name: "Request mapsheets as HTML", + fields: fields{ + configFile: "internal/ogc/features/testdata/config_mapsheets.yaml", + url: "http://localhost:8080/collections/:collectionId/items?limit=2", + collectionID: "example_mapsheets", + contentCrs: "<" + domain.WGS84CrsURI + ">", + format: "html", + }, + want: want{ + body: "internal/ogc/features/testdata/expected_mapsheets.html", + statusCode: http.StatusOK, + }, + }, { name: "Request slow response, hitting query timeout", fields: fields{ diff --git a/internal/ogc/features/testdata/config_mapsheets.yaml b/internal/ogc/features/testdata/config_mapsheets.yaml new file mode 100644 index 00000000..d01fe75e --- /dev/null +++ b/internal/ogc/features/testdata/config_mapsheets.yaml @@ -0,0 +1,37 @@ +--- +version: 1.0.2 +title: OGC API Features +abstract: Example config to test mapsheet +baseUrl: http://localhost:8080 +serviceIdentifier: Feats +license: + name: CC0 + url: https://www.tldrlegal.com/license/creative-commons-cc0-1-0-universal +ogcApi: + features: + datasources: + defaultWGS84: + geopackage: + local: + file: ./internal/ogc/features/datasources/geopackage/testdata/bag.gpkg + fid: feature_id + collections: + - id: example_mapsheets + tableName: ligplaatsen + mapSheetDownloads: + properties: + # this gpgk doesn't actually contain mapsheets, we just (mis)use some columns + # in order to test the mapsheet functionality + assetUrl: rdf_seealso + size: nummer_id + mediaType: application/octet-stream + mapSheetId: nummer_id + metadata: + title: Dummy mapsheets + description: Map sheets test + links: + downloads: + - name: Full download + assetUrl: https://example.com/awesome.zip + size: 123MB + mediaType: application/zip diff --git a/internal/ogc/features/testdata/expected_mapsheets.html b/internal/ogc/features/testdata/expected_mapsheets.html new file mode 100644 index 00000000..3e366e94 --- /dev/null +++ b/internal/ogc/features/testdata/expected_mapsheets.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/ogc/features/testdata/expected_mapsheets.json b/internal/ogc/features/testdata/expected_mapsheets.json new file mode 100644 index 00000000..41e696d9 --- /dev/null +++ b/internal/ogc/features/testdata/expected_mapsheets.json @@ -0,0 +1,103 @@ +{ + "type": "FeatureCollection", + "timeStamp": "2000-01-01T00:00:00Z", + "links": [ + { + "rel": "self", + "title": "This document as GeoJSON", + "type": "application/geo+json", + "href": "http://localhost:8080/collections/example_mapsheets/items?f=json&limit=2" + }, + { + "rel": "alternate", + "title": "This document as JSON-FG", + "type": "application/vnd.ogc.fg+json", + "href": "http://localhost:8080/collections/example_mapsheets/items?f=jsonfg&limit=2" + }, + { + "rel": "alternate", + "title": "This document as HTML", + "type": "text/html", + "href": "http://localhost:8080/collections/example_mapsheets/items?f=html&limit=2" + }, + { + "rel": "next", + "title": "Next page", + "type": "application/geo+json", + "href": "http://localhost:8080/collections/example_mapsheets/items?cursor=Dv4%7CNwyr1Q&f=json&limit=2" + } + ], + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 120919.942, + 489320.199 + ] + }, + "properties": { + "datum_doc": "1900-01-01", + "datum_eind": null, + "datum_strt": "1900-01-01", + "document": "GV00000402", + "huisletter": null, + "huisnummer": 14, + "nummer_id": "0363200000454013", + "postcode": "1013CR", + "rdf_seealso": "http://bag.basisregistraties.overheid.nl/bag/id/nummeraanduiding/0363200000454013", + "status": "Naamgeving uitgegeven", + "straatnaam": "Van Diemenkade", + "toevoeging": null, + "type": "Ligplaats", + "woonplaats": "Amsterdam" + }, + "id": "3542", + "links": [ + { + "rel": "enclosure", + "title": "Download feature", + "type": "application/octet-stream", + "href": "http://bag.basisregistraties.overheid.nl/bag/id/nummeraanduiding/0363200000454013" + } + ] + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 121108.424, + 488930.925 + ] + }, + "properties": { + "datum_doc": "1900-01-01", + "datum_eind": null, + "datum_strt": "1900-01-01", + "document": "GV00000402", + "huisletter": null, + "huisnummer": 9, + "nummer_id": "0363200000398886", + "postcode": "1013KW", + "rdf_seealso": "http://bag.basisregistraties.overheid.nl/bag/id/nummeraanduiding/0363200000398886", + "status": "Naamgeving uitgegeven", + "straatnaam": "Realengracht", + "toevoeging": null, + "type": "Ligplaats", + "woonplaats": "Amsterdam" + }, + "id": "3837", + "links": [ + { + "rel": "enclosure", + "title": "Download feature", + "type": "application/octet-stream", + "href": "http://bag.basisregistraties.overheid.nl/bag/id/nummeraanduiding/0363200000398886" + } + ] + } + ], + "numberReturned": 2 +} diff --git a/viewer/src/app/feature-view/boxControl.ts b/viewer/src/app/feature-view/boxcontrol.ts similarity index 100% rename from viewer/src/app/feature-view/boxControl.ts rename to viewer/src/app/feature-view/boxcontrol.ts diff --git a/viewer/src/app/feature-view/feature-view.component.ts b/viewer/src/app/feature-view/feature-view.component.ts index 475150fb..be41b95a 100644 --- a/viewer/src/app/feature-view/feature-view.component.ts +++ b/viewer/src/app/feature-view/feature-view.component.ts @@ -16,11 +16,11 @@ import { Circle, Fill, Stroke, Style, Text } from 'ol/style' import WMTSTileGrid from 'ol/tilegrid/WMTS' import { take } from 'rxjs/operators' import { environment } from 'src/environments/environment' -import { DataUrl, FeatureServiceService, ProjectionMapping, defaultMapping } from '../feature-service.service' +import { DataUrl, FeatureService, ProjectionMapping, defaultMapping } from '../feature.service' import { getRijksdriehoek } from '../map-projection' import { NgChanges } from '../vectortile-view/vectortile-view.component' -import { BoxControl, emitBox } from './boxControl' -import { FullBoxControl } from './fullBoxControl' +import { BoxControl, emitBox } from './boxcontrol' +import { FullBoxControl } from './fullboxcontrol' import { Types as BrowserEventType } from 'ol/MapBrowserEventType' import { Options as TextOptions } from 'ol/style/Text' import { getPointResolution, get as getProjection, transform } from 'ol/proj' @@ -95,7 +95,7 @@ export class FeatureViewComponent implements OnChanges, AfterViewInit { constructor( private el: ElementRef, - private featureService: FeatureServiceService, + private featureService: FeatureService, private logger: NGXLogger ) {} diff --git a/viewer/src/app/feature-view/fullBoxControl.ts b/viewer/src/app/feature-view/fullboxcontrol.ts similarity index 95% rename from viewer/src/app/feature-view/fullBoxControl.ts rename to viewer/src/app/feature-view/fullboxcontrol.ts index fb93e615..11b8d83f 100644 --- a/viewer/src/app/feature-view/fullBoxControl.ts +++ b/viewer/src/app/feature-view/fullboxcontrol.ts @@ -1,7 +1,7 @@ import { Control } from 'ol/control.js' import { EventEmitter } from '@angular/core' -import { emitBox } from './boxControl' +import { emitBox } from './boxcontrol' import { Geometry } from 'ol/geom' import { fromExtent } from 'ol/geom/Polygon' diff --git a/viewer/src/app/feature-service.service.ts b/viewer/src/app/feature.service.ts similarity index 98% rename from viewer/src/app/feature-service.service.ts rename to viewer/src/app/feature.service.ts index e714d15f..9c3e7579 100644 --- a/viewer/src/app/feature-service.service.ts +++ b/viewer/src/app/feature.service.ts @@ -68,12 +68,13 @@ export type DataUrl = { url: string dataMapping: ProjectionMapping } + export const defaultMapping: ProjectionMapping = { dataProjection: 'EPSG:4326', visualProjection: 'EPSG:3857' } @Injectable({ providedIn: 'root', }) -export class FeatureServiceService { +export class FeatureService { constructor( private logger: NGXLogger, private http: HttpClient