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>
187 lines
6.2 KiB
TypeScript
187 lines
6.2 KiB
TypeScript
#!/usr/bin/env npx tsx
|
|
/**
|
|
* Re-decode mojibake'd Swedish characters in supplier and customer text fields.
|
|
*
|
|
* Background: prior to the CSV-encoding fix in lib/import/shared/workbook-reader.ts,
|
|
* the supplier/customer CSV importer passed raw bytes to xlsx with `type: 'array'`,
|
|
* which decodes UTF-8 multi-byte sequences as Latin-1: turning "GÖTEBORG" into
|
|
* "GÖTEBORG" (and similar for å, ä, å, Å, Ä, Ö). This script repairs those rows.
|
|
*
|
|
* Idempotent: lib/import/shared/encoding.ts:decodeStringContent is a no-op on
|
|
* strings that already contain correct Swedish characters, so it is safe to
|
|
* re-run.
|
|
*
|
|
* Usage:
|
|
* # Preview every company
|
|
* npx tsx scripts/fix-import-mojibake.ts
|
|
*
|
|
* # Preview a single company
|
|
* npx tsx scripts/fix-import-mojibake.ts --company-id <uuid>
|
|
*
|
|
* # Apply
|
|
* npx tsx scripts/fix-import-mojibake.ts --commit
|
|
* npx tsx scripts/fix-import-mojibake.ts --company-id <uuid> --commit
|
|
*/
|
|
|
|
import { config } from 'dotenv'
|
|
config({ path: '.env.local' })
|
|
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
|
import { decodeStringContent, hasEncodingIssues } from '../lib/import/shared/encoding'
|
|
|
|
function arg(name: string): string | undefined {
|
|
const i = process.argv.indexOf(`--${name}`)
|
|
return i >= 0 ? process.argv[i + 1] : undefined
|
|
}
|
|
|
|
const COMPANY_ID = arg('company-id') ?? null
|
|
const COMMIT = process.argv.includes('--commit')
|
|
|
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
|
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
|
|
if (!supabaseUrl || !serviceRoleKey) {
|
|
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local')
|
|
process.exit(1)
|
|
}
|
|
|
|
const supabase = createClient(supabaseUrl, serviceRoleKey) as SupabaseClient
|
|
|
|
console.log('─────────────────────────────────────────────────────────')
|
|
console.log('Mojibake repair: suppliers + customers')
|
|
console.log('─────────────────────────────────────────────────────────')
|
|
console.log('Supabase URL :', supabaseUrl)
|
|
console.log('Company :', COMPANY_ID ?? '(all)')
|
|
console.log('Mode :', COMMIT ? 'COMMIT (writes)' : 'DRY RUN (no writes)')
|
|
console.log('─────────────────────────────────────────────────────────\n')
|
|
|
|
const SUPPLIER_TEXT_FIELDS = [
|
|
'name',
|
|
'address_line1',
|
|
'address_line2',
|
|
'city',
|
|
'country',
|
|
'category',
|
|
'notes',
|
|
] as const
|
|
|
|
const CUSTOMER_TEXT_FIELDS = [
|
|
'name',
|
|
'address_line1',
|
|
'address_line2',
|
|
'city',
|
|
'country',
|
|
'notes',
|
|
] as const
|
|
|
|
const PAGE_SIZE = 500
|
|
|
|
async function fetchAll(table: 'suppliers' | 'customers', columns: string[]) {
|
|
const all: Record<string, unknown>[] = []
|
|
let from = 0
|
|
for (;;) {
|
|
let query = supabase
|
|
.from(table)
|
|
.select(['id', 'company_id', ...columns].join(','))
|
|
.order('id', { ascending: true })
|
|
.range(from, from + PAGE_SIZE - 1)
|
|
|
|
if (COMPANY_ID) {
|
|
query = query.eq('company_id', COMPANY_ID)
|
|
}
|
|
|
|
const { data, error } = await query
|
|
if (error) throw new Error(`Failed to read ${table}: ${error.message}`)
|
|
const rows = ((data ?? []) as unknown) as Record<string, unknown>[]
|
|
all.push(...rows)
|
|
if (rows.length < PAGE_SIZE) break
|
|
from += PAGE_SIZE
|
|
}
|
|
return all
|
|
}
|
|
|
|
interface RepairResult {
|
|
scanned: number
|
|
affected: number
|
|
fields: number
|
|
applied: number
|
|
failed: number
|
|
}
|
|
|
|
async function repair(
|
|
table: 'suppliers' | 'customers',
|
|
textFields: readonly string[],
|
|
): Promise<RepairResult> {
|
|
console.log(`[${table}]`)
|
|
const rows = await fetchAll(table, [...textFields])
|
|
console.log(` · Scanned ${rows.length} rows`)
|
|
|
|
let affected = 0
|
|
let fieldsFixed = 0
|
|
let applied = 0
|
|
let failed = 0
|
|
|
|
for (const row of rows) {
|
|
const updates: Record<string, string | null> = {}
|
|
const before: Record<string, string> = {}
|
|
for (const field of textFields) {
|
|
const value = row[field]
|
|
if (typeof value !== 'string' || value === '') continue
|
|
if (!hasEncodingIssues(value)) continue
|
|
const fixed = decodeStringContent(value)
|
|
if (fixed === value) continue
|
|
updates[field] = fixed
|
|
before[field] = value
|
|
}
|
|
|
|
if (Object.keys(updates).length === 0) continue
|
|
|
|
affected++
|
|
fieldsFixed += Object.keys(updates).length
|
|
|
|
const id = row.id as string
|
|
const companyId = row.company_id as string
|
|
console.log(`\n · ${table}.${id} (company ${companyId})`)
|
|
for (const [field, fixed] of Object.entries(updates)) {
|
|
console.log(` ${field}:`)
|
|
console.log(` before: ${JSON.stringify(before[field])}`)
|
|
console.log(` after : ${JSON.stringify(fixed)}`)
|
|
}
|
|
|
|
if (!COMMIT) continue
|
|
|
|
const { error } = await supabase.from(table).update(updates).eq('id', id)
|
|
if (error) {
|
|
console.error(` FAILED: ${error.message}`)
|
|
failed++
|
|
} else {
|
|
applied++
|
|
}
|
|
}
|
|
|
|
return { scanned: rows.length, affected, fields: fieldsFixed, applied, failed }
|
|
}
|
|
|
|
async function main() {
|
|
try {
|
|
const sup = await repair('suppliers', SUPPLIER_TEXT_FIELDS)
|
|
const cus = await repair('customers', CUSTOMER_TEXT_FIELDS)
|
|
|
|
console.log('\n─────────────────────────────────────────────────────────')
|
|
console.log('Summary')
|
|
console.log('─────────────────────────────────────────────────────────')
|
|
console.log(`suppliers : ${sup.affected}/${sup.scanned} rows affected (${sup.fields} fields)`)
|
|
console.log(`customers : ${cus.affected}/${cus.scanned} rows affected (${cus.fields} fields)`)
|
|
if (COMMIT) {
|
|
console.log(`Applied : ${sup.applied + cus.applied}`)
|
|
console.log(`Failed : ${sup.failed + cus.failed}`)
|
|
} else {
|
|
console.log('\nRe-run with --commit to apply.')
|
|
}
|
|
} catch (err) {
|
|
console.error('\nFATAL:', err instanceof Error ? err.message : err)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
main()
|