Feature: allow freeform input for opening hours, fix #2522, refactor some uppercase method names; fixes to linked data loader

This commit is contained in:
Pieter Vander Vennet 2025-09-08 23:28:00 +02:00
parent e7de94576f
commit 96fb4c457d
7 changed files with 90 additions and 85 deletions

View file

@ -158,7 +158,10 @@ export default class LinkedDataLoader {
openingHoursSpecification, openingHoursSpecification,
<any>LinkedDataLoader.COMPACTING_CONTEXT_OH <any>LinkedDataLoader.COMPACTING_CONTEXT_OH
) )
const spec: object = compacted["@graph"] const spec:({
"@type":"http://schema.org/OpeningHoursSpecification",
"dayOfWeek": string[]
})[] = compacted["@graph"]
if (!spec) { if (!spec) {
return undefined return undefined
} }
@ -173,17 +176,21 @@ export default class LinkedDataLoader {
} }
return dow.toLowerCase().substring(0, 2) return dow.toLowerCase().substring(0, 2)
}) })
const opens: string = rule.opens const opens: string = rule["http://schema.org/opens"] ?? rule["opens"]
const closes: string = rule.closes === "23:59" ? "24:00" : rule.closes let closes: string = (rule["http://schema.org/closes"] ?? rule["closes"])
allRules.push(...OH.ParseRule(dow + " " + opens + "-" + closes)) closes = closes === "23:59" ? "24:00" : closes
allRules.push(...OH.parseRule(dow + " " + opens + "-" + closes))
} }
return OH.ToString(OH.MergeTimes(allRules)) return OH.toString(OH.MergeTimes(allRules))
} }
static async compact(data: object, options?: JsonLdLoaderOptions): Promise<object> { static async compact(data: object, options?: JsonLdLoaderOptions): Promise<Record<string, string> | Record<string, string>[]> {
if (Array.isArray(data)) { if (Array.isArray(data)) {
return await Promise.all(data.map((point) => LinkedDataLoader.compact(point, options)))
const result: Awaited<Record<string, string> | Record<string, string>[]>[] = await Promise.all(data.map((point) => LinkedDataLoader.compact(point, options)))
return result.flatMap(x => Array.isArray(x) ? x : [x])
} }
const country = options?.country const country = options?.country
@ -276,8 +283,8 @@ export default class LinkedDataLoader {
continue continue
} }
if (k === "opening_hours") { if (k === "opening_hours") {
const oh = [].concat(...v.split(";").map((r) => OH.ParseRule(r) ?? [])) const oh = [].concat(...v.split(";").map((r) => OH.parseRule(r) ?? []))
const merged = OH.ToString(OH.MergeTimes(oh ?? [])) const merged = OH.toString(OH.MergeTimes(oh ?? []))
if (merged === d[k]) { if (merged === d[k]) {
delete d[k] delete d[k]
continue continue

View file

@ -97,9 +97,9 @@ export default class VeloparkLoader {
const startHour = spec.opens const startHour = spec.opens
const endHour = spec.closes === "23:59" ? "24:00" : spec.closes const endHour = spec.closes === "23:59" ? "24:00" : spec.closes
const merged = OH.MergeTimes( const merged = OH.MergeTimes(
OH.ParseRule(dayOfWeek + " " + startHour + "-" + endHour) OH.parseRule(dayOfWeek + " " + startHour + "-" + endHour)
) )
return OH.ToString(merged) return OH.toString(merged)
}) })
.join("; ") .join("; ")
) )

View file

