schedule

package module
v0.1.9 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 3, 2023 License: MIT Imports: 12 Imported by: 4

README

Schedule

build-img pkg-img reportcard-img

This package is a dependency free utility package. It is a collection of time related value objects. The main reason it exists is because a time.Time is one specific moment in time. It is a Date, Clock, and Timezone all in one.

Sometimes we just want a Date or DateRange which does not care about times or time zones. Sometimes we just want a Clock or TimeSlot which does not care about dates. We wanted to know if a Date was within a DateRange without having to worry about what timezone it is in and making sure the start date was 00:00 end date was at 23:59. It is a date, the time doesn't matter in these cases.

Clock

A time without date information which will always be within 24 hours. If you add minutes to it that go past 24 hours it auto wraps as a clock would.

It json and sql encodes/decodes to/from a "HH:MM" string

Why not use time.Time ? Because a time.Time is much more than we need. We just need a clock, that does not care about timezones or dates. If you store a timeslot with time.Time 's then those objects have dates and timezone information in them.

String format: "13:00"

Constructors
  NewClock(h, m int) Clock
  ParseClock(string) Clock // from "HH:MM" format
Useful Methods
  String() string             // "HH:MM" format
  Add(minutes int) Clock
  Subtract(mintues int) Clock
  Hour() int
  Minute() int
  Equal(Clock) bool
  Before(Clock) bool
  After(Clock) bool
  IsZero() bool
  Pointer() *Clock            // useful for setting Until on some other types
  ToDuration() time.Duration
  ToTime(Date, *time.Location) time.Time

TimeSlot

A TimeSlot is just a type with two Clock's in it Start and End

It is important to note that a TimeSlot of "23:00-02:00" is valid and simply means it crosses midnight. So it would have a 3 hour duration.

Also two time slots that share a single moment in time at its edge do not overlap. So "09:00-10:00" and "10:00-11:00" time slots do not conflict, they just share a boundary.

String format: "09:00-13:00"

Constructors
  NewTimeSlot(start, end Clock) TimeSlot
  ParseTimeSlot(string) TimeSlot         // from "HH:MM-HH:MM" format
Useful Methods
  String() string   // "HH:MM-HH:MM" format
  StartTime() Clock
  EndTime() Clock
  Minutes() int
  Duration() time.Duration
  IsZero() bool

Weekday

More or less the same as time.Weekday other than it json/sql encode/decode 's to/from string name. Which is the only reason this type exists really.

type Weekday time.Weekday

const (
	Sunday    = Weekday(time.Sunday)
	Monday    = Weekday(time.Monday)
	Tuesday   = Weekday(time.Tuesday)
	Wednesday = Weekday(time.Wednesday)
	Thursday  = Weekday(time.Thursday)
	Friday    = Weekday(time.Friday)
	Saturday  = Weekday(time.Saturday)
)
Constructors
  ParseWeekday(string) (Weekday, error)
Useful Methods
  String() string
  Next() Weekday

WeekdayTimeSlot

String format: "Monday 09:00-13:00"

A WeekdayTimeSlot can be converted to/from an int. The purpose of this is simply to provide something like a unique key to this value object. The int is actually the weekday, start, and end all encoded into an int. However nothing that uses the int value should understand how it is encoded or care, it is just used sometimes as a unique key to identify a value. It is also helpful for sorting and knowing if two values are equal or not.

Constructors
  NewWeekdayTimeSlot(Weekday, TimeSlot) WeekdayTimeSlot
  NewWeekdayAllDayTimeSlot(Weekday) WeekdayTimeSlot
  WeekdayTimeSlotFromString(string) WeekdayTimeSlot
  WeekdayTimeSlotFromInt(int) WeekdayTimeSlot
Methods
  Weekday() Weekday
  Slot() TimeSlot
  String() string
  ToInt() int
  Start() Clock
  End() Clock
  Minutes() int
  Duration() time.Duration
  IsAllDay() bool
  OverlapsWith(WeekdayTimeSlot) bool
  Equal() bool
Helper functions
  SortWeekdayTimeSlots(...WeekdayTimeSlot) []WeekdayTimeSlot
  UniqueWeekdayTimeSlots(...WeekdayTimeSLot) []WeekdayTimeSlot
  SlotKeys(...WeekdayTimeSlot) []int

