feat: parse natural language date

This commit is contained in:
Stephen Zhou 2025-10-22 11:20:08 +08:00
parent c72c526664
commit c2ed44c19e
No known key found for this signature in database
3 changed files with 160 additions and 7 deletions

View File

@ -53,6 +53,7 @@
"@yornaath/batshit": "0.11.1",
"ai": "5.0.76",
"camelcase-keys": "10.0.0",
"chrono-node": "2.9.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",

View File

@ -1,3 +1,4 @@
import * as chrono from "chrono-node"
import dayjs from "dayjs"
import type { IFuseOptions } from "fuse.js"
import Fuse from "fuse.js"
@ -129,6 +130,74 @@ const normalizeQuery = (query: string): string => {
return trimmed.startsWith("@") ? trimmed.slice(1) : trimmed
}
/**
* Get the appropriate chrono parser based on language
*/
const getChronoParser = (language: string) => {
if (language === "zh-CN") {
return chrono.zh.hans
}
if (language === "zh-TW") {
return chrono.zh.hant
}
if (language === "ja") {
return chrono.ja
}
return chrono.en
}
/**
* Parse natural language dates using chrono-node with language-specific parser
* Returns a DateRange if the input can be parsed, otherwise null
*/
const parseNaturalLanguageDate = (query: string, language: string): DateRange | null => {
if (!query.trim()) return null
try {
// Get language-specific parser
const parser = getChronoParser(language)
// Try to parse the date using chrono with language-specific parser first
let parsed = parser.parse(query)
// If failed and not already using English parser, try English as fallback
if ((!parsed || parsed.length === 0) && parser !== chrono.en) {
parsed = chrono.en.parse(query)
}
if (!parsed || parsed.length === 0) return null
const result = parsed[0]
if (!result) return null
// Get the start date
const startDate = result.start.date()
const start = dayjs(startDate).startOf("day")
// Check if there's an end date (for ranges)
let end: dayjs.Dayjs
if (result.end) {
const endDate = result.end.date()
end = dayjs(endDate).startOf("day")
} else {
// If no end date, use the start date as a single day
end = start
}
// Validate the dates are valid
if (!start.isValid() || !end.isValid()) return null
// Ensure start is before or equal to end
if (start.isAfter(end)) {
return { start: end, end: start }
}
return { start, end }
} catch {
return null
}
}
export const createDateMentionBuilder = (context: DateMentionBuilderContext) => {
const candidates = buildRelativeCandidates(context)
const fuse = new Fuse(candidates, FUSE_OPTIONS)
@ -136,10 +205,27 @@ export const createDateMentionBuilder = (context: DateMentionBuilderContext) =>
return (query: string): MentionData[] => {
const normalized = normalizeQuery(query)
const today = dayjs().startOf("day")
const bucket = normalized ? fuse.search(normalized).map((result) => result.item) : candidates
const mentions: MentionData[] = []
// Try to parse as natural language date first
if (normalized) {
const naturalDateRange = parseNaturalLanguageDate(normalized, context.language)
if (naturalDateRange) {
// Successfully parsed a natural language date
const chronoMention = createDateMentionData({
range: naturalDateRange,
translate: context.t,
locale: context.language,
withRangeKey: RANGE_WITH_LABEL_KEY,
displayName: formatLocalizedRange(naturalDateRange, context.language),
})
mentions.push(chronoMention)
}
}
// Add predefined relative date suggestions
const bucket = normalized ? fuse.search(normalized).map((result) => result.item) : candidates
bucket.forEach((candidate) => {
const range = candidate.definition.range(today)
if (!range) return

View File

@ -563,6 +563,9 @@ importers:
camelcase-keys:
specifier: 10.0.0
version: 10.0.0
chrono-node:
specifier: 2.9.0
version: 2.9.0
class-variance-authority:
specifier: 0.7.1
version: 0.7.1
@ -773,7 +776,7 @@ importers:
version: link:../../../../packages/internal/utils
'@folo-services/ai-tools':
specifier: 0.2.43
version: 0.2.43(@opentelemetry/api@1.9.0)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.16.0(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(hono@4.9.8)(kysely@0.28.8)(pg@8.16.3)
version: 0.2.43(@opentelemetry/api@1.9.0)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.16.0(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(kysely@0.28.8)(pg@8.16.3)
'@types/node':
specifier: 24.8.1
version: 24.8.1
@ -8415,6 +8418,10 @@ packages:
chromium-pickle-js@0.2.0:
resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==}
chrono-node@2.9.0:
resolution: {integrity: sha512-glI4YY2Jy6JII5l3d5FN6rcrIbKSQqKPhWsIRYPK2IK8Mm4Q1ZZFdYIaDqglUNf7gNwG+kWIzTn0omzzE0VkvQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
ci-info@2.0.0:
resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==}
@ -20685,10 +20692,10 @@ snapshots:
- svelte
- vue
'@folo-services/ai-tools@0.2.43(@opentelemetry/api@1.9.0)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.16.0(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(hono@4.9.8)(kysely@0.28.8)(pg@8.16.3)':
'@folo-services/ai-tools@0.2.43(@opentelemetry/api@1.9.0)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.16.0(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(kysely@0.28.8)(pg@8.16.3)':
dependencies:
'@ai-sdk/openai': 2.0.52(zod@4.1.11)
'@folo-services/drizzle': 0.1.29(@opentelemetry/api@1.9.0)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.16.0(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(hono@4.9.8)(kysely@0.28.8)
'@folo-services/drizzle': 0.1.29(@opentelemetry/api@1.9.0)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.16.0(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(kysely@0.28.8)
ai: 5.0.48(zod@4.1.11)
drizzle-orm: 0.44.6(@opentelemetry/api@1.9.0)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.16.0(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(kysely@0.28.8)(pg@8.16.3)
zod: 4.1.11
@ -20770,6 +20777,47 @@ snapshots:
- sql.js
- sqlite3
'@folo-services/drizzle@0.1.29(@opentelemetry/api@1.9.0)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.16.0(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(kysely@0.28.8)':
dependencies:
'@folo-services/exceptions': 0.1.16
'@hono/zod-openapi': 1.1.2(hono@4.9.8)(zod@4.1.11)
drizzle-orm: 0.44.6(@opentelemetry/api@1.9.0)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.16.0(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(kysely@0.28.8)(pg@8.16.3)
drizzle-zod: 0.8.3(drizzle-orm@0.44.6(@opentelemetry/api@1.9.0)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.16.0(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(react-native@0.79.6(@babel/core@7.28.0)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0))(react@19.2.0))(kysely@0.28.8)(pg@8.16.3))(zod@4.1.11)
nanoid: 5.1.6
pg: 8.16.3
zod: 4.1.11
transitivePeerDependencies:
- '@aws-sdk/client-rds-data'
- '@cloudflare/workers-types'
- '@electric-sql/pglite'
- '@libsql/client'
- '@libsql/client-wasm'
- '@neondatabase/serverless'
- '@op-engineering/op-sqlite'
- '@opentelemetry/api'
- '@planetscale/database'
- '@prisma/client'
- '@tidbcloud/serverless'
- '@types/better-sqlite3'
- '@types/pg'
- '@types/sql.js'
- '@upstash/redis'
- '@vercel/postgres'
- '@xata.io/client'
- better-sqlite3
- bun-types
- expo-sqlite
- gel
- hono
- knex
- kysely
- mysql2
- pg-native
- postgres
- prisma
- sql.js
- sqlite3
'@folo-services/drizzle@0.1.29(@opentelemetry/api@1.9.0)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.4)(@expo/metro-runtime@5.0.4(react-native@0.79.6(@babel/core@7.28.4)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)(utf-8-validate@6.0.5)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.16.0(react-native@0.79.6(@babel/core@7.28.4)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)(utf-8-validate@6.0.5))(react@19.2.0))(react-native@0.79.6(@babel/core@7.28.4)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)(utf-8-validate@6.0.5))(react@19.2.0)(utf-8-validate@6.0.5))(react-native@0.79.6(@babel/core@7.28.4)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)(utf-8-validate@6.0.5))(react@19.2.0))(hono@4.9.8)(kysely@0.28.8)':
dependencies:
'@folo-services/exceptions': 0.1.16
@ -23256,7 +23304,7 @@ snapshots:
debug: 2.6.9
invariant: 2.2.4
metro: 0.82.4(bufferutil@4.0.9)(utf-8-validate@6.0.5)
metro-config: 0.82.4(bufferutil@4.0.9)
metro-config: 0.82.4(bufferutil@4.0.9)(utf-8-validate@6.0.5)
metro-core: 0.82.4
semver: 7.7.3
transitivePeerDependencies:
@ -26171,6 +26219,8 @@ snapshots:
chromium-pickle-js@0.2.0: {}
chrono-node@2.9.0: {}
ci-info@2.0.0: {}
ci-info@3.9.0: {}
@ -31200,6 +31250,22 @@ snapshots:
- supports-color
- utf-8-validate
metro-config@0.82.4(bufferutil@4.0.9)(utf-8-validate@6.0.5):
dependencies:
connect: 3.7.0
cosmiconfig: 5.2.1
flow-enums-runtime: 0.0.6
jest-validate: 29.7.0
metro: 0.82.4(bufferutil@4.0.9)(utf-8-validate@6.0.5)
metro-cache: 0.82.4
metro-core: 0.82.4
metro-runtime: 0.82.4
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
optional: true
metro-core@0.82.4:
dependencies:
flow-enums-runtime: 0.0.6
@ -31385,7 +31451,7 @@ snapshots:
metro-babel-transformer: 0.82.4
metro-cache: 0.82.4
metro-cache-key: 0.82.4
metro-config: 0.82.4(bufferutil@4.0.9)
metro-config: 0.82.4(bufferutil@4.0.9)(utf-8-validate@6.0.5)
metro-core: 0.82.4
metro-file-map: 0.82.4
metro-resolver: 0.82.4