ec27228a8e
Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
178 lines
6.0 KiB
TypeScript
178 lines
6.0 KiB
TypeScript
/**
|
||
* Parses Swedish (and a few mixed Swedish/English) date-range patterns out
|
||
* of free-text invoice descriptions / line items so the periodisering wizard
|
||
* can auto-detect supplier invoices and customer invoices whose service
|
||
* window crosses the fiscal-period boundary.
|
||
*
|
||
* The function is intentionally conservative: it only returns a parse when
|
||
* BOTH a recognizable start and end date are present. Single dates, "från
|
||
* 2026-01-01" with no end, and malformed strings all return null. The auto-
|
||
* detect step later sets `confidence: 'low'` when the parser only caught a
|
||
* partial pattern (currently always "high" since partials return null,
|
||
* kept as a knob so the wizard UI can downgrade future heuristics without
|
||
* touching this file).
|
||
*
|
||
* Patterns supported (case-insensitive):
|
||
* 1. ISO : "period: 2026-01-01 till 2027-12-31"
|
||
* 2. ISO : "perioden 2026-01-01 - 2026-12-31"
|
||
* 3. Swede : "period: 1 jan 2026 - 31 dec 2026"
|
||
* 4. Swede : "jan 2026 till dec 2026" (whole-month range, expanded to 1st / last)
|
||
* 5. yyyy-mm: "2026-01 till 2027-12" (expanded to 1st of start / last of end)
|
||
* 6. Free : "giltig från 2026-01-01 till 2027-12-31"
|
||
*
|
||
* All return ISO `yyyy-mm-dd`. The function does no fiscal logic: it just
|
||
* reports the parsed window. The caller decides whether endDate > period_end.
|
||
*/
|
||
|
||
const SWEDISH_MONTHS: Record<string, number> = {
|
||
jan: 0,
|
||
januari: 0,
|
||
feb: 1,
|
||
februari: 1,
|
||
mar: 2,
|
||
mars: 2,
|
||
apr: 3,
|
||
april: 3,
|
||
maj: 4,
|
||
jun: 5,
|
||
juni: 5,
|
||
jul: 6,
|
||
juli: 6,
|
||
aug: 7,
|
||
augusti: 7,
|
||
sep: 8,
|
||
september: 8,
|
||
okt: 9,
|
||
oktober: 9,
|
||
nov: 10,
|
||
november: 10,
|
||
dec: 11,
|
||
december: 11,
|
||
}
|
||
|
||
const MONTH_NAMES_PATTERN = Object.keys(SWEDISH_MONTHS).join('|')
|
||
|
||
/** Separator tokens between the two dates: " till ", " - ", "–", "—". */
|
||
const SEP = '(?:\\s*[-–—]\\s*|\\s+till\\s+|\\s+t\\.?\\s*o\\.?\\s*m\\.?\\s+)'
|
||
|
||
/** ISO date `yyyy-mm-dd`. */
|
||
const ISO = '(\\d{4}-\\d{2}-\\d{2})'
|
||
|
||
/** yyyy-mm without day. */
|
||
const YM = '(\\d{4}-\\d{2})'
|
||
|
||
/** Swedish long form "1 jan 2026": day optional. */
|
||
const SWE_LONG = `(?:(\\d{1,2})\\s+)?(${MONTH_NAMES_PATTERN})\\s+(\\d{4})`
|
||
|
||
function pad2(n: number): string {
|
||
return n < 10 ? `0${n}` : String(n)
|
||
}
|
||
|
||
/** Number of days in a (1-based) month/year, honoring leap years. */
|
||
function daysInMonth(year: number, monthZeroBased: number): number {
|
||
return new Date(Date.UTC(year, monthZeroBased + 1, 0)).getUTCDate()
|
||
}
|
||
|
||
function isoFromYMD(year: number, monthZeroBased: number, day: number): string {
|
||
return `${year}-${pad2(monthZeroBased + 1)}-${pad2(day)}`
|
||
}
|
||
|
||
/** "2026-01" → "2026-01-01"; "2026-02" with `lastDay=true` → "2026-02-28". */
|
||
function expandYearMonth(ym: string, lastDay: boolean): string | null {
|
||
const m = /^(\d{4})-(\d{2})$/.exec(ym)
|
||
if (!m) return null
|
||
const year = parseInt(m[1], 10)
|
||
const monthZero = parseInt(m[2], 10) - 1
|
||
if (monthZero < 0 || monthZero > 11) return null
|
||
const day = lastDay ? daysInMonth(year, monthZero) : 1
|
||
return isoFromYMD(year, monthZero, day)
|
||
}
|
||
|
||
/** Swedish long form ("1 jan 2026" or "jan 2026") → ISO. When the day is
|
||
* missing, anchor to the 1st (start side) or the last day of the month
|
||
* (end side). */
|
||
function expandSwedishLong(
|
||
day: string | undefined,
|
||
monthName: string,
|
||
year: string,
|
||
endSide: boolean,
|
||
): string | null {
|
||
const monthZero = SWEDISH_MONTHS[monthName.toLowerCase()]
|
||
if (monthZero === undefined) return null
|
||
const y = parseInt(year, 10)
|
||
if (Number.isNaN(y)) return null
|
||
if (day) {
|
||
const d = parseInt(day, 10)
|
||
if (d < 1 || d > daysInMonth(y, monthZero)) return null
|
||
return isoFromYMD(y, monthZero, d)
|
||
}
|
||
return isoFromYMD(y, monthZero, endSide ? daysInMonth(y, monthZero) : 1)
|
||
}
|
||
|
||
function validateRange(startDate: string, endDate: string): boolean {
|
||
// endDate must be strictly after startDate. A single date repeated isn't
|
||
// a range, just a point: the caller should treat that as "no range".
|
||
return endDate > startDate
|
||
}
|
||
|
||
export interface ParsedDateRange {
|
||
startDate: string // ISO yyyy-mm-dd
|
||
endDate: string // ISO yyyy-mm-dd
|
||
}
|
||
|
||
/**
|
||
* Attempt to extract a date range from a free-text description. Returns
|
||
* null if nothing recognizable is found.
|
||
*/
|
||
export function parseInvoiceDateRange(description: string | null | undefined): ParsedDateRange | null {
|
||
if (!description) return null
|
||
const text = description.toLowerCase()
|
||
|
||
// 1. ISO-ISO: "2026-01-01 till 2027-12-31", "2026-01-01 - 2027-12-31"
|
||
const isoRe = new RegExp(`${ISO}${SEP}${ISO}`, 'i')
|
||
const isoMatch = isoRe.exec(text)
|
||
if (isoMatch) {
|
||
const start = isoMatch[1]
|
||
const end = isoMatch[2]
|
||
if (isValidIso(start) && isValidIso(end) && validateRange(start, end)) {
|
||
return { startDate: start, endDate: end }
|
||
}
|
||
}
|
||
|
||
// 2. Swedish long form on both sides: "1 jan 2026 - 31 dec 2026"
|
||
const sweRe = new RegExp(`${SWE_LONG}${SEP}${SWE_LONG}`, 'i')
|
||
const sweMatch = sweRe.exec(text)
|
||
if (sweMatch) {
|
||
const startDate = expandSwedishLong(sweMatch[1], sweMatch[2], sweMatch[3], false)
|
||
const endDate = expandSwedishLong(sweMatch[4], sweMatch[5], sweMatch[6], true)
|
||
if (startDate && endDate && validateRange(startDate, endDate)) {
|
||
return { startDate, endDate }
|
||
}
|
||
}
|
||
|
||
// 3. yyyy-mm on both sides: "2026-01 till 2027-12"
|
||
// Guarded: don't allow a full ISO date here, anchor to space / start.
|
||
const ymRe = new RegExp(`(?:^|[^\\d-])${YM}${SEP}${YM}(?![\\d-])`, 'i')
|
||
const ymMatch = ymRe.exec(text)
|
||
if (ymMatch) {
|
||
const startDate = expandYearMonth(ymMatch[1], false)
|
||
const endDate = expandYearMonth(ymMatch[2], true)
|
||
if (startDate && endDate && validateRange(startDate, endDate)) {
|
||
return { startDate, endDate }
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
function isValidIso(s: string): boolean {
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false
|
||
const [yStr, mStr, dStr] = s.split('-')
|
||
const year = parseInt(yStr, 10)
|
||
const monthZero = parseInt(mStr, 10) - 1
|
||
const day = parseInt(dStr, 10)
|
||
if (monthZero < 0 || monthZero > 11) return false
|
||
if (day < 1 || day > daysInMonth(year, monthZero)) return false
|
||
return true
|
||
}
|