WeekdayTimeSlotMap

This is really the same thing as a []WeekdayTimeSlot but organized as map[Weekday][]TimeSlot

It can be easier to work with at times and has unique enforcement built in. It has methods to convert this to/from []WeekdayTimeSlot so you can go back and forth as needed. Though be warned that you will lose any duplicates, though I've yet to actually want any duplicates.

Constructors
  NewWeekdayTimeSlotMap() WeekdayTimeSlotMap
  WeekdayTimeSlotMapFromSlice([]WeekdayTimeSlot) WeekdayTimeSlotMap
Methods
  Add(Weekday, ...TimeSlot) WeekdayTimeSlotMap
  Has(Weekday, ...TimeSlot) bool
  AddTimeSlot(day Weekday, start, end Clock) WeekdayTimeSlotMap
  TimeSlots(day Weekday) []TimeSlot
  ToWeekdayTimeSlots() []WeekdayTimeSlot

Date

A date is any calendar date. It takes advantage of time.Time to do anything complicated but it has no time or location data built into it.

json/sql encode/decode to/from string

String format: "2022-07-09"

Constructors
  Today() Date
  NewDate(year int, month time.Month, day int) Date
  NewDateFromTime(t time.Time) Date
  ParseDate(string) *Date
  ZeroDate() *Date
Methods
  String() string
  Year() int
  Month() time.Month
  Day() int
  Weekday() Weekday
  Before(Date) bool
  After(Date) bool
  Equal(Date) bool
  Next() Date
  Pointer() *Date
  IsZero() bool
  AddDate(year, month, day int) Date
  Sub(Date) int
  ToTime() time.Time

DateRange

A DateRange goes from Date until *Date and so the until can be nil and when it is nil it means that the DateRange has no end and therefore is interpreted as "forever". It is important to understand this when reasoning how Overlap or Contains work.

Also a DateRange is inclusive at both ends, check out this comment from the code:

// DateRange represents a set of days
// this set includes the From date and the Until date
// from Jan1 until Jan1 is one day  
// from Jan1 until Jan2 is two days  
// when Until is nil it means forever

Isn't it nice to not have to worry about time when you don't need to?

// InfDays represents an infinite number of days
// MaxInt32 days is over 5.8 million years  
// we are not using a negative number because someone  
// may check DayCount() > 0 to know if it has days or not  
const InfDays = math.MaxInt32
Constructors
  NewDateRange() DateRange   // Today until forever
  NewDateRangeUntil(from Date, until *Date) DateRange
  ZeroDateRange() DateRange
Methods
  WithFrom(Date) DateRange
  WithUntil(Date) DateRange
  Validate() error          // from is required, from can not be after until
  IsZero() bool
  ContainsDate(Date)
  Overlaps(DateRange) bool
  Exceeds(DateRange) bool
  Equal(DateRange) bool
  String() string           // "from 2022-01-01 until forever"
  HasDays() bool            // DayCount() > 0
  DayCount() int            // when until is nil then returns InfDays

Schedule

type Schedule struct {
	DateRange DateRange
	TimeSlots []WeekdayTimeSlot
}
Constructors
  NewSchedule(dr DateRange, slots ...WeekdayTimeSlot)
Methods
  WithDateRange(dateRange DateRange) Schedule
  WithFrom(from Date) Schedule
  WithUntil(until Date) Schedule
  WithTimeSlots(slots ...WeekdayTimeSlot) Schedule
  From() Date
  Until() *Date
  IsEmpty() bool
  HasTimeSlots() bool
  Merge(schedules ...Schedule) Schedule
Merge

Before using this function you should really understand what it does...

// Merge does a merge on both the schedule dateRanges and the timeslots
//   The intended use for this is to merge a parent schedule with a sub schedule
//   the sub schedule is a subset of the parent, however it may have all-day entries
//   which allow for config on "any" timeslots for that day
//   if timeslots exist on only the parent, they are excluded
//   if timeslots exist on only the child, they are invalid, and therefore excluded
// dateRange
//   a merged dateRange is where they intersect
//   - ex: jan01-jan30 merged with jan15-feb15 results in jan15-jan30
// timeslots
//   are only kept in a merge when they exist in all schedules without conflicting
//   exception is when one schedule has an all day timeslot and another specific timeslots
//   this results in the specific timeslots being kept in favor of the all day
//   - ex: Mon7-8,Tues8-9,Wed merged with Mon7-8,Tues6-7,Wed7-8 results in Mon7-8,Wed7-8
//         Tues809,Tues6-7 were excluded because they only exist in one schedule
//         Wed7-8 was included because the other schedule was for Wed all day
// see TestSchedulesMerge for a good example

