Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
25 changes: 25 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
9 changes: 9 additions & 0 deletions src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
62 changes: 62 additions & 0 deletions src/db/migrations/010_style_templates.ts
Original file line number Diff line number Diff line change
@@ -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 },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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' },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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 });
};
125 changes: 125 additions & 0 deletions src/db/migrations/011_seed_default_style_rules.ts
Original file line number Diff line number Diff line change
@@ -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'`);
};
Loading