1 | import { Component, ParsedComponents, ParsedResult, ParsingReference } from "./types";
|
2 |
|
3 | import quarterOfYear from "dayjs/plugin/quarterOfYear";
|
4 | import dayjs, { QUnitType } from "dayjs";
|
5 | import { assignSimilarDate, assignSimilarTime, implySimilarTime } from "./utils/dayjs";
|
6 | import { toTimezoneOffset } from "./timezone";
|
7 | dayjs.extend(quarterOfYear);
|
8 |
|
9 | export class ReferenceWithTimezone {
|
10 | readonly instant: Date;
|
11 | readonly timezoneOffset?: number | null;
|
12 |
|
13 | constructor(input?: ParsingReference | Date) {
|
14 | input = input ?? new Date();
|
15 | if (input instanceof Date) {
|
16 | this.instant = input;
|
17 | } else {
|
18 | this.instant = input.instant ?? new Date();
|
19 | this.timezoneOffset = toTimezoneOffset(input.timezone, this.instant);
|
20 | }
|
21 | }
|
22 |
|
23 | |
24 |
|
25 |
|
26 |
|
27 | getDateWithAdjustedTimezone() {
|
28 | return new Date(this.instant.getTime() + this.getSystemTimezoneAdjustmentMinute(this.instant) * 60000);
|
29 | }
|
30 |
|
31 | |
32 |
|
33 |
|
34 |
|
35 |
|
36 | getSystemTimezoneAdjustmentMinute(date?: Date, overrideTimezoneOffset?: number): number {
|
37 | if (!date || date.getTime() < 0) {
|
38 |
|
39 |
|
40 | date = new Date();
|
41 | }
|
42 |
|
43 | const currentTimezoneOffset = -date.getTimezoneOffset();
|
44 | const targetTimezoneOffset = overrideTimezoneOffset ?? this.timezoneOffset ?? currentTimezoneOffset;
|
45 | return currentTimezoneOffset - targetTimezoneOffset;
|
46 | }
|
47 | }
|
48 |
|
49 | export class ParsingComponents implements ParsedComponents {
|
50 | private knownValues: { [c in Component]?: number };
|
51 | private impliedValues: { [c in Component]?: number };
|
52 | private reference: ReferenceWithTimezone;
|
53 | private _tags = new Set<string>();
|
54 |
|
55 | constructor(reference: ReferenceWithTimezone, knownComponents?: { [c in Component]?: number }) {
|
56 | this.reference = reference;
|
57 | this.knownValues = {};
|
58 | this.impliedValues = {};
|
59 | if (knownComponents) {
|
60 | for (const key in knownComponents) {
|
61 | this.knownValues[key as Component] = knownComponents[key as Component];
|
62 | }
|
63 | }
|
64 |
|
65 | const refDayJs = dayjs(reference.instant);
|
66 | this.imply("day", refDayJs.date());
|
67 | this.imply("month", refDayJs.month() + 1);
|
68 | this.imply("year", refDayJs.year());
|
69 | this.imply("hour", 12);
|
70 | this.imply("minute", 0);
|
71 | this.imply("second", 0);
|
72 | this.imply("millisecond", 0);
|
73 | }
|
74 |
|
75 | get(component: Component): number | null {
|
76 | if (component in this.knownValues) {
|
77 | return this.knownValues[component];
|
78 | }
|
79 |
|
80 | if (component in this.impliedValues) {
|
81 | return this.impliedValues[component];
|
82 | }
|
83 |
|
84 | return null;
|
85 | }
|
86 |
|
87 | isCertain(component: Component): boolean {
|
88 | return component in this.knownValues;
|
89 | }
|
90 |
|
91 | getCertainComponents(): Array<Component> {
|
92 | return Object.keys(this.knownValues) as Array<Component>;
|
93 | }
|
94 |
|
95 | imply(component: Component, value: number): ParsingComponents {
|
96 | if (component in this.knownValues) {
|
97 | return this;
|
98 | }
|
99 | this.impliedValues[component] = value;
|
100 | return this;
|
101 | }
|
102 |
|
103 | assign(component: Component, value: number): ParsingComponents {
|
104 | this.knownValues[component] = value;
|
105 | delete this.impliedValues[component];
|
106 | return this;
|
107 | }
|
108 |
|
109 | delete(component: Component) {
|
110 | delete this.knownValues[component];
|
111 | delete this.impliedValues[component];
|
112 | }
|
113 |
|
114 | clone(): ParsingComponents {
|
115 | const component = new ParsingComponents(this.reference);
|
116 | component.knownValues = {};
|
117 | component.impliedValues = {};
|
118 |
|
119 | for (const key in this.knownValues) {
|
120 | component.knownValues[key as Component] = this.knownValues[key as Component];
|
121 | }
|
122 |
|
123 | for (const key in this.impliedValues) {
|
124 | component.impliedValues[key as Component] = this.impliedValues[key as Component];
|
125 | }
|
126 |
|
127 | return component;
|
128 | }
|
129 |
|
130 | isOnlyDate(): boolean {
|
131 | return !this.isCertain("hour") && !this.isCertain("minute") && !this.isCertain("second");
|
132 | }
|
133 |
|
134 | isOnlyTime(): boolean {
|
135 | return (
|
136 | !this.isCertain("weekday") && !this.isCertain("day") && !this.isCertain("month") && !this.isCertain("year")
|
137 | );
|
138 | }
|
139 |
|
140 | isOnlyWeekdayComponent(): boolean {
|
141 | return this.isCertain("weekday") && !this.isCertain("day") && !this.isCertain("month");
|
142 | }
|
143 |
|
144 | isDateWithUnknownYear(): boolean {
|
145 | return this.isCertain("month") && !this.isCertain("year");
|
146 | }
|
147 |
|
148 | isValidDate(): boolean {
|
149 | const date = this.dateWithoutTimezoneAdjustment();
|
150 |
|
151 | if (date.getFullYear() !== this.get("year")) return false;
|
152 | if (date.getMonth() !== this.get("month") - 1) return false;
|
153 | if (date.getDate() !== this.get("day")) return false;
|
154 | if (this.get("hour") != null && date.getHours() != this.get("hour")) return false;
|
155 | if (this.get("minute") != null && date.getMinutes() != this.get("minute")) return false;
|
156 |
|
157 | return true;
|
158 | }
|
159 |
|
160 | toString() {
|
161 | return `[ParsingComponents {
|
162 | tags: ${JSON.stringify(Array.from(this._tags).sort())},
|
163 | knownValues: ${JSON.stringify(this.knownValues)},
|
164 | impliedValues: ${JSON.stringify(this.impliedValues)}},
|
165 | reference: ${JSON.stringify(this.reference)}]`;
|
166 | }
|
167 |
|
168 | dayjs() {
|
169 | return dayjs(this.date());
|
170 | }
|
171 |
|
172 | date(): Date {
|
173 | const date = this.dateWithoutTimezoneAdjustment();
|
174 | const timezoneAdjustment = this.reference.getSystemTimezoneAdjustmentMinute(date, this.get("timezoneOffset"));
|
175 | return new Date(date.getTime() + timezoneAdjustment * 60000);
|
176 | }
|
177 |
|
178 | addTag(tag: string): ParsingComponents {
|
179 | this._tags.add(tag);
|
180 | return this;
|
181 | }
|
182 |
|
183 | addTags(tags: string[] | Set<string>): ParsingComponents {
|
184 | for (const tag of tags) {
|
185 | this._tags.add(tag);
|
186 | }
|
187 | return this;
|
188 | }
|
189 |
|
190 | tags(): Set<string> {
|
191 | return new Set(this._tags);
|
192 | }
|
193 |
|
194 | private dateWithoutTimezoneAdjustment() {
|
195 | const date = new Date(
|
196 | this.get("year"),
|
197 | this.get("month") - 1,
|
198 | this.get("day"),
|
199 | this.get("hour"),
|
200 | this.get("minute"),
|
201 | this.get("second"),
|
202 | this.get("millisecond")
|
203 | );
|
204 |
|
205 | date.setFullYear(this.get("year"));
|
206 | return date;
|
207 | }
|
208 |
|
209 | static createRelativeFromReference(
|
210 | reference: ReferenceWithTimezone,
|
211 | fragments: { [c in QUnitType]?: number }
|
212 | ): ParsingComponents {
|
213 | let date = dayjs(reference.instant);
|
214 | for (const key in fragments) {
|
215 | date = date.add(fragments[key as QUnitType], key as QUnitType);
|
216 | }
|
217 |
|
218 | const components = new ParsingComponents(reference);
|
219 | if (fragments["hour"] || fragments["minute"] || fragments["second"]) {
|
220 | assignSimilarTime(components, date);
|
221 | assignSimilarDate(components, date);
|
222 | if (reference.timezoneOffset !== null) {
|
223 | components.assign("timezoneOffset", -reference.instant.getTimezoneOffset());
|
224 | }
|
225 | } else {
|
226 | implySimilarTime(components, date);
|
227 | if (reference.timezoneOffset !== null) {
|
228 | components.imply("timezoneOffset", -reference.instant.getTimezoneOffset());
|
229 | }
|
230 |
|
231 | if (fragments["d"]) {
|
232 | components.assign("day", date.date());
|
233 | components.assign("month", date.month() + 1);
|
234 | components.assign("year", date.year());
|
235 | } else if (fragments["week"]) {
|
236 | components.assign("day", date.date());
|
237 | components.assign("month", date.month() + 1);
|
238 | components.assign("year", date.year());
|
239 | components.imply("weekday", date.day());
|
240 | } else {
|
241 | components.imply("day", date.date());
|
242 | if (fragments["month"]) {
|
243 | components.assign("month", date.month() + 1);
|
244 | components.assign("year", date.year());
|
245 | } else {
|
246 | components.imply("month", date.month() + 1);
|
247 | if (fragments["year"]) {
|
248 | components.assign("year", date.year());
|
249 | } else {
|
250 | components.imply("year", date.year());
|
251 | }
|
252 | }
|
253 | }
|
254 | }
|
255 |
|
256 | return components;
|
257 | }
|
258 | }
|
259 |
|
260 | export class ParsingResult implements ParsedResult {
|
261 | refDate: Date;
|
262 | index: number;
|
263 | text: string;
|
264 |
|
265 | reference: ReferenceWithTimezone;
|
266 |
|
267 | start: ParsingComponents;
|
268 | end?: ParsingComponents;
|
269 |
|
270 | constructor(
|
271 | reference: ReferenceWithTimezone,
|
272 | index: number,
|
273 | text: string,
|
274 | start?: ParsingComponents,
|
275 | end?: ParsingComponents
|
276 | ) {
|
277 | this.reference = reference;
|
278 | this.refDate = reference.instant;
|
279 | this.index = index;
|
280 | this.text = text;
|
281 | this.start = start || new ParsingComponents(reference);
|
282 | this.end = end;
|
283 | }
|
284 |
|
285 | clone() {
|
286 | const result = new ParsingResult(this.reference, this.index, this.text);
|
287 | result.start = this.start ? this.start.clone() : null;
|
288 | result.end = this.end ? this.end.clone() : null;
|
289 | return result;
|
290 | }
|
291 |
|
292 | date(): Date {
|
293 | return this.start.date();
|
294 | }
|
295 |
|
296 | tags(): Set<string> {
|
297 | const combinedTags: Set<string> = new Set(this.start.tags());
|
298 | if (this.end) {
|
299 | for (const tag of this.end.tags()) {
|
300 | combinedTags.add(tag);
|
301 | }
|
302 | }
|
303 | return combinedTags;
|
304 | }
|
305 |
|
306 | toString() {
|
307 | const tags = Array.from(this.tags()).sort();
|
308 | return `[ParsingResult {index: ${this.index}, text: '${this.text}', tags: ${JSON.stringify(tags)} ...}]`;
|
309 | }
|
310 | }
|