Calendar

Constructors
  NewCalendar(schedules ...Schedule) Calendar
Methods
  WithSchedules(schedules ...Schedule) Calendar
  ByDate(limit Date) CalendarMap

CalendarMap

type CalendarMap map[Date][]WeekdayTimeSlot
Methods
HasDate(date Date) bool

Documentation

Index

Constants

View Source
const (
	Sunday    = Weekday(time.Sunday)
	Monday    = Weekday(time.Monday)
	Tuesday   = Weekday(time.Tuesday)
	Wednesday = Weekday(time.Wednesday)
	Thursday  = Weekday(time.Thursday)
	Friday    = Weekday(time.Friday)
	Saturday  = Weekday(time.Saturday)
)
View Source
const InfDays = math.MaxInt32

InfDays represents an infinite number of days MaxInt32 days is over 5.8 million years we are not using a negative number because someone may check DayCount() > 0 to know if it has days or not

Variables

View Source
var (
	ErrFromRequired      = errors.New("from is required")
	ErrPastUntil         = errors.New("until can not be before from")
	ErrInvalidDayName    = errors.New("invalid day name")
	ErrInvalidDateString = errors.New("can not parse date, must use yyyy-mm-dd format")
)

Functions

func SlotKeys

func SlotKeys(slots ...WeekdayTimeSlot) []int

Types

type Calendar

type Calendar struct {
	// contains filtered or unexported fields
}

func NewCalendar

func NewCalendar(schedules ...Schedule) Calendar

func (Calendar) ByDate

func (c Calendar) ByDate(limit Date) CalendarMap

func (Calendar) WithSchedules

func (c Calendar) WithSchedules(schedules ...Schedule) Calendar

WithSchedules appends schedules to existing Calendar

type CalendarMap

type CalendarMap map[Date][]WeekdayTimeSlot

func (CalendarMap) GetTimeslots

func (cm CalendarMap) GetTimeslots() []WeekdayTimeSlot

func (CalendarMap) HasDate

func (cm CalendarMap) HasDate(date Date) bool

type Clock

type Clock struct {
	// contains filtered or unexported fields
}

Clock contains minute in a day

func NewClock

func NewClock(hour, minute int) Clock

NewClock Clock

func ParseClock

func ParseClock(clockStr string) Clock

ParseClock takes a string of hours:minutes such as "15:30"

func (Clock) Add

func (c Clock) Add(minutes int) Clock

Add minutes to a Clock

func (Clock) After

func (c Clock) After(c2 Clock) bool

func (Clock) Before

func (c Clock) Before(c2 Clock) bool

func (Clock) Equal

func (c Clock) Equal(c2 Clock) bool

func (Clock) Hour

func (c Clock) Hour() int

func (Clock) IsZero

func (c Clock) IsZero() bool

func (Clock) MarshalJSON

func (c Clock) MarshalJSON() ([]byte, error)

MarshalJSON marshals the enum as a quoted json string

func (Clock) Minute

func (c Clock) Minute() int

func (Clock) Nanosecond

func (c Clock) Nanosecond() int

func (Clock) Pointer

func (c Clock) Pointer() *Clock

func (*Clock) Scan

func (c *Clock) Scan(src interface{}) error

Scan implements sql.Scanner so that Scan will be scanned correctly from storage

func (Clock) Second

func (c Clock) Second() int

func (Clock) String

func (c Clock) String() string

String of a Clock hh:mm

func (Clock) Subtract

func (c Clock) Subtract(minutes int) Clock

Subtract minutes from a Clock

func (Clock) ToDuration

func (c Clock) ToDuration() time.Duration

func (Clock) ToTime

func (c Clock) ToTime(date Date, loc *time.Location) time.Time

func (*Clock) UnmarshalJSON

func (c *Clock) UnmarshalJSON(b []byte) error

UnmarshalJSON unmashals a quoted json string

