diff --git a/.gitignore b/.gitignore index 018d41b..2515959 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,9 @@ tests/fixtures/text/cpi-*.txt tests/fixtures/text/arcat-*.txt tests/fixtures/text/manufacturer-*.txt +# UFGS source fixtures — extracted at implementation time, values encoded in migrations +docs/references/UFGS/**/*.docx + # Test output coverage/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f825c9f..dbae0c1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -312,6 +312,31 @@ CREATE TABLE project_specs ( PRIMARY KEY (project_id, spec_id) ); +-- Style templates: per-firm DOCX rendering rules +-- (Phase 2c-i — schema only; generator wiring lands in #32) +CREATE TABLE style_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, -- 'UFGS-Default', 'Acme-Firm', ... + owner TEXT, -- NULL for built-in templates + created_at TIMESTAMPTZ DEFAULT now() +); + +-- Per-NodeType style rules (one row per node_type per template) +CREATE TABLE style_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + template_id UUID NOT NULL REFERENCES style_templates(id) ON DELETE CASCADE, + node_type VARCHAR(20) NOT NULL, -- 'part' | 'article' | 'pr1'..'pr5' + font_family TEXT, + font_size_half_pt INTEGER, -- OOXML native unit (20 = 10pt) + bold BOOLEAN NOT NULL DEFAULT false, + caps BOOLEAN NOT NULL DEFAULT false, + indent_twips INTEGER, -- OOXML native unit (1440 twips = 1in) + space_before_twips INTEGER, + space_after_twips INTEGER, + numbering_format TEXT, -- 'PART %1 -', '%1.%2', '%3.', ... + UNIQUE (template_id, node_type) +); + -- Cross-references extracted at parse time -- target_spec_id resolved lazily (NULL = unresolved or broken) CREATE TABLE spec_references ( diff --git a/src/db/index.ts b/src/db/index.ts index 81dc8f8..0c54295 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -57,6 +57,15 @@ export type { } from './queries/projects.js'; export { searchParagraphs, listSpecSections, lookupSpecSectionTitle } from './queries/search.js'; export type { ParagraphSearchResult, SpecSectionResult } from './queries/search.js'; +export { + getTemplate, + getTemplateByName, + listTemplates, + createTemplate, + upsertStyleRule, +} from './queries/templates.js'; +export { STYLE_NODE_TYPES } from './queries/templates.js'; +export type { StyleNodeType, StyleRule, Template, TemplateMeta } from './queries/templates.js'; export { upsertMapping, deleteMapping, diff --git a/src/db/migrations/010_style_templates.ts b/src/db/migrations/010_style_templates.ts new file mode 100644 index 0000000..8d1f662 --- /dev/null +++ b/src/db/migrations/010_style_templates.ts @@ -0,0 +1,62 @@ +import type { MigrationBuilder } from 'node-pg-migrate'; + +export const up = (pgm: MigrationBuilder): void => { + pgm.createTable('style_templates', { + id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, + name: { type: 'text', notNull: true, unique: true }, + owner: { type: 'text' }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }); + // Reject empty / whitespace-only template names — semantically invalid and + // would silently break getTemplateByName() lookups. + pgm.addConstraint('style_templates', 'style_templates_name_non_empty_check', { + check: `length(trim(name)) > 0`, + }); + + pgm.createTable('style_rules', { + id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, + template_id: { + type: 'uuid', + notNull: true, + references: 'style_templates', + onDelete: 'CASCADE', + }, + // 'part' | 'article' | 'pr1' | 'pr2' | 'pr3' | 'pr4' | 'pr5' + node_type: { type: 'varchar(20)', notNull: true }, + font_family: { type: 'text' }, + // OOXML native unit: half-points (20 = 10pt) + font_size_half_pt: { type: 'integer' }, + bold: { type: 'boolean', notNull: true, default: false }, + caps: { type: 'boolean', notNull: true, default: false }, + // OOXML native unit: twips (1440 twips = 1 inch) + indent_twips: { type: 'integer' }, + space_before_twips: { type: 'integer' }, + space_after_twips: { type: 'integer' }, + numbering_format: { type: 'text' }, + }); + + pgm.addConstraint('style_rules', 'style_rules_template_node_type_key', { + unique: ['template_id', 'node_type'], + }); + // Enforce the StyleNodeType domain at the DB boundary — mirrors the TS union + // exported from src/db/queries/templates.ts. Keep the two in sync. + pgm.addConstraint('style_rules', 'style_rules_node_type_check', { + check: `node_type IN ('part', 'article', 'pr1', 'pr2', 'pr3', 'pr4', 'pr5')`, + }); + // OOXML units are unsigned by definition: half-points and twips cannot be negative. + // Defends against bad data from future template imports / API writes. + pgm.addConstraint('style_rules', 'style_rules_non_negative_ooxml_units_check', { + check: ` + (font_size_half_pt IS NULL OR font_size_half_pt >= 0) AND + (indent_twips IS NULL OR indent_twips >= 0) AND + (space_before_twips IS NULL OR space_before_twips >= 0) AND + (space_after_twips IS NULL OR space_after_twips >= 0) + `, + }); + pgm.createIndex('style_rules', 'template_id', { name: 'style_rules_template_idx' }); +}; + +export const down = (pgm: MigrationBuilder): void => { + pgm.dropTable('style_rules', { cascade: true }); + pgm.dropTable('style_templates', { cascade: true }); +}; diff --git a/src/db/migrations/011_seed_default_style_rules.ts b/src/db/migrations/011_seed_default_style_rules.ts new file mode 100644 index 0000000..27b0f0a --- /dev/null +++ b/src/db/migrations/011_seed_default_style_rules.ts @@ -0,0 +1,125 @@ +import type { MigrationBuilder } from 'node-pg-migrate'; + +/** + * Default UFGS-derived style template seed. + * + * Source fixture: docs/references/UFGS/DIVISION_27/27080001.docx (gitignored) + * Extraction commands (re-run to verify): + * unzip -p docs/references/UFGS/DIVISION_27/27080001.docx word/styles.xml > /tmp/specr-styles.xml + * unzip -p docs/references/UFGS/DIVISION_27/27080001.docx word/numbering.xml > /tmp/specr-numbering.xml + * + * Resolved values (basedOn chain + numId=2 abstractNum lookup): + * + * Normal style (root): + * rFonts ascii="Courier New", sz=20 (half-pt → 10pt), spacing before=0 after=120 + * SpecNormal (basedOn Normal): + * adds spacing line=360 before=0 after=0 (overrides Normal spacing) + * + * | NodeType | Style | basedOn chain | numPr (ilvl/numId) | abstractNum ilvl source | Font | sz | bold | caps | ind start | before | after | lvlText | + * |----------|----------|----------------------|--------------------|-------------------------|-------------|----|------|------|-----------|--------|-------|------------| + * | part | PART | PART→Normal | ilvl=0, numId=2 | ilvl=0 | Courier New | 20 | true | true | 0 | 0 | 120 | PART %1 - | + * | article | Article | Article→Normal | none (parent-only) | ilvl=1 | Courier New | 20 | false| true | NULL | 0 | 120 | %1.%2 | + * | pr1 | Level11 | Level11→SpecNormal→N | ilvl=2, numId=2 | ilvl=2 | Courier New | 20 | false| false| 720 | 0 | 0 | %3. | + * | pr2 | Level21 | Level21→Level11→… | inherits ilvl=2 | ilvl=3 (per design map) | Courier New | 20 | false| false| 1080 | 0 | 0 | %4. | + * | pr3 | Level31 | Level31→Level21→… | inherits | ilvl=4 | Courier New | 20 | false| false| 1440 | 0 | 0 | %5. | + * | pr4 | Level41 | Level41→Level31→… | inherits | ilvl=5 | Courier New | 20 | false| false| 1800 | 0 | 0 | %6) | + * | pr5 | Level51 | Level51→Level41→… | inherits | ilvl=6 | Courier New | 20 | false| false| 2160 | 0 | 0 | %7) | + * + * lvlText values normalized to match buildSpecNumberingConfig() (no trailing spaces); + * the UFGS fixture has trailing spaces on ilvl=0 ("PART %1 - ") and ilvl=1 ("%1.%2 ") + * but our generator emits the trimmed form. See issue #30 for rationale. + * + * Indent values come from the abstractNum level override (per design doc methodology: + * "abstractNum level override → paragraph style → null"). Spacing values come from + * the paragraph style basedOn chain (Normal vs SpecNormal). Where extraction yielded + * no value, we write NULL — never fabricate. + */ + +type SeedNodeType = 'part' | 'article' | 'pr1' | 'pr2' | 'pr3' | 'pr4' | 'pr5'; + +interface SeedRow { + readonly nodeType: SeedNodeType; + readonly fontFamily: string | null; + readonly fontSizeHalfPt: number | null; + readonly bold: boolean; + readonly caps: boolean; + readonly indentTwips: number | null; + readonly spaceBeforeTwips: number | null; + readonly spaceAfterTwips: number | null; + readonly numberingFormat: string | null; +} + +const ROWS: readonly SeedRow[] = [ + cn('part', true, true, 0, 0, 120, 'PART %1 -'), + cn('article', false, true, null, 0, 120, '%1.%2'), + cn('pr1', false, false, 720, 0, 0, '%3.'), + cn('pr2', false, false, 1080, 0, 0, '%4.'), + cn('pr3', false, false, 1440, 0, 0, '%5.'), + cn('pr4', false, false, 1800, 0, 0, '%6)'), + cn('pr5', false, false, 2160, 0, 0, '%7)'), +]; + +function cn( + nodeType: SeedNodeType, + bold: boolean, + caps: boolean, + indent: number | null, + before: number | null, + after: number | null, + fmt: string +): SeedRow { + return { + nodeType, + fontFamily: 'Courier New', + fontSizeHalfPt: 20, + bold, + caps, + indentTwips: indent, + spaceBeforeTwips: before, + spaceAfterTwips: after, + numberingFormat: fmt, + }; +} + +function sqlLit(v: string | number | boolean | null): string { + if (v === null) return 'NULL'; + if (typeof v === 'number') return String(v); + if (typeof v === 'boolean') return v ? 'true' : 'false'; + // Escape single quotes in string literals (defense in depth — seed values are static) + return `'${v.replace(/'/g, "''")}'`; +} + +function insertSql(row: SeedRow): string { + const cols = [ + row.nodeType, + row.fontFamily, + row.fontSizeHalfPt, + row.bold, + row.caps, + row.indentTwips, + row.spaceBeforeTwips, + row.spaceAfterTwips, + row.numberingFormat, + ]; + const literals = cols.map(sqlLit).join(', '); + return `INSERT INTO style_rules ( + template_id, node_type, font_family, font_size_half_pt, + bold, caps, indent_twips, space_before_twips, space_after_twips, numbering_format + ) SELECT id, ${literals} + FROM style_templates WHERE name = 'UFGS-Default'`; +} + +export const up = (pgm: MigrationBuilder): void => { + pgm.sql(`INSERT INTO style_templates (name, owner) VALUES ('UFGS-Default', NULL)`); + for (const row of ROWS) { + pgm.sql(insertSql(row)); + } +}; + +export const down = (pgm: MigrationBuilder): void => { + const nodeTypeList = ROWS.map((r) => `'${r.nodeType}'`).join(','); + pgm.sql( + `DELETE FROM style_rules WHERE template_id IN (SELECT id FROM style_templates WHERE name = 'UFGS-Default') AND node_type IN (${nodeTypeList})` + ); + pgm.sql(`DELETE FROM style_templates WHERE name = 'UFGS-Default'`); +}; diff --git a/src/db/queries/templates.integration.test.ts b/src/db/queries/templates.integration.test.ts new file mode 100644 index 0000000..b5a84d5 --- /dev/null +++ b/src/db/queries/templates.integration.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { pool } from '../index.js'; +import { + getTemplate, + getTemplateByName, + listTemplates, + createTemplate, + upsertStyleRule, + type StyleNodeType, + type StyleRule, +} from './templates.js'; + +const CREATED_TEMPLATE_NAMES: string[] = []; + +afterEach(async () => { + if (CREATED_TEMPLATE_NAMES.length === 0) return; + await pool.query(`DELETE FROM style_templates WHERE name = ANY($1::text[])`, [ + CREATED_TEMPLATE_NAMES, + ]); + CREATED_TEMPLATE_NAMES.length = 0; +}); + +function trackName(name: string): string { + CREATED_TEMPLATE_NAMES.push(name); + return name; +} + +describe('getTemplateByName — UFGS-Default seed', () => { + it('returns the seeded UFGS-Default template', async () => { + const tpl = await getTemplateByName('UFGS-Default'); + expect(tpl).not.toBeNull(); + expect(tpl!.name).toBe('UFGS-Default'); + expect(tpl!.owner).toBeNull(); + }); + + it('contains exactly 7 style rules (one per NodeType)', async () => { + const tpl = await getTemplateByName('UFGS-Default'); + expect(tpl!.rules).toHaveLength(7); + }); + + it('contains each NodeType exactly once', async () => { + const tpl = await getTemplateByName('UFGS-Default'); + const types = tpl!.rules.map((r) => r.nodeType).sort((a, b) => a.localeCompare(b)); + expect(types).toEqual(['article', 'part', 'pr1', 'pr2', 'pr3', 'pr4', 'pr5']); + }); + + it('part rule has correct UFGS-extracted values', async () => { + const tpl = await getTemplateByName('UFGS-Default'); + const part = tpl!.rules.find((r) => r.nodeType === 'part'); + expect(part).toBeDefined(); + expect(part!.fontFamily).toBe('Courier New'); + expect(part!.fontSizeHalfPt).toBe(20); + expect(part!.bold).toBe(true); + expect(part!.caps).toBe(true); + expect(part!.numberingFormat).toBe('PART %1 -'); + }); +}); + +describe('getTemplate', () => { + it('returns null for unknown UUID', async () => { + const result = await getTemplate('00000000-0000-0000-0000-000000000000'); + expect(result).toBeNull(); + }); + + it('loads template + rules by id', async () => { + const byName = await getTemplateByName('UFGS-Default'); + const byId = await getTemplate(byName!.id); + expect(byId).not.toBeNull(); + expect(byId!.id).toBe(byName!.id); + expect(byId!.rules).toHaveLength(7); + }); +}); + +describe('listTemplates', () => { + it('includes UFGS-Default in metadata list', async () => { + const list = await listTemplates(); + const names = list.map((t) => t.name); + expect(names).toContain('UFGS-Default'); + }); +}); + +describe('createTemplate', () => { + it('creates a new template row with given name + owner', async () => { + const name = trackName(`test-firm-${Date.now()}`); + const meta = await createTemplate(name, 'Acme'); + expect(meta.name).toBe(name); + expect(meta.owner).toBe('Acme'); + expect(meta.id).toMatch(/^[0-9a-f-]{36}$/); + }); + + it('defaults owner to null when omitted', async () => { + const name = trackName(`test-noowner-${Date.now()}`); + const meta = await createTemplate(name); + expect(meta.owner).toBeNull(); + }); +}); + +describe('upsertStyleRule', () => { + function ruleFor(nodeType: StyleNodeType, indent: number): StyleRule { + return { + nodeType, + fontFamily: 'Arial', + fontSizeHalfPt: 24, + bold: false, + caps: false, + indentTwips: indent, + spaceBeforeTwips: null, + spaceAfterTwips: null, + numberingFormat: null, + }; + } + + it('inserts on first call, updates on second call (idempotent)', async () => { + const name = trackName(`upsert-test-${Date.now()}`); + const meta = await createTemplate(name); + + await upsertStyleRule(meta.id, ruleFor('pr1', 720)); + const first = await getTemplate(meta.id); + expect(first!.rules).toHaveLength(1); + expect(first!.rules[0]!.indentTwips).toBe(720); + + await upsertStyleRule(meta.id, ruleFor('pr1', 1440)); + const second = await getTemplate(meta.id); + expect(second!.rules).toHaveLength(1); // still one row + expect(second!.rules[0]!.indentTwips).toBe(1440); // updated + }); +}); + +describe('FK cascade behavior', () => { + it('deleting a template cascades to its style_rules rows', async () => { + const name = trackName(`cascade-test-${Date.now()}`); + const meta = await createTemplate(name); + await upsertStyleRule(meta.id, { + nodeType: 'pr1', + fontFamily: null, + fontSizeHalfPt: null, + bold: false, + caps: false, + indentTwips: null, + spaceBeforeTwips: null, + spaceAfterTwips: null, + numberingFormat: null, + }); + + const before = await pool.query<{ count: string }>( + 'SELECT COUNT(*)::text AS count FROM style_rules WHERE template_id = $1', + [meta.id] + ); + expect(Number(before.rows[0]!.count)).toBe(1); + + await pool.query('DELETE FROM style_templates WHERE id = $1', [meta.id]); + + const after = await pool.query<{ count: string }>( + 'SELECT COUNT(*)::text AS count FROM style_rules WHERE template_id = $1', + [meta.id] + ); + expect(Number(after.rows[0]!.count)).toBe(0); + + // Don't keep this in the afterEach delete list — already gone. + const idx = CREATED_TEMPLATE_NAMES.indexOf(name); + if (idx >= 0) CREATED_TEMPLATE_NAMES.splice(idx, 1); + }); +}); + +describe('UNIQUE constraint on template name', () => { + it('rejects creating two templates with the same name', async () => { + const name = trackName(`unique-test-${Date.now()}`); + await createTemplate(name); + await expect(createTemplate(name)).rejects.toThrow(); + }); +}); diff --git a/src/db/queries/templates.ts b/src/db/queries/templates.ts new file mode 100644 index 0000000..e8da8ee --- /dev/null +++ b/src/db/queries/templates.ts @@ -0,0 +1,181 @@ +import { pool, DatabaseError } from '../index.js'; + +/** + * Finite set of node types that may carry visual style. + * Subset of the broader AST NodeType — excludes structural-only kinds + * ('spec', 'note', 'continuation') that never receive style rules. + * Mirrored by the `style_rules.node_type` CHECK constraint in migration 010. + */ +export type StyleNodeType = 'part' | 'article' | 'pr1' | 'pr2' | 'pr3' | 'pr4' | 'pr5'; + +export const STYLE_NODE_TYPES: readonly StyleNodeType[] = [ + 'part', + 'article', + 'pr1', + 'pr2', + 'pr3', + 'pr4', + 'pr5', +]; + +export interface StyleRule { + readonly nodeType: StyleNodeType; + readonly fontFamily: string | null; + readonly fontSizeHalfPt: number | null; + readonly bold: boolean; + readonly caps: boolean; + readonly indentTwips: number | null; + readonly spaceBeforeTwips: number | null; + readonly spaceAfterTwips: number | null; + readonly numberingFormat: string | null; +} + +export interface TemplateMeta { + readonly id: string; + readonly name: string; + readonly owner: string | null; + readonly createdAt: Date; +} + +export interface Template extends TemplateMeta { + readonly rules: readonly StyleRule[]; +} + +interface TemplateRow { + readonly id: string; + readonly name: string; + readonly owner: string | null; + readonly created_at: Date; +} + +interface StyleRuleRow { + readonly node_type: StyleNodeType; + readonly font_family: string | null; + readonly font_size_half_pt: number | null; + readonly bold: boolean; + readonly caps: boolean; + readonly indent_twips: number | null; + readonly space_before_twips: number | null; + readonly space_after_twips: number | null; + readonly numbering_format: string | null; +} + +function mapRuleRow(row: StyleRuleRow): StyleRule { + return { + nodeType: row.node_type, + fontFamily: row.font_family, + fontSizeHalfPt: row.font_size_half_pt, + bold: row.bold, + caps: row.caps, + indentTwips: row.indent_twips, + spaceBeforeTwips: row.space_before_twips, + spaceAfterTwips: row.space_after_twips, + numberingFormat: row.numbering_format, + }; +} + +function mapMetaRow(row: TemplateRow): TemplateMeta { + return { id: row.id, name: row.name, owner: row.owner, createdAt: row.created_at }; +} + +async function loadRules(templateId: string): Promise { + const result = await pool.query( + `SELECT node_type, font_family, font_size_half_pt, bold, caps, + indent_twips, space_before_twips, space_after_twips, numbering_format + FROM style_rules WHERE template_id = $1 + ORDER BY node_type`, + [templateId] + ); + return result.rows.map(mapRuleRow); +} + +export async function getTemplate(id: string): Promise