forked from MapComplete/MapComplete
		
	Velopark: first decent, working version
This commit is contained in:
		
							parent
							
								
									890816d2dd
								
							
						
					
					
						commit
						5b6cd1d2ae
					
				
					 18 changed files with 7054 additions and 21769 deletions
				
			
		|  | @ -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" | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|  |  | |||
							
								
								
									
										27022
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										27022
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							|  | @ -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", | ||||
|  |  | |||
|  | @ -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( | ||||
|  |  | |||
							
								
								
									
										22
									
								
								scripts/downloadLinkedDataList.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								scripts/downloadLinkedDataList.ts
									
										
									
									
									
										Normal 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() | ||||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
|  | @ -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" | ||||
|  |  | |||
|  | @ -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") | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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,29 +53,58 @@ 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" | ||||
|     ] | ||||
| 
 | ||||
|     static async geoToGeometry(geo): Promise<Geometry> { | ||||
|         const context = { | ||||
|             lat: { | ||||
|                 "@id": "http://schema.org/latitude", | ||||
|             }, | ||||
|             lon: { | ||||
|                 "@id": "http://schema.org/longitude", // TODO formatting to decimal should be possible from this type?
 | ||||
|             }, | ||||
|     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)))] | ||||
|         } | ||||
|         const flattened = await jsonld.compact(geo, context) | ||||
|     } | ||||
| 
 | ||||
|     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] | ||||
| 
 | ||||
|         return { | ||||
|             type: "Point", | ||||
|             coordinates: [Number(flattened.lon), Number(flattened.lat)], | ||||
|         } | ||||
| 
 | ||||
|         if (geo["@type"] === "http://schema.org/GeoCoordinates") { | ||||
| 
 | ||||
|             const context = { | ||||
|                 lat: { | ||||
|                     "@id": "http://schema.org/latitude", | ||||
|                     "@type": "http://www.w3.org/2001/XMLSchema#double" | ||||
|                 }, | ||||
|                 lon: { | ||||
|                     "@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)] | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         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"] | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | @ -74,6 +112,8 @@ export default class LinkedDataLoader { | |||
|      * | ||||
|      * // 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,30 +165,20 @@ 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> { | ||||
| 
 | ||||
|     /** | ||||
|      * | ||||
|      * | ||||
|      * { | ||||
|      *   "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))) | ||||
|         if (Array.isArray(data)) { | ||||
|             return await Promise.all(data.map(point => LinkedDataLoader.compact(point, options))) | ||||
|         } | ||||
| 
 | ||||
|         const country = options?.country | ||||
|         const compacted = await jsonld.compact(data, <any> LinkedDataLoader.COMPACTING_CONTEXT) | ||||
|         const compacted = await jsonld.compact(data, <any>LinkedDataLoader.COMPACTING_CONTEXT) | ||||
| 
 | ||||
|         compacted["opening_hours"] = await LinkedDataLoader.ohToOsmFormat( | ||||
|             compacted["opening_hours"] | ||||
|         ) | ||||
|         if (compacted["openingHours"]) { | ||||
|             const ohspec: string[] = <any> compacted["openingHours"] | ||||
|             const ohspec: string[] = <any>compacted["openingHours"] | ||||
|             compacted["opening_hours"] = OH.simplify( | ||||
|                 ohspec.map((r) => LinkedDataLoader.ohStringToOsmFormat(r)).join("; ") | ||||
|             ) | ||||
|  | @ -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) | ||||
|     } | ||||
|  | @ -174,7 +224,7 @@ export default class LinkedDataLoader { | |||
|      * @param externalData | ||||
|      * @param currentData | ||||
|      */ | ||||
|     static removeDuplicateData(externalData: Record<string, string>, currentData: Record<string, string>) : Record<string, string>{ | ||||
|     static removeDuplicateData(externalData: Record<string, string>, currentData: Record<string, string>): Record<string, string> { | ||||
|         const d = { ...externalData } | ||||
|         delete d["@context"] | ||||
|         for (const k in d) { | ||||
|  | @ -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 | ||||
|     } | ||||
| } | ||||
|  |  | |||
							
								
								
									
										94
									
								
								src/Logic/Web/TypedSparql.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								src/Logic/Web/TypedSparql.ts
									
										
									
									
									
										Normal 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(" ")} }` | ||||
|     } | ||||
| } | ||||
|  | @ -69,12 +69,17 @@ | |||
|   </div> | ||||
| {:else} | ||||
|   <div class="low-interaction p-1 border-interactive"> | ||||
|     <Tr t={t.loadedFrom.Subs({url: sourceUrl, source: sourceUrl})} /> | ||||
|     <h3> | ||||
|       <Tr t={t.conflicting.title} /> | ||||
|     </h3> | ||||
|     <div class="flex flex-col gap-y-8"> | ||||
|       <Tr t={t.conflicting.intro} /> | ||||
|     {#if !readonly} | ||||
|       <Tr t={t.loadedFrom.Subs({url: sourceUrl, source: sourceUrl})} /> | ||||
|       <h3> | ||||
|         <Tr t={t.conflicting.title} /> | ||||
|       </h3> | ||||
|     {/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"> | ||||
|  | @ -102,7 +107,7 @@ | |||
|             </button> | ||||
|           {/if} | ||||
|         {:else if currentStep === "applying_all"} | ||||
|           <Loading/> | ||||
|           <Loading /> | ||||
|         {:else if currentStep === "all_applied"} | ||||
|           <div class="thanks"> | ||||
|             <Tr t={t.allAreApplied} /> | ||||
|  |  | |||
|  | @ -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") { | ||||
|             return "24/7" | ||||
|         if (rules.length === 1) { | ||||
|             const rule = rules[0] | ||||
|             if (rule === "Mo-Su 00:00-00:00") { | ||||
|                 return "24/7" | ||||
|             } | ||||
|             if (rule.startsWith("Mo-Su ")) { | ||||
|                 return rule.substring("Mo-Su ".length) | ||||
|             } | ||||
|         } | ||||
|         return oh | ||||
| 
 | ||||
|         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
											
										
									
								
							|  | @ -25,7 +25,7 @@ | |||
| {/if} | ||||
| 
 | ||||
| {#if $wikipediaDetails.wikidata} | ||||
|   <ToSvelte construct={WikidataPreviewBox.WikidataResponsePreview($wikipediaDetails.wikidata)} /> | ||||
|   <ToSvelte construct={() => WikidataPreviewBox.WikidataResponsePreview($wikipediaDetails.wikidata)} /> | ||||
| {/if} | ||||
| 
 | ||||
| {#if $wikipediaDetails.articleUrl} | ||||
|  |  | |||
							
								
								
									
										11
									
								
								src/test.ts
									
										
									
									
									
								
							
							
						
						
									
										11
									
								
								src/test.ts
									
										
									
									
									
								
							|  | @ -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) | ||||
|  |  | |||
							
								
								
									
										275
									
								
								test/Logic/Web/LinkedDataLoader.spec.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										275
									
								
								test/Logic/Web/LinkedDataLoader.spec.ts
									
										
									
									
									
										Normal 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") | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
|     }) | ||||
| }) | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue