@fruggr/zendesk-mcp-server 2.12.2 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +207 -14
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -112,11 +112,12 @@ const positiveIntEnv = (name, fallback) => {
|
|
|
112
112
|
const raw = process.env[name];
|
|
113
113
|
if (raw === void 0 || raw.trim() === "") return fallback;
|
|
114
114
|
const parsed = Number(raw);
|
|
115
|
-
return Number.
|
|
115
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
116
116
|
};
|
|
117
117
|
const MAX_ATTACHMENT_BYTES = positiveIntEnv("ZENDESK_MAX_ATTACHMENT_BYTES", 5 * 1024 * 1024);
|
|
118
118
|
const MAX_EMBEDDED_IMAGE_COUNT = positiveIntEnv("ZENDESK_MAX_EMBEDDED_IMAGES", 10);
|
|
119
119
|
const MAX_COMMENT_PAGES = positiveIntEnv("ZENDESK_MAX_COMMENT_PAGES", 10);
|
|
120
|
+
const REORDER_CONFIRM_THRESHOLD = positiveIntEnv("ZENDESK_REORDER_CONFIRM_THRESHOLD", 20);
|
|
120
121
|
const getBaseUrl = (subdomain) => `https://${subdomain}.zendesk.com/api/v2`;
|
|
121
122
|
const getHelpCenterBaseUrl = (subdomain) => `https://${subdomain}.zendesk.com/api/v2/help_center`;
|
|
122
123
|
const getOAuthUrls = (subdomain) => ({
|
|
@@ -1305,6 +1306,81 @@ const groupByNamespace = (tools) => {
|
|
|
1305
1306
|
return grouped;
|
|
1306
1307
|
};
|
|
1307
1308
|
//#endregion
|
|
1309
|
+
//#region src/utils/article-order.ts
|
|
1310
|
+
const hasPositionInversion = (order) => {
|
|
1311
|
+
for (let i = 0; i < order.length - 1; i += 1) {
|
|
1312
|
+
const here = order[i];
|
|
1313
|
+
const next = order[i + 1];
|
|
1314
|
+
if (here && next && here.position > next.position) return true;
|
|
1315
|
+
}
|
|
1316
|
+
return false;
|
|
1317
|
+
};
|
|
1318
|
+
const arrangeDesiredOrder = (effective, movedId, target, referenceId) => {
|
|
1319
|
+
const moved = effective.find((a) => a.id === movedId);
|
|
1320
|
+
if (!moved) throw new Error(`Article ${movedId} is not in the section.`);
|
|
1321
|
+
const rest = effective.filter((a) => a.id !== movedId);
|
|
1322
|
+
let slot;
|
|
1323
|
+
if (target === "top") slot = 0;
|
|
1324
|
+
else if (target === "bottom") slot = rest.length;
|
|
1325
|
+
else {
|
|
1326
|
+
const refIndex = rest.findIndex((a) => a.id === referenceId);
|
|
1327
|
+
if (refIndex === -1) throw new Error(`Reference article ${referenceId} is not in the section.`);
|
|
1328
|
+
slot = target === "before" ? refIndex : refIndex + 1;
|
|
1329
|
+
}
|
|
1330
|
+
return [
|
|
1331
|
+
...rest.slice(0, slot),
|
|
1332
|
+
moved,
|
|
1333
|
+
...rest.slice(slot)
|
|
1334
|
+
];
|
|
1335
|
+
};
|
|
1336
|
+
const computePositionWrites = (desired, movedId, normalize) => {
|
|
1337
|
+
const writes = [];
|
|
1338
|
+
if (normalize) {
|
|
1339
|
+
desired.forEach((a, index) => {
|
|
1340
|
+
if (a.position !== index) writes.push({
|
|
1341
|
+
id: a.id,
|
|
1342
|
+
position: index
|
|
1343
|
+
});
|
|
1344
|
+
});
|
|
1345
|
+
return writes;
|
|
1346
|
+
}
|
|
1347
|
+
const movedIndex = desired.findIndex((a) => a.id === movedId);
|
|
1348
|
+
const moved = desired[movedIndex];
|
|
1349
|
+
if (!moved) return writes;
|
|
1350
|
+
if (movedIndex === desired.length - 1) {
|
|
1351
|
+
const maxOthers = desired.slice(0, movedIndex).reduce((max, a) => Math.max(max, a.position), -1);
|
|
1352
|
+
if (moved.position > maxOthers) return writes;
|
|
1353
|
+
writes.push({
|
|
1354
|
+
id: moved.id,
|
|
1355
|
+
position: maxOthers + 1
|
|
1356
|
+
});
|
|
1357
|
+
return writes;
|
|
1358
|
+
}
|
|
1359
|
+
const left = desired[movedIndex - 1];
|
|
1360
|
+
let running = left ? left.position : -1;
|
|
1361
|
+
for (let i = movedIndex; i < desired.length; i += 1) {
|
|
1362
|
+
const article = desired[i];
|
|
1363
|
+
if (!article) break;
|
|
1364
|
+
if (i !== movedIndex && article.position > running) break;
|
|
1365
|
+
const target = running + 1;
|
|
1366
|
+
if (article.position !== target) writes.push({
|
|
1367
|
+
id: article.id,
|
|
1368
|
+
position: target
|
|
1369
|
+
});
|
|
1370
|
+
running = target;
|
|
1371
|
+
}
|
|
1372
|
+
return writes;
|
|
1373
|
+
};
|
|
1374
|
+
const isPlacedAsRequested = (effectiveAfter, movedId, target, referenceId) => {
|
|
1375
|
+
const movedIndex = effectiveAfter.findIndex((a) => a.id === movedId);
|
|
1376
|
+
if (movedIndex === -1) return false;
|
|
1377
|
+
if (target === "top") return movedIndex === 0;
|
|
1378
|
+
if (target === "bottom") return movedIndex === effectiveAfter.length - 1;
|
|
1379
|
+
const refIndex = effectiveAfter.findIndex((a) => a.id === referenceId);
|
|
1380
|
+
if (refIndex === -1) return false;
|
|
1381
|
+
return target === "before" ? movedIndex < refIndex : movedIndex > refIndex;
|
|
1382
|
+
};
|
|
1383
|
+
//#endregion
|
|
1308
1384
|
//#region src/utils/article-sections.ts
|
|
1309
1385
|
const HEADING_LEVELS = /* @__PURE__ */ new Set([
|
|
1310
1386
|
"h1",
|
|
@@ -1402,6 +1478,7 @@ const markdownToHtml = (markdown) => {
|
|
|
1402
1478
|
//#endregion
|
|
1403
1479
|
//#region src/tools/help-center.ts
|
|
1404
1480
|
const ARTICLE_ID_DESC = "Article ID — the numeric id of the Help Center article. Obtain it from list_articles or search_articles.";
|
|
1481
|
+
const listTranslations = (subdomain, token, articleId) => helpCenterGet(subdomain, token, `/articles/${articleId}/translations`).then((res) => res.translations);
|
|
1405
1482
|
const largeArticleHint = (body, sectionCount) => {
|
|
1406
1483
|
if (body.length < 3e3 && sectionCount < 4) return null;
|
|
1407
1484
|
return [
|
|
@@ -1411,8 +1488,24 @@ const largeArticleHint = (body, sectionCount) => {
|
|
|
1411
1488
|
""
|
|
1412
1489
|
].join("\n");
|
|
1413
1490
|
};
|
|
1491
|
+
const autoSortNotice = (sectionId, applied) => [applied === void 0 ? `Section #${sectionId} looks like it is sorted automatically, so a manual reorder would have no visible effect.` : `Wrote ${applied} article position(s), but the display order of section #${sectionId} did not change — the section is sorted automatically, so positions are ignored.`, "To order its articles manually: in Guide, open the section, choose \"Edit section\", set \"Order articles by\" to Manual, then re-run this tool."].join(" ");
|
|
1414
1492
|
const createHelpCenterTools = (ctx) => {
|
|
1415
1493
|
const { subdomain, getToken } = ctx;
|
|
1494
|
+
const fetchSectionOrder = async (sectionId, locale, token) => {
|
|
1495
|
+
const order = [];
|
|
1496
|
+
let cursor;
|
|
1497
|
+
do {
|
|
1498
|
+
const response = await helpCenterGet(subdomain, token, `/${locale}/sections/${sectionId}/articles`, buildCursorParams(100, cursor));
|
|
1499
|
+
const articles = response.articles ?? [];
|
|
1500
|
+
for (const article of articles) order.push({
|
|
1501
|
+
id: article.id,
|
|
1502
|
+
position: article.position
|
|
1503
|
+
});
|
|
1504
|
+
const meta = extractPaginationMeta(response, articles.length);
|
|
1505
|
+
cursor = meta.has_more && meta.after_cursor ? meta.after_cursor : void 0;
|
|
1506
|
+
} while (cursor);
|
|
1507
|
+
return order;
|
|
1508
|
+
};
|
|
1416
1509
|
return [
|
|
1417
1510
|
{
|
|
1418
1511
|
name: "search_articles",
|
|
@@ -1468,7 +1561,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1468
1561
|
const token = await getToken();
|
|
1469
1562
|
const path = locale ? `/${locale}/articles/${article_id}` : `/articles/${article_id}`;
|
|
1470
1563
|
const { article } = await helpCenterGet(subdomain, token, path);
|
|
1471
|
-
const
|
|
1564
|
+
const translations = await listTranslations(subdomain, token, article_id);
|
|
1472
1565
|
return { content: [{
|
|
1473
1566
|
type: "text",
|
|
1474
1567
|
text: truncateIfNeeded((largeArticleHint(article.body, parseSections(article.body).length) ?? "") + formatArticle(article) + `\n\n**Available translations**: ${translations.map((t) => t.locale).join(", ")}`)
|
|
@@ -1575,8 +1668,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1575
1668
|
text: formatList(articles, formatArticleSummary, extractPaginationMeta(response, articles.length))
|
|
1576
1669
|
}] };
|
|
1577
1670
|
const formatted = await Promise.all(articles.map(async (article) => {
|
|
1578
|
-
const
|
|
1579
|
-
const locales = translations.map((t) => t.locale).join(", ");
|
|
1671
|
+
const locales = (await listTranslations(subdomain, token, article.id)).map((t) => t.locale).join(", ");
|
|
1580
1672
|
return `${formatArticleSummary(article)}\n- **Translations**: ${locales}`;
|
|
1581
1673
|
}));
|
|
1582
1674
|
const meta = extractPaginationMeta(response, articles.length);
|
|
@@ -1602,10 +1694,9 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1602
1694
|
handler: async (params) => {
|
|
1603
1695
|
const { article_id } = params;
|
|
1604
1696
|
const token = await getToken();
|
|
1605
|
-
const { translations } = await helpCenterGet(subdomain, token, `/articles/${article_id}/translations`);
|
|
1606
1697
|
return { content: [{
|
|
1607
1698
|
type: "text",
|
|
1608
|
-
text: formatList(
|
|
1699
|
+
text: formatList(await listTranslations(subdomain, token, article_id), formatTranslationSummary)
|
|
1609
1700
|
}] };
|
|
1610
1701
|
}
|
|
1611
1702
|
},
|
|
@@ -1769,6 +1860,81 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1769
1860
|
}] };
|
|
1770
1861
|
}
|
|
1771
1862
|
},
|
|
1863
|
+
{
|
|
1864
|
+
name: "reorder_article",
|
|
1865
|
+
namespace: "help_center",
|
|
1866
|
+
readOnly: false,
|
|
1867
|
+
title: "Reorder Help Center Article",
|
|
1868
|
+
description: "Reorder an article within its current section by moving it relative to its siblings (top, bottom, or before/after another article), and return whether the new order was applied. This is the reliable way to satisfy \"put this article first/last\" requests: it writes the minimal set of article positions needed to make the order deterministic, because Zendesk leaves newly created articles tied at position 0 where a plain position update is silently ambiguous. It does NOT move the article to a different section — use update_article with section_id for that. Zendesk exposes no way to read whether a section is manually or automatically sorted, so when the section is sorted automatically (by date or alphabetically) the position writes are ignored; this tool detects that after the fact and returns guidance to switch the section to manual ordering in Guide. A move may reposition several neighbouring articles; when that count exceeds a configurable safety threshold the call is refused unless confirm is set to true.",
|
|
1869
|
+
inputSchema: z.object({
|
|
1870
|
+
article_id: z.number().int().describe("Article ID — the numeric id of the article to move within its section. Obtain it from list_articles or search_articles."),
|
|
1871
|
+
target: z.enum([
|
|
1872
|
+
"top",
|
|
1873
|
+
"bottom",
|
|
1874
|
+
"before",
|
|
1875
|
+
"after"
|
|
1876
|
+
]).describe("Where to move the article relative to its section siblings: \"top\" (becomes first), \"bottom\" (becomes last), or \"before\"/\"after\" a specific reference article. \"before\" and \"after\" require reference_article_id."),
|
|
1877
|
+
reference_article_id: z.number().int().optional().describe("The sibling article to position next to when target is \"before\" or \"after\" (numeric id from list_articles). Must belong to the same section and differ from article_id; leave it unset for \"top\" or \"bottom\"."),
|
|
1878
|
+
normalize: z.boolean().default(false).describe("When true, also renumber every article in the section to contiguous positions (0, 1, 2, …) so the stored positions stay tidy. Defaults to false, which writes the fewest positions possible and lets gaps remain. Either way the confirmation threshold still applies."),
|
|
1879
|
+
confirm: z.boolean().default(false).describe("Safety guard for large reorders. When the move would rewrite more article positions than the configured threshold (ZENDESK_REORDER_CONFIRM_THRESHOLD, default 20), the tool refuses and reports the count until you pass true here. Has no effect on small reorders.")
|
|
1880
|
+
}),
|
|
1881
|
+
annotations: {
|
|
1882
|
+
readOnlyHint: false,
|
|
1883
|
+
destructiveHint: true,
|
|
1884
|
+
idempotentHint: true,
|
|
1885
|
+
openWorldHint: true
|
|
1886
|
+
},
|
|
1887
|
+
handler: async (params) => {
|
|
1888
|
+
const { article_id, target, reference_article_id, normalize = false, confirm = false } = params;
|
|
1889
|
+
const needsReference = target === "before" || target === "after";
|
|
1890
|
+
if (needsReference && reference_article_id === void 0) throw new Error(`target "${target}" requires reference_article_id (the article to move ${target}).`);
|
|
1891
|
+
if (!needsReference && reference_article_id !== void 0) throw new Error(`reference_article_id must be omitted when target is "${target}" (it only applies to "before"/"after").`);
|
|
1892
|
+
if (reference_article_id !== void 0 && reference_article_id === article_id) throw new Error("reference_article_id must differ from article_id.");
|
|
1893
|
+
const token = await getToken();
|
|
1894
|
+
const { article } = await helpCenterGet(subdomain, token, `/articles/${article_id}`);
|
|
1895
|
+
const sectionId = article.section_id;
|
|
1896
|
+
const locale = article.source_locale;
|
|
1897
|
+
const effective = await fetchSectionOrder(sectionId, locale, token);
|
|
1898
|
+
if (needsReference && !effective.some((a) => a.id === reference_article_id)) {
|
|
1899
|
+
let detail = "was not found";
|
|
1900
|
+
try {
|
|
1901
|
+
const { article: ref } = await helpCenterGet(subdomain, token, `/articles/${reference_article_id}`);
|
|
1902
|
+
detail = `is in section #${ref.section_id}, not section #${sectionId}`;
|
|
1903
|
+
} catch {}
|
|
1904
|
+
throw new Error(`Reference article #${reference_article_id} ${detail}. It must be in the same section (#${sectionId}) as article #${article_id}.`);
|
|
1905
|
+
}
|
|
1906
|
+
const targetLabel = needsReference ? `${target} article #${reference_article_id}` : target;
|
|
1907
|
+
const writes = computePositionWrites(arrangeDesiredOrder(effective, article_id, target, reference_article_id), article_id, normalize);
|
|
1908
|
+
if (writes.length === 0) return { content: [{
|
|
1909
|
+
type: "text",
|
|
1910
|
+
text: `Article #${article_id} is already positioned ${targetLabel} in section #${sectionId}. No changes made.`
|
|
1911
|
+
}] };
|
|
1912
|
+
if (hasPositionInversion(effective)) return { content: [{
|
|
1913
|
+
type: "text",
|
|
1914
|
+
text: autoSortNotice(sectionId)
|
|
1915
|
+
}] };
|
|
1916
|
+
if (writes.length > REORDER_CONFIRM_THRESHOLD && confirm !== true) return { content: [{
|
|
1917
|
+
type: "text",
|
|
1918
|
+
text: `Reordering article #${article_id} to ${targetLabel} would reposition ${writes.length} articles in section #${sectionId}, above the safety threshold of ${REORDER_CONFIRM_THRESHOLD}. Re-run with confirm: true to proceed.`
|
|
1919
|
+
}] };
|
|
1920
|
+
let applied = 0;
|
|
1921
|
+
for (const write of writes) try {
|
|
1922
|
+
await helpCenterPut(subdomain, token, `/articles/${write.id}`, { article: { position: write.position } });
|
|
1923
|
+
applied += 1;
|
|
1924
|
+
} catch (error) {
|
|
1925
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1926
|
+
throw new Error(`Reorder of article #${article_id} failed after ${applied}/${writes.length} position write(s) (on article #${write.id}): ${reason} Positions are written absolutely, so re-running the identical call is safe and resumes where it stopped.`, { cause: error });
|
|
1927
|
+
}
|
|
1928
|
+
if (!isPlacedAsRequested(await fetchSectionOrder(sectionId, locale, token), article_id, target, reference_article_id)) return { content: [{
|
|
1929
|
+
type: "text",
|
|
1930
|
+
text: autoSortNotice(sectionId, applied)
|
|
1931
|
+
}] };
|
|
1932
|
+
return { content: [{
|
|
1933
|
+
type: "text",
|
|
1934
|
+
text: `Article #${article_id} moved to ${targetLabel} in section #${sectionId} (${applied} article${applied === 1 ? "" : "s"} repositioned).`
|
|
1935
|
+
}] };
|
|
1936
|
+
}
|
|
1937
|
+
},
|
|
1772
1938
|
{
|
|
1773
1939
|
name: "archive_article",
|
|
1774
1940
|
namespace: "help_center",
|
|
@@ -1953,7 +2119,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1953
2119
|
const { article } = await helpCenterGet(subdomain, token, `/articles/${article_id}`);
|
|
1954
2120
|
const effectiveLocale = locale ?? article.source_locale;
|
|
1955
2121
|
const { translation } = await helpCenterGet(subdomain, token, `/articles/${article_id}/translations/${effectiveLocale}`);
|
|
1956
|
-
const
|
|
2122
|
+
const translations = await listTranslations(subdomain, token, article_id);
|
|
1957
2123
|
const sections = parseSections(translation.body);
|
|
1958
2124
|
const outlineLines = sections.length ? sections.map((s) => `- [${s.index}] ${s.headingTag ? `${s.headingTag}: ` : ""}${s.heading} (${s.wordCount} words)`).join("\n") : "_(no sections detected)_";
|
|
1959
2125
|
const translationsList = translations.map((t) => `- ${t.locale}${t.outdated ? " (outdated)" : ""}`).join("\n");
|
|
@@ -2048,7 +2214,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2048
2214
|
namespace: "help_center",
|
|
2049
2215
|
readOnly: true,
|
|
2050
2216
|
title: "Compare Article Translations",
|
|
2051
|
-
description: "Compare
|
|
2217
|
+
description: "Compare two locales of the same article to decide whether the target translation needs work, reporting independent signals instead of one ambiguous verdict. (1) Header — a \"Freshness\" verdict for the target, derived from the two translations' updated_at timestamps: if the source was edited after the target it is \"likely behind, review recommended\" (with the day gap), otherwise \"up to date\". This is the primary staleness signal and is always available. (2) Zendesk's own per-translation \"outdated\" flag for the target (\"yes\"/\"no\"/\"unknown\"), shown as a secondary overlay: it is only set through Guide's native \"mark out of date\" workflow and NOT by API edits, so a \"no\" does not by itself mean current — prefer Freshness. (3) A global structure check (section count and heading-tag sequence); on mismatch the header warns the per-index rows may be misaligned. (4) A per-section table matched by index, status \"ok\" (present in both), \"missing\" (present in source, absent in target) or \"extra\" (present in target, absent in source). (5) Per-section source/target word counts, INFORMATIONAL ONLY: a length difference between languages is normal and is deliberately NOT flagged as a divergence — do not read a word-count gap as an edit regression or staleness. Read-only; performs three Help Center GET calls (both translations plus the translations list for the outdated flag).",
|
|
2052
2218
|
inputSchema: z.object({
|
|
2053
2219
|
article_id: z.number().int().describe(ARTICLE_ID_DESC),
|
|
2054
2220
|
source_locale: z.string().describe("Reference locale to diff against, e.g. \"en-us\". Usually the article source_locale (from get_article)."),
|
|
@@ -2063,10 +2229,32 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2063
2229
|
handler: async (params) => {
|
|
2064
2230
|
const { article_id, source_locale, target_locale } = params;
|
|
2065
2231
|
const token = await getToken();
|
|
2066
|
-
const [sourceRes, targetRes] = await Promise.all([
|
|
2232
|
+
const [sourceRes, targetRes, translations] = await Promise.all([
|
|
2233
|
+
helpCenterGet(subdomain, token, `/articles/${article_id}/translations/${source_locale}`),
|
|
2234
|
+
helpCenterGet(subdomain, token, `/articles/${article_id}/translations/${target_locale}`),
|
|
2235
|
+
listTranslations(subdomain, token, article_id)
|
|
2236
|
+
]);
|
|
2067
2237
|
const sourceSections = parseSections(sourceRes.translation.body);
|
|
2068
2238
|
const targetSections = parseSections(targetRes.translation.body);
|
|
2069
2239
|
const maxLen = Math.max(sourceSections.length, targetSections.length);
|
|
2240
|
+
const sourceUpdated = sourceRes.translation.updated_at;
|
|
2241
|
+
const targetUpdated = targetRes.translation.updated_at;
|
|
2242
|
+
const srcMs = Date.parse(sourceUpdated);
|
|
2243
|
+
const tgtMs = Date.parse(targetUpdated);
|
|
2244
|
+
const comparable = Number.isFinite(srcMs) && Number.isFinite(tgtMs);
|
|
2245
|
+
let freshnessLine;
|
|
2246
|
+
if (comparable && srcMs > tgtMs) {
|
|
2247
|
+
const days = Math.floor((srcMs - tgtMs) / 864e5);
|
|
2248
|
+
freshnessLine = `- **Freshness (target ${target_locale})**: source was edited ${days >= 1 ? `${days} day(s)` : "less than a day"} after this translation → likely behind, review recommended.`;
|
|
2249
|
+
} else if (comparable) freshnessLine = `- **Freshness (target ${target_locale})**: up to date (source has not been edited since this translation).`;
|
|
2250
|
+
else freshnessLine = `- **Freshness (target ${target_locale})**: unknown (could not compare edit timestamps).`;
|
|
2251
|
+
const targetLocaleKey = target_locale.toLowerCase();
|
|
2252
|
+
const targetListEntry = translations.find((t) => t.locale.toLowerCase() === targetLocaleKey);
|
|
2253
|
+
const outdated = targetListEntry?.outdated === void 0 ? "unknown" : targetListEntry.outdated ? "yes" : "no";
|
|
2254
|
+
const outdatedLine = outdated === "yes" ? `- **Zendesk outdated flag (target ${target_locale})**: yes — explicitly marked out of date in Guide.` : outdated === "no" ? `- **Zendesk outdated flag (target ${target_locale})**: no (only set via Guide's own edit workflow; "no" does not by itself mean current — rely on Freshness above).` : `- **Zendesk outdated flag (target ${target_locale})**: unknown.`;
|
|
2255
|
+
const sourceTags = sourceSections.map((s) => s.headingTag).join(",");
|
|
2256
|
+
const targetTags = targetSections.map((s) => s.headingTag).join(",");
|
|
2257
|
+
const structureLine = sourceSections.length === targetSections.length && sourceTags === targetTags ? `- **Structure**: ${sourceSections.length} sections in both locales — aligned.` : `- **Structure**: ${sourceSections.length} source vs ${targetSections.length} target sections — MISMATCH; the per-index rows below may be misaligned.`;
|
|
2070
2258
|
const rows = [];
|
|
2071
2259
|
rows.push(`| Idx | Heading | Status | Source words | Target words |`);
|
|
2072
2260
|
rows.push(`| --- | --- | --- | --- | --- |`);
|
|
@@ -2078,11 +2266,8 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2078
2266
|
const targetWords = tgt?.wordCount ?? 0;
|
|
2079
2267
|
let status;
|
|
2080
2268
|
if (!tgt) status = "missing";
|
|
2081
|
-
else if (!src) status = "
|
|
2082
|
-
else
|
|
2083
|
-
const denom = Math.max(sourceWords, 1);
|
|
2084
|
-
status = Math.abs(sourceWords - targetWords) / denom > .25 ? "different" : "ok";
|
|
2085
|
-
}
|
|
2269
|
+
else if (!src) status = "extra";
|
|
2270
|
+
else status = "ok";
|
|
2086
2271
|
rows.push(`| ${i} | ${heading} | ${status} | ${sourceWords} | ${targetWords} |`);
|
|
2087
2272
|
}
|
|
2088
2273
|
return { content: [{
|
|
@@ -2090,6 +2275,14 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2090
2275
|
text: [
|
|
2091
2276
|
`# Translation diff — Article #${article_id} (${source_locale} → ${target_locale})`,
|
|
2092
2277
|
"",
|
|
2278
|
+
freshnessLine,
|
|
2279
|
+
outdatedLine,
|
|
2280
|
+
structureLine,
|
|
2281
|
+
`- **Updated**: source ${sourceUpdated} | target ${targetUpdated}`,
|
|
2282
|
+
`- **Target draft**: ${targetRes.translation.draft ? "yes" : "no"}`,
|
|
2283
|
+
"",
|
|
2284
|
+
"_Word counts are informational: a length difference between languages is normal, not a divergence._",
|
|
2285
|
+
"",
|
|
2093
2286
|
...rows
|
|
2094
2287
|
].join("\n")
|
|
2095
2288
|
}] };
|