diff --git a/client/src/api.ts b/client/src/api.ts index fbbdeb604..d089491a9 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -3000,6 +3000,17 @@ class API { return res.data; } + /** + * Writes the book's Lulu printing tag onto its coverpage. Pass an empty + * string to remove whatever tag is there. + */ + async setPublishLuluTag(projectID: string, tag: string) { + const res = await axios.put< + { luluTag: string | null } & ConductorBaseResponse + >(`/project/${projectID}/publish/lulu-tag`, { tag }); + return res.data; + } + async movePublishedBook(projectID: string, to: string) { const res = await axios.post<{ path: string } & ConductorBaseResponse>( `/project/${projectID}/publish/move`, diff --git a/client/src/components/projects/ProjectLinkButtons.tsx b/client/src/components/projects/ProjectLinkButtons.tsx index a1200946a..2bb4bfeca 100644 --- a/client/src/components/projects/ProjectLinkButtons.tsx +++ b/client/src/components/projects/ProjectLinkButtons.tsx @@ -182,11 +182,7 @@ const ProjectLinkButtons: React.FC = ({ View Export Options )} - {/* Hidden on purpose. The publish flow is merged but not yet released to - the publishing team; uncomment this to turn it on. Everything behind - it — `canPublish`, the drawer, the API — is live and unchanged, so - this is the only line that has to move. */} - {/* {canPublish && ( + {canPublish && user.isSuperAdmin && ( = ({ > Publish Book (Admin Visible Only) - )} */} + )} {projectVisibility === "public" && ( void; + isSaving: boolean; +} + +/** + * The optional Lulu printing tag row. + * + * Wraps {@link PublishStep} so the row's numbering, badge, and button stay + * identical to every other step, while the input's state stays local — the + * drawer has no business holding a half-typed tag. + * + * Unlike the real steps there is nothing persisted on the project: the status + * shown here is derived from whether the coverpage currently carries a `lulu@` + * tag, so the row never renders `failed`. A failed save surfaces through the + * notification the hook pushes instead. + */ +const LuluTagStep: React.FC = ({ + index, + luluTag, + onSave, + isSaving, +}) => { + const fieldId = useId(); + const messageId = useId(); + const [value, setValue] = useState(luluTag ?? ""); + + // The drawer re-reads publishing status every few seconds while a step is + // running. Syncing on the *server* value changing, rather than on every + // render, is what keeps a poll from stomping what the user is typing. + const lastServerTag = useRef(luluTag); + useEffect(() => { + if (lastServerTag.current === luluTag) return; + lastServerTag.current = luluTag; + setValue(luluTag ?? ""); + }, [luluTag]); + + const trimmed = value.trim(); + const invalid = + trimmed.length > 0 && !trimmed.toLowerCase().startsWith(TAG_PREFIX); + const unchanged = trimmed === (luluTag ?? ""); + const clearing = trimmed.length === 0 && !!luluTag; + + return ( + + Override cover page values for Lulu printing. Format is{" "} + + { + 'lulu@@@@' + } + .{" "} + To use the field value as-is (no override) use an empty space in + the corresponding segment. + + } + status={luluTag ? "succeeded" : "not-started"} + // facts={[luluTag ? `Currently ${luluTag}` : undefined]} + actionLabel={clearing ? "Remove tag" : "Save tag"} + actionIcon={} + onRun={() => onSave(trimmed)} + isSubmitting={isSaving} + actionDisabled={invalid || unchanged} + > +
+ setValue(e.target.value)} + error={invalid} + aria-invalid={invalid} + aria-describedby={messageId} + disabled={isSaving} + /> + {/* Rendered here rather than through Davis's own `errorMessage` / + `helperText` so the text carries an id the input can point + `aria-describedby` at. Davis does not yet wire those props to the + input — pending a Davis fix. */} + + {invalid + ? 'The tag must start with "lulu@".' + : 'Must start with "lulu@". Clear the field and save to remove the tag.'} + +
+
+ ); +}; + +export default LuluTagStep; diff --git a/client/src/components/projects/PublishBook/PublishBookDrawer.tsx b/client/src/components/projects/PublishBook/PublishBookDrawer.tsx index f8baf21d5..665ad79df 100644 --- a/client/src/components/projects/PublishBook/PublishBookDrawer.tsx +++ b/client/src/components/projects/PublishBook/PublishBookDrawer.tsx @@ -2,6 +2,7 @@ import { useCallback, useState } from "react"; import { Alert, Drawer, Spinner, Text } from "@libretexts/davis-react"; import usePublishBook from "../../../hooks/usePublishBook"; import PublishStep from "./PublishStep"; +import LuluTagStep from "./LuluTagStep"; import MoveDestinationPicker from "./MoveDestinationPicker"; import { IconBolt, IconEye, IconHandMove, IconPackage } from "@tabler/icons-react"; @@ -30,6 +31,9 @@ const PublishBookDrawer: React.FC = ({ runMove, runVisibility, runCompile, + luluTag, + runSetLuluTag, + isSavingLuluTag, } = usePublishBook({ projectID, enabled: open }); const [destination, setDestination] = useState(null); @@ -114,8 +118,15 @@ const PublishBookDrawer: React.FC = ({ isSubmitting={isSubmitting("security")} /> - + + = ({ // The live path is the honest answer to "where is this book now", // and it can differ from what a past move recorded. status?.currentPath && status.currentPath !== steps.move.detail - ? ` Currently at ${status.currentPath}` + ? Currently at {" "} + + {status.currentPath} + . + : undefined, ]} finishedAt={steps.move.finishedAt} @@ -147,7 +162,7 @@ const PublishBookDrawer: React.FC = ({ = ({ /> = ({ const badge = STATUS_BADGE[status]; const running = status === "running" || isSubmitting; - const shownFacts = facts.filter((f): f is string => !!f); + const shownFacts = facts.filter((f): f is React.ReactNode => !!f); if (status === "succeeded" && finishedAt) { shownFacts.push(formatDate(new Date(finishedAt), DATE_FORMAT)); } @@ -93,7 +93,12 @@ const PublishStep: React.FC = ({ {shownFacts.length > 0 && ( - {shownFacts.join(" · ")} + {shownFacts.map((fact, i) => ( + + {i > 0 && " · "} + {fact} + + ))} )} diff --git a/client/src/hooks/usePublishBook.ts b/client/src/hooks/usePublishBook.ts index 62ce7e5f0..63f852fbe 100644 --- a/client/src/hooks/usePublishBook.ts +++ b/client/src/hooks/usePublishBook.ts @@ -59,6 +59,15 @@ export interface UsePublishBookResult { runMove: (to: string) => void; runVisibility: () => void; runCompile: () => void; + /** + * The coverpage's `lulu@` printing tag, and the save for it. Kept apart from + * the steps because the tag is optional and nothing about it is persisted on + * the project — see `PublishService.setLuluTag`. + */ + luluTag: string | null; + /** Pass an empty string to remove the tag. */ + runSetLuluTag: (tag: string) => void; + isSavingLuluTag: boolean; refetch: () => void; } @@ -137,6 +146,25 @@ export default function usePublishBook({ ...stepMutationOptions, }); + /** + * The tag save is synchronous, so it announces itself here rather than + * through the step-transition effect below — that effect walks + * `PUBLISH_STEP_ORDER`, and the printing tag is deliberately not in it. + */ + const luluTagMutation = useMutation({ + mutationFn: async (tag: string) => + api.setPublishLuluTag(projectID as string, tag), + onSuccess: (data) => { + addNotification({ + type: "success", + message: data.luluTag + ? "Lulu printing tag saved." + : "Lulu printing tag removed.", + }); + }, + ...stepMutationOptions, + }); + /** * Announce each step once, on its transition out of `running`. * @@ -219,6 +247,14 @@ export default function usePublishBook({ compileMutation.mutate(); }, [projectID, compileMutation]); + const runSetLuluTag = useCallback( + (tag: string) => { + if (!projectID) return; + luluTagMutation.mutate(tag); + }, + [projectID, luluTagMutation] + ); + return { status, steps, @@ -231,6 +267,9 @@ export default function usePublishBook({ runMove, runVisibility, runCompile, + luluTag: status?.luluTag ?? null, + runSetLuluTag, + isSavingLuluTag: luluTagMutation.isPending, refetch: invalidate, }; } diff --git a/client/src/types/Publish.ts b/client/src/types/Publish.ts index edad94355..405bd9c26 100644 --- a/client/src/types/Publish.ts +++ b/client/src/types/Publish.ts @@ -48,6 +48,13 @@ export type PublishStatus = { currentPath: string | null; /** The coverpage's current MindTouch restriction, read live. */ restriction: string | null; + /** + * The coverpage's current `lulu@` printing tag, read live from the library. + * + * Optional, and not one of the steps: it is absent from `PublishStepKey` and + * `PUBLISH_STEP_ORDER` on purpose, so it can never affect `isPublished`. + */ + luluTag: string | null; visibility: "public" | "private"; steps: Record; preprocessPercentage?: number; diff --git a/server/api.js b/server/api.js index c0fb1a206..1059d7074 100644 --- a/server/api.js +++ b/server/api.js @@ -3459,6 +3459,16 @@ router.route('/project/:projectID/publish/security').post( catchInternal((req, res) => publishAPI.setPublishBookSecurity(req, res)), ); +// PUT rather than POST: this replaces one value on the coverpage and is +// idempotent, mirroring the CXOne tags call underneath it. +router.route('/project/:projectID/publish/lulu-tag').put( + authAPI.verifyRequest, + authAPI.getUserAttributes, + authAPI.checkHasRoleMiddleware("libretexts", "superadmin"), + middleware.validateZod(PublishValidators.LuluTagValidator), + catchInternal((req, res) => publishAPI.setPublishLuluTag(req, res)), +); + router.route('/project/:projectID/publish/move').post( authAPI.verifyRequest, authAPI.getUserAttributes, diff --git a/server/api/publish.ts b/server/api/publish.ts index 4d4c38203..b80201399 100644 --- a/server/api/publish.ts +++ b/server/api/publish.ts @@ -13,6 +13,7 @@ import PublishService, { } from "./services/publish-service.js"; import { ListDestinationsValidator, + LuluTagValidator, MoveBookValidator, PublishStatusValidator, PublishStepValidator, @@ -150,6 +151,37 @@ export async function setPublishBookSecurity( } } +/** + * Writes the book's Lulu printing tag onto its coverpage, or removes it when + * the body carries an empty string. + * + * Optional, and not one of the recorded steps — see `PublishService.setLuluTag`. + */ +export async function setPublishLuluTag( + req: ZodReqWithUser>, + res: Response +) { + const project = await loadPublishableProject(req.params.projectID, res); + if (!project) return; + + try { + const luluTag = await new PublishService().setLuluTag( + project, + req.body.tag || null, + req.user.decoded.uuid + ); + return res.status(200).json({ + err: false, + msg: luluTag + ? "Lulu printing tag saved." + : "Lulu printing tag removed.", + luluTag, + }); + } catch (err) { + return respondToStepError(res, err, "setPublishLuluTag"); + } +} + export async function movePublishedBook( req: ZodReqWithUser>, res: Response diff --git a/server/api/services/publish-service.ts b/server/api/services/publish-service.ts index cbd7f96c0..5c86d3f82 100644 --- a/server/api/services/publish-service.ts +++ b/server/api/services/publish-service.ts @@ -15,7 +15,8 @@ import User from "../../models/user.js"; import BookBotRun from "../../models/bookbotrun.js"; import { ShapeshiftJob } from "../../types/Shapeshift.js"; import ExpertWithSSM from "../../util/ExpertWithSSM.js"; -import { ExpertError, text } from "@libretexts/cxone-expert-node"; +import { ExpertError, text, type Tags } from "@libretexts/cxone-expert-node"; +import { buildTagsPayload, readTagValues } from "../../util/pageTags.js"; const publishLog = childLogger("publish"); @@ -41,6 +42,11 @@ export type PublishStatus = { currentPath: string | null; /** The coverpage's current MindTouch restriction, read live. */ restriction: string | null; + /** + * The coverpage's current `lulu@` printing tag, read live. Optional, so it + * never counts toward `isPublished`. + */ + luluTag: string | null; visibility: ProjectInterfaceRaw["visibility"]; steps: Record; /** Progress of the in-flight editor-preprocess run, when there is one. */ @@ -62,6 +68,12 @@ const STEP_KEYS: PublishStepKey[] = [ */ const PUBLIC_RESTRICTION = "Public"; +/** + * Prefix every Lulu print-fulfillment tag carries. What follows it is Lulu's + * business, so only the prefix is enforced. + */ +const LULU_TAG_PREFIX = "lulu@"; + const NOT_STARTED: PublishStepState = { status: "not-started" }; /** @@ -282,6 +294,138 @@ export default class PublishService { }); } + /** + * Optional step, between steps 2 and 3 — write the book's Lulu printing tag + * onto the coverpage. + * + * Not every published book is printed, so this is deliberately not one of the + * recorded steps: nothing is written to `project.publishing` and the tag has + * no bearing on whether a book counts as published. The coverpage's own tag + * set is the only record, read back live by {@link getStatus}. + * + * `PUT /pages/{id}/tags` replaces the page's entire tag set, so this reads the + * current tags, swaps the `lulu@` value in or out, and writes the whole set + * back. + * + * The `lulu@` prefix is lowercased on the way in, because the Commons sync + * that reads these tags back matches it case-sensitively. + * + * @param tag - The tag to store, or `null` to remove whatever is there. + * @returns The tag now on the page, or `null` once it has been removed. + */ + public async setLuluTag( + project: ProjectInterfaceRaw, + tag: string | null, + actorUUID: string + ): Promise { + const { libreLibrary, libreCoverID, projectID } = project; + if (!libreLibrary || !libreCoverID) { + throw new PublishStepError( + "This project is not linked to a library book, so there is nothing to tag." + ); + } + if (tag && !tag.toLowerCase().startsWith(LULU_TAG_PREFIX)) { + throw new PublishStepError( + `A Lulu printing tag must start with "${LULU_TAG_PREFIX}".` + ); + } + + const expert = await ExpertWithSSM.getInstance().forLibrary(libreLibrary); + const current = readTagValues(await expert.pages.getPageTags(libreCoverID)); + + // A PUT replaces the whole set, so refuse to write a set we could not fully + // read — that would delete the tags we failed to parse. + if (current.unreadable) { + throw new PublishStepError( + `${current.unreadable} tag(s) on the coverpage could not be read, so its ` + + "tags cannot be rewritten without losing them. Check the page's tags " + + "on the library." + ); + } + + // The Commons sync matches the prefix case-sensitively — see + // `parsePublishingTags` in library-sync-service.ts — so a tag stored as + // `LULU@…` is accepted everywhere here and then silently ignored by the + // catalog, which is the worst of both outcomes. The prefix is normalized + // before anything is written; the rest of the value is left exactly as the + // user typed it, since those segments are printed verbatim. + const normalized = tag + ? `${LULU_TAG_PREFIX}${tag.slice(LULU_TAG_PREFIX.length)}` + : null; + + const isLulu = (value: string) => + value.toLowerCase().startsWith(LULU_TAG_PREFIX); + const kept = current.values.filter((value) => !isLulu(value)); + const next = normalized ? [...kept, normalized] : kept; + + // Re-saving a tag the page already carries writes nothing. Also covers + // clearing a field that was already empty. + if ( + next.length === current.values.length && + next.every((value) => current.values.includes(value)) + ) { + publishLog.debug( + { projectID, libreLibrary, libreCoverID, tag: normalized }, + "Lulu printing tag unchanged, skipping write" + ); + return normalized; + } + + const payload = buildTagsPayload(next); + let updated: Partial | undefined; + try { + // The SDK's request client hard-defaults `Content-Type: application/json` + // and `putPageTags` sets no type of its own, so the XML body would go out + // mislabelled. `funcArgs.headers` is merged over the client defaults, + // which is the only hook for correcting it. Axios leaves a string body + // untouched, so `payload` is sent verbatim. + updated = await expert.pages.putPageTags( + libreCoverID, + payload, + undefined, + { headers: { "Content-Type": "application/xml" } } + ); + } catch (err) { + // Rewriting tags needs ADMIN on the page; without it the API answers 403 + // and nothing else here would explain why. + if (PublishService.httpStatusOf(err) === 403) { + throw new PublishStepError( + "The library account does not have admin access to this book, so its tags cannot be changed.", + 400 + ); + } + throw err; + } + + // CXOne echoes the new tag set back. Checking it keeps a write that did not + // take from being reported as a success, and catches the failure that + // matters most here: a tag that should have survived going missing. + const after = readTagValues(updated); + const stored = after.values.find(isLulu) ?? null; + if ( + normalized + ? stored?.toLowerCase() !== normalized.toLowerCase() + : stored !== null + ) { + throw new Error( + `Coverpage tags did not take: expected ${normalized ?? "no lulu@ tag"}, ` + + `got ${stored ?? "none"}` + ); + } + const lost = kept.filter((value) => !after.values.includes(value)); + if (lost.length) { + throw new Error( + `PUT dropped coverpage tag(s) that should have been kept: ${lost.join(", ")}` + ); + } + + publishLog.info( + { projectID, libreLibrary, libreCoverID, tag: normalized, actor: actorUUID }, + normalized ? "Lulu printing tag set" : "Lulu printing tag removed" + ); + return stored; + } + /** * Step 3 — move the book's coverpage, and with it every subpage, to its * final location on the library. @@ -475,10 +619,19 @@ export default class PublishService { } } - const live = - libreLibrary && libreCoverID - ? await this.getLivePageState(libreLibrary, libreCoverID) - : { path: null, restriction: null }; + let live: { path: string | null; restriction: string | null } = { + path: null, + restriction: null, + }; + let luluTag: string | null = null; + if (libreLibrary && libreCoverID) { + // Both reads hit the same library host, so they go out together rather + // than serially — the drawer re-reads this while a step is running. + [live, luluTag] = await Promise.all([ + this.getLivePageState(libreLibrary, libreCoverID), + this.getLuluTag(libreLibrary, libreCoverID), + ]); + } // Like visibility, the library's restriction is a fact rather than a claim: // a book somebody made public by hand is public whether or not the step ran. @@ -496,6 +649,7 @@ export default class PublishService { coverID: libreCoverID ?? null, currentPath: live.path, restriction: live.restriction, + luluTag, visibility: project.visibility, steps, preprocessPercentage, @@ -543,6 +697,34 @@ export default class PublishService { } } + /** + * The `lulu@` tag currently on the coverpage, if there is one. + * + * Read live because the coverpage's tag set is the only record of it. A + * failure is swallowed rather than raised: the printing tag is a side detail, + * and it must not take down the status read the whole drawer depends on. + */ + private async getLuluTag( + library: string, + coverID: string + ): Promise { + try { + const expert = await ExpertWithSSM.getInstance().forLibrary(library); + const { values } = readTagValues(await expert.pages.getPageTags(coverID)); + return ( + values.find((value) => + value.toLowerCase().startsWith(LULU_TAG_PREFIX) + ) ?? null + ); + } catch (err) { + publishLog.warn( + { err, library, coverID }, + "Could not read the Lulu printing tag" + ); + return null; + } + } + /** * Undoes the single layer of percent-encoding MindTouch puts on `uri.ui`. * diff --git a/server/api/validators/publish.ts b/server/api/validators/publish.ts index 4f90d770d..4f0f1fb38 100644 --- a/server/api/validators/publish.ts +++ b/server/api/validators/publish.ts @@ -54,3 +54,42 @@ export const MoveBookValidator = z.object({ to: DestinationPath, }), }); + +/** + * Longest Lulu printing tag accepted. Same reasoning as the path cap above: the + * value reaches a library-mutating call, so it is bounded. Generous, because + * the tag packs four human-readable fields into one string. + */ +const MAX_LULU_TAG_LENGTH = 512; + +/** + * A book's Lulu print-fulfillment tag, as stored on the CXOne coverpage. + * + * The shape is `lulu@@@@`, + * so the segments are prose: spaces, commas, and periods all belong there + * ("lulu@Introductory Chemistry@Delmar Larsen, PhD@UC Davis@Intro Chem"), and a + * single space is how a segment says "use the cover page's own value". Only the + * `lulu@` prefix is enforced; past it the content is Lulu's business. + * + * Control characters are the one exclusion — they do not survive a MindTouch + * tag value intact. Surrounding whitespace is trimmed, so an empty string is + * the removal signal. + */ +export const LuluPrintingTag = z + .string() + .trim() + .max(MAX_LULU_TAG_LENGTH) + .refine((v) => v === "" || /^lulu@/i.test(v), { + message: 'Tag must start with "lulu@".', + }) + // Covers tabs and line breaks too, which are themselves below 0x20. + .refine((v) => ![...v].some((c) => c.charCodeAt(0) < 0x20), { + message: "Tag must not contain line breaks, tabs, or control characters.", + }); + +export const LuluTagValidator = z.object({ + params: ProjectScopedParams, + body: z.object({ + tag: LuluPrintingTag, + }), +}); diff --git a/server/scripts/removeCoverpageTags.ts b/server/scripts/removeCoverpageTags.ts index 92ef9e367..b4dee14f9 100644 --- a/server/scripts/removeCoverpageTags.ts +++ b/server/scripts/removeCoverpageTags.ts @@ -48,7 +48,7 @@ import "dotenv/config"; import fs from "node:fs/promises"; import path from "node:path"; import Expert from "../util/ExpertWithSSM.js"; -import type { PageTag, Tags } from "@libretexts/cxone-expert-node"; +import { buildTagsPayload, readTagValues } from "../util/pageTags.js"; import { mapWithConcurrency } from "../util/concurrency.js"; // --------------------------------------------------------------------------- @@ -95,65 +95,6 @@ type Outcome = | { status: "not-tagged"; ref: PageRef } | { status: "failed"; ref: PageRef; error: string }; -/** CXOne returns repeated elements as object | array | "" depending on cardinality. */ -function toArray(value: T | T[] | "" | undefined | null): T[] { - if (!value) return []; - return Array.isArray(value) ? value : [value]; -} - -/** - * Tag values from a `GET /pages/{id}/tags` response, in the order returned. - * - * Because `PUT /pages/{id}/tags` replaces the whole set, a tag we fail to read - * is a tag we would silently delete. Anything without a usable `@value` is - * therefore reported instead of dropped, and the caller aborts the page. - */ -function tagValues(tags: Partial | undefined): { - values: string[]; - unreadable: number; -} { - const entries = toArray | "">(tags?.tag); - const values: string[] = []; - let unreadable = 0; - - for (const entry of entries) { - const value = entry ? entry["@value"] : undefined; - if (typeof value === "string" && value.length) { - if (!values.includes(value)) values.push(value); - } else { - unreadable++; - } - } - - return { values, unreadable }; -} - -/** - * Escapes a tag value for use inside a double-quoted XML attribute. `&` must go - * first so the ampersands introduced by the later replacements are not escaped - * a second time. `'` needs no escaping inside double quotes. - */ -function escapeXmlAttr(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - -/** - * Builds the `` document CXOne expects, sent as - * `application/xml`. An empty list yields ``, which clears the page's - * tags. - */ -function buildTagsPayload(values: string[]): string { - if (!values.length) return ""; - const tags = values - .map((value) => ``) - .join(""); - return `${tags}`; -} - /** * Reads the input file and returns the pages worth visiting: rows missing a * library or cover id are dropped, and repeat (library, coverID) pairs are @@ -206,7 +147,7 @@ async function untagPage(ref: PageRef, dryRun: boolean): Promise { try { const expert = await Expert.getInstance().forLibrary(ref.library); - const current = tagValues(await expert.pages.getPageTags(ref.coverID)); + const current = readTagValues(await expert.pages.getPageTags(ref.coverID)); // A PUT replaces the whole set, so refuse to write a set we could not // fully read — that would delete the tags we failed to parse. @@ -246,7 +187,7 @@ async function untagPage(ref: PageRef, dryRun: boolean): Promise { // CXOne echoes the new tag set back. Treat the tag still being present, or // a kept tag having gone missing, as a failure rather than reporting a // write that didn't take. - const after = tagValues(updated); + const after = readTagValues(updated); if (after.values.includes(TARGET_TAG)) { throw new Error( `${TARGET_TAG} still present after PUT (got: ${after.values.join(", ") || "none"})` diff --git a/server/util/pageTags.ts b/server/util/pageTags.ts new file mode 100644 index 000000000..674d35d1b --- /dev/null +++ b/server/util/pageTags.ts @@ -0,0 +1,71 @@ +/** + * Helpers for reading and rewriting a CXOne (MindTouch) page's tags. + * + * CXOne has no "add a tag" or "delete a tag" endpoint: `PUT /pages/{id}/tags` + * replaces the page's entire tag set. Every write is therefore a + * read-modify-write — `getPageTags`, edit the list, PUT the whole list back — + * and a tag that could not be read cleanly must abort the write rather than be + * silently dropped. {@link readTagValues} reports unreadable entries instead of + * discarding them so callers can make that refusal. + */ + +import type { PageTag, Tags } from "@libretexts/cxone-expert-node"; + +/** CXOne returns repeated elements as object | array | "" depending on cardinality. */ +export function toArray(value: T | T[] | "" | undefined | null): T[] { + if (!value) return []; + return Array.isArray(value) ? value : [value]; +} + +/** + * Tag values from a `GET /pages/{id}/tags` response, in the order returned. + * + * Because `PUT /pages/{id}/tags` replaces the whole set, a tag we fail to read + * is a tag we would silently delete. Anything without a usable `@value` is + * therefore reported instead of dropped, and the caller aborts the page. + */ +export function readTagValues(tags: Partial | undefined): { + values: string[]; + unreadable: number; +} { + const entries = toArray | "">(tags?.tag); + const values: string[] = []; + let unreadable = 0; + + for (const entry of entries) { + const value = entry ? entry["@value"] : undefined; + if (typeof value === "string" && value.length) { + if (!values.includes(value)) values.push(value); + } else { + unreadable++; + } + } + + return { values, unreadable }; +} + +/** + * Escapes a tag value for use inside a double-quoted XML attribute. `&` must go + * first so the ampersands introduced by the later replacements are not escaped + * a second time. `'` needs no escaping inside double quotes. + */ +export function escapeXmlAttr(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** + * Builds the `` document CXOne expects, sent as + * `application/xml`. An empty list yields ``, which clears the page's + * tags. + */ +export function buildTagsPayload(values: string[]): string { + if (!values.length) return ""; + const tags = values + .map((value) => ``) + .join(""); + return `${tags}`; +}