66
77export type RawRule =
88 | { type : "with" ; value : string } // canonical member name as the group knows it
9- | { type : "after" | "back_by" ; value : string } // "HH:MM", 24 h, group time zone
9+ | { type : "after" | "back_by" ; value : string ; bare ?: boolean } // "HH:MM", 24 h, group time zone; bare: no am/pm said
10+ | { type : "after_in" ; value : number } // a delay from now in minutes: "in 10 min", "10min", "give me 10"
1011 | { type : "max_walk" ; value : number }
1112 | { type : "max_step" | "min_step" ; value : 1 | 2 | 3 | 4 } ;
1213
@@ -67,7 +68,36 @@ export function parseTime(raw: string): string | null {
6768 return `${ String ( h ) . padStart ( 2 , "0" ) } :${ String ( min ) . padStart ( 2 , "0" ) } ` ;
6869}
6970
70- const TIME = String . raw `(noon|midday|(?:quarter|half) (?:past|after|to) \w+|half \w+|\d{1,2}(?::\d{2})?\s*(?:am|pm|o'?clock)?)` ;
71+ /** True when a spoken time names an hour with no am or pm ("after 10"), so the next upcoming one is meant. */
72+ export const isBareTime = ( raw : string ) : boolean => {
73+ const s = raw . trim ( ) . toLowerCase ( ) . replace ( / \. / g, "" ) ;
74+ if ( / ( a m | p m ) \b | \b ( n o o n | m i d d a y ) \b / . test ( s ) ) return false ;
75+ const h = Number ( s . match ( / ( \d { 1 , 2 } ) / ) ?. [ 1 ] ?? WORD_NUM [ s . split ( " " ) . at ( - 1 ) ?? "" ] ?? 0 ) ;
76+ return h >= 1 && h <= 12 ;
77+ } ;
78+
79+ // A number followed by a minutes unit is a length of time, never a clock time ("after 10min").
80+ const TIME = String . raw `(noon|midday|(?:quarter|half) (?:past|after|to) \w+|half \w+|\d{1,2}(?::\d{2})?(?!\s*(?:m|mins?|minutes?)\b)\s*(?:am|pm|o'?clock)?)` ;
81+ // Delays from now (live bug, Oct 11): "after 10min", "can come in 10", "give me 10", "in half an hour".
82+ const UNIT = String . raw `(?:m|mins?|minutes?)` ;
83+ const DELAY = [
84+ new RegExp (
85+ String . raw `\b(?:in|after|give me|gimme|there in|here in)\s+(?:about\s+|like\s+)?(\d{1,3})\s*${ UNIT } \b(?!\s*(?:walk|away|on foot|of walk))` ,
86+ "gi" ,
87+ ) ,
88+ new RegExp (
89+ String . raw `\b(?:in|give me|gimme)\s+(?:about\s+|like\s+)?(\d{1,3})\b(?!\s*(?::|am\b|pm\b|o'?clock|${ UNIT } \b|walk|step))` ,
90+ "gi" ,
91+ ) ,
92+ // A bare "10min" opening a sentence or clause: "10min only with you".
93+ new RegExp (
94+ String . raw `(?:^|[,;.!]\s*)(\d{1,3})\s*${ UNIT } \b(?!\s*(?:walk|away|on foot|of walk|max|tops|at most|or less|no more))` ,
95+ "gi" ,
96+ ) ,
97+ ] ;
98+ // "with you", "only if you come", "you and me": the friend is talking to the host.
99+ const WITH_YOU =
100+ / \b (?: o n l y w i t h | w i t h | o n l y i f | i f | a s l o n g a s ) \s + y o u \b (? ! \s + (?: g u y s | a l l | t w o | l o t ) ) | \b (?: y o u a n d m e | m e a n d y o u | y o u a n d i ) \b / i;
71101const AFTER = [
72102 new RegExp ( String . raw `\b(?:after|not before|earliest|from)\s+${ TIME } ` , "gi" ) ,
73103 new RegExp ( String . raw `\bafter my\s+${ TIME } ` , "gi" ) ,
@@ -136,7 +166,9 @@ const softName = (n: string) =>
136166// Step words that need no Plan context (fixture core_40 and hard_20).
137167// S1 (fine-tune): sitting or saying hi is the smallest step ("sit somewhere close", "just wanna say hi").
138168const SMALLEST = / \b ( p o r c h | s m a l l e s t | t i n i e s t | o n l y t h e s m a l l o n e | t h e s m a l l o n e | s a y (?: h i | h e l l o ) | s i t (?: t i n g ) ? ) \b / ;
139- const SHORT = / \b ( s h o r t o n e | q u i c k ( o n e | v e r s i o n | l a p ) | s h o r t e s t | s h o r t (?: l a p | v e r s i o n ) ) \b / ;
169+ // Short-stay words (live bug, Oct 11) read as the shorter steps on a Plan; a Flare has no steps.
170+ const SHORT =
171+ / \b ( s h o r t o n e | q u i c k ( o n e | v e r s i o n | l a p | v i s i t | s t o p ) | s h o r t e s t | s h o r t (?: l a p | v e r s i o n | v i s i t ) | f o r a b i t | s t o p p i n g b y | p o p (?: p i n g ) ? b y | s w i n g b y ) \b / ;
140172const LONGEST = / \b ( l o n g o n e | l o n g e s t | f u l l w a l k | f u l l l o o p | w h o l e ( w a l k | l o o p | t h i n g ) | b i g g e s t | b i g o n e ) \b / ;
141173
142174const MIN_WORD : Record < string , number > = { five : 5 , ten : 10 , fifteen : 15 , twenty : 20 , thirty : 30 } ;
@@ -187,25 +219,37 @@ function stepRules(low: string, steps: StepName[]): RawRule[] {
187219 * Text to raw rules. `members` are display names already decrypted on this phone (BR-C-3).
188220 * Returns [] for a plain yes, didnt_catch for anything it can't read (SRS-COND-8).
189221 */
190- export function parseRaw ( text : string , members : string [ ] , steps : StepName [ ] = [ ] ) : RawResult {
222+ export function parseRaw ( text : string , members : string [ ] , steps : StepName [ ] = [ ] , host = "" ) : RawResult {
191223 // M0 (fine-tune): spoken durations to minutes before anything else, so "half hour" never reads as a time.
192224 const t = text
193225 . trim ( )
194226 . replace ( / \b h a l f (?: \s + a n | \s + a | - | \s + ) \s * h o u r \b / gi, "30 min" )
195227 . replace ( / \b (?: a n | o n e ) h o u r \b / gi, "60 min" )
196228 . replace ( / \b ( f i v e | t e n | f i f t e e n | t w e n t y | t h i r t y ) (?: \s + | - ) m i n / gi, ( _ , w : string ) => `${ MIN_WORD [ w . toLowerCase ( ) ] } min` ) ;
197229 if ( ! t ) return { ok : false , reason : "didnt_catch" } ;
230+ const delays : number [ ] = [ ] ;
231+ let rest = t ;
232+ for ( const re of DELAY )
233+ rest = rest . replace ( re , ( _ , n : string ) => {
234+ delays . push ( Number ( n ) ) ;
235+ return " " ;
236+ } ) ;
198237 if ( PLAIN_YES . test ( t ) ) return { ok : true , rules : [ ] } ;
199238 if ( OR_ALTERNATIVE . test ( t ) ) return { ok : false , reason : "didnt_catch" } ;
200239 const out : RawRule [ ] = [ ] ;
201- const low = t . toLowerCase ( ) ;
240+ const low = rest . toLowerCase ( ) ;
202241 const add = ( r : RawRule ) => {
203242 if ( ! out . some ( ( x ) => x . type === r . type && x . value === r . value ) ) out . push ( r ) ;
204243 } ;
244+ for ( const n of delays ) {
245+ if ( n < 1 || n > 240 ) return { ok : false , reason : "didnt_catch" } ;
246+ add ( { type : "after_in" , value : n } ) ;
247+ }
248+ if ( host && WITH_YOU . test ( rest ) ) add ( { type : "with" , value : host } ) ;
205249
206250 // with(X): names after with / if / only if, joined by "and".
207251 const byLower = new Map ( members . map ( ( n ) => [ n . toLowerCase ( ) , n ] ) ) ;
208- for ( const m of t . matchAll ( NAME_RE ) ) {
252+ for ( const m of rest . matchAll ( NAME_RE ) ) {
209253 for ( const cand of [ m [ 1 ] , m [ 2 ] ] ) {
210254 if ( ! cand ) continue ;
211255 const k = cand . toLowerCase ( ) ;
@@ -214,22 +258,22 @@ export function parseRaw(text: string, members: string[], steps: StepName[] = []
214258 else if ( / ^ [ A - Z ] / . test ( cand ) && ! NOT_NAMES . has ( k ) ) return { ok : false , reason : "not_in_group" , name : cand } ;
215259 }
216260 }
217- for ( const name of members ) if ( softName ( escapeRe ( name ) ) . test ( t ) ) add ( { type : "with" , value : name } ) ;
261+ for ( const name of members ) if ( softName ( escapeRe ( name ) ) . test ( rest ) ) add ( { type : "with" , value : name } ) ;
218262
219263 for ( const re of AFTER )
220- for ( const m of t . matchAll ( re ) ) {
264+ for ( const m of rest . matchAll ( re ) ) {
221265 const v = parseTime ( m [ 1 ] ?? "" ) ;
222266 if ( ! v ) return { ok : false , reason : "didnt_catch" } ;
223- add ( { type : "after" , value : v } ) ;
267+ add ( { type : "after" , value : v , ... ( isBareTime ( m [ 1 ] ?? "" ) ? { bare : true } : { } ) } ) ;
224268 }
225269 for ( const re of BACK_BY )
226- for ( const m of t . matchAll ( re ) ) {
270+ for ( const m of rest . matchAll ( re ) ) {
227271 const v = parseTime ( m [ 1 ] ?? "" ) ;
228272 if ( ! v ) return { ok : false , reason : "didnt_catch" } ;
229- add ( { type : "back_by" , value : v } ) ;
273+ add ( { type : "back_by" , value : v , ... ( isBareTime ( m [ 1 ] ?? "" ) ? { bare : true } : { } ) } ) ;
230274 }
231275 if ( ! out . some ( ( r ) => r . type === "back_by" ) && EVENT_BACK . test ( low ) && ! / \b a f t e r \b / . test ( low ) ) {
232- const m = t . match ( EVENT_AT ) ;
276+ const m = rest . match ( EVENT_AT ) ;
233277 const v = m ?. [ 1 ] ? parseTime ( m [ 1 ] ) : null ;
234278 if ( v ) add ( { type : "back_by" , value : v } ) ;
235279 }
0 commit comments