Skip to content

Commit 3ff0517

Browse files
committed
fix(release): read Excel start dates, refuse impossible times and repeated columns, keep typed escapes, no suggestions for punctuation
1 parent 53a2caa commit 3ff0517

8 files changed

Lines changed: 109 additions & 34 deletions

File tree

‎apps/extension/src/omnibox.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,16 @@ function escapeXml(text: string): string {
5353
.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`)
5454
}
5555

56-
/** `encodeURIComponent`, except a lone surrogate (which it refuses) is dropped. */
56+
/**
57+
* A typed value encoded for the path as the address bar would send it: raw characters are
58+
* escaped, an escape already typed (`a%2Fb`) is kept rather than escaped again, and a lone
59+
* surrogate (which `encodeURIComponent` refuses) is dropped.
60+
*/
5761
function encodeSegment(segment: string): string {
58-
return encodeURIComponent(segment.replace(LONE_SURROGATE, ''))
62+
return encodeURIComponent(segment.replace(LONE_SURROGATE, '')).replace(
63+
/%25([0-9A-Fa-f]{2})/g,
64+
'%$1'
65+
)
5966
}
6067

6168
/**

‎apps/sim/app/o/[organizationId]/people/people-import-modal.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ async function readPeopleSheet(file: File): Promise<PeopleSheet> {
4747
type: 'array',
4848
raw: /\.(csv|tsv)$/i.test(file.name),
4949
dense: true,
50+
// Keeps each cell's number format, which is how a date cell is told from a number.
51+
cellNF: true,
5052
})
5153
const sheet = workbook.Sheets[workbook.SheetNames[0] ?? '']
5254
if (!sheet?.['!ref']) throw new Error('The file has no rows')

‎apps/sim/lib/people/import.test.ts‎

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ describe('matchPeopleColumns', () => {
6565
})
6666
})
6767

68+
it('refuses two columns that name the same field, rather than ignoring one', () => {
69+
expect(matchPeopleColumns(['Email', 'e-mail', 'Title'])).toEqual({
70+
ok: false,
71+
message: 'The Email and e-mail columns name the same field; keep one',
72+
})
73+
})
74+
6875
it('refuses a file without an email column, which is how people are matched', () => {
6976
expect(matchPeopleColumns(['name', 'title'])).toEqual({
7077
ok: false,
@@ -215,6 +222,7 @@ describe('planPeopleImport', () => {
215222
['2021/3/4', '2021-03-04'],
216223
['3/4/2021', '2021-03-04'],
217224
['2021-03-04T09:00:00Z', '2021-03-04'],
225+
['2021-03-04T09:00:00.123+02:00', '2021-03-04'],
218226
['2021-03-04 09:00', '2021-03-04'],
219227
])('reads the start date %s', (value, startDate) => {
220228
const result = plan(
@@ -224,22 +232,30 @@ describe('planPeopleImport', () => {
224232
expect(result.changes).toEqual([{ memberId: 'm-ada', profile: profile({ startDate }) }])
225233
})
226234

227-
it.each(['2021-02-30', '13/45/2021', '04.03.2021', 'March 4', '1/2/21', '2021-03-04Tsoon'])(
228-
'skips the row with the unreadable start date %s instead of guessing',
229-
(value) => {
230-
const result = plan(
231-
['email', 'start date'],
232-
[{ email: 'ada@example.com', 'start date': value }]
233-
)
234-
expect(result.changes).toEqual([])
235-
expect(result.issues).toEqual([
236-
expect.objectContaining({
237-
severity: 'skipped',
238-
message: 'Use a start date like 2024-01-31 or 1/31/2024',
239-
}),
240-
])
241-
}
242-
)
235+
it.each([
236+
'2021-02-30',
237+
'13/45/2021',
238+
'04.03.2021',
239+
'March 4',
240+
'1/2/21',
241+
'2021-03-04Tsoon',
242+
'2021-03-04T99:99',
243+
'2021-03-04T24:00',
244+
'2021-03-04T09:00+99:99',
245+
'2021-03/04',
246+
])('skips the row with the unreadable start date %s instead of guessing', (value) => {
247+
const result = plan(
248+
['email', 'start date'],
249+
[{ email: 'ada@example.com', 'start date': value }]
250+
)
251+
expect(result.changes).toEqual([])
252+
expect(result.issues).toEqual([
253+
expect.objectContaining({
254+
severity: 'skipped',
255+
message: 'Use a start date like 2024-01-31 or 1/31/2024',
256+
}),
257+
])
258+
})
243259

244260
it('leaves the manager as it was when the manager is not a member, and says so', () => {
245261
const result = plan(

‎apps/sim/lib/people/import.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,35 @@ function findColumn(headers: readonly string[], aliases: readonly string[]): str
5656
return undefined
5757
}
5858

59+
const RECOGNIZED_KEYS = new Set<string>([
60+
...Object.values(COLUMN_ALIASES).flat(),
61+
...LOCATION_PARTS,
62+
])
63+
64+
/** Two headers that spell the same recognized column differently (`Email`, `e-mail`), if any. */
65+
function repeatedColumn(headers: readonly string[]): [string, string] | null {
66+
const seen = new Map<string, string>()
67+
for (const header of headers) {
68+
const key = headerKey(header)
69+
if (!RECOGNIZED_KEYS.has(key)) continue
70+
const earlier = seen.get(key)
71+
if (earlier !== undefined) return [earlier, header]
72+
seen.set(key, header)
73+
}
74+
return null
75+
}
76+
5977
/** Which header holds each field, or why the file can't be imported. */
6078
export function matchPeopleColumns(
6179
headers: readonly string[]
6280
): { ok: true; columns: PeopleColumns } | { ok: false; message: string } {
81+
const repeated = repeatedColumn(headers)
82+
if (repeated) {
83+
return {
84+
ok: false,
85+
message: `The ${repeated[0]} and ${repeated[1]} columns name the same field; keep one`,
86+
}
87+
}
6388
const email = findColumn(headers, COLUMN_ALIASES.email)
6489
if (!email) {
6590
return { ok: false, message: 'Add an email column so each row can be matched to a member' }

‎apps/sim/lib/people/validation.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ import { z } from 'zod'
22

33
export const PROFILE_TEXT_MAX_LENGTH = 200
44

5-
/** A date, optionally with a time of day and offset, which are dropped. */
5+
/**
6+
* A date with one separator throughout, optionally followed by a real time of day and offset,
7+
* which are checked and then dropped.
8+
*/
69
const ISO_DATE =
7-
/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/
10+
/^(\d{4})([-/])(\d{1,2})\2(\d{1,2})(?:[T ](?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|[+-](?:[01]\d|2[0-3]):?[0-5]\d)?)?$/
811
const US_DATE = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/
912

1013
function calendarDate(year: number, month: number, day: number): string | null {
@@ -22,7 +25,7 @@ function calendarDate(year: number, month: number, day: number): string | null {
2225
*/
2326
export function parseStartDate(value: string): string | null {
2427
const iso = ISO_DATE.exec(value.trim())
25-
if (iso) return calendarDate(Number(iso[1]), Number(iso[2]), Number(iso[3]))
28+
if (iso) return calendarDate(Number(iso[1]), Number(iso[3]), Number(iso[4]))
2629
const us = US_DATE.exec(value.trim())
2730
if (us) return calendarDate(Number(us[3]), Number(us[1]), Number(us[2]))
2831
return null

‎apps/sim/lib/sim-links/application/use-cases.ts‎

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,9 @@ import { requireSimLinksEnabled } from '@/lib/sim-links/feature-flag'
2020
import type { SimLinkAccess, SimLinkRecord, SimLinkViewer } from '@/lib/sim-links/repository'
2121
import * as repository from '@/lib/sim-links/repository'
2222
import {
23-
normalizeSimLinkName,
24-
parseTypedSimLink,
2523
type SimLinkFields,
2624
type SimLinkSort,
27-
stripSimLinkPrefix,
25+
typedSimLinkPrefix,
2826
} from '@/lib/sim-links/validation'
2927
import type { SortDirection } from '@/lib/url-state'
3028

@@ -255,12 +253,6 @@ export const resolveSimLink = defineAuthorizedOrganizationUseCase({
255253
},
256254
})
257255

258-
/** The name in what was typed after the `sim` keyword: `sim/jira/ABC-1?x` suggests from `jira`. */
259-
function typedNamePrefix(query: string): string {
260-
const [path = ''] = stripSimLinkPrefix(query.trim()).split('?')
261-
return normalizeSimLinkName(parseTypedSimLink(path.split('/')).name)
262-
}
263-
264256
/**
265257
* Address-bar suggestions from the organization `sim/` opens in for this session, so every
266258
* suggestion resolves where it is followed. Nothing is suggested where Sim links are off.
@@ -269,7 +261,8 @@ export const suggestSimLinks = defineAuthorizedCredentialUserUseCase({
269261
operation: simLinkOperations.suggest,
270262
async execute({ principal, input }: { principal: SessionPrincipal; input: { query: string } }) {
271263
const organizationId = await resolveSimLinkOrganization(principal)
272-
if (!organizationId) return []
273-
return repository.suggestSimLinks(organizationId, typedNamePrefix(input.query))
264+
const prefix = typedSimLinkPrefix(input.query)
265+
if (!organizationId || prefix === null) return []
266+
return repository.suggestSimLinks(organizationId, prefix)
274267
},
275268
})

‎apps/sim/lib/sim-links/destination.test.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, expect, it } from 'vitest'
22
import { resolveSimLinkTarget } from '@/lib/sim-links/destination'
3-
import { parseTypedSimLink, simLinkFieldsSchema } from '@/lib/sim-links/validation'
3+
import {
4+
parseTypedSimLink,
5+
simLinkFieldsSchema,
6+
typedSimLinkPrefix,
7+
} from '@/lib/sim-links/validation'
48

59
const fields = {
610
name: 'docs',
@@ -163,6 +167,19 @@ describe('Sim link destinations', () => {
163167
})
164168
})
165169

170+
describe('typedSimLinkPrefix', () => {
171+
it.each([
172+
['', ''],
173+
['sim/Team-Docs/ABC-1?x=1', 'teamdocs'],
174+
])('reads the name prefix of %j', (typed, prefix) => {
175+
expect(typedSimLinkPrefix(typed)).toBe(prefix)
176+
})
177+
178+
it.each(['...', '--', '.:-'])('has no prefix for %j, which no link name can match', (typed) => {
179+
expect(typedSimLinkPrefix(typed)).toBeNull()
180+
})
181+
})
182+
166183
describe('parseTypedSimLink', () => {
167184
it.each([
168185
[' onboarding', 'onboarding'],

‎apps/sim/lib/sim-links/validation.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export function normalizeSimLinkName(name: string): string {
2020
}
2121

2222
/** Text as typed after `sim/`, whether or not the prefix itself was typed too. */
23-
export function stripSimLinkPrefix(input: string): string {
23+
function stripSimLinkPrefix(input: string): string {
2424
return input.replace(/^sim\//i, '')
2525
}
2626

@@ -55,6 +55,18 @@ export function parseTypedSimLink(segments: readonly string[]): {
5555
return { name: name.replace(PASTED_TRAILING_PUNCTUATION, '').trimEnd() || name, values }
5656
}
5757

58+
/**
59+
* The normalized name prefix to suggest links from, out of what was typed after the `sim`
60+
* keyword: `sim/jira/ABC-1?x` suggests from `jira`, and nothing typed browses every link. `null`
61+
* when a name was typed that no link can match, such as only punctuation.
62+
*/
63+
export function typedSimLinkPrefix(query: string): string | null {
64+
const [path = ''] = stripSimLinkPrefix(query.trim()).split('?')
65+
const { name } = parseTypedSimLink(path.split('/'))
66+
const prefix = normalizeSimLinkName(name)
67+
return name && !prefix ? null : prefix
68+
}
69+
5870
export function isVariableSimLinkUrl(url: string): boolean {
5971
return url.includes(SIM_LINK_VARIABLE)
6072
}

0 commit comments

Comments
 (0)