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,
<any>LinkedDataLoader.COMPACTING_CONTEXT_OH
)
const spec: object = compacted["@graph"]
const spec:({
"@type":"http://schema.org/OpeningHoursSpecification",
"dayOfWeek": string[]
})[] = compacted["@graph"]
if (!spec) {
return undefined
}
@ -173,17 +176,21 @@ export default class LinkedDataLoader {
}
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))
const opens: string = rule["http://schema.org/opens"] ?? rule["opens"]
let closes: string = (rule["http://schema.org/closes"] ?? rule["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)) {
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
@ -276,8 +283,8 @@ export default class LinkedDataLoader {
continue
}
if (k === "opening_hours") {
const oh = [].concat(...v.split(";").map((r) => OH.ParseRule(r) ?? []))
const merged = OH.ToString(OH.MergeTimes(oh ?? []))
const oh = [].concat(...v.split(";").map((r) => OH.parseRule(r) ?? []))
const merged = OH.toString(OH.MergeTimes(oh ?? []))
if (merged === d[k]) {
delete d[k]
continue

View file

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

View file

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

View file

@ -46,31 +46,31 @@ export class OH {
/**
* const rules = [{weekday: 6,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; 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}]
* 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 }]);
* 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 }];
* 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
* const timerange0 = {weekday: 1, endHour: 23, endMinutes: 30, startHour: 23, startMinutes: 0 }
* 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
* const timerange0 = {weekday: 1, endHour: 23, endMinutes: 30, startHour: 23, startMinutes: 0 }
* 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[]) {
if (ohs.length == 0) {
public static toString(ohs: OpeningHour[]): string {
if (!ohs || ohs.length == 0) {
return ""
}
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.start // => "12:00"
* rules.end // => "17:00"
*
* OH.ParseRule("PH 12:00-17:00") // => null
* OH.ParseRule("Th[-1] off") // => null
* OH.parseRule("PH 12:00-17:00") // => 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[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[0].weekday // => 0
* rules[0].startHour // => 11
* 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[0].weekday // => 0
* rules[0].startHour // => 11
* 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[0].weekday // => 0
* rules[0].startHour // => 20
@ -321,13 +322,13 @@ export class OH {
* rules[1].startHour // => 0
* 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[0].weekday // => 0
* rules[0].startHour // => 0
* rules[0].endHour // => 24
*/
public static ParseRule(rule: string): OpeningHour[] {
public static parseRule(rule: string): OpeningHour[] {
try {
if (rule.trim() == "24/7") {
return OH.multiply(
@ -346,33 +347,34 @@ export class OH {
const split = rule.trim().replace(/, */g, ",").split(" ")
if (split.length == 1) {
// 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]
return OH.multiply(weekdays, timeranges)
}
if (split.length == 2) {
const weekdays = OH.ParseWeekdayRanges(split[0])
const timeranges = OH.ParseHhmmRanges(split[1])
const weekdays = OH.parseWeekdayRanges(split[0])
const timeranges = OH.parseHhmmRanges(split[1])
return OH.multiply(weekdays, timeranges)
}
return []
// THis rule is too complicated to parse
return null
} catch (e) {
console.log("Could not parse weekday rule ", rule)
return []
return null
}
}
/**
*
* OH.ParsePHRule("PH Off") // => {mode: "off"}
* OH.ParsePHRule("PH OPEN") // => {mode: "open"}
* OH.ParsePHRule("PH 10:00-12:00") // => {mode: " ", start: "10:00", end: "12:00"}
* OH.ParsePHRule(undefined) // => null
* OH.ParsePHRule(null) // => null
* OH.ParsePHRule("some random string") // => null
* OH.parsePHRule("PH Off") // => {mode: "off"}
* OH.parsePHRule("PH OPEN") // => {mode: "open"}
* OH.parsePHRule("PH 10:00-12:00") // => {mode: " ", start: "10:00", end: "12:00"}
* OH.parsePHRule(undefined) // => null
* OH.parsePHRule(null) // => null
* OH.parsePHRule("some random string") // => null
*/
public static ParsePHRule(str: string): {
public static parsePHRule(str: string): {
mode: string
start?: string
end?: string
@ -418,13 +420,21 @@ export class OH {
}
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
* 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 === "") {
return []
}
@ -438,12 +448,15 @@ export class OH {
continue
}
try {
const parsed = OH.ParseRule(rule)
if (parsed !== null) {
ohs.push(...parsed)
const parsed = OH.parseRule(rule)
if (parsed === null) {
return null // Parsing failed
}
ohs.push(...parsed)
} catch (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-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("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("00:00-24:00") // => [{startHour: 0, startMinutes: 0, endHour: 24, endMinutes: 0}]
*/
private static ParseHhmmRanges(hhmms: string): {
private static parseHhmmRanges(hhmms: string): {
startHour: number
startMinutes: number
endHour: number
@ -858,21 +871,21 @@ changes // => [[36000,61200], ["10:00", "17:00"]]
.filter((v) => v != null)
}
private static ParseWeekday(weekday: string): number {
private static parseWeekday(weekday: string): number {
return OH.daysIndexed[weekday.trim().toLowerCase()]
}
private static ParseWeekdayRange(weekdays: string): number[] {
private static parseWeekdayRange(weekdays: string): number[] {
const split = weekdays.split("-")
if (split.length == 1) {
const parsed = OH.ParseWeekday(weekdays)
const parsed = OH.parseWeekday(weekdays)
if (parsed == null) {
return null
}
return [parsed]
} else if (split.length == 2) {
const start = OH.ParseWeekday(split[0])
const end = OH.ParseWeekday(split[1])
const start = OH.parseWeekday(split[0])
const end = OH.parseWeekday(split[1])
if ((start ?? null) === null || (end ?? null) === 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 split = weekdays.split(",")
for (const weekday of split) {
const parsed = OH.ParseWeekdayRange(weekday)
const parsed = OH.parseWeekdayRange(weekday)
if (parsed === undefined || parsed === null) {
return null
}

View file

@ -55,10 +55,10 @@ export default class OpeningHoursState {
const leftOvers: string[] = []
const rules = str.split(";")
for (const rule of rules) {
if (OH.ParseRule(rule) !== null) {
if (OH.parseRule(rule) !== null) {
continue
}
if (OH.ParsePHRule(rule) !== null) {
if (OH.parsePHRule(rule) !== null) {
continue
}
if (leftOvers.indexOf(rule) >= 0) {
@ -72,7 +72,7 @@ export default class OpeningHoursState {
let ph = ""
const rules = valueWithoutPrefix.data?.split(";") ?? []
for (const rule of rules) {
if (OH.ParsePHRule(rule) !== null) {
if (OH.parsePHRule(rule) !== null) {
// We found the rule containing the public holiday information
ph = rule
break
@ -82,14 +82,12 @@ export default class OpeningHoursState {
// Note: MUST be bound AFTER the leftover rules!
this.normalOhs = valueWithoutPrefix.sync(
(str) => {
return OH.Parse(str)
},
(str) => OH.parse(str),
[this.leftoverRules, this.phSelectorValue],
(rules, oldString) => {
// 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!
let str = OH.ToString(rules) + ";"
let str = OH.toString(rules) + ";"
const ph = this.phSelectorValue.data
if (ph) {
str += " " + ph + ";" // There must be a space after every ";"
@ -112,19 +110,5 @@ export default class OpeningHoursState {
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(
value
.map((ph) => OH.ParsePHRule(ph), onDestroy)
.map((ph) => OH.parsePHRule(ph), onDestroy)
.addCallbackAndRunD((parsed) => {
if (parsed === null) {
return

View file

@ -73,5 +73,6 @@ describe("LinkedDataLoader", () => {
}
const compacted = await LinkedDataLoader.compact(graph)
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")
})
})