From d7b70844422b43e330187cbb3fce1a26e4a1a95a Mon Sep 17 00:00:00 2001 From: Jake Turner Date: Sun, 13 Oct 2024 17:54:16 -0700 Subject: [PATCH] feat: ai generated page summaries and meta tags --- client/src/api.ts | 117 +++++-- .../ControlledInputs/CtlTextArea.tsx | 7 +- client/src/components/TreeView/TreeView.css | 3 - client/src/components/TreeView/index.jsx | 104 ------ client/src/components/TreeView/index.tsx | 128 +++++++ .../TextbookStructure/EditMetadataModal.tsx | 318 +++++++++++++++++ .../TextbookStructure/index.tsx | 152 +++++++++ .../src/components/projects/ProjectView.jsx | 2 + .../projects/RenderProjectModules.tsx | 15 + client/src/screens/commons/Book/index.tsx | 40 +-- client/src/styles/global.css | 8 + client/src/types/Book.ts | 21 ++ client/src/types/index.ts | 5 +- client/src/utils/misc.ts | 10 + server/api.js | 30 ++ server/api/books.ts | 322 ++++++++++++++++++ server/api/validators/book.ts | 37 +- server/package-lock.json | 206 +++++++++++ server/package.json | 1 + server/util/CXOne/CXOnePageAPIEndpoints.ts | 8 + server/util/CXOne/CXOnePageProperties.ts | 1 + server/util/CXOne/CXOneTemplates.ts | 3 + server/util/librariesclient.ts | 1 + 23 files changed, 1368 insertions(+), 171 deletions(-) delete mode 100644 client/src/components/TreeView/TreeView.css delete mode 100644 client/src/components/TreeView/index.jsx create mode 100644 client/src/components/TreeView/index.tsx create mode 100644 client/src/components/projects/ProjectModules/TextbookStructure/EditMetadataModal.tsx create mode 100644 client/src/components/projects/ProjectModules/TextbookStructure/index.tsx diff --git a/client/src/api.ts b/client/src/api.ts index 40ddcad6..0ba6ec37 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -20,14 +20,21 @@ import { HarvestRequest, Homework, HomeworkSearchParams, + PageDetailsResponse, PeerReview, Project, ProjectFile, ProjectSearchParams, + TableOfContents, User, UserSearchParams, } from "./types"; -import { AddableProjectTeamMember, CIDDescriptor, ProjectFileAuthor, ProjectTag } from "./types/Project"; +import { + AddableProjectTeamMember, + CIDDescriptor, + ProjectFileAuthor, + ProjectTag, +} from "./types/Project"; import { Collection } from "./types/Collection"; import { AuthorSearchParams, @@ -217,10 +224,11 @@ class API { return res; } - public cloudflareStreamUploadURL: string = `${import.meta.env.MODE === "development" - ? import.meta.env.VITE_DEV_BASE_URL - : "" - }/api/v1/cloudflare/stream-url`; + public cloudflareStreamUploadURL: string = `${ + import.meta.env.MODE === "development" + ? import.meta.env.VITE_DEV_BASE_URL + : "" + }/api/v1/cloudflare/stream-url`; // Authors async getAuthors({ @@ -321,6 +329,51 @@ class API { return res; } + async getBookTOC(bookID: string) { + const res = await axios.get< + { + toc: TableOfContents; + } & ConductorBaseResponse + >(`/commons/book/${bookID}/toc`); + return res; + } + + async getPageDetails(pageID: string) { + const res = await axios.get( + `/commons/pages/${pageID}` + ); + return res; + } + + async getPageAISummary(pageID: string) { + const res = await axios.get< + { + summary: string; + } & ConductorBaseResponse + >(`/commons/pages/${pageID}/ai-summary`); + return res; + } + + async getPageAITags(pageID: string) { + const res = await axios.get< + { + tags: string[]; + } & ConductorBaseResponse + >(`/commons/pages/${pageID}/ai-tags`); + return res; + } + + async updatePageDetails( + pageID: string, + data: { summary: string; tags: string[] } + ) { + const res = await axios.patch( + `/commons/pages/${pageID}`, + data + ); + return res; + } + // Central Identity async getCentralIdentityOrgs({ activePage, @@ -371,16 +424,20 @@ class API { } async generateADAPTAccessCode() { - const res = await axios.get<{ - access_code: string; - } & ConductorBaseResponse>("/central-identity/adapt-access-code"); + const res = await axios.get< + { + access_code: string; + } & ConductorBaseResponse + >("/central-identity/adapt-access-code"); return res; } - async getCentralIdentityApps(){ - const res = await axios.get<{ - applications: CentralIdentityApp[]; - } & ConductorBaseResponse>("/central-identity/apps"); + async getCentralIdentityApps() { + const res = await axios.get< + { + applications: CentralIdentityApp[]; + } & ConductorBaseResponse + >("/central-identity/apps"); return res; } @@ -420,7 +477,11 @@ class API { return res; } - async getCentralIdentityVerificationRequests(queryParams: { page?: number; limit?: number, status?: 'open' | 'closed' }) { + async getCentralIdentityVerificationRequests(queryParams: { + page?: number; + limit?: number; + status?: "open" | "closed"; + }) { const res = await axios.get< { requests: CentralIdentityVerificationRequest[]; @@ -570,9 +631,9 @@ class API { page: params.page?.toString() || "1", limit: params.limit?.toString() || "20", }); - const res = await axios.get<{ users: AddableProjectTeamMember[] } & ConductorBaseResponse>( - `/project/${params.projectID}/team/addable?${queryParams}` - ); + const res = await axios.get< + { users: AddableProjectTeamMember[] } & ConductorBaseResponse + >(`/project/${params.projectID}/team/addable?${queryParams}`); return res; } async getPublicProjects(params?: { page?: number; limit?: number }) { @@ -771,7 +832,7 @@ class API { { resources: CollectionResource[]; total_items: number; - cursor?: number + cursor?: number; } & ConductorBaseResponse >( `/commons/collection/${encodeURIComponent( @@ -805,7 +866,7 @@ class API { { collections: Collection[]; total_items: number; - cursor?: number + cursor?: number; } & ConductorBaseResponse >(`/commons/collections`, { params: { @@ -848,11 +909,15 @@ class API { } async deleteCollection(id: string) { - return await axios.delete(`/commons/collection/${id}`); + return await axios.delete( + `/commons/collection/${id}` + ); } async deleteCollectionResource(collID: string, resourceID: string) { - return await axios.delete(`/commons/collection/${collID}/resources/${resourceID}`); + return await axios.delete( + `/commons/collection/${collID}/resources/${resourceID}` + ); } // USERS (Control Panel) @@ -862,11 +927,13 @@ class API { limit?: number; sort?: string; }) { - return await axios.get<{ - results: User[]; - total_items: number; - } & ConductorBaseResponse>("/users", { - params + return await axios.get< + { + results: User[]; + total_items: number; + } & ConductorBaseResponse + >("/users", { + params, }); } } diff --git a/client/src/components/ControlledInputs/CtlTextArea.tsx b/client/src/components/ControlledInputs/CtlTextArea.tsx index decc5e8e..bf9b9d34 100644 --- a/client/src/components/ControlledInputs/CtlTextArea.tsx +++ b/client/src/components/ControlledInputs/CtlTextArea.tsx @@ -1,12 +1,15 @@ import { FieldValues, FieldPath, Controller } from "react-hook-form"; import { Form, FormTextAreaProps } from "semantic-ui-react"; import { ControlledInputProps } from "../../types"; +import "../../styles/global.css"; interface CtlTextAreaProps extends FormTextAreaProps { label?: string; required?: boolean; maxLength?: number; showRemaining?: boolean; + fluid?: boolean; + bordered?: boolean; } /** @@ -24,6 +27,8 @@ export default function CtlTextArea< required = false, maxLength, showRemaining = false, + fluid = false, + bordered = false, ...rest }: ControlledInputProps & CtlTextAreaProps) { const { className: restClassName } = rest; @@ -57,7 +62,7 @@ export default function CtlTextArea< onChange={onChange} onBlur={onBlur} error={error?.message} - className="!m-0" + className={`!m-0 ${fluid ? "fluid-textarea" : ""} ${bordered ? 'border border-slate-400 rounded-md padded-textarea': ''}`} {...rest} /> {maxLength && showRemaining && typeof value === "string" && ( diff --git a/client/src/components/TreeView/TreeView.css b/client/src/components/TreeView/TreeView.css deleted file mode 100644 index 4208b11d..00000000 --- a/client/src/components/TreeView/TreeView.css +++ /dev/null @@ -1,3 +0,0 @@ -.treenode-child-icon { - vertical-align: middle !important; -} \ No newline at end of file diff --git a/client/src/components/TreeView/index.jsx b/client/src/components/TreeView/index.jsx deleted file mode 100644 index 0630da7d..00000000 --- a/client/src/components/TreeView/index.jsx +++ /dev/null @@ -1,104 +0,0 @@ -import React, { useState } from 'react'; -import PropTypes from 'prop-types'; -import { Icon, List } from 'semantic-ui-react'; -import './TreeView.css'; - -/** - * Displays items in a "tree" view, with nested and expandable lists. - */ -const TreeNode = ({ parentKey, item, asLink, hrefKey, textKey }) => { - - const [expanded, setExpanded] = useState(false); - let hasChildren = Array.isArray(item.children) && item.children.length > 0; - - let display = null; - let styleObj = {}; - if (item.color) styleObj = { color: item.color }; - if (asLink) { - if (typeof(item.metaLink) === 'object') { - display = ( - - {item[textKey]}{item.metaLink.text} - - ) - } else if (typeof(item.meta) === 'object') { - display = {item[textKey]}{item.meta.text} - } else { - display = {item[textKey]} - } - } else { - display = {item.title} - } - - return ( - - setExpanded(!expanded)} - className={hasChildren ? 'cursor-pointer' : 'treenode-child-icon'} - /> - - {display} - {(hasChildren && expanded) && - - {item.children.map((subItem, idx) => { - return ( - - ) - })} - - } - - - ) -}; - - -const TreeView = ({ items, asLinks, hrefKey, textKey }) => { - if (Array.isArray(items)) { - if ((asLinks === true && typeof(hrefKey) === 'string' && typeof(textKey) === 'string') || asLinks === false) { - return ( - - {items.map((item, idx) => { - return ( - - ) - })} - - ) - } - } - return null; -}; - -TreeView.propTypes = { - items: PropTypes.arrayOf(PropTypes.object), - asLinks: PropTypes.bool, - hrefKey: PropTypes.string, - textKey: PropTypes.string, -}; - -TreeView.defaultProps = { - items: null, - asLinks: false, - hrefKey: null, - textKey: null -}; - -export default TreeView; diff --git a/client/src/components/TreeView/index.tsx b/client/src/components/TreeView/index.tsx new file mode 100644 index 00000000..222736b8 --- /dev/null +++ b/client/src/components/TreeView/index.tsx @@ -0,0 +1,128 @@ +import React, { useState } from "react"; +import { Icon, List } from "semantic-ui-react"; +import { TableOfContents } from "../../types"; + +interface TreeViewProps { + items: TableOfContents[] | null; + asLinks: boolean; + hrefKey: string | null; + textKey: string | null; +} + +interface TreeNodeProps extends Omit { + parentKey: number; + item: TableOfContents; +} + +/** + * Displays items in a "tree" view, with nested and expandable lists. + */ +const TreeNode: React.FC = ({ + parentKey, + item, + asLinks, + hrefKey, + textKey, +}) => { + const [expanded, setExpanded] = useState(false); + const hasChildren = Array.isArray(item.children) && item.children.length > 0; + + let display = null; + let styleObj = {}; + //if (item.color) styleObj = { color: item.color }; + if (asLinks && hrefKey && textKey) { + // if (typeof(item.metaLink) === 'object') { + // display = ( + // + // {item[textKey]}{item.metaLink.text} + // + // ) + // } else if (typeof(item.meta) === 'object') { + // display = {item[textKey]}{item.meta.text} + // } else { + display = ( + + + {/** @ts-ignore */} + {item[textKey]} + + + ); + } else { + display = {item.title}; + } + + return ( + + setExpanded(!expanded)} + className={hasChildren ? "cursor-pointer" : "!align-middle"} + /> + + {display} + {hasChildren && expanded && ( + + {item.children.map((subItem, idx) => { + return ( + + ); + })} + + )} + + + ); +}; + +const TreeView: React.FC = ({ + items, + asLinks, + hrefKey, + textKey, +}) => { + if (!Array.isArray(items)) return ; + if ( + (asLinks === true && + typeof hrefKey === "string" && + typeof textKey === "string") || + asLinks === false + ) { + return ( + + {items.map((item, idx) => { + return ( + + ); + })} + + ); + } + + return ; +}; + +export default TreeView; diff --git a/client/src/components/projects/ProjectModules/TextbookStructure/EditMetadataModal.tsx b/client/src/components/projects/ProjectModules/TextbookStructure/EditMetadataModal.tsx new file mode 100644 index 00000000..6fc6ca38 --- /dev/null +++ b/client/src/components/projects/ProjectModules/TextbookStructure/EditMetadataModal.tsx @@ -0,0 +1,318 @@ +import { + Button, + Divider, + Dropdown, + Form, + Icon, + Label, + Loader, + Modal, + Segment, +} from "semantic-ui-react"; +import { useModals } from "../../../../context/ModalContext"; +import { Controller, useForm } from "react-hook-form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import api from "../../../../api"; +import { GenericKeyTextValueObj, PageTag } from "../../../../types"; +import CtlTextArea from "../../../ControlledInputs/CtlTextArea"; +import { useEffect, useMemo, useState } from "react"; +import useGlobalError from "../../../error/ErrorHooks"; +import LoadingSpinner from "../../../LoadingSpinner"; +import { useNotifications } from "../../../../context/NotificationContext"; + +type PageMetadata = { + summary: string; + tags: GenericKeyTextValueObj[]; +}; + +interface EditMetadataModalProps { + library: string; + pageID: string; + title: string; +} + +const EditMetadataModal: React.FC = ({ + library, + pageID, + title, +}) => { + const queryClient = useQueryClient(); + const { handleGlobalError } = useGlobalError(); + const [showSummaryAI, setShowSummaryAI] = useState(false); + const [showTagsAI, setShowTagsAI] = useState(false); + const { closeAllModals } = useModals(); + const { addNotification } = useNotifications(); + const { control, setValue, getValues, watch } = useForm({ + defaultValues: { + summary: "", + tags: [], + }, + }); + + const { data, isLoading } = useQuery({ + queryKey: ["page-details", library, pageID], + queryFn: async () => { + const res = await api.getPageDetails(`${library}-${pageID}`); + + const mappedTags = res.data.tags.map((tag) => ({ + key: tag["@id"], + text: tag.title, + value: tag.title, + })); + return { + summary: res.data.overview, + tags: mappedTags, + }; + }, + refetchOnMount: false, + refetchOnWindowFocus: false, + enabled: !!library && !!pageID, + }); + + const { + data: aiSummary, + isLoading: aiSummaryLoading, + refetch: refetchAISummary, + } = useQuery<{ summary: string }>({ + queryKey: ["ai-summary", library, pageID], + queryFn: async () => { + const res = await api.getPageAISummary(`${library}-${pageID}`); + return res.data; + }, + refetchOnMount: false, + refetchOnWindowFocus: false, + enabled: showSummaryAI, + }); + + const { + data: aiTags, + isLoading: aiTagsLoading, + refetch: refetchAITags, + } = useQuery<{ tags: string[] }>({ + queryKey: ["ai-tags", library, pageID], + queryFn: async () => { + const res = await api.getPageAITags(`${library}-${pageID}`); + return res.data; + }, + refetchOnMount: false, + refetchOnWindowFocus: false, + enabled: showTagsAI, + }); + + const updatePageMutation = useMutation({ + mutationFn: async (data: PageMetadata) => { + const tags = data.tags.map((tag) => tag.value); + await api.updatePageDetails(`${library}-${pageID}`, { + summary: data.summary, + tags, + }); + }, + onSettled: () => { + queryClient.invalidateQueries(["page-details", library, pageID]); // Refresh the page details + addNotification({ + type: "success", + message: "Page metadata updated successfully", + }); + closeAllModals(); + }, + onError: (error) => { + handleGlobalError(error); + }, + }); + + useEffect(() => { + if (!data) return; + setValue("summary", data.summary); + setValue("tags", data.tags); + }, [data]); + + const handleInsertSummary = () => { + if (!aiSummary?.summary) return; + setValue("summary", aiSummary?.summary); + setShowSummaryAI(false); + }; + + const handleInsertTag = (tag: string) => { + setValue("tags", [ + ...getValues("tags"), + { + key: tag, + text: tag, + value: tag, + }, + ]); + }; + + const displayTags = useMemo(() => { + if (!aiTags) return []; + + // Remove tags that are already in the list + return aiTags?.tags.filter( + (tag) => !getValues("tags").some((t) => t.value === tag) + ); + }, [aiTags, watch("tags")]); + + return ( + + Edit Page Metadata: {title} + + {isLoading && ( +
+ +
+ )} + {!isLoading && ( + <> +

Summary:

+ + {showSummaryAI && ( + +

{aiSummary?.summary}

+
+ + + {aiSummary?.summary && ( + + )} +
+
+ )} + {!showSummaryAI && ( + + )} + +

Tags:

+ + + ( + { + field.onChange(value as string); + }} + value={field.value.map((tag) => tag.value)} + fluid + selection + multiple + loading={isLoading} + renderLabel={(tag) => ({ + color: "blue", + content: tag.text, + onRemove: (e: any, data: any) => { + e.stopPropagation(); + + // remove only the tag that was clicked + field.onChange( + field.value.filter((t) => t.value !== data.value) + ); + }, + })} + disabled={isLoading || updatePageMutation.isLoading} + /> + )} + name="tags" + control={control} + /> + + {showTagsAI && ( + +
+ {displayTags.map((tag) => ( + + ))} +
+
+ + +
+
+ )} + {!showTagsAI && ( + + )} + + )} +
+ + + + +
+ ); +}; + +export default EditMetadataModal; diff --git a/client/src/components/projects/ProjectModules/TextbookStructure/index.tsx b/client/src/components/projects/ProjectModules/TextbookStructure/index.tsx new file mode 100644 index 00000000..10c33e0c --- /dev/null +++ b/client/src/components/projects/ProjectModules/TextbookStructure/index.tsx @@ -0,0 +1,152 @@ +import { + List, + Grid, + Header, + Segment, + Search, + Button, + Icon, + Popup, + Image, + Label, +} from "semantic-ui-react"; +import Breakpoint from "../../../util/Breakpoints"; +import { Link } from "react-router-dom-v5-compat"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import api from "../../../../api"; +import TreeView from "../../../TreeView"; +import { TableOfContents } from "../../../../types"; +import { useModals } from "../../../../context/ModalContext"; +import EditMetadataModal from "./EditMetadataModal"; + +type WithUIState = TableOfContents & { expanded: boolean }; + +interface TextbookStructureProps { + projectID: string; + libreLibrary?: string; + libreCoverID?: string; +} + +const TextbookStructure: React.FC = ({ + projectID, + libreLibrary, + libreCoverID, +}) => { + const { openModal, closeAllModals } = useModals(); + const queryClient = useQueryClient(); + const { data, isLoading } = useQuery({ + queryKey: ["textbook-structure", projectID], + queryFn: async () => { + const res = await api.getBookTOC(`${libreLibrary}-${libreCoverID}`); + const content = res.data?.toc?.children; // Skip the first level of the TOC + const withUIState = content.map((item) => { + return { ...item, expanded: false }; + }); + + return withUIState; + }, + refetchOnMount: false, + refetchOnWindowFocus: false, + retry: 2, + enabled: !!libreLibrary && !!libreCoverID, + }); + + const handleToggle = (id: string) => { + const updatedData = data?.map((item) => { + if (item.id === id) { + return { ...item, expanded: !item.expanded }; + } + return item; + }); + + queryClient.setQueryData(["textbook-structure", projectID], updatedData); + }; + + const handleOpenEditModal = (library: string, pageID: string, title:string) => { + openModal(); + }; + + return ( + +
+ Textbook Structure (Beta) +
+ + + {data ? ( + + {data.map((item, idx) => { + return ( + + + + { + e.preventDefault(); + handleToggle(item.id); + }} + /> + {item.title} + + {item.children && item.expanded && ( +
+ + {item.children.map((subItem, idx) => { + return ( + + + + + {subItem.title} + +
+ +
+
+
+
+ ); + })} +
+
+ )} +
+
+ ); + })} +
+ ) : ( +
+

+ No content available. +

+
+ )} +
+
+
+ ); +}; + +export default TextbookStructure; diff --git a/client/src/components/projects/ProjectView.jsx b/client/src/components/projects/ProjectView.jsx index fa10f199..c61526bb 100644 --- a/client/src/components/projects/ProjectView.jsx +++ b/client/src/components/projects/ProjectView.jsx @@ -1899,6 +1899,8 @@ const ProjectView = (props) => { loadingTasks={loadingTasks} defaultNotificationSetting={project.defaultChatNotification} mngTaskLoading={mngTaskLoading} + libreLibrary={project.libreLibrary} + libreCoverID={project.libreCoverID} /> diff --git a/client/src/components/projects/RenderProjectModules.tsx b/client/src/components/projects/RenderProjectModules.tsx index 280e31f3..8e38985a 100644 --- a/client/src/components/projects/RenderProjectModules.tsx +++ b/client/src/components/projects/RenderProjectModules.tsx @@ -16,6 +16,8 @@ import FilesManager from "../FilesManager"; import { Link } from "react-router-dom"; import Breakpoint from "../util/Breakpoints"; import { DEFAULT_PROJECT_MODULES } from "../../utils/projectHelpers"; +import { useTypedSelector } from "../../state/hooks"; +import TextbookStructure from "./ProjectModules/TextbookStructure"; interface RenderProjectModulesProps { projectID: string; @@ -44,6 +46,8 @@ interface RenderProjectModulesProps { loadingTasks: boolean; defaultNotificationSetting?: string; mngTaskLoading: boolean; + libreLibrary?: string; + libreCoverID?: string; } const RenderProjectModules: React.FC = ({ @@ -69,7 +73,10 @@ const RenderProjectModules: React.FC = ({ loadingTasks, defaultNotificationSetting, mngTaskLoading, + libreLibrary, + libreCoverID, }) => { + const userState = useTypedSelector((state) => state.user); const [showDiscussion, setShowDiscussion] = useState(true); const [showFiles, setShowFiles] = useState(true); @@ -625,6 +632,14 @@ const RenderProjectModules: React.FC = ({ ); }); + if(userState.isSuperAdmin && libreLibrary && libreCoverID) { + modules.push( + + + + ); + } + return <>{modules}; }, [projectID, project, DiscussionModule, FilesModule, TasksModule]); diff --git a/client/src/screens/commons/Book/index.tsx b/client/src/screens/commons/Book/index.tsx index 9b0099ed..ee0b4dda 100644 --- a/client/src/screens/commons/Book/index.tsx +++ b/client/src/screens/commons/Book/index.tsx @@ -40,6 +40,7 @@ import { LicenseReportText, PeerReview as PeerReviewType, ProjectFile, + TableOfContents, } from "../../../types"; import { isLicenseReport } from "../../../utils/typeHelpers"; import { useQuery } from "@tanstack/react-query"; @@ -104,7 +105,6 @@ const CommonsBook = () => { // General UI const [showAdoptionReport, setShowAdoptionReport] = useState(false); const [loadedData, setLoadedData] = useState(false); - const [loadedTOC, setLoadedTOC] = useState(false); const [loadedLicensing, setLoadedLicensing] = useState(false); const [showFiles, setShowFiles] = useState(true); // show files by default const [showTOC, setShowTOC] = useState(false); @@ -135,7 +135,14 @@ const CommonsBook = () => { >([]); // TOC - const [bookTOC, setBookTOC] = useState([]); + const { data: bookTOC, isLoading: loadingTOC } = useQuery({ + queryKey: ["book-toc", bookID], + queryFn: async () => { + const res = await api.getBookTOC(bookID); + return res.data?.toc?.children // skip first level + }, + enabled: !!bookID, + }) // Licensing Report const [foundCLR, setFoundCLR] = useState(false); @@ -188,29 +195,6 @@ const CommonsBook = () => { }, ]; - /** - * Load the Book's Table of Contents from the server and save to state. - */ - const getTOC = useCallback(async () => { - try { - const tocRes = await axios.get(`/commons/book/${bookID}/toc`); - if (tocRes.data.err) { - throw new Error(tocRes.data.err); - } - - if (typeof tocRes.data.toc !== "object") { - throw new Error("Error parsing server data."); - } - if (Array.isArray(tocRes.data.toc.children)) { - // skip first level - setBookTOC(tocRes.data.toc.children); - } - } catch (e) { - handleGlobalError(e); - } - setLoadedTOC(true); - }, [bookID, setBookTOC, setLoadedTOC, handleGlobalError]); - /** * Load the Licensing Report from the server and, if found, compute * the information to display in the pie chart. @@ -379,14 +363,12 @@ const CommonsBook = () => { setPRAllow(true); setPRProjectID(bookData.projectID); } - getTOC(); } catch (e) { handleGlobalError(e); } setLoadedData(true); }, [ bookID, - getTOC, setBook, setPRAllow, setPRProjectID, @@ -1147,7 +1129,7 @@ const CommonsBook = () => { )} {showTOC ? ( - +

Table of Contents

@@ -1162,7 +1144,7 @@ const CommonsBook = () => {
- {bookTOC.length > 0 ? ( + {bookTOC && bookTOC.length > 0 ? ( (response: Promise>): Promise { + return (await response).data; +} \ No newline at end of file diff --git a/server/api.js b/server/api.js index a356977d..9a48b6bd 100644 --- a/server/api.js +++ b/server/api.js @@ -656,6 +656,36 @@ router.route('/commons/book/:bookID/peerreviews').get( booksAPI.getBookPeerReviews, ); +router.route('/commons/pages/:pageID').get( + middleware.validateZod(BookValidators.getWithPageIDParamSchema), + authAPI.verifyRequest, + authAPI.getUserAttributes, + authAPI.checkHasRoleMiddleware('libretexts', 'superadmin'), + booksAPI.getPageDetail, +).patch( + middleware.validateZod(BookValidators.updatePageDetailsSchema), + authAPI.verifyRequest, + authAPI.getUserAttributes, + authAPI.checkHasRoleMiddleware('libretexts', 'superadmin'), + booksAPI.updatePageDetails, +) + +router.route('/commons/pages/:pageID/ai-summary').get( + middleware.validateZod(BookValidators.getWithPageIDParamSchema), + authAPI.verifyRequest, + authAPI.getUserAttributes, + authAPI.checkHasRoleMiddleware('libretexts', 'superadmin'), + booksAPI.getPageAISummary, +) + +router.route('/commons/pages/:pageID/ai-tags').get( + middleware.validateZod(BookValidators.getWithPageIDParamSchema), + authAPI.verifyRequest, + authAPI.getUserAttributes, + authAPI.checkHasRoleMiddleware('libretexts', 'superadmin'), + booksAPI.getPageAITags, +) + router.route('/commons/filters').get(booksAPI.getCatalogFilterOptions); router.route('/commons/catalogs/addresource').put( diff --git a/server/api/books.ts b/server/api/books.ts index d8e7d5b0..aa637f57 100644 --- a/server/api/books.ts +++ b/server/api/books.ts @@ -75,7 +75,10 @@ import { getWithBookIDBodySchema, getBookFilesSchema, downloadBookFileSchema, + getWithPageIDParamSchema, + updatePageDetailsSchema, } from "../validators/book.js"; +import * as cheerio from "cheerio"; const BOOK_PROJECTION: Partial> = { _id: 0, @@ -2015,6 +2018,321 @@ async function getLicenseReport( } } +async function getPageDetail( + req: z.infer, + res: Response +) { + try { + const { pageID } = req.params; + + const [subdomain, coverID] = getLibraryAndPageFromBookID(pageID); + if(!subdomain || !coverID) { + return res.status(400).send({ + err: true, + errMsg: conductorErrors.err2, + }); + } + + const pagePropertiesRes = await CXOneFetch({ + scope: "page", + path: parseInt(coverID), + api: MindTouch.API.Page.GET_Page_Properties, + subdomain, + }) + .catch((err) => { + console.error(err) + throw new Error(`Error fetching page details: ${err}`); + }); + + if (!pagePropertiesRes.ok) { + throw new Error(`Error fetching page details: ${pagePropertiesRes.statusText}`); + } + + const pageProperties = await pagePropertiesRes.json(); + const overviewProperty = pageProperties.property?.find((prop: any) => prop['@name'] === MindTouch.PageProps.PageOverview); + const overviewText = overviewProperty?.contents?.['#text'] || ''; + + const pageTagsRes = await CXOneFetch({ + scope: "page", + path: parseInt(coverID), + api: MindTouch.API.Page.GET_Page_Tags, + subdomain, + }).catch((err) => { + console.error(err) + throw new Error(`Error fetching page tags: ${err}`); + }); + + if (!pageTagsRes.ok) { + throw new Error(`Error fetching page tags: ${pageTagsRes.statusText}`); + } + + const pageTagsData = await pageTagsRes.json(); + const pageTags = pageTagsData.tag || []; + + return res.send({ + err: false, + overview: overviewText, + tags: pageTags, + }); + } catch (e) { + debugError(e); + return res.status(500).send({ + err: true, + errMsg: conductorErrors.err6, + }); + } +} + +async function getPageAISummary( + req: z.infer, + res: Response +) { + try { + const { pageID } = req.params; + + const [subdomain, parsedPageID] = getLibraryAndPageFromBookID(pageID); + if(!subdomain || !parsedPageID) { + return res.status(400).send({ + err: true, + errMsg: conductorErrors.err2, + }); + } + + // Ensure OpenAI API key is set + if(!process.env.OPENAI_API_KEY) { + return res.status(500).send({ + err: true, + errMsg: conductorErrors.err6, + }); + } + + const pageText = await _getPageTextContent(subdomain, parsedPageID); + + const aiSummaryRes = await axios.post('https://api.openai.com/v1/chat/completions', { + model: 'gpt-4o', + messages: [ + { + role: 'system', + content: 'Generate a summary of this page. Disregard any code blocks or images.', + }, + { + role: 'user', + content: pageText, + } + ] + }, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}` + } + }) + + const aiSummaryOutput = aiSummaryRes.data?.choices?.[0]?.message?.content || ''; + if(!aiSummaryOutput) { + return res.status(400).send({ + err: true, + errMsg: 'Error generating page summary.', + }); + }; + + return res.send({ + err: false, + summary: aiSummaryOutput, + }); + } catch (e) { + debugError(e); + return res.status(500).send({ + err: true, + errMsg: conductorErrors.err6, + }); + } +} + +async function getPageAITags( + req: z.infer, + res: Response +) { + try { + const { pageID } = req.params; + + const [subdomain, parsedPageID] = getLibraryAndPageFromBookID(pageID); + if(!subdomain || !parsedPageID) { + return res.status(400).send({ + err: true, + errMsg: conductorErrors.err2, + }); + } + + const pageText = await _getPageTextContent(subdomain, parsedPageID); + + const aiTagsRes = await axios.post('https://api.openai.com/v1/chat/completions', { + model: 'gpt-4o', + messages: [ + { + role: 'system', + content: 'Generate a list of tags, seperated by commas, for this page. Disregard any code blocks or images.', + }, + { + role: 'user', + content: pageText, + } + ] + }, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}` + } + }) + + const aiTagsOutput = aiTagsRes.data?.choices?.[0]?.message?.content || ''; + if(!aiTagsOutput) { + return res.status(400).send({ + err: true, + errMsg: 'Error generating page summary.', + }); + }; + + const splitTags = aiTagsOutput.split(',').map((tag: string) => tag.trim()) || []; + + // if tags end with a period, remove it + if(splitTags.length > 0 && splitTags[splitTags.length - 1].endsWith('.')) { + splitTags[splitTags.length - 1] = splitTags[splitTags.length - 1].slice(0, -1); + } + + return res.send({ + err: false, + tags: splitTags + }); + } catch (e) { + debugError(e); + return res.status(500).send({ + err: true, + errMsg: conductorErrors.err6, + }); + } +} + +async function _getPageTextContent(subdomain: string, pageID: string): Promise { + const pageContentsRes = await CXOneFetch({ + scope: "page", + path: parseInt(pageID), + api: MindTouch.API.Page.GET_Page_Contents, + subdomain, + }) + .catch((err) => { + console.error(err) + throw new Error(`Error fetching page details: ${err}`); + }); + + if (!pageContentsRes.ok) { + throw new Error(`Error fetching page details: ${pageContentsRes.statusText}`); + } + + const pageContent = await pageContentsRes.json(); + const pageRawBody = pageContent.body?.[0]; + if(!pageRawBody) { + return res.send({ + err: false, + summary: '', + }); + } + + const cheerioObj = cheerio.load(pageRawBody); + const pageText = cheerioObj.text(); // Extract text from HTML + + return pageText; +} + +async function updatePageDetails( + req: z.infer, + res: Response +){ + try { + const { pageID } = req.params; + const { summary, tags } = req.body; + + const [subdomain, parsedPageID] = getLibraryAndPageFromBookID(pageID); + if(!subdomain || !parsedPageID) { + return res.status(400).send({ + err: true, + errMsg: conductorErrors.err2, + }); + } + + // Get current page properties and find the overview property + const pagePropertiesRes = await CXOneFetch({ + scope: "page", + path: parseInt(parsedPageID), + api: MindTouch.API.Page.GET_Page_Properties, + subdomain, + }) + .catch((err) => { + console.error(err) + throw new Error(`Error fetching page details: ${err}`); + }); + + if (!pagePropertiesRes.ok) { + throw new Error(`Error fetching page details: ${pagePropertiesRes.statusText}`); + } + + const pageProperties = await pagePropertiesRes.json(); + const overviewProperty = pageProperties.property?.find((prop: any) => prop['@name'] === MindTouch.PageProps.PageOverview); + if(!overviewProperty) { + throw new Error('Error fetching page details.'); + } + + // Update page overview property + const updatedOverviewRes = await CXOneFetch({ + scope: "page", + path: parseInt(parsedPageID), + api: MindTouch.API.Page.PUT_Page_Property(MindTouch.PageProps.PageOverview), + subdomain, + options: { + method: "PUT", + headers: { + 'Content-Type': 'text/plain', + 'Etag': overviewProperty['@etag'], + }, + body: summary, + }, + }) + + if (!updatedOverviewRes.ok) { + throw new Error(`Error updating page details: ${updatedOverviewRes.statusText}`); + } + + // Update the page tags + const updatedTagsRes = await CXOneFetch({ + scope: "page", + path: parseInt(parsedPageID), + api: MindTouch.API.Page.PUT_Page_Tags, + subdomain, + options: { + method: "PUT", + headers: { + 'Content-Type': 'application/xml', + }, + body: MindTouch.Templates.PUT_PageTags(tags), + }, + }) + + if (!updatedTagsRes.ok) { + throw new Error(`Error updating page tags: ${updatedTagsRes.statusText}`); + } + + return res.send({ + err: false, + msg: 'Page details updated successfully.', + }); + } catch (err) { + debugError(err); + return res.status(500).send({ + err: true, + errMsg: conductorErrors.err6, + }); + } +} + /** * Generates a JSON file containing Commons Books listings for use by 3rd parties. * @returns {boolean} True if export creation succeeded, false otherwise. @@ -2170,5 +2488,9 @@ export default { getBookSummary, getBookTOC, getLicenseReport, + getPageDetail, + getPageAISummary, + getPageAITags, + updatePageDetails, retrieveKBExport, }; diff --git a/server/api/validators/book.ts b/server/api/validators/book.ts index f3f9ab3f..333f8202 100644 --- a/server/api/validators/book.ts +++ b/server/api/validators/book.ts @@ -1,6 +1,6 @@ -import { z } from 'zod'; -import { checkBookIDFormat } from '../../util/bookutils.js'; -import conductorErrors from '../../conductor-errors.js'; +import { z } from "zod"; +import { checkBookIDFormat } from "../../util/bookutils.js"; +import conductorErrors from "../../conductor-errors.js"; // Book ID format: library-pageid (e.g. "chem-123") export const bookIDSchema = z.string().regex(/^[a-zA-Z]{2,12}-\d{1,12}$/, { @@ -20,18 +20,18 @@ export const getCommonsCatalogSchema = z.object({ activePage: z.coerce.number().min(1).default(1), limit: z.coerce.number().min(1).default(10), sort: z - .union([z.literal('title'), z.literal('author'), z.literal('random')]) + .union([z.literal("title"), z.literal("author"), z.literal("random")]) .optional() - .default('title'), + .default("title"), }), }); export const getMasterCatalogSchema = z.object({ query: z.object({ sort: z - .union([z.literal('title'), z.literal('author'), z.literal('random')]) + .union([z.literal("title"), z.literal("author"), z.literal("random")]) .optional() - .default('title'), + .default("title"), search: z.string().min(1).optional(), }), }); @@ -76,5 +76,26 @@ export const deleteBookSchema = z.intersection( deleteProject: z.coerce.boolean().optional(), }), }), - getWithBookIDParamSchema, + getWithBookIDParamSchema ); + +// Uses the same ID format as getWithBookIDParamSchema +export const getWithPageIDParamSchema = z.object({ + params: z.object({ + pageID: z.string().refine(checkBookIDFormat, { + message: conductorErrors.err1, + }), + }), +}); + +export const updatePageDetailsSchema = z.object({ + params: z.object({ + pageID: z.string().refine(checkBookIDFormat, { + message: conductorErrors.err1, + }), + }), + body: z.object({ + summary: z.string().optional(), + tags: z.array(z.string()).max(100).optional(), + }), +}); diff --git a/server/package-lock.json b/server/package-lock.json index 9f421d7f..227fe8ab 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -21,6 +21,7 @@ "bcryptjs": "^2.4.3", "bluebird": "^3.7.2", "body-parser": "^1.19.0", + "cheerio": "^1.0.0", "cookie-parser": "^1.4.5", "cors": "^2.8.5", "crypto-random-string": "^5.0.0", @@ -4520,6 +4521,11 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, "node_modules/bowser": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", @@ -4666,6 +4672,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cheerio": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", + "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "encoding-sniffer": "^0.2.0", + "htmlparser2": "^9.1.0", + "parse5": "^7.1.2", + "parse5-htmlparser2-tree-adapter": "^7.0.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^6.19.5", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=18.17" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -4968,6 +5014,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css-tree": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", @@ -4980,6 +5041,17 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/cssstyle": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.0.1.tgz", @@ -5192,11 +5264,62 @@ "csstype": "^3.0.2" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, "node_modules/dompurify": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.6.tgz", "integrity": "sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==" }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", @@ -5253,6 +5376,29 @@ "node": ">= 0.8" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", + "integrity": "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -6394,6 +6540,24 @@ "node": ">=18" } }, + "node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -7541,6 +7705,17 @@ "node": ">=0.10.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7755,6 +7930,29 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "dependencies": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -9187,6 +9385,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.20.0.tgz", + "integrity": "sha512-AITZfPuxubm31Sx0vr8bteSalEbs9wQb/BOBi9FPlD9Qpd6HxZ4Q0+hI742jBhkPb4RT2v5MQzaW5VhRVyj+9A==", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", diff --git a/server/package.json b/server/package.json index b576099f..9b439097 100644 --- a/server/package.json +++ b/server/package.json @@ -24,6 +24,7 @@ "bcryptjs": "^2.4.3", "bluebird": "^3.7.2", "body-parser": "^1.19.0", + "cheerio": "^1.0.0", "cookie-parser": "^1.4.5", "cors": "^2.8.5", "crypto-random-string": "^5.0.0", diff --git a/server/util/CXOne/CXOnePageAPIEndpoints.ts b/server/util/CXOne/CXOnePageAPIEndpoints.ts index a50afdd8..8ce6bede 100644 --- a/server/util/CXOne/CXOnePageAPIEndpoints.ts +++ b/server/util/CXOne/CXOnePageAPIEndpoints.ts @@ -1,12 +1,20 @@ const DREAM_OUT_FORMAT = "dream.out.format=json"; const CXOnePageAPIEndpoints = { + GET_Page: `?${DREAM_OUT_FORMAT}`, + GET_Page_Contents: `contents?${DREAM_OUT_FORMAT}`, GET_Page_Info: `info?${DREAM_OUT_FORMAT}`, + GET_Page_Properties: `properties?${DREAM_OUT_FORMAT}`, + GET_Subpages: `subpages?${DREAM_OUT_FORMAT}`, + GET_Page_Tags: `tags?${DREAM_OUT_FORMAT}`, POST_Contents_Title: (title: string) => `contents?title=${encodeURIComponent(title)}&${DREAM_OUT_FORMAT}`, POST_Properties: `properties?${DREAM_OUT_FORMAT}`, POST_Security: `security?${DREAM_OUT_FORMAT}`, PUT_File_Default_Thumbnail: "files/=mindtouch.page%2523thumbnail", + PUT_Page_Property: (property: string) => + `properties/${encodeURIComponent(property)}?${DREAM_OUT_FORMAT}`, + PUT_Page_Tags: `tags?${DREAM_OUT_FORMAT}`, PUT_Security: `security?${DREAM_OUT_FORMAT}`, }; diff --git a/server/util/CXOne/CXOnePageProperties.ts b/server/util/CXOne/CXOnePageProperties.ts index f49241a3..899f7f16 100644 --- a/server/util/CXOne/CXOnePageProperties.ts +++ b/server/util/CXOne/CXOnePageProperties.ts @@ -1,4 +1,5 @@ const CXOnePageProperties = { + PageOverview: 'mindtouch.page#overview', WelcomeHidden: 'mindtouch.page#welcomeHidden', SubPageListing: 'mindtouch.idf#subpageListing', GuideDisplay: 'mindtouch.idf#guideDisplay', diff --git a/server/util/CXOne/CXOneTemplates.ts b/server/util/CXOne/CXOneTemplates.ts index 9ed34382..48cd9313 100644 --- a/server/util/CXOne/CXOneTemplates.ts +++ b/server/util/CXOne/CXOneTemplates.ts @@ -52,6 +52,9 @@ const CXOneTemplates = { "templatePath": "MindTouch/IDF3/Views/Topic_hierarchy", "guid": "fc488b5c-f7e1-1cad-1a9a-343d5c8641f5" }]`, + PUT_PageTags: (tags: string[]) => ` + ${tags.map((tag) => ``).join("")} + `, PUT_SetSemiPrivatePermissions: (userID: string, devGroupID?: string) => ` diff --git a/server/util/librariesclient.ts b/server/util/librariesclient.ts index 2f57fd5a..4e84e239 100644 --- a/server/util/librariesclient.ts +++ b/server/util/librariesclient.ts @@ -185,6 +185,7 @@ export async function CXOneFetch(params: CXOneFetchParams): Promise { const result = await request; if (!result.ok && !silentFail) { + console.log(result.url) // Log failed URL for debugging throw new Error( `Error fetching ${ params.scope === "groups"