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…
Reference in a new issue