func (Clock) Value

func (c Clock) Value() (driver.Value, error)

Value implements driver.Valuer which is used parsing sql param values

type Date

type Date struct {
	// contains filtered or unexported fields
}

func MaxDate

func MaxDate(a, b *Date) *Date

func MinDate

func MinDate(a, b *Date) *Date

func NewDate

func NewDate(year int, month time.Month, day int) Date

func NewDateFromTime

func NewDateFromTime(t time.Time) Date

func ParseDate

func ParseDate(s string) *Date

func Today

func Today() Date

func ZeroDate

func ZeroDate() *Date

ZeroDate is just a zero value Date it is good for json decoding and sql scanning

func (Date) AddDate

func (d Date) AddDate(year, month, day int) Date

func (Date) After

func (d Date) After(date Date) bool

func (Date) Before

func (d Date) Before(date Date) bool

func (*Date) Date

func (d *Date) Date() *Date

func (Date) Day

func (d Date) Day() int

func (Date) Equal

func (d Date) Equal(date Date) bool

func (*Date) IsZero

func (d *Date) IsZero() bool

func (Date) MarshalJSON

func (d Date) MarshalJSON() ([]byte, error)

func (Date) MarshalText

func (d Date) MarshalText() (text []byte, err error)

func (Date) Month

func (d Date) Month() time.Month

func (Date) Next

func (d Date) Next() Date

func (Date) Pointer

func (d Date) Pointer() *Date

func (*Date) Scan

func (d *Date) Scan(src interface{}) error

func (Date) String

func (d Date) String() string

func (Date) Sub

func (d Date) Sub(date Date) int

Sub subtracts two dates, returning the number of days between

today.Sub(today)     = 0
today.Sub(yesterday) = 1
yesterday.Sub(today) = -1

func (Date) ToTime

func (d Date) ToTime() time.Time

func (*Date) UnmarshalJSON

func (d *Date) UnmarshalJSON(bytes []byte) error

func (*Date) UnmarshalText

func (d *Date) UnmarshalText(text []byte) error

func (Date) Value

func (d Date) Value() (driver.Value, error)

func (Date) Weekday

func (d Date) Weekday() Weekday

func (Date) Year

func (d Date) Year() int

type DateRange

type DateRange struct {
	From  Date  `json:"from"`
	Until *Date `json:"until,omitempty"`
}

DateRange represents a set of days this set includes the From date and the Until date from Jan1 until Jan1 is one day from Jan1 until Jan2 is two days when Until is nil it means forever

func NewDateRange

func NewDateRange() DateRange

func NewDateRangeUntil

func NewDateRangeUntil(from Date, until *Date) DateRange

func ZeroDateRange

func ZeroDateRange() DateRange

func (DateRange) ContainsDate

func (dr DateRange) ContainsDate(date Date) bool

func (DateRange) DayCount

func (dr DateRange) DayCount() int

DayCount is the number of days in range when until is nil then the range is infinite from today until today has one day in range from today until tomorrow has two days in range

func (DateRange) Equal

func (dr DateRange) Equal(dr2 DateRange) bool

func (DateRange) Exceeds

func (dr DateRange) Exceeds(parentDR DateRange) bool

func (DateRange) HasDays

func (dr DateRange) HasDays() bool

func (DateRange) IsZero

func (dr DateRange) IsZero() bool

func (DateRange) Merge

func (dr DateRange) Merge(dr2 DateRange) DateRange

Merge keeps the intersection of both date ranges so given dr is jan01-jan31 and dr2 is jan15-feb15 then result is jan15-jan31

func (DateRange) Overlaps

func (dr DateRange) Overlaps(dr2 DateRange) bool

func (DateRange) String

func (dr DateRange) String() string

func (DateRange) Validate

func (dr DateRange) Validate() error

func (DateRange) WithFrom

func (dr DateRange) WithFrom(d Date) DateRange

func (DateRange) WithUntil

func (dr DateRange) WithUntil(d Date) DateRange

type Schedule

type Schedule struct {
	DateRange DateRange
	TimeSlots []WeekdayTimeSlot
}

func NewSchedule

func NewSchedule(dr DateRange, slots ...WeekdayTimeSlot) Schedule

func (Schedule) From

func (s Schedule) From() Date