@ -110,7 +110,7 @@
} }
let startMinutes = Math.round((start * 60) % 60) let startMinutes = Math.round((start * 60) % 60)
let endMinutes = Math.round((end * 60) % 60) let endMinutes = Math.round((end * 60) % 60)
let newOhs = [...value.data] let newOhs = [...(value.data ?? [])]
for ( for (
let wd = Math.min(selectionStart[0], weekday); let wd = Math.min(selectionStart[0], weekday);
wd <= Math.max(selectionStart[0], weekday); wd <= Math.max(selectionStart[0], weekday);
@ -212,7 +212,7 @@
{#each range(7) as wd} {#each range(7) as wd}
<td style="width: 13%; position: relative;"> <td style="width: 13%; position: relative;">
<div class="pointer-events-none h-0" style="z-index: 10"> <div class="pointer-events-none h-0" style="z-index: 10">
{#each $value {#each ($value ?? [])
.filter((oh) => oh.weekday === wd) .filter((oh) => oh.weekday === wd)
.map((oh) => OpeningHours.rangeAs24Hr(oh)) as range} .map((oh) => OpeningHours.rangeAs24Hr(oh)) as range}
<div <div
@ -228,8 +228,8 @@
<button <button
class="pointer-events-auto w-fit self-center rounded-full p-1" class="pointer-events-auto w-fit self-center rounded-full p-1"
on:click={() => { on:click={() => {
const cleaned = value.data.filter((v) => !OpeningHours.isSame(v, range)) const cleaned = (value.data??[]).filter((v) => !OpeningHours.isSame(v, range))
console.log("Cleaned", cleaned, OpeningHours.ToString(value.data)) console.log("Cleaned", cleaned, OpeningHours.toString(value.data ?? []))
value.set(cleaned) value.set(cleaned)
}} }}
> >

View file

@ -46,31 +46,31 @@ export class OH {
/** /**
* const rules = [{weekday: 6,endHour: 17,endMinutes: 0,startHour: 13,startMinutes: 0}, * const rules = [{weekday: 6,endHour: 17,endMinutes: 0,startHour: 13,startMinutes: 0},
* {weekday: 1,endHour: 12,endMinutes: 0,startHour: 10,startMinutes: 0}] * {weekday: 1,endHour: 12,endMinutes: 0,startHour: 10,startMinutes: 0}]
* OH.ToString(rules) // => "Tu 10:00-12:00; Su 13:00-17:00" * OH.toString(rules) // => "Tu 10:00-12:00; Su 13:00-17:00"
* *
* const rules = [{weekday: 3,endHour: 17,endMinutes: 0,startHour: 13,startMinutes: 0}, {weekday: 1,endHour: 12,endMinutes: 0,startHour: 10,startMinutes: 0}] * const rules = [{weekday: 3,endHour: 17,endMinutes: 0,startHour: 13,startMinutes: 0}, {weekday: 1,endHour: 12,endMinutes: 0,startHour: 10,startMinutes: 0}]
* OH.ToString(rules) // => "Tu 10:00-12:00; Th 13:00-17:00" * OH.toString(rules) // => "Tu 10:00-12:00; Th 13:00-17:00"
* *
* const rules = [ { weekday: 1, endHour: 17, endMinutes: 0, startHour: 13, startMinutes: 0 }, { weekday: 1, endHour: 12, endMinutes: 0, startHour: 10, startMinutes: 0 }]); * const rules = [ { weekday: 1, endHour: 17, endMinutes: 0, startHour: 13, startMinutes: 0 }, { weekday: 1, endHour: 12, endMinutes: 0, startHour: 10, startMinutes: 0 }]);
* OH.ToString(rules) // => "Tu 10:00-12:00, 13:00-17:00" * OH.toString(rules) // => "Tu 10:00-12:00, 13:00-17:00"
* *
* const rules = [ { weekday: 0, endHour: 12, endMinutes: 0, startHour: 10, startMinutes: 0 }, { weekday: 0, endHour: 17, endMinutes: 0, startHour: 13, startMinutes: 0}, { weekday: 1, endHour: 17, endMinutes: 0, startHour: 13, startMinutes: 0 }, { weekday: 1, endHour: 12, endMinutes: 0, startHour: 10, startMinutes: 0 }]; * const rules = [ { weekday: 0, endHour: 12, endMinutes: 0, startHour: 10, startMinutes: 0 }, { weekday: 0, endHour: 17, endMinutes: 0, startHour: 13, startMinutes: 0}, { weekday: 1, endHour: 17, endMinutes: 0, startHour: 13, startMinutes: 0 }, { weekday: 1, endHour: 12, endMinutes: 0, startHour: 10, startMinutes: 0 }];
* OH.ToString(rules) // => "Mo-Tu 10:00-12:00, 13:00-17:00" * OH.toString(rules) // => "Mo-Tu 10:00-12:00, 13:00-17:00"
* *
* // should merge overlapping opening hours * // should merge overlapping opening hours
* const timerange0 = {weekday: 1, endHour: 23, endMinutes: 30, startHour: 23, startMinutes: 0 } * const timerange0 = {weekday: 1, endHour: 23, endMinutes: 30, startHour: 23, startMinutes: 0 }
* const touchingTimeRange = { weekday: 1, endHour: 0, endMinutes: 0, startHour: 23, startMinutes: 30 } * const touchingTimeRange = { weekday: 1, endHour: 0, endMinutes: 0, startHour: 23, startMinutes: 30 }
* OH.ToString(OH.MergeTimes([timerange0, touchingTimeRange])) // => "Tu 23:00-00:00" * OH.toString(OH.MergeTimes([timerange0, touchingTimeRange])) // => "Tu 23:00-00:00"
* *
* // should merge touching opening hours * // should merge touching opening hours
* const timerange0 = {weekday: 1, endHour: 23, endMinutes: 30, startHour: 23, startMinutes: 0 } * const timerange0 = {weekday: 1, endHour: 23, endMinutes: 30, startHour: 23, startMinutes: 0 }
* const overlappingTimeRange = { weekday: 1, endHour: 24, endMinutes: 0, startHour: 23, startMinutes: 30 } * const overlappingTimeRange = { weekday: 1, endHour: 24, endMinutes: 0, startHour: 23, startMinutes: 30 }
* OH.ToString(OH.MergeTimes([timerange0, overlappingTimeRange])) // => "Tu 23:00-00:00" * OH.toString(OH.MergeTimes([timerange0, overlappingTimeRange])) // => "Tu 23:00-00:00"
* *
*/ */
public static ToString(ohs: OpeningHour[]) { public static toString(ohs: OpeningHour[]): string {
if (ohs.length == 0) { if (!ohs || ohs.length == 0) {
return "" return ""
} }
const partsPerWeekday: string[][] = [[], [], [], [], [], [], []] const partsPerWeekday: string[][] = [[], [], [], [], [], [], []]
@ -284,35 +284,36 @@ export class OH {
} }
/** /**
* Converts an OH-syntax rule into an object * Converts a single OH-syntax rule into one or more objects.
* If the rule cannot be parsed, returns 'null'
* *
* * const rules = OH.parsePHRule("PH 12:00-17:00")
* const rules = OH.ParsePHRule("PH 12:00-17:00")
* rules.mode // => " " * rules.mode // => " "
* rules.start // => "12:00" * rules.start // => "12:00"
* rules.end // => "17:00" * rules.end // => "17:00"
* *
* OH.ParseRule("PH 12:00-17:00") // => null * OH.parseRule("PH 12:00-17:00") // => null
* OH.ParseRule("Th[-1] off") // => null * OH.parseRule("Th[-1] off") // => null
* OH.parseRule("Jul-Oct Mo-fr 11:00-19:00") // => null
* *
* const rules = OH.Parse("24/7"); * const rules = OH.parse("24/7");
* rules.length // => 7 * rules.length // => 7
* rules[0].startHour // => 0 * rules[0].startHour // => 0
* OH.ToString(rules) // => "24/7" * OH.toString(rules) // => "24/7"
* *
* const rules = OH.ParseRule("11:00-19:00"); * const rules = OH.parseRule("11:00-19:00");
* rules.length // => 7 * rules.length // => 7
* rules[0].weekday // => 0 * rules[0].weekday // => 0
* rules[0].startHour // => 11 * rules[0].startHour // => 11
* rules[3].endHour // => 19 * rules[3].endHour // => 19
* *
* const rules = OH.ParseRule("Mo-Th 11:00-19:00"); * const rules = OH.parseRule("Mo-Th 11:00-19:00");
* rules.length // => 4 * rules.length // => 4
* rules[0].weekday // => 0 * rules[0].weekday // => 0
* rules[0].startHour // => 11 * rules[0].startHour // => 11
* rules[3].endHour // => 19 * rules[3].endHour // => 19
* *
* const rules = OH.ParseRule("Mo 20:00-02:00"); * const rules = OH.parseRule("Mo 20:00-02:00");
* rules.length // => 2 * rules.length // => 2
* rules[0].weekday // => 0 * rules[0].weekday // => 0
* rules[0].startHour // => 20 * rules[0].startHour // => 20
@ -321,13 +322,13 @@ export class OH {
* rules[1].startHour // => 0 * rules[1].startHour // => 0
* rules[1].endHour // => 2 * rules[1].endHour // => 2
* *
* const rules = OH.ParseRule("Mo 00:00-24:00") * const rules = OH.parseRule("Mo 00:00-24:00")
* rules.length // => 1 * rules.length // => 1
* rules[0].weekday // => 0 * rules[0].weekday // => 0
* rules[0].startHour // => 0 * rules[0].startHour // => 0
* rules[0].endHour // => 24 * rules[0].endHour // => 24
*/ */
public static ParseRule(rule: string): OpeningHour[] { public static parseRule(rule: string): OpeningHour[] {
try { try {
if (rule.trim() == "24/7") { if (rule.trim() == "24/7") {
return OH.multiply( return OH.multiply(
@ -346,33 +347,34 @@ export class OH {
const split = rule.trim().replace(/, */g, ",").split(" ") const split = rule.trim().replace(/, */g, ",").split(" ")
if (split.length == 1) { if (split.length == 1) {
// First, try to parse this rule as a rule without weekdays // First, try to parse this rule as a rule without weekdays
const timeranges = OH.ParseHhmmRanges(rule) const timeranges = OH.parseHhmmRanges(rule)
const weekdays = [0, 1, 2, 3, 4, 5, 6] const weekdays = [0, 1, 2, 3, 4, 5, 6]
return OH.multiply(weekdays, timeranges) return OH.multiply(weekdays, timeranges)
} }
if (split.length == 2) { if (split.length == 2) {
const weekdays = OH.ParseWeekdayRanges(split[0]) const weekdays = OH.parseWeekdayRanges(split[0])
const timeranges = OH.ParseHhmmRanges(split[1]) const timeranges = OH.parseHhmmRanges(split[1])
return OH.multiply(weekdays, timeranges) return OH.multiply(weekdays, timeranges)
} }
return [] // THis rule is too complicated to parse
return null
} catch (e) { } catch (e) {
console.log("Could not parse weekday rule ", rule) console.log("Could not parse weekday rule ", rule)
return [] return null
} }
} }
/** /**
* *
* OH.ParsePHRule("PH Off") // => {mode: "off"} * OH.parsePHRule("PH Off") // => {mode: "off"}
* OH.ParsePHRule("PH OPEN") // => {mode: "open"} * OH.parsePHRule("PH OPEN") // => {mode: "open"}
* OH.ParsePHRule("PH 10:00-12:00") // => {mode: " ", start: "10:00", end: "12:00"} * OH.parsePHRule("PH 10:00-12:00") // => {mode: " ", start: "10:00", end: "12:00"}
* OH.ParsePHRule(undefined) // => null * OH.parsePHRule(undefined) // => null
* OH.ParsePHRule(null) // => null * OH.parsePHRule(null) // => null
* OH.ParsePHRule("some random string") // => null * OH.parsePHRule("some random string") // => null
*/ */
public static ParsePHRule(str: string): { public static parsePHRule(str: string): {
mode: string mode: string
start?: string start?: string
end?: string end?: string
@ -418,13 +420,21 @@ export class OH {
} }
public static simplify(str: string): string { public static simplify(str: string): string {
return OH.ToString(OH.MergeTimes(OH.Parse(str))) const parsed = OH.parse(str)
if(parsed === null){
// Parsing failed, probably too complicated to handle
return str
}
return OH.toString(OH.MergeTimes(parsed))
} }
/** /**
* Parses a string into Opening Hours * Parses a string into Opening Hours
* Returns 'null' if the rules are to complicated to parse
*
* OH.parse("Feb-Oct Mo-Fr 10:00-12:00") // => null
*/ */
public static Parse(rules: string): OpeningHour[] { public static parse(rules: string):null | OpeningHour[] {
if (rules === undefined || rules === "") { if (rules === undefined || rules === "") {
return [] return []
} }
@ -438,12 +448,15 @@ export class OH {
continue continue
} }
try { try {
const parsed = OH.ParseRule(rule) const parsed = OH.parseRule(rule)
if (parsed !== null) { if (parsed === null) {
ohs.push(...parsed) return null // Parsing failed
} }
ohs.push(...parsed)
} catch (e) { } catch (e) {
console.error("Could not parse ", rule, ": ", e) console.error("Could not parse ", rule, ": ", e)
return null
} }
} }
@ -837,11 +850,11 @@ changes // => [[36000,61200], ["10:00", "17:00"]]
} }
/** /**
* OH.ParseHhmmRanges("20:00-22:15") // => [{startHour: 20, startMinutes: 0, endHour: 22, endMinutes: 15}] * OH.parseHhmmRanges("20:00-22:15") // => [{startHour: 20, startMinutes: 0, endHour: 22, endMinutes: 15}]
* OH.ParseHhmmRanges("20:00-02:15") // => [{startHour: 20, startMinutes: 0, endHour: 2, endMinutes: 15}] * OH.parseHhmmRanges("20:00-02:15") // => [{startHour: 20, startMinutes: 0, endHour: 2, endMinutes: 15}]
* OH.ParseHhmmRanges("00:00-24:00") // => [{startHour: 0, startMinutes: 0, endHour: 24, endMinutes: 0}] * OH.parseHhmmRanges("00:00-24:00") // => [{startHour: 0, startMinutes: 0, endHour: 24, endMinutes: 0}]
*/ */
private static ParseHhmmRanges(hhmms: string): { private static parseHhmmRanges(hhmms: string): {
startHour: number startHour: number
startMinutes: number startMinutes: number
endHour: number endHour: number
@ -858,21 +871,21 @@ changes // => [[36000,61200], ["10:00", "17:00"]]
.filter((v) => v != null) .filter((v) => v != null)
} }
private static ParseWeekday(weekday: string): number { private static parseWeekday(weekday: string): number {
return OH.daysIndexed[weekday.trim().toLowerCase()] return OH.daysIndexed[weekday.trim().toLowerCase()]
} }
private static ParseWeekdayRange(weekdays: string): number[] { private static parseWeekdayRange(weekdays: string): number[] {
const split = weekdays.split("-") const split = weekdays.split("-")
if (split.length == 1) { if (split.length == 1) {
const parsed = OH.ParseWeekday(weekdays) const parsed = OH.parseWeekday(weekdays)
if (parsed == null) { if (parsed == null) {
return null return null
} }
return [parsed] return [parsed]
} else if (split.length == 2) { } else if (split.length == 2) {
const start = OH.ParseWeekday(split[0]) const start = OH.parseWeekday(split[0])
const end = OH.ParseWeekday(split[1]) const end = OH.parseWeekday(split[1])
if ((start ?? null) === null || (end ?? null) === null) { if ((start ?? null) === null || (end ?? null) === null) {
return null return null
} }
@ -886,11 +899,11 @@ changes // => [[36000,61200], ["10:00", "17:00"]]
} }
} }
private static ParseWeekdayRanges(weekdays: string): number[] { private static parseWeekdayRanges(weekdays: string): number[] {
const ranges = [] const ranges = []
const split = weekdays.split(",") const split = weekdays.split(",")
for (const weekday of split) { for (const weekday of split) {
const parsed = OH.ParseWeekdayRange(weekday) const parsed = OH.parseWeekdayRange(weekday)
if (parsed === undefined || parsed === null) { if (parsed === undefined || parsed === null) {
return null return null
} }

View file

@ -55,10 +55,10 @@ export default class OpeningHoursState {
const leftOvers: string[] = [] const leftOvers: string[] = []
const rules = str.split(";") const rules = str.split(";")
for (const rule of rules) { for (const rule of rules) {
if (OH.ParseRule(rule) !== null) { if (OH.parseRule(rule) !== null) {
continue continue
} }
if (OH.ParsePHRule(rule) !== null) { if (OH.parsePHRule(rule) !== null) {
continue continue
} }
if (leftOvers.indexOf(rule) >= 0) { if (leftOvers.indexOf(rule) >= 0) {
@ -72,7 +72,7 @@ export default class OpeningHoursState {
let ph = "" let ph = ""
const rules = valueWithoutPrefix.data?.split(";") ?? [] const rules = valueWithoutPrefix.data?.split(";") ?? []
for (const rule of rules) { for (const rule of rules) {
if (OH.ParsePHRule(rule) !== null) { if (OH.parsePHRule(rule) !== null) {
// We found the rule containing the public holiday information // We found the rule containing the public holiday information
ph = rule ph = rule
break break
@ -82,14 +82,12 @@ export default class OpeningHoursState {
// Note: MUST be bound AFTER the leftover rules! // Note: MUST be bound AFTER the leftover rules!
this.normalOhs = valueWithoutPrefix.sync( this.normalOhs = valueWithoutPrefix.sync(
(str) => { (str) => OH.parse(str),
return OH.Parse(str)
},
[this.leftoverRules, this.phSelectorValue], [this.leftoverRules, this.phSelectorValue],
(rules, oldString) => { (rules, oldString) => {
// We always add a ';', to easily add new rules. We remove the ';' again at the end of the function // We always add a ';', to easily add new rules. We remove the ';' again at the end of the function
// Important: spaces are _not_ allowed after a ';' as it'll destabilize the parsing! // Important: spaces are _not_ allowed after a ';' as it'll destabilize the parsing!
let str = OH.ToString(rules) + ";" let str = OH.toString(rules) + ";"
const ph = this.phSelectorValue.data const ph = this.phSelectorValue.data
if (ph) { if (ph) {
str += " " + ph + ";" // There must be a space after every ";" str += " " + ph + ";" // There must be a space after every ";"
@ -112,19 +110,5 @@ export default class OpeningHoursState {
return str return str
} }
) )
/*
const leftoverWarning = new VariableUiElement(
leftoverRules.map((leftovers: string[]) => {
if (leftovers.length == 0) {
return ""
}
return new Combine([
Translations.t.general.opening_hours.not_all_rules_parsed,
new FixedUiElement(leftovers.map((r) => `${r}<br/>`).join("")).SetClass(
"subtle"
),
])
})
)*/
} }
} }

View file

@ -16,7 +16,7 @@
onDestroy( onDestroy(
value value
.map((ph) => OH.ParsePHRule(ph), onDestroy) .map((ph) => OH.parsePHRule(ph), onDestroy)
.addCallbackAndRunD((parsed) => { .addCallbackAndRunD((parsed) => {
if (parsed === null) { if (parsed === null) {
return return

View file

@ -73,5 +73,6 @@ describe("LinkedDataLoader", () => {
} }
const compacted = await LinkedDataLoader.compact(graph) const compacted = await LinkedDataLoader.compact(graph)
expect(compacted.phone).equal("+32 89 41 35 20") expect(compacted.phone).equal("+32 89 41 35 20")
expect(compacted.opening_hours).equal("Mo 12:00-18:30; Tu-Sa 08:00-18:30; Su 08:00-12:00")
}) })
}) })