diff --git a/client/src/components/remixer/EditPanel.tsx b/client/src/components/remixer/EditPanel.tsx index db9c4dfe8..de8fa438f 100644 --- a/client/src/components/remixer/EditPanel.tsx +++ b/client/src/components/remixer/EditPanel.tsx @@ -30,7 +30,13 @@ function sanitizeRemixerTitle( trim: boolean = true, allowColon = false, ): string { - let s = allowColon ? value : value.replace(/:/g, ""); + let s = value; + if (!allowColon) { + const colonIdx = value.indexOf(":"); + if (colonIdx !== -1) { + s = value.slice(colonIdx + 1); + } + } return trim ? s.trim() : s; } diff --git a/client/src/components/remixer/PublishPanel.tsx b/client/src/components/remixer/PublishPanel.tsx index 006b67b23..ba56c6ad2 100644 --- a/client/src/components/remixer/PublishPanel.tsx +++ b/client/src/components/remixer/PublishPanel.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Icon, List, Progress } from "semantic-ui-react"; import { RemixerSubPage } from "./model"; import { appendSiblingTitleSuffix } from "./services"; @@ -21,6 +21,8 @@ interface SummarySection { items: RemixerSubPage[]; } +const NEAR_BOTTOM_PX = 24; + const PublishPanel: React.FC = ({ open, dimmer, @@ -32,10 +34,27 @@ const PublishPanel: React.FC = ({ publishMessages = [], }) => { const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); + const [autoScroll, setAutoScroll] = useState(true); useEffect(() => { + if (publishStatus === "idle" || publishMessages.length === 0) { + setAutoScroll(true); + } + }, [publishStatus, publishMessages.length]); + + useEffect(() => { + if (!autoScroll) return; messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [publishMessages]); + }, [publishMessages, autoScroll]); + + const handleMessagesScroll = useCallback(() => { + const el = messagesContainerRef.current; + if (!el) return; + const distanceFromBottom = + el.scrollHeight - el.scrollTop - el.clientHeight; + setAutoScroll(distanceFromBottom <= NEAR_BOTTOM_PX); + }, []); const publish = () => { handlePublish(); @@ -176,6 +195,8 @@ const PublishPanel: React.FC = ({ )} {publishMessages.length > 0 ? (
{ }; /** Discard local/server drafts and reload the book from source. Resets undo/redo and UI panels. */ - const startOverMutation = useMutation({ + const {mutate: startOverMutation, isPending: isStartOverPending} = useMutation({ mutationFn: async () => { clearLocalDraft(id); serverStateRef.current = null; @@ -1725,7 +1725,7 @@ const RemixerDashboard: React.FC = () => { cancelText="Keep Changes" onCancel={closeAllModals} onConfirm={() => { - startOverMutation.mutate(); + startOverMutation(); closeAllModals(); }} /> @@ -2364,7 +2364,7 @@ const RemixerDashboard: React.FC = () => { canRedo={redoStack.length > 0} /> - {remixerData.currentBook ? ( + {(remixerData.currentBook && !isStartOverPending) ? ( { const rawTitle = page.title || page["@title"] || "Untitled"; - if (page.parentID === "-1" && page.article === "topic-category") { + if (page.parentID === "-1" ) { return rawTitle; } const cleanTitle = stripDefaultTitlePrefixBeforeColon( @@ -545,6 +545,56 @@ const applyDefaultRemixerPageProperties = async ( await addPageProperty(subdomain, pageID, "WelcomeHidden", true); }; +/** Book root → topic-category; cover children → topic-guide; everyone else → topic. */ +type RemixerArticleKind = "topic-category" | "topic-guide" | "topic"; + +const articleKindForPlacement = ( + pageId: string | undefined, + parentId: string | undefined, + coverId?: string, +): RemixerArticleKind => { + if (coverId && (pageId === coverId || pageId === "-1")) { + return "topic-category"; + } + if (coverId && parentId === coverId) { + return "topic-guide"; + } + return "topic"; +}; + +const contentTemplateForArticleKind = (kind: RemixerArticleKind): string => { + if (kind === "topic-category") { + return RemixerTemplates.POST_CreateBlankTopicCategory; + } + if (kind === "topic-guide") { + return RemixerTemplates.POST_CreateBlankTopicGuide; + } + return RemixerTemplates.POST_CreateBlankPage("topic"); +}; + +const localArticleField = ( + kind: RemixerArticleKind, +): RemixerSubPageState["article"] => (kind === "topic" ? "article" : kind); + +const applyArticleKindToPage = async ( + page: RemixerSubPageState, + kind: RemixerArticleKind, + subdomain: string, + coverId: string, +): Promise => { + const bookService = new BookService({ + bookID: `${subdomain}-${coverId}`, + }); + const tag = + kind === "topic" ? "article:topic" : (`article:${kind}` as const); + await bookService.updatePageDetails(page["@id"], undefined, [tag]); + await bookService.activateShowOrg( + page["@id"], + kind === "topic-guide" || kind === "topic-category", + ); + page.article = localArticleField(kind); +}; + const handleNewPage = async ( page: RemixerSubPageState, parent: RemixerSubPageState, @@ -552,10 +602,9 @@ const handleNewPage = async ( subdomain: string, coverId?: string, ): Promise<{ pageID: string; pageURI: string }> => { - const content = - page["@subpages"] === true || parent["@id"] === coverId - ? RemixerTemplates.POST_CreateBlankTopicGuide - : RemixerTemplates.POST_CreateBlankPage("topic"); + const kind = articleKindForPlacement(page["@id"], parent["@id"], coverId); + const content = contentTemplateForArticleKind(kind); + page.article = localArticleField(kind); const rawTitle = page["@title"] || page.title || title; // segment must be un-encoded here — we double-encode the full path below, // matching CXOneFetch's encodeURIComponent(encodeURIComponent(path)) convention. @@ -620,11 +669,11 @@ const remixerPagePaddedSlug = ( isBookRoot: boolean = false, ): string => { const rawTitle = page["@title"] || page.title || displayTitle; - if (isBookRoot && page.article === "topic-category") { + if (isBookRoot ) { return rawTitle .toLowerCase() .replace(/ /g, "-") - .replace(/[\:\.\-]/g, ""); + .replace(/[\:\.\-]/g, "_"); } return buildRemixerPagePathSegment(page, rawTitle, page.siblingTitleIndex); }; @@ -753,22 +802,10 @@ const handleModifiedPage = async ( throwForMindTouchResponse(response, "Error moving/renaming page"); } - // Match handleNewPage article types based on placement under the cover. + // Placement: cover children are topic-guide; nested pages are topic. if (isMoved && coverId && parent) { - const bookService = new BookService({ - bookID: `${subdomain}-${coverId}`, - }); - if (parent["@id"] === coverId) { - await bookService.updatePageDetails(pageId, undefined, [ - "article:topic-guide", - ]); - await bookService.activateShowOrg(pageId, true); - page.article = "topic-guide"; - } else { - await bookService.updatePageDetails(pageId, undefined, ["article:topic"]); - await bookService.activateShowOrg(pageId, false); - page.article = "article"; - } + const kind = articleKindForPlacement(page["@id"], parent["@id"], coverId); + await applyArticleKindToPage(page, kind, subdomain, coverId); } }; @@ -1051,13 +1088,8 @@ const handleImportedPage = async ( ); postComment = "Remixer fork"; } - // hasChildren is true if the page has any children - if (hasChildren) { - contentsBody = RemixerTemplates.POST_CreateBlankTopicGuide + contentsBody; - } else { - contentsBody = - RemixerTemplates.POST_CreateBlankPage("topic") + contentsBody; - } + const kind = articleKindForPlacement(page["@id"], parent["@id"], coverId); + contentsBody = contentTemplateForArticleKind(kind) + contentsBody; const postRes = await CXOneFetch({ scope: "page", path: parseInt(pageID, 10), @@ -1472,7 +1504,7 @@ const runRemixerJob = async ({ inDeletedBranch, autoNumbering, ); - + console.log("title", title); const status = getPageStatus(page); const shouldSkip = shouldSkipPage(page, inMatterBranch, status); @@ -1492,6 +1524,9 @@ const runRemixerJob = async ({ try { if (status === "new") { + if(shouldSkip) { + return "success"; + } const parentId = page.parentID ?? "-1"; const parent = parentId !== "-1" ? byId.get(parentId) : undefined; if (parent) { @@ -1516,6 +1551,9 @@ const runRemixerJob = async ({ await orderPageAfterPreviousSibling(pageID, page, pages, subdomain); } } else if (status === "imported") { + if(shouldSkip) { + return "success"; + } const parentId = page.parentID ?? "-1"; const parent = parentId !== "-1" ? byId.get(parentId) : undefined; if (parent) { diff --git a/server/util/remixerutils.ts b/server/util/remixerutils.ts index 2f9862eb7..6a613c026 100644 --- a/server/util/remixerutils.ts +++ b/server/util/remixerutils.ts @@ -60,9 +60,14 @@ export const buildRemixerPagePathSegment = ( : ""; // Prefer formattedPath so autoNumbering `start` (incl. 0 → `00%3A_…`) is honored. const numbering = - page.formattedPath?.trim() || page.numberedPath?.trim() || ""; - return numbering - ? `${numbering.padStart(2, "0")}:_${titleSegment}${siblingTitleIndexPostfix}` + page.numberedPath?.trim() ||page.formattedPath?.trim() || ""; + const parts = numbering.split("."); + if (parts.length > 0) { + parts[parts.length - 1] = parts[parts.length - 1]!.padStart(2, "0"); + } + const paddedNumbering = numbering ? parts.join(".") : ""; + return paddedNumbering + ? `${paddedNumbering}:_${titleSegment}${siblingTitleIndexPostfix}` : titleSegment; }; export const generatePagePath = (parent: string, title: string): string => { @@ -128,7 +133,9 @@ export const getPageStatus = (page: RemixerSubPageState): RemixerPageStatus => { export const shouldSkipPage = (page: RemixerSubPageState, inMatterBranch: boolean, status: RemixerPageStatus): boolean => { const pathLen = page.pathNumber?.length ?? 0; const isBookRoot = pathLen === 0; - return isBookRoot || inMatterBranch || status === "unchanged"; + const pageStatus = getPageStatus(page); + const isDeleteNoExisting = page.isDeleted && (pageStatus === "imported" || pageStatus === "new") || false; + return isBookRoot || inMatterBranch || status === "unchanged" || isDeleteNoExisting; }; /**