diff --git a/package-lock.json b/package-lock.json index d5b5502b..3ca782dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,6 @@ "@typescript-eslint/eslint-plugin": "^8.16.0", "@typescript-eslint/parser": "^8.16.0", "builtin-modules": "^3.3.0", - "dayjs": "^1.11.11", "esbuild": "^0.16.17", "esbuild-jest": "^0.5.0", "esbuild-plugin-replace": "^1.4.0", @@ -47,6 +46,7 @@ "svelte-i18next": "^2.2.2", "svelte-media-queries": "^1.6.2", "svelte-preprocess": "^5.1.4", + "temporal-polyfill": "^0.2.5", "ts-essentials": "^10.0.3", "ts-jest": "^29.1.3", "tslib": "^2.6.2", @@ -7620,6 +7620,23 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/temporal-polyfill": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-0.2.5.tgz", + "integrity": "sha512-ye47xp8Cb0nDguAhrrDS1JT1SzwEV9e26sSsrWzVu+yPZ7LzceEcH0i2gci9jWfOfSCCgM3Qv5nOYShVUUFUXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "temporal-spec": "^0.2.4" + } + }, + "node_modules/temporal-spec": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-0.2.4.tgz", + "integrity": "sha512-lDMFv4nKQrSjlkHKAlHVqKrBG4DyFfa9F74cmBZ3Iy3ed8yvWnlWSIdi4IKfSqwmazAohBNwiN64qGx4y5Q3IQ==", + "dev": true, + "license": "ISC" + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmmirror.com/test-exclude/-/test-exclude-6.0.0.tgz", diff --git a/package.json b/package.json index 1c0aecb3..8df699f3 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "@typescript-eslint/eslint-plugin": "^8.16.0", "@typescript-eslint/parser": "^8.16.0", "builtin-modules": "^3.3.0", - "dayjs": "^1.11.11", "esbuild": "^0.16.17", "esbuild-jest": "^0.5.0", "esbuild-plugin-replace": "^1.4.0", @@ -56,6 +55,7 @@ "svelte-i18next": "^2.2.2", "svelte-media-queries": "^1.6.2", "svelte-preprocess": "^5.1.4", + "temporal-polyfill": "^0.2.5", "ts-essentials": "^10.0.3", "ts-jest": "^29.1.3", "tslib": "^2.6.2", @@ -63,4 +63,4 @@ "uuid": "^11.0.3", "yaml": "^2.4.2" } -} \ No newline at end of file +} diff --git a/src/lib/dataApi.ts b/src/lib/dataApi.ts index bac957d8..f1f972f1 100644 --- a/src/lib/dataApi.ts +++ b/src/lib/dataApi.ts @@ -1,4 +1,3 @@ -import dayjs from "dayjs"; import { produce } from "immer"; import moment from "moment"; import { get } from "svelte/store"; @@ -17,6 +16,7 @@ import { decodeFrontMatter, encodeFrontMatter } from "./metadata"; import { i18n } from "./stores/i18n"; import { settings } from "./stores/settings"; import { interpolateTemplate } from "./templates/interpolate"; +import { Temporal } from "temporal-polyfill"; import { function as F, task as T, either as E, taskEither as TE } from "fp-ts"; import { @@ -181,21 +181,44 @@ export function doUpdateRecord( Object.entries({ ...frontmatter, ...record.values }) .map((entry) => { if (isDate(entry[1])) { - const isDatetime = fields.find( + const [, dateValue] = entry; + + const hasTime = fields.some( (field) => field.name === entry[0] && field.type === DataFieldType.Date && (field.typeConfig?.time || - entry[1].getHours() || - entry[1].getMinutes() || - entry[1].getSeconds() || - entry[1].getMilliseconds()) + dateValue.hour || + dateValue.minute || + dateValue.second || + dateValue.millisecond) ); + const isZoned = + dateValue.timeZoneId !== Temporal.Now.timeZoneId() && + dateValue.offset !== Temporal.Now.zonedDateTimeISO().offset; + return produce(entry, (draft) => { - draft[1] = dayjs(entry[1]).format( - isDatetime ? "YYYY-MM-DDTHH:mm" : "YYYY-MM-DD" - ); + if (hasTime && isZoned) { + draft[1] = + dateValue.timeZoneId === "UTC" // The original raw string ends with "Z" + ? dateValue.toString({ + smallestUnit: "minute", + offset: "never", + timeZoneName: "never", + }) + "Z" + : dateValue.toString({ + smallestUnit: "minute", + offset: "auto", + timeZoneName: "never", + }); + } else { + draft[1] = hasTime + ? dateValue + .toPlainDateTime() + .toString({ smallestUnit: "minute" }) + : (draft[1] = dateValue.toPlainDate().toString()); + } }); } return entry; diff --git a/src/lib/dataframe/dataframe.ts b/src/lib/dataframe/dataframe.ts index ed77a55e..5f827a87 100644 --- a/src/lib/dataframe/dataframe.ts +++ b/src/lib/dataframe/dataframe.ts @@ -1,5 +1,6 @@ import type { FieldConfig } from "src/settings/settings"; import type { RecordError } from "../datasources/frontmatter/datasource"; +import { Temporal } from "temporal-polyfill"; /** * DataFrame is the core data structure that contains structured data for a @@ -77,32 +78,32 @@ export type DataValue = | string | number | boolean - | Date + | Temporal.ZonedDateTime | Array>; -export function isOptionalDataValue( - value: unknown -): value is Optional { - switch (typeof value) { - case "string": - return true; - case "number": - return true; - case "boolean": - return true; - default: - return false; - } -} +// export function isOptionalDataValue( +// value: unknown +// ): value is Optional { +// switch (typeof value) { +// case "string": +// return true; +// case "number": +// return true; +// case "boolean": +// return true; +// default: +// return false; +// } +// } -export function isRepeatedDataValue( - value: unknown -): value is Array> { - if (Array.isArray(value)) { - return value.every(isOptionalDataValue); - } - return false; -} +// export function isRepeatedDataValue( +// value: unknown +// ): value is Array> { +// if (Array.isArray(value)) { +// return value.every(isOptionalDataValue); +// } +// return false; +// } export type Optional = | T @@ -138,8 +139,10 @@ export function isNumber( return typeof value === "number"; } -export function isDate(value: Optional | DataValue): value is Date { - return value instanceof Date; +export function isDate( + value: Optional | DataValue +): value is Temporal.ZonedDateTime { + return value instanceof Temporal.ZonedDateTime; } // export function hasValue(value: Optional): value is DataValue { @@ -179,7 +182,7 @@ export function isOptionalNumber( export function isOptionalDate( value: Optional -): value is Optional { +): value is Optional { return isDate(value) || isOptional(value); } diff --git a/src/lib/datasources/dataview/standardize.ts b/src/lib/datasources/dataview/standardize.ts index 2e219ab1..fb5ade2a 100644 --- a/src/lib/datasources/dataview/standardize.ts +++ b/src/lib/datasources/dataview/standardize.ts @@ -1,6 +1,6 @@ -import dayjs from "dayjs"; import type { Link } from "obsidian-dataview"; import type { DataValue, Optional } from "src/lib/dataframe/dataframe"; +import { Temporal } from "temporal-polyfill"; /** * standardizeValues converts a Dataview data structure of values to the common @@ -36,6 +36,6 @@ function standardizeObject(value: any) { return (value as Link).toString(); } if ("ts" in value) { - return dayjs(value.ts).format("YYYY-MM-DD"); + return Temporal.PlainDateTime.from(value.c).toString() } } diff --git a/src/lib/datasources/helpers.test.ts b/src/lib/datasources/helpers.test.ts index 8a94b7a4..2a09f0ef 100644 --- a/src/lib/datasources/helpers.test.ts +++ b/src/lib/datasources/helpers.test.ts @@ -172,6 +172,8 @@ describe("detectCellType", () => { // Complex values. ["2022-01-01", DataFieldType.Date], ["2022-01-01T22:35", DataFieldType.Date], + ["2022-01-01T22:35Z", DataFieldType.Date], + ["2022-01-01T22:35+02:00", DataFieldType.Date], [{ my: "object" }, DataFieldType.Unknown], ]; diff --git a/src/lib/datasources/helpers.ts b/src/lib/datasources/helpers.ts index d29283f4..ae3bb6a6 100644 --- a/src/lib/datasources/helpers.ts +++ b/src/lib/datasources/helpers.ts @@ -1,5 +1,3 @@ -import dayjs from "dayjs"; - import { DataFieldType, type DataField, @@ -7,6 +5,7 @@ import { type DataValue, type Optional, } from "../dataframe/dataframe"; +import { Temporal } from "temporal-polyfill"; /** * Parses the values for each record based on the detected field types. @@ -31,7 +30,9 @@ export function parseRecords( switch (field.type) { case DataFieldType.Date: if (typeof value === "string") { - record.values[field.name] = dayjs(value).toDate(); + record.values[field.name] = parseStringDate(value); + } else if (typeof value === "number") { + record.values[field.name] = parseNumberDate(value); } break; case DataFieldType.Number: @@ -46,7 +47,7 @@ export function parseRecords( break; case DataFieldType.String: if (typeof value !== "object") { - record.values[field.name] = value?.toLocaleString(); + record.values[field.name] = value?.toLocaleString(); //TODO: fallback type, to be refined in type casting } break; } @@ -55,6 +56,44 @@ export function parseRecords( return records; } +function parseStringDate(value: string) { + for (const parseFn of [ + () => Temporal.ZonedDateTime.from(value), // with timezone id + () => Temporal.Instant.from(value).toZonedDateTimeISO(value), // with timezone offset + () => + Temporal.PlainDateTime.from(value).toZonedDateTime( + Temporal.Now.timeZoneId() + ), // w/o timezone info + ]) { + try { + return parseFn(); + } catch { + continue; + } + } + return null; +} + +function parseNumberDate(value: number) { + for (const parseFn of [ + () => + Temporal.PlainDateTime.from(value.toString()).toZonedDateTime( + Temporal.Now.timeZoneId() + ), + () => + Temporal.Instant.fromEpochMilliseconds(value).toZonedDateTimeISO( + Temporal.Now.timeZoneId() + ), + ]) { + try { + return parseFn(); + } catch { + continue; + } + } + return null; +} + /** * Merges a new version of `values` into a copy of data record. * @@ -119,12 +158,13 @@ function typeFromValues(values: Optional[]): DataFieldType { } } +export const DateTimeRegex = + /^\d{4}-\d{2}-\d{2}(?:[Tt ](?:\d{2})?(?::\d{2})?(?::\d{2})?(?:.\d+)?(?:[+-]\d{2}(?::?\d{2})?|[Zz])?(?:\[[^\]]+\])?)?$/; + export function detectCellType(value: unknown): DataFieldType { // Standard types if (typeof value === "string") { - if ( - /^\d{4}-\d{2}-\d{2}(T)?(\d{2})?(:\d{2})?(:\d{2})?(.\d{3})?$/.test(value) - ) { + if (DateTimeRegex.test(value)) { return DataFieldType.Date; } return DataFieldType.String; diff --git a/src/main.ts b/src/main.ts index aa87c764..7c75b3f7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,3 @@ -import dayjs from "dayjs"; -import isoWeek from "dayjs/plugin/isoWeek"; -import localizedFormat from "dayjs/plugin/localizedFormat"; import { either, task, taskEither } from "fp-ts"; import { pipe } from "fp-ts/lib/function"; import { Plugin, TFolder, WorkspaceLeaf, addIcon } from "obsidian"; @@ -25,9 +22,6 @@ import { } from "./settings/settings"; import { ProjectsView, VIEW_TYPE_PROJECTS } from "./view"; -dayjs.extend(isoWeek); -dayjs.extend(localizedFormat); - const PROJECTS_PLUGIN_ID = "obsidian-projects"; export default class ProjectsPlugin extends Plugin { diff --git a/src/ui/app/filterFunctions.ts b/src/ui/app/filterFunctions.ts index 993ac7ad..012906e0 100644 --- a/src/ui/app/filterFunctions.ts +++ b/src/ui/app/filterFunctions.ts @@ -1,5 +1,4 @@ import { produce } from "immer"; -import dayjs from "dayjs"; import { type DataFrame, type DataRecord, @@ -27,6 +26,7 @@ import { type DateFilterOperator, type ListFilterOperator, } from "src/settings/settings"; +import { Temporal } from "temporal-polyfill"; export function matchesCondition( cond: FilterCondition, @@ -61,15 +61,31 @@ export function matchesCondition( } else if (isOptionalBoolean(value) && isBooleanFilterOperator(operator)) { return booleanFns[operator](value); } else if (isOptionalDate(value) && isDateFilterOperator(operator)) { - return dateFns[operator]( - value, - cond.value ? dayjs(cond.value).toDate() : undefined - ); + const parsedDate = cond.value ? parseStringDate(cond.value) : undefined; + return dateFns[operator](value, parsedDate); } return false; } +function parseStringDate(value: string) { + for (const parseFn of [ + () => Temporal.ZonedDateTime.from(value), // with timezone id + () => Temporal.Instant.from(value).toZonedDateTimeISO(value), // with timezone offset + () => + Temporal.PlainDateTime.from(value).toZonedDateTime( + Temporal.Now.timeZoneId() + ), // w/o timezone info + ]) { + try { + return parseFn(); + } catch { + continue; + } + } + return null; +} + export function matchesFilterConditions( filter: FilterDefinition, record: DataRecord @@ -136,23 +152,29 @@ export const booleanFns: Record< "is-not-checked": (value) => value === false, }; +// TODO: time filters, should be implemented together with day-level view export const dateFns: Record< DateFilterOperator, - (left: Optional, right?: Optional) => boolean + ( + left: Optional, + right?: Optional + ) => boolean > = { "is-on": (left, right) => { - return left && right ? left.getTime() == right.getTime() : false; + return left && right ? left.equals(right) : false; }, "is-not-on": (left, right) => - left && right ? left.getTime() != right.getTime() : true, + left?.toPlainDateTime() && right?.toPlainDateTime() + ? !left.toPlainDateTime().equals(right.toPlainDateTime()) + : true, "is-before": (left, right) => - left && right ? left.getTime() < right.getTime() : false, + left && right ? Temporal.PlainDateTime.compare(left, right) == -1 : false, "is-after": (left, right) => - left && right ? left.getTime() > right.getTime() : false, + left && right ? Temporal.PlainDateTime.compare(left, right) == 1 : false, "is-on-and-before": (left, right) => - left && right ? left.getTime() <= right.getTime() : false, + left && right ? Temporal.PlainDateTime.compare(left, right) < 1 : false, "is-on-and-after": (left, right) => - left && right ? left.getTime() >= right.getTime() : false, + left && right ? Temporal.PlainDateTime.compare(left, right) > -1 : false, }; export const listFns_multitext: Record< diff --git a/src/ui/app/onboarding/demoProject.ts b/src/ui/app/onboarding/demoProject.ts index 3856b296..0cae62ea 100644 --- a/src/ui/app/onboarding/demoProject.ts +++ b/src/ui/app/onboarding/demoProject.ts @@ -1,6 +1,6 @@ -import dayjs from "dayjs"; import { normalizePath, stringifyYaml, type Vault } from "obsidian"; import { v4 as uuidv4 } from "uuid"; +import { Temporal } from "temporal-polyfill"; import { settings } from "src/lib/stores/settings"; import type { BoardConfig } from "src/ui/views/Board/types"; @@ -14,12 +14,12 @@ export async function createDemoProject(vault: Vault) { await vault.createFolder(demoFolder); - const startDate = dayjs(); + const startDate = Temporal.Now.zonedDateTimeISO(); const files = { "The Best Notes You'll Ever Make": { status: "Done", - due: startDate.subtract(2, "weeks").format("YYYY-MM-DD"), + due: startDate.subtract({weeks: 2}).toPlainDate().toString(), published: true, weight: 1, tags: ["note-taking"], @@ -28,7 +28,7 @@ export async function createDemoProject(vault: Vault) { }, "The Easiest Way to Start Taking Notes": { status: "Done", - due: startDate.subtract(1, "weeks").format("YYYY-MM-DD"), + due: startDate.subtract({weeks: 1}).toPlainDate().toString(), published: true, weight: 2, tags: ["note-taking", "obsidian"], @@ -37,7 +37,7 @@ export async function createDemoProject(vault: Vault) { }, "Why You Should Be Taking More Notes": { status: "Doing", - due: startDate.format("YYYY-MM-DD"), + due: startDate.toPlainDate().toString(), published: false, weight: 3, tags: ["note-taking", "pkm"], @@ -46,7 +46,7 @@ export async function createDemoProject(vault: Vault) { }, "What I Learned From Taking 15,000 Notes": { status: "Backlog", - due: startDate.add(1, "weeks").format("YYYY-MM-DD"), + due: startDate.add({weeks: 1}).toPlainDate().toString(), published: false, weight: 4, tags: ["pkm", "obsidian"], @@ -55,7 +55,7 @@ export async function createDemoProject(vault: Vault) { }, "5 Mistakes I Made When I Started Using Obsidian": { status: "Backlog", - due: startDate.add(2, "weeks").format("YYYY-MM-DD"), + due: startDate.add({weeks: 2}).toPlainDate().toString(), published: false, tags: ["obsidian"], image: diff --git a/src/ui/app/toolbar/viewOptions/color/ColorOptions.svelte b/src/ui/app/toolbar/viewOptions/color/ColorOptions.svelte index 874fa346..0c7ca726 100644 --- a/src/ui/app/toolbar/viewOptions/color/ColorOptions.svelte +++ b/src/ui/app/toolbar/viewOptions/color/ColorOptions.svelte @@ -1,6 +1,6 @@ -{#if field.type === DataFieldType.Date && !field.repeated && value instanceof Date} - {Intl.DateTimeFormat("default", { +{#if field.type === DataFieldType.Date && !field.repeated && isDate(value)} + {value.toPlainDateTime().toLocaleString("default", { year: "numeric", month: "numeric", day: "numeric", - }).format(value)} + })} {/if} diff --git a/src/ui/components/CardMetadata/Datetime.svelte b/src/ui/components/CardMetadata/Datetime.svelte index ed272094..04cdf005 100644 --- a/src/ui/components/CardMetadata/Datetime.svelte +++ b/src/ui/components/CardMetadata/Datetime.svelte @@ -1,6 +1,7 @@ -{#if field.type === DataFieldType.Date && !field.repeated && value instanceof Date} - {Intl.DateTimeFormat("default", { +{#if field.type === DataFieldType.Date && !field.repeated && isDate(value)} + {value.toPlainDateTime().toLocaleString("default", { year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", hour12: false, - }).format(value)} + })} {/if} diff --git a/src/ui/components/DateInput.svelte b/src/ui/components/DateInput.svelte index e7ca4a42..3410eecf 100644 --- a/src/ui/components/DateInput.svelte +++ b/src/ui/components/DateInput.svelte @@ -1,11 +1,11 @@