Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08af4cd0ae | ||
|
|
eb279a87a9 | ||
|
|
ea3b595b32 | ||
|
|
e9b432bec6 | ||
|
|
741c8109fa | ||
|
|
07d9c8d9f7 | ||
|
|
0928b3636e | ||
|
|
738f852168 | ||
|
|
aa7b97689b | ||
|
|
5cd5a9e37d | ||
|
|
27b734f6d0 | ||
|
|
91072548d4 | ||
|
|
5b380863cf | ||
|
|
1293da7f25 | ||
|
|
d9cf8f53e8 | ||
|
|
f509e24942 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.18",
|
"version": "2.4.26",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.18",
|
"version": "2.4.26",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.102.0",
|
"@anthropic-ai/sdk": "^0.102.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.18",
|
"version": "2.4.26",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -112,6 +112,16 @@ beforeAll(async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
fixUser2Id = u2.id;
|
fixUser2Id = u2.id;
|
||||||
|
// Leave balance for the HR tools (far-future year per fixture hygiene).
|
||||||
|
await prisma.leave_balances.create({
|
||||||
|
data: {
|
||||||
|
user_id: fixUserId,
|
||||||
|
year: 2098,
|
||||||
|
vacation_total: 25,
|
||||||
|
vacation_used: 5,
|
||||||
|
sick_used: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
await prisma.attendance.create({
|
await prisma.attendance.create({
|
||||||
data: {
|
data: {
|
||||||
user_id: fixUserId,
|
user_id: fixUserId,
|
||||||
@@ -176,6 +186,107 @@ beforeAll(async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const FIX_OFFER_NUMBER = "2098/NA/99999";
|
||||||
|
const FIX_SUPPLIER = "AiTest Dodavatel s.r.o.";
|
||||||
|
const FIX_RI_SUPPLIER = "AiTest Supplier s.r.o.";
|
||||||
|
const FIX_CUSTOMER = "AiTest Zákazník s.r.o.";
|
||||||
|
let fixOfferId = 0;
|
||||||
|
let fixSupplierId = 0;
|
||||||
|
let fixRiId = 0;
|
||||||
|
let fixCustomerId = 0;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await prisma.quotations.deleteMany({
|
||||||
|
where: { quotation_number: FIX_OFFER_NUMBER },
|
||||||
|
});
|
||||||
|
await prisma.sklad_suppliers.deleteMany({ where: { name: FIX_SUPPLIER } });
|
||||||
|
await prisma.received_invoices.deleteMany({
|
||||||
|
where: { supplier_name: FIX_RI_SUPPLIER },
|
||||||
|
});
|
||||||
|
const sup = await prisma.sklad_suppliers.create({
|
||||||
|
data: { name: FIX_SUPPLIER, ico: "99999998", city: "Brno" },
|
||||||
|
});
|
||||||
|
fixSupplierId = sup.id;
|
||||||
|
// Customer + two projects for the per-customer aggregation tool.
|
||||||
|
await prisma.projects.deleteMany({
|
||||||
|
where: { name: { startsWith: "AiTest Projekt" } },
|
||||||
|
});
|
||||||
|
await prisma.customers.deleteMany({ where: { name: FIX_CUSTOMER } });
|
||||||
|
const cust = await prisma.customers.create({
|
||||||
|
data: { name: FIX_CUSTOMER },
|
||||||
|
});
|
||||||
|
fixCustomerId = cust.id;
|
||||||
|
await prisma.projects.createMany({
|
||||||
|
data: [
|
||||||
|
{ name: "AiTest Projekt 1", customer_id: fixCustomerId },
|
||||||
|
{ name: "AiTest Projekt 2", customer_id: fixCustomerId },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const ri = await prisma.received_invoices.create({
|
||||||
|
data: {
|
||||||
|
month: 6,
|
||||||
|
year: 2098,
|
||||||
|
supplier_name: FIX_RI_SUPPLIER,
|
||||||
|
invoice_number: "AITEST-RI-1",
|
||||||
|
amount: 1210,
|
||||||
|
vat_rate: 21,
|
||||||
|
vat_amount: 210,
|
||||||
|
currency: "CZK",
|
||||||
|
issue_date: new Date(Date.UTC(2098, 5, 1)),
|
||||||
|
due_date: new Date(Date.UTC(2098, 5, 15)),
|
||||||
|
status: "unpaid",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fixRiId = ri.id;
|
||||||
|
const q = await prisma.quotations.create({
|
||||||
|
data: {
|
||||||
|
quotation_number: FIX_OFFER_NUMBER,
|
||||||
|
status: "active",
|
||||||
|
currency: "EUR",
|
||||||
|
quotation_items: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
position: 1,
|
||||||
|
description: "Rozvaděč RVO-1",
|
||||||
|
item_description: "<p>Hlavní <b>rozvaděč</b> včetně montáže</p>",
|
||||||
|
quantity: 2,
|
||||||
|
unit: "ks",
|
||||||
|
unit_price: 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: 2,
|
||||||
|
description: "Doprava (informativní)",
|
||||||
|
quantity: 1,
|
||||||
|
unit: "ks",
|
||||||
|
unit_price: 500,
|
||||||
|
is_included_in_total: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
scope_sections: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
position: 1,
|
||||||
|
title_cz: "Rozsah projektu",
|
||||||
|
content: "<p>Dodávka & montáž <i>na klíč</i>.</p>",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fixOfferId = q.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Items/sections cascade with the quotation.
|
||||||
|
await prisma.quotations.deleteMany({ where: { id: fixOfferId } });
|
||||||
|
await prisma.sklad_suppliers.deleteMany({ where: { id: fixSupplierId } });
|
||||||
|
await prisma.received_invoices.deleteMany({ where: { id: fixRiId } });
|
||||||
|
// Projects before the customer (Restrict FK on projects.customer_id).
|
||||||
|
await prisma.projects.deleteMany({ where: { customer_id: fixCustomerId } });
|
||||||
|
await prisma.customers.deleteMany({ where: { id: fixCustomerId } });
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
// FK-safe order: plan entries carry a Restrict FK on created_by.
|
// FK-safe order: plan entries carry a Restrict FK on created_by.
|
||||||
const fixUserIds = [fixUserId, fixUser2Id];
|
const fixUserIds = [fixUserId, fixUser2Id];
|
||||||
@@ -410,6 +521,320 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
|
|||||||
expect(o.total_km).toBe(52); // foreign 100km trip excluded from totals too
|
expect(o.total_km).toBe(52); // foreign 100km trip excluded from totals too
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("get_offer_detail returns line items, totals and stripped sections", async () => {
|
||||||
|
const res = await executeTool(
|
||||||
|
"get_offer_detail",
|
||||||
|
{ number: "99999" }, // partial number lookup
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
const r = res.result as {
|
||||||
|
number: string;
|
||||||
|
currency: string;
|
||||||
|
item_count: number;
|
||||||
|
items_total: number;
|
||||||
|
items: {
|
||||||
|
name: string;
|
||||||
|
detail?: string;
|
||||||
|
line_total: number;
|
||||||
|
excluded_from_total?: boolean;
|
||||||
|
}[];
|
||||||
|
sections: { title: string | null; text: string }[];
|
||||||
|
};
|
||||||
|
expect(r.number).toBe(FIX_OFFER_NUMBER);
|
||||||
|
expect(r.currency).toBe("EUR");
|
||||||
|
expect(r.item_count).toBe(2);
|
||||||
|
expect(r.items[0]).toMatchObject({
|
||||||
|
name: "Rozvaděč RVO-1",
|
||||||
|
detail: "Hlavní rozvaděč včetně montáže", // HTML stripped
|
||||||
|
line_total: 2000,
|
||||||
|
});
|
||||||
|
expect(r.items[1].excluded_from_total).toBe(true);
|
||||||
|
expect(r.items_total).toBe(2000); // excluded row not counted
|
||||||
|
expect(r.sections[0]).toMatchObject({ title: "Rozsah projektu" });
|
||||||
|
expect(r.sections[0].text).toContain("Dodávka & montáž na klíč");
|
||||||
|
|
||||||
|
// Unknown number → explicit Czech error, flagged not-ok.
|
||||||
|
const miss = await executeTool(
|
||||||
|
"get_offer_detail",
|
||||||
|
{ number: "NEEXISTUJE-123" },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(miss.ok).toBe(false);
|
||||||
|
|
||||||
|
// No offers.view → denied.
|
||||||
|
const denied = await executeTool(
|
||||||
|
"get_offer_detail",
|
||||||
|
{ number: "99999" },
|
||||||
|
NOBODY,
|
||||||
|
);
|
||||||
|
expect(denied.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get_leave_balance: own for a recorder; foreign needs balances/manage", async () => {
|
||||||
|
const own = await executeTool(
|
||||||
|
"get_leave_balance",
|
||||||
|
{ year: 2098 },
|
||||||
|
{
|
||||||
|
userId: fixUserId,
|
||||||
|
roleName: "viewer",
|
||||||
|
permissions: ["attendance.record"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(own.ok).toBe(true);
|
||||||
|
expect(own.result).toMatchObject({
|
||||||
|
user_id: fixUserId,
|
||||||
|
year: 2098,
|
||||||
|
vacation_total: 25,
|
||||||
|
vacation_used: 5,
|
||||||
|
vacation_remaining: 20,
|
||||||
|
sick_used: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bare attendance.record asking about someone else → denied.
|
||||||
|
const denied = await executeTool(
|
||||||
|
"get_leave_balance",
|
||||||
|
{ user_id: fixUserId, year: 2098 },
|
||||||
|
RECORDER,
|
||||||
|
);
|
||||||
|
expect(denied.ok).toBe(false);
|
||||||
|
|
||||||
|
// attendance.balances (the overview route's permission) unlocks others.
|
||||||
|
const hr = await executeTool(
|
||||||
|
"get_leave_balance",
|
||||||
|
{ user_id: fixUserId, year: 2098 },
|
||||||
|
{
|
||||||
|
userId: 999999998,
|
||||||
|
roleName: "viewer",
|
||||||
|
permissions: ["attendance.balances"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(hr.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get_work_fund returns the current-month fund; foreign needs manage", async () => {
|
||||||
|
const res = await executeTool(
|
||||||
|
"get_work_fund",
|
||||||
|
{ user_id: fixUserId },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
const r = res.result as {
|
||||||
|
fund_hours: number;
|
||||||
|
worked_hours: number;
|
||||||
|
business_days: number;
|
||||||
|
};
|
||||||
|
expect(r.fund_hours).toBeGreaterThan(0);
|
||||||
|
expect(r.business_days).toBeGreaterThan(0);
|
||||||
|
expect(r.worked_hours).toBe(0); // fixture attendance lives in 2098
|
||||||
|
|
||||||
|
const denied = await executeTool(
|
||||||
|
"get_work_fund",
|
||||||
|
{ user_id: fixUserId },
|
||||||
|
RECORDER,
|
||||||
|
);
|
||||||
|
expect(denied.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get_project_hours_report is manage-gated and aggregates the fixture shift", async () => {
|
||||||
|
const denied = await executeTool("get_project_hours_report", {}, RECORDER);
|
||||||
|
expect(denied.ok).toBe(false);
|
||||||
|
|
||||||
|
// The 2098 fixture shift (8.00 h, no project) lands in the labeled
|
||||||
|
// no-project bucket with per-user hours.
|
||||||
|
const res = await executeTool(
|
||||||
|
"get_project_hours_report",
|
||||||
|
{ year: 2098 },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
const r = res.result as {
|
||||||
|
year: number;
|
||||||
|
projects: {
|
||||||
|
label: string;
|
||||||
|
hours: number;
|
||||||
|
by_user: Record<string, number>;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
expect(r.year).toBe(2098);
|
||||||
|
const bucket = r.projects.find((p) => p.label === "(bez projektu)");
|
||||||
|
expect(bucket).toBeDefined();
|
||||||
|
expect(bucket!.hours).toBe(8);
|
||||||
|
expect(bucket!.by_user[`${FIX.first} ${FIX.last}`]).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("find_supplier + list_vehicles return their compact shapes", async () => {
|
||||||
|
const sup = await executeTool(
|
||||||
|
"find_supplier",
|
||||||
|
{ search: "AiTest Dodavatel" },
|
||||||
|
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||||||
|
);
|
||||||
|
expect(sup.ok).toBe(true);
|
||||||
|
const s = (
|
||||||
|
sup.result as { suppliers: { name: string; ico: string | null }[] }
|
||||||
|
).suppliers.find((x) => x.name === FIX_SUPPLIER);
|
||||||
|
expect(s).toMatchObject({ ico: "99999998" });
|
||||||
|
|
||||||
|
// IČO typed with spaces finds the space-less stored value.
|
||||||
|
const spacedIco = await executeTool(
|
||||||
|
"find_supplier",
|
||||||
|
{ search: "999 99 998" },
|
||||||
|
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(spacedIco.result as { suppliers: { name: string }[] }).suppliers.some(
|
||||||
|
(x) => x.name === FIX_SUPPLIER,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const veh = await executeTool(
|
||||||
|
"list_vehicles",
|
||||||
|
{ search: FIX.spz },
|
||||||
|
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
||||||
|
);
|
||||||
|
expect(veh.ok).toBe(true);
|
||||||
|
const v = (
|
||||||
|
veh.result as {
|
||||||
|
vehicles: { spz: string; current_km: number; trip_count: number }[];
|
||||||
|
}
|
||||||
|
).vehicles[0];
|
||||||
|
// Odometer rule: max trip end_km (1050) over initial_km; 3 fixture trips.
|
||||||
|
expect(v).toMatchObject({ spz: FIX.spz, current_km: 1050, trip_count: 3 });
|
||||||
|
|
||||||
|
// SPZ spacing is normalized: "AITEST 999" finds the stored "AITEST999",
|
||||||
|
// in list_vehicles AND in the list_trips vehicle filter.
|
||||||
|
const spaced = await executeTool(
|
||||||
|
"list_vehicles",
|
||||||
|
{ search: "aitest 999" },
|
||||||
|
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(spaced.result as { vehicles: { spz: string }[] }).vehicles[0]?.spz,
|
||||||
|
).toBe(FIX.spz);
|
||||||
|
const tripsByPlate = await executeTool(
|
||||||
|
"list_trips",
|
||||||
|
{ vehicle: "AITEST 999", date_from: "2098-06-15", date_to: "2098-06-16" },
|
||||||
|
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(tripsByPlate.result as { total_matching: number }).total_matching,
|
||||||
|
).toBe(2);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await executeTool("find_supplier", { search: "x" }, NOBODY)).ok,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get_document_totals sums the WHOLE filtered set per currency", async () => {
|
||||||
|
const res = await executeTool(
|
||||||
|
"get_document_totals",
|
||||||
|
{ type: "offers", search: FIX_OFFER_NUMBER },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(
|
||||||
|
(res.result as { totals: { currency: string; amount: number }[] }).totals,
|
||||||
|
).toEqual([{ currency: "EUR", amount: 2000 }]); // excluded row not counted
|
||||||
|
|
||||||
|
// orders.view alone cannot read offer totals (per-type re-check).
|
||||||
|
const denied = await executeTool(
|
||||||
|
"get_document_totals",
|
||||||
|
{ type: "offers" },
|
||||||
|
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||||||
|
);
|
||||||
|
expect(denied.ok).toBe(false);
|
||||||
|
|
||||||
|
// Offers have no period filter — month/year must error, not be ignored.
|
||||||
|
const offersMonth = await executeTool(
|
||||||
|
"get_document_totals",
|
||||||
|
{ type: "offers", month: 6, year: 2026 },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(offersMonth.ok).toBe(false);
|
||||||
|
|
||||||
|
// A bare month defaults the year (never an unmarked all-time sum) and
|
||||||
|
// the applied period is echoed back.
|
||||||
|
const bareMonth = await executeTool(
|
||||||
|
"get_document_totals",
|
||||||
|
{ type: "orders", month: 6 },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(bareMonth.ok).toBe(true);
|
||||||
|
expect((bareMonth.result as { period: string }).period).toBe(
|
||||||
|
`6/${new Date().getFullYear()}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get_top_customers aggregates per customer across the whole table", async () => {
|
||||||
|
const res = await executeTool(
|
||||||
|
"get_top_customers",
|
||||||
|
{ type: "projects" },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
const r = res.result as {
|
||||||
|
customers_with_records: number;
|
||||||
|
top: { customer: string; count: number }[];
|
||||||
|
};
|
||||||
|
const mine = r.top.find((t) => t.customer === FIX_CUSTOMER);
|
||||||
|
expect(mine).toBeDefined();
|
||||||
|
expect(mine!.count).toBe(2);
|
||||||
|
expect(r.customers_with_records).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// invoices.view alone cannot aggregate projects (per-type re-check).
|
||||||
|
const denied = await executeTool(
|
||||||
|
"get_top_customers",
|
||||||
|
{ type: "projects" },
|
||||||
|
VIEWER_INVOICES,
|
||||||
|
);
|
||||||
|
expect(denied.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("find_employee and find_supplier report total_matching", async () => {
|
||||||
|
const emp = await executeTool(
|
||||||
|
"find_employee",
|
||||||
|
{ search: FIX.last },
|
||||||
|
RECORDER,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(emp.result as { total_matching: number }).total_matching,
|
||||||
|
).toBeGreaterThanOrEqual(1);
|
||||||
|
const sup = await executeTool(
|
||||||
|
"find_supplier",
|
||||||
|
{ search: FIX_SUPPLIER },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(sup.result as { total_matching: number }).total_matching,
|
||||||
|
).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get_received_invoice_detail returns the gross-amount detail", async () => {
|
||||||
|
const res = await executeTool(
|
||||||
|
"get_received_invoice_detail",
|
||||||
|
{ search: "AiTest Supplier" },
|
||||||
|
VIEWER_INVOICES,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.result).toMatchObject({
|
||||||
|
supplier: FIX_RI_SUPPLIER,
|
||||||
|
number: "AITEST-RI-1",
|
||||||
|
amount_with_vat: 1210,
|
||||||
|
vat_amount: 210,
|
||||||
|
status: "unpaid",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await executeTool(
|
||||||
|
"get_received_invoice_detail",
|
||||||
|
{ search: "x" },
|
||||||
|
NOBODY,
|
||||||
|
)
|
||||||
|
).ok,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("get_work_plan resolves the range-entry into per-day rows", async () => {
|
it("get_work_plan resolves the range-entry into per-day rows", async () => {
|
||||||
const res = await executeTool(
|
const res = await executeTool(
|
||||||
"get_work_plan",
|
"get_work_plan",
|
||||||
|
|||||||
44
src/__tests__/pdf-shared.test.ts
Normal file
44
src/__tests__/pdf-shared.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { cleanQuillHtml } from "../utils/pdf-shared";
|
||||||
|
|
||||||
|
describe("cleanQuillHtml — inline-style whitelist", () => {
|
||||||
|
it("keeps Quill text and background colors", () => {
|
||||||
|
const html =
|
||||||
|
'<p><span style="color: rgb(230, 0, 0);">červeně</span> ' +
|
||||||
|
'<span style="background-color: #ffff00;">zvýrazněně</span> ' +
|
||||||
|
'<span style="color: #06c;">modře</span></p>';
|
||||||
|
const out = cleanQuillHtml(html);
|
||||||
|
expect(out).toContain('style="color: rgb(230, 0, 0)"');
|
||||||
|
expect(out).toContain('style="background-color: #ffff00"');
|
||||||
|
expect(out).toContain('style="color: #06c"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops every non-color style declaration", () => {
|
||||||
|
const out = cleanQuillHtml(
|
||||||
|
'<span style="position: fixed; color: red; font-size: 99px;">x</span>',
|
||||||
|
);
|
||||||
|
expect(out).toBe('<span style="color: red">x</span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops unsafe color values (url, expression, quotes)", () => {
|
||||||
|
expect(
|
||||||
|
cleanQuillHtml('<span style="color: url(javascript:alert(1))">x</span>'),
|
||||||
|
).toBe("<span>x</span>");
|
||||||
|
expect(
|
||||||
|
cleanQuillHtml('<span style="color: expression(alert(1))">x</span>'),
|
||||||
|
).toBe("<span>x</span>");
|
||||||
|
expect(cleanQuillHtml("<span style='color: \"red\"'>x</span>")).toBe(
|
||||||
|
"<span>x</span>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps stripping scripts, event handlers and js: URLs", () => {
|
||||||
|
const out = cleanQuillHtml(
|
||||||
|
'<p onclick="alert(1)"><script>alert(1)</script>' +
|
||||||
|
'<a href="javascript:alert(1)">x</a></p>',
|
||||||
|
);
|
||||||
|
expect(out).not.toContain("script");
|
||||||
|
expect(out).not.toContain("onclick");
|
||||||
|
expect(out).not.toContain("javascript:");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -214,7 +214,8 @@ function SortableItemRow({
|
|||||||
placeholder="Volitelný"
|
placeholder="Volitelný"
|
||||||
InputProps={{ readOnly }}
|
InputProps={{ readOnly }}
|
||||||
multiline
|
multiline
|
||||||
rows={2}
|
minRows={2}
|
||||||
|
maxRows={16}
|
||||||
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
||||||
sx={{ "& textarea": { resize: "vertical" } }}
|
sx={{ "& textarea": { resize: "vertical" } }}
|
||||||
/>
|
/>
|
||||||
@@ -352,7 +353,8 @@ function SortableItemRow({
|
|||||||
placeholder="Podrobný popis (volitelný)"
|
placeholder="Podrobný popis (volitelný)"
|
||||||
InputProps={{ readOnly }}
|
InputProps={{ readOnly }}
|
||||||
multiline
|
multiline
|
||||||
rows={2}
|
minRows={2}
|
||||||
|
maxRows={16}
|
||||||
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
||||||
sx={{
|
sx={{
|
||||||
"& textarea": {
|
"& textarea": {
|
||||||
|
|||||||
@@ -437,8 +437,13 @@ export default function OdinChat() {
|
|||||||
// page never scrolls and the chat's message list scrolls internally.
|
// page never scrolls and the chat's message list scrolls internally.
|
||||||
// Desktop: svh === vh.
|
// Desktop: svh === vh.
|
||||||
// Mobile is immersive (AppShell hides its header and main padding on
|
// Mobile is immersive (AppShell hides its header and main padding on
|
||||||
// /odin): the chat owns the whole viewport, full-bleed.
|
// /odin): the chat owns the whole viewport, full-bleed. --app-height
|
||||||
height: { xs: "100svh", md: "calc(100svh - 100px)" },
|
// is AppShell's live window.innerHeight measurement — standalone-PWA
|
||||||
|
// viewports misreport svh/dvh (MIUI), only the measured value fits.
|
||||||
|
height: {
|
||||||
|
xs: "var(--app-height, 100svh)",
|
||||||
|
md: "calc(100svh - 100px)",
|
||||||
|
},
|
||||||
display: "flex",
|
display: "flex",
|
||||||
// Longhand on purpose: a responsive `border` shorthand lands in a
|
// Longhand on purpose: a responsive `border` shorthand lands in a
|
||||||
// media query AFTER borderColor and resets the color to black.
|
// media query AFTER borderColor and resets the color to black.
|
||||||
|
|||||||
@@ -317,7 +317,8 @@ function SortableInvoiceRow({
|
|||||||
onChange={(e) => onUpdate(index, "item_description", e.target.value)}
|
onChange={(e) => onUpdate(index, "item_description", e.target.value)}
|
||||||
placeholder="Volitelný"
|
placeholder="Volitelný"
|
||||||
multiline
|
multiline
|
||||||
rows={2}
|
minRows={2}
|
||||||
|
maxRows={16}
|
||||||
sx={{ "& textarea": { resize: "vertical" } }}
|
sx={{ "& textarea": { resize: "vertical" } }}
|
||||||
/>
|
/>
|
||||||
<Box
|
<Box
|
||||||
@@ -418,7 +419,8 @@ function SortableInvoiceRow({
|
|||||||
}
|
}
|
||||||
placeholder="Podrobný popis (volitelný)"
|
placeholder="Podrobný popis (volitelný)"
|
||||||
multiline
|
multiline
|
||||||
rows={2}
|
minRows={2}
|
||||||
|
maxRows={16}
|
||||||
sx={{
|
sx={{
|
||||||
"& textarea": {
|
"& textarea": {
|
||||||
fontSize: "0.8rem",
|
fontSize: "0.8rem",
|
||||||
|
|||||||
@@ -28,6 +28,36 @@ export default function AppShell() {
|
|||||||
// button. Desktop keeps the normal shell.
|
// button. Desktop keeps the normal shell.
|
||||||
const immersiveOnMobile = location.pathname === "/odin";
|
const immersiveOnMobile = location.pathname === "/odin";
|
||||||
|
|
||||||
|
// Installed-PWA (standalone) viewports lie to CSS units: on MIUI/Android
|
||||||
|
// svh/dvh are computed against a viewport that includes system UI the real
|
||||||
|
// layout viewport doesn't have, leaving scroll room no unit can remove
|
||||||
|
// (and Chrome can serve a stale viewport after minimize/restore). Measure
|
||||||
|
// the truth — window.innerHeight — into --app-height and keep it fresh;
|
||||||
|
// immersive layouts size from it with an svh fallback.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!immersiveOnMobile) return;
|
||||||
|
const root = document.documentElement;
|
||||||
|
const set = () =>
|
||||||
|
root.style.setProperty("--app-height", `${window.innerHeight}px`);
|
||||||
|
set();
|
||||||
|
window.addEventListener("resize", set);
|
||||||
|
window.visualViewport?.addEventListener("resize", set);
|
||||||
|
// Kill pull-to-refresh: a PTR reload lands mid-viewport-settle and
|
||||||
|
// re-races the measurement (Chromium settles without a resize event).
|
||||||
|
// The immersive page is fixed-viewport — the gesture has no use here.
|
||||||
|
const prevRootOverscroll = root.style.overscrollBehaviorY;
|
||||||
|
const prevBodyOverscroll = document.body.style.overscrollBehaviorY;
|
||||||
|
root.style.overscrollBehaviorY = "none";
|
||||||
|
document.body.style.overscrollBehaviorY = "none";
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", set);
|
||||||
|
window.visualViewport?.removeEventListener("resize", set);
|
||||||
|
root.style.removeProperty("--app-height");
|
||||||
|
root.style.overscrollBehaviorY = prevRootOverscroll;
|
||||||
|
document.body.style.overscrollBehaviorY = prevBodyOverscroll;
|
||||||
|
};
|
||||||
|
}, [immersiveOnMobile]);
|
||||||
|
|
||||||
const handleLogout = useCallback(() => {
|
const handleLogout = useCallback(() => {
|
||||||
setLoggingOut(true);
|
setLoggingOut(true);
|
||||||
setMobileOpen(false);
|
setMobileOpen(false);
|
||||||
@@ -79,13 +109,22 @@ export default function AppShell() {
|
|||||||
duration: loggingOut ? 0.4 : 0.25,
|
duration: loggingOut ? 0.4 : 0.25,
|
||||||
ease: [0.4, 0, 0.2, 1],
|
ease: [0.4, 0, 0.2, 1],
|
||||||
}}
|
}}
|
||||||
style={{ minHeight: "100dvh" }}
|
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
minHeight: "100dvh",
|
|
||||||
bgcolor: "background.default",
|
bgcolor: "background.default",
|
||||||
|
// Immersive mobile pages must make the DOCUMENT exactly the
|
||||||
|
// small-viewport height and clip it: MIUI/Xiaomi Chrome keeps
|
||||||
|
// 100dvh at the large-viewport size while the URL bar overlays,
|
||||||
|
// so a 100dvh min-height leaves the page scrollable by the bar
|
||||||
|
// height (invisible in desktop emulation). svh + hidden overflow
|
||||||
|
// makes body scroll impossible regardless of dvh interpretation.
|
||||||
|
minHeight: immersiveOnMobile ? { xs: 0, md: "100dvh" } : "100dvh",
|
||||||
|
...(immersiveOnMobile && {
|
||||||
|
height: { xs: "var(--app-height, 100svh)", md: "auto" },
|
||||||
|
overflow: { xs: "hidden", md: "visible" },
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Drawer
|
<Drawer
|
||||||
@@ -128,6 +167,7 @@ export default function AppShell() {
|
|||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
@@ -173,6 +213,10 @@ export default function AppShell() {
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
px: immersiveOnMobile ? { xs: 0, md: 3 } : { xs: 2, md: 3 },
|
px: immersiveOnMobile ? { xs: 0, md: 3 } : { xs: 2, md: 3 },
|
||||||
pb: immersiveOnMobile ? { xs: 0, md: 4 } : 4,
|
pb: immersiveOnMobile ? { xs: 0, md: 4 } : 4,
|
||||||
|
...(immersiveOnMobile && {
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: { xs: "hidden", md: "visible" },
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ export function buildInvoicePdfFooter(
|
|||||||
const t = translations[lang] || translations.cs;
|
const t = translations[lang] || translations.cs;
|
||||||
const pageWord = lang === "cs" ? "Strana" : "Page";
|
const pageWord = lang === "cs" ? "Strana" : "Page";
|
||||||
const ofWord = lang === "cs" ? "z" : "of";
|
const ofWord = lang === "cs" ? "z" : "of";
|
||||||
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
return `<div style="width:100%; font-size:8pt; font-family:'Segoe UI', Tahoma, Arial, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||||
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerLine)}</div>
|
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerLine)}</div>
|
||||||
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
|
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
|
||||||
<div style="flex:1;"></div>
|
<div style="flex:1;"></div>
|
||||||
@@ -898,7 +898,7 @@ export default async function invoicesPdfRoutes(
|
|||||||
}
|
}
|
||||||
.section-content,
|
.section-content,
|
||||||
.section-content * {
|
.section-content * {
|
||||||
font-family: Tahoma, sans-serif !important;
|
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
|
||||||
}
|
}
|
||||||
.section-content { font-size: 14px; }
|
.section-content { font-size: 14px; }
|
||||||
.section-content h1 { font-size: 20px; }
|
.section-content h1 { font-size: 20px; }
|
||||||
@@ -910,7 +910,7 @@ export default async function invoicesPdfRoutes(
|
|||||||
.section-content li { margin-bottom: 0.2em; }
|
.section-content li { margin-bottom: 0.2em; }
|
||||||
|
|
||||||
/* Quill fonty – v PDF vynuceno Tahoma */
|
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
|
||||||
.ql-align-center { text-align: center; }
|
.ql-align-center { text-align: center; }
|
||||||
.ql-align-right { text-align: right; }
|
.ql-align-right { text-align: right; }
|
||||||
.ql-align-justify { text-align: justify; }
|
.ql-align-justify { text-align: justify; }
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ export function buildIssuedOrderPdfFooter(
|
|||||||
const t = translations[lang];
|
const t = translations[lang];
|
||||||
const pageWord = lang === "cs" ? "Strana" : "Page";
|
const pageWord = lang === "cs" ? "Strana" : "Page";
|
||||||
const ofWord = lang === "cs" ? "z" : "of";
|
const ofWord = lang === "cs" ? "z" : "of";
|
||||||
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
return `<div style="width:100%; font-size:8pt; font-family:'Segoe UI', Tahoma, Arial, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||||
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerName)}</div>
|
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerName)}</div>
|
||||||
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
|
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
|
||||||
<div style="flex:1;"></div>
|
<div style="flex:1;"></div>
|
||||||
@@ -655,7 +655,7 @@ export function renderIssuedOrderHtml(
|
|||||||
}
|
}
|
||||||
.section-content,
|
.section-content,
|
||||||
.section-content * {
|
.section-content * {
|
||||||
font-family: Tahoma, sans-serif !important;
|
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
|
||||||
}
|
}
|
||||||
.section-content { font-size: 14px; }
|
.section-content { font-size: 14px; }
|
||||||
.section-content h1 { font-size: 20px; }
|
.section-content h1 { font-size: 20px; }
|
||||||
@@ -667,7 +667,7 @@ export function renderIssuedOrderHtml(
|
|||||||
.section-content li { margin-bottom: 0.2em; }
|
.section-content li { margin-bottom: 0.2em; }
|
||||||
|
|
||||||
/* Quill fonty – v PDF vynuceno Tahoma */
|
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
|
||||||
.ql-align-center { text-align: center; }
|
.ql-align-center { text-align: center; }
|
||||||
.ql-align-right { text-align: right; }
|
.ql-align-right { text-align: right; }
|
||||||
.ql-align-justify { text-align: justify; }
|
.ql-align-justify { text-align: justify; }
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ export function renderOfferHtml(
|
|||||||
img, table, pre, code { max-width: 100%; }
|
img, table, pre, code { max-width: 100%; }
|
||||||
|
|
||||||
/* ---- Quill font classes – v PDF vynuceno Tahoma ---- */
|
/* ---- Quill font classes – v PDF vynuceno Tahoma ---- */
|
||||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
|
||||||
|
|
||||||
/* ---- Quill alignment ---- */
|
/* ---- Quill alignment ---- */
|
||||||
.ql-align-center { text-align: center; }
|
.ql-align-center { text-align: center; }
|
||||||
@@ -502,7 +502,7 @@ ${indentCSS}
|
|||||||
}
|
}
|
||||||
.section-content,
|
.section-content,
|
||||||
.section-content * {
|
.section-content * {
|
||||||
font-family: Tahoma, sans-serif !important;
|
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
|
||||||
}
|
}
|
||||||
.section-content { font-size: 14px; }
|
.section-content { font-size: 14px; }
|
||||||
.section-content h1 { font-size: 20px; }
|
.section-content h1 { font-size: 20px; }
|
||||||
|
|||||||
@@ -570,7 +570,7 @@ export function renderOrderConfirmationHtml(
|
|||||||
.invoice-notes-content li { margin-bottom: 0.2em; }
|
.invoice-notes-content li { margin-bottom: 0.2em; }
|
||||||
.invoice-notes-content,
|
.invoice-notes-content,
|
||||||
.invoice-notes-content * {
|
.invoice-notes-content * {
|
||||||
font-family: Tahoma, sans-serif !important;
|
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
|
||||||
}
|
}
|
||||||
.invoice-notes-content { font-size: 14px; }
|
.invoice-notes-content { font-size: 14px; }
|
||||||
.invoice-notes-content h1 { font-size: 20px; }
|
.invoice-notes-content h1 { font-size: 20px; }
|
||||||
@@ -579,7 +579,7 @@ export function renderOrderConfirmationHtml(
|
|||||||
.invoice-notes-content h4 { font-size: 15px; }
|
.invoice-notes-content h4 { font-size: 15px; }
|
||||||
|
|
||||||
/* Quill fonty – v PDF vynuceno Tahoma */
|
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
|
||||||
.ql-align-center { text-align: center; }
|
.ql-align-center { text-align: center; }
|
||||||
.ql-align-right { text-align: right; }
|
.ql-align-right { text-align: right; }
|
||||||
.ql-align-justify { text-align: justify; }
|
.ql-align-justify { text-align: justify; }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -291,6 +291,7 @@ function agentSystemPrompt(
|
|||||||
"Odpovědi piš jako PROSTÝ TEXT — žádný Markdown (žádné tabulky, **tučné**, nadpisy); výčty piš jako řádky s pomlčkou. " +
|
"Odpovědi piš jako PROSTÝ TEXT — žádný Markdown (žádné tabulky, **tučné**, nadpisy); výčty piš jako řádky s pomlčkou. " +
|
||||||
(tools.length > 0
|
(tools.length > 0
|
||||||
? "Máš nástroje POUZE PRO ČTENÍ dat systému — používej je, kdykoli se dotaz týká firemních dat, a odpovídej výhradně z jejich výsledků (nikdy si firemní čísla nevymýšlej). " +
|
? "Máš nástroje POUZE PRO ČTENÍ dat systému — používej je, kdykoli se dotaz týká firemních dat, a odpovídej výhradně z jejich výsledků (nikdy si firemní čísla nevymýšlej). " +
|
||||||
|
"Seznamové nástroje zobrazují max ~20 řádků, ale total_matching je CELKOVÝ počet odpovídajících záznamů — na otázky 'kolik' odpovídej z total_matching. Na 'pro koho nejvíc' a součty používej agregační nástroje (get_top_customers, get_document_totals, get_invoice_stats) — nikdy nesčítej řádky ze seznamů. " +
|
||||||
(hasFindEmployee
|
(hasFindEmployee
|
||||||
? "Když se dotaz týká konkrétního zaměstnance (docházka, kniha jízd, plán práce), zjisti nejdřív jeho user_id nástrojem find_employee podle jména — neptej se uživatele na ID. "
|
? "Když se dotaz týká konkrétního zaměstnance (docházka, kniha jízd, plán práce), zjisti nejdřív jeho user_id nástrojem find_employee podle jména — neptej se uživatele na ID. "
|
||||||
: "") +
|
: "") +
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export function buildPdfHeaderTemplate(
|
|||||||
logoDataUri: string,
|
logoDataUri: string,
|
||||||
heading: string,
|
heading: string,
|
||||||
): string {
|
): string {
|
||||||
return `<div style="width:100%; font-family:Tahoma, sans-serif; padding:0 12mm; box-sizing:border-box;">
|
return `<div style="width:100%; font-family:'Segoe UI', Tahoma, Arial, sans-serif; padding:0 12mm; box-sizing:border-box;">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; padding-bottom:1mm; border-bottom:2pt solid #de3a3a;">
|
<div style="display:flex; justify-content:space-between; align-items:center; padding-bottom:1mm; border-bottom:2pt solid #de3a3a;">
|
||||||
<div style="flex:0 0 auto;">${logoDataUri ? `<img src="${logoDataUri}" style="max-width:42mm; max-height:22mm; object-fit:contain;" />` : ""}</div>
|
<div style="flex:0 0 auto;">${logoDataUri ? `<img src="${logoDataUri}" style="max-width:42mm; max-height:22mm; object-fit:contain;" />` : ""}</div>
|
||||||
<div style="font-size:13pt; font-weight:700; color:#de3a3a; text-align:right; letter-spacing:0.03em;">${escapeHtml(heading)}</div>
|
<div style="font-size:13pt; font-weight:700; color:#de3a3a; text-align:right; letter-spacing:0.03em;">${escapeHtml(heading)}</div>
|
||||||
@@ -108,6 +108,13 @@ export function buildPdfHeaderTemplate(
|
|||||||
* and merge adjacent identical spans. Runs AFTER DOMPurify at every call site
|
* and merge adjacent identical spans. Runs AFTER DOMPurify at every call site
|
||||||
* (regex pass = defense-in-depth, not the primary sanitizer).
|
* (regex pass = defense-in-depth, not the primary sanitizer).
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Literal CSS color values only: #hex, rgb()/rgba() with numeric components,
|
||||||
|
* or a bare keyword. No quotes, parentheses beyond rgb(a), url(), variables.
|
||||||
|
*/
|
||||||
|
const SAFE_CSS_COLOR =
|
||||||
|
/^(#[0-9a-f]{3,8}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+)\s*)?\)|[a-z]{3,20})$/i;
|
||||||
|
|
||||||
export function cleanQuillHtml(html: string | null | undefined): string {
|
export function cleanQuillHtml(html: string | null | undefined): string {
|
||||||
if (!html) return "";
|
if (!html) return "";
|
||||||
let s = html;
|
let s = html;
|
||||||
@@ -134,7 +141,28 @@ export function cleanQuillHtml(html: string | null | undefined): string {
|
|||||||
);
|
);
|
||||||
// Replace with regular space (outside of tags)
|
// Replace with regular space (outside of tags)
|
||||||
s = s.replace(/( )/g, " ");
|
s = s.replace(/( )/g, " ");
|
||||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
// Inline styles: Quill writes text/background colors as style attributes.
|
||||||
|
// Keep ONLY color/background-color with literal color values — everything
|
||||||
|
// else (url(), expression(), positioning…) must never reach Puppeteer.
|
||||||
|
s = s.replace(
|
||||||
|
/\s+style\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi,
|
||||||
|
(_m, dq, sq, bare) => {
|
||||||
|
const kept = String(dq ?? sq ?? bare ?? "")
|
||||||
|
.split(";")
|
||||||
|
.map((decl) => {
|
||||||
|
const i = decl.indexOf(":");
|
||||||
|
if (i < 0) return null;
|
||||||
|
const prop = decl.slice(0, i).trim().toLowerCase();
|
||||||
|
const val = decl.slice(i + 1).trim();
|
||||||
|
return (prop === "color" || prop === "background-color") &&
|
||||||
|
SAFE_CSS_COLOR.test(val)
|
||||||
|
? `${prop}: ${val}`
|
||||||
|
: null;
|
||||||
|
})
|
||||||
|
.filter((d): d is string => d !== null);
|
||||||
|
return kept.length ? ` style="${kept.join("; ")}"` : "";
|
||||||
|
},
|
||||||
|
);
|
||||||
// Merge adjacent spans with same attributes
|
// Merge adjacent spans with same attributes
|
||||||
let prev = "";
|
let prev = "";
|
||||||
while (prev !== s) {
|
while (prev !== s) {
|
||||||
|
|||||||
Reference in New Issue
Block a user