func (Schedule) HasTimeSlots

func (s Schedule) HasTimeSlots() bool

func (Schedule) IsEmpty

func (s Schedule) IsEmpty() bool

IsEmpty means there are either no days in range or there are no timeslots, therefore nothing on schedule

func (Schedule) Merge

func (s Schedule) Merge(schedules ...Schedule) Schedule

Merge does a merge on both the schedule dateRanges and the timeslots

The intended use for this is to merge a parent schedule with a sub schedule
the sub schedule is a subset of the parent, however it may have all-day entries
which allow for config on "any" timeslots for that day
if timeslots exist on only the parent, they are excluded
if timeslots exist on only the child, they are invalid, and therefore excluded

dateRange

a merged dateRange is where they intersect
- ex: jan01-jan30 merged with jan15-feb15 results in jan15-jan30

timeslots

are only kept in a merge when they exist in all schedules without conflicting
exception is when one schedule has an all day timeslot and another specific timeslots
this results in the specific timeslots being kept in favor of the all day
- ex: Mon7-8,Tues8-9,Wed merged with Mon7-8,Tues6-7,Wed7-8 results in Mon7-8,Wed7-8
      Tues809,Tues6-7 were excluded because they only exist in one schedule
      Wed7-8 was included because the other schedule was for Wed all day

see TestSchedulesMerge for a good example

func (Schedule) Until

func (s Schedule) Until() *Date

func (Schedule) WithDateRange

func (s Schedule) WithDateRange(dateRange DateRange) Schedule

func (Schedule) WithFrom

func (s Schedule) WithFrom(from Date) Schedule

func (Schedule) WithTimeSlots

func (s Schedule) WithTimeSlots(slots ...WeekdayTimeSlot) Schedule

func (Schedule) WithUntil

func (s Schedule) WithUntil(until Date) Schedule

type TimeSlot

type TimeSlot struct {
	Start Clock `json:"start"`
	End   Clock `json:"end"`
}

func NewTimeSlot

func NewTimeSlot(start, end Clock) TimeSlot

func ParseTimeSlot

func ParseTimeSlot(tsString string) TimeSlot

func (TimeSlot) Duration

func (ts TimeSlot) Duration() time.Duration

func (TimeSlot) EndTime

func (ts TimeSlot) EndTime() Clock

func (TimeSlot) Equal added in v0.1.7

func (ts TimeSlot) Equal(ts2 TimeSlot) bool

func (TimeSlot) IsZero

func (ts TimeSlot) IsZero() bool

IsZero returns true only when the start and time are both 00:00

func (TimeSlot) Minutes

func (ts TimeSlot) Minutes() int

func (TimeSlot) StartTime

func (ts TimeSlot) StartTime() Clock

func (TimeSlot) String

func (ts TimeSlot) String() string

type Weekday

type Weekday time.Weekday

Weekday exists only because time.Weekday does not serialize to a string so models.Weekday will be a string when in JSON or database

func NewWeekday

func NewWeekday(value string) (Weekday, error)

NewWeekday converts a string name to a Weekday object to get Weekday from time.Weekday simply type assert ex: models.Weekday(time.Monday)

func ParseWeekday

func ParseWeekday(dayName string) (Weekday, error)

func TodayWeekday

func TodayWeekday() Weekday

func (Weekday) MarshalJSON

func (w Weekday) MarshalJSON() ([]byte, error)

MarshalJSON marshals the enum as a quoted json string

func (Weekday) MarshalText

func (w Weekday) MarshalText() (text []byte, err error)

func (Weekday) Next

func (w Weekday) Next() Weekday

func (*Weekday) Scan

func (w *Weekday) Scan(src interface{}) error

Scan implements sql.Scanner so that Scan will be scanned correctly from storage

func (Weekday) String

func (w Weekday) String() string

func (*Weekday) UnmarshalJSON

func (w *Weekday) UnmarshalJSON(b []byte) error

func (*Weekday) UnmarshalText

func (w *Weekday) UnmarshalText(b []byte) error

func (Weekday) Value

func (w Weekday) Value() (driver.Value, error)

Value is used for sql exec to persist this type as a string

type WeekdayTimeSlot

type WeekdayTimeSlot struct {
	// contains filtered or unexported fields
}

func MergeWeekdayTimeSlots

func MergeWeekdayTimeSlots(a, b []WeekdayTimeSlot) []WeekdayTimeSlot

func NewWeekdayAllDayTimeSlot

func NewWeekdayAllDayTimeSlot(day Weekday) WeekdayTimeSlot

func NewWeekdayTimeSlot

func NewWeekdayTimeSlot(day Weekday, slot TimeSlot) WeekdayTimeSlot

func SortWeekdayTimeSlots

func SortWeekdayTimeSlots(wtsSlice ...WeekdayTimeSlot) []WeekdayTimeSlot

func UniqueWeekdayTimeSlots

func UniqueWeekdayTimeSlots(wtsSlice ...WeekdayTimeSlot) []WeekdayTimeSlot

UniqueWeekdayTimeSlots sorts and removes duplicates

func WeekdayTimeSlotFromInt

func WeekdayTimeSlotFromInt(wtsInt int) WeekdayTimeSlot

func WeekdayTimeSlotFromString

func WeekdayTimeSlotFromString(wtsString string) WeekdayTimeSlot

func (WeekdayTimeSlot) Duration

func (s WeekdayTimeSlot) Duration() time.Duration

func (WeekdayTimeSlot) End

func (s WeekdayTimeSlot) End() Clock

func (WeekdayTimeSlot) Equal

func (s WeekdayTimeSlot) Equal(s2 WeekdayTimeSlot) bool

func (WeekdayTimeSlot) IsAllDay

func (s WeekdayTimeSlot) IsAllDay() bool

func (WeekdayTimeSlot) MarshalJSON added in v0.1.7

func (s WeekdayTimeSlot) MarshalJSON() ([]byte, error)

func (WeekdayTimeSlot) Minutes

func (s WeekdayTimeSlot) Minutes() int

func (WeekdayTimeSlot) OverlapsWith

func (s WeekdayTimeSlot) OverlapsWith(wts2 WeekdayTimeSlot) bool

func (*WeekdayTimeSlot) Scan added in v0.1.7

func (s *WeekdayTimeSlot) Scan(src interface{}) error

func (WeekdayTimeSlot) Slot

func (s WeekdayTimeSlot) Slot() TimeSlot

func (WeekdayTimeSlot) Start

func (s WeekdayTimeSlot) Start() Clock

func (WeekdayTimeSlot) String

func (s WeekdayTimeSlot) String() string

func (WeekdayTimeSlot) ToInt

func (s WeekdayTimeSlot) ToInt() int

ToInt stores the object in binary 3 bits for day, 11 bits for start, 11 bits for end this could be used to check equality or sorting

func (WeekdayTimeSlot) ToString

func (s WeekdayTimeSlot) ToString() string

func (*WeekdayTimeSlot) UnmarshalJSON added in v0.1.7

func (s *WeekdayTimeSlot) UnmarshalJSON(data []byte) error

func (WeekdayTimeSlot) Value added in v0.1.7

func (s WeekdayTimeSlot) Value() (driver.Value, error)

func (WeekdayTimeSlot) Weekday

func (s WeekdayTimeSlot) Weekday() Weekday

type WeekdayTimeSlotMap

type WeekdayTimeSlotMap map[Weekday][]TimeSlot

func NewWeekdayTimeSlotMap

func NewWeekdayTimeSlotMap() WeekdayTimeSlotMap

func WeekdayTimeSlotMapFromSlice

func WeekdayTimeSlotMapFromSlice(wtsSlice []WeekdayTimeSlot) WeekdayTimeSlotMap

func (WeekdayTimeSlotMap) Add

func (WeekdayTimeSlotMap) AddTimeSlot

func (w WeekdayTimeSlotMap) AddTimeSlot(day Weekday, start, end Clock) WeekdayTimeSlotMap

func (WeekdayTimeSlotMap) Has

func (w WeekdayTimeSlotMap) Has(day Weekday, slot TimeSlot) bool

func (WeekdayTimeSlotMap) TimeSlots

func (w WeekdayTimeSlotMap) TimeSlots(day Weekday) []TimeSlot

func (WeekdayTimeSlotMap) ToWeekdayTimeSlots

func (w WeekdayTimeSlotMap) ToWeekdayTimeSlots() []WeekdayTimeSlot

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL