Velopark: first decent, working version

This commit is contained in:
Pieter Vander Vennet 2024-04-05 17:49:31 +02:00
parent 890816d2dd
commit 5b6cd1d2ae
18 changed files with 7054 additions and 21769 deletions

View file

@ -81,7 +81,10 @@
"render": {
"special": {
"type": "linked_data_from_website",
"key": "ref:velopark"
"key": "ref:velopark",
"useProxy": "no",
"host": "https://data.velopark.be",
"mode": "readonly"
}
}
},
@ -328,7 +331,9 @@
"render": {
"special": {
"type": "linked_data_from_website",
"key": "ref:velopark"
"key": "ref:velopark",
"useProxy": "no",
"host": "https://data.velopark.be"
}
}
}

27048
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -41,7 +41,7 @@
],
"country_coder_host": "https://raw.githubusercontent.com/pietervdvn/MapComplete-data/main/latlon2country",
"nominatimEndpoint": "https://geocoding.geofabrik.de/b75350b1cfc34962ac49824fe5b582dc/",
"jsonld-proxy": "https://cache.mapcomplete.org/extractgraph?url={url}",
"jsonld-proxy": "http://127.0.0.1:2346/extractgraph?url={url}",
"protomaps": {
"api-key": "2af8b969a9e8b692",
"endpoint": "https://api.protomaps.com/tiles/",
@ -125,7 +125,9 @@
"not op_mini all"
],
"dependencies": {
"@comunica/query-sparql": "^2.10.2",
"@comunica/core": "^3.0.1",
"@comunica/query-sparql": "^3.0.1",
"@comunica/query-sparql-link-traversal": "^0.3.0",
"@rgossiaux/svelte-headlessui": "^1.0.2",
"@rgossiaux/svelte-heroicons": "^0.1.2",
"@rollup/plugin-typescript": "^11.0.0",

View file

@ -176,7 +176,7 @@ export default class ScriptUtils {
const requestPromise = new Promise((resolve, reject) => {
try {
headers = headers ?? {}
headers.accept = "application/json"
headers.accept ??= "application/json"
console.log(" > ScriptUtils.Download(", url, ")")
const urlObj = new URL(url)
const request = https.get(

View file

@ -0,0 +1,22 @@
import Script from "./Script"
import LinkedDataLoader from "../src/Logic/Web/LinkedDataLoader"
import { writeFileSync } from "fs"
export default class DownloadLinkedDataList extends Script {
constructor() {
super("Downloads the localBusinesses from the given location. Usage: url [--no-proxy]")
}
async main([url, noProxy]: string[]): Promise<void> {
const useProxy = noProxy !== "--no-proxy"
const data = await LinkedDataLoader.fetchJsonLd(url, {}, useProxy)
const path = "linked_data_"+url.replace(/[^a-zA-Z0-9_]/g, "_")+".jsonld"
writeFileSync(path,
JSON.stringify(data),
"utf8"
)
console.log("Written",path)
}
}
new DownloadLinkedDataList().run()

View file

@ -15,7 +15,7 @@ class CompareWebsiteData extends Script {
if(fs.existsSync(filename)){
return JSON.parse(fs.readFileSync(filename, "utf-8"))
}
const jsonLd = await LinkedDataLoader.fetchJsonLdWithProxy(url)
const jsonLd = await LinkedDataLoader.fetchJsonLd(url, undefined, true)
console.log("Got:", jsonLd)
fs.writeFileSync(filename, JSON.stringify(jsonLd))
return jsonLd

View file

@ -8,7 +8,8 @@ export class Server {
},
handle: {
mustMatch: string | RegExp
mimetype: string
mimetype: string,
addHeaders?: Record<string, string>,
handle: (path: string, queryParams: URLSearchParams) => Promise<string>
}[]
) {
@ -30,18 +31,18 @@ export class Server {
})
http.createServer(async (req: http.IncomingMessage, res) => {
try {
console.log(
req.method + " " + req.url,
"from:",
req.headers.origin,
new Date().toISOString()
)
const url = new URL(`http://127.0.0.1/` + req.url)
let path = url.pathname
while (path.startsWith("/")) {
path = path.substring(1)
}
console.log(
req.method + " " + req.url,
"from:",
req.headers.origin,
new Date().toISOString(),
path
)
if (options?.ignorePathPrefix) {
for (const toIgnore of options.ignorePathPrefix) {
if (path.startsWith(toIgnore)) {
@ -90,7 +91,11 @@ export class Server {
try {
const result = await handler.handle(path, url.searchParams)
res.writeHead(200, { "Content-Type": handler.mimetype })
if(typeof result !== "string"){
console.error("Internal server error: handling", url,"resulted in a ",typeof result," instead of a string:", result)
}
const extraHeaders = handler.addHeaders ?? {}
res.writeHead(200, { "Content-Type": handler.mimetype , ...extraHeaders})
res.write(result)
res.end()
} catch (e) {

View file

@ -15,8 +15,12 @@ class ServerLdScrape extends Script {
{
mustMatch: "extractgraph",
mimetype: "application/ld+json",
addHeaders: {
"Cache-control":"max-age=3600, public"
},
async handle(content, searchParams: URLSearchParams) {
const url = searchParams.get("url")
console.log("URL", url)
if (cache[url] !== undefined) {
const { date, contents } = cache[url]
console.log(">>>", date, contents)
@ -37,6 +41,15 @@ class ServerLdScrape extends Script {
return "{\"#\":\"timout reached\"}"
}
} while (dloaded["redirect"])
if(dloaded["content"].startsWith("{")){
// This is probably a json
const snippet = JSON.parse(dloaded["content"])
console.log("Snippet is", snippet)
cache[url] = { contents: snippet, date: new Date() }
return JSON.stringify(snippet)
}
const parsed = parse(dloaded["content"])
const scripts = Array.from(parsed.getElementsByTagName("script"))
for (const script of scripts) {

View file

@ -2,8 +2,8 @@ import Script from "../Script"
import fs from "fs"
import { Feature, FeatureCollection } from "geojson"
import { GeoOperations } from "../../src/Logic/GeoOperations"
import * as os from "os"
// vite-node scripts/velopark/compare.ts -- scripts/velopark/velopark_all_2024-02-14T12\:18\:41.772Z.geojson ~/Projecten/OSM/Fietsberaad/2024-02-02\ Fietsenstallingen_OSM_met_velopark_ref.geojson
// vite-node scripts/velopark/compare.ts -- scripts/velopark/velopark_all.geojson osm_with_velopark_link_.geojson
class Compare extends Script {
compare(
veloId: string,
@ -30,6 +30,9 @@ class Compare extends Script {
Object.keys(osmParking.properties).concat(Object.keys(veloParking.properties))
)
for (const key of allKeys) {
if(["name","numberOfLevels"].indexOf(key) >= 0){
continue // We don't care about these tags
}
if (osmParking.properties[key] === veloParking.properties[key]) {
continue
}
@ -42,16 +45,22 @@ class Compare extends Script {
diffs.push({
key,
osm: osmParking.properties[key],
velopark: veloParking.properties[key],
velopark: veloParking.properties[key]
})
}
let osmid = osmParking.properties["@id"] ?? osmParking["id"] /*Not in the properties, that is how overpass returns it*/
if (!osmid.startsWith("http")) {
osmid = "https://openstreetmap.org/" + osmid
}
return {
ref: veloId,
osmid: osmParking.properties["@id"],
osmid,
distance,
diffs,
diffs
}
}
async main(args: string[]): Promise<void> {
let [velopark, osm, key] = args
key ??= "ref:velopark"
@ -60,7 +69,7 @@ class Compare extends Script {
const veloparkById: Record<string, Feature> = {}
for (const parking of veloparkData.features) {
veloparkById[parking.properties[key]] = parking
veloparkById[parking.properties[key] ?? parking.properties.url] = parking
}
const diffs = []
@ -73,9 +82,12 @@ class Compare extends Script {
}
diffs.push(this.compare(veloId, parking, veloparking))
}
console.log("Found ", diffs.length, " items with differences between OSM and the provided data")
fs.writeFileSync("report_diff.json", JSON.stringify(diffs))
fs.writeFileSync("report_diff.json", JSON.stringify(diffs, null, " "))
console.log("Written report_diff.json")
}
constructor() {
super(
"Compares a velopark geojson with OSM geojson. Usage: `compare velopark.geojson osm.geojson [key-to-compare-on]`. If key-to-compare-on is not given, `ref:velopark` will be used"

View file

@ -1,75 +1,175 @@
import Script from "../Script"
import fs from "fs"
import LinkedDataLoader from "../../src/Logic/Web/LinkedDataLoader"
import { Utils } from "../../src/Utils"
import { Feature } from "geojson"
import { BBox } from "../../src/Logic/BBox"
import { Overpass } from "../../src/Logic/Osm/Overpass"
import { RegexTag } from "../../src/Logic/Tags/RegexTag"
import Constants from "../../src/Models/Constants"
import { ImmutableStore } from "../../src/Logic/UIEventSource"
import { BBox } from "../../src/Logic/BBox"
import LinkedDataLoader from "../../src/Logic/Web/LinkedDataLoader"
import Constants from "../../src/Models/Constants"
class VeloParkToGeojson extends Script {
constructor() {
super(
"Downloads the latest Velopark data and converts it to a geojson, which will be saved at the current directory",
"Downloads the latest Velopark data and converts it to a geojson, which will be saved at the current directory"
)
}
exportTo(filename: string, features) {
features = features.slice(0,25) // TODO REMOVE
const file = filename + "_" + /*new Date().toISOString() + */".geojson"
private static exportGeojsonTo(filename: string, features: Feature[], extension = ".geojson") {
const file = filename + "_" + /*new Date().toISOString() + */extension
fs.writeFileSync(file,
JSON.stringify(
{
extension === ".geojson" ? {
type: "FeatureCollection",
"#":"Only 25 features are shown!", // TODO REMOVE
features,
},
features
} : features,
null,
" ",
),
" "
)
console.log("Written",file)
)
console.log("Written", file, "("+features.length," features)")
}
async main(args: string[]): Promise<void> {
public static sumProperties(data: object, addTo: Record<string, Set<string>>) {
delete data["@context"]
for (const k in data) {
if (k === "@graph") {
for (const obj of data["@graph"]) {
this.sumProperties(obj, addTo)
}
continue
}
if (addTo[k] === undefined) {
addTo[k] = new Set<string>()
}
addTo[k].add(data[k])
}
}
private static async downloadData() {
console.log("Downloading velopark data")
// Download data for NIS-code 1000. 1000 means: all of belgium
const url = "https://www.velopark.be/api/parkings/1000"
const allVelopark = await LinkedDataLoader.fetchJsonLd(url, { country: "be" })
this.exportTo("velopark_all", allVelopark)
const allVeloparkRaw: { url: string }[] = await Utils.downloadJson(url)
let failed = 0
console.log("Got", allVeloparkRaw.length, "items")
const allVelopark: Feature[] = []
const allProperties = {}
for (let i = 0; i < allVeloparkRaw.length; i++) {
const f = allVeloparkRaw[i]
console.log("Handling", i + "/" + allVeloparkRaw.length)
try {
const cachePath = "/home/pietervdvn/data/velopark_cache/" + f.url.replace(/[/:.]/g, "_")
if (!fs.existsSync(cachePath)) {
const data = await Utils.downloadJson(f.url)
fs.writeFileSync(cachePath, JSON.stringify(data), "utf-8")
console.log("Saved a backup to", cachePath)
}
this.sumProperties(JSON.parse(fs.readFileSync(cachePath, "utf-8")), allProperties)
const linkedData = await LinkedDataLoader.fetchVeloparkEntry(f.url)
for (const sectionId in linkedData) {
const sectionInfo = linkedData[sectionId]
if (Object.keys(sectionInfo).length === 0) {
console.warn("No result for", f.url)
}
sectionInfo["ref:velopark"] = [sectionId ?? f.url]
allVelopark.push(sectionInfo)
}
} catch (e) {
console.error("Loading ", f.url, " failed due to", e)
failed++
}
}
console.log("Fetching data done, got ", allVelopark.length + "/" + allVeloparkRaw.length, "failed:", failed)
VeloParkToGeojson.exportGeojsonTo("velopark_all.geojson", allVelopark)
for (const k in allProperties) {
allProperties[k] = Array.from(allProperties[k])
}
fs.writeFileSync("all_properties_mashup.json", JSON.stringify(allProperties, null, " "))
return allVelopark
}
private static loadFromFile(): Feature[] {
return JSON.parse(fs.readFileSync("velopark_all.geojson", "utf-8")).features
}
private static exportExtraAmenities(allVelopark: Feature[]) {
const amenities: Record<string, Feature[]> = {}
for (const bikeparking of allVelopark) {
const props = bikeparking.properties
if (!props["fixme_nearby_amenity"]) {
continue
}
if (props["fixme_nearby_amenity"]?.endsWith("CameraSurveillance")) {
delete props["fixme_nearby_amenity"]
continue
}
const amenity = props["fixme_nearby_amenity"].split("#")[1]
if (!amenities[amenity]) {
amenities[amenity] = []
}
amenities[amenity].push(bikeparking)
}
for (const k in amenities) {
this.exportGeojsonTo("velopark_amenity_" + k + ".geojson", amenities[k])
}
}
private static async createDiff(allVelopark: Feature[]) {
const bboxBelgium = new BBox([
[2.51357303225, 49.5294835476],
[6.15665815596, 51.4750237087],
[6.15665815596, 51.4750237087]
])
const alreadyLinkedQuery = new Overpass(
new RegexTag("ref:velopark", /.+/),
[],
Constants.defaultOverpassUrls[0],
new ImmutableStore(60 * 5),
false,
false
)
const alreadyLinkedFeatures = await alreadyLinkedQuery.queryGeoJson(bboxBelgium)
const alreadyLinkedFeatures = (await alreadyLinkedQuery.queryGeoJson(bboxBelgium))[0]
const seenIds = new Set<string>(
alreadyLinkedFeatures[0].features.map((f) => f.properties["ref:velopark"]),
alreadyLinkedFeatures.features.map((f) => f.properties?.["ref:velopark"])
)
this.exportGeojsonTo("osm_with_velopark_link", <Feature[]> alreadyLinkedFeatures.features)
console.log("OpenStreetMap contains", seenIds.size, "bicycle parkings with a velopark ref")
const features = allVelopark.filter((f) => !seenIds.has(f.properties["ref:velopark"]))
const features: Feature[] = allVelopark.filter((f) => !seenIds.has(f.properties["ref:velopark"]))
VeloParkToGeojson.exportGeojsonTo("velopark_nonsynced", features)
const allProperties = new Set<string>()
for (const feature of features) {
Object.keys(feature.properties).forEach((k) => allProperties.add(k))
Object.keys(feature).forEach((k) => allProperties.add(k))
}
this.exportTo("velopark_noncynced", features)
allProperties.delete("ref:velopark")
for (const feature of features) {
allProperties.forEach((k) => {
delete feature.properties[k]
delete feature[k]
})
}
this.exportTo("velopark_nonsynced_id_only", features)
this.exportGeojsonTo("velopark_nonsynced_id_only", features)
}
async main(args: string[]): Promise<void> {
const allVelopark = VeloParkToGeojson.loadFromFile() // VeloParkToGeojson.downloadData()
console.log("Got", allVelopark.length, " items")
// VeloParkToGeojson.exportExtraAmenities(allVelopark)
await VeloParkToGeojson.createDiff(allVelopark)
console.log("Use vite-node script/velopark/compare to compare the results and generate a diff file")
}
}

View file

@ -1,4 +1,4 @@
import type { Geometry } from "geojson"
import type { Feature, GeoJSON, Geometry, Polygon } from "geojson"
import jsonld from "jsonld"
import { OH, OpeningHour } from "../../UI/OpeningHours/OpeningHours"
import { Utils } from "../../Utils"
@ -7,10 +7,14 @@ import EmailValidator from "../../UI/InputElement/Validators/EmailValidator"
import { Validator } from "../../UI/InputElement/Validator"
import UrlValidator from "../../UI/InputElement/Validators/UrlValidator"
import Constants from "../../Models/Constants"
import TypedSparql, { default as S, SparqlResult } from "./TypedSparql"
interface JsonLdLoaderOptions {
country?: string
}
type PropertiesSpec<T extends string> = Partial<Record<T, string | string[] | Partial<Record<T, string>>>>
export default class LinkedDataLoader {
private static readonly COMPACTING_CONTEXT = {
name: "http://schema.org/name",
@ -20,18 +24,23 @@ export default class LinkedDataLoader {
image: { "@id": "http://schema.org/image", "@type": "@id" },
opening_hours: { "@id": "http://schema.org/openingHoursSpecification" },
openingHours: { "@id": "http://schema.org/openingHours", "@container": "@set" },
geo: { "@id": "http://schema.org/geo" },
geo: { "@id": "http://schema.org/geo" }
}
private static COMPACTING_CONTEXT_OH = {
dayOfWeek: { "@id": "http://schema.org/dayOfWeek", "@container": "@set" },
closes: { "@id": "http://schema.org/closes" },
opens: { "@id": "http://schema.org/opens" },
closes: {
"@id": "http://schema.org/closes",
"@type": "http://www.w3.org/2001/XMLSchema#time"
},
opens: {
"@id": "http://schema.org/opens",
"@type": "http://www.w3.org/2001/XMLSchema#time"
}
}
private static formatters: Record<string, Validator> = {
phone: new PhoneValidator(),
email: new EmailValidator(),
website: new UrlValidator(undefined, undefined, true),
website: new UrlValidator(undefined, undefined, true)
}
private static ignoreKeys = [
"http://schema.org/logo",
@ -44,36 +53,67 @@ export default class LinkedDataLoader {
"http://schema.org/description",
"http://schema.org/hasMap",
"http://schema.org/priceRange",
"http://schema.org/contactPoint",
"http://schema.org/contactPoint"
]
private static ignoreTypes = [
"Breadcrumblist",
"http://schema.org/SearchAction"
]
private static shapeToPolygon(str: string): Polygon {
const polygon = str.substring("POLYGON ((".length, str.length - 2)
return <Polygon>{
type: "Polygon",
coordinates: [polygon.split(",").map(coors => coors.trim().split(" ").map(n => Number(n)))]
}
}
private static async geoToGeometry(geo): Promise<Geometry> {
if (Array.isArray(geo)) {
const features = await Promise.all(geo.map(g => LinkedDataLoader.geoToGeometry(g)))
const polygon = features.find(f => f.type === "Polygon")
if (polygon) {
return polygon
}
const ls = features.find(f => f.type === "LineString")
if (ls) {
return ls
}
return features[0]
}
if (geo["@type"] === "http://schema.org/GeoCoordinates") {
static async geoToGeometry(geo): Promise<Geometry> {
const context = {
lat: {
"@id": "http://schema.org/latitude",
"@type": "http://www.w3.org/2001/XMLSchema#double"
},
lon: {
"@id": "http://schema.org/longitude", // TODO formatting to decimal should be possible from this type?
},
"@id": "http://schema.org/longitude",
"@type": "http://www.w3.org/2001/XMLSchema#double"
}
}
const flattened = await jsonld.compact(geo, context)
return {
type: "Point",
coordinates: [Number(flattened.lon), Number(flattened.lat)],
coordinates: [Number(flattened.lon), Number(flattened.lat)]
}
}
if (geo["@type"] === "http://schema.org/GeoShape" && geo["http://schema.org/polygon"] !== undefined) {
const str = geo["http://schema.org/polygon"]["@value"]
LinkedDataLoader.shapeToPolygon(str)
}
throw "Unsupported geo type: " + geo["@type"]
}
/**
* Parses http://schema.org/openingHours
*
* // Weird data format from C&A
* LinkedDataLoader.ohStringToOsmFormat("MO 09:30-18:00 TU 09:30-18:00 WE 09:30-18:00 TH 09:30-18:00 FR 09:30-18:00 SA 09:30-18:00") // => "Mo-Sa 09:30-18:00"
* LinkedDataLoader.ohStringToOsmFormat("MO 09:30-18:00 TU 09:30-18:00 WE 09:30-18:00 TH 09:30-18:00 FR 09:30-18:00 SA 09:30-18:00 SU 09:30-18:00") // => "09:30-18:00"
*
*/
static ohStringToOsmFormat(oh: string) {
oh = oh.toLowerCase()
@ -82,7 +122,7 @@ export default class LinkedDataLoader {
}
const regex = /([a-z]+ [0-9:]+-[0-9:]+) (.*)/
let match = oh.match(regex)
let parts: string[] = []
const parts: string[] = []
while (match) {
parts.push(match[1])
oh = match[2]
@ -94,15 +134,29 @@ export default class LinkedDataLoader {
return OH.simplify(parts.join(";"))
}
static async ohToOsmFormat(openingHoursSpecification): Promise<string> {
const compacted = await jsonld.flatten(
static async ohToOsmFormat(openingHoursSpecification): Promise<string | undefined> {
if (typeof openingHoursSpecification === "string") {
return openingHoursSpecification
}
const compacted = await jsonld.compact(
openingHoursSpecification,
<any>LinkedDataLoader.COMPACTING_CONTEXT_OH
)
const spec: any = compacted["@graph"]
let allRules: OpeningHour[] = []
const spec: object = compacted["@graph"]
if (!spec) {
return undefined
}
const allRules: OpeningHour[] = []
for (const rule of spec) {
const dow: string[] = rule.dayOfWeek.map((dow) => dow.toLowerCase().substring(0, 2))
const dow: string[] = rule.dayOfWeek.map((dow) => {
if (typeof dow !== "string") {
dow = dow["@id"]
}
if (dow.startsWith("http://schema.org/")) {
dow = dow.substring("http://schema.org/".length)
}
return dow.toLowerCase().substring(0, 2)
})
const opens: string = rule.opens
const closes: string = rule.closes === "23:59" ? "24:00" : rule.closes
allRules.push(...OH.ParseRule(dow + " " + opens + "-" + closes))
@ -111,25 +165,15 @@ export default class LinkedDataLoader {
return OH.ToString(OH.MergeTimes(allRules))
}
static async fetchJsonLdWithProxy(url: string, options?: JsonLdLoaderOptions): Promise<any> {
const urlWithProxy = Constants.linkedDataProxy.replace("{url}", encodeURIComponent(url))
return await this.fetchJsonLd(urlWithProxy, options)
static async compact(data: object, options?: JsonLdLoaderOptions): Promise<object> {
if (Array.isArray(data)) {
return await Promise.all(data.map(point => LinkedDataLoader.compact(point, options)))
}
/**
*
*
* {
* "content": "{\"@context\":\"http://schema.org\",\"@type\":\"LocalBusiness\",\"@id\":\"http://stores.delhaize.be/nl/ad-delhaize-munsterbilzen\",\"name\":\"AD Delhaize Munsterbilzen\",\"url\":\"http://stores.delhaize.be/nl/ad-delhaize-munsterbilzen\",\"logo\":\"https://stores.delhaize.be/build/images/web/shop/delhaize-be/favicon.ico\",\"image\":\"http://stores.delhaize.be/image/mobilosoft-testing?apiPath=rehab/delhaize-be/images/location/ad%20delhaize%20image%20ge%CC%81ne%CC%81rale%20%281%29%201652787176865&imageSize=h_500\",\"email\":\"\",\"telephone\":\"+3289413520\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Waterstraat, 18\",\"addressLocality\":\"Bilzen\",\"postalCode\":\"3740\",\"addressCountry\":\"BE\"},\"geo\":{\"@type\":\"GeoCoordinates\",\"latitude\":50.8906898,\"longitude\":5.5260586},\"openingHoursSpecification\":[{\"@type\":\"OpeningHoursSpecification\",\"dayOfWeek\":\"Tuesday\",\"opens\":\"08:00\",\"closes\":\"18:30\"},{\"@type\":\"OpeningHoursSpecification\",\"dayOfWeek\":\"Wednesday\",\"opens\":\"08:00\",\"closes\":\"18:30\"},{\"@type\":\"OpeningHoursSpecification\",\"dayOfWeek\":\"Thursday\",\"opens\":\"08:00\",\"closes\":\"18:30\"},{\"@type\":\"OpeningHoursSpecification\",\"dayOfWeek\":\"Friday\",\"opens\":\"08:00\",\"closes\":\"18:30\"},{\"@type\":\"OpeningHoursSpecification\",\"dayOfWeek\":\"Saturday\",\"opens\":\"08:00\",\"closes\":\"18:30\"},{\"@type\":\"OpeningHoursSpecification\",\"dayOfWeek\":\"Sunday\",\"opens\":\"08:00\",\"closes\":\"12:00\"},{\"@type\":\"OpeningHoursSpecification\",\"dayOfWeek\":\"Monday\",\"opens\":\"12:00\",\"closes\":\"18:30\"}],\"@base\":\"https://stores.delhaize.be/nl/ad-delhaize-munsterbilzen\"}"
* }
*/
private static async compact(data: any, options?: JsonLdLoaderOptions): Promise<any>{
console.log("Compacting",data)
if(Array.isArray(data)) {
return await Promise.all(data.map(d => LinkedDataLoader.compact(d)))
}
const country = options?.country
const compacted = await jsonld.compact(data, <any>LinkedDataLoader.COMPACTING_CONTEXT)
compacted["opening_hours"] = await LinkedDataLoader.ohToOsmFormat(
compacted["opening_hours"]
)
@ -143,6 +187,8 @@ export default class LinkedDataLoader {
if (compacted["geo"]) {
compacted["geo"] = <any>await LinkedDataLoader.geoToGeometry(compacted["geo"])
}
for (const k in compacted) {
if (compacted[k] === "") {
delete compacted[k]
@ -161,10 +207,14 @@ export default class LinkedDataLoader {
}
}
}
return <any>compacted
return compacted
}
static async fetchJsonLd(url: string, options?: JsonLdLoaderOptions): Promise<any> {
static async fetchJsonLd(url: string, options?: JsonLdLoaderOptions, useProxy: boolean = false): Promise<object> {
if (useProxy) {
url = Constants.linkedDataProxy.replace("{url}", encodeURIComponent(url))
}
const data = await Utils.downloadJson(url)
return await LinkedDataLoader.compact(data, options)
}
@ -197,4 +247,384 @@ export default class LinkedDataLoader {
}
return d
}
static asGeojson(linkedData: Record<string, string[]>): Feature {
delete linkedData["@context"]
const properties: Record<string, string> = {}
for (const k in linkedData) {
if (linkedData[k].length > 1) {
throw "Found multiple values in properties for " + k + ": " + linkedData[k].join("; ")
}
properties[k] = linkedData[k].join("; ")
}
let geometry: Geometry = undefined
if (properties["latitude"] && properties["longitude"]) {
geometry = {
type: "Point",
coordinates: [Number(properties["longitude"]), Number(properties["latitude"])]
}
delete properties["latitude"]
delete properties["longitude"]
}
if (properties["shape"]) {
geometry = LinkedDataLoader.shapeToPolygon(properties["shape"])
}
const geo: GeoJSON = {
type: "Feature",
properties,
geometry
}
delete linkedData.geo
delete properties.shape
delete properties.type
delete properties.parking
delete properties.g
delete properties.section
return geo
}
private static patchVeloparkProperties(input: Record<string, Set<string>>): Record<string, string[]> {
const output: Record<string, string[]> = {}
for (const k in input) {
output[k] = Array.from(input[k])
}
function on(key: string, applyF: (s: string) => string) {
if (!output[key]) {
return
}
output[key] = output[key].map(v => applyF(v))
}
function asBoolean(key: string, invert: boolean = false) {
on(key, str => {
const isTrue = ("" + str) === "true" || str === "True" || str === "yes"
if (isTrue != invert) {
return "yes"
}
return "no"
})
}
on("maxstay", (maxstay => {
const match = maxstay.match(/P([0-9]+)D/)
if (match) {
const days = Number(match[1])
if (days === 1) {
return "1 day"
}
return days + " days"
}
return maxstay
}))
function rename(source: string, target: string) {
if (output[source] === undefined || output[source] === null) {
return
}
output[target] = output[source]
delete output[source]
}
on("phone", (p => new PhoneValidator().reformat(p, () => "be")))
on("charge", (p => {
if(Number(p) === 0){
output["fee"] = ["no"]
return undefined
}
return "€" + Number(p)
}))
if (output["charge"] && output["timeUnit"]) {
const duration = Number(output["chargeEnd"] ?? "1") - Number(output["chargeStart"] ?? "0")
const unit = output["timeUnit"][0]
let durationStr = ""
if (duration !== 1) {
durationStr = duration + ""
}
output["charge"] = output["charge"].map(c => c + "/" + (durationStr + unit))
}
delete output["chargeEnd"]
delete output["chargeStart"]
delete output["timeUnit"]
asBoolean("covered")
asBoolean("fee", true)
asBoolean("publicAccess")
output["images"]?.forEach((p, i) => {
if (i === 0) {
output["image"] = [p]
} else {
output["image:" + i] = [p]
}
})
delete output["images"]
on("access", audience => {
if (["brede publiek", "iedereen", "bezoekers", "iedereen - vooral bezoekers gemeentehuis of bibliotheek."].indexOf(audience.toLowerCase()) >= 0) {
return "public"
}
if(audience.toLowerCase().startsWith("bezoekers")){
return "public"
}
if (["abonnees"].indexOf(audience.toLowerCase()) >= 0) {
return "members"
}
if(audience.indexOf("Blue-locker app") >= 0){
return "members"
}
if (["buurtbewoners"].indexOf(audience.toLowerCase()) >= 0) {
return "permissive"
// return "members"
}
if(audience.toLowerCase().startsWith("klanten") ||
audience.toLowerCase().startsWith("werknemers") ||
audience.toLowerCase().startsWith("personeel")){
return "customers"
}
console.warn("Suspicious 'access'-tag:", audience, "for", input["ref:velopark"]," assuming public")
return "public"
})
if(output["publicAccess"]?.[0] == "no"){
output["access"] =[ "private"]
}
delete output["publicAccess"]
if (output["restrictions"]?.[0] === "Geen bromfietsen, noch andere gemotoriseerde voertuigen") {
output["motor_vehicle"] = ["no"]
delete output["restrictions"]
}
if (output["cargoBikeType"]) {
output["cargo_bike"] = ["yes"]
delete output["cargoBikeType"]
}
rename("capacityCargobike", "capacity:cargo_bike")
if (output["tandemBikeType"]) {
output["tandem"] = ["yes"]
delete output["tandemBikeType"]
}
rename("capacityTandem", "capacity:tandem")
if (output["electricBikeType"]) {
output["electric_bicycle"] = ["yes"]
delete output["electricBikeType"]
}
rename("capacityElectric", "capacity:electric_bicycle")
delete output["name"]
delete output["numberOfLevels"]
return output
}
private static async fetchVeloparkProperty<T extends string, G extends T>(url: string, property: string, variable?: string): Promise<SparqlResult<T, G>> {
const results = await new TypedSparql().typedSparql<T, G>(
{
schema: "http://schema.org/",
mv: "http://schema.mobivoc.org/",
gr: "http://purl.org/goodrelations/v1#",
vp: "https://data.velopark.be/openvelopark/vocabulary#",
vpt: "https://data.velopark.be/openvelopark/terms#"
},
[url],
undefined,
" ?parking a <http://schema.mobivoc.org/BicycleParkingStation>",
"?parking " + property + " " + (variable ?? "")
)
return results
}
private static async fetchVeloparkGraphProperty<T extends string>(url: string, property: string, subExpr?: string):
Promise<SparqlResult<T, "section">> {
const results = await new TypedSparql().typedSparql<T, "g">(
{
schema: "http://schema.org/",
mv: "http://schema.mobivoc.org/",
gr: "http://purl.org/goodrelations/v1#",
vp: "https://data.velopark.be/openvelopark/vocabulary#",
vpt: "https://data.velopark.be/openvelopark/terms#"
},
[url],
"g",
" ?parking a <http://schema.mobivoc.org/BicycleParkingStation>",
S.graph("g",
"?section " + property + " " + (subExpr ?? ""),
"?section a ?type"
)
)
return results
}
/**
* Merges many subresults into one result
* THis is a workaround for 'optional' not working decently
* @param r0
*/
public static mergeResults(...r0: SparqlResult<string, string>[]): SparqlResult<string, string> {
const r: SparqlResult<string> = { "default": {} }
for (const subResult of r0) {
if (Object.keys(subResult).length === 0) {
continue
}
for (const sectionKey in subResult) {
if (!r[sectionKey]) {
r[sectionKey] = {}
}
const section = subResult[sectionKey]
for (const key in section) {
r[sectionKey][key] ??= section[key]
}
}
}
if (r["default"] !== undefined && Object.keys(r).length > 1) {
for (const section in r) {
if (section === "default") {
continue
}
for (const k in r.default) {
r[section][k] ??= r.default[k]
}
}
delete r.default
}
return r
}
public static async fetchEntry<T extends string>(directUrl: string,
propertiesWithoutGraph: PropertiesSpec<T>,
propertiesInGraph: PropertiesSpec<T>,
extra?: string[]): Promise<SparqlResult<T, string>> {
const allPartialResults: SparqlResult<T, string>[] = []
for (const propertyName in propertiesWithoutGraph) {
const e = propertiesWithoutGraph[propertyName]
if (typeof e === "string") {
const variableName = e
const result = await this.fetchVeloparkProperty(directUrl, propertyName, "?" + variableName)
allPartialResults.push(result)
} else {
for (const subProperty in e) {
const variableName = e[subProperty]
const result = await this.fetchVeloparkProperty(directUrl,
propertyName, `[${subProperty} ?${variableName}] `)
allPartialResults.push(result)
}
}
}
for (const propertyName in propertiesInGraph ?? {}) {
const e = propertiesInGraph[propertyName]
if (Array.isArray(e)) {
for (const subquery of e) {
let variableName = subquery
if (variableName.match(/[a-zA-Z_]+/)) {
variableName = "?" + subquery
}
const result = await this.fetchVeloparkGraphProperty(directUrl, propertyName, variableName)
allPartialResults.push(result)
}
} else if (typeof e === "string") {
let variableName = e
if (variableName.match(/[a-zA-Z_]+/)) {
variableName = "?" + e
}
const result = await this.fetchVeloparkGraphProperty(directUrl, propertyName, variableName)
allPartialResults.push(result)
} else {
for (const subProperty in e) {
const variableName = e[subProperty]
const result = await this.fetchVeloparkGraphProperty(directUrl,
propertyName, `[${subProperty} ?${variableName}] `)
allPartialResults.push(result)
}
}
}
for (const e of extra) {
const r = await this.fetchVeloparkGraphProperty(directUrl, e)
allPartialResults.push(r)
}
const results = this.mergeResults(...allPartialResults)
return results
}
/**
* Fetches all data relevant to velopark.
* The id will be saved as `ref:velopark`
* @param url
*/
public static async fetchVeloparkEntry(url: string): Promise<Feature[]> {
const withProxyUrl = Constants.linkedDataProxy.replace("{url}", encodeURIComponent(url))
const optionalPaths: Record<string, string | Record<string, string>> = {
"schema:interactionService": {
"schema:url": "website"
},
"schema:name": "name",
"mv:operatedBy": {
"gr:legalName": "operator"
},
"schema:contactPoint": {
"schema:email": "email",
"schema:telephone": "phone"
}
}
const graphOptionalPaths = {
"vp:covered": "covered",
"vp:maximumParkingDuration": "maxstay",
"mv:totalCapacity": "capacity",
"schema:publicAccess": "publicAccess",
"schema:photos": "images",
"mv:numberOfLevels": "numberOfLevels",
"vp:intendedAudience": "access",
"schema:geo": {
"schema:latitude": "latitude",
"schema:longitude": "longitude",
"schema:polygon": "shape"
},
"schema:priceSpecification": {
"mv:freeOfCharge": "fee",
"schema:price": "charge"
},
"schema:amenityFeature": {
"a": "fixme_nearby_amenity"
}
}
const extra = [
"schema:priceSpecification [ mv:dueForTime [ mv:timeStartValue ?chargeStart; mv:timeEndValue ?chargeEnd; mv:timeUnit ?timeUnit ] ]",
"vp:allows [vp:bicycleType <https://data.velopark.be/openvelopark/terms#CargoBicycle>; vp:bicyclesAmount ?capacityCargobike; vp:bicycleType ?cargoBikeType]",
"vp:allows [vp:bicycleType <https://data.velopark.be/openvelopark/terms#ElectricBicycle>; vp:bicyclesAmount ?capacityElectric; vp:bicycleType ?electricBikeType]",
"vp:allows [vp:bicycleType <https://data.velopark.be/openvelopark/terms#TandemBicycle>; vp:bicyclesAmount ?capacityTandem; vp:bicycleType ?tandemBikeType]"
]
const unpatched = await this.fetchEntry(withProxyUrl, optionalPaths, graphOptionalPaths, extra)
const patched: Feature[] = []
for (const section in unpatched) {
const p = LinkedDataLoader.patchVeloparkProperties(unpatched[section])
p["ref:velopark"] = [section]
patched.push(LinkedDataLoader.asGeojson(p))
}
return patched
}
}

View file

@ -0,0 +1,94 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { QueryEngine } from "@comunica/query-sparql"
export type SparqlVar<T extends string> = `?${T}`
export type SparqlExpr = string
export type SparqlStmt<T extends string> = `${SparqlVar<T> | SparqlExpr} ${SparqlVar<T> | SparqlExpr} ${SparqlVar<T> | SparqlExpr}`
export type TypedExpression<T extends string> = SparqlStmt<T> | string
export type SparqlResult<T extends string, G extends string = "default"> = Record<G, Record<T, Set<string>>>
export default class TypedSparql {
private readonly comunica: QueryEngine
constructor() {
this.comunica = new QueryEngine()
}
public static optional<Vars extends string>(...statements: (TypedExpression<Vars> | string)[]): TypedExpression<Vars> {
return ` OPTIONAL { ${statements.join(". \n\t")} }`
}
public static graph<Vars extends string>(varname: Vars, ...statements: (string | TypedExpression<Vars>)[]): TypedExpression<Vars> {
return `GRAPH ?${varname} { ${statements.join(".\n")} }`
}
public static about<Vars extends string>(varname: Vars, ...statements: `${SparqlVar<Vars> | SparqlExpr} ${SparqlVar<Vars> | SparqlExpr}`[]): TypedExpression<Vars> {
return `?${varname} ${statements.join(";")}`
}
/**
* @param sources The source-urls where reading should start
* @param select all the variables name, without leading '?', e.g. ['s','p','o']
* @param query The main contents of the WHERE-part of the query
* @param prefixes the prefixes used by this query, e.g. {schema: 'http://schema.org/', vp: 'https://data.velopark.be/openvelopark/vocabulary#'}
* @param graphVariable optional: specify which variable has the tag data. If specified, the results will be tagged with the graph IRI
*/
public async typedSparql<VARS extends string, G extends string = undefined>(
prefixes: Record<string, string>,
sources: readonly [string, ...string[]], // array with at least one element
graphVariable: G | undefined,
...query: (TypedExpression<VARS> | string)[]
): Promise<SparqlResult<VARS, G>> {
const q: string = this.buildQuery(query, prefixes)
try {
const bindingsStream = await this.comunica.queryBindings(
q, { sources: [...sources], lenient: true }
)
const bindings = await bindingsStream.toArray()
const resultAllGraphs: SparqlResult<VARS, G> = <SparqlResult<VARS, G>>{}
bindings.forEach(item => {
const result = <Record<VARS | G, Set<string>>>{}
item.forEach(
(value, key) => {
if (!result[key.value]) {
result[key.value] = new Set()
}
result[key.value].add(value.value)
}
)
if (graphVariable && result[graphVariable]?.size > 0) {
const id = Array.from(result[graphVariable])?.[0] ?? "default"
resultAllGraphs[id] = result
} else {
resultAllGraphs["default"] = result
}
})
return resultAllGraphs
} catch (e) {
console.log("Running query failed. The query is", q)
throw e
}
}
private buildQuery(
query: readonly string[],
prefixes: Record<string, string>): string {
return `
${Object.keys(prefixes).map(prefix => `PREFIX ${prefix}: <${prefixes[prefix]}>`).join("\n")}
SELECT *
WHERE {
${query.join(". \n")} .
}
`
}
static values<VARS extends string>(varname: VARS, ...values: string[]): TypedExpression<VARS> {
return `VALUES ?${varname} { ${values.join(" ")} }`
}
}

View file

@ -69,12 +69,17 @@
</div>
{:else}
<div class="low-interaction p-1 border-interactive">
{#if !readonly}
<Tr t={t.loadedFrom.Subs({url: sourceUrl, source: sourceUrl})} />
<h3>
<Tr t={t.conflicting.title} />
</h3>
<div class="flex flex-col gap-y-8">
{/if}
<div class="flex flex-col" class:gap-y-8={!readonly}>
{#if !readonly}
<Tr t={t.conflicting.intro} />
{/if}
{#if different.length > 0}
{#each different as key}
<div class="mx-2 rounded-2xl">

View file

@ -32,7 +32,7 @@ export class OH {
th: 3,
fr: 4,
sa: 5,
su: 6,
su: 6
}
public static hhmm(h: number, m: number): string {
@ -82,7 +82,7 @@ export class OH {
const stringPerWeekday = partsPerWeekday.map((parts) => parts.sort().join(", "))
const rules = []
const rules: string[] = []
let rangeStart = 0
let rangeEnd = 0
@ -107,11 +107,17 @@ export class OH {
}
pushRule()
const oh = rules.join("; ")
if (oh === "Mo-Su 00:00-00:00") {
if (rules.length === 1) {
const rule = rules[0]
if (rule === "Mo-Su 00:00-00:00") {
return "24/7"
}
return oh
if (rule.startsWith("Mo-Su ")) {
return rule.substring("Mo-Su ".length)
}
}
return rules.join("; ")
}
/**
@ -137,7 +143,7 @@ export class OH {
const queue = ohs.map((oh) => {
if (oh.endHour === 0 && oh.endMinutes === 0) {
const newOh = {
...oh,
...oh
}
newOh.endHour = 24
return newOh
@ -146,7 +152,7 @@ export class OH {
})
const newList = []
while (queue.length > 0) {
let maybeAdd = queue.pop()
const maybeAdd = queue.pop()
let doAddEntry = true
if (maybeAdd.weekday == undefined) {
@ -205,7 +211,7 @@ export class OH {
startMinutes: startMinutes,
endHour: endHour,
endMinutes: endMinutes,
weekday: guard.weekday,
weekday: guard.weekday
})
doAddEntry = false
@ -273,7 +279,7 @@ export class OH {
startHour: start.hours,
startMinutes: start.minutes,
endHour: end.hours,
endMinutes: end.minutes,
endMinutes: end.minutes
}
}
@ -331,8 +337,8 @@ export class OH {
startHour: 0,
startMinutes: 0,
endHour: 24,
endMinutes: 0,
},
endMinutes: 0
}
]
)
}
@ -350,10 +356,10 @@ export class OH {
const timeranges = OH.ParseHhmmRanges(split[1])
return OH.multiply(weekdays, timeranges)
}
return null
return []
} catch (e) {
console.log("Could not parse weekday rule ", rule)
return null
return []
}
}
@ -382,13 +388,13 @@ export class OH {
str = str.trim()
if (str.toLowerCase() === "ph off") {
return {
mode: "off",
mode: "off"
}
}
if (str.toLowerCase() === "ph open") {
return {
mode: "open",
mode: "open"
}
}
@ -404,7 +410,7 @@ export class OH {
return {
mode: " ",
start: OH.hhmm(timerange.startHour, timerange.startMinutes),
end: OH.hhmm(timerange.endHour, timerange.endMinutes),
end: OH.hhmm(timerange.endHour, timerange.endMinutes)
}
} catch (e) {
return null
@ -570,8 +576,8 @@ This list will be sorted
lon: tags._lon,
address: {
country_code: country.toLowerCase(),
state: undefined,
},
state: undefined
}
},
<any>{ tag_key: "opening_hours" }
)
@ -747,7 +753,7 @@ This list will be sorted
isOpen: iterator.getState(),
comment: iterator.getComment(),
startDate: iterator.getDate() as Date,
endDate: endDate, // Should be overwritten by the next iteration
endDate: endDate // Should be overwritten by the next iteration
}
prevValue = value
@ -885,7 +891,7 @@ This list will be sorted
startHour: timerange.startHour,
startMinutes: timerange.startMinutes,
endHour: timerange.endHour,
endMinutes: timerange.endMinutes,
endMinutes: timerange.endMinutes
})
} else {
ohs.push({
@ -893,14 +899,14 @@ This list will be sorted
startHour: timerange.startHour,
startMinutes: timerange.startMinutes,
endHour: 0,
endMinutes: 0,
endMinutes: 0
})
ohs.push({
weekday: (weekday + 1) % 7,
startHour: 0,
startMinutes: 0,
endHour: timerange.endHour,
endMinutes: timerange.endMinutes,
endMinutes: timerange.endMinutes
})
}
}
@ -961,7 +967,7 @@ export class ToTextualDescription {
"thursday",
"friday",
"saturday",
"sunday",
"sunday"
]
function addRange(start: number, end: number) {
@ -1019,7 +1025,7 @@ export class ToTextualDescription {
private static createRangeFor(range: OpeningRange): Translation {
return Translations.t.general.opening_hours.ranges.Subs({
starttime: ToTextualDescription.timeString(range.startDate),
endtime: ToTextualDescription.timeString(range.endDate),
endtime: ToTextualDescription.timeString(range.endDate)
})
}
@ -1031,7 +1037,7 @@ export class ToTextualDescription {
for (let i = 1; i < ranges.length; i++) {
tr = Translations.t.general.opening_hours.rangescombined.Subs({
range0: tr,
range1: ToTextualDescription.createRangeFor(ranges[i]),
range1: ToTextualDescription.createRangeFor(ranges[i])
})
}
return tr

File diff suppressed because it is too large Load diff

View file

@ -25,7 +25,7 @@
{/if}
{#if $wikipediaDetails.wikidata}
<ToSvelte construct={WikidataPreviewBox.WikidataResponsePreview($wikipediaDetails.wikidata)} />
<ToSvelte construct={() => WikidataPreviewBox.WikidataResponsePreview($wikipediaDetails.wikidata)} />
{/if}
{#if $wikipediaDetails.articleUrl}

View file

@ -1,4 +1,15 @@
import SvelteUIElement from "./UI/Base/SvelteUIElement"
import Test from "./UI/Test.svelte"
import LinkedDataLoader from "./Logic/Web/LinkedDataLoader"
import { src_url_equal } from "svelte/internal"
new SvelteUIElement(Test).AttachTo("maindiv")
const url_multiple_sections = "https://data.velopark.be/data/Stad-Deinze_14"
const url_single_section = "https://data.velopark.be/data/NMBS_764"
const url_with_shape = "https://data.velopark.be/data/Stad-Leuven_APCOA_018"
const url_with_yearly_charge = "https://data.velopark.be/data/Cyclopark_AL02"
const url = url_multiple_sections /*/ url_single_section //*/
const results = await LinkedDataLoader.fetchVeloparkEntry(url_with_yearly_charge)
console.log(results)

View file

@ -0,0 +1,275 @@
import { describe, expect, it } from "vitest"
import LinkedDataLoader from "../../../src/Logic/Web/LinkedDataLoader"
describe("LinkedDataLoader", () => {
it("should compact a shop entry", async () => {
const graph = {
"@context": "http://schema.org",
"@type": "LocalBusiness",
"@id": "http://stores.delhaize.be/nl/ad-delhaize-munsterbilzen",
"name": "AD Delhaize Munsterbilzen",
"url": "http://stores.delhaize.be/nl/ad-delhaize-munsterbilzen",
"logo": "https://stores.delhaize.be/build/images/web/shop/delhaize-be/favicon.ico",
"image": "http://stores.delhaize.be/image/mobilosoft-testing?apiPath=rehab/delhaize-be/images/location/ad%20delhaize%20image%20ge%CC%81ne%CC%81rale%20%281%29%201652787176865&imageSize=h_500",
"email": "",
"telephone": "+3289413520",
"address": {
"@type": "PostalAddress",
"streetAddress": "Waterstraat, 18",
"addressLocality": "Bilzen",
"postalCode": "3740",
"addressCountry": "BE",
},
"geo": {
"@type": "GeoCoordinates",
"latitude": 50.8906898,
"longitude": 5.5260586,
},
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": "Tuesday",
"opens": "08:00",
"closes": "18:30",
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": "Wednesday",
"opens": "08:00",
"closes": "18:30",
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": "Thursday",
"opens": "08:00",
"closes": "18:30",
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": "Friday",
"opens": "08:00",
"closes": "18:30",
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": "Saturday",
"opens": "08:00",
"closes": "18:30",
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": "Sunday",
"opens": "08:00",
"closes": "12:00",
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": "Monday",
"opens": "12:00",
"closes": "18:30",
},
],
"@base": "https://stores.delhaize.be/nl/ad-delhaize-munsterbilzen",
}
const compacted = await LinkedDataLoader.compact(graph)
expect(compacted.phone).equal("+32 89 41 35 20")
})
it("should handle velopark data", async () => {
const veloparkEntry = {
"@context": {
"xsd": "http://www.w3.org/2001/XMLSchema#",
"schema": "http://schema.org/",
"mv": "http://schema.mobivoc.org/",
"dct": "http://purl.org/dc/terms#",
"dbo": "http://dbpedia.org/ontology/",
"gr": "http://purl.org/goodrelations/v1#",
"vp": "https://data.velopark.be/openvelopark/vocabulary#",
"vpt": "https://data.velopark.be/openvelopark/terms#",
"PostalAddress": "schema:PostalAddress",
"GeoCoordinates": "schema:GeoCoordinates",
"GeoShape": "schema:GeoShape",
"Map": "schema:Map",
"ContactPoint": "schema:ContactPoint",
"Language": "schema:Language",
"OpeningHoursSpecification": "schema:OpeningHoursSpecification",
"WebSite": "schema:WebSite",
"PriceSpecification": "schema:PriceSpecification",
"Photograph": "schema:Photograph",
"Place": "schema:Place",
"BicycleParkingStation": "mv:BicycleParkingStation",
"Entrance": "mv:ParkingFacilityEntrance",
"Exit": "mv:ParkingFacilityExit",
"TimeSpecification": "mv:TimeSpecification",
"Bicycle": "vp:Bicycle",
"AllowedBicycle": "vp:AllowedBicycle",
"BikeParkingFeature": "vp:BikeParkingFeature",
"SecurityFeature": "vp:SecurityFeature",
"PublicBicycleParking": "vpt:PublicBicycleParking",
"ResidentBicycleParking": "vpt:ResidentBicycleParking",
"BicycleLocker": "vpt:BicycleLocker",
"RegularBicycle": "vpt:RegularBicycle",
"ElectricBicycle": "vpt:ElectricBicycle",
"CargoBicycle": "vpt:CargoBicycle",
"TandemBicycle": "vpt:TandemBicycle",
"CameraSurveillance": "vpt:CameraSurveillance",
"PersonnelSupervision": "vpt:PersonnelSupervision",
"ElectronicAccess": "vpt:ElectronicAccess",
"BicyclePump": "vpt:BicyclePump",
"MaintenanceService": "vpt:MaintenanceService",
"ChargingPoint": "vpt:ChargingPoint",
"LockerService": "vpt:LockerService",
"ToiletService": "vpt:ToiletService",
"BikeRentalService": "vpt:BikeRentalService",
"BusinessEntity": "gr:BusinessEntity",
"address": "schema:address",
"geo": "schema:geo",
"hasMap": "schema:hasMap",
"url": "schema:url",
"image": "schema:image",
"contactPoint": "schema:contactPoint",
"interactionService": "schema:interactionService",
"dueForTime": "mv:dueForTime",
"ownedBy": "mv:ownedBy",
"operatedBy": "mv:operatedBy",
"rights": "dct:rights",
"about": { "@id": "schema:about", "@type": "@id" },
"description": { "@id": "schema:description", "@type": "xsd:string" },
"dateModified": { "@id": "schema:dateModified", "@type": "xsd:dateTime" },
"name": { "@id": "schema:name", "@container": "@set" },
"value": { "@id": "schema:value", "@type": "xsd:boolean" },
"postalCode": { "@id": "schema:postalCode", "@type": "xsd:string" },
"streetAddress": { "@id": "schema:streetAddress", "@type": "xsd:string" },
"country": { "@id": "schema:addressCountry", "@type": "xsd:string" },
"polygon": { "@id": "schema:polygon", "@type": "xsd:string" },
"latitude": { "@id": "schema:latitude", "@type": "xsd:double" },
"longitude": { "@id": "schema:longitude", "@type": "xsd:double" },
"openingHoursSpecification": { "@id": "schema:openingHoursSpecification", "@container": "@set" },
"contactType": { "@id": "schema:contactType", "@type": "xsd:string" },
"email": { "@id": "schema:email", "@type": "xsd:string" },
"telephone": { "@id": "schema:telephone", "@type": "xsd:string" },
"availableLanguage": { "@id": "schema:availableLanguage", "@container": "@set" },
"hoursAvailable": { "@id": "schema:hoursAvailable", "@container": "@set" },
"dayOfWeek": { "@id": "schema:dayOfWeek", "@type": "@id" },
"opens": { "@id": "schema:opens", "@type": "xsd:time" },
"closes": { "@id": "schema:closes", "@type": "xsd:time" },
"sectionName": { "@id": "schema:name", "@type": "xsd:string" },
"publicAccess": { "@id": "schema:publicAccess", "@type": "xsd:boolean" },
"priceSpecification": { "@id": "schema:priceSpecification", "@container": "@set" },
"price": { "@id": "schema:price", "@type": "xsd:double" },
"currency": { "@id": "schema:priceCurrency", "@type": "xsd:string" },
"amenityFeature": { "@id": "schema:amenityFeature", "@container": "@set" },
"photos": { "@id": "schema:photos", "@container": "@set" },
"entrance": { "@id": "mv:entrance", "@container": "@set" },
"exit": { "@id": "mv:exit", "@container": "@set" },
"numberOfLevels": { "@id": "mv:numberOfLevels", "@type": "xsd:integer" },
"totalCapacity": { "@id": "mv:totalCapacity", "@type": "xsd:integer" },
"liveCapacity": { "@id": "mv:capacity", "@type": "@id" },
"currentValue": { "@id": "mv:currentValue", "@type": "xsd:integer" },
"freeOfCharge": { "@id": "mv:freeOfCharge", "@type": "xsd:boolean" },
"timeStartValue": { "@id": "mv:timeStartValue", "@type": "xsd:double" },
"timeEndValue": { "@id": "mv:timeEndValue", "@type": "xsd:double" },
"timeUnit": { "@id": "mv:timeUnit", "@type": "xsd:string" },
"startDate": { "@id": "vp:startDate", "@type": "xsd:dateTime" },
"endDate": { "@id": "vp:endDate", "@type": "xsd:dateTime" },
"allows": { "@id": "vp:allows", "@container": "@set" },
"covered": { "@id": "vp:covered", "@type": "xsd:boolean" },
"maximumParkingDuration": { "@id": "vp:maximumParkingDuration", "@type": "xsd:duration" },
"openingHoursExtraInformation": { "@id": "vp:openingHoursExtraInformation", "@type": "xsd:string" },
"intendedAudience": { "@id": "vp:intendedAudience", "@type": "xsd:string" },
"restrictions": { "@id": "vp:restrictions", "@type": "xsd:string" },
"removalConditions": { "@id": "vp:removalConditions", "@type": "xsd:string" },
"postRemovalAction": { "@id": "vp:postRemovalAction", "@type": "xsd:string" },
"bicycleType": { "@id": "vp:bicycleType", "@type": "@id" },
"bicyclesAmount": { "@id": "vp:bicyclesAmount", "@type": "xsd:integer" },
"countingSystem": { "@id": "vp:countingSystem", "@type": "xsd:boolean" },
"companyName": { "@id": "gr:legalName", "@type": "xsd:string" },
"identifier": { "@id": "dct:identifier", "@type": "xsd:string" },
"date": { "@id": "dct:date", "@type": "xsd:dateTime" },
"closeTo": { "@id": "dbo:closeTo", "@container": "@set" },
"temporarilyClosed": { "@id": "vp:temporarilyClosed", "@type": "xsd:boolean" },
},
"@id": "https://data.velopark.be/data/De-Lijn_303749",
"@type": "BicycleParkingStation",
"dateModified": "2020-04-28T12:34:06.227Z",
"identifier": "303749",
"name": [{ "@value": " Meise Van Dievoetlaan", "@language": "nl" }],
"temporarilyClosed": false,
"ownedBy": { "@id": "https://www.delijn.be/", "@type": "BusinessEntity", "companyName": "De Lijn" },
"operatedBy": { "@id": "https://www.delijn.be/", "@type": "BusinessEntity", "companyName": "De Lijn" },
"address": {
"@type": "PostalAddress",
"postalCode": "1860",
"streetAddress": "Nieuwelaan",
"country": "Belgium",
},
"hasMap": { "@type": "Map", "url": "https://www.openstreetmap.org/#map=18/50.94047/4.324813" },
"interactionService": { "@type": "WebSite", "url": "https://www.delijn.be/en/contact/" },
"@graph": [{
"@type": "https://data.velopark.be/openvelopark/terms#BicycleStand",
"openingHoursSpecification": [{
"@type": "OpeningHoursSpecification",
"dayOfWeek": "http://schema.org/Monday",
"opens": "00:00",
"closes": "23:59",
}, {
"@type": "OpeningHoursSpecification",
"dayOfWeek": "http://schema.org/Tuesday",
"opens": "00:00",
"closes": "23:59",
}, {
"@type": "OpeningHoursSpecification",
"dayOfWeek": "http://schema.org/Wednesday",
"opens": "00:00",
"closes": "23:59",
}, {
"@type": "OpeningHoursSpecification",
"dayOfWeek": "http://schema.org/Thursday",
"opens": "00:00",
"closes": "23:59",
}, {
"@type": "OpeningHoursSpecification",
"dayOfWeek": "http://schema.org/Friday",
"opens": "00:00",
"closes": "23:59",
}, {
"@type": "OpeningHoursSpecification",
"dayOfWeek": "http://schema.org/Saturday",
"opens": "00:00",
"closes": "23:59",
}, {
"@type": "OpeningHoursSpecification",
"dayOfWeek": "http://schema.org/Sunday",
"opens": "00:00",
"closes": "23:59",
}],
"maximumParkingDuration": "P30D",
"publicAccess": true,
"numberOfLevels": 1,
"covered": false,
"totalCapacity": 5,
"allows": [{
"@type": "AllowedBicycle",
"bicycleType": "https://data.velopark.be/openvelopark/terms#RegularBicycle",
"bicyclesAmount": 5,
"countingSystem": false,
}],
"geo": [{ "@type": "GeoCoordinates", "latitude": 50.94047, "longitude": 4.324813 }],
"priceSpecification": [{ "@type": "PriceSpecification", "freeOfCharge": true }],
"@id": "https://data.velopark.be/data/De-Lijn_303749#section1",
}],
}
const compacted = await LinkedDataLoader.compact(veloparkEntry)
expect(compacted.fee).equal("no")
expect(compacted.operator).equal("De Lijn")
expect(compacted.building).equal("bicycle_shed")
expect(compacted.access).equal("yes")
expect(compacted.max_stay).equal("30 days")
expect(compacted.opening_hours).equal("24/7")
})
})