From dc487e0e90403e968342eb01cb7b128b3c350130 Mon Sep 17 00:00:00 2001
From: yghaemi
Date: Tue, 3 Mar 2026 14:12:29 -0500
Subject: [PATCH 1/8] feat: scraper UI and API and util
---
.../projects/ImportWorkbenchModal.tsx | 350 ++++++++++++++++++
server/util/pressbookutils.ts | 111 ++++++
2 files changed, 461 insertions(+)
create mode 100644 client/src/components/projects/ImportWorkbenchModal.tsx
create mode 100644 server/util/pressbookutils.ts
diff --git a/client/src/components/projects/ImportWorkbenchModal.tsx b/client/src/components/projects/ImportWorkbenchModal.tsx
new file mode 100644
index 00000000..2f5b3b01
--- /dev/null
+++ b/client/src/components/projects/ImportWorkbenchModal.tsx
@@ -0,0 +1,350 @@
+import React, { useEffect, useState } from "react";
+import {
+ Button,
+ Dropdown,
+ Form,
+ Icon,
+ Message,
+ Modal,
+ ModalProps,
+} from "semantic-ui-react";
+import { Controller, get, useForm } from "react-hook-form";
+import { required } from "../../utils/formRules";
+import { CentralIdentityApp } from "../../types";
+import CtlTextInput from "../ControlledInputs/CtlTextInput";
+import { useTypedSelector } from "../../state/hooks";
+import useGlobalError from "../error/ErrorHooks";
+import axios from "axios";
+import api from "../../api";
+import { getCentralAuthInstructorURL } from "../../utils/centralIdentityHelpers";
+import TeamAccessWarningModal from "./TeamAccessWarningModal";
+
+interface ImportWorkbenchModalProps extends ModalProps {
+ show: boolean;
+ projectID: string;
+ projectTitle: string;
+ project: any;
+ onClose: () => void;
+ onSuccess: () => void;
+}
+interface ImportWorkbenchForm {
+ library: number | string;
+ pbBookURL: string;
+ title: string;
+
+}
+
+interface TeamMemberWithoutAccess {
+ uuid: string;
+ firstName: string;
+ lastName: string;
+ avatar: string;
+}
+
+const ImportWorkbenchModal: React.FC = (props) => {
+ const {
+ show,
+ projectID,
+ projectTitle,
+ project,
+ onClose,
+ onSuccess,
+ ...rest
+ } = props;
+ const teamMembers = [
+ ...(project?.auditors || []),
+ ...(project?.leads || []),
+ ...(project?.liaisons || []),
+ ...(project?.members || []),
+ ].map((member) => {
+ return {
+ uuid: member.uuid,
+ firstName: member.firstName,
+ lastName: member.lastName,
+ avatar: member.avatar,
+ };
+ });
+
+ const { handleGlobalError } = useGlobalError();
+ const user = useTypedSelector((state) => state.user);
+ const { control, getValues, setValue, reset, trigger, formState, watch } =
+ useForm({
+ defaultValues: {
+ library: "",
+ pbBookURL: "",
+ },
+ });
+ const [loading, setLoading] = useState(false);
+ const [libraryOptsLoading, setLibraryOptsLoading] = useState(false);
+ const [libraryOptions, setLibraryOptions] = useState(
+ [],
+ );
+ const [showAccessWarning, setShowAccessWarning] = useState(false);
+ const [membersWithoutAccess, setMembersWithoutAccess] = useState<
+ TeamMemberWithoutAccess[]
+ >([]);
+ const [selectedLibraryName, setSelectedLibraryName] = useState("");
+ const [canAccessLibrary, setCanAccessLibrary] = useState(true);
+
+ const selectedLibrary = watch("library");
+
+ useEffect(() => {
+ if (show) {
+ reset(); // reset form on open
+ loadLibraries();
+ setValue("pbBookURL", projectTitle);
+ }
+ }, [show]);
+
+ async function loadLibraries() {
+ try {
+ setLibraryOptsLoading(true);
+ const res = await axios.get("/central-identity/public/apps");
+ if (res.data.err) {
+ throw new Error(res.data.errMsg);
+ }
+ if (!res.data.applications) throw new Error("No libraries found");
+
+ const libraries = res.data.applications.filter(
+ (a: CentralIdentityApp) => a.app_type === "library",
+ );
+
+ if (!libraries.length) throw new Error("No libraries found");
+ setLibraryOptions(libraries);
+ } catch (err) {
+ handleGlobalError(err);
+ } finally {
+ setLibraryOptsLoading(false);
+ }
+ }
+
+ useEffect(() => {
+ checkLibraryAccess();
+ }, [user, selectedLibrary]);
+
+ useEffect(() => {
+ if (selectedLibrary) {
+ const libraryObj = libraryOptions.find(
+ (lib) => lib.id.toString() === selectedLibrary.toString(),
+ );
+ if (libraryObj) {
+ setSelectedLibraryName(libraryObj.name);
+ }
+ }
+ }, [selectedLibrary, libraryOptions]);
+
+ async function checkLibraryAccess() {
+ try {
+ if (!user.uuid || !getValues("library")) return;
+ const res = await axios.get(
+ `/central-identity/users/${user.uuid}/applications/${getValues(
+ "library",
+ )}`,
+ );
+ if (res.data.err) {
+ throw new Error(res.data.errMsg);
+ }
+ setCanAccessLibrary(res.data.hasAccess ?? false);
+ } catch (err) {
+ handleGlobalError(err);
+ }
+ }
+
+ async function checkTeamMembersAccess() {
+ try {
+ if (!teamMembers.length || !getValues("library")) return [];
+ const ids = teamMembers.map((member) => member.uuid);
+
+ const res = await api.checkTeamLibraryAccess(getValues("library"), ids);
+
+ const withoutAccess = teamMembers.filter(
+ (member) =>
+ !res.data.accessResults.find(
+ (result: any) => result.id === member.uuid,
+ )?.hasAccess,
+ );
+ return withoutAccess;
+ } catch (err) {
+ handleGlobalError(err);
+ return [];
+ }
+ }
+
+ async function createWorkbench() {
+ try {
+ if (!canAccessLibrary) return;
+ setLoading(true);
+ if (!(await trigger())) return;
+ const res = await axios.post("/commons/import-pressbooks", {
+ ...getValues(),
+ projectID,
+ });
+ if (res.data.err) {
+ throw new Error(res.data.errMsg);
+ }
+ onSuccess();
+ } catch (err) {
+ handleGlobalError(err);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleCreateClick() {
+ try {
+ setLoading(true);
+
+ // Check team members' access
+ const membersWithoutAccess = await checkTeamMembersAccess();
+ if (membersWithoutAccess.length > 0) {
+ setMembersWithoutAccess(membersWithoutAccess);
+ setShowAccessWarning(true);
+ setLoading(false);
+ console.error(getValues());
+ } else {
+ // Everyone has access, proceed with creating the workbench
+ await createWorkbench();
+
+ console.log(getValues());
+ }
+ } catch (err) {
+ handleGlobalError(err);
+ setLoading(false);
+ }
+ }
+
+ return (
+ <>
+
+ Import Book
+
+
+ This imports a book from a library into this Conductor project.
+
+
+
+ {!canAccessLibrary && (
+
+ Cannot Access Library
+
+ Oops, it looks like you do not have access to this library. If
+ you need to request access, please submit or update your
+ instructor verification request here:{" "}
+
+ {getCentralAuthInstructorURL()}
+
+
+
+ )}
+
+
+
+ Cancel
+
+
+
+ Create
+
+
+
+ {
+ setShowAccessWarning(false);
+ setLoading(false);
+ }}
+ onCreateWithWarning={() => {
+ setShowAccessWarning(false);
+ createWorkbench();
+ }}
+ />
+ >
+ );
+};
+
+export default ImportWorkbenchModal;
diff --git a/server/util/pressbookutils.ts b/server/util/pressbookutils.ts
new file mode 100644
index 00000000..90bf8170
--- /dev/null
+++ b/server/util/pressbookutils.ts
@@ -0,0 +1,111 @@
+import * as crypto from "crypto";
+import { generateBookPathAndURL } from "./librariesclient";
+import conductorErrors from "../conductor-errors";
+
+export interface MindTouchConfig {
+ hostname: string;
+ apiKey: string;
+ apiSecret: string;
+ user?: string;
+ rootPath?: string;
+}
+
+export interface PublishOptions {
+// mindtouch: MindTouchConfig;
+ auth?: { username: string; password: string };
+}
+
+export interface PublishResult {
+ err: boolean;
+ path: string;
+ url: string;
+ errMsg?: string;
+}
+
+export class PressBookScraper {
+ private pbBookURL: string;
+ private title?: string;
+ private subdomain: string;
+ private pbHeaders: Record;
+
+ constructor(
+ pbBookURL: string,
+ subdomain: string,
+ title?: string,
+ ) {
+ this.pbBookURL = pbBookURL;
+ this.title = title;
+ this.subdomain = subdomain;
+ this.pbHeaders = {
+ "User-Agent": "Scraper/1.0 (educational research)",
+ Accept: "application/json",
+ };
+ }
+
+ private async getJson(
+ url: string,
+ auth?: { username: string; password: string },
+ ): Promise {
+ const headers: Record = { ...this.pbHeaders };
+ if (auth) {
+ headers["Authorization"] =
+ "Basic " +
+ Buffer.from(`${auth.username}:${auth.password}`).toString("base64");
+ }
+ const res = await fetch(url, {
+ headers,
+ signal: AbortSignal.timeout(30_000),
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
+ return res.json();
+ }
+
+ private pbApi(bookUrl: string): string {
+ return bookUrl.replace(/\/+$/, "") + "/wp-json/pressbooks/v2";
+ }
+
+ async publishBook(options: PublishOptions) : Promise {
+ const encodePbURL = this.pbBookURL.replace(/\/+$/, "");
+ const uploadImages = true;
+ const auth = options.auth;
+ const result: PublishResult = {
+ err: false,
+ path: "",
+ url: "",
+ };
+ let metadata: any;
+ try {
+ metadata = await this.getJson(this.pbApi(encodePbURL) + "/metadata", auth);
+ } catch {
+ try {
+ metadata = await this.getJson(encodePbURL + "/wp-json/", auth);
+ } catch {
+ metadata = {};
+ throw new Error(conductorErrors.err8);
+ }
+ }
+ console.log("[*] Fetching TOC...");
+ let toc: any[];
+ try {
+ toc = await this.getJson(this.pbApi(encodePbURL) + "/toc", auth);
+ } catch {
+ toc = [];
+ throw new Error(conductorErrors.err8);
+ }
+
+ const title =
+ this.title ||
+ metadata.name ||
+ metadata.title?.rendered ||
+ new URL(encodePbURL).pathname.replace(/^\/|\/$/g, "") ||
+ "pressbooks_book";
+ const [bookPath, bookURL] = generateBookPathAndURL(this.subdomain, title);
+ console.log("[*] bookPath: ", bookPath);
+ console.log("[*] bookURL: ", bookURL);
+ result.path = bookPath;
+ result.url = bookURL;
+ console.log("[*] result info: ", result);
+ return result;
+
+ }
+}
From afb92c3018a0e51819fc74f07c7c85986c64aeae Mon Sep 17 00:00:00 2001
From: yghaemi
Date: Fri, 6 Mar 2026 08:38:05 -0500
Subject: [PATCH 2/8] feat: pressbooks scraper, import to library
---
.../projects/ImportWorkbenchModal.tsx | 3 +-
.../projects/ProjectLinkButtons.tsx | 24 +-
server/api.js | 8 +
server/api/books.ts | 517 ++++++++++--------
server/api/validators/book.ts | 22 +
server/util/pressbookutils.ts | 424 ++++++++++++--
6 files changed, 739 insertions(+), 259 deletions(-)
diff --git a/client/src/components/projects/ImportWorkbenchModal.tsx b/client/src/components/projects/ImportWorkbenchModal.tsx
index 2f5b3b01..8fc0513e 100644
--- a/client/src/components/projects/ImportWorkbenchModal.tsx
+++ b/client/src/components/projects/ImportWorkbenchModal.tsx
@@ -175,6 +175,7 @@ const ImportWorkbenchModal: React.FC = (props) => {
if (!canAccessLibrary) return;
setLoading(true);
if (!(await trigger())) return;
+
const res = await axios.post("/commons/import-pressbooks", {
...getValues(),
projectID,
@@ -204,8 +205,6 @@ const ImportWorkbenchModal: React.FC = (props) => {
} else {
// Everyone has access, proceed with creating the workbench
await createWorkbench();
-
- console.log(getValues());
}
} catch (err) {
handleGlobalError(err);
diff --git a/client/src/components/projects/ProjectLinkButtons.tsx b/client/src/components/projects/ProjectLinkButtons.tsx
index a1bf2df5..834e3206 100644
--- a/client/src/components/projects/ProjectLinkButtons.tsx
+++ b/client/src/components/projects/ProjectLinkButtons.tsx
@@ -7,6 +7,7 @@ import {
} from "../../utils/projectHelpers";
import { lazy, useState } from "react";
import { ProjectClassification } from "../../types";
+import ImportWorkbenchModal from "./ImportWorkbenchModal";
const CreateWorkbenchModal = lazy(() => import("./CreateWorkbenchModal"));
interface ProjectLinkButtonsProps {
@@ -38,6 +39,8 @@ const ProjectLinkButtons: React.FC = ({
}) => {
const [showCreateWorkbenchModal, setShowCreateWorkbenchModal] =
useState(false);
+ const [showImportWorkbenchModal, setShowImportWorkbenchModal] =
+ useState(false);
const validWorkbench = didCreateWorkbench && libreCoverID && libreLibrary;
return (
@@ -47,7 +50,7 @@ const ProjectLinkButtons: React.FC = ({
{projectClassification === ProjectClassification.MINI_REPO ? null : (
- {!projectLink && !didCreateWorkbench && isProjectMemberOrAdmin && (
+ {!projectLink && !didCreateWorkbench && isProjectMemberOrAdmin && (<>
setShowCreateWorkbenchModal(true)}
@@ -55,6 +58,14 @@ const ProjectLinkButtons: React.FC = ({
Create Book
+
setShowImportWorkbenchModal(true)}
+ >
+
+ Import Book
+
+ >
)}
{(projectLink || validWorkbench) && (
= ({
project={project}
/>
)}
+
+ {projectID && projectTitle && (
+ setShowImportWorkbenchModal(false)}
+ onSuccess={() => window.location.reload()}
+ project={project}
+ />
+ )}
)}
diff --git a/server/api.js b/server/api.js
index 1ab5b313..76dcc419 100644
--- a/server/api.js
+++ b/server/api.js
@@ -1068,6 +1068,14 @@ router
booksAPI.createBook
);
+router
+ .route("/commons/import-pressbooks")
+ .post(
+ authAPI.verifyRequest,
+ middleware.validateZod(BookValidators.importPressBooksBookSchema),
+ booksAPI.importPressBooksBook
+ );
+
router
.route("/commons/book/:bookID")
.get(
diff --git a/server/api/books.ts b/server/api/books.ts
index e0223013..046b3abb 100644
--- a/server/api/books.ts
+++ b/server/api/books.ts
@@ -80,10 +80,12 @@ import {
getWithPageIDParamAndCoverPageIDSchema,
updatePageDetailsSchema,
bulkUpdatePageTagsSchema,
+ importPressBooksBookSchema,
} from "./validators/book.js";
import BookService from "./services/book-service.js";
import { normalizedSort } from "../util/searchutils.js";
import SearchService from "./services/search-service.js";
+import { PressBookScraper } from "../util/pressbookutils.js";
const BOOK_PROJECTION: Partial> = {
_id: 0,
@@ -238,7 +240,7 @@ const autoGenerateCollections = () => {
program: 1,
},
},
- ])
+ ]),
);
}
}
@@ -253,7 +255,7 @@ const autoGenerateCollections = () => {
for (let i = 0, n = allBooksFound.length; i < n; i += 1) {
const currBook = allBooksFound[i];
const collIdx = collections.findIndex(
- (coll) => coll.program === currBook.program
+ (coll) => coll.program === currBook.program,
);
if (collIdx > -1) {
const resourcesById =
@@ -396,7 +398,7 @@ const syncWithLibraries = async (_req: Request, res: Response) => {
if (String(book.link).includes("/Bookshelves/")) {
location = "central";
let baseURL = `https://${extractLibFromID(
- book.zipFilename
+ book.zipFilename,
)}.libretexts.org/Bookshelves/`;
let isolated = String(book.link).replace(baseURL, "");
let splitURL = isolated.split("/");
@@ -408,7 +410,7 @@ const syncWithLibraries = async (_req: Request, res: Response) => {
if (String(book.link).includes("/Courses/")) {
location = "campus";
let baseURL = `https://${extractLibFromID(
- book.zipFilename
+ book.zipFilename,
)}.libretexts.org/Courses/`;
let isolated = String(book.link).replace(baseURL, "");
let splitURL = isolated.split("/");
@@ -441,7 +443,7 @@ const syncWithLibraries = async (_req: Request, res: Response) => {
library: extractLibFromID(book.zipFilename),
thumbnail: genThumbnailLink(
extractLibFromID(book.zipFilename),
- book.id
+ book.id,
),
links: {
online: link,
@@ -545,7 +547,7 @@ const syncWithLibraries = async (_req: Request, res: Response) => {
links: book.links,
lastUpdated: book.lastUpdated,
libraryTags: book.libraryTags,
- randomIndex: hashStringToFloat(book.bookID)
+ randomIndex: hashStringToFloat(book.bookID),
},
},
upsert: true,
@@ -555,7 +557,7 @@ const syncWithLibraries = async (_req: Request, res: Response) => {
existingBooks.forEach((book) => {
/* check if book needs to be deleted */
let foundProcessed = processedBooks.find(
- (processed) => book === processed.bookID
+ (processed) => book === processed.bookID,
);
if (foundProcessed === undefined) {
// book not found in new batch, needs to be deleted
@@ -578,7 +580,7 @@ const syncWithLibraries = async (_req: Request, res: Response) => {
if (writeErr.result?.nMatched > 0) {
// Some imports failed (silent)
debugCommonsSync(
- `Updated only ${writeErr.result.nMatched} books when ${allBooks.length} books were expected.`
+ `Updated only ${writeErr.result.nMatched} books when ${allBooks.length} books were expected.`,
);
return null; // Continue to auto-generate Program Collections
} else {
@@ -680,7 +682,7 @@ const syncWithLibraries = async (_req: Request, res: Response) => {
*/
const runAutomatedSyncWithLibraries = (req: Request, res: Response) => {
debugServer(
- `Received automated request to sync Commons with Libraries ${new Date().toLocaleString()}`
+ `Received automated request to sync Commons with Libraries ${new Date().toLocaleString()}`,
);
return syncWithLibraries(req, res);
};
@@ -693,13 +695,15 @@ const runAutomatedSyncWithLibraries = (req: Request, res: Response) => {
* @param {Object} orgData - An Organization information object.
* @returns {String[]} An array of known Organization names.
*/
-export const buildOrganizationNamesList = (orgData: OrganizationInterface): string[] => {
+export const buildOrganizationNamesList = (
+ orgData: OrganizationInterface,
+): string[] => {
if (!orgData) return [];
const names = new Set();
// Collect base names
- const fields = ['name', 'shortName', 'abbreviation'] as const;
+ const fields = ["name", "shortName", "abbreviation"] as const;
fields.forEach((field) => {
const value = orgData[field];
if (value && !isEmptyString(value)) {
@@ -714,7 +718,7 @@ export const buildOrganizationNamesList = (orgData: OrganizationInterface): stri
// Generate normalized variations
const normalizedVariations = Array.from(names).flatMap((name) => {
- const normalized = String(name).replace(/[,\-:']/g, '');
+ const normalized = String(name).replace(/[,\-:']/g, "");
return [normalized, normalized.toLowerCase()];
});
@@ -733,7 +737,7 @@ export const buildOrganizationNamesList = (orgData: OrganizationInterface): stri
*/
async function getCommonsCatalog(
req: z.input,
- res: Response
+ res: Response,
) {
try {
const orgID = process.env.ORG_ID;
@@ -741,10 +745,10 @@ async function getCommonsCatalog(
const seed = req.query.seed
? parseInt(req.query.seed.toString())
: Math.floor(Math.random() * 1000000);
-
+
const paginationOffset = getPaginationOffset(
(req.query.activePage as number) || 1,
- limit
+ limit,
);
if (isNaN(seed) || seed < 1) {
@@ -766,11 +770,11 @@ async function getCommonsCatalog(
abbreviation: 1,
aliases: 1,
autoCatalogMatchingDisabled: 1,
- }
+ },
).lean(),
CustomCatalog.findOne(
{ orgID },
- { _id: 0, orgID: 1, resources: 1, automaticMatchingExclusions: 1 }
+ { _id: 0, orgID: 1, resources: 1, automaticMatchingExclusions: 1 },
).lean(),
]);
if (!orgData || Object.keys(orgData).length === 0) {
@@ -778,7 +782,7 @@ async function getCommonsCatalog(
}
const campusNames = buildOrganizationNamesList(orgData).map((name) =>
- name.toLowerCase()
+ name.toLowerCase(),
);
const matchObject = {
$and: [
@@ -880,7 +884,9 @@ async function getCommonsCatalog(
},
]);
- const totalCountPromise = Book.countDocuments({ randomIndex: { $ne: null } });
+ const totalCountPromise = Book.countDocuments({
+ randomIndex: { $ne: null },
+ });
const [allBooks, totalCount] = await Promise.all([
allBookPromise,
totalCountPromise,
@@ -893,7 +899,7 @@ async function getCommonsCatalog(
// Check if the associated project has any public or instructor only files
const publicOrInstructorSearch =
await _getBookPublicOrInstructorAssetsCount(
- books.map((r) => r.bookID) || []
+ books.map((r) => r.bookID) || [],
);
// Add the publicOrInstructorAssets field to each book
@@ -930,7 +936,7 @@ async function getCommonsCatalog(
*/
const getMasterCatalog = (
req: z.infer,
- res: Response
+ res: Response,
) => {
var sortedBooks: BookInterface[] = [];
var orgData = {};
@@ -984,7 +990,7 @@ const getMasterCatalog = (
_id: 0,
orgID: 1,
resources: 1,
- }
+ },
);
} else {
return {}; // LibreCommons — don't need to lookup Custom Catalog
@@ -1039,30 +1045,24 @@ async function getMasterCatalogV2(_req: Request, res: Response) {
groupBy: {
$cond: [
{
- $and: [
- { $ne: ["$course", null] },
- { $ne: ["$course", ""] }
- ]
+ $and: [{ $ne: ["$course", null] }, { $ne: ["$course", ""] }],
},
"$course",
- "$subject"
- ]
+ "$subject",
+ ],
},
type: {
$cond: [
{
- $and: [
- { $ne: ["$course", null] },
- { $ne: ["$course", ""] }
- ]
+ $and: [{ $ne: ["$course", null] }, { $ne: ["$course", ""] }],
},
"course",
- "subject"
- ]
- }
+ "subject",
+ ],
+ },
},
- books: { $push: "$$ROOT" }
- }
+ books: { $push: "$$ROOT" },
+ },
},
{
$group: {
@@ -1073,11 +1073,11 @@ async function getMasterCatalogV2(_req: Request, res: Response) {
{ $eq: ["$_id.type", "course"] },
{
course: "$_id.groupBy",
- books: "$books"
+ books: "$books",
},
- "$$REMOVE"
- ]
- }
+ "$$REMOVE",
+ ],
+ },
},
subjects: {
$push: {
@@ -1085,48 +1085,56 @@ async function getMasterCatalogV2(_req: Request, res: Response) {
{ $eq: ["$_id.type", "subject"] },
{
subject: "$_id.groupBy",
- books: "$books"
+ books: "$books",
},
- "$$REMOVE"
- ]
- }
- }
- }
+ "$$REMOVE",
+ ],
+ },
+ },
+ },
},
{
$sort: {
- "_id": 1
- }
+ _id: 1,
+ },
},
{
$project: {
- "_id": 0,
- "library": "$_id",
- "courses": 1,
- "subjects": 1
- }
- }
+ _id: 0,
+ library: "$_id",
+ courses: 1,
+ subjects: 1,
+ },
+ },
]);
// We can't sort by parallel arrays in the aggregation, so we need to do it here
- (libraries as MasterCatalogV2Response['libraries']).forEach((lib) => {
+ (libraries as MasterCatalogV2Response["libraries"]).forEach((lib) => {
// First sort the course and subject groups
- lib.courses = lib.courses.sort((a, b) => normalizedSort(a.course, b.course));
- lib.subjects = lib.subjects.sort((a, b) => normalizedSort(a.subject, b.subject));
-
+ lib.courses = lib.courses.sort((a, b) =>
+ normalizedSort(a.course, b.course),
+ );
+ lib.subjects = lib.subjects.sort((a, b) =>
+ normalizedSort(a.subject, b.subject),
+ );
+
// Then sort the books within each group
lib.courses.forEach((courseGroup) => {
- courseGroup.books = courseGroup.books.sort((a, b) => normalizedSort(a.title, b.title));
+ courseGroup.books = courseGroup.books.sort((a, b) =>
+ normalizedSort(a.title, b.title),
+ );
});
lib.subjects.forEach((subjectGroup) => {
- subjectGroup.books = subjectGroup.books.sort((a, b) => normalizedSort(a.title, b.title));
+ subjectGroup.books = subjectGroup.books.sort((a, b) =>
+ normalizedSort(a.title, b.title),
+ );
});
});
return res.send({
err: false,
- libraries
+ libraries,
});
} catch (error) {
debugError(error);
@@ -1164,7 +1172,7 @@ async function getCatalogFilterOptions(_req: Request, res: Response) {
shortName: 1,
abbreviation: 1,
aliases: 1,
- }
+ },
).lean(),
CustomCatalog.findOne(
{ orgID },
@@ -1172,7 +1180,7 @@ async function getCatalogFilterOptions(_req: Request, res: Response) {
_id: 0,
orgID: 1,
resources: 1,
- }
+ },
).lean(),
]);
const campusNames = buildOrganizationNamesList(orgData);
@@ -1283,7 +1291,7 @@ async function getCatalogFilterOptions(_req: Request, res: Response) {
*/
async function createBook(
req: ZodReqWithUser>,
- res: Response
+ res: Response,
) {
try {
const { library, title, projectID } = req.body;
@@ -1311,7 +1319,7 @@ async function createBook(
const hasLibAccess =
await centralIdentity.checkUserApplicationAccessInternal(
user.centralID,
- libraryApp.id
+ libraryApp.id,
);
if (!hasLibAccess) {
throw new Error(conductorErrors.err8);
@@ -1319,6 +1327,7 @@ async function createBook(
// Create book coverpage
const [bookPath, bookURL] = generateBookPathAndURL(subdomain, title);
+ // create book coverpage
const createBookRes = await CXOneFetch({
scope: "page",
path: bookPath,
@@ -1380,7 +1389,7 @@ async function createBook(
subdomain,
chapterOnePath,
"GuideTabs",
- MindTouch.Templates.PROP_GuideTabs
+ MindTouch.Templates.PROP_GuideTabs,
),
]);
@@ -1402,7 +1411,7 @@ async function createBook(
`https://batch.libretexts.org/print/Libretext=${bookURL}?createMatterOnly=true`,
{
headers: { origin: "commons.libretexts.org" },
- }
+ },
); // Don't wait for response, no-op if fails
sleep(1500); // let CXone catch up with page creations
@@ -1421,12 +1430,12 @@ async function createBook(
const permsUpdated = await updateTeamWorkbenchPermissions(
projectID,
subdomain,
- newBookID
+ newBookID,
);
if (!permsUpdated) {
console.log(
- `[createBook] Failed to update permissions for ${projectID}.`
+ `[createBook] Failed to update permissions for ${projectID}.`,
); // Silent fail
}
@@ -1454,6 +1463,64 @@ async function createBook(
}
}
+async function importPressBooksBook(
+ req: ZodReqWithUser>,
+ res: Response,
+) {
+ try {
+ const { library, title, projectID, pbBookURL } = req.body;
+ const { uuid: userID } = req.user.decoded;
+
+ const user = await User.findOne({ uuid: userID }).orFail();
+ const project = await Project.findOne({ projectID }).orFail();
+
+ const libraryApp = await centralIdentity.getApplicationById(library);
+ if (!libraryApp) {
+ throw new Error("badlibrary");
+ }
+
+ const subdomain = getSubdomainFromUrl(libraryApp.main_url);
+ if (!subdomain) {
+ throw new Error("badlibrary");
+ }
+
+ // Check project permissions
+ const canCreate = projectsAPI.checkProjectMemberPermission(project, user);
+ if (!canCreate) {
+ throw new Error(conductorErrors.err8);
+ }
+
+ const hasLibAccess =
+ await centralIdentity.checkUserApplicationAccessInternal(
+ user.centralID,
+ libraryApp.id,
+ );
+
+
+ if (!hasLibAccess) {
+ throw new Error(conductorErrors.err8);
+ }
+ const scraper = new PressBookScraper(pbBookURL, subdomain, title);
+ const result = await scraper.publishBook({
+ });
+ project.libreLibrary = subdomain;
+ project.libreCoverID = result.bookID;
+ project.didCreateWorkbench = true;
+ await project.save();
+ return res.send({
+ err: false,
+ path: result.path,
+ url: result.url,
+ });
+
+ } catch (err: any) {
+ return res.status(500).send({
+ err: true,
+ errMsg: err.message,
+ });
+ }
+}
+
/**
* Deletes a book (and its related resources) from both the Conductor DB and LibreTexts central listings.
*
@@ -1462,7 +1529,7 @@ async function createBook(
*/
async function deleteBook(
req: ZodReqWithUser>,
- res: Response
+ res: Response,
) {
try {
const deleteProject = !!req.query?.deleteProject;
@@ -1535,7 +1602,7 @@ async function deleteBook(
*/
async function getBookDetail(
req: z.infer,
- res: Response
+ res: Response,
) {
try {
const { bookID } = req.params;
@@ -1619,7 +1686,7 @@ async function getBookDetail(
],
},
allowAnonPR: {
- $eq: ["$project.allowAnonPR", true]
+ $eq: ["$project.allowAnonPR", true],
},
hasPeerReviews: {
$and: [
@@ -1769,7 +1836,7 @@ async function getBookDetail(
*/
async function getBookPeerReviews(
req: z.infer,
- res: Response
+ res: Response,
) {
try {
let allowsAnon = true;
@@ -1799,7 +1866,7 @@ async function getBookPeerReviews(
allowsAnon = false; // true by default
}
const peerReviews = await PeerReview.aggregate(
- buildPeerReviewAggregation(project.projectID)
+ buildPeerReviewAggregation(project.projectID),
);
return res.send({
err: false,
@@ -1830,7 +1897,7 @@ async function getBookPeerReviews(
*/
const addBookToCustomCatalog = async (
req: z.infer,
- res: Response
+ res: Response,
) => {
try {
await CustomCatalog.updateOne(
@@ -1844,11 +1911,11 @@ const addBookToCustomCatalog = async (
},
$pull: {
automaticMatchingExclusions: req.body.bookID, // ensure not in excluded list
- }
+ },
},
{
upsert: true,
- }
+ },
);
return res.send({
@@ -1877,7 +1944,7 @@ const addBookToCustomCatalog = async (
*/
const removeBookFromCustomCatalog = async (
req: z.infer,
- res: Response
+ res: Response,
) => {
try {
await CustomCatalog.updateOne(
@@ -1885,8 +1952,8 @@ const removeBookFromCustomCatalog = async (
{
$pullAll: {
resources: [req.body.bookID],
- }
- }
+ },
+ },
);
return res.send({
@@ -1904,18 +1971,21 @@ const removeBookFromCustomCatalog = async (
const excludeBookFromAutoMatch = async (
req: z.infer,
- res: Response
+ res: Response,
) => {
try {
const orgData = await Organization.findOne(
{ orgID: process.env.ORG_ID },
- { _id: 0, autoCatalogMatchingDisabled: 1 }
- ).lean().orFail();
+ { _id: 0, autoCatalogMatchingDisabled: 1 },
+ )
+ .lean()
+ .orFail();
if (orgData?.autoCatalogMatchingDisabled) {
return res.status(400).send({
err: true,
- errMsg: "Automatic Catalog Matching is not enabled for this organization. Exclusion not necessary.",
+ errMsg:
+ "Automatic Catalog Matching is not enabled for this organization. Exclusion not necessary.",
});
}
@@ -1926,9 +1996,9 @@ const excludeBookFromAutoMatch = async (
resources: [req.body.bookID],
},
$addToSet: {
- automaticMatchingExclusions: req.body.bookID
+ automaticMatchingExclusions: req.body.bookID,
},
- }
+ },
);
return res.send({
@@ -1954,7 +2024,7 @@ const excludeBookFromAutoMatch = async (
*/
async function downloadBookFile(
req: z.infer,
- res: Response
+ res: Response,
) {
try {
const [lib, coverID] = getLibraryAndPageFromBookID(req.params.bookID);
@@ -1985,7 +2055,7 @@ async function downloadBookFile(
[fileID],
true,
"",
- true
+ true,
);
if (
@@ -2023,7 +2093,7 @@ async function downloadBookFile(
*/
async function getBookTOC(
req: z.infer,
- res: Response
+ res: Response,
) {
try {
const bookService = new BookService({ bookID: req.params.bookID });
@@ -2050,7 +2120,7 @@ async function getBookTOC(
*/
async function getLicenseReport(
req: z.infer,
- res: Response
+ res: Response,
) {
const notFoundResponse = {
err: false,
@@ -2090,7 +2160,7 @@ async function getLicenseReport(
async function getBookPagesDetails(
req: ZodReqWithUser>,
- res: Response
+ res: Response,
) {
try {
const { bookID } = req.params;
@@ -2106,7 +2176,7 @@ async function getBookPagesDetails(
// Loop through table of contents and add overviews and tags to each page (based on ID)
// Table of contents is a nested array, so we need to loop through each level
const addOverviewsAndTags = (
- toc: TableOfContents
+ toc: TableOfContents,
): TableOfContentsDetailed => {
const pageOverview = overviews.find((o) => o.id === toc.id);
const pageTags = tags.find((t) => t.id === toc.id)?.tags || [];
@@ -2138,7 +2208,7 @@ async function getBookPagesDetails(
async function getPageDetail(
req: ZodReqWithUser>,
- res: Response
+ res: Response,
) {
try {
const { pageID: fullPageID } = req.params;
@@ -2150,7 +2220,12 @@ async function getPageDetail(
// Check if the user has access to the page. If not, check if they are a superadmin first before returning 403.
const canAccess = await bookService.canAccessPage(req.user.decoded.uuid);
if (!canAccess) {
- const isSuperadmin = authAPI.checkHasRole(req.user, "libretexts", "superadmin", true);
+ const isSuperadmin = authAPI.checkHasRole(
+ req.user,
+ "libretexts",
+ "superadmin",
+ true,
+ );
if (!isSuperadmin) {
return res.status(403).send({
err: true,
@@ -2183,7 +2258,7 @@ async function getPageDetail(
async function updatePageDetails(
req: ZodReqWithUser>,
- res: Response
+ res: Response,
) {
try {
const { pageID } = req.params;
@@ -2202,7 +2277,7 @@ async function updatePageDetails(
const [error, success] = await bookService.updatePageDetails(
pageID,
summary,
- tags
+ tags,
);
if (error) {
@@ -2242,7 +2317,7 @@ async function updatePageDetails(
async function bulkUpdatePageTags(
req: ZodReqWithUser>,
- res: Response
+ res: Response,
) {
try {
const { bookID } = req.params;
@@ -2258,7 +2333,7 @@ async function bulkUpdatePageTags(
const [error, success] = await bookService.updatePageDetails(
page.id,
undefined,
- page.tags
+ page.tags,
);
if (error) {
reject(error);
@@ -2384,7 +2459,7 @@ const generateKBExport = () => {
lastUpdated: 1,
},
},
- ])
+ ]),
);
})
.then((commonsBooks) => {
@@ -2450,7 +2525,7 @@ const generateKBExport = () => {
let authorProcess = author.trim();
if (
authorProcess.toLowerCase() !==
- "no attribution by request" &&
+ "no attribution by request" &&
authorProcess.length > 0
) {
itemAuthors.push(authorProcess);
@@ -2505,126 +2580,117 @@ const retrieveKBExport = (_req: Request, res: Response) => {
});
};
-export async function _getBookPublicOrInstructorAssetsCount(ids: string[]): Promise<{
- bookID: string;
- publicAssets: number;
- instructorAssets: number;
-}[]> {
- return Book.aggregate([{
- $match: {
- bookID: { $in: ids },
- }
- },
+export async function _getBookPublicOrInstructorAssetsCount(
+ ids: string[],
+): Promise<
{
- $lookup: {
- from: "projects",
- let: {
- bookIdParts: {
- $split: ["$bookID", "-"]
- }
+ bookID: string;
+ publicAssets: number;
+ instructorAssets: number;
+ }[]
+> {
+ return Book.aggregate([
+ {
+ $match: {
+ bookID: { $in: ids },
},
- pipeline: [
- {
- $match: {
- $expr: {
- $and: [
- {
- $eq: [
- "$libreLibrary",
- {
- $arrayElemAt: [
- "$$bookIdParts",
- 0
- ]
- }
- ]
- },
- {
- $eq: [
- "$libreCoverID",
- {
- $arrayElemAt: [
- "$$bookIdParts",
- 1
- ]
- }
- ]
- }
- ]
- }
- }
+ },
+ {
+ $lookup: {
+ from: "projects",
+ let: {
+ bookIdParts: {
+ $split: ["$bookID", "-"],
+ },
},
- {
- $project: {
- projectID: 1
- }
- }
- ],
- as: "projectDetails"
- }
- },
- {
- $addFields: {
- project: {
- $first: "$projectDetails"
- }
- }
- },
- {
- $match: {
- "project.projectID": {
- $exists: true,
- $ne: ""
- }
- }
- },
- {
- $lookup: {
- from: "projectfiles",
- localField: "project.projectID",
- foreignField: "projectID",
- as: "projectFiles"
- }
- },
- {
- $project: {
- bookID: "$bookID",
- publicAssets: {
- $size: {
- $filter: {
- input: "$projectFiles",
- as: "file",
- cond: {
- $eq: [
- "$$file.access",
- "public"
- ]
- }
- }
- }
+ pipeline: [
+ {
+ $match: {
+ $expr: {
+ $and: [
+ {
+ $eq: [
+ "$libreLibrary",
+ {
+ $arrayElemAt: ["$$bookIdParts", 0],
+ },
+ ],
+ },
+ {
+ $eq: [
+ "$libreCoverID",
+ {
+ $arrayElemAt: ["$$bookIdParts", 1],
+ },
+ ],
+ },
+ ],
+ },
+ },
+ },
+ {
+ $project: {
+ projectID: 1,
+ },
+ },
+ ],
+ as: "projectDetails",
},
- instructorAssets: {
- $size: {
- $filter: {
- input: "$projectFiles",
- as: "file",
- cond: {
- $eq: [
- "$$file.access",
- "instructors"
- ]
- }
- }
- }
- }
- }
- }]);
+ },
+ {
+ $addFields: {
+ project: {
+ $first: "$projectDetails",
+ },
+ },
+ },
+ {
+ $match: {
+ "project.projectID": {
+ $exists: true,
+ $ne: "",
+ },
+ },
+ },
+ {
+ $lookup: {
+ from: "projectfiles",
+ localField: "project.projectID",
+ foreignField: "projectID",
+ as: "projectFiles",
+ },
+ },
+ {
+ $project: {
+ bookID: "$bookID",
+ publicAssets: {
+ $size: {
+ $filter: {
+ input: "$projectFiles",
+ as: "file",
+ cond: {
+ $eq: ["$$file.access", "public"],
+ },
+ },
+ },
+ },
+ instructorAssets: {
+ $size: {
+ $filter: {
+ input: "$projectFiles",
+ as: "file",
+ cond: {
+ $eq: ["$$file.access", "instructors"],
+ },
+ },
+ },
+ },
+ },
+ },
+ ]);
}
-export async function syncWithSearchIndex(
- req: Request,
- res: Response
-) {
+export async function syncWithSearchIndex(req: Request, res: Response) {
try {
// Return response immediately to avoid timeout
res.send({
@@ -2657,7 +2723,7 @@ async function syncBooksInBackground() {
try {
debugServer("Initiating Commons Books search index synchronization...");
const searchService = await SearchService.create();
-
+
const batchSize = 500; // Process 500 books at a time
let skip = 0;
let hasMore = true;
@@ -2694,7 +2760,7 @@ async function syncBooksInBackground() {
},
],
as: "project",
- }
+ },
},
{
$addFields: {
@@ -2731,9 +2797,9 @@ async function syncBooksInBackground() {
createdAt: 0,
updatedAt: 0,
randomIndex: 0,
- project: 0
- }
- }
+ project: 0,
+ },
+ },
];
while (hasMore) {
@@ -2750,7 +2816,9 @@ async function syncBooksInBackground() {
await searchService.addDocuments("books", books);
totalSynced += books.length;
- debugServer(`Synced batch of ${books.length} books (${totalSynced} total)...`);
+ debugServer(
+ `Synced batch of ${books.length} books (${totalSynced} total)...`,
+ );
skip += batchSize;
@@ -2760,7 +2828,9 @@ async function syncBooksInBackground() {
}
}
- debugServer(`Commons Books search index sync completed. Total synced: ${totalSynced}`);
+ debugServer(
+ `Commons Books search index sync completed. Total synced: ${totalSynced}`,
+ );
} catch (e) {
debugError("Error in syncBooksInBackground:", e);
throw e;
@@ -2774,6 +2844,7 @@ export default {
getMasterCatalog,
getMasterCatalogV2,
createBook,
+ importPressBooksBook,
deleteBook,
getBookDetail,
getBookPeerReviews,
@@ -2790,5 +2861,5 @@ export default {
bulkUpdatePageTags,
retrieveKBExport,
syncWithSearchIndex,
- _getBookPublicOrInstructorAssetsCount
+ _getBookPublicOrInstructorAssetsCount,
};
diff --git a/server/api/validators/book.ts b/server/api/validators/book.ts
index 0a09cbf0..d545d190 100644
--- a/server/api/validators/book.ts
+++ b/server/api/validators/book.ts
@@ -15,6 +15,28 @@ export const createBookSchema = z.object({
}),
});
+export const importPressBooksBookSchema = z.object({
+ body: z.object({
+ library: z.coerce.number().positive().int(),
+ // pressbooks book URL
+ pbBookURL: z.string().superRefine((value, ctx) => {
+ try {
+ // Use the WHATWG URL parser instead of the deprecated validator.js wrapper
+ // to validate that the string is a well-formed absolute URL.
+ // eslint-disable-next-line no-new
+ new URL(value);
+ } catch {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Invalid URL",
+ });
+ }
+ }),
+ title: z.string().min(1).max(255).optional(),
+ projectID: z.string().length(10),
+ }),
+});
+
export const getCommonsCatalogSchema = z.object({
query: z.object({
activePage: z.coerce.number().min(1).default(1),
diff --git a/server/util/pressbookutils.ts b/server/util/pressbookutils.ts
index 90bf8170..d6d72274 100644
--- a/server/util/pressbookutils.ts
+++ b/server/util/pressbookutils.ts
@@ -1,6 +1,34 @@
-import * as crypto from "crypto";
-import { generateBookPathAndURL } from "./librariesclient";
+import {
+ addPageProperty,
+ CXOneFetch,
+ generateBookPathAndURL,
+ getPageID,
+} from "./librariesclient";
import conductorErrors from "../conductor-errors";
+import { parse as parseHTML } from "node-html-parser";
+import MindTouch from "./CXOne/index.js";
+
+const defaultImagesURL = "https://cdn.libretexts.net/DefaultImages";
+
+interface TocNode {
+ id: number;
+ title: string;
+ slug: string;
+ menu_order: number;
+ has_post_content: boolean;
+ status: string;
+}
+
+interface TocPart extends TocNode {
+ chapters: TocNode[];
+ link: string;
+}
+
+interface TocShape {
+ "front-matter": TocNode[];
+ parts: TocPart[];
+ "back-matter": TocNode[];
+}
export interface MindTouchConfig {
hostname: string;
@@ -11,15 +39,60 @@ export interface MindTouchConfig {
}
export interface PublishOptions {
-// mindtouch: MindTouchConfig;
auth?: { username: string; password: string };
}
export interface PublishResult {
- err: boolean;
- path: string;
- url: string;
- errMsg?: string;
+ err: boolean;
+ path: string;
+ url: string;
+ bookID: string;
+ errMsg?: string;
+}
+
+export interface Author {
+ name: string;
+ description: string;
+ url: string;
+ avatar: string;
+}
+
+export interface FeaturedImage {
+ url: string;
+ alt: string;
+ caption: string;
+ width?: number;
+ height?: number;
+ thumbnail: string;
+ medium: string;
+ full: string;
+}
+
+export interface EnrichedItem {
+ id: number | null;
+ slug: string;
+ order: number;
+ link: string;
+ status: string;
+ date: string;
+ modified: string;
+ title: string;
+ content_html: string;
+ excerpt: string;
+ author: Author | Record;
+ featured_image: FeaturedImage | Record;
+ terms: string[];
+ comments: Reply[];
+}
+
+export interface Reply {
+ author_name: string;
+ date: string;
+ content: string;
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((r) => setTimeout(r, ms));
}
export class PressBookScraper {
@@ -28,11 +101,7 @@ export class PressBookScraper {
private subdomain: string;
private pbHeaders: Record;
- constructor(
- pbBookURL: string,
- subdomain: string,
- title?: string,
- ) {
+ constructor(pbBookURL: string, subdomain: string, title?: string) {
this.pbBookURL = pbBookURL;
this.title = title;
this.subdomain = subdomain;
@@ -64,48 +133,337 @@ export class PressBookScraper {
return bookUrl.replace(/\/+$/, "") + "/wp-json/pressbooks/v2";
}
- async publishBook(options: PublishOptions) : Promise {
+ private convertLatexShortcodes(html: string): string {
+ return html.replace(/\[latex](.*?)\[\/latex]/g, "\\($1\\)");
+ }
+
+ async publishBook(options: PublishOptions): Promise {
const encodePbURL = this.pbBookURL.replace(/\/+$/, "");
- const uploadImages = true;
const auth = options.auth;
- const result: PublishResult = {
- err: false,
- path: "",
- url: "",
- };
- let metadata: any;
+ const result: PublishResult = { err: false, path: "", url: "", bookID: "" };
+
+ // ── 1. Metadata ──────────────────────────────────────────────────────────
+ let metadata: any = {};
try {
- metadata = await this.getJson(this.pbApi(encodePbURL) + "/metadata", auth);
+ metadata = await this.getJson(
+ this.pbApi(encodePbURL) + "/metadata",
+ auth,
+ );
} catch {
try {
metadata = await this.getJson(encodePbURL + "/wp-json/", auth);
} catch {
- metadata = {};
throw new Error(conductorErrors.err8);
}
}
+
+ // ── 2. TOC — single source of truth for structure AND order ──────────────
console.log("[*] Fetching TOC...");
- let toc: any[];
+ let toc: TocShape;
try {
- toc = await this.getJson(this.pbApi(encodePbURL) + "/toc", auth);
+ toc = (await this.getJson(
+ this.pbApi(encodePbURL) + "/toc",
+ auth,
+ )) as TocShape;
} catch {
- toc = [];
throw new Error(conductorErrors.err8);
}
+ // ── 3. Book path / URL ────────────────────────────────────────────────────
const title =
- this.title ||
- metadata.name ||
- metadata.title?.rendered ||
- new URL(encodePbURL).pathname.replace(/^\/|\/$/g, "") ||
- "pressbooks_book";
+ this.title ||
+ metadata.name ||
+ metadata.title?.rendered ||
+ new URL(encodePbURL).pathname.replace(/^\/|\/$/g, "") ||
+ "pressbooks_book";
+
const [bookPath, bookURL] = generateBookPathAndURL(this.subdomain, title);
- console.log("[*] bookPath: ", bookPath);
- console.log("[*] bookURL: ", bookURL);
result.path = bookPath;
result.url = bookURL;
- console.log("[*] result info: ", result);
+ console.log("[*] bookPath:", bookPath);
+ console.log("[*] bookURL:", bookURL);
+
+ // ── 4. Create the CXOne book root ─────────────────────────────────────────
+ const createBookRes = await CXOneFetch({
+ scope: "page",
+ path: bookPath,
+ api: MindTouch.API.Page.POST_Contents_Title(title),
+ subdomain: this.subdomain,
+ options: { method: "POST", body: MindTouch.Templates.POST_CreateBook },
+ query: { abort: "exists" },
+ }).catch(() => {
+ throw Object.assign(new Error(conductorErrors.err86), {
+ name: "CreateBookError",
+ });
+ });
+
+ if (!createBookRes.ok) {
+ throw new Error(`Error creating Workbench book: "${title}"`);
+ }
+
+ await Promise.all([
+ addPageProperty(this.subdomain, bookPath, "WelcomeHidden", true),
+ addPageProperty(this.subdomain, bookPath, "SubPageListing", "simple"),
+ ]);
+
+ const imageRes = await fetch(`${defaultImagesURL}/default.png`);
+ await CXOneFetch({
+ scope: "page",
+ path: bookPath,
+ api: MindTouch.API.Page.PUT_File_Default_Thumbnail,
+ subdomain: this.subdomain,
+ options: { method: "PUT", body: await imageRes.blob() },
+ });
+
+ // ── 5. Front Matter container + items ─────────────────────────────────────
+ console.log(`\n[*] Front Matter: ${toc["front-matter"].length} items`);
+ const frontMatterContainerPath = `${bookPath}/Front_Matter`;
+
+ // isContainer: true — Front_Matter holds child pages
+ await this.upsertCXOnePage(
+ frontMatterContainerPath,
+ "Front Matter",
+ "",
+ true,
+ );
+
+ const sortedFrontMatter = [...toc["front-matter"]].sort(
+ (a, b) => a.menu_order - b.menu_order,
+ );
+
+ for (let i = 0; i < sortedFrontMatter.length; i++) {
+ const node = sortedFrontMatter[i];
+ const seq = String(i + 1).padStart(2, "0");
+ const pagePath = `${frontMatterContainerPath}/${seq}:_${this.slugifyNode(node)}`;
+ console.log(` [FM ${seq}] ${node.title}`);
+
+ const content = node.has_post_content
+ ? await this.fetchNodeContent(
+ encodePbURL,
+ "front-matter",
+ node.id,
+ auth,
+ )
+ : "";
+
+ // isContainer: false — Front Matter items are leaf content pages
+ await this.upsertCXOnePage(pagePath, node.title, content, false);
+ }
+
+ // ── 6. Parts → Chapters ───────────────────────────────────────────────────
+ console.log(`\n[*] Parts: ${toc.parts.length}`);
+ const sortedParts = [...toc.parts].sort(
+ (a, b) => a.menu_order - b.menu_order,
+ );
+
+ for (let pi = 0; pi < sortedParts.length; pi++) {
+ const part = sortedParts[pi];
+ const partSeq = String(pi + 1).padStart(2, "0");
+ const partPath = `${bookPath}/${partSeq}:${this.slugifyNode(part)}`;
+ console.log(
+ `\n [Part ${partSeq}] ${part.title} (${part.chapters.length} chapters)`,
+ );
+
+ const partContent = part.has_post_content
+ ? await this.fetchNodeContent(encodePbURL, "parts", part.id, auth)
+ : "";
+
+ // isContainer: true — Parts hold chapters under them
+ await this.upsertCXOnePage(
+ partPath,
+ `${partSeq}:_${part.title}`,
+ partContent,
+ true,
+ );
+
+ const sortedChapters = [...part.chapters].sort(
+ (a, b) => a.menu_order - b.menu_order,
+ );
+
+ for (let ci = 0; ci < sortedChapters.length; ci++) {
+ const chapter = sortedChapters[ci];
+ const chapterSeq = String(ci + 1).padStart(2, "0");
+ const chapterPath = `${partPath}/${chapterSeq}:_${this.slugifyNode(chapter)}`;
+ console.log(` [Ch ${chapterSeq}] ${chapter.title}`);
+
+ const content = chapter.has_post_content
+ ? await this.fetchNodeContent(
+ encodePbURL,
+ "chapters",
+ chapter.id,
+ auth,
+ )
+ : "";
+
+ // isContainer: false — Chapters are leaf content pages
+ await this.upsertCXOnePage(chapterPath, chapter.title, content, false);
+ }
+ }
+
+ // ── 7. Back Matter container + items ──────────────────────────────────────
+ console.log(`\n[*] Back Matter: ${toc["back-matter"].length} items`);
+ const backMatterContainerPath = `${bookPath}/Back_Matter`;
+
+ // isContainer: true — Back_Matter holds child pages
+ await this.upsertCXOnePage(
+ backMatterContainerPath,
+ "Back Matter",
+ "",
+ true,
+ );
+
+ const sortedBackMatter = [...toc["back-matter"]].sort(
+ (a, b) => a.menu_order - b.menu_order,
+ );
+
+ for (let i = 0; i < sortedBackMatter.length; i++) {
+ const node = sortedBackMatter[i];
+ const seq = String(i + 1).padStart(2, "0");
+ const pagePath = `${backMatterContainerPath}/${seq}:_${this.slugifyNode(node)}`;
+ console.log(` [BM ${seq}] ${node.title}`);
+
+ const content = node.has_post_content
+ ? await this.fetchNodeContent(encodePbURL, "back-matter", node.id, auth)
+ : "";
+
+ // isContainer: false — Back Matter items are leaf content pages
+ await this.upsertCXOnePage(pagePath, node.title, content, false);
+ }
+
+ // ── 8. Trigger MindMap TOC update ─────────────────────────────────────────
+ console.log("[*] Triggering MindMap TOC update...");
+ fetch(`https://batch.libretexts.org/print/Libretext=${bookURL}`, {
+ headers: { origin: "commons.libretexts.org" },
+ }).catch((e) => {
+ console.warn(
+ "[PressBookScraper] MindMap trigger failed (non-fatal):",
+ (e as Error).message,
+ );
+ });
+ await sleep(1500);
+
+ // ── 9. Verify book ID ─────────────────────────────────────────────────────
+ const newBookID = await getPageID(bookPath, this.subdomain);
+ if (!newBookID) {
+ throw new Error(
+ `Error locating Workbench book ID after import: "${title}"`,
+ );
+ }
+ result.bookID = newBookID;
return result;
+ }
+
+ // ── Helpers ────────────────────────────────────────────────────────────────
+
+ /**
+ * Fetch the rendered HTML content for a single node by hitting its
+ * individual REST endpoint.
+ */
+ private async fetchNodeContent(
+ bookUrl: string,
+ postType: string,
+ id: number,
+ auth?: { username: string; password: string },
+ ): Promise {
+ try {
+ const url = `${this.pbApi(bookUrl)}/${postType}/${id}?_embed=1`;
+ const item = await this.getJson(url, auth);
+ const html: string = item.content?.rendered ?? "";
+ return this.convertLatexShortcodes(html);
+ } catch (err) {
+ console.warn(
+ `[PressBookScraper] Failed to fetch content for ${postType}/${id}:`,
+ (err as Error).message,
+ );
+ return "";
+ }
+ }
+
+ /** Clean a TOC node's slug/title into a safe CXOne path segment */
+ private slugifyNode(node: { slug?: string; title?: string }): string {
+ const base = node.slug?.trim().length
+ ? node.slug
+ : node.title?.trim().length
+ ? node.title
+ : "section";
+ const cleaned = base
+ .trim()
+ .replace(/\s+/g, "_")
+ .replace(/[^A-Za-z0-9_\-]/g, "");
+ return cleaned.length > 0 ? cleaned : "Section";
+ }
+
+ /**
+ * Create the CXOne page (idempotent) then POST its HTML content and properties.
+ *
+ * @param pagePath - Full CXOne path for the page
+ * @param title - Display title for the page
+ * @param contentHtml - Raw HTML content to push (empty string for containers)
+ * @param isContainer - true → page holds children, needs SubPageListing
+ * false → leaf content page
+ */
+ private async upsertCXOnePage(
+ pagePath: string,
+ title: string,
+ contentHtml: string,
+ isContainer = false,
+ ): Promise {
+ // Create the page (no-op if it already exists)
+ await CXOneFetch({
+ scope: "page",
+ path: pagePath,
+ api: MindTouch.API.Page.POST_Contents_Title(title || pagePath),
+ subdomain: this.subdomain,
+ options: {
+ method: "POST",
+ body: MindTouch.Templates.POST_CreateBookChapter,
+ },
+ query: { abort: "exists" },
+ });
+
+ const pageID = await getPageID(pagePath, this.subdomain);
+ if (!pageID)
+ throw new Error(`Error locating CXOne page ID for "${pagePath}"`);
+
+ // POST the HTML content as plain text so MindTouch renders it
+ const res = await CXOneFetch({
+ scope: "page",
+ path: parseInt(pageID, 10),
+ api: MindTouch.API.Page.POST_Contents,
+ subdomain: this.subdomain,
+ query: { edittime: "now", comment: "Imported from Pressbooks" },
+ options: {
+ method: "POST",
+ body: contentHtml || `${title || pagePath}
`,
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
+ },
+ });
+
+ if (!res.ok) {
+ throw new Error(
+ `Error updating CXOne content for "${pagePath}": ${res.statusText}`,
+ );
+ }
+
+ // Set page properties — all pages get these three
+ const pageProps: Promise[] = [
+ addPageProperty(this.subdomain, pagePath, "WelcomeHidden", true),
+ addPageProperty(this.subdomain, pagePath, "GuideDisplay", "single"),
+ addPageProperty(
+ this.subdomain,
+ pagePath,
+ "GuideTabs",
+ MindTouch.Templates.PROP_GuideTabs,
+ ),
+ ];
+
+ // Container pages additionally need SubPageListing to show child pages
+ if (isContainer) {
+ pageProps.push(
+ addPageProperty(this.subdomain, pagePath, "SubPageListing", "simple"),
+ );
+ }
+ await Promise.all(pageProps);
}
}
From 33cee9d367aa4a169b274c0bd31cd9a17ca3057d Mon Sep 17 00:00:00 2001
From: yghaemi
Date: Mon, 16 Mar 2026 10:26:25 -0400
Subject: [PATCH 3/8] feat: enhance ImportWorkbenchModal with job tracking and
status updates
---
.../projects/ImportWorkbenchModal.tsx | 118 ++++++++-
.../projects/ProjectLinkButtons.tsx | 56 ++++-
server/api/books.ts | 208 +++++++++++++++-
server/api/validators/book.ts | 12 +
server/models/pressbooksimportjob.ts | 83 +++++++
server/util/CXOne/CXOnePageProperties.ts | 1 +
server/util/CXOne/CXOneTemplates.ts | 10 +-
server/util/pressbookutils.ts | 231 ++++++++++++++----
8 files changed, 650 insertions(+), 69 deletions(-)
create mode 100644 server/models/pressbooksimportjob.ts
diff --git a/client/src/components/projects/ImportWorkbenchModal.tsx b/client/src/components/projects/ImportWorkbenchModal.tsx
index 8fc0513e..ab08092d 100644
--- a/client/src/components/projects/ImportWorkbenchModal.tsx
+++ b/client/src/components/projects/ImportWorkbenchModal.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useRef, useState } from "react";
import {
Button,
Dropdown,
@@ -26,6 +26,9 @@ interface ImportWorkbenchModalProps extends ModalProps {
project: any;
onClose: () => void;
onSuccess: () => void;
+ initialJobID?: string | null;
+ initialJobStatus?: "pending" | "running" | "success" | "error";
+ initialJobMessages?: string[];
}
interface ImportWorkbenchForm {
library: number | string;
@@ -49,6 +52,9 @@ const ImportWorkbenchModal: React.FC = (props) => {
project,
onClose,
onSuccess,
+ initialJobID,
+ initialJobStatus,
+ initialJobMessages,
...rest
} = props;
const teamMembers = [
@@ -85,6 +91,18 @@ const ImportWorkbenchModal: React.FC = (props) => {
>([]);
const [selectedLibraryName, setSelectedLibraryName] = useState("");
const [canAccessLibrary, setCanAccessLibrary] = useState(true);
+ const [jobID, setJobID] = useState(null);
+ const [jobStatus, setJobStatus] = useState<
+ "idle" | "pending" | "running" | "success" | "error"
+ >("idle");
+ const [jobMessages, setJobMessages] = useState([]);
+ const messagesContainerRef = useRef(null);
+
+ useEffect(() => {
+ if (!messagesContainerRef.current) return;
+ messagesContainerRef.current.scrollTop =
+ messagesContainerRef.current.scrollHeight;
+ }, [jobMessages]);
const selectedLibrary = watch("library");
@@ -96,6 +114,22 @@ const ImportWorkbenchModal: React.FC = (props) => {
}
}, [show]);
+ useEffect(() => {
+ if (!show) return;
+ if (!initialJobID) return;
+
+ setJobID(initialJobID);
+ if (initialJobStatus) {
+ setJobStatus(initialJobStatus);
+ } else {
+ setJobStatus("pending");
+ }
+ if (Array.isArray(initialJobMessages)) {
+ setJobMessages(initialJobMessages);
+ }
+ setLoading(true);
+ }, [show, initialJobID, initialJobStatus, initialJobMessages]);
+
async function loadLibraries() {
try {
setLibraryOptsLoading(true);
@@ -174,8 +208,11 @@ const ImportWorkbenchModal: React.FC = (props) => {
try {
if (!canAccessLibrary) return;
setLoading(true);
- if (!(await trigger())) return;
-
+ if (!(await trigger())) {
+ setLoading(false);
+ return;
+ }
+
const res = await axios.post("/commons/import-pressbooks", {
...getValues(),
projectID,
@@ -183,14 +220,61 @@ const ImportWorkbenchModal: React.FC = (props) => {
if (res.data.err) {
throw new Error(res.data.errMsg);
}
- onSuccess();
+
+ if (!res.data.jobID) {
+ throw new Error("Failed to start import job.");
+ }
+
+ setJobID(res.data.jobID);
+ setJobStatus("pending");
+ setJobMessages([]);
} catch (err) {
handleGlobalError(err);
- } finally {
- setLoading(false);
}
}
+ useEffect(() => {
+ if (!jobID) return;
+
+ setJobStatus((current) =>
+ current === "idle" || current === "pending" ? "running" : current,
+ );
+
+ const interval = setInterval(async () => {
+ try {
+ const res = await axios.get(`/commons/import-pressbooks/${jobID}`);
+ if (res.data.err) {
+ throw new Error(res.data.errMsg);
+ }
+
+ const job = res.data.job;
+ setJobStatus(job.status);
+ if (Array.isArray(job.messages)) {
+ setJobMessages(job.messages);
+ }
+
+ if (job.status === "success") {
+ clearInterval(interval);
+ setLoading(false);
+ setJobID(null);
+ onSuccess();
+ } else if (job.status === "error") {
+ clearInterval(interval);
+ setLoading(false);
+ setJobID(null);
+ handleGlobalError(new Error(job.errorMessage || "Import failed."));
+ }
+ } catch (err) {
+ clearInterval(interval);
+ setLoading(false);
+ setJobID(null);
+ handleGlobalError(err);
+ }
+ }, 3000);
+
+ return () => clearInterval(interval);
+ }, [jobID]);
+
async function handleCreateClick() {
try {
setLoading(true);
@@ -298,6 +382,28 @@ const ImportWorkbenchModal: React.FC = (props) => {
imported! Leaving this blank will use the title from the book Metadata.
+ {(jobStatus === "pending" || jobStatus === "running") && (
+
+ Import in progress
+ {jobMessages.length > 0 ? (
+
+
+ {jobMessages.map((msg, idx) => (
+ {msg}
+ ))}
+
+
+ ) : (
+
+ The book is being imported from Pressbooks. This may take a
+ few minutes. Please keep this window open.
+
+ )}
+
+ )}
{!canAccessLibrary && (
Cannot Access Library
diff --git a/client/src/components/projects/ProjectLinkButtons.tsx b/client/src/components/projects/ProjectLinkButtons.tsx
index 834e3206..b1ca1eeb 100644
--- a/client/src/components/projects/ProjectLinkButtons.tsx
+++ b/client/src/components/projects/ProjectLinkButtons.tsx
@@ -5,9 +5,11 @@ import {
buildLibraryPageGoURL,
buildRemixerURL,
} from "../../utils/projectHelpers";
-import { lazy, useState } from "react";
+import { lazy, useEffect, useState } from "react";
+import axios from "axios";
import { ProjectClassification } from "../../types";
import ImportWorkbenchModal from "./ImportWorkbenchModal";
+import { useTypedSelector } from "../../state/hooks";
const CreateWorkbenchModal = lazy(() => import("./CreateWorkbenchModal"));
interface ProjectLinkButtonsProps {
@@ -41,8 +43,38 @@ const ProjectLinkButtons: React.FC = ({
useState(false);
const [showImportWorkbenchModal, setShowImportWorkbenchModal] =
useState(false);
+ const [initialImportJob, setInitialImportJob] = useState<{
+ jobID: string;
+ status: "pending" | "running" | "success" | "error";
+ messages: string[];
+ } | null>(null);
+ const user = useTypedSelector((state) => state.user);
const validWorkbench = didCreateWorkbench && libreCoverID && libreLibrary;
+ useEffect(() => {
+ let cancelled = false;
+ if (!projectID) return;
+
+ axios
+ .get("/commons/import-pressbooks/active", {
+ params: { projectID },
+ })
+ .then((res) => {
+ if (cancelled) return;
+ if (!res.data.err && res.data.job) {
+ setInitialImportJob(res.data.job);
+ setShowImportWorkbenchModal(true);
+ }
+ })
+ .catch(() => {
+ // Silently ignore; absence of job is non-fatal
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [projectID]);
+
return (
@@ -58,13 +90,16 @@ const ProjectLinkButtons: React.FC = ({
Create Book
- setShowImportWorkbenchModal(true)}
- >
-
- Import Book
-
+ {user.isSuperAdmin && (
+ setShowImportWorkbenchModal(true)}
+ >
+
+ Import Book
+
+ )}
+
>
)}
{(projectLink || validWorkbench) && (
@@ -183,7 +218,7 @@ const ProjectLinkButtons: React.FC = ({
/>
)}
- {projectID && projectTitle && (
+ {projectID && projectTitle && user.isSuperAdmin && (
= ({
onClose={() => setShowImportWorkbenchModal(false)}
onSuccess={() => window.location.reload()}
project={project}
+ initialJobID={initialImportJob?.jobID ?? null}
+ initialJobStatus={initialImportJob?.status}
+ initialJobMessages={initialImportJob?.messages}
/>
)}
diff --git a/server/api/books.ts b/server/api/books.ts
index 046b3abb..3bde484d 100644
--- a/server/api/books.ts
+++ b/server/api/books.ts
@@ -81,11 +81,15 @@ import {
updatePageDetailsSchema,
bulkUpdatePageTagsSchema,
importPressBooksBookSchema,
+ getPressbooksImportJobStatusSchema,
+ getActivePressbooksImportJobSchema,
} from "./validators/book.js";
import BookService from "./services/book-service.js";
import { normalizedSort } from "../util/searchutils.js";
import SearchService from "./services/search-service.js";
import { PressBookScraper } from "../util/pressbookutils.js";
+import PressbooksImportJob from "../models/pressbooksimportjob.js";
+import base62 from "base62-random";
const BOOK_PROJECTION: Partial> = {
_id: 0,
@@ -1471,6 +1475,80 @@ async function importPressBooksBook(
const { library, title, projectID, pbBookURL } = req.body;
const { uuid: userID } = req.user.decoded;
+ const jobID = base62(10);
+
+ await PressbooksImportJob.create({
+ jobID,
+ projectID,
+ userID,
+ library,
+ pbBookURL,
+ title,
+ status: "pending",
+ messages: ["Pressbooks import job created."],
+ });
+
+ res.send({
+ err: false,
+ jobID,
+ });
+
+ void runPressbooksImportJob({
+ jobID,
+ library,
+ title,
+ projectID,
+ pbBookURL,
+ userID,
+ });
+ } catch (err: any) {
+ return res.status(500).send({
+ err: true,
+ errMsg: err.message,
+ });
+ }
+}
+
+type PressbooksImportJobParams = {
+ jobID: string;
+ library: number;
+ title?: string;
+ projectID: string;
+ pbBookURL: string;
+ userID: string;
+};
+
+async function appendPressbooksJobMessages(jobID: string, messages: string[]) {
+ if (!messages.length) return;
+ await PressbooksImportJob.updateOne(
+ { jobID },
+ {
+ $push: {
+ messages: {
+ $each: messages,
+ },
+ },
+ },
+ );
+}
+
+async function runPressbooksImportJob(params: PressbooksImportJobParams) {
+ const { jobID, library, title, projectID, pbBookURL, userID } = params;
+
+ try {
+ await PressbooksImportJob.updateOne(
+ { jobID },
+ {
+ $set: {
+ status: "running",
+ },
+ },
+ );
+
+ await appendPressbooksJobMessages(jobID, [
+ "Validating user, project, and library access...",
+ ]);
+
const user = await User.findOne({ uuid: userID }).orFail();
const project = await Project.findOne({ projectID }).orFail();
@@ -1495,28 +1573,142 @@ async function importPressBooksBook(
user.centralID,
libraryApp.id,
);
-
-
+
if (!hasLibAccess) {
throw new Error(conductorErrors.err8);
}
- const scraper = new PressBookScraper(pbBookURL, subdomain, title);
+
+ const scraper = new PressBookScraper(pbBookURL, subdomain, title);
const result = await scraper.publishBook({
+ log: (message: string) => {
+ void appendPressbooksJobMessages(jobID, [message]);
+ },
});
+
+ await appendPressbooksJobMessages(jobID, [
+ "Updating associated project with new Workbench information...",
+ ]);
+
project.libreLibrary = subdomain;
project.libreCoverID = result.bookID;
project.didCreateWorkbench = true;
+ result.authorsName && (project.author = result.authorsName);
+ result.license && (project.license = result.license);
+ result.sourcePublicationDate && (project.sourceOriginalPublicationDate = result.sourcePublicationDate);
+ result.thumbnail && (project.thumbnail = result.thumbnail);
+ result.resourceURL && (project.projectURL = result.resourceURL);
await project.save();
+
+ await PressbooksImportJob.updateOne(
+ { jobID },
+ {
+ $set: {
+ status: "success",
+ resultPath: result.path,
+ resultURL: result.url,
+ },
+ },
+ );
+
+ await appendPressbooksJobMessages(jobID, [
+ "Pressbooks import completed successfully.",
+ ]);
+ } catch (err: any) {
+ debugError(err);
+ await PressbooksImportJob.updateOne(
+ { jobID },
+ {
+ $set: {
+ status: "error",
+ errorMessage: err?.message || conductorErrors.err6,
+ },
+ },
+ );
+ await appendPressbooksJobMessages(jobID, [
+ `Pressbooks import failed: ${err?.message || conductorErrors.err6}`,
+ ]);
+ }
+}
+
+async function getPressBooksImportJobStatus(
+ req: ZodReqWithUser>,
+ res: Response,
+) {
+ try {
+ const { jobID } = req.params;
+ const requesterID = req.user.decoded.uuid;
+
+ const job = await PressbooksImportJob.findOne({ jobID }).lean();
+ if (!job) {
+ return res.status(404).send({
+ err: true,
+ errMsg: "Import job not found.",
+ });
+ }
+
+ if (job.userID !== requesterID) {
+ return res.status(403).send({
+ err: true,
+ errMsg: conductorErrors.err8,
+ });
+ }
+
return res.send({
err: false,
- path: result.path,
- url: result.url,
+ job: {
+ jobID: job.jobID,
+ status: job.status,
+ messages: job.messages || [],
+ errorMessage: job.errorMessage,
+ resultPath: job.resultPath,
+ resultURL: job.resultURL,
+ },
+ });
+ } catch (e) {
+ debugError(e);
+ return res.status(500).send({
+ err: true,
+ errMsg: conductorErrors.err6,
});
+ }
+}
- } catch (err: any) {
+async function getActivePressBooksImportJob(
+ req: ZodReqWithUser>,
+ res: Response,
+) {
+ try {
+ const { projectID } = req.query;
+ const requesterID = req.user.decoded.uuid;
+
+ const job = await PressbooksImportJob.findOne({
+ projectID,
+ userID: requesterID,
+ status: { $in: ["pending", "running"] },
+ })
+ .sort({ createdAt: -1 })
+ .lean();
+
+ if (!job) {
+ return res.send({
+ err: false,
+ job: null,
+ });
+ }
+
+ return res.send({
+ err: false,
+ job: {
+ jobID: job.jobID,
+ status: job.status,
+ messages: job.messages || [],
+ },
+ });
+ } catch (e) {
+ debugError(e);
return res.status(500).send({
err: true,
- errMsg: err.message,
+ errMsg: conductorErrors.err6,
});
}
}
@@ -2845,6 +3037,8 @@ export default {
getMasterCatalogV2,
createBook,
importPressBooksBook,
+ getPressBooksImportJobStatus,
+ getActivePressBooksImportJob,
deleteBook,
getBookDetail,
getBookPeerReviews,
diff --git a/server/api/validators/book.ts b/server/api/validators/book.ts
index d545d190..0aefa049 100644
--- a/server/api/validators/book.ts
+++ b/server/api/validators/book.ts
@@ -37,6 +37,18 @@ export const importPressBooksBookSchema = z.object({
}),
});
+export const getPressbooksImportJobStatusSchema = z.object({
+ params: z.object({
+ jobID: z.string().min(1),
+ }),
+});
+
+export const getActivePressbooksImportJobSchema = z.object({
+ query: z.object({
+ projectID: z.string().length(10),
+ }),
+});
+
export const getCommonsCatalogSchema = z.object({
query: z.object({
activePage: z.coerce.number().min(1).default(1),
diff --git a/server/models/pressbooksimportjob.ts b/server/models/pressbooksimportjob.ts
new file mode 100644
index 00000000..126c1549
--- /dev/null
+++ b/server/models/pressbooksimportjob.ts
@@ -0,0 +1,83 @@
+import { model, Schema, Document } from "mongoose";
+
+export type PressbooksImportJobStatus =
+ | "pending"
+ | "running"
+ | "success"
+ | "error";
+
+export interface PressbooksImportJobInterface extends Document {
+ jobID: string;
+ projectID: string;
+ userID: string;
+ library: number;
+ pbBookURL: string;
+ title?: string;
+ status: PressbooksImportJobStatus;
+ messages: string[];
+ errorMessage?: string;
+ resultPath?: string;
+ resultURL?: string;
+}
+
+const PressbooksImportJobSchema = new Schema(
+ {
+ jobID: {
+ type: String,
+ required: true,
+ unique: true,
+ index: true,
+ },
+ projectID: {
+ type: String,
+ required: true,
+ index: true,
+ },
+ userID: {
+ type: String,
+ required: true,
+ index: true,
+ },
+ library: {
+ type: Number,
+ required: true,
+ },
+ pbBookURL: {
+ type: String,
+ required: true,
+ },
+ title: {
+ type: String,
+ },
+ status: {
+ type: String,
+ enum: ["pending", "running", "success", "error"],
+ default: "pending",
+ index: true,
+ },
+ messages: {
+ type: [String],
+ default: [],
+ },
+ errorMessage: {
+ type: String,
+ },
+ resultPath: {
+ type: String,
+ },
+ resultURL: {
+ type: String,
+ },
+ },
+ {
+ timestamps: true,
+ },
+);
+
+const PressbooksImportJob = model(
+ "PressbooksImportJob",
+ PressbooksImportJobSchema,
+);
+
+export default PressbooksImportJob;
+
diff --git a/server/util/CXOne/CXOnePageProperties.ts b/server/util/CXOne/CXOnePageProperties.ts
index 899f7f16..53c3757f 100644
--- a/server/util/CXOne/CXOnePageProperties.ts
+++ b/server/util/CXOne/CXOnePageProperties.ts
@@ -4,6 +4,7 @@ const CXOnePageProperties = {
SubPageListing: 'mindtouch.idf#subpageListing',
GuideDisplay: 'mindtouch.idf#guideDisplay',
GuideTabs: 'mindtouch.idf#guideTabs',
+ ArticleType: 'mindtouch.page#articleType',
}
export default CXOnePageProperties;
\ No newline at end of file
diff --git a/server/util/CXOne/CXOneTemplates.ts b/server/util/CXOne/CXOneTemplates.ts
index 3bb9785d..7ff00070 100644
--- a/server/util/CXOne/CXOneTemplates.ts
+++ b/server/util/CXOne/CXOneTemplates.ts
@@ -7,6 +7,12 @@ const CXOneTemplates = {
{{template.ShowOrg()}}
article:topic-guide
`,
+ POST_CreateBookSection: `
+ {{template.ShowOrg()}}
+
+ article:topic-category
+
+`,
POST_GrantContributorRole: (userID: string) => `
Semi-Private
@@ -22,7 +28,7 @@ const CXOneTemplates = {
visibility: string,
editorIDs: string[],
viewerIDs: string[],
- libreBotID: string
+ libreBotID: string,
) =>
`
@@ -48,7 +54,7 @@ const CXOneTemplates = {
`,
PUT_FileProperties: (
- properties: { name: string; value: string; etag?: string }[]
+ properties: { name: string; value: string; etag?: string }[],
) => `
${properties.map((prop) => {
diff --git a/server/util/pressbookutils.ts b/server/util/pressbookutils.ts
index d6d72274..6d262753 100644
--- a/server/util/pressbookutils.ts
+++ b/server/util/pressbookutils.ts
@@ -5,8 +5,8 @@ import {
getPageID,
} from "./librariesclient";
import conductorErrors from "../conductor-errors";
-import { parse as parseHTML } from "node-html-parser";
import MindTouch from "./CXOne/index.js";
+import { License } from "../types";
const defaultImagesURL = "https://cdn.libretexts.net/DefaultImages";
@@ -40,6 +40,11 @@ export interface MindTouchConfig {
export interface PublishOptions {
auth?: { username: string; password: string };
+ /**
+ * Optional logging callback used to stream progress messages.
+ * Intended for background jobs (e.g., Pressbooks imports).
+ */
+ log?: (message: string) => void | Promise;
}
export interface PublishResult {
@@ -48,6 +53,12 @@ export interface PublishResult {
url: string;
bookID: string;
errMsg?: string;
+ authorsName?: string;
+ resourceURL?: string;
+ sourcePublicationDate?: Date;
+ license?: License;
+ thumbnail?: string;
+ isbn?: string;
}
export interface Author {
@@ -136,11 +147,52 @@ export class PressBookScraper {
private convertLatexShortcodes(html: string): string {
return html.replace(/\[latex](.*?)\[\/latex]/g, "\\($1\\)");
}
+ private getAuthorsName(metadata: any): string {
+ return metadata.authors.map((author: any) => author.name).join(", ");
+ }
async publishBook(options: PublishOptions): Promise {
const encodePbURL = this.pbBookURL.replace(/\/+$/, "");
const auth = options.auth;
- const result: PublishResult = { err: false, path: "", url: "", bookID: "" };
+ const result: PublishResult = {
+ err: false,
+ path: "",
+ url: "",
+ bookID: "",
+ authorsName: undefined,
+ resourceURL: undefined,
+ sourcePublicationDate: undefined,
+ license: undefined,
+ };
+ const log = (message: string) => {
+ // Always log to server console
+ // eslint-disable-next-line no-console
+ console.log(message);
+ // Optionally forward to external logger (e.g., job status)
+ if (typeof options.log === "function") {
+ try {
+ const maybePromise = options.log(message);
+ if (
+ maybePromise &&
+ typeof (maybePromise as any).then === "function"
+ ) {
+ (maybePromise as Promise).catch((e) => {
+ // eslint-disable-next-line no-console
+ console.error(
+ "[PressBookScraper] Error in external log callback:",
+ e,
+ );
+ });
+ }
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error(
+ "[PressBookScraper] Error invoking external log callback:",
+ e,
+ );
+ }
+ }
+ };
// ── 1. Metadata ──────────────────────────────────────────────────────────
let metadata: any = {};
@@ -158,7 +210,7 @@ export class PressBookScraper {
}
// ── 2. TOC — single source of truth for structure AND order ──────────────
- console.log("[*] Fetching TOC...");
+ log("[*] Fetching TOC...");
let toc: TocShape;
try {
toc = (await this.getJson(
@@ -180,8 +232,40 @@ export class PressBookScraper {
const [bookPath, bookURL] = generateBookPathAndURL(this.subdomain, title);
result.path = bookPath;
result.url = bookURL;
- console.log("[*] bookPath:", bookPath);
- console.log("[*] bookURL:", bookURL);
+ log(`[*] bookPath: ${bookPath}`);
+ log(`[*] bookURL: ${bookURL}`);
+ // ── 3.1 Authors Name ──────────────────────────────────────────────────────
+ try {
+ result.authorsName = this.getAuthorsName(metadata);
+ } catch {
+ result.authorsName = undefined;
+ }
+ // ── 3.2 Source Publication Date ────────────────────────────────────────────
+ try {
+ // "datePublished": "2024-01-18",
+ result.sourcePublicationDate = new Date(metadata.datePublished);
+ log(`[*] result.sourcePublicationDate: ${result.sourcePublicationDate}`);
+ } catch {
+ result.sourcePublicationDate = undefined;
+ }
+ // ── 3.3 License ────────────────────────────────────────────────────────────
+ try {
+ const license: License = {
+ name: metadata.license.name,
+ url: metadata.license.url,
+ };
+ result.license = license;
+ } catch {
+ result.license = undefined;
+ }
+ // ── 3.4 Resource URL ───────────────────────────────────────────────────────
+ result.resourceURL = this.pbBookURL;
+ // ── 3.5 Thumbnail ────────────────────────────────────────────────────────────
+ try {
+ result.thumbnail = metadata.image;
+ } catch {
+ result.thumbnail = `${defaultImagesURL}/default.png`;
+ }
// ── 4. Create the CXOne book root ─────────────────────────────────────────
const createBookRes = await CXOneFetch({
@@ -206,7 +290,9 @@ export class PressBookScraper {
addPageProperty(this.subdomain, bookPath, "SubPageListing", "simple"),
]);
- const imageRes = await fetch(`${defaultImagesURL}/default.png`);
+ const imageRes = await fetch(
+ result.thumbnail || `${defaultImagesURL}/default.png`,
+ );
await CXOneFetch({
scope: "page",
path: bookPath,
@@ -216,8 +302,8 @@ export class PressBookScraper {
});
// ── 5. Front Matter container + items ─────────────────────────────────────
- console.log(`\n[*] Front Matter: ${toc["front-matter"].length} items`);
- const frontMatterContainerPath = `${bookPath}/Front_Matter`;
+ log(`\n[*] Front Matter: ${toc["front-matter"].length} items`);
+ const frontMatterContainerPath = `${bookPath}/00:_Front_Matter`;
// isContainer: true — Front_Matter holds child pages
await this.upsertCXOnePage(
@@ -235,7 +321,7 @@ export class PressBookScraper {
const node = sortedFrontMatter[i];
const seq = String(i + 1).padStart(2, "0");
const pagePath = `${frontMatterContainerPath}/${seq}:_${this.slugifyNode(node)}`;
- console.log(` [FM ${seq}] ${node.title}`);
+ log(` [FM ${seq}] ${node.title}`);
const content = node.has_post_content
? await this.fetchNodeContent(
@@ -250,8 +336,24 @@ export class PressBookScraper {
await this.upsertCXOnePage(pagePath, node.title, content, false);
}
+ await Promise.all([
+ addPageProperty(
+ this.subdomain,
+ frontMatterContainerPath,
+ "SubPageListing",
+ "simple",
+ ),
+ addPageProperty(
+ this.subdomain,
+ frontMatterContainerPath,
+ "WelcomeHidden",
+ true,
+ ),
+
+ ]);
+
// ── 6. Parts → Chapters ───────────────────────────────────────────────────
- console.log(`\n[*] Parts: ${toc.parts.length}`);
+ log(`\n[*] Parts: ${toc.parts.length}`);
const sortedParts = [...toc.parts].sort(
(a, b) => a.menu_order - b.menu_order,
);
@@ -260,7 +362,7 @@ export class PressBookScraper {
const part = sortedParts[pi];
const partSeq = String(pi + 1).padStart(2, "0");
const partPath = `${bookPath}/${partSeq}:${this.slugifyNode(part)}`;
- console.log(
+ log(
`\n [Part ${partSeq}] ${part.title} (${part.chapters.length} chapters)`,
);
@@ -271,7 +373,7 @@ export class PressBookScraper {
// isContainer: true — Parts hold chapters under them
await this.upsertCXOnePage(
partPath,
- `${partSeq}:_${part.title}`,
+ `${partSeq}: ${part.title}`,
partContent,
true,
);
@@ -284,7 +386,7 @@ export class PressBookScraper {
const chapter = sortedChapters[ci];
const chapterSeq = String(ci + 1).padStart(2, "0");
const chapterPath = `${partPath}/${chapterSeq}:_${this.slugifyNode(chapter)}`;
- console.log(` [Ch ${chapterSeq}] ${chapter.title}`);
+ log(` [Ch ${chapterSeq}] ${chapter.title}`);
const content = chapter.has_post_content
? await this.fetchNodeContent(
@@ -298,12 +400,20 @@ export class PressBookScraper {
// isContainer: false — Chapters are leaf content pages
await this.upsertCXOnePage(chapterPath, chapter.title, content, false);
}
+
+ await Promise.all([
+ addPageProperty(this.subdomain, partPath, "SubPageListing", "simple"),
+ addPageProperty(this.subdomain, partPath, "WelcomeHidden", true),
+ // addPageProperty(this.subdomain, partPath, "ArticleType", "Guide"),
+ // addPageProperty(this.subdomain, partPath, "GuideTabs", MindTouch.Templates.PROP_GuideTabs),
+ // addPageProperty(this.subdomain, partPath, "GuideDisplay", "single"),
+ ]);
}
// ── 7. Back Matter container + items ──────────────────────────────────────
- console.log(`\n[*] Back Matter: ${toc["back-matter"].length} items`);
- const backMatterContainerPath = `${bookPath}/Back_Matter`;
-
+ log(`\n[*] Back Matter: ${toc["back-matter"].length} items`);
+ const backMatterSeq = String(sortedParts.length + 1).padStart(2, "0");
+ const backMatterContainerPath = `${bookPath}/${backMatterSeq}:_Back_Matter`;
// isContainer: true — Back_Matter holds child pages
await this.upsertCXOnePage(
backMatterContainerPath,
@@ -320,27 +430,46 @@ export class PressBookScraper {
const node = sortedBackMatter[i];
const seq = String(i + 1).padStart(2, "0");
const pagePath = `${backMatterContainerPath}/${seq}:_${this.slugifyNode(node)}`;
- console.log(` [BM ${seq}] ${node.title}`);
-
- const content = node.has_post_content
- ? await this.fetchNodeContent(encodePbURL, "back-matter", node.id, auth)
- : "";
+ log(
+ ` [BM ${seq}] ${node.title}, ${node.has_post_content}, id: ${node.id}`,
+ );
+ const content =
+ (await this.fetchNodeContent(
+ encodePbURL,
+ "back-matter",
+ node.id,
+ auth,
+ )) || "";
// isContainer: false — Back Matter items are leaf content pages
await this.upsertCXOnePage(pagePath, node.title, content, false);
}
+ await Promise.all([
+ addPageProperty(
+ this.subdomain,
+ backMatterContainerPath,
+ "SubPageListing",
+ "simple",
+ ),
+ addPageProperty(
+ this.subdomain,
+ backMatterContainerPath,
+ "WelcomeHidden",
+ true,
+ ),
+ ]);
// ── 8. Trigger MindMap TOC update ─────────────────────────────────────────
- console.log("[*] Triggering MindMap TOC update...");
- fetch(`https://batch.libretexts.org/print/Libretext=${bookURL}`, {
- headers: { origin: "commons.libretexts.org" },
- }).catch((e) => {
- console.warn(
- "[PressBookScraper] MindMap trigger failed (non-fatal):",
- (e as Error).message,
- );
- });
- await sleep(1500);
+ // log("[*] Triggering MindMap TOC update...");
+ // fetch(`https://batch.libretexts.org/print/Libretext=${bookURL}`, {
+ // headers: { origin: "commons.libretexts.org" },
+ // }).catch((e) => {
+ // console.warn(
+ // "[PressBookScraper] MindMap trigger failed (non-fatal):",
+ // (e as Error).message,
+ // );
+ // });
+ // await sleep(1500);
// ── 9. Verify book ID ─────────────────────────────────────────────────────
const newBookID = await getPageID(bookPath, this.subdomain);
@@ -349,6 +478,18 @@ export class PressBookScraper {
`Error locating Workbench book ID after import: "${title}"`,
);
}
+
+ // const res = await CXOneFetch({
+ // scope: "users",
+ // subdomain: this.subdomain,
+ // query: {
+ // verbose: "false",
+ // seatfilter: "seated",
+ // limit: "all",
+ // },
+ // });
+ // const raw = await res.json();
+ // console.log(raw);
result.bookID = newBookID;
return result;
}
@@ -366,6 +507,7 @@ export class PressBookScraper {
auth?: { username: string; password: string },
): Promise {
try {
+ console.log(`[*] id: ${id}`);
const url = `${this.pbApi(bookUrl)}/${postType}/${id}?_embed=1`;
const item = await this.getJson(url, auth);
const html: string = item.content?.rendered ?? "";
@@ -434,7 +576,7 @@ export class PressBookScraper {
query: { edittime: "now", comment: "Imported from Pressbooks" },
options: {
method: "POST",
- body: contentHtml || `${title || pagePath}
`,
+ body: `${!isContainer ? MindTouch.Templates.POST_CreateBookChapter : MindTouch.Templates.POST_CreateBookSection}\n${contentHtml || ""}`,
headers: { "Content-Type": "text/plain; charset=utf-8" },
},
});
@@ -445,22 +587,21 @@ export class PressBookScraper {
);
}
- // Set page properties — all pages get these three
- const pageProps: Promise[] = [
- addPageProperty(this.subdomain, pagePath, "WelcomeHidden", true),
- addPageProperty(this.subdomain, pagePath, "GuideDisplay", "single"),
- addPageProperty(
- this.subdomain,
- pagePath,
- "GuideTabs",
- MindTouch.Templates.PROP_GuideTabs,
- ),
- ];
+ // Set page properties — all pages get these three+
+ const pageProps: Promise[] = [];
// Container pages additionally need SubPageListing to show child pages
- if (isContainer) {
+ if (!isContainer) {
pageProps.push(
- addPageProperty(this.subdomain, pagePath, "SubPageListing", "simple"),
+ addPageProperty(this.subdomain, pagePath, "WelcomeHidden", true),
+ addPageProperty(this.subdomain, pagePath, "ArticleType", "Topic"),
+ addPageProperty(this.subdomain, pagePath, "GuideDisplay", "single"),
+ addPageProperty(
+ this.subdomain,
+ pagePath,
+ "GuideTabs",
+ MindTouch.Templates.PROP_GuideTabs,
+ ),
);
}
From 563543df3244dd83ae955d4f9a6d29941e28dc03 Mon Sep 17 00:00:00 2001
From: yghaemi
Date: Wed, 22 Apr 2026 15:54:29 -0400
Subject: [PATCH 4/8] feat: remixer v3
---
client/package-lock.json | 74 +-
client/package.json | 7 +-
client/src/Conductor.jsx | 3 +
client/src/api.ts | 256 ++-
.../projects/CreateWorkbenchModal.tsx | 2 +-
.../remixer/BookContent/ContextMenu.tsx | 127 +
.../remixer/BookContent/Dashboard.tsx | 748 ++++++
.../remixer/BookContent/TreeNodeContainer.tsx | 190 ++
.../remixer/BookContent/TreeSkeleton.tsx | 30 +
.../remixer/CatalogBook/CatalogList.tsx | 238 ++
.../src/components/remixer/ControlPanel.tsx | 232 ++
client/src/components/remixer/EditPanel.tsx | 129 ++
.../src/components/remixer/PathNameFormat.tsx | 277 +++
.../src/components/remixer/PublishPanel.tsx | 242 ++
.../src/components/remixer/RecoveryModal.tsx | 124 +
.../components/remixer/RemixerDashboard.tsx | 2043 +++++++++++++++++
client/src/components/remixer/model.ts | 158 ++
client/src/components/remixer/services.ts | 756 ++++++
client/src/components/remixer/style.ts | 14 +
client/src/index.jsx | 2 -
client/src/types/Project.ts | 5 +
package-lock.json | 4 +-
server/api.js | 279 ++-
server/api/adoptionreports.js | 416 ++--
server/api/apiclients.js | 4 +-
server/api/mail.js | 895 ++++----
server/api/oauth.js | 34 +-
server/api/remixer.ts | 484 ++++
server/api/services/authkey.ts | 19 +
server/api/services/remixer-service.ts | 862 +++++++
server/api/tasks.js | 1945 ++++++++--------
server/api/translationfeedback.js | 282 ++-
server/api/validators/remixer.ts | 42 +
.../AddRegisteredByToOrgEventParticipant.js | 18 +-
...Projects_CIDDescriptorsSingleToMultiple.js | 4 +-
server/models/pressbooksimportjob.ts | 2 +
server/models/projectremixer.ts | 145 ++
server/models/projectremixerjob.ts | 68 +
server/package-lock.json | 18 +
server/package.json | 1 +
server/server.ts | 8 +-
server/util/CXOne/CXOnePageAPIEndpoints.ts | 5 +-
server/util/CXOne/CXOneRemixerTemplates.ts | 152 ++
server/util/helpers.js | 220 +-
server/util/librariesclient.ts | 14 +-
server/util/mtkeys.js | 40 +-
server/util/peerreviewutils.js | 194 +-
server/util/pressbookutils.ts | 3 +-
server/util/remixerutils.ts | 46 +
server/util/scopes.js | 18 +-
50 files changed, 9588 insertions(+), 2291 deletions(-)
create mode 100644 client/src/components/remixer/BookContent/ContextMenu.tsx
create mode 100644 client/src/components/remixer/BookContent/Dashboard.tsx
create mode 100644 client/src/components/remixer/BookContent/TreeNodeContainer.tsx
create mode 100644 client/src/components/remixer/BookContent/TreeSkeleton.tsx
create mode 100644 client/src/components/remixer/CatalogBook/CatalogList.tsx
create mode 100644 client/src/components/remixer/ControlPanel.tsx
create mode 100644 client/src/components/remixer/EditPanel.tsx
create mode 100644 client/src/components/remixer/PathNameFormat.tsx
create mode 100644 client/src/components/remixer/PublishPanel.tsx
create mode 100644 client/src/components/remixer/RecoveryModal.tsx
create mode 100644 client/src/components/remixer/RemixerDashboard.tsx
create mode 100644 client/src/components/remixer/model.ts
create mode 100644 client/src/components/remixer/services.ts
create mode 100644 client/src/components/remixer/style.ts
create mode 100644 server/api/remixer.ts
create mode 100644 server/api/services/authkey.ts
create mode 100644 server/api/services/remixer-service.ts
create mode 100644 server/api/validators/remixer.ts
create mode 100644 server/models/projectremixer.ts
create mode 100644 server/models/projectremixerjob.ts
create mode 100644 server/util/CXOne/CXOneRemixerTemplates.ts
create mode 100644 server/util/remixerutils.ts
diff --git a/client/package-lock.json b/client/package-lock.json
index e3d6376f..7f29285c 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -11,6 +11,9 @@
"@ckeditor/ckeditor5-build-multi-root": "^41.0.0",
"@ckeditor/ckeditor5-react": "^6.2.0",
"@cloudflare/stream-react": "^1.9.1",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@fortawesome/fontawesome-free": "^5.15.4",
"@headlessui/react": "^2.2.4",
"@headlessui/tailwindcss": "^0.2.2",
@@ -36,7 +39,7 @@
"@uppy/progress-bar": "^4.3.2",
"@uppy/react": "^4.5.2",
"@uppy/screen-capture": "^4.4.2",
- "axios": "^1.12.0",
+ "axios": "^1.13.5",
"canvas": "^3.1.0",
"classnames": "^2.5.1",
"d3": "^7.9.0",
@@ -47,7 +50,7 @@
"extended-eventsource": "^2.1.0",
"file-saver": "^2.0.5",
"focus-trap-react": "^10.1.1",
- "frappe-gantt": "^0.5.0",
+ "frappe-gantt": "^1.2.1",
"fuse.js": "^7.0.0",
"html-react-parser": "^1.2.7",
"js-cookie": "^2.2.1",
@@ -6322,6 +6325,55 @@
"react": ">=16"
}
},
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
@@ -10386,13 +10438,12 @@
}
},
"node_modules/axios": {
- "version": "1.13.2",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
- "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
- "license": "MIT",
+ "version": "1.13.5",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
+ "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"dependencies": {
- "follow-redirects": "^1.15.6",
- "form-data": "^4.0.4",
+ "follow-redirects": "^1.15.11",
+ "form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
@@ -12455,10 +12506,9 @@
}
},
"node_modules/frappe-gantt": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/frappe-gantt/-/frappe-gantt-0.5.0.tgz",
- "integrity": "sha512-RAskVuBmnTcPJXh87oVhYmnNGy/9lvZlLHGui8QFB8yRBuUjzpZoZfZ+hKmDtBDmWNrE2/LRta06W5WmhTzzWQ==",
- "license": "MIT"
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/frappe-gantt/-/frappe-gantt-1.2.1.tgz",
+ "integrity": "sha512-jBd3ZfDuQnWl4+s01nHhn+w9RlX2OzXZjdZvbw/obZDhJNFpS2sGTYkS+ua2Y0+v6jVF2D1ei5OsSCnNV4ReoA=="
},
"node_modules/fs-constants": {
"version": "1.0.0",
diff --git a/client/package.json b/client/package.json
index e6125c97..91586d79 100644
--- a/client/package.json
+++ b/client/package.json
@@ -12,6 +12,9 @@
"@ckeditor/ckeditor5-build-multi-root": "^41.0.0",
"@ckeditor/ckeditor5-react": "^6.2.0",
"@cloudflare/stream-react": "^1.9.1",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@fortawesome/fontawesome-free": "^5.15.4",
"@headlessui/react": "^2.2.4",
"@headlessui/tailwindcss": "^0.2.2",
@@ -37,7 +40,7 @@
"@uppy/progress-bar": "^4.3.2",
"@uppy/react": "^4.5.2",
"@uppy/screen-capture": "^4.4.2",
- "axios": "^1.12.0",
+ "axios": "^1.13.5",
"canvas": "^3.1.0",
"classnames": "^2.5.1",
"d3": "^7.9.0",
@@ -48,7 +51,7 @@
"extended-eventsource": "^2.1.0",
"file-saver": "^2.0.5",
"focus-trap-react": "^10.1.1",
- "frappe-gantt": "^0.5.0",
+ "frappe-gantt": "^1.2.1",
"fuse.js": "^7.0.0",
"html-react-parser": "^1.2.7",
"js-cookie": "^2.2.1",
diff --git a/client/src/Conductor.jsx b/client/src/Conductor.jsx
index a51182de..74b48285 100644
--- a/client/src/Conductor.jsx
+++ b/client/src/Conductor.jsx
@@ -90,6 +90,7 @@ import LibreTextsPrivateRoute from './components/util/LibreTextsPrivateRoute';
import StoreNavbar from './components/navigation/StoreNavbar';
import CartProvider from './providers/CartProvider';
import SupportCenterProvider from './providers/SupportCenterProvider';
+import RemixerDashboard from './components/remixer/RemixerDashboard';
const RenderNavbar = () => {
if(window.location.pathname.includes('/insight') || window.location.pathname.includes('/support')){
@@ -166,6 +167,8 @@ const Conductor = () => {
+ {/* Remixer routes */}
+
{/* LibreTexts org public routes */}
diff --git a/client/src/api.ts b/client/src/api.ts
index a6828e9d..d721017a 100644
--- a/client/src/api.ts
+++ b/client/src/api.ts
@@ -67,6 +67,7 @@ import {
} from "./types";
import {
AddableProjectTeamMember,
+ AuthenBrowser,
CIDDescriptor,
ProjectBookBatchUpdateJob,
ProjectFileAuthor,
@@ -198,7 +199,7 @@ class API {
file: FormData,
- opts?: AxiosRequestConfig
+ opts?: AxiosRequestConfig,
) {
const res = await axios.post(
`/project/${projectID}/files`,
@@ -208,7 +209,7 @@ class API {
"Content-Type": "multipart/form-data",
},
...opts,
- }
+ },
);
return res;
}
@@ -216,14 +217,14 @@ class API {
async addProjectFileFolder(
projectID: string,
name: string,
- parentID?: string
+ parentID?: string,
) {
const res = await axios.post(
`/project/${projectID}/files/folder`,
{
name,
parentID,
- }
+ },
);
return res;
}
@@ -232,7 +233,7 @@ class API {
projectID: string,
fileID: string,
file: FormData,
- opts?: AxiosRequestConfig
+ opts?: AxiosRequestConfig,
) {
const res = await axios.put(
`/project/${projectID}/files/${fileID}`,
@@ -242,7 +243,7 @@ class API {
"Content-Type": "multipart/form-data",
},
...opts,
- }
+ },
);
return res;
}
@@ -259,7 +260,7 @@ class API {
async uploadProjectFileCaptions(
projectID: string,
fileID: string,
- captions: FormData
+ captions: FormData,
) {
const res = await axios.put(
`/project/${projectID}/files/${fileID}/captions`,
@@ -268,7 +269,7 @@ class API {
headers: {
"Content-Type": "multipart/form-data",
},
- }
+ },
);
return res;
}
@@ -338,7 +339,7 @@ class API {
async getAuthorAssets(
id: string,
- paramsObj?: { page?: number; limit?: number }
+ paramsObj?: { page?: number; limit?: number },
) {
const res = await axios.get<
{
@@ -420,7 +421,7 @@ class API {
async getPageDetails(pageID: string, coverPageID: string) {
const nonce = Math.random().toString(36).substring(7);
const res = await axios.get(
- `/commons/pages/${pageID}?coverPageID=${coverPageID}?nonce=${nonce}`
+ `/commons/pages/${pageID}?coverPageID=${coverPageID}?nonce=${nonce}`,
);
return res;
}
@@ -446,7 +447,7 @@ class API {
async generatePageImagesAltText(
pageID: string,
coverPageID: string,
- overwrite: boolean
+ overwrite: boolean,
) {
const res = await axios.post<
{
@@ -469,7 +470,7 @@ class API {
summaries: { generate: boolean; overwrite: boolean };
tags: { generate: boolean; overwrite: boolean };
alttext: { generate: boolean; overwrite: boolean };
- }
+ },
) {
return new EventSource(
`${this.BASE_URL}/co-author/books/${bookID}/ai-metadata-batch`,
@@ -480,7 +481,7 @@ class API {
withCredentials: true,
method: "POST",
body: JSON.stringify(config),
- }
+ },
);
}
@@ -491,7 +492,7 @@ class API {
*/
batchUpdateBookMetadata(
bookID: string,
- pages: { id: string; summary: string; tags: string[] }[]
+ pages: { id: string; summary: string; tags: string[] }[],
) {
return new EventSource(
`${this.BASE_URL}/co-author/books/${bookID}/update-metadata-batch`,
@@ -502,25 +503,25 @@ class API {
withCredentials: true,
method: "POST",
body: JSON.stringify({ pages }),
- }
+ },
);
}
async updatePageDetails(
pageID: string,
coverPageID: string,
- data: { summary: string; tags: string[] }
+ data: { summary: string; tags: string[] },
) {
const res = await axios.patch(
`/commons/pages/${pageID}?coverPageID=${coverPageID}`,
- data
+ data,
);
return res;
}
async bulkUpdatePageTags(
bookID: string,
- pages: { id: string; tags: string[] }[]
+ pages: { id: string; tags: string[] }[],
) {
const res = await axios.put<
{
@@ -645,7 +646,7 @@ class API {
"/store/admin/orders",
{
params,
- }
+ },
);
return res;
}
@@ -848,7 +849,7 @@ class API {
async getCentralIdentityPublicApps() {
return await axios.get<{ applications: CentralIdentityApp[] }>(
- "/central-identity/public/apps"
+ "/central-identity/public/apps",
);
}
@@ -924,7 +925,7 @@ class API {
async updateCentralIdentityService(body: { body: string }, id: number) {
const res = await axios.put(
`/central-identity/services/${id}`,
- body
+ body,
);
return res;
@@ -966,7 +967,7 @@ class API {
async updateCentralIdentityUser(
uuid: string,
- data: Partial
+ data: Partial,
) {
const res = await axios.patch<
{
@@ -988,32 +989,32 @@ class API {
async disableCentralIdentityUser(uuid: string, reason: string) {
const res = await axios.patch(
`/central-identity/users/${uuid}/disable`,
- { reason }
+ { reason },
);
return res;
}
async deleteCentralIdentityUser(uuid: string) {
const res = await axios.delete(
- `/central-identity/users/${uuid}`
+ `/central-identity/users/${uuid}`,
);
return res;
}
async reEnableCentralIdentityUser(uuid: string) {
const res = await axios.patch(
- `/central-identity/users/${uuid}/re-enable`
+ `/central-identity/users/${uuid}/re-enable`,
);
return res;
}
async updateCentralIdentityUserOrgs(
uuid: string,
- orgs: Array
+ orgs: Array,
) {
const res = await axios.post(
`/central-identity/users/${uuid}/orgs`,
- { orgs }
+ { orgs },
);
return res;
}
@@ -1021,18 +1022,18 @@ class API {
async updateCentralIdentityUserOrgAdminRole(
uuid: string,
orgId: string | number,
- admin_role: string
+ admin_role: string,
) {
const res = await axios.patch(
`/central-identity/users/${uuid}/orgs/${orgId}/admin-role`,
- { admin_role }
+ { admin_role },
);
return res;
}
async updateCentralIdentityUserAcademyOnlineAccess(
uuid: string,
- data: EditAcademyOnlineAccessFormValues
+ data: EditAcademyOnlineAccessFormValues,
) {
const res = await axios.patch<
{
@@ -1067,7 +1068,7 @@ class API {
async revokeCentralIdentityAppLicense(
// Either user_id or org_id must be present, but not both
- data: { user_id?: string; org_id?: string; application_license_id: string }
+ data: { user_id?: string; org_id?: string; application_license_id: string },
) {
const res = await axios.post<
{
@@ -1080,13 +1081,13 @@ class API {
async bulkGenerateCentralIdentityAppLicenseAccessCodes(
application_license_id: string,
- quantity: number
+ quantity: number,
) {
const res = await axios.post(
`/central-identity/app-licenses/${application_license_id}/bulk-generate`,
{
quantity,
- }
+ },
);
return res;
}
@@ -1103,7 +1104,7 @@ class API {
// Client Config
async getClientConfig() {
return await axios.get<{ data: ClientConfig } & ConductorBaseResponse>(
- "/config"
+ "/config",
);
}
@@ -1133,13 +1134,13 @@ class API {
async getMasterCatalogV2() {
return await axios.get(
- "/commons/mastercatalog/v2"
+ "/commons/mastercatalog/v2",
);
}
async syncWithLibraries() {
return await axios.post<{ msg: string } & ConductorBaseResponse>(
- "/commons/syncwithlibs"
+ "/commons/syncwithlibs",
);
}
@@ -1148,29 +1149,29 @@ class API {
`/commons/catalogs/addresource`,
{
bookID,
- }
+ },
);
}
async disableBookOnCommons(bookID: string) {
return await axios.put(
`/commons/catalogs/removeresource`,
- { bookID }
+ { bookID },
);
- };
+ }
async excludeBookFromAutoCatalogMatching(bookID: string) {
return await axios.put(
`/commons/catalogs/exclude-auto-match`,
- { bookID }
+ { bookID },
);
- };
+ }
// Harvest Requests
async createHarvestRequest(data: HarvestRequest) {
const res = await axios.post(
"/harvestingrequest",
- data
+ data,
);
return res;
}
@@ -1353,11 +1354,11 @@ class API {
orgID: string,
params: {
autoCatalogMatchingEnabled: boolean;
- }
+ },
) {
const res = await axios.patch(
`/org/${orgID}/automatic-catalog-matching`,
- params
+ params,
);
return res;
}
@@ -1385,7 +1386,7 @@ class API {
async reSyncProjectTeamBookAccess(projectID: string) {
const res = await axios.put(
- `/project/${projectID}/team/re-sync`
+ `/project/${projectID}/team/re-sync`,
);
return res;
}
@@ -1422,11 +1423,11 @@ class API {
| {
action: "add-folder" | "remove-folder";
folder: string;
- }
+ },
) {
const res = await axios.patch(
"/user/projects/pinned",
- data
+ data,
);
return res;
}
@@ -1485,7 +1486,7 @@ class API {
async getTags() {
const res = await axios.get<{ tags: ProjectTag[] } & ConductorBaseResponse>(
- "projects/tags/org"
+ "projects/tags/org",
);
return res;
}
@@ -1506,7 +1507,7 @@ class API {
async getProjectFiles(
projectID: string,
folderID?: string,
- publicOnly = false
+ publicOnly = false,
) {
const res = await axios.get<
{
@@ -1533,7 +1534,7 @@ class API {
async getFileDownloadURL(
projectID: string,
fileID: string,
- shouldIncrement?: boolean
+ shouldIncrement?: boolean,
) {
const res = await axios.get<
{
@@ -1547,10 +1548,7 @@ class API {
return res;
}
- async bulkDownloadFiles(
- projectID: string,
- fileIDs: string[]
- ) {
+ async bulkDownloadFiles(projectID: string, fileIDs: string[]) {
const arrQuery = fileIDs.map((id) => `fileID=${id}`).join(`&`);
const res = await axios.get<{ file?: string } & ConductorBaseResponse>(
`/project/${projectID}/files/bulk`,
@@ -1558,7 +1556,7 @@ class API {
params: {
fileIDs: arrQuery,
},
- }
+ },
);
return res;
}
@@ -1569,14 +1567,14 @@ class API {
data: {
tags: AssetTag[];
tagMode: "replace" | "append";
- }
+ },
) {
return await axios.patch<{ files: ProjectFile[] } & ConductorBaseResponse>(
`/project/${projectID}/files/bulk`,
{
fileIDs,
...data,
- }
+ },
);
}
@@ -1605,7 +1603,7 @@ class API {
"/kb/oembed",
{
params: { url },
- }
+ },
);
return res;
}
@@ -1635,7 +1633,7 @@ class API {
async deleteTicket(ticketID: string) {
const res = await axios.delete(
- `/support/ticket/${ticketID}`
+ `/support/ticket/${ticketID}`,
);
return res;
}
@@ -1661,14 +1659,14 @@ class API {
priority,
status,
queue,
- }
+ },
);
}
async getTicketAttachmentURL(
ticketID: string,
attachmentID: string,
- guestAccessKey?: string
+ guestAccessKey?: string,
) {
const res = await axios.get<
{
@@ -1720,7 +1718,7 @@ class API {
} & ConductorBaseResponse
>(
`/commons/collection/${encodeURIComponent(
- collIDOrTitle ?? ""
+ collIDOrTitle ?? "",
)}/resources`,
{
params: {
@@ -1729,7 +1727,7 @@ class API {
sort,
query,
},
- }
+ },
);
}
@@ -1794,13 +1792,13 @@ class API {
async deleteCollection(id: string) {
return await axios.delete(
- `/commons/collection/${id}`
+ `/commons/collection/${id}`,
);
}
async deleteCollectionResource(collID: string, resourceID: string) {
return await axios.delete(
- `/commons/collection/${collID}/resources/${resourceID}`
+ `/commons/collection/${collID}/resources/${resourceID}`,
);
}
@@ -1857,7 +1855,7 @@ class API {
`/support/ticket/${ticketID}/assign`,
{
assigned,
- }
+ },
);
}
@@ -1866,7 +1864,7 @@ class API {
`/support/ticket/${ticketID}/cc`,
{
email,
- }
+ },
);
}
@@ -1877,7 +1875,7 @@ class API {
data: {
email,
},
- }
+ },
);
}
@@ -1920,7 +1918,7 @@ class API {
async createProjectInvitation(
projectID: string,
email: string,
- role: string
+ role: string,
) {
const res = await axios.post<
{
@@ -1936,7 +1934,7 @@ class API {
async getAllProjectInvitations(
projectID: string,
page: number = 1,
- limit: number
+ limit: number,
) {
const res = await axios.get<
{
@@ -1991,7 +1989,7 @@ class API {
{},
{
params: { token },
- }
+ },
);
return res.data;
@@ -2067,7 +2065,7 @@ class API {
// Project Traffic Analytics
async getProjectTrafficAnalyticsAggregatedMetricsByPage(
params: TrafficAnalyticsBaseRequestParams,
- signal?: AbortSignal
+ signal?: AbortSignal,
) {
const { projectID, ...rest } = params;
return await axios.get<
@@ -2079,13 +2077,13 @@ class API {
{
params: rest,
signal,
- }
+ },
);
}
async getProjectTrafficAnalyticsPageViews(
params: TrafficAnalyticsBaseRequestParams,
- signal?: AbortSignal
+ signal?: AbortSignal,
) {
const { projectID, ...rest } = params;
return await axios.get<
@@ -2100,7 +2098,7 @@ class API {
async getProjectTrafficAnalyticsUniqueVisitors(
params: TrafficAnalyticsBaseRequestParams,
- signal?: AbortSignal
+ signal?: AbortSignal,
) {
const { projectID, ...rest } = params;
return await axios.get<
@@ -2115,7 +2113,7 @@ class API {
async getProjectTrafficAnalyticsVisitorCountries(
params: TrafficAnalyticsBaseRequestParams,
- signal?: AbortSignal
+ signal?: AbortSignal,
) {
const { projectID, ...rest } = params;
return await axios.get<
@@ -2153,6 +2151,109 @@ class API {
return res.data;
}
+ async getRemixerProject(id: string) {
+ const res = await axios.get<
+ {
+ project: Project;
+ } & ConductorBaseResponse
+ >(`/remixer/${id}/project`);
+ return res.data;
+ }
+
+ async saveRemixerProjectState(
+ id: string,
+ currentBook: unknown[],
+ settings?: { autoNumbering?: boolean; copyModeState?: string; pathLevelFormats?: unknown[] },
+ ) {
+ const res = await axios.put<
+ {
+ projectID: string;
+ currentBook: unknown[];
+ autoNumbering?: boolean;
+ copyModeState?: string;
+ pathLevelFormats?: unknown[];
+ } & ConductorBaseResponse
+ >(`/remixer/${id}/project`, {
+ currentBook,
+ ...settings,
+ });
+ return res.data;
+ }
+
+ async publishRemixerProject(
+ id: string,
+ currentBook: unknown[],
+ settings?: { autoNumbering?: boolean; copyModeState?: string; pathLevelFormats?: unknown[] },
+ ) {
+ const res = await axios.post<
+ {
+ projectID: string;
+ currentBook: unknown[];
+ } & ConductorBaseResponse
+ >(`/remixer/${id}/publish`, { currentBook, ...settings });
+ return res.data;
+ }
+
+ async getRemixerPublishJobStatus(id: string) {
+ const res = await axios.get<
+ {
+ job: {
+ jobID: string;
+ projectID: string;
+ userID: string;
+ status: "pending" | "running" | "success" | "error";
+ messages: string[];
+ errorMessage?: string;
+ createdAt?: string;
+ updatedAt?: string;
+ } | null;
+ } & ConductorBaseResponse
+ >(`/remixer/${id}/publish`);
+ return res.data;
+ }
+
+ async getRemixerProjectState(id: string) {
+ const res = await axios.post<
+ {
+ projectID: string;
+ currentBook: unknown[];
+ autoNumbering?: boolean;
+ copyModeState?: string;
+ pathLevelFormats?: unknown[];
+ } & ConductorBaseResponse
+ >(`/remixer/${id}/project`, {});
+ return res.data;
+ }
+
+ async deleteRemixerProjectState(id: string) {
+ const res = await axios.delete<
+ {
+ projectID: string;
+ currentBook: unknown[];
+ } & ConductorBaseResponse
+ >(`/remixer/${id}/project`);
+ return res.data;
+ }
+
+ async getRemixerPage(
+ id: string,
+ path: string,
+ subdomain: string,
+ pageDetails: boolean = false,
+ currentbook: boolean = true,
+ option: { includeMatter: boolean; linkTitle: boolean; full: boolean },
+ ) {
+
+ const res = await axios.post(`/remixer/${id}/page`, {
+ path,
+ subdomain,
+ pageDetails,
+ currentbook,
+ option,
+ });
+ return res.data;
+ }
+
async queryLangGraphAgent(query: string, sessionId: string) {
const res = await axios.post<
{
@@ -2164,7 +2265,7 @@ class API {
slug?: string;
url: string;
relevanceScore?: number;
- source: 'kb' | 'web';
+ source: "kb" | "web";
}>;
query: string;
timestamp: string;
@@ -2176,7 +2277,6 @@ class API {
return res.data;
}
-
}
export default new API();
diff --git a/client/src/components/projects/CreateWorkbenchModal.tsx b/client/src/components/projects/CreateWorkbenchModal.tsx
index 6c1cc073..3e604f74 100644
--- a/client/src/components/projects/CreateWorkbenchModal.tsx
+++ b/client/src/components/projects/CreateWorkbenchModal.tsx
@@ -251,7 +251,7 @@ const CreateWorkbenchModal: React.FC = ({
{/* Super Admins can use the dev library for debugging */}
setValue("library", "dev")}
+ onClick={() => setValue("library", 21, { shouldDirty: true })}
>
Use Dev (Super Admins Only)
diff --git a/client/src/components/remixer/BookContent/ContextMenu.tsx b/client/src/components/remixer/BookContent/ContextMenu.tsx
new file mode 100644
index 00000000..77312b0b
--- /dev/null
+++ b/client/src/components/remixer/BookContent/ContextMenu.tsx
@@ -0,0 +1,127 @@
+import React from "react";
+import { Icon } from "semantic-ui-react";
+
+interface ContextMenuPosition {
+ nodeId: string;
+ x: number;
+ y: number;
+}
+
+type ContextMenuAction =
+ | "add-above"
+ | "add-below"
+ | "add-to"
+ | "delete"
+ | "modify"
+ | "duplicate";
+
+interface ContextMenuProps {
+ contextMenu: ContextMenuPosition | null;
+ canAddSibling: boolean;
+ canDuplicate: boolean;
+ addAboveLabel: string;
+ addToLabel: string;
+ addBelowLabel: string;
+ onAction: (action: ContextMenuAction) => void;
+}
+
+const itemStyle: React.CSSProperties = {
+ padding: "8px 16px",
+ cursor: "pointer",
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+};
+
+const ContextMenu: React.FC = ({
+ contextMenu,
+ canAddSibling,
+ canDuplicate,
+ addAboveLabel,
+ addToLabel,
+ addBelowLabel,
+ onAction,
+}) => {
+ if (!contextMenu) return null;
+
+ return (
+
+ {canAddSibling && (
+
(e.currentTarget.style.background = "#f0f0f0")}
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
+ onClick={() => onAction("add-above")}
+ >
+ {addAboveLabel}
+
+ )}
+
(e.currentTarget.style.background = "#f0f0f0")}
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
+ onClick={() => onAction("add-to")}
+ >
+ {addToLabel}
+
+ {canAddSibling && (
+
(e.currentTarget.style.background = "#f0f0f0")}
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
+ onClick={() => onAction("add-below")}
+ >
+ {addBelowLabel}
+
+ )}
+ {canDuplicate && (
+
(e.currentTarget.style.background = "#f0f0f0")}
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
+ onClick={() => onAction("duplicate")}
+ >
+ Duplicate
+
+ )}
+
+
(e.currentTarget.style.background = "#f0f0f0")}
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
+ onClick={() => onAction("delete")}
+ >
+ Delete
+
+
(e.currentTarget.style.background = "#f0f0f0")}
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
+ onClick={() => onAction("modify")}
+ >
+ Modify
+
+
+ );
+};
+
+export default ContextMenu;
diff --git a/client/src/components/remixer/BookContent/Dashboard.tsx b/client/src/components/remixer/BookContent/Dashboard.tsx
new file mode 100644
index 00000000..4d0687aa
--- /dev/null
+++ b/client/src/components/remixer/BookContent/Dashboard.tsx
@@ -0,0 +1,748 @@
+import { Icon, List } from "semantic-ui-react";
+import { Dispatch, DragEvent, SetStateAction, useMemo, useState } from "react";
+import { PathLevelFormat, RemixerSubPage } from "../model";
+import {
+ computeRemixerOrdinalPathsMap,
+ getRemixerDisplayTitle,
+ isMatterNode,
+ stripDefaultTitlePrefixBeforeColon,
+ stripLeadingNumbering,
+} from "../services";
+import TreeNodeContainer from "./TreeNodeContainer";
+
+type DropPosition = "before" | "inside" | "after";
+type TreeId = "library" | "book";
+
+const STATUS_PALETTE = {
+ info: "#0288d1",
+ infoBg: "#bbdefb",
+ error: "#d32f2f",
+ errorBg: "#ffcdd2",
+ success: "#2e7d32",
+ successBg: "#c8e6c9",
+ warning: "#ed6c02",
+ warningBg: "#ffe0b2",
+};
+
+interface ExternalDropPayload {
+ sourceTreeId: TreeId;
+ node: RemixerSubPage;
+}
+
+interface TreeDndProps {
+ currentBook: RemixerSubPage[];
+ autoNumbering?: boolean;
+ pathLevelFormats?: PathLevelFormat[];
+ onExpand?: (id: string) => void;
+ treeId: TreeId;
+ onImportNode?: (params: {
+ sourceTreeId: TreeId;
+ targetTreeId: TreeId;
+ node: RemixerSubPage;
+ targetNodeId: string;
+ position: DropPosition;
+ targetParentId: string;
+ }) => void;
+ onImportNodeById?: (params: {
+ nodeId: string;
+ targetTreeId: TreeId;
+ targetNodeId: string;
+ position: DropPosition;
+ targetParentId: string;
+ }) => void;
+ onReorderNode?: (params: {
+ draggedNodeId: string;
+ targetNodeId: string;
+ position: DropPosition;
+ }) => void;
+ onMarkMovedNodes?: (nodeIds: string[]) => void;
+ selectedNodeId?: string;
+ onSelectNode?: (nodeId?: string) => void;
+ onNodeDoubleClick?: (nodeId: string) => void;
+ onNodeContextMenu?: (nodeId: string, event: React.MouseEvent) => void;
+ expandedNodeIds: Set;
+ setExpandedNodeIds: Dispatch>>;
+}
+
+const TreeDnd: React.FC = ({
+ currentBook,
+ autoNumbering = true,
+ pathLevelFormats = [],
+ onExpand,
+ treeId,
+ onImportNode,
+ onImportNodeById,
+ onReorderNode,
+ onMarkMovedNodes,
+ selectedNodeId,
+ onSelectNode,
+ onNodeDoubleClick,
+ onNodeContextMenu,
+ expandedNodeIds,
+ setExpandedNodeIds,
+}) => {
+ const isBookTree = treeId === "book";
+
+ const [draggingId, setDraggingId] = useState(null);
+ const [dropIndicator, setDropIndicator] = useState<{
+ targetId: string;
+ position: DropPosition;
+ } | null>(null);
+
+ const pagesById = useMemo(
+ () => new Map(currentBook.map((page) => [page["@id"], page])),
+ [currentBook],
+ );
+
+ const ordinalPathById = useMemo(() => {
+ if (!isBookTree) return new Map();
+ return computeRemixerOrdinalPathsMap(currentBook);
+ }, [isBookTree, currentBook]);
+
+ /**
+ * Maps each node id to a display suffix like " (1)", " (2)", ... when two or
+ * more siblings under the same parent share the same title (case-insensitive).
+ * The stored title is never modified; the suffix is visual-only.
+ */
+ const siblingDisplaySuffixById = useMemo(() => {
+ const result = new Map();
+ const byParent = new Map();
+ currentBook.forEach((node) => {
+ const parentKey = node.parentID ?? "-1";
+ const siblings = byParent.get(parentKey) ?? [];
+ siblings.push(node);
+ byParent.set(parentKey, siblings);
+ });
+ byParent.forEach((siblings) => {
+ const byTitle = new Map();
+ siblings.forEach((node) => {
+ const raw = (node["@title"] || node.title || "").trim();
+ const normalized =
+ node.formattedPathOverride === true
+ ? stripLeadingNumbering(raw).toLowerCase()
+ : stripDefaultTitlePrefixBeforeColon(
+ stripLeadingNumbering(raw),
+ ).toLowerCase();
+ if (!normalized) return;
+ const group = byTitle.get(normalized) ?? [];
+ group.push(node);
+ byTitle.set(normalized, group);
+ });
+ byTitle.forEach((group) => {
+ if (group.length <= 1) return;
+ group.forEach((node, index) => {
+ result.set(node["@id"], ` (${index + 1})`);
+ });
+ });
+ });
+ return result;
+ }, [currentBook]);
+
+ const getDisplayedParentId = (page: RemixerSubPage): string =>
+ page.parentID ?? "-1";
+
+ const getChildrenByParent = (parentId: string): RemixerSubPage[] =>
+ currentBook.filter((p) => (p.parentID ?? "-1") === parentId);
+
+ const hasChildren = (pageId: string): boolean =>
+ getChildrenByParent(pageId).length > 0;
+
+ const isMatterBranchNode = (nodeId: string): boolean => {
+ if (!isBookTree) return false;
+ let currentId: string | undefined = nodeId;
+ const visited = new Set();
+ while (currentId && currentId !== "-1" && !visited.has(currentId)) {
+ visited.add(currentId);
+ const node = pagesById.get(currentId);
+ if (!node) return false;
+ if (isMatterNode(node)) return true;
+ currentId = getDisplayedParentId(node);
+ }
+ return false;
+ };
+
+ const isDescendant = (nodeId: string, ancestorId: string): boolean => {
+ let currentParentId = pagesById.get(nodeId)?.parentID;
+ while (currentParentId && currentParentId !== "-1") {
+ if (currentParentId === ancestorId) {
+ return true;
+ }
+ currentParentId = pagesById.get(currentParentId)?.parentID;
+ }
+ return false;
+ };
+
+ const getDropPosition = (
+ event: DragEvent,
+ ): DropPosition => {
+ const rect = event.currentTarget.getBoundingClientRect();
+ const relativeY = (event.clientY - rect.top) / rect.height;
+ if (relativeY < 0.25) return "before";
+ if (relativeY > 0.75) return "after";
+ return "inside";
+ };
+
+ const getEffectiveDropPosition = (
+ position: DropPosition,
+ targetLevel: number,
+ ): DropPosition => {
+ // Match legacy Remixer behavior: drops on top-level nodes are always "inside".
+ if (targetLevel <= 1) {
+ return "inside";
+ }
+ return position;
+ };
+
+ const moveNode = (
+ draggedNodeId: string,
+ targetNodeId: string,
+ position: DropPosition,
+ ) => {
+ if (!isBookTree) return;
+ if (draggedNodeId === targetNodeId) return;
+
+ const targetNode = pagesById.get(targetNodeId);
+ const draggedNode = pagesById.get(draggedNodeId);
+ if (!targetNode || !draggedNode) return;
+
+ let nextParentId = targetNodeId;
+ if (position !== "inside") {
+ nextParentId = getDisplayedParentId(targetNode);
+ }
+
+ if (!nextParentId || nextParentId === draggedNodeId) return;
+ if (isDescendant(nextParentId, draggedNodeId)) return;
+
+ const draggedNodeIsNative = draggedNode.addedItem !== true;
+ if (treeId === "book" && draggedNodeIsNative) {
+ onMarkMovedNodes?.([draggedNodeId]);
+ }
+
+ onReorderNode?.({
+ draggedNodeId,
+ targetNodeId,
+ position,
+ });
+
+ setExpandedNodeIds((prev: Set) => {
+ const next = new Set(prev);
+ next.add(nextParentId);
+ return next;
+ });
+ if (position === "inside") {
+ onExpand?.(targetNodeId);
+ }
+ };
+
+ const getTargetParentId = (
+ targetNodeId: string,
+ position: DropPosition,
+ ): string | null => {
+ const targetNode = pagesById.get(targetNodeId);
+ if (!targetNode) return null;
+ if (position === "inside") return targetNodeId;
+ return getDisplayedParentId(targetNode);
+ };
+
+ const parseExternalDropPayload = (
+ event: DragEvent,
+ ): ExternalDropPayload | null => {
+ const rawPayload = event.dataTransfer.getData("application/x-remixer-node");
+ if (!rawPayload) return null;
+ try {
+ const parsedPayload = JSON.parse(rawPayload) as ExternalDropPayload;
+ if (!parsedPayload?.node?.["@id"] || !parsedPayload?.sourceTreeId) {
+ return null;
+ }
+ return parsedPayload;
+ } catch {
+ return null;
+ }
+ };
+
+ const tryImportById = (
+ draggedNodeId: string,
+ targetNodeId: string,
+ position: DropPosition,
+ targetLevel: number,
+ ) => {
+ const effectivePosition = getEffectiveDropPosition(position, targetLevel);
+ const targetParentId = getTargetParentId(targetNodeId, effectivePosition);
+ if (!targetParentId) return;
+ onImportNodeById?.({
+ nodeId: draggedNodeId,
+ targetTreeId: treeId,
+ targetNodeId,
+ position: effectivePosition,
+ targetParentId,
+ });
+ };
+
+ const toggleFolder = (page: RemixerSubPage) => {
+ const pageId = page["@id"];
+ const isExpanded = expandedNodeIds.has(pageId);
+
+ setExpandedNodeIds((prev: Set) => {
+ const next = new Set(prev);
+ if (isExpanded) {
+ next.delete(pageId);
+ } else {
+ next.add(pageId);
+ }
+ return next;
+ });
+
+ // Keep lazy-loading behavior when a folder is expanded.
+ if (!isExpanded && page["@subpages"]) {
+ onExpand?.(pageId);
+ }
+ };
+
+ const expandFolder = (page: RemixerSubPage) => {
+ const pageId = page["@id"];
+ const isExpanded = expandedNodeIds.has(pageId);
+ if (isExpanded) return;
+
+ setExpandedNodeIds((prev: Set) => {
+ const next = new Set(prev);
+ next.add(pageId);
+ return next;
+ });
+
+ if (page["@subpages"]) {
+ onExpand?.(pageId);
+ }
+ };
+
+ const displayTitleOptions = {
+ isBookTree,
+ autoNumbering: autoNumbering ?? false,
+ pathLevelFormats,
+ ...(isBookTree
+ ? {
+ remixerPathLookup: {
+ nodesById: pagesById,
+ ordinalPathById,
+ },
+ }
+ : {}),
+ };
+
+ const renderNodes = (
+ parentId: string,
+ depth: number,
+ parentInDeletedBranch = false,
+ parentInMatterNoNumberSubtree = false,
+ ) => {
+ const children = getChildrenByParent(parentId);
+
+ return children.map((page) => {
+ const inMatterNoNumberSubtree =
+ parentInMatterNoNumberSubtree || isMatterNode(page);
+ const inDeletedBranch =
+ parentInDeletedBranch ||
+ page.isDeleted === true ||
+ page.deletedItem === true;
+ const numberPath = isBookTree
+ ? (ordinalPathById.get(page["@id"]) ?? [])
+ : [];
+ const isFolder = page["@subpages"] === true || hasChildren(page["@id"]);
+ const isExpanded = expandedNodeIds.has(page["@id"]);
+ const isDeleted = page.isDeleted ?? page.deletedItem === true;
+ const isImported = page.isImported ?? page.addedItem === true;
+ const isRenamed = page.isRenamed ?? page.renamedItem === true;
+ const isPlacementChanged =
+ page.isPlacementChanged ?? page.movedItem === true;
+ const isSelected = selectedNodeId === page["@id"];
+ const itemLink = page["uri.ui"] || page["@href"];
+ const targetLevel = depth + 1;
+ const isInteractionLocked = isMatterBranchNode(page["@id"]);
+ const isDropInside =
+ dropIndicator?.targetId === page["@id"] &&
+ dropIndicator.position === "inside";
+ const isDropBefore =
+ dropIndicator?.targetId === page["@id"] &&
+ dropIndicator.position === "before";
+ const isDropAfter =
+ dropIndicator?.targetId === page["@id"] &&
+ dropIndicator.position === "after";
+ return (
+ ) => {
+ if (isInteractionLocked) return;
+ setDraggingId(page["@id"]);
+ event.dataTransfer.setData("text/plain", page["@id"]);
+ event.dataTransfer.setData(
+ "application/x-remixer-node",
+ JSON.stringify({
+ sourceTreeId: treeId,
+ node: page,
+ } as ExternalDropPayload),
+ );
+ }}
+ onDragEnd={() => {
+ setDraggingId(null);
+ setDropIndicator(null);
+ }}
+ onDragOver={(event: DragEvent) => {
+ if (!isBookTree || isInteractionLocked) return;
+ const draggedNodeId =
+ draggingId || event.dataTransfer.getData("text/plain");
+ // Some browsers only expose transfer payload at drop time.
+ // Always allow dragover so cross-tree drop can complete.
+ if (draggedNodeId && draggedNodeId === page["@id"]) return;
+ event.preventDefault();
+ setDropIndicator({
+ targetId: page["@id"],
+ position: getDropPosition(event),
+ });
+ }}
+ onDragLeave={() => {
+ if (dropIndicator?.targetId === page["@id"]) {
+ setDropIndicator(null);
+ }
+ }}
+ onDrop={(event: DragEvent) => {
+ if (!isBookTree || isInteractionLocked) return;
+ event.preventDefault();
+ const draggedNodeId =
+ draggingId || event.dataTransfer.getData("text/plain");
+ const targetNodeId = page["@id"];
+ const position = dropIndicator?.position ?? getDropPosition(event);
+ const effectivePosition = getEffectiveDropPosition(
+ position,
+ targetLevel,
+ );
+ const externalPayload = parseExternalDropPayload(event);
+
+ setDropIndicator(null);
+ setDraggingId(null);
+
+ if (draggedNodeId && pagesById.has(draggedNodeId)) {
+ moveNode(draggedNodeId, targetNodeId, effectivePosition);
+ return;
+ }
+
+ if (!externalPayload || externalPayload.sourceTreeId === treeId) {
+ if (draggedNodeId) {
+ tryImportById(
+ draggedNodeId,
+ targetNodeId,
+ position,
+ targetLevel,
+ );
+ }
+ return;
+ }
+ const targetParentId = getTargetParentId(
+ targetNodeId,
+ effectivePosition,
+ );
+ if (!targetParentId) return;
+ onImportNode?.({
+ sourceTreeId: externalPayload.sourceTreeId,
+ targetTreeId: treeId,
+ node: externalPayload.node,
+ targetNodeId,
+ position: effectivePosition,
+ targetParentId,
+ });
+ }}
+ onSelect={() => {
+ if (isBookTree) {
+ if (isInteractionLocked) {
+ onSelectNode?.(undefined);
+ return;
+ }
+ onSelectNode?.(isSelected ? undefined : page["@id"]);
+ }
+ }}
+ onDoubleClick={() => {
+ if (isBookTree && !isInteractionLocked) {
+ onNodeDoubleClick?.(page["@id"]);
+ }
+ }}
+ onContextMenu={(event: React.MouseEvent) => {
+ if (isBookTree && !isInteractionLocked) {
+ event.preventDefault();
+ onNodeContextMenu?.(page["@id"], event);
+ }
+ }}
+ >
+ {isExpanded
+ ? renderNodes(
+ page["@id"],
+ depth + 1,
+ inDeletedBranch,
+ inMatterNoNumberSubtree,
+ )
+ : null}
+
+ );
+ });
+ };
+
+ // Roots are nodes with parentID === "-1"
+ const roots = getChildrenByParent("-1");
+
+ return (
+
+ {/*
+ {title}
+
*/}
+
+
+ {roots.map((root) => {
+ const inMatterNoNumberSubtree = isMatterNode(root);
+ const inDeletedBranch =
+ root.isDeleted === true || root.deletedItem === true;
+ const numberPath = isBookTree
+ ? (ordinalPathById.get(root["@id"]) ?? [])
+ : [];
+ const isExpanded = true;
+ const isDeleted = root.isDeleted ?? root.deletedItem === true;
+ const isImported = root.isImported ?? root.addedItem === true;
+ const isRenamed = root.isRenamed ?? root.renamedItem === true;
+ const isPlacementChanged =
+ root.isPlacementChanged ?? root.movedItem === true;
+ const isSelected = selectedNodeId === root["@id"];
+ const itemLink = root["uri.ui"] || root["@href"];
+ const targetLevel = 1;
+ const isInteractionLocked = isMatterBranchNode(root["@id"]);
+
+ return (
+
+
) => {
+ if (isInteractionLocked) return;
+ setDraggingId(root["@id"]);
+ event.dataTransfer.setData("text/plain", root["@id"]);
+ event.dataTransfer.setData(
+ "application/x-remixer-node",
+ JSON.stringify({
+ sourceTreeId: treeId,
+ node: root,
+ } as ExternalDropPayload),
+ );
+ }}
+ onDragEnd={() => {
+ setDraggingId(null);
+ setDropIndicator(null);
+ }}
+ onDragOver={(event: DragEvent) => {
+ if (!isBookTree || isInteractionLocked) return;
+ const draggedNodeId =
+ draggingId || event.dataTransfer.getData("text/plain");
+ // Some browsers only expose transfer payload at drop time.
+ // Always allow dragover so cross-tree drop can complete.
+ if (draggedNodeId && draggedNodeId === root["@id"]) return;
+ event.preventDefault();
+ setDropIndicator({
+ targetId: root["@id"],
+ position: getDropPosition(event),
+ });
+ }}
+ onDragLeave={() => {
+ if (dropIndicator?.targetId === root["@id"]) {
+ setDropIndicator(null);
+ }
+ }}
+ onDrop={(event: DragEvent) => {
+ if (!isBookTree || isInteractionLocked) return;
+ event.preventDefault();
+ const draggedNodeId =
+ draggingId || event.dataTransfer.getData("text/plain");
+ const targetNodeId = root["@id"];
+ const position = dropIndicator?.position ?? getDropPosition(event);
+ const effectivePosition = getEffectiveDropPosition(
+ position,
+ targetLevel,
+ );
+ const externalPayload = parseExternalDropPayload(event);
+
+ setDropIndicator(null);
+ setDraggingId(null);
+
+ if (draggedNodeId && pagesById.has(draggedNodeId)) {
+ moveNode(draggedNodeId, targetNodeId, effectivePosition);
+ return;
+ }
+
+ if (!externalPayload || externalPayload.sourceTreeId === treeId) {
+ if (draggedNodeId) {
+ tryImportById(
+ draggedNodeId,
+ targetNodeId,
+ position,
+ targetLevel,
+ );
+ }
+ return;
+ }
+ const targetParentId = getTargetParentId(
+ targetNodeId,
+ effectivePosition,
+ );
+ if (!targetParentId) return;
+ onImportNode?.({
+ sourceTreeId: externalPayload.sourceTreeId,
+ targetTreeId: treeId,
+ node: externalPayload.node,
+ targetNodeId,
+ position: effectivePosition,
+ targetParentId,
+ });
+ }}
+ onClick={() => {
+ if (isBookTree) {
+ if (isInteractionLocked) {
+ onSelectNode?.(undefined);
+ return;
+ }
+ onSelectNode?.(isSelected ? undefined : root["@id"]);
+ }
+ }}
+ onDoubleClick={() => {
+ if (isBookTree && !isInteractionLocked) {
+ onNodeDoubleClick?.(root["@id"]);
+ }
+ }}
+ onContextMenu={(event: React.MouseEvent) => {
+ if (isBookTree && !isInteractionLocked) {
+ event.preventDefault();
+ onNodeContextMenu?.(root["@id"], event);
+ }
+ }}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ padding: "4px 0",
+ opacity: isInteractionLocked ? 0.6 : 1,
+ background:
+ dropIndicator?.targetId === root["@id"] &&
+ dropIndicator.position === "inside"
+ ? STATUS_PALETTE.infoBg
+ : isDeleted
+ ? STATUS_PALETTE.errorBg
+ : isImported
+ ? STATUS_PALETTE.successBg
+ : isRenamed || isPlacementChanged || root.movedItem === true
+ ? STATUS_PALETTE.warningBg
+ : "transparent",
+
+ borderTop:
+ dropIndicator?.targetId === root["@id"] &&
+ dropIndicator.position === "before"
+ ? `2px solid ${STATUS_PALETTE.info}`
+ : "2px solid transparent",
+ borderBottom:
+ dropIndicator?.targetId === root["@id"] &&
+ dropIndicator.position === "after"
+ ? `2px solid ${STATUS_PALETTE.info}`
+ : "2px solid transparent",
+ borderRadius: 4,
+ outline: isSelected ? `2px solid ${STATUS_PALETTE.info}` : "none",
+ cursor: isInteractionLocked ? "not-allowed" : "pointer",
+ }}
+ >
+ {/* Root is always expandable if it has subpages */}
+ {root["@subpages"] ? (
+ {
+ event.stopPropagation();
+ expandFolder(root);
+ }}
+ onDoubleClick={(event) => event.stopPropagation()}
+ >
+
+
+ ) : (
+
+ )}
+
+
+
+
+ {getRemixerDisplayTitle(
+ root,
+ numberPath,
+ inMatterNoNumberSubtree,
+ inDeletedBranch,
+ displayTitleOptions,
+ ) + (siblingDisplaySuffixById.get(root["@id"]) ?? "")}
+
+ {!isBookTree && itemLink ? (
+ event.stopPropagation()}
+ >
+
+
+ ) : null}
+
+ {renderNodes(
+ root["@id"],
+ 1,
+ inDeletedBranch,
+ inMatterNoNumberSubtree,
+ )}
+
+ );
+ })}
+
+
+ );
+};
+
+export default TreeDnd;
\ No newline at end of file
diff --git a/client/src/components/remixer/BookContent/TreeNodeContainer.tsx b/client/src/components/remixer/BookContent/TreeNodeContainer.tsx
new file mode 100644
index 00000000..177e1844
--- /dev/null
+++ b/client/src/components/remixer/BookContent/TreeNodeContainer.tsx
@@ -0,0 +1,190 @@
+import React, { DragEvent } from "react";
+import { Icon, List } from "semantic-ui-react";
+import { RemixerSubPage } from "../model";
+
+interface StatusPalette {
+ info: string;
+ infoBg: string;
+ error: string;
+ errorBg: string;
+ success: string;
+ successBg: string;
+ warning: string;
+ warningBg: string;
+}
+
+/** Pixels added per tree level. Nested wrappers stack this once per depth (no compounding). */
+export const TREE_LEVEL_INDENT_PX = 12;
+
+interface TreeNodeContainerProps {
+ page: RemixerSubPage;
+ isFolder: boolean;
+ isExpanded: boolean;
+ isDeleted: boolean;
+ isImported: boolean;
+ isRenamed: boolean;
+ isPlacementChanged: boolean;
+ isSelected: boolean;
+ isBookTree: boolean;
+ isInteractionLocked?: boolean;
+ isVisualLocked?: boolean;
+ itemLink?: string;
+ displayTitle: string;
+ isDropInside: boolean;
+ isDropBefore: boolean;
+ isDropAfter: boolean;
+ palette: StatusPalette;
+ onToggleFolder: (page: RemixerSubPage) => void;
+ onDragStart: (event: DragEvent) => void;
+ onDragEnd: () => void;
+ onDragOver: (event: DragEvent) => void;
+ onDragLeave: () => void;
+ onDrop: (event: DragEvent) => void;
+ onSelect: () => void;
+ onDoubleClick?: () => void;
+ onContextMenu?: (event: React.MouseEvent) => void;
+ children?: React.ReactNode;
+}
+
+const TreeNodeContainer: React.FC = ({
+ page,
+ isFolder,
+ isExpanded,
+ isDeleted,
+ isImported,
+ isRenamed,
+ isPlacementChanged,
+ isSelected,
+ isBookTree,
+ isInteractionLocked = false,
+ isVisualLocked = false,
+ itemLink,
+ displayTitle,
+ isDropInside,
+ isDropBefore,
+ isDropAfter,
+ palette,
+ onToggleFolder,
+ onDragStart,
+ onDragEnd,
+ onDragOver,
+ onDragLeave,
+ onDrop,
+ onSelect,
+ onDoubleClick,
+ onContextMenu,
+ children,
+}) => {
+ return (
+
+
+ {isFolder ? (
+ {
+ event.stopPropagation();
+ onToggleFolder(page);
+ }}
+ onDoubleClick={(event) => event.stopPropagation()}
+ >
+
+
+ ) : (
+
+ )}
+
+
+
+
+ {displayTitle}
+ {isDeleted && (
+
+ )}
+ {(isRenamed ||
+ isPlacementChanged ||
+ page.movedItem) && (
+
+ )}
+ {isImported && (
+
+ )}
+
+ {!isBookTree && itemLink ? (
+ event.stopPropagation()}
+ >
+
+
+ ) : null}
+
+ {children}
+
+ );
+};
+
+export default TreeNodeContainer;
diff --git a/client/src/components/remixer/BookContent/TreeSkeleton.tsx b/client/src/components/remixer/BookContent/TreeSkeleton.tsx
new file mode 100644
index 00000000..5c82bf87
--- /dev/null
+++ b/client/src/components/remixer/BookContent/TreeSkeleton.tsx
@@ -0,0 +1,30 @@
+import React from "react";
+import { Placeholder } from "semantic-ui-react";
+
+
+ const TreeSkeleton: React.FC = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ export default TreeSkeleton
\ No newline at end of file
diff --git a/client/src/components/remixer/CatalogBook/CatalogList.tsx b/client/src/components/remixer/CatalogBook/CatalogList.tsx
new file mode 100644
index 00000000..4d9cd135
--- /dev/null
+++ b/client/src/components/remixer/CatalogBook/CatalogList.tsx
@@ -0,0 +1,238 @@
+import React, { useEffect, useMemo, useState } from "react";
+import { Button, Input, Modal, Table } from "semantic-ui-react";
+import { Book } from "../../../types";
+import { PaginationWithItemsSelect } from "../../util/PaginationWithItemsSelect";
+import { getLibraryName } from "../../util/LibraryOptions";
+import { getLicenseText } from "../../util/LicenseOptions";
+
+const truncateStyle: React.CSSProperties = {
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+};
+
+interface CatalogListProps {
+ open: boolean;
+ onClose: () => void;
+ dimmer: string;
+ catalogBook?: Book[];
+ loadSelectedBook: (bookID: string, library: string, url: string) => void | Promise;
+ loading?: boolean;
+}
+
+type SortableColumn = "title" | "bookID" | "library" | "author" | "course" | "license";
+type SortDirection = "ascending" | "descending";
+
+const CatalogList: React.FC = ({
+ open,
+ onClose,
+ dimmer,
+ catalogBook,
+ loadSelectedBook,
+ loading = false,
+}: CatalogListProps) => {
+ const [searchTerm, setSearchTerm] = useState("");
+ const [selectedBook, setSelectedBook] = useState(null);
+ const [activePage, setActivePage] = useState(1);
+ const [itemsPerPage, setItemsPerPage] = useState(10);
+ const [sortColumn, setSortColumn] = useState(null);
+ const [sortDirection, setSortDirection] = useState("ascending");
+
+ const handleSort = (column: SortableColumn) => {
+ if (sortColumn === column) {
+ setSortDirection((prev) =>
+ prev === "ascending" ? "descending" : "ascending",
+ );
+ } else {
+ setSortColumn(column);
+ setSortDirection("ascending");
+ }
+ setActivePage(1);
+ };
+
+ const filteredCatalogBook = useMemo(() => {
+ const books = catalogBook ?? [];
+ const term = searchTerm.trim().toLowerCase();
+ if (!term) return books;
+ return books.filter((book) => {
+ const haystack = [
+ book.title,
+ book.bookID,
+ book.library,
+ getLibraryName(book.library),
+ book.author,
+ book.course,
+ book.license,
+ getLicenseText(book.license),
+ ]
+ .map((s) => (s ?? "").toString().toLowerCase())
+ .join(" ");
+ return haystack.includes(term);
+ });
+ }, [catalogBook, searchTerm]);
+
+ const sortedBooks = useMemo(() => {
+ if (!sortColumn) return filteredCatalogBook;
+ const sorted = [...filteredCatalogBook].sort((a, b) => {
+ const rawA = (a[sortColumn] ?? "").toString();
+ const rawB = (b[sortColumn] ?? "").toString();
+ const resolve = (raw: string) => {
+ if (sortColumn === "library") return getLibraryName(raw);
+ if (sortColumn === "license") return getLicenseText(raw) ?? raw;
+ return raw;
+ };
+ const aVal = resolve(rawA).toLowerCase();
+ const bVal = resolve(rawB).toLowerCase();
+ return aVal.localeCompare(bVal);
+ });
+ return sortDirection === "descending" ? sorted.reverse() : sorted;
+ }, [filteredCatalogBook, sortColumn, sortDirection]);
+
+ const totalPages = Math.max(
+ 1,
+ Math.ceil(sortedBooks.length / itemsPerPage) || 1,
+ );
+
+ const paginatedBooks = useMemo(() => {
+ const start = (activePage - 1) * itemsPerPage;
+ return sortedBooks.slice(start, start + itemsPerPage);
+ }, [sortedBooks, activePage, itemsPerPage]);
+
+ useEffect(() => {
+ setActivePage(1);
+ }, [searchTerm, catalogBook]);
+
+ useEffect(() => {
+ if (activePage > totalPages) {
+ setActivePage(totalPages);
+ }
+ }, [activePage, totalPages]);
+
+ return (
+
+ Catalog Book
+
+ setSearchTerm(value)}
+ style={{ marginBottom: 12 }}
+ />
+ {
+ setItemsPerPage(n);
+ setActivePage(1);
+ }}
+ activePage={activePage}
+ setActivePageFn={setActivePage}
+ totalPages={totalPages}
+ totalLength={sortedBooks.length}
+ />
+
+
+
+
+ handleSort("title")}
+ >
+ Title
+
+ handleSort("bookID")}
+ >
+ ID
+
+ handleSort("library")}
+ >
+ Library
+
+ handleSort("author")}
+ >
+ Author
+
+ handleSort("course")}
+ >
+ Campus
+
+ handleSort("license")}
+ >
+ License
+
+
+
+
+ {paginatedBooks.length === 0 ? (
+
+
+ No books match your search.
+
+
+ ) : (
+ paginatedBooks.map((book) => {
+ const isSelected = selectedBook?.bookID === book.bookID;
+ return (
+ setSelectedBook(book)}
+ style={{ cursor: "pointer" }}
+ >
+
+ {book.title}
+
+ {book.bookID}
+
+ {getLibraryName(book.library)}
+
+
+ {book.author}
+
+
+ {book.course}
+
+
+ {getLicenseText(book.license)}
+
+
+ );
+ })
+ )}
+
+
+
+
+
+ Close
+ loadSelectedBook(selectedBook?.bookID ?? "", selectedBook?.library ?? "", selectedBook?.links?.online ?? "")}
+ >
+ Load on Library
+
+
+
+
+ );
+};
+
+export default CatalogList;
diff --git a/client/src/components/remixer/ControlPanel.tsx b/client/src/components/remixer/ControlPanel.tsx
new file mode 100644
index 00000000..507b57a6
--- /dev/null
+++ b/client/src/components/remixer/ControlPanel.tsx
@@ -0,0 +1,232 @@
+import React, { useState } from "react";
+import { Button, Dropdown, Grid, Header, Icon, Modal, Popup } from "semantic-ui-react";
+import {
+ buttonActiveStyle,
+ buttonStyle,
+ handleMouseEnter,
+ handleMouseLeave,
+} from "./style";
+import { CopyMode, copyModeStates, defaultCopyModeState } from "./model";
+
+interface ControlPanelProps {
+ onStartOver?: () => void | Promise;
+ onLoadVersion?: () => void;
+ onPublish?: () => void;
+ onPathNameFormat?: () => void;
+ onSave?: () => void;
+ copyModeState?: CopyMode;
+ onCopyModeChange?: (value: CopyMode) => void;
+ isAdmin?: boolean;
+}
+
+const ControlPanel: React.FC = ({
+ onStartOver,
+ onLoadVersion,
+
+ onPublish,
+ onSave,
+ onPathNameFormat,
+ copyModeState,
+ onCopyModeChange,
+ isAdmin = false,
+}) => {
+ const [confirmStartOverOpen, setConfirmStartOverOpen] = useState(false);
+ const [startOverLoading, setStartOverLoading] = useState(false);
+
+ const handleStartOverConfirm = async () => {
+ if (!onStartOver) {
+ setConfirmStartOverOpen(false);
+ return;
+ }
+ setStartOverLoading(true);
+ try {
+ await onStartOver();
+ setConfirmStartOverOpen(false);
+ } finally {
+ setStartOverLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
setConfirmStartOverOpen(true)}
+ onMouseEnter={handleMouseEnter}
+ onMouseLeave={handleMouseLeave}
+ >
+ Start Over
+
+
+
+
+ }
+ />{" "}
+
+
+ {isAdmin && (
+
+ ({
+ key: state.value,
+ text: state.title,
+ value: state.value,
+ }))}
+ value={copyModeState ?? defaultCopyModeState.value}
+ onChange={(_e, { value }) => {
+ const next = copyModeStates.find((s) => s.value === value);
+ if (next) onCopyModeChange?.(next.value);
+ }}
+ selection
+ compact
+ upward={false}
+ placeholder="Copy Mode..."
+ style={{ minWidth: 180, zIndex: 20 }}
+ />
+
+ )}
+
+
+ Auto number
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ {
+ if (!startOverLoading) setConfirmStartOverOpen(false);
+ }}
+ >
+ Start Over?
+
+ This will delete the saved remixer draft for this project. This action
+ cannot be undone.
+
+
+ setConfirmStartOverOpen(false)}
+ disabled={startOverLoading}
+ >
+ Cancel
+
+
+ Confirm
+
+
+
+
+ );
+};
+
+export default ControlPanel;
diff --git a/client/src/components/remixer/EditPanel.tsx b/client/src/components/remixer/EditPanel.tsx
new file mode 100644
index 00000000..5ef06148
--- /dev/null
+++ b/client/src/components/remixer/EditPanel.tsx
@@ -0,0 +1,129 @@
+import React, { useEffect, useState } from "react";
+import { Button, Checkbox, Input, Modal } from "semantic-ui-react";
+import { RemixerSubPage } from "./model";
+
+interface EditPanelProps {
+ open: boolean;
+ dimmer: string;
+ onClose: () => void;
+ currentPage?: RemixerSubPage;
+ handleSave: (page: RemixerSubPage) => void;
+ formattedPathDefault?: string;
+}
+
+/** Colons are not allowed. If present, drop the prefix before the first ":" and any remaining ":". */
+function sanitizeRemixerTitle(value: string, trim: boolean = true): string {
+ let s = value;
+ const colonIndex = s.indexOf(":");
+ if (colonIndex !== -1) {
+ s = s.slice(colonIndex + 1);
+ }
+ if (trim) {
+ return s.replace(/:/g, "").trim();
+ }
+ return s.replace(/:/g, "");
+}
+
+const EditPanel: React.FC = (props) => {
+ const {
+ open,
+ dimmer,
+ onClose,
+ currentPage,
+ handleSave,
+ formattedPathDefault,
+ } = props;
+ const [page, setPage] = useState(currentPage);
+
+ const handleSaveClick = () => {
+ if (!page) return;
+ const title = sanitizeRemixerTitle(page.title ?? page["@title"] ?? "");
+ const normalizedPage: RemixerSubPage = {
+ ...page,
+ title,
+ "@title": title,
+ formattedPathOverride: page.formattedPathOverride === true,
+ formattedPath:
+ page.formattedPathOverride === true
+ ? (page.formattedPath ?? "")
+ : undefined,
+ };
+ handleSave(normalizedPage);
+ };
+
+ useEffect(() => {
+ if (!currentPage) {
+ setPage(undefined);
+ return;
+ }
+ const title = sanitizeRemixerTitle(
+ currentPage.title ?? currentPage["@title"] ?? "",
+ );
+ setPage({ ...currentPage, title, "@title": title });
+ }, [currentPage, open]);
+
+ return (
+
+ Edit Page
+
+ {
+ const next = sanitizeRemixerTitle(e.target.value,false);
+ setPage((prev) =>
+ prev ? { ...prev, title: next, "@title": next } : prev,
+ );
+ }}
+ />
+
+ setPage((prev) => {
+ if (!prev) return prev;
+ const enabled = data.checked === true;
+ return {
+ ...prev,
+ formattedPathOverride: enabled,
+ formattedPath: enabled
+ ? (prev.formattedPath ?? formattedPathDefault ?? "")
+ : undefined,
+ };
+ })
+ }
+ />
+
+ setPage((prev) =>
+ prev ? { ...prev, formattedPath: e.target.value } : prev,
+ )
+ }
+ />
+
+
+
+ Save
+
+
+ Cancel
+
+
+
+ );
+};
+
+export default EditPanel;
diff --git a/client/src/components/remixer/PathNameFormat.tsx b/client/src/components/remixer/PathNameFormat.tsx
new file mode 100644
index 00000000..6a69c4a4
--- /dev/null
+++ b/client/src/components/remixer/PathNameFormat.tsx
@@ -0,0 +1,277 @@
+import React, { useEffect, useMemo, useState } from "react";
+import { Button, Checkbox, Dropdown, Input, Message, Modal, Table } from "semantic-ui-react";
+import {
+ DEFAULT_PREFIX_OPTIONS,
+ DELIMITER_OPTIONS,
+ NUMBERING_TYPE_OPTIONS,
+ NumberingType,
+ PathLevelFormat,
+ PrefixOption,
+} from "./model";
+import { getStartToken } from "./services";
+
+interface PathNameFormatProps {
+ open: boolean;
+ dimmer: string;
+ onClose: () => void;
+ depth: number;
+ pathLevelFormats: PathLevelFormat[];
+ setPathLevelFormats: (pathLevelFormats: PathLevelFormat[]) => void;
+ autoNumbering?: boolean;
+ onAutoNumberingChange?: (checked: boolean) => void;
+}
+
+const PathNameFormat: React.FC = (props) => {
+ const {
+ open,
+ dimmer,
+ onClose,
+ depth,
+ pathLevelFormats,
+ setPathLevelFormats,
+ autoNumbering = true,
+ onAutoNumberingChange,
+ } = props;
+
+ const [levelFormats, setLevelFormats] = useState([]);
+ const [prefixOptions, setPrefixOptions] =
+ useState(DEFAULT_PREFIX_OPTIONS);
+
+ useEffect(() => {
+ if (!open) return;
+ const savedByLevel = new Map(
+ (pathLevelFormats ?? []).map((format) => [format.level, format]),
+ );
+ const normalized = Array.from({ length: Math.max(0, depth) }, (_, index) => {
+ const level = index + 1;
+ const existing = savedByLevel.get(level);
+ return (
+ existing ?? {
+ level,
+ delimiter: ".",
+ prefix: "",
+ start: 1,
+ type: "numeric" as NumberingType,
+ }
+ );
+ });
+ setLevelFormats(normalized);
+ setPrefixOptions((prev) => {
+ const next = [...prev];
+ normalized.forEach((format) => {
+ if (!next.some((option) => option.value === format.prefix)) {
+ next.push(toPrefixOption(format.prefix));
+ }
+ });
+ return next;
+ });
+ }, [open, depth, pathLevelFormats]);
+
+ const previewByLevel = useMemo(() => {
+ return levelFormats.map((_, targetIndex) => {
+ let numericPath = "";
+ let previewText = "";
+ for (let index = 0; index <= targetIndex; index += 1) {
+ const format = levelFormats[index];
+ const token = getStartToken(format.start, format.type);
+ const normalizedToken = token.trim();
+ if (format.excludeParent) {
+ numericPath = normalizedToken ? token : "";
+ previewText = numericPath;
+ continue;
+ }
+ if (normalizedToken) {
+ const delimiter = format.delimiter ?? ".";
+ numericPath = numericPath ? `${numericPath}${delimiter}${token}` : token;
+ previewText = format.prefix ? `${format.prefix}${numericPath}` : numericPath;
+ }
+ }
+ return previewText;
+ });
+ }, [levelFormats]);
+
+ const updateLevelFormat = (
+ levelIndex: number,
+ field: keyof Omit,
+ value: string | number | boolean,
+ ) => {
+ setLevelFormats((prev) =>
+ prev.map((format, index) =>
+ index === levelIndex ? { ...format, [field]: value } : format,
+ ),
+ );
+ };
+
+ const toPrefixOption = (rawPrefix: string): PrefixOption => {
+ const trimmed = rawPrefix.trim();
+ const label = trimmed || "None";
+ return {
+ key: `custom-${label.toLowerCase().replace(/\s+/g, "-")}`,
+ text: label,
+ value: rawPrefix,
+ };
+ };
+
+ const ensurePrefixOption = (rawPrefix: string) => {
+ if (prefixOptions.some((option) => option.value === rawPrefix)) return;
+ setPrefixOptions((prev) => [...prev, toPrefixOption(rawPrefix)]);
+ };
+
+ const handleSave = () => {
+ setPathLevelFormats(levelFormats);
+ onClose();
+ };
+
+ return (
+
+
+
+
Autonumber Options
+
+ Auto Numbering
+ onAutoNumberingChange?.(!!data.checked)}
+ />
+
+
+
+
+ Configure how each level of hierarchy numbering should be displayed.
+ {depth <= 0 ? (
+
+ ) : (
+
+
+
+ Level
+ Exclude Parent
+ Delimiter
+ Prefix
+ Type
+ Starting
+
+ Preview
+
+
+
+ {levelFormats.map((format, index) => (
+
+ {format.level}
+
+ updateLevelFormat(index, "excludeParent", !(format.excludeParent ))}
+ />
+
+
+
+ updateLevelFormat(
+ index,
+ "delimiter",
+ String(data.value ?? "."),
+ )
+ }
+ />
+
+
+ {
+ const rawValue = String(data.value ?? "").trim();
+ const normalized =
+ rawValue.length > 0 && !rawValue.endsWith(" ")
+ ? `${rawValue} `
+ : rawValue;
+ ensurePrefixOption(normalized);
+ updateLevelFormat(index, "prefix", normalized);
+ }}
+ onChange={(_, data) => {
+ const value = String(data.value ?? "");
+ updateLevelFormat(index, "prefix", value);
+ }}
+ />
+
+
+ Example: {`${format.prefix}${getStartToken(format.start, format.type)}`}
+
+
+
+
+ updateLevelFormat(
+ index,
+ "type",
+ (data.value as NumberingType) ?? "numeric",
+ )
+ }
+ />
+
+
+
+ updateLevelFormat(
+ index,
+ "start",
+ Number(event.target.value) || 1,
+ )
+ }
+ />
+
+ Based on type: {getStartToken(format.start, format.type)}
+
+
+ {previewByLevel[index]}
+
+ ))}
+
+
+ )}
+
+
+ Cancel
+
+ Apply
+
+
+
+ );
+};
+
+export default PathNameFormat;
diff --git a/client/src/components/remixer/PublishPanel.tsx b/client/src/components/remixer/PublishPanel.tsx
new file mode 100644
index 00000000..7299a55b
--- /dev/null
+++ b/client/src/components/remixer/PublishPanel.tsx
@@ -0,0 +1,242 @@
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionTitle,
+ Button,
+ Icon,
+ List,
+ Modal,
+ ModalActions,
+ ModalContent,
+ ModalHeader,
+ Progress,
+} from "semantic-ui-react";
+import { RemixerSubPage } from "./model";
+
+interface PublishPanelProps {
+ open: boolean;
+ dimmer: string;
+ handleClose: () => void;
+ handlePublish: () => void;
+ currentBook?: RemixerSubPage[];
+ publishInProgress?: boolean;
+ publishStatus?: "idle" | "pending" | "running" | "success" | "error";
+ publishMessages?: string[];
+}
+
+interface SummarySection {
+ key:
+ | "added"
+ | "moved"
+ | "renamed"
+ | "tagsModified"
+ | "deleted"
+ | "unchanged";
+ label: string;
+ color: string;
+ items: RemixerSubPage[];
+}
+
+const PublishPanel: React.FC = ({
+ open,
+ dimmer,
+ handleClose,
+ currentBook = [],
+ handlePublish,
+ publishInProgress = false,
+ publishStatus = "idle",
+ publishMessages = [],
+}) => {
+ const [openSection, setOpenSection] = useState("");
+ const messagesEndRef = useRef(null);
+
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, [publishMessages]);
+
+ const publish = () => {
+ handlePublish();
+ };
+
+ const canClose = !publishInProgress;
+
+ const progressInfo = useMemo(() => {
+ if (publishStatus === "idle") return null;
+ const total = currentBook.length;
+ const processed = publishMessages.filter((m) => m.endsWith("- processed") || m.endsWith("- skipped")).length;
+ let percent: number;
+ if (publishStatus === "success") {
+ percent = 100;
+ } else if (publishStatus === "error") {
+ percent = total > 0 ? Math.round((processed / total) * 100) : 0;
+ } else {
+ percent = total > 0 ? Math.round((processed / total) * 100) : 0;
+ }
+ return { percent, total, processed };
+ }, [publishStatus, publishMessages, currentBook]);
+
+ const sections = useMemo(() => {
+ const deleted = currentBook.filter((page) => page.deletedItem);
+ const renamed = currentBook.filter(
+ (page) => page.renamedItem && !page.deletedItem,
+ );
+ const moved = currentBook.filter(
+ (page) => page.movedItem && !page.deletedItem,
+ );
+ const added = currentBook.filter(
+ (page) => page.addedItem && !page.deletedItem,
+ );
+ const unchanged = currentBook.filter(
+ (page) =>
+ !page.addedItem &&
+ !page.movedItem &&
+ !page.renamedItem &&
+ !page.deletedItem,
+ );
+ return [
+ {
+ key: "added",
+ label: "pages will be added",
+ color: "#2e7d32",
+ items: added,
+ },
+ {
+ key: "moved",
+ label: "pages will be moved",
+ color: "#f39c12",
+ items: moved,
+ },
+ {
+ key: "renamed",
+ label: "pages will be renamed",
+ color: "#f39c12",
+ items: renamed,
+ },
+ {
+ key: "tagsModified",
+ label: "pages will have tags modified",
+ color: "#d4a72c",
+ items: [],
+ },
+ {
+ key: "deleted",
+ label: "pages will be deleted",
+ color: "#e53935",
+ items: deleted,
+ },
+ {
+ key: "unchanged",
+ label: "pages will be unchanged",
+ color: "#7f8c8d",
+ items: unchanged,
+ },
+ ];
+ }, [currentBook]);
+
+ return (
+
+ Save on Library
+
+ {publishStatus !== "idle" && (
+
+
+
+ Status:{" "}
+ {publishStatus === "error"
+ ? "Failed"
+ : publishStatus.charAt(0).toUpperCase() + publishStatus.slice(1)}
+
+ {progressInfo && (
+
+ {progressInfo.processed} / {progressInfo.total} pages
+
+ )}
+
+ {progressInfo && (
+
+ )}
+ {publishMessages.length > 0 ? (
+
+
+ {publishMessages.map((message, index) => (
+ {message}
+ ))}
+
+
+
+ ) : (
+
No messages yet
+ )}
+
+ )}
+
+ {sections.map((section) => (
+
+
+ setOpenSection((prev) => (prev === section.key ? "" : section.key))
+ }
+ >
+
+
+ {section.items.length} {section.label}
+
+
+
+ {section.items.length === 0 ? (
+ No pages
+ ) : (
+
+ {section.items.map((item) => (
+
+ {item["@title"] || item.title}
+
+ ))}
+
+ )}
+
+
+ ))}
+
+
+
+ Close
+
+ Publish
+
+
+
+ );
+};
+
+export default PublishPanel;
\ No newline at end of file
diff --git a/client/src/components/remixer/RecoveryModal.tsx b/client/src/components/remixer/RecoveryModal.tsx
new file mode 100644
index 00000000..8b1be261
--- /dev/null
+++ b/client/src/components/remixer/RecoveryModal.tsx
@@ -0,0 +1,124 @@
+import React from "react";
+import { Button, Header, Icon, Modal, Segment } from "semantic-ui-react";
+
+interface AvailableSources {
+ hasLocal: boolean;
+ hasServer: boolean;
+ hasServerDraft: boolean;
+ localTimestamp?: number;
+}
+
+export type BookSourceType = "local" | "serverDraft" | "server" | "fresh";
+
+interface RecoveryModalProps {
+ open: boolean;
+ loading: boolean;
+ availableSources: AvailableSources;
+ onLoadSource: (source: BookSourceType) => void;
+ onClose: () => void;
+}
+
+const RecoveryModal: React.FC = ({
+ open,
+ loading,
+ availableSources,
+ onLoadSource,
+ onClose,
+}) => {
+ return (
+ !loading && onClose()}>
+
+ Load Remixer State
+
+
+
+ Choose which version to load. This will replace your current book
+ tree.
+
+
+ {availableSources.hasLocal && (
+ !loading && onLoadSource("local")}
+ >
+
+ Browser Draft
+ {availableSources.localTimestamp && (
+
+ Saved:{" "}
+ {new Date(
+ availableSources.localTimestamp,
+ ).toLocaleString()}
+
+ )}
+
+
+ Restore unsaved changes from this browser.
+
+
+ )}
+ {availableSources.hasServerDraft && (
+ !loading && onLoadSource("serverDraft")}
+ >
+
+
+ Load the draft saved to the server.
+
+
+ )}
+ {/* {availableSources.hasServer && (
+ !loading && onLoadSource("server")}
+ >
+
+
+ Fetch the latest saved state from the server.
+
+
+ )} */}
+ !loading && onLoadSource("fresh")}
+ >
+
+
+ Reload the original book structure from the library.
+
+
+
+
+
+
+ Cancel
+
+
+
+ );
+};
+
+export default RecoveryModal;
diff --git a/client/src/components/remixer/RemixerDashboard.tsx b/client/src/components/remixer/RemixerDashboard.tsx
new file mode 100644
index 00000000..f887843d
--- /dev/null
+++ b/client/src/components/remixer/RemixerDashboard.tsx
@@ -0,0 +1,2043 @@
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import { useParams } from "react-router-dom";
+import {
+ Button,
+ Container,
+ Dimmer,
+ Dropdown,
+ Grid,
+ Icon,
+ Loader,
+ Modal,
+ Popup,
+} from "semantic-ui-react";
+import {
+ CopyMode,
+ Library,
+ PathLevelFormat,
+ RemixerData,
+ RemixerUiState,
+ RemixerSubPage,
+ libraries,
+ libraryTitles,
+ remixerDataInit,
+ remixerUiStateInit,
+ PublishJobStatus,
+} from "./model";
+
+import {
+ DropPosition,
+ applyBookNodeDeletion,
+ buildBookPaths,
+ clearLocalDraft,
+ cloneBook,
+ computeHighestPathLevel,
+ getLocalDraft,
+ insertAtSiblingPosition,
+ isMatterBranchNode as isMatterBranchNodePure,
+ reorderBookNodes,
+ setLocalDraft,
+ withDerivedStatusFlags,
+} from "./services";
+import api from "../../api";
+import TreeDnd from "./BookContent/Dashboard";
+import ControlPanel from "./ControlPanel";
+import PublishPanel from "./PublishPanel";
+import EditPanel from "./EditPanel";
+import PathNameFormat from "./PathNameFormat";
+import { useNotifications } from "../../context/NotificationContext";
+import CatalogList from "./CatalogBook/CatalogList";
+import RecoveryModal from "./RecoveryModal";
+import { flattenCatalogResponse } from "../../utils/booksManagerHelpers";
+import { buttonStyle, handleMouseEnter, handleMouseLeave } from "./style";
+import { useTypedSelector } from "../../state/hooks";
+import TreeSkeleton from "./BookContent/TreeSkeleton";
+import ContextMenu from "./BookContent/ContextMenu";
+
+
+const isLibrary = (value: string): value is Library =>
+ libraries.includes(value as Library);
+
+
+const RemixerDashboard: React.FC = () => {
+ const user = useTypedSelector((state) => state.user);
+ const isAdmin = user?.isSuperAdmin || user?.isCampusAdmin;
+ const [expandedNodeIdsBook, setExpandedNodeIdsBook] = useState>(
+ new Set(),
+ );
+ const [expandedNodeIdsLibrary, setExpandedNodeIdsLibrary] = useState<
+ Set
+ >(new Set());
+ const { addNotification } = useNotifications();
+ const [remixerData, setRemixerData] = useState(remixerDataInit);
+ const [uiState, setUiState] = useState(remixerUiStateInit);
+ const [libraryLoading, setLibraryLoading] = useState(false);
+ const [undoStack, setUndoStack] = useState([]);
+ const [redoStack, setRedoStack] = useState([]);
+ const [publishStatus, setPublishStatus] = useState("idle");
+ const [publishMessages, setPublishMessages] = useState([]);
+ const [publishPolling, setPublishPolling] = useState(false);
+ const { id } = useParams<{ id: string }>();
+ /** When true, the selected-library useEffect skips one fetch (catalog-driven load already populated `library`). */
+ const skipLibraryAutoLoadRef = useRef(false);
+ const [showRecoveryModal, setShowRecoveryModal] = useState(false);
+ const [loadingRecovery, setLoadingRecovery] = useState(false);
+ const [availableSources, setAvailableSources] = useState<{
+ hasLocal: boolean;
+ hasServer: boolean;
+ hasServerDraft: boolean;
+ localTimestamp?: number;
+ }>({ hasLocal: false, hasServer: false, hasServerDraft: false });
+ const serverStateRef = useRef<{
+ book: RemixerSubPage[];
+ settings: {
+ autoNumbering?: boolean;
+ copyModeState?: string;
+ pathLevelFormats?: unknown;
+ };
+ } | null>(null);
+ const [contextMenu, setContextMenu] = useState<{
+ nodeId: string;
+ x: number;
+ y: number;
+ } | null>(null);
+ const [pendingBookImport, setPendingBookImport] = useState<{
+ node: RemixerSubPage;
+ targetNodeId: string;
+ position: DropPosition;
+ targetParentId: string;
+ } | null>(null);
+ const [isImportingFromLibrary, setIsImportingFromLibrary] =
+ useState(false);
+
+ const selectedLibraryPages = remixerData.selectedLibrary
+ ? remixerData.library?.[remixerData.selectedLibrary]
+ : undefined;
+
+ const RESTRICTED_LIBRARY_SHELF_TITLES = [
+ "bookshelves",
+ "campus bookshelves",
+ ];
+
+ const getLibraryNodeTitle = (node: RemixerSubPage | undefined): string =>
+ (node?.["@title"] || node?.title || "").trim().toLowerCase();
+
+ const isRestrictedShelfNode = useCallback(
+ (nodeId: string): boolean => {
+ const pages = selectedLibraryPages ?? [];
+ if (pages.length === 0) return false;
+ const nodesById = new Map(pages.map((node) => [node["@id"], node]));
+ const node = nodesById.get(nodeId);
+ if (!node) return false;
+ const title = getLibraryNodeTitle(node);
+ if (RESTRICTED_LIBRARY_SHELF_TITLES.includes(title)) return true;
+ const parent = node.parentID ? nodesById.get(node.parentID) : undefined;
+ const parentTitle = getLibraryNodeTitle(parent);
+ return RESTRICTED_LIBRARY_SHELF_TITLES.includes(parentTitle);
+ },
+ [selectedLibraryPages],
+ );
+
+ const isBookLevelLibraryNode = useCallback(
+ (nodeId: string): boolean => {
+ const catalog = remixerData.catalogBook ?? [];
+ if (catalog.length === 0 || !nodeId) return false;
+ const lib = remixerData.selectedLibrary;
+ return catalog.some((book) => {
+ const parts = (book.bookID ?? "").split("-");
+ const bookLib = parts[0];
+ const bookPageId = parts[1];
+ if (!bookPageId) return false;
+ if (bookPageId !== nodeId) return false;
+ if (lib && bookLib && bookLib !== lib) return false;
+ return true;
+ });
+ },
+ [remixerData.catalogBook, remixerData.selectedLibrary],
+ );
+
+ const normalizeBookState = useCallback(
+ (
+ book: RemixerSubPage[],
+ options: { initializeOriginalPathNumber?: boolean } = {},
+ ): RemixerSubPage[] => {
+ const { initializeOriginalPathNumber = false } = options;
+ const withPaths = buildBookPaths(
+ book,
+ uiState.pathLevelFormats ?? [],
+ ).map((page) =>
+ initializeOriginalPathNumber
+ ? {
+ ...page,
+ originalPathNumber: page.pathNumber ? [...page.pathNumber] : [],
+ }
+ : page,
+ );
+ return withDerivedStatusFlags(withPaths);
+ },
+ [uiState.pathLevelFormats],
+ );
+
+ const loadSelectedBook = async (bookID: string, lib: string) => {
+ skipLibraryAutoLoadRef.current = true;
+ let expandedNodeIds = new Set();
+ setRemixerData((prev) => ({ ...prev, selectedLibrary: lib as Library }));
+ // fetch bookCoverID
+ let pageId = bookID.split("-")[1];
+ const fetchingLibarary = [] as RemixerSubPage[];
+
+ while (true) {
+ if (fetchingLibarary.find((c) => c["@id"] === pageId)) {
+ expandedNodeIds.add(pageId);
+ const parent = fetchingLibarary.find((c) => c["@id"] === pageId);
+ pageId = parent?.parentID ?? "";
+ if (!pageId || pageId === "-1") break;
+ continue;
+ }
+ const res = await api.getRemixerPage(id, pageId, lib, false, false, {
+ includeMatter: false,
+ linkTitle: true,
+ full: false,
+ });
+ const pagedetails = await api.getRemixerPage(
+ id,
+ pageId,
+ lib,
+ true,
+ false,
+ {
+ includeMatter: false,
+ linkTitle: true,
+ full: false,
+ },
+ );
+
+ if (res.err === false) {
+ fetchingLibarary.push(...res.response);
+ expandedNodeIds.add(pageId);
+ }
+
+ pageId = pagedetails.response["parentID"];
+ if (!pageId || pageId === "-1") {
+ console.debug("pageId not found", pageId);
+ break;
+ }
+ }
+ const libsubpages = await api.getRemixerPage(id, "0", lib, false, false, {
+ includeMatter: false,
+ linkTitle: true,
+ full: false,
+ });
+ const libdetails = await api.getRemixerPage(id, "0", lib, true, false, {
+ includeMatter: false,
+ linkTitle: true,
+ full: false,
+ });
+ try {
+ fetchingLibarary.push(...(libsubpages.response as RemixerSubPage[]), {
+ ...libdetails.response,
+ ["@id"]: "0",
+ });
+ } catch {
+ console.debug("wrong push");
+ }
+
+ setRemixerData((prev) => ({
+ ...prev,
+ library: {
+ ...(prev.library ?? {}),
+ [lib]: fetchingLibarary.sort(
+ (a, b) => parseInt(a["@id"]) - parseInt(b["@id"]),
+ ),
+ },
+ // selectedLibrary: lib as Library,
+ }));
+
+ setExpandedNodeIdsLibrary(
+ new Set(
+ Array.from(expandedNodeIds).sort((a, b) => parseInt(a) - parseInt(b)),
+ ),
+ );
+
+ setUiState((prev) => ({
+ ...prev,
+ catalogListOpen: false,
+ }));
+ setLibraryLoading(false);
+ const targetNodeId = bookID.split("-")[1];
+ setTimeout(() => {
+ skipLibraryAutoLoadRef.current = false;
+ const el = document.querySelector(
+ `[data-node-id="${targetNodeId}"]`,
+ );
+ if (el) {
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
+ el.style.transition =
+ "outline-color 0.5s ease, background-color 0.5s ease";
+ el.style.outline = "2px solid #1e70bf";
+ el.style.backgroundColor = "rgba(30, 112, 191, 0.1)";
+ el.style.borderRadius = "4px";
+ setTimeout(() => {
+ el.style.outlineColor = "transparent";
+ el.style.backgroundColor = "transparent";
+ }, 4000);
+ setTimeout(() => {
+ el.style.transition = "";
+ el.style.outline = "";
+ el.style.backgroundColor = "";
+ el.style.borderRadius = "";
+ }, 4500);
+ }
+ }, 200);
+ };
+
+ const updateCurrentBook = (
+ updater: (prevBook: RemixerSubPage[]) => RemixerSubPage[],
+ options: { trackHistory?: boolean } = {},
+ ) => {
+ const { trackHistory = false } = options;
+ setRemixerData((prev) => {
+ const prevBook = prev.currentBook ?? [];
+ const nextBook = normalizeBookState(updater(prevBook));
+ const changed = JSON.stringify(prevBook) !== JSON.stringify(nextBook);
+
+ if (!changed) {
+ return prev;
+ }
+
+ if (trackHistory) {
+ setUndoStack((prevUndo) => [...prevUndo, cloneBook(prevBook)]);
+ setRedoStack([]);
+ }
+
+ return {
+ ...prev,
+ currentBook: nextBook,
+ };
+ });
+ };
+
+ const highestPathLevel = useCallback(
+ (): number => computeHighestPathLevel(remixerData.currentBook ?? []),
+ [remixerData.currentBook],
+ );
+
+ const handleUndo = () => {
+ setUndoStack((prevUndo) => {
+ if (prevUndo.length === 0) return prevUndo;
+ const previousBook = prevUndo[prevUndo.length - 1];
+ setRemixerData((prev) => {
+ const currentBook = prev.currentBook ?? [];
+ setRedoStack((prevRedo) => [...prevRedo, cloneBook(currentBook)]);
+ return {
+ ...prev,
+ currentBook: normalizeBookState(cloneBook(previousBook)),
+ };
+ });
+ return prevUndo.slice(0, -1);
+ });
+ };
+
+ const handleRedo = () => {
+ setRedoStack((prevRedo) => {
+ if (prevRedo.length === 0) return prevRedo;
+ const nextBook = prevRedo[prevRedo.length - 1];
+ setRemixerData((prev) => {
+ const currentBook = prev.currentBook ?? [];
+ setUndoStack((prevUndo) => [...prevUndo, cloneBook(currentBook)]);
+ return {
+ ...prev,
+ currentBook: normalizeBookState(cloneBook(nextBook)),
+ };
+ });
+ return prevRedo.slice(0, -1);
+ });
+ };
+
+ const isMatterBranchNode = (nodeId?: string): boolean =>
+ isMatterBranchNodePure(nodeId, remixerData.currentBook ?? []);
+
+ const applyDraftSettings = (settings: {
+ autoNumbering?: boolean;
+ copyModeState?: string;
+ pathLevelFormats?: unknown;
+ }) => {
+ if (settings.autoNumbering !== undefined) {
+ setRemixerData((prev) => ({
+ ...prev,
+ autoNumbering: settings.autoNumbering,
+ }));
+ }
+ if (
+ settings.copyModeState !== undefined ||
+ settings.pathLevelFormats !== undefined
+ ) {
+ setUiState((prev) => ({
+ ...prev,
+ ...(settings.copyModeState !== undefined && {
+ copyModeState: settings.copyModeState,
+ }),
+ ...(settings.pathLevelFormats !== undefined && {
+ pathLevelFormats: settings.pathLevelFormats as PathLevelFormat[],
+ }),
+ }));
+ }
+ };
+
+ const handleLoadSource = async (
+ source: "local" | "server" | "serverDraft" | "fresh",
+ ) => {
+ setShowRecoveryModal(false);
+ setLoadingRecovery(true);
+ try {
+ if (source === "local") {
+ const draft = getLocalDraft(id);
+ if (!draft) return;
+ applyDraftSettings(draft);
+ setRemixerData((prev) => ({
+ ...prev,
+ currentBook: normalizeBookState(draft.currentBook),
+ }));
+ } else if (source === "serverDraft") {
+ if (serverStateRef.current) {
+ applyDraftSettings(serverStateRef.current.settings);
+ setRemixerData((prev) => ({
+ ...prev,
+ currentBook: normalizeBookState(serverStateRef.current!.book),
+ }));
+ } else {
+ try {
+ const savedState = await api.getRemixerProjectState(id);
+ const savedBook = (savedState.currentBook ??
+ []) as RemixerSubPage[];
+ if (Array.isArray(savedBook) && savedBook.length > 0) {
+ applyDraftSettings(savedState);
+ serverStateRef.current = {
+ book: savedBook,
+ settings: savedState,
+ };
+ setRemixerData((prev) => ({
+ ...prev,
+ currentBook: normalizeBookState(savedBook),
+ }));
+ }
+ } catch {
+ addNotification({
+ message: "Failed to load server draft.",
+ type: "error",
+ duration: 3000,
+ });
+ }
+ }
+ } else if (source === "server") {
+ if (serverStateRef.current) {
+ applyDraftSettings(serverStateRef.current.settings);
+ setRemixerData((prev) => ({
+ ...prev,
+ currentBook: normalizeBookState(serverStateRef.current!.book),
+ }));
+ } else {
+ try {
+ const savedState = await api.getRemixerProjectState(id);
+ const savedBook = (savedState.currentBook ??
+ []) as RemixerSubPage[];
+ if (Array.isArray(savedBook) && savedBook.length > 0) {
+ applyDraftSettings(savedState);
+ serverStateRef.current = {
+ book: savedBook,
+ settings: savedState,
+ };
+ setRemixerData((prev) => ({
+ ...prev,
+ currentBook: normalizeBookState(savedBook),
+ }));
+ }
+ } catch {
+ addNotification({
+ message: "Failed to load server draft.",
+ type: "error",
+ duration: 3000,
+ });
+ }
+ }
+ } else {
+ clearLocalDraft(id);
+ const fullBook = await loadEntireBook(
+ id,
+ remixerData.liberCoverID!,
+ remixerData.libreLibrary!,
+ );
+ setRemixerData((prev) => ({
+ ...prev,
+ currentBook: normalizeBookState(fullBook, {
+ initializeOriginalPathNumber: true,
+ }),
+ }));
+ }
+ setUndoStack([]);
+ setRedoStack([]);
+ } finally {
+ setLoadingRecovery(false);
+ }
+ };
+
+ const openRecoveryModal = async () => {
+ const localDraft = getLocalDraft(id);
+
+ if (!serverStateRef.current) {
+ try {
+ const savedState = await api.getRemixerProjectState(id);
+ const savedBook = (savedState.currentBook ?? []) as RemixerSubPage[];
+ if (Array.isArray(savedBook) && savedBook.length > 0) {
+ serverStateRef.current = {
+ book: savedBook,
+ settings: {
+ autoNumbering: savedState.autoNumbering,
+ copyModeState: savedState.copyModeState,
+ pathLevelFormats: savedState.pathLevelFormats,
+ },
+ };
+ }
+ } catch {
+ // Server unreachable — leave serverStateRef as null
+ }
+ }
+
+ setAvailableSources({
+ hasLocal: !!localDraft,
+ hasServer: !!serverStateRef.current,
+ hasServerDraft: !!serverStateRef.current,
+ localTimestamp: localDraft?.savedAt,
+ });
+ setShowRecoveryModal(true);
+ };
+
+ const handleDeleteSelectedBookNode = () => {
+ const selectedNodeId = uiState.selectedBookNodeId;
+ if (!selectedNodeId) return;
+ if (isMatterBranchNode(selectedNodeId)) return;
+ updateCurrentBook(
+ (existingBookNodes) =>
+ applyBookNodeDeletion(existingBookNodes, selectedNodeId),
+ { trackHistory: true },
+ );
+ setUiState((prev) => ({ ...prev, selectedBookNodeId: undefined }));
+ };
+
+ const handleMarkMovedNodes = (nodeIds: string[]) => {
+ if (nodeIds.length === 0) return;
+ setRemixerData((prev) => {
+ const existingBook = prev.currentBook ?? [];
+ const movedSet = new Set(nodeIds);
+ return {
+ ...prev,
+ currentBook: normalizeBookState(
+ existingBook.map((node) =>
+ movedSet.has(node["@id"]) && !node.deletedItem
+ ? { ...node, movedItem: true }
+ : node,
+ ),
+ ),
+ };
+ });
+ };
+
+ const handleReorderBookNode = ({
+ draggedNodeId,
+ targetNodeId,
+ position,
+ }: {
+ draggedNodeId: string;
+ targetNodeId: string;
+ position: DropPosition;
+ }) => {
+ updateCurrentBook(
+ (existingBook) =>
+ reorderBookNodes({
+ existingBook,
+ draggedNodeId,
+ targetNodeId,
+ position,
+ }),
+ { trackHistory: true },
+ );
+ };
+
+ const handleAddBookItem = () => {
+ const canNestInSelectedNode = !!uiState.selectedBookNodeId;
+ const parentId = canNestInSelectedNode
+ ? uiState.selectedBookNodeId
+ : remixerData.liberCoverID;
+ const newNodeId = `new-${Date.now()}-${Math.random()
+ .toString(36)
+ .slice(2, 8)}`;
+ let title = "New Chapter";
+ if (canNestInSelectedNode && parentId !== remixerData.liberCoverID) {
+ const nodesById = new Map(
+ (remixerData.currentBook ?? []).map((node) => [node["@id"], node]),
+ );
+ let depth = 0;
+ let currentId: string | undefined = uiState.selectedBookNodeId;
+ while (
+ currentId &&
+ currentId !== remixerData.liberCoverID &&
+ currentId !== "-1"
+ ) {
+ const node = nodesById.get(currentId);
+ if (!node) break;
+ depth += 1;
+ currentId = node.parentID;
+ }
+ // New child under chapter => page; any deeper nesting => subpage.
+ title = depth <= 1 ? "New Page" : "New Subpage";
+ }
+ const newNode: RemixerSubPage = {
+ "@id": newNodeId,
+ "@title": title,
+ "@href": "#",
+ "@subpages": false,
+ article: "article",
+ parentID: parentId,
+ namespace: "main",
+ title,
+ "uri.ui": "#",
+ addedItem: true,
+ };
+
+ updateCurrentBook(
+ (existingBookNodes) => [
+ ...existingBookNodes.map((node) =>
+ node["@id"] === parentId ? { ...node, "@subpages": true } : node,
+ ),
+ newNode,
+ ],
+ { trackHistory: true },
+ );
+ setUiState((prev) => ({ ...prev, selectedBookNodeId: newNodeId }));
+ const folderToExpand = uiState.selectedBookNodeId;
+ if (folderToExpand) {
+ setExpandedNodeIdsBook((prev) => {
+ const next = new Set(prev);
+ next.add(folderToExpand);
+ return next;
+ });
+ }
+ };
+
+ const loadEntireBook = async (
+ projectId: string,
+ coverPageId: string,
+ libreLibrary: string,
+ ): Promise => {
+ const nodesById = new Map();
+ const fetchedParentIds = new Set();
+ const queue: string[] = [];
+
+ const rootDetails = await api.getRemixerPage(
+ projectId,
+ coverPageId,
+ libreLibrary,
+ true,
+ true,
+ { includeMatter: false, linkTitle: true, full: false },
+ );
+ const rootNode: RemixerSubPage = {
+ ...rootDetails.response,
+ addedItem: false,
+ };
+ nodesById.set(rootNode["@id"], rootNode);
+ queue.push(rootNode["@id"]);
+
+ while (queue.length > 0) {
+ const parentId = queue.shift();
+ if (!parentId || fetchedParentIds.has(parentId)) {
+ continue;
+ }
+ fetchedParentIds.add(parentId);
+
+ const response = await api.getRemixerPage(
+ projectId,
+ parentId,
+ libreLibrary,
+ false,
+ true,
+ { includeMatter: false, linkTitle: true, full: false },
+ );
+
+ const children: RemixerSubPage[] = (response.response ?? []).map(
+ (node: RemixerSubPage) => ({
+ ...node,
+ addedItem: false,
+ }),
+ );
+
+ children.forEach((child) => {
+ nodesById.set(child["@id"], child);
+ if (child["@subpages"]) {
+ queue.push(child["@id"]);
+ }
+ });
+ }
+
+ return Array.from(nodesById.values());
+ };
+
+ const loadLibrarySubtree = async (
+ projectId: string,
+ rootNode: RemixerSubPage,
+ libreLibrary: string,
+ ): Promise => {
+ const nodesById = new Map();
+ const fetchedParentIds = new Set();
+ const queue: string[] = [];
+
+ nodesById.set(rootNode["@id"], {
+ ...rootNode,
+ addedItem: false,
+ });
+ queue.push(rootNode["@id"]);
+
+ while (queue.length > 0) {
+ const parentId = queue.shift();
+ if (!parentId || fetchedParentIds.has(parentId)) {
+ continue;
+ }
+ fetchedParentIds.add(parentId);
+
+ const parentNode = nodesById.get(parentId);
+ if (!parentNode?.["@subpages"]) {
+ continue;
+ }
+
+ const response = await api.getRemixerPage(
+ projectId,
+ parentId,
+ libreLibrary,
+ false,
+ true,
+ { includeMatter: false, linkTitle: true, full: false },
+ );
+
+ const children: RemixerSubPage[] = (response.response ?? []).map(
+ (node: RemixerSubPage) => ({
+ ...node,
+ addedItem: false,
+ }),
+ );
+
+ children.forEach((child) => {
+ nodesById.set(child["@id"], child);
+ if (child["@subpages"]) {
+ queue.push(child["@id"]);
+ }
+ });
+ }
+
+ return Array.from(nodesById.values());
+ };
+
+ useEffect(() => {
+ const getRemixerProject = async () => {
+ // get the authen browser details
+
+ setRemixerData((prev) => ({
+ ...prev,
+ libraries: libraries,
+ }));
+ // get the project details
+ const res = await api.getRemixerProject(id);
+ setRemixerData((prev) => ({
+ ...prev,
+ projectID: res.project.projectID,
+ title: res.project.title,
+ liberCoverID: res.project.libreCoverID,
+ libreLibrary: res.project.libreLibrary,
+ selectedLibrary: isLibrary(res.project.libreLibrary)
+ ? res.project.libreLibrary
+ : undefined,
+ }));
+
+ // Resume polling if a job is already in progress
+ try {
+ const jobStatusRes = await api.getRemixerPublishJobStatus(id);
+ const existingJob = jobStatusRes.job;
+ if (
+ existingJob &&
+ (existingJob.status === "pending" || existingJob.status === "running")
+ ) {
+ setPublishStatus(existingJob.status);
+ setPublishMessages(existingJob.messages ?? []);
+ setPublishPolling(true);
+ setUiState((prev) => ({ ...prev, publishPanelOpen: true }));
+ }
+ } catch {
+ // Non-critical; ignore errors checking job status on load
+ }
+
+ const localDraft = getLocalDraft(id);
+
+ let serverBook: RemixerSubPage[] | null = null;
+ let serverSettings: {
+ autoNumbering?: boolean;
+ copyModeState?: string;
+ pathLevelFormats?: unknown;
+ } | null = null;
+
+ try {
+ const savedState = await api.getRemixerProjectState(id);
+ const savedBook = (savedState.currentBook ?? []) as RemixerSubPage[];
+ if (Array.isArray(savedBook) && savedBook.length > 0) {
+ serverBook = savedBook;
+ serverSettings = {
+ autoNumbering: savedState.autoNumbering,
+ copyModeState: savedState.copyModeState,
+ pathLevelFormats: savedState.pathLevelFormats,
+ };
+ serverStateRef.current = {
+ book: savedBook,
+ settings: serverSettings,
+ };
+ }
+ } catch (error) {
+ console.error("Failed to load remixer saved state", error);
+ }
+
+ setAvailableSources({
+ hasLocal: !!localDraft,
+ hasServer: !!serverBook,
+ hasServerDraft: !!serverBook,
+ localTimestamp: localDraft?.savedAt,
+ });
+
+ if (localDraft && serverBook) {
+ setShowRecoveryModal(true);
+ return;
+ }
+
+ if (localDraft) {
+ applyDraftSettings(localDraft);
+ setRemixerData((prev) => ({
+ ...prev,
+ currentBook: normalizeBookState(localDraft.currentBook),
+ }));
+ return;
+ }
+
+ if (serverBook && serverSettings) {
+ applyDraftSettings(serverSettings);
+ setRemixerData((prev) => ({
+ ...prev,
+ currentBook: normalizeBookState(serverBook!),
+ }));
+ return;
+ }
+
+ const fullBook = await loadEntireBook(
+ id,
+ res.project.libreCoverID,
+ res.project.libreLibrary,
+ );
+ setRemixerData((prev) => ({
+ ...prev,
+ currentBook: normalizeBookState(fullBook, {
+ initializeOriginalPathNumber: true,
+ }),
+ }));
+ };
+ const loadCatalogBook = async () => {
+ const [res, masterCatRes] = await Promise.all([
+ api.getCommonsCatalog({ limit: 10000 }),
+ api.getMasterCatalogV2(),
+ ]);
+
+ const masterBooks = flattenCatalogResponse(masterCatRes.data);
+ const commonsBooks = res.data.books ?? [];
+ const seen = new Set(commonsBooks.map((b) => b.bookID));
+ const merged = [
+ ...commonsBooks,
+ ...masterBooks.filter((b) => !seen.has(b.bookID)),
+ ];
+
+ setRemixerData((prev) => ({
+ ...prev,
+ catalogBook: merged,
+ }));
+ };
+
+ getRemixerProject();
+ loadCatalogBook();
+ }, [id]);
+
+ useEffect(() => {
+ const loadSelectedLibrary = async () => {
+ if (!id || !remixerData.selectedLibrary || skipLibraryAutoLoadRef.current)
+ return;
+ setLibraryLoading(true);
+
+ // get the selected library details
+ const resLibraryDetails = await api.getRemixerPage(
+ id,
+ "0",
+ remixerData.selectedLibrary,
+ true,
+ true,
+ { includeMatter: false, linkTitle: true, full: false },
+ );
+
+ // get selected library root children
+ const resLibrary = await api.getRemixerPage(
+ id,
+ resLibraryDetails.response["@id"],
+ remixerData.selectedLibrary,
+ false,
+ true,
+ { includeMatter: false, linkTitle: true, full: false },
+ );
+
+ setRemixerData((prev) => ({
+ ...prev,
+ library: {
+ ...(prev.library ?? {}),
+ [remixerData.selectedLibrary as Library]: [
+ resLibraryDetails.response,
+ ...(resLibrary.response ?? []),
+ ],
+ },
+ }));
+ setLibraryLoading(false);
+ };
+
+ loadSelectedLibrary();
+ }, [id, remixerData.selectedLibrary]);
+
+ useEffect(() => {
+ if (!uiState.selectedBookNodeId) return;
+ const stillExists = (remixerData.currentBook ?? []).some(
+ (node) => node["@id"] === uiState.selectedBookNodeId,
+ );
+ if (!stillExists) {
+ setUiState((prev) => ({ ...prev, selectedBookNodeId: undefined }));
+ }
+ }, [remixerData.currentBook, uiState.selectedBookNodeId]);
+
+ useEffect(() => {
+ updateCurrentBook((existingBook) => existingBook);
+ }, [uiState.pathLevelFormats]);
+
+ useEffect(() => {
+ if (!id || !remixerData.currentBook || remixerData.currentBook.length === 0)
+ return;
+ const timer = setTimeout(() => {
+ setLocalDraft(id, {
+ currentBook: remixerData.currentBook!,
+ autoNumbering: remixerData.autoNumbering,
+ copyModeState: uiState.copyModeState,
+ pathLevelFormats: uiState.pathLevelFormats,
+ savedAt: Date.now(),
+ });
+ }, 500);
+ return () => clearTimeout(timer);
+ }, [
+ id,
+ remixerData.currentBook,
+ remixerData.autoNumbering,
+ uiState.copyModeState,
+ uiState.pathLevelFormats,
+ ]);
+
+ const isExpandedAllCurrentBookNodes = useCallback(() => {
+ const { currentBook } = remixerData;
+ if (!currentBook) return false;
+ const nodesToExpand = currentBook
+ .filter((node) => node["@subpages"])
+ .map((node) => node["@id"]);
+ return nodesToExpand.every((nodeId) => expandedNodeIdsBook.has(nodeId));
+ }, [remixerData.currentBook, expandedNodeIdsBook]);
+
+ const expandBookTree = async (nodeId: string) => {
+ // If this folder has server-backed children, don't refetch.
+ // If it only has locally added items, fetch from server.
+ const currentChildren = (remixerData.currentBook ?? []).filter(
+ (p) => p.parentID === nodeId,
+ );
+ const hasServerBackedChildren = currentChildren.some(
+ (child) => !child.addedItem,
+ );
+ if (hasServerBackedChildren) {
+ return;
+ }
+ const expandedNode = (remixerData.currentBook ?? []).find(
+ (node) => node["@id"] === nodeId,
+ );
+ const inheritAddedItem = expandedNode?.addedItem === true;
+
+ const resPage = await api.getRemixerPage(
+ id,
+ nodeId,
+ remixerData.libreLibrary || "",
+ false,
+ true,
+ { includeMatter: false, linkTitle: true, full: false },
+ );
+
+ updateCurrentBook((existingBook) => {
+ const incomingPages: RemixerSubPage[] = (resPage.response ?? []).map(
+ (page: RemixerSubPage) => ({
+ ...page,
+ addedItem: inheritAddedItem,
+ }),
+ );
+ const incomingIds = new Set(incomingPages.map((page) => page["@id"]));
+ const updatedExisting = existingBook.map((page) =>
+ incomingIds.has(page["@id"])
+ ? { ...page, addedItem: inheritAddedItem }
+ : page,
+ );
+ const newPages = incomingPages.filter(
+ (incomingPage) =>
+ !existingBook.some(
+ (existingPage) => existingPage["@id"] === incomingPage["@id"],
+ ),
+ );
+ return [...updatedExisting, ...newPages];
+ });
+ };
+ const expandAllCurrentBook = async () => {
+ const { currentBook } = remixerData;
+ if (!currentBook) return;
+ const ids = currentBook.map((node) => {
+ return node["@id"];
+ });
+
+ setExpandedNodeIdsBook(new Set(ids));
+ };
+ const collapseAllCurrentBook = async () => {
+ setExpandedNodeIdsBook(new Set());
+ };
+
+ const expandLibraryTree = async (nodeId: string) => {
+ if (!remixerData.selectedLibrary) return;
+ // If this folder already has loaded children, don't refetch
+ if (selectedLibraryPages?.some((p) => p.parentID === nodeId)) {
+ return;
+ }
+ const node = selectedLibraryPages?.find((p) => p["@id"] === nodeId);
+ const pagePath =
+ node?.title === "Workbench" || node?.["@title"] === "Workbench"
+ ? "Workbench"
+ : nodeId;
+ const resLibrary = await api.getRemixerPage(
+ id,
+ pagePath,
+ remixerData.selectedLibrary,
+ false,
+ true,
+ { includeMatter: false, linkTitle: true, full: false },
+ );
+ setRemixerData((prev) => ({
+ ...prev,
+ library: {
+ ...(prev.library ?? {}),
+ [remixerData.selectedLibrary as Library]: [
+ ...(prev.library?.[remixerData.selectedLibrary as Library] ?? []),
+ ...(resLibrary.response ?? []),
+ ],
+ },
+ }));
+ };
+
+ const importLibraryNodeToBook = async (
+ {
+ sourceTreeId,
+ targetTreeId,
+ node,
+ targetNodeId,
+ position,
+ targetParentId,
+ }: {
+ sourceTreeId: "library" | "book";
+ targetTreeId: "library" | "book";
+ node: RemixerSubPage;
+ targetNodeId: string;
+ position: DropPosition;
+ targetParentId: string;
+ },
+ options: { extractContent?: boolean; bypassPrompt?: boolean } = {},
+ ) => {
+ if (sourceTreeId !== "library" || targetTreeId !== "book") return;
+ if (!remixerData.selectedLibrary) return;
+ if (isRestrictedShelfNode(node["@id"])) {
+ addNotification({
+ message:
+ "Import blocked: Bookshelves and Campus Bookshelves (and their immediate children) cannot be moved to Current Book.",
+ type: "info",
+ duration: 3500,
+ });
+ return;
+ }
+
+ if (!options.bypassPrompt && isBookLevelLibraryNode(node["@id"])) {
+ setPendingBookImport({
+ node,
+ targetNodeId,
+ position,
+ targetParentId,
+ });
+ return;
+ }
+
+ setIsImportingFromLibrary(true);
+ let subtreeNodes: RemixerSubPage[] = [];
+ try {
+ subtreeNodes = await loadLibrarySubtree(
+ id,
+ node,
+ remixerData.selectedLibrary,
+ );
+ } catch (error) {
+ setIsImportingFromLibrary(false);
+ addNotification({
+ message:
+ error instanceof Error
+ ? error.message
+ : "Failed to load library content.",
+ type: "error",
+ duration: 3000,
+ });
+ return;
+ }
+
+ updateCurrentBook(
+ (existingBookNodes) => {
+ const suffix = `-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ const idMap = new Map();
+ for (const sn of subtreeNodes) {
+ idMap.set(sn["@id"], `${sn["@id"]}${suffix}`);
+ }
+
+ const originalRootId = node["@id"];
+
+ if (options.extractContent) {
+ const directChildren = subtreeNodes.filter(
+ (sn) => sn.parentID === originalRootId,
+ );
+ const subtreeWithoutRoot = subtreeNodes.filter(
+ (sn) => sn["@id"] !== originalRootId,
+ );
+ const copied = subtreeWithoutRoot.map((sn) => ({
+ ...sn,
+ sourceID: sn["@id"],
+ "@id": idMap.get(sn["@id"])!,
+ addedItem: true,
+ parentID:
+ sn.parentID === originalRootId
+ ? targetParentId
+ : idMap.get(sn.parentID ?? "") ?? sn.parentID,
+ }));
+
+ let nextBook = [...existingBookNodes, ...copied];
+
+ if (position !== "inside") {
+ const childIdsNew = directChildren.map(
+ (child) => idMap.get(child["@id"])!,
+ );
+ childIdsNew.forEach((newId, index) => {
+ const currentTarget =
+ index === 0 ? targetNodeId : childIdsNew[index - 1];
+ const currentPos: DropPosition = index === 0 ? position : "after";
+ nextBook = insertAtSiblingPosition({
+ bookNodes: nextBook,
+ importedRootId: newId,
+ targetNodeId: currentTarget,
+ position: currentPos,
+ targetParentId,
+ });
+ });
+ }
+
+ return nextBook;
+ }
+
+ const importedRootId = idMap.get(originalRootId)!;
+ const copiedSubtreeNodes = subtreeNodes.map((subtreeNode) => ({
+ ...subtreeNode,
+ sourceID: subtreeNode["@id"],
+ "@id": idMap.get(subtreeNode["@id"])!,
+ addedItem: true,
+ parentID:
+ subtreeNode["@id"] === originalRootId
+ ? targetParentId
+ : idMap.get(subtreeNode.parentID ?? "") ?? subtreeNode.parentID,
+ }));
+
+ const nextBookNodes = [
+ ...existingBookNodes,
+ ...copiedSubtreeNodes,
+ ];
+
+ return insertAtSiblingPosition({
+ bookNodes: nextBookNodes,
+ importedRootId,
+ targetNodeId,
+ position,
+ targetParentId,
+ });
+ },
+ { trackHistory: true },
+ );
+ setIsImportingFromLibrary(false);
+ };
+
+ const resolvePendingBookImport = async (extractContent: boolean) => {
+ if (!pendingBookImport) return;
+ const pending = pendingBookImport;
+ setPendingBookImport(null);
+ await importLibraryNodeToBook(
+ {
+ sourceTreeId: "library",
+ targetTreeId: "book",
+ node: pending.node,
+ targetNodeId: pending.targetNodeId,
+ position: pending.position,
+ targetParentId: pending.targetParentId,
+ },
+ { extractContent, bypassPrompt: true },
+ );
+ };
+
+ const importLibraryNodeToBookById = async ({
+ nodeId,
+ targetTreeId,
+ targetNodeId,
+ position,
+ targetParentId,
+ }: {
+ nodeId: string;
+ targetTreeId: "library" | "book";
+ targetNodeId: string;
+ position: DropPosition;
+ targetParentId: string;
+ }) => {
+ if (targetTreeId !== "book") return;
+ const node = (selectedLibraryPages ?? []).find(
+ (item) => item["@id"] === nodeId,
+ );
+ if (!node) return;
+ if (isRestrictedShelfNode(nodeId)) {
+ addNotification({
+ message:
+ "Import blocked: Bookshelves and Campus Bookshelves (and their immediate children) cannot be moved to Current Book.",
+ type: "info",
+ duration: 3500,
+ });
+ return;
+ }
+ await importLibraryNodeToBook({
+ sourceTreeId: "library",
+ targetTreeId,
+ node,
+ targetNodeId,
+ position,
+ targetParentId,
+ });
+ };
+
+ const selectedBookNode = uiState.selectedBookNodeId
+ ? remixerData.currentBook?.find(
+ (node) => node["@id"] === uiState.selectedBookNodeId,
+ )
+ : undefined;
+
+ const selectedBookDefaultFormattedPath = useCallback((): string => {
+ if (remixerData.autoNumbering === false) return "";
+ const selectedId = uiState.selectedBookNodeId;
+ const book = remixerData.currentBook ?? [];
+ if (!selectedId || book.length === 0) return "";
+ const normalizedBook = buildBookPaths(
+ book,
+ uiState.pathLevelFormats ?? [],
+ {
+ ignoreOverrides: true,
+ },
+ );
+ return (
+ normalizedBook.find((node) => node["@id"] === selectedId)
+ ?.formattedPath ?? ""
+ );
+ }, [
+ remixerData.autoNumbering,
+ remixerData.currentBook,
+ uiState.pathLevelFormats,
+ uiState.selectedBookNodeId,
+ ]);
+
+ const handleSaveEdit = (page: RemixerSubPage) => {
+ if (isMatterBranchNode(page["@id"])) {
+ setUiState((prev) => ({ ...prev, editPanelOpen: false }));
+ return;
+ }
+ setUiState((prev) => ({ ...prev, editPanelOpen: false }));
+ updateCurrentBook(
+ (existingBook) => {
+ return existingBook.map((node) => {
+ if (node["@id"] !== page["@id"]) return node;
+ const previousTitle = node.title || node["@title"] || "";
+ const nextTitle = page.title || page["@title"] || "";
+ const renamed = previousTitle !== nextTitle;
+ return {
+ ...node,
+ ...page,
+ title: nextTitle,
+ "@title": nextTitle,
+ formattedPathOverride: page.formattedPathOverride === true,
+ formattedPath:
+ page.formattedPathOverride === true
+ ? page.formattedPath
+ : undefined,
+ renamedItem: node.renamedItem || renamed,
+ };
+ });
+ },
+ { trackHistory: true },
+ );
+ };
+
+ const handleSaveDraft = async () => {
+ if (!id) return;
+ try {
+ const response = await api.saveRemixerProjectState(
+ id,
+ remixerData.currentBook ?? [],
+ {
+ autoNumbering: remixerData.autoNumbering,
+ copyModeState: uiState.copyModeState,
+ pathLevelFormats: uiState.pathLevelFormats,
+ },
+ );
+ if (response.err) {
+ throw new Error(response.errMsg ?? "Failed to save draft");
+ }
+ clearLocalDraft(id);
+ serverStateRef.current = {
+ book: remixerData.currentBook ?? [],
+ settings: {
+ autoNumbering: remixerData.autoNumbering,
+ copyModeState: uiState.copyModeState,
+ pathLevelFormats: uiState.pathLevelFormats,
+ },
+ };
+ addNotification({
+ message: "Draft saved successfully.",
+ type: "success",
+ duration: 3000,
+ });
+ } catch (error) {
+ addNotification({
+ message:
+ error instanceof Error ? error.message : "Failed to save draft",
+ type: "error",
+ duration: 3000,
+ });
+ }
+ };
+
+ const handlePublish = async () => {
+ if (!id) return;
+ try {
+ setPublishStatus("pending");
+ setPublishMessages(["Publish request accepted. Creating backend job..."]);
+ const response = await api.publishRemixerProject(
+ id,
+ remixerData.currentBook ?? [],
+ {
+ autoNumbering: remixerData.autoNumbering,
+ copyModeState: uiState.copyModeState,
+ pathLevelFormats: uiState.pathLevelFormats,
+ },
+ );
+ if (response.err) {
+ throw new Error(response.errMsg ?? "Failed to publish");
+ }
+ setPublishPolling(true);
+ } catch (error) {
+ setPublishStatus("error");
+ addNotification({
+ message: error instanceof Error ? error.message : "Failed to publish",
+ type: "error",
+ duration: 3000,
+ });
+ }
+ };
+
+ useEffect(() => {
+ if (!id || !publishPolling) return;
+
+ let isCancelled = false;
+ const pollOnce = async () => {
+ try {
+ const statusResponse = await api.getRemixerPublishJobStatus(id);
+ if (isCancelled) return;
+
+ const job = statusResponse.job;
+ if (!job) {
+ setPublishStatus("pending");
+ return;
+ }
+
+ setPublishStatus(job.status);
+ setPublishMessages(job.messages ?? []);
+
+ if (job.status === "success") {
+ setPublishPolling(false);
+ addNotification({
+ message: "Publish completed successfully.",
+ type: "success",
+ duration: 4000,
+ });
+ setTimeout(() => window.location.reload(), 4000);
+ } else if (job.status === "error") {
+ setPublishPolling(false);
+ addNotification({
+ message: job.errorMessage || "Publish failed.",
+ type: "error",
+ duration: 5000,
+ });
+ }
+ } catch (error) {
+ if (isCancelled) return;
+ setPublishPolling(false);
+ setPublishStatus("error");
+ addNotification({
+ message:
+ error instanceof Error
+ ? error.message
+ : "Failed to get publish status.",
+ type: "error",
+ duration: 5000,
+ });
+ }
+ };
+
+ pollOnce();
+ const intervalId = window.setInterval(pollOnce, 2000);
+ return () => {
+ isCancelled = true;
+ window.clearInterval(intervalId);
+ };
+ }, [id, publishPolling, addNotification]);
+
+ const handleStartOver = async () => {
+ if (!id) return;
+ clearLocalDraft(id);
+ serverStateRef.current = null;
+ await api.deleteRemixerProjectState(id);
+ const res = await api.getRemixerProject(id);
+ const fullBook = await loadEntireBook(
+ id,
+ res.project.libreCoverID,
+ res.project.libreLibrary,
+ );
+ setUndoStack([]);
+ setRedoStack([]);
+ setUiState((prev) => ({
+ ...prev,
+ selectedBookNodeId: undefined,
+ editPanelOpen: false,
+ publishPanelOpen: false,
+ }));
+ setRemixerData((prev) => ({
+ ...prev,
+ projectID: res.project.projectID,
+ title: res.project.title,
+ liberCoverID: res.project.libreCoverID,
+ libreLibrary: res.project.libreLibrary,
+ selectedLibrary: isLibrary(res.project.libreLibrary)
+ ? res.project.libreLibrary
+ : undefined,
+ currentBook: normalizeBookState(fullBook, {
+ initializeOriginalPathNumber: true,
+ }),
+ }));
+ };
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "F2" && uiState.selectedBookNodeId) {
+ e.preventDefault();
+ setUiState((prev) => ({ ...prev, editPanelOpen: true }));
+ }
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [uiState.selectedBookNodeId]);
+
+ useEffect(() => {
+ if (!contextMenu) return;
+ const dismiss = () => setContextMenu(null);
+ const frameId = requestAnimationFrame(() => {
+ window.addEventListener("click", dismiss);
+ window.addEventListener("contextmenu", dismiss);
+ });
+ return () => {
+ cancelAnimationFrame(frameId);
+ window.removeEventListener("click", dismiss);
+ window.removeEventListener("contextmenu", dismiss);
+ };
+ }, [contextMenu]);
+
+ const getContextNodeDepth = (nodeId: string): number => {
+ const nodesById = new Map(
+ (remixerData.currentBook ?? []).map((n) => [n["@id"], n]),
+ );
+ let depth = 0;
+ let currentId: string | undefined = nodeId;
+ while (
+ currentId &&
+ currentId !== remixerData.liberCoverID &&
+ currentId !== "-1"
+ ) {
+ const node = nodesById.get(currentId);
+ if (!node) break;
+ depth += 1;
+ currentId = node.parentID;
+ }
+ return depth;
+ };
+
+ const isContextNodeRoot = (nodeId: string): boolean => {
+ const node = (remixerData.currentBook ?? []).find(
+ (n) => n["@id"] === nodeId,
+ );
+ return !node?.parentID || node.parentID === "-1";
+ };
+
+ const contextMenuCanAddSibling =
+ contextMenu != null && !isContextNodeRoot(contextMenu.nodeId);
+
+ const contextMenuCanDuplicate =
+ contextMenu != null &&
+ !(remixerData.currentBook ?? []).some(
+ (n) => n.parentID === contextMenu.nodeId,
+ );
+
+ const getTitleForDepth = (depth: number): string => {
+ if (depth <= 0) return "New Chapter";
+ if (depth <= 1) return "New Page";
+ return "New Subpage";
+ };
+
+ const getNodeLabelForDepth = (depth: number): string =>
+ getTitleForDepth(depth).replace(/^New\s+/, "");
+
+ const contextMenuSiblingTypeLabel = contextMenu
+ ? getNodeLabelForDepth(getContextNodeDepth(contextMenu.nodeId) - 1)
+ : "Item";
+ const contextMenuChildTypeLabel = contextMenu
+ ? getNodeLabelForDepth(getContextNodeDepth(contextMenu.nodeId))
+ : "Item";
+
+ const addNodeRelative = (
+ targetNodeId: string,
+ mode: "above" | "below" | "inside",
+ ) => {
+ const book = remixerData.currentBook ?? [];
+ const nodesById = new Map(book.map((n) => [n["@id"], n]));
+ const targetNode = nodesById.get(targetNodeId);
+ if (!targetNode) return;
+
+ const newNodeId = `new-${Date.now()}-${Math.random()
+ .toString(36)
+ .slice(2, 8)}`;
+
+ if (mode === "inside") {
+ const depth = getContextNodeDepth(targetNodeId);
+ const title = getTitleForDepth(depth);
+ const newNode: RemixerSubPage = {
+ "@id": newNodeId,
+ "@title": title,
+ "@href": "#",
+ "@subpages": false,
+ article: "article",
+ parentID: targetNodeId,
+ namespace: "main",
+ title,
+ "uri.ui": "#",
+ addedItem: true,
+ };
+ updateCurrentBook(
+ (existingBookNodes) => [
+ ...existingBookNodes.map((n) =>
+ n["@id"] === targetNodeId ? { ...n, "@subpages": true } : n,
+ ),
+ newNode,
+ ],
+ { trackHistory: true },
+ );
+ setExpandedNodeIdsBook((prev) => {
+ const next = new Set(prev);
+ next.add(targetNodeId);
+ return next;
+ });
+ } else {
+ const parentId = targetNode.parentID ?? "-1";
+ const depth = getContextNodeDepth(targetNodeId) - 1;
+ const title = getTitleForDepth(depth);
+ const newNode: RemixerSubPage = {
+ "@id": newNodeId,
+ "@title": title,
+ "@href": "#",
+ "@subpages": false,
+ article: "article",
+ parentID: parentId,
+ namespace: "main",
+ title,
+ "uri.ui": "#",
+ addedItem: true,
+ };
+ updateCurrentBook(
+ (existingBookNodes) => {
+ const siblings = existingBookNodes.filter(
+ (n) => (n.parentID ?? "-1") === parentId,
+ );
+ const targetIndex = siblings.findIndex(
+ (n) => n["@id"] === targetNodeId,
+ );
+ const insertAfterIndex =
+ mode === "above" ? targetIndex - 1 : targetIndex;
+ const insertAfterId = siblings[insertAfterIndex]?.["@id"];
+
+ const result: RemixerSubPage[] = [];
+ for (const n of existingBookNodes) {
+ result.push(n);
+ if (insertAfterId && n["@id"] === insertAfterId) {
+ result.push(newNode);
+ }
+ }
+ if (!insertAfterId) {
+ const firstSiblingIndex = result.findIndex(
+ (n) => (n.parentID ?? "-1") === parentId,
+ );
+ if (firstSiblingIndex >= 0) {
+ result.splice(firstSiblingIndex, 0, newNode);
+ } else {
+ result.push(newNode);
+ }
+ }
+ return result;
+ },
+ { trackHistory: true },
+ );
+ }
+ setUiState((prev) => ({ ...prev, selectedBookNodeId: newNodeId }));
+ };
+
+ const handleContextMenuAction = (
+ action: "add-above" | "add-to" | "add-below" | "delete" | "modify" | "duplicate",
+ ) => {
+ if (!contextMenu) return;
+ const { nodeId } = contextMenu;
+ setContextMenu(null);
+
+ if (action === "modify") {
+ setUiState((prev) => ({
+ ...prev,
+ selectedBookNodeId: nodeId,
+ editPanelOpen: true,
+ }));
+ } else if (action === "delete") {
+ if (isMatterBranchNode(nodeId)) return;
+ updateCurrentBook(
+ (existingBookNodes) =>
+ applyBookNodeDeletion(existingBookNodes, nodeId),
+ { trackHistory: true },
+ );
+ setUiState((prev) => ({ ...prev, selectedBookNodeId: undefined }));
+ } else if (action === "add-above") {
+ addNodeRelative(nodeId, "above");
+ } else if (action === "add-below") {
+ addNodeRelative(nodeId, "below");
+ } else if (action === "add-to") {
+ addNodeRelative(nodeId, "inside");
+ } else if (action === "duplicate") {
+ const book = remixerData.currentBook ?? [];
+ const original = book.find((n) => n["@id"] === nodeId);
+ if (!original) return;
+ const newNodeId = `${nodeId}-dup-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ const resolvedSourceID = original.sourceID || original["@id"];
+ const duplicate: RemixerSubPage = {
+ ...original,
+ "@id": newNodeId,
+ ...(resolvedSourceID && !resolvedSourceID.startsWith("new-")
+ ? { sourceID: resolvedSourceID }
+ : {}),
+ addedItem: true,
+ };
+ updateCurrentBook(
+ (existingBookNodes) => {
+ const result: RemixerSubPage[] = [];
+ for (const n of existingBookNodes) {
+ result.push(n);
+ if (n["@id"] === nodeId) {
+ result.push(duplicate);
+ }
+ }
+ return result;
+ },
+ { trackHistory: true },
+ );
+ setUiState((prev) => ({ ...prev, selectedBookNodeId: newNodeId }));
+ }
+ };
+
+ return (
+
+
+ setUiState((prev) => ({ ...prev, publishPanelOpen: true }))
+ }
+ onPathNameFormat={() =>
+ setUiState((prev) => ({ ...prev, pathNameFormatOpen: true }))
+ }
+ onSave={handleSaveDraft}
+ copyModeState={uiState.copyModeState as CopyMode | undefined}
+ onCopyModeChange={(value) =>
+ setUiState((prev) => ({ ...prev, copyModeState: value }))
+ }
+ isAdmin={isAdmin}
+ />
+
+
+
+
+
+
Library
+
+
({
+ key: library,
+ text: isLibrary(library)
+ ? libraryTitles[library]
+ : library,
+ value: library,
+ }))}
+ value={remixerData.selectedLibrary}
+ onChange={(e, { value }) => {
+ const nextLibrary =
+ typeof value === "string" && isLibrary(value)
+ ? value
+ : undefined;
+ setRemixerData((prev) => ({
+ ...prev,
+ selectedLibrary: nextLibrary,
+ }));
+ }}
+ fluid
+ selection
+ placeholder="Library..."
+ style={{ flex: 1 }}
+ />
+
+
+ setUiState((prev) => ({ ...prev, catalogListOpen: true }))
+ }
+ style={buttonStyle}
+ onMouseEnter={handleMouseEnter}
+ onMouseLeave={handleMouseLeave}
+ >
+
+
+ }
+ />
+
+
+ {!libraryLoading &&
+ selectedLibraryPages &&
+ remixerData.selectedLibrary ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
Text
+
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+ {remixerData.currentBook ? (
+
+
+ setUiState((prev) => ({
+ ...prev,
+ selectedBookNodeId: nodeId,
+ }))
+ }
+ onNodeDoubleClick={(nodeId) =>
+ setUiState((prev) => ({
+ ...prev,
+ selectedBookNodeId: nodeId,
+ editPanelOpen: true,
+ }))
+ }
+ onNodeContextMenu={(nodeId, event) => {
+ setUiState((prev) => ({
+ ...prev,
+ selectedBookNodeId: nodeId,
+ }));
+ setContextMenu({
+ nodeId,
+ x: event.clientX,
+ y: event.clientY,
+ });
+ }}
+ />
+
+ Importing from library…
+
+
+ ) : (
+
+ )}
+
+
+
+
+ setUiState((prev) => ({ ...prev, publishPanelOpen: false }))
+ }
+ handlePublish={handlePublish}
+ currentBook={remixerData.currentBook}
+ publishInProgress={publishPolling}
+ publishStatus={publishStatus}
+ publishMessages={publishMessages}
+ />
+
+ setUiState((prev) => ({ ...prev, editPanelOpen: false }))
+ }
+ formattedPathDefault={selectedBookDefaultFormattedPath()}
+ currentPage={selectedBookNode}
+ handleSave={handleSaveEdit}
+ />
+
+ setUiState((prev) => ({ ...prev, pathNameFormatOpen: false }))
+ }
+ depth={highestPathLevel()}
+ pathLevelFormats={uiState.pathLevelFormats ?? []}
+ setPathLevelFormats={(pathLevelFormats) =>
+ setUiState((prev) => ({ ...prev, pathLevelFormats }))
+ }
+ autoNumbering={remixerData.autoNumbering ?? true}
+ onAutoNumberingChange={(checked) => {
+ setRemixerData((prev) => ({
+ ...prev,
+ autoNumbering: checked,
+ }));
+ }}
+ />
+
+ setUiState((prev) => ({ ...prev, catalogListOpen: false }))
+ }
+ dimmer="blurring"
+ catalogBook={remixerData.catalogBook}
+ loadSelectedBook={loadSelectedBook}
+ loading={skipLibraryAutoLoadRef.current}
+ />
+ setShowRecoveryModal(false)}
+ />
+ setPendingBookImport(null)}
+ >
+ Import Book
+
+
+ You're importing{" "}
+
+ {pendingBookImport?.node?.["@title"] ||
+ pendingBookImport?.node?.title ||
+ "this book"}
+
+ .
+
+
+ Do you want to extract its content (insert only its
+ chapters/pages), or insert the book as-is (keeping the book node
+ itself)?
+
+
+
+ setPendingBookImport(null)}>Cancel
+ resolvePendingBookImport(false)}>
+ Keep as-is
+
+ resolvePendingBookImport(true)}>
+ Extract Content
+
+
+
+
+
+ );
+};
+
+export default RemixerDashboard;
diff --git a/client/src/components/remixer/model.ts b/client/src/components/remixer/model.ts
new file mode 100644
index 00000000..fb4d9511
--- /dev/null
+++ b/client/src/components/remixer/model.ts
@@ -0,0 +1,158 @@
+import { Book, MasterCatalogV2Response } from "../../types";
+
+export type Library = "bio" | "biz" | "chem" | "dev" | "eng" | "espanol" | "geo" | "human" | "k12" | "math" | "med" | "phys" | "socialsci" | "stats" | "workforce";
+export const libraries: Library[] = ["bio", "biz", "chem", "dev", "eng", "espanol", "geo", "human", "k12", "math", "med", "phys", "socialsci", "stats", "workforce"];
+export type PublishJobStatus = "idle" | "pending" | "running" | "success" | "error";
+
+export const libraryTitles: Record = {
+ bio: "Biology",
+ biz: "Business",
+ chem: "Chemistry",
+ dev: "Development",
+ eng: "Engineering",
+ espanol: "Español",
+ geo: "Geosciences",
+ human: "Humanities",
+ k12: "K-12 Education",
+ math: "Mathematics",
+ med: "Medicine",
+ phys: "Physics",
+ socialsci: "Social Sciences",
+ stats: "Statistics",
+ workforce: "Workforce",
+};
+
+
+export type NumberingType = "numeric" | "alphabetic" | "alphabetic_lower" | "roman" | "roman_lower" | "none";
+
+export interface PathLevelFormat {
+ level: number;
+ excludeParent?: boolean;
+ delimiter?: string;
+ prefix: string;
+ start: number;
+ type: NumberingType;
+}
+
+export interface RemixerSubPage {
+ "@id": string;
+ "@title": string;
+ "@href": string;
+ "@subpages":boolean;
+ "article": "article"|"topic-category"|"topic-guide";
+ "parentID"?: string;
+ "namespace": string;
+ "title": string;
+ "uri.ui": string;
+ originalPathNumber?: string[];
+ pathNumber?: string[];
+ numberedPath?: string;
+ formattedPath?: string;
+ formattedPathOverride?: boolean;
+ isDeleted?: boolean;
+ isImported?: boolean;
+ isRenamed?: boolean;
+ isPlacementChanged?: boolean;
+ addedItem?: boolean;
+ movedItem?: boolean;
+ renamedItem?: boolean;
+ deletedItem?: boolean;
+ sourceID?: string;
+}
+
+export type RemixerLibrary = Partial>;
+
+export interface RemixerData {
+ projectID?: string;
+ title?: string;
+ liberCoverID?: string;
+ libreLibrary?: string;
+ currentBook?: RemixerSubPage[];
+ library?: RemixerLibrary;
+ libraries?: string[];
+ selectedLibrary?: Library;
+ autoNumbering?: boolean;
+ catalogBook?: Book[];
+ masterCatelog ?: MasterCatalogV2Response;
+}
+
+export const remixerDataInit:RemixerData = { autoNumbering: true } as RemixerData;
+
+export interface RemixerUiState {
+ catalogListOpen: boolean;
+ publishPanelOpen: boolean;
+ pathNameFormatOpen: boolean;
+ editPanelOpen: boolean;
+ selectedBookNodeId?: string;
+ pathNameFormatDepth: number;
+ pathLevelFormats?: PathLevelFormat[];
+ copyModeState?: string;
+}
+
+
+
+export type CopyMode="Transclude"|"Fork"| "Full";
+export interface CopyModeState {
+ title: string;
+ value: CopyMode;
+ isAdminOnly: boolean;
+}
+
+export const copyModeStates: CopyModeState[] = [
+ { title: "Copy-Transclude (Recommended)", value: "Transclude", isAdminOnly: false },
+ { title: "Copy-Fork", value: "Fork", isAdminOnly: false },
+ { title: "Copy-Full (Admin Only)", value: "Full", isAdminOnly: true },
+];
+
+export const defaultCopyModeState: CopyModeState = copyModeStates[0];
+
+export interface PrefixOption {
+ key: string;
+ text: string;
+ value: string;
+}
+
+export const DEFAULT_PREFIX_OPTIONS: PrefixOption[] = [
+ { key: "none", text: "None", value: "" },
+ { key: "chapter", text: "Chapter", value: "Chapter " },
+ { key: "unit", text: "Unit", value: "Unit " },
+ { key: "section", text: "Section", value: "Section " },
+];
+
+export const DELIMITER_OPTIONS = [
+ { key: "dot", text: ".", value: "." },
+ { key: "dash", text: "-", value: "-" },
+ { key: "slash", text: "/", value: "/" },
+ { key: "space", text: "space", value: " " },
+];
+
+export const NUMBERING_TYPE_OPTIONS = [
+ { key: "numeric", text: "Numeric (1, 2, 3)", value: "numeric" },
+ { key: "alphabetic", text: "Upper Alphabetic (A, B, C)", value: "alphabetic" },
+ { key: "alphabetic_lower", text: "Lower Alphabetic (a, b, c)", value: "alphabetic_lower" },
+ { key: "roman", text: "Upper Roman (I, II, III)", value: "roman" },
+ { key: "roman_lower", text: "Lower Roman (i, ii, iii)", value: "roman_lower" },
+ { key: "none", text: "No numbering", value: "none" },
+];
+
+export const remixerUiStateInit: RemixerUiState = {
+ catalogListOpen: false,
+ publishPanelOpen: false,
+ pathNameFormatOpen: false,
+ editPanelOpen: false,
+ selectedBookNodeId: undefined,
+ pathNameFormatDepth: 0,
+ pathLevelFormats: [],
+ copyModeState: copyModeStates[0].value,
+};
+
+export interface GetRemixerDisplayTitleOptions {
+ isBookTree: boolean;
+ autoNumbering: boolean;
+ pathLevelFormats?: PathLevelFormat[];
+ /** Nearest-ancestor override resolution for descendants (book tree). */
+ remixerPathLookup?: {
+ nodesById: Map;
+ ordinalPathById: Map;
+ };
+}
\ No newline at end of file
diff --git a/client/src/components/remixer/services.ts b/client/src/components/remixer/services.ts
new file mode 100644
index 00000000..988a002e
--- /dev/null
+++ b/client/src/components/remixer/services.ts
@@ -0,0 +1,756 @@
+import { GetRemixerDisplayTitleOptions, NumberingType, PathLevelFormat, RemixerSubPage } from "./model";
+
+export type DropPosition = "before" | "inside" | "after";
+
+export interface LocalDraft {
+ currentBook: RemixerSubPage[];
+ autoNumbering?: boolean;
+ copyModeState?: string;
+ pathLevelFormats?: PathLevelFormat[];
+ savedAt: number;
+}
+
+const LOCAL_DRAFT_KEY = (projectId: string) => `remixer_draft_${projectId}`;
+
+export function getLocalDraft(projectId: string): LocalDraft | null {
+ try {
+ const raw = localStorage.getItem(LOCAL_DRAFT_KEY(projectId));
+ if (!raw) return null;
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed.currentBook) || parsed.currentBook.length === 0)
+ return null;
+ return parsed as LocalDraft;
+ } catch {
+ return null;
+ }
+}
+
+export function setLocalDraft(projectId: string, draft: LocalDraft): void {
+ try {
+ localStorage.setItem(LOCAL_DRAFT_KEY(projectId), JSON.stringify(draft));
+ } catch {
+ // localStorage quota exceeded or unavailable
+ }
+}
+
+export function clearLocalDraft(projectId: string): void {
+ try {
+ localStorage.removeItem(LOCAL_DRAFT_KEY(projectId));
+ } catch {
+ // ignore
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Path/number helpers
+// ---------------------------------------------------------------------------
+
+export const stripLeadingNumbering = (value: string): string =>
+ value.replace(/^\s*\d+(?:\.\d+)*\s*[:.\-]\s*/, "").trim();
+
+/**
+ * Stored titles often duplicate the computed path prefix ("1.2: Title"). With auto-numbering,
+ * drop the legacy segment before the first ":" and any further ":" in the remainder.
+ */
+export const stripDefaultTitlePrefixBeforeColon = (value: string): string => {
+ let s = value;
+ const colonIndex = s.indexOf(":");
+ if (colonIndex !== -1) {
+ s = s.slice(colonIndex + 1);
+ }
+ return s.replace(/:/g, "").trim();
+};
+
+const normalizedMatterTitle = (node: RemixerSubPage): string =>
+ stripLeadingNumbering(node["@title"] || node.title || "").toLowerCase();
+
+export const isFrontMatterNode = (node: RemixerSubPage): boolean => {
+ if (normalizedMatterTitle(node) === "front matter") return true;
+ const uri = (node["uri.ui"] || node["@href"] || "").toLowerCase();
+ return uri.includes("front_matter");
+};
+
+export const isBackMatterNode = (node: RemixerSubPage): boolean => {
+ if (normalizedMatterTitle(node) === "back matter") return true;
+ const uri = (node["uri.ui"] || node["@href"] || "").toLowerCase();
+ return uri.includes("back_matter");
+};
+
+/** Path segment as integer for formatting ("0" → 0, "3" → 3). */
+export const parsePathSegmentOrdinal = (segment: string): number => {
+ const t = segment.trim();
+ if (t === "") return 1;
+ const n = Number.parseInt(t, 10);
+ if (Number.isFinite(n) && String(n) === t) return n;
+ return Math.max(1, Number(segment) || 1);
+};
+
+export const arePathNumbersEqual = (
+ left?: string[],
+ right?: string[],
+): boolean => {
+ const l = left ?? [];
+ const r = right ?? [];
+ if (l.length !== r.length) return false;
+ return l.every((segment, index) => segment === r[index]);
+};
+
+export const cloneBook = (book: RemixerSubPage[]): RemixerSubPage[] =>
+ book.map((page) => ({ ...page }));
+
+export const toRoman = (value: number): string => {
+ if (value <= 0) return "I";
+ const numerals: Array<[number, string]> = [
+ [1000, "M"],
+ [900, "CM"],
+ [500, "D"],
+ [400, "CD"],
+ [100, "C"],
+ [90, "XC"],
+ [50, "L"],
+ [40, "XL"],
+ [10, "X"],
+ [9, "IX"],
+ [5, "V"],
+ [4, "IV"],
+ [1, "I"],
+ ];
+ let remaining = Math.floor(value);
+ let result = "";
+ for (const [num, symbol] of numerals) {
+ while (remaining >= num) {
+ result += symbol;
+ remaining -= num;
+ }
+ }
+ return result || "I";
+};
+
+export const toAlphabetic = (value: number): string => {
+ const normalized = Math.max(1, Math.floor(value));
+ let n = normalized;
+ let result = "";
+ while (n > 0) {
+ const remainder = (n - 1) % 26;
+ result = String.fromCharCode(65 + remainder) + result;
+ n = Math.floor((n - 1) / 26);
+ }
+ return result;
+};
+
+export const getFormattedTokenByType = (
+ value: number,
+ type: NumberingType,
+): string => {
+ if (type === "none") return "";
+ if (type === "alphabetic") return toAlphabetic(value);
+ if (type === "alphabetic_lower") return toAlphabetic(value).toLowerCase();
+ if (type === "roman") return toRoman(value);
+ if (type === "roman_lower") return toRoman(value).toLowerCase();
+ return String(value);
+};
+
+export const getStartToken = (start: number, type: NumberingType): string => {
+ if (type === "none") return "";
+ return getFormattedTokenByType(Math.max(1, start || 1), type);
+};
+
+/**
+ * Formatted display path from ordinal segments — matches `buildBookPaths` `toPaths` rules
+ * (delimiters, excludeParent reset, prefix/type per level).
+ */
+export const formatOrdinalSegmentsToFormattedPath = (
+ segments: string[],
+ pathLevelFormats: PathLevelFormat[],
+ startLevel: number,
+): string => {
+ let numericPath = "";
+ let formattedPath = "";
+ segments.forEach((segment, index) => {
+ const level = startLevel + index;
+ const format = pathLevelFormats.find((item) => item.level === level);
+ const delimiter = format?.delimiter ?? ".";
+ const start = Math.max(1, format?.start ?? 1);
+ const type: NumberingType = format?.type ?? "numeric";
+ const value = start + parsePathSegmentOrdinal(segment) - 1;
+ const token = getFormattedTokenByType(value, type);
+ const tokenExists = token.trim().length > 0;
+
+ if (format?.excludeParent) {
+ numericPath = tokenExists ? token : "";
+ formattedPath = numericPath;
+ return;
+ }
+
+ if (tokenExists) {
+ numericPath = numericPath ? `${numericPath}${delimiter}${token}` : token;
+ const prefix = format?.prefix ?? "";
+ formattedPath = prefix ? `${prefix}${numericPath}` : numericPath;
+ }
+ });
+ return formattedPath;
+};
+
+/** Builds the path prefix string from ordinal segments (same rules as book `formattedPath`). */
+export const buildFormattedPathFromNumberPath = (
+ numberPath: string[],
+ pathLevelFormats: PathLevelFormat[],
+ options?: { startLevel?: number },
+): string =>
+ formatOrdinalSegmentsToFormattedPath(
+ numberPath,
+ pathLevelFormats,
+ options?.startLevel ?? 1,
+ );
+
+/**
+ * If an ancestor has `formattedPathOverride`, build `ancestorPrefix.` from this node's
+ * path segments below that ancestor (e.g. parent `CH-2A` → child `CH-2A.1`).
+ */
+export const resolveInheritedFormattedPathPrefix = (
+ page: RemixerSubPage,
+ numberPath: string[],
+ pathLevelFormats: PathLevelFormat[],
+ nodesById: Map,
+ ordinalPathById: Map,
+): string | null => {
+ let parentId = page.parentID ?? "-1";
+ while (parentId !== "-1") {
+ const ancestor = nodesById.get(parentId);
+ if (!ancestor) break;
+ if (
+ ancestor.formattedPathOverride === true &&
+ typeof ancestor.formattedPath === "string" &&
+ ancestor.formattedPath.trim().length > 0
+ ) {
+ const ancestorDepth = (ordinalPathById.get(parentId) ?? []).length;
+ const relative = numberPath.slice(ancestorDepth);
+ if (relative.length === 0) return null;
+ const firstRelLevel = ancestorDepth + 1;
+ const firstRelFormat = pathLevelFormats.find((item) => item.level === firstRelLevel);
+ const suffix = formatOrdinalSegmentsToFormattedPath(
+ relative,
+ pathLevelFormats,
+ firstRelLevel,
+ );
+ const prefix = ancestor.formattedPath.trim();
+ if (!suffix) return prefix;
+ // excludeParent on first relative level: restart display (no inherited prefix), same as non-inherited tree.
+ if (firstRelFormat?.excludeParent) {
+ return suffix;
+ }
+ const joinDelim = firstRelFormat?.delimiter ?? ".";
+ return `${prefix}${joinDelim}${suffix}`;
+ }
+ parentId = ancestor.parentID ?? "-1";
+ }
+ return null;
+};
+
+export const getRemixerDisplayTitle = (
+ page: RemixerSubPage,
+ numberPath: string[],
+ /** True on front/back matter nodes and all descendants — no numeric path prefix. */
+ inMatterNoNumberSubtree: boolean,
+ inDeletedBranch: boolean,
+ options: GetRemixerDisplayTitleOptions,
+): string => {
+ const {
+ isBookTree,
+ autoNumbering,
+ pathLevelFormats = [],
+ remixerPathLookup,
+ } = options;
+ const rawTitle = page["@title"] || page.title || "";
+ if (!isBookTree) return rawTitle;
+ if (!autoNumbering) return rawTitle;
+ let cleanTitle = stripLeadingNumbering(rawTitle);
+ const preserveTitleThroughColon = page.formattedPathOverride === true;
+ if (!preserveTitleThroughColon) {
+ cleanTitle = stripDefaultTitlePrefixBeforeColon(cleanTitle);
+ }
+ if (inDeletedBranch || numberPath.length === 0 || inMatterNoNumberSubtree) {
+ return cleanTitle;
+ }
+ const overriddenFormattedPath =
+ page.formattedPathOverride === true &&
+ typeof page.formattedPath === "string" &&
+ page.formattedPath.trim().length > 0
+ ? page.formattedPath.trim()
+ : "";
+ if (overriddenFormattedPath) {
+ return `${overriddenFormattedPath}: ${cleanTitle}`;
+ }
+ const inherited =
+ remixerPathLookup &&
+ resolveInheritedFormattedPathPrefix(
+ page,
+ numberPath,
+ pathLevelFormats,
+ remixerPathLookup.nodesById,
+ remixerPathLookup.ordinalPathById,
+ );
+ if (inherited) {
+ return `${inherited}: ${cleanTitle}`;
+ }
+ const formattedPath = buildFormattedPathFromNumberPath(
+ numberPath,
+ pathLevelFormats,
+ );
+ return formattedPath ? `${formattedPath}: ${cleanTitle}` : cleanTitle;
+};
+
+const isDeletedForPath = (node: RemixerSubPage): boolean =>
+ node.deletedItem === true || node.isDeleted === true;
+
+/**
+ * Ordinal path segments per page: book root []; front matter ["0"]; chapters ["1"]…;
+ * back matter root uses last index (chapter count + 1). Nested: ["1","1"], ["0","1"], …
+ * Single top-level book node gets [] and layout applies to its children.
+ */
+export const computeRemixerOrdinalPathsMap = (
+ book: RemixerSubPage[],
+): Map => {
+ const nodesById = new Map(book.map((node) => [node["@id"], node]));
+ const childrenByParent = new Map();
+
+ const pushChild = (parentId: string, node: RemixerSubPage) => {
+ const children = childrenByParent.get(parentId) ?? [];
+ children.push(node);
+ childrenByParent.set(parentId, children);
+ };
+
+ book.forEach((node) => {
+ const parentId = node.parentID ?? "-1";
+ if (parentId === "-1" || !nodesById.has(parentId)) {
+ pushChild("-1", node);
+ } else {
+ pushChild(parentId, node);
+ }
+ });
+
+ const ordinalPathById = new Map();
+ const visited = new Set();
+
+ const rootRow = childrenByParent.get("-1") ?? [];
+ const singletonBookRootId =
+ rootRow.length === 1 ? rootRow[0]["@id"] : null;
+
+ const isRootLevelLayout = (parentId: string, parentPath: string[]): boolean => {
+ if (parentId === "-1" && parentPath.length === 0) return true;
+ if (
+ singletonBookRootId &&
+ parentId === singletonBookRootId &&
+ parentPath.length === 0
+ ) {
+ return true;
+ }
+ return false;
+ };
+
+ const assignUnderParent = (
+ parentId: string,
+ parentPath: string[],
+ parentInDeletedBranch: boolean,
+ ) => {
+ const children = childrenByParent.get(parentId) ?? [];
+
+ if (isRootLevelLayout(parentId, parentPath)) {
+ const chapterSlotNodes = children.filter(
+ (c) =>
+ !isFrontMatterNode(c) &&
+ !isBackMatterNode(c) &&
+ !isDeletedForPath(c),
+ );
+ const backMatterSegment = String(chapterSlotNodes.length + 1);
+
+ for (const child of children) {
+ if (isDeletedForPath(child) || parentInDeletedBranch) {
+ const path = [...parentPath];
+ ordinalPathById.set(child["@id"], path);
+ visited.add(child["@id"]);
+ assignUnderParent(child["@id"], path, true);
+ continue;
+ }
+ let nextPath: string[];
+ if (isFrontMatterNode(child)) {
+ nextPath = [...parentPath, "0"];
+ } else if (isBackMatterNode(child)) {
+ nextPath = [...parentPath, backMatterSegment];
+ } else {
+ const idx = chapterSlotNodes.indexOf(child);
+ nextPath =
+ idx >= 0 ? [...parentPath, String(idx + 1)] : [...parentPath];
+ }
+ ordinalPathById.set(child["@id"], nextPath);
+ visited.add(child["@id"]);
+ assignUnderParent(child["@id"], nextPath, false);
+ }
+ return;
+ }
+
+ let ordinal = 0;
+ for (const child of children) {
+ if (isDeletedForPath(child) || parentInDeletedBranch) {
+ const path = [...parentPath];
+ ordinalPathById.set(child["@id"], path);
+ visited.add(child["@id"]);
+ assignUnderParent(child["@id"], path, true);
+ continue;
+ }
+ ordinal += 1;
+ const nextPath = [...parentPath, String(ordinal)];
+ ordinalPathById.set(child["@id"], nextPath);
+ visited.add(child["@id"]);
+ assignUnderParent(child["@id"], nextPath, false);
+ }
+ };
+
+ if (singletonBookRootId) {
+ const only = rootRow[0];
+ ordinalPathById.set(only["@id"], []);
+ visited.add(only["@id"]);
+ assignUnderParent(singletonBookRootId, [], false);
+ } else {
+ assignUnderParent("-1", [], false);
+ }
+
+ book.forEach((node) => {
+ if (!visited.has(node["@id"])) {
+ ordinalPathById.set(node["@id"], [String(ordinalPathById.size + 1)]);
+ }
+ });
+
+ return ordinalPathById;
+};
+
+export const buildBookPaths = (
+ book: RemixerSubPage[],
+ pathLevelFormats: PathLevelFormat[] = [],
+ options: { ignoreOverrides?: boolean } = {},
+): RemixerSubPage[] => {
+ const { ignoreOverrides = false } = options;
+ if (book.length === 0) return book;
+
+ const nodesById = new Map(book.map((n) => [n["@id"], n]));
+ const ordinalPathById = computeRemixerOrdinalPathsMap(book);
+
+ const toPaths = (ordinalPath: string[]) => {
+ let numberedPath = "";
+ const formattedPath = formatOrdinalSegmentsToFormattedPath(
+ ordinalPath,
+ pathLevelFormats,
+ 1,
+ );
+ ordinalPath.forEach((segment, index) => {
+ const level = index + 1;
+ const format = pathLevelFormats.find((item) => item.level === level);
+ const delimiter = format?.delimiter ?? ".";
+ const numericToken = segment;
+ const numericTokenExists = numericToken.trim().length > 0;
+
+ if (format?.excludeParent) {
+ numberedPath = numericTokenExists ? numericToken : "";
+ return;
+ }
+
+ if (numericTokenExists) {
+ numberedPath = numberedPath
+ ? `${numberedPath}${delimiter}${numericToken}`
+ : numericToken;
+ }
+ });
+ return { numberedPath, formattedPath };
+ };
+
+ return book.map((node) => {
+ const ordinalPath = ordinalPathById.get(node["@id"]) ?? [];
+ const { numberedPath, formattedPath: computedFormattedPath } = toPaths(ordinalPath);
+
+ const hasOverride =
+ ignoreOverrides !== true &&
+ node.formattedPathOverride === true &&
+ typeof node.formattedPath === "string" &&
+ node.formattedPath.trim().length > 0;
+ const inheritedPrefix =
+ !hasOverride
+ ? resolveInheritedFormattedPathPrefix(
+ node,
+ ordinalPath,
+ pathLevelFormats,
+ nodesById,
+ ordinalPathById,
+ )
+ : null;
+ const formattedPath = hasOverride
+ ? node.formattedPath
+ : inheritedPrefix ?? computedFormattedPath;
+ return {
+ ...node,
+ originalPathNumber: node.originalPathNumber,
+ pathNumber: ordinalPath,
+ numberedPath,
+ formattedPath,
+ };
+ });
+};
+
+export const withDerivedStatusFlags = (book: RemixerSubPage[]): RemixerSubPage[] =>
+ book.map((page) => {
+ const placementChanged =
+ page.addedItem === true
+ ? false
+ : page.originalPathNumber
+ ? !arePathNumbersEqual(page.originalPathNumber, page.pathNumber)
+ : false;
+ return {
+ ...page,
+ movedItem: placementChanged,
+ isDeleted: page.deletedItem === true,
+ isImported: page.addedItem === true,
+ isRenamed: page.renamedItem === true,
+ isPlacementChanged: placementChanged,
+ };
+ });
+
+// ---------------------------------------------------------------------------
+// Matter node helpers
+// ---------------------------------------------------------------------------
+
+export const isMatterNode = (node: RemixerSubPage): boolean =>
+ isFrontMatterNode(node) || isBackMatterNode(node);
+
+export const isMatterBranchNode = (
+ nodeId: string | undefined,
+ book: RemixerSubPage[],
+): boolean => {
+ if (!nodeId) return false;
+ const nodesById = new Map(book.map((node) => [node["@id"], node]));
+ let currentId: string | undefined = nodeId;
+ const visited = new Set();
+ while (currentId && currentId !== "-1" && !visited.has(currentId)) {
+ visited.add(currentId);
+ const node = nodesById.get(currentId);
+ if (!node) return false;
+ if (isMatterNode(node)) return true;
+ currentId = node.parentID ?? "-1";
+ }
+ return false;
+};
+
+// ---------------------------------------------------------------------------
+// Book depth
+// ---------------------------------------------------------------------------
+
+export const computeHighestPathLevel = (book: RemixerSubPage[]): number => {
+ if (!book || book.length === 0) return 0;
+ const nodesById = new Map(book.map((page) => [page["@id"], page]));
+ const depthById = new Map();
+ const visiting = new Set();
+
+ const computeDepth = (nodeId: string): number => {
+ if (depthById.has(nodeId)) return depthById.get(nodeId) as number;
+ if (visiting.has(nodeId)) return 0; // guard against cyclic refs
+ visiting.add(nodeId);
+ const node = nodesById.get(nodeId);
+ if (!node) {
+ visiting.delete(nodeId);
+ return 0;
+ }
+ const parentId = node.parentID ?? "-1";
+ const depth =
+ parentId === "-1" || !nodesById.has(parentId)
+ ? 0
+ : computeDepth(parentId) + 1;
+ visiting.delete(nodeId);
+ depthById.set(nodeId, depth);
+ return depth;
+ };
+
+ return Math.max(...book.map((page) => computeDepth(page["@id"])));
+};
+
+// ---------------------------------------------------------------------------
+// Book mutation helpers (pure — return new arrays, no side effects)
+// ---------------------------------------------------------------------------
+
+export const applyBookNodeDeletion = (
+ existingBookNodes: RemixerSubPage[],
+ selectedNodeId: string,
+): RemixerSubPage[] => {
+ const childMap = new Map();
+ existingBookNodes.forEach((node) => {
+ const parentId = node.parentID ?? "-1";
+ const siblings = childMap.get(parentId) ?? [];
+ siblings.push(node["@id"]);
+ childMap.set(parentId, siblings);
+ });
+
+ const toDelete = new Set();
+ const queue: string[] = [selectedNodeId];
+ while (queue.length > 0) {
+ const nodeId = queue.shift();
+ if (!nodeId || toDelete.has(nodeId)) continue;
+ toDelete.add(nodeId);
+ (childMap.get(nodeId) ?? []).forEach((childId) => queue.push(childId));
+ }
+
+ const withDeleted = existingBookNodes.map((node) =>
+ toDelete.has(node["@id"]) ? { ...node, deletedItem: true } : node,
+ );
+
+ const activeChildrenByParent = new Set();
+ withDeleted.forEach((node) => {
+ if (!node.deletedItem && node.parentID) {
+ activeChildrenByParent.add(node.parentID);
+ }
+ });
+
+ return withDeleted.map((node) =>
+ !node.deletedItem
+ ? { ...node, "@subpages": activeChildrenByParent.has(node["@id"]) }
+ : node,
+ );
+};
+
+const isDescendant = (
+ nodeId: string,
+ ancestorId: string,
+ nodesById: Map,
+): boolean => {
+ let currentParentId = nodesById.get(nodeId)?.parentID ?? "-1";
+ const visited = new Set();
+ while (currentParentId && currentParentId !== "-1" && !visited.has(currentParentId)) {
+ if (currentParentId === ancestorId) return true;
+ visited.add(currentParentId);
+ currentParentId = nodesById.get(currentParentId)?.parentID ?? "-1";
+ }
+ return false;
+};
+
+export const reorderBookNodes = ({
+ existingBook,
+ draggedNodeId,
+ targetNodeId,
+ position,
+}: {
+ existingBook: RemixerSubPage[];
+ draggedNodeId: string;
+ targetNodeId: string;
+ position: DropPosition;
+}): RemixerSubPage[] => {
+ if (draggedNodeId === targetNodeId) return existingBook;
+ const nodesById = new Map(existingBook.map((node) => [node["@id"], node]));
+ const draggedNode = nodesById.get(draggedNodeId);
+ const targetNode = nodesById.get(targetNodeId);
+ if (!draggedNode || !targetNode) return existingBook;
+
+ const targetParentId =
+ position === "inside" ? targetNodeId : targetNode.parentID ?? "-1";
+ if (!targetParentId || targetParentId === draggedNodeId) return existingBook;
+ if (isDescendant(targetParentId, draggedNodeId, nodesById)) return existingBook;
+
+ const withUpdatedParent = existingBook.map((node) =>
+ node["@id"] === draggedNodeId ? { ...node, parentID: targetParentId } : node,
+ );
+
+ const siblingNodes = withUpdatedParent.filter(
+ (node) => (node.parentID ?? "-1") === targetParentId,
+ );
+ const siblingMap = new Map(
+ siblingNodes.map((siblingNode) => [siblingNode["@id"], siblingNode]),
+ );
+ const siblingIds = siblingNodes
+ .map((siblingNode) => siblingNode["@id"])
+ .filter((siblingId) => siblingId !== draggedNodeId);
+ const targetIndex = siblingIds.indexOf(targetNodeId);
+ const insertIndex =
+ position === "before"
+ ? Math.max(targetIndex, 0)
+ : position === "after"
+ ? targetIndex >= 0
+ ? targetIndex + 1
+ : siblingIds.length
+ : siblingIds.length;
+
+ const orderedSiblingIds = [...siblingIds];
+ orderedSiblingIds.splice(insertIndex, 0, draggedNodeId);
+
+ const orderedSiblings = orderedSiblingIds
+ .map((siblingId) => siblingMap.get(siblingId))
+ .filter(Boolean) as RemixerSubPage[];
+
+ const reordered: RemixerSubPage[] = [];
+ let insertedSiblings = false;
+ withUpdatedParent.forEach((bookNode) => {
+ if ((bookNode.parentID ?? "-1") === targetParentId) {
+ if (!insertedSiblings) {
+ reordered.push(...orderedSiblings);
+ insertedSiblings = true;
+ }
+ return;
+ }
+ reordered.push(bookNode);
+ });
+ if (!insertedSiblings) {
+ reordered.push(...orderedSiblings);
+ }
+ return reordered;
+};
+
+export const insertAtSiblingPosition = ({
+ bookNodes,
+ importedRootId,
+ targetNodeId,
+ position,
+ targetParentId,
+}: {
+ bookNodes: RemixerSubPage[];
+ importedRootId: string;
+ targetNodeId: string;
+ position: DropPosition;
+ targetParentId: string;
+}): RemixerSubPage[] => {
+ if (position === "inside") return bookNodes;
+
+ const siblingNodes = bookNodes.filter(
+ (bookNode) => (bookNode.parentID ?? "-1") === targetParentId,
+ );
+ const siblingMap = new Map(
+ siblingNodes.map((siblingNode) => [siblingNode["@id"], siblingNode]),
+ );
+ const siblingIds = siblingNodes
+ .map((siblingNode) => siblingNode["@id"])
+ .filter((siblingId) => siblingId !== importedRootId);
+ const targetIndex = siblingIds.indexOf(targetNodeId);
+ const insertIndex =
+ position === "before"
+ ? Math.max(targetIndex, 0)
+ : targetIndex >= 0
+ ? targetIndex + 1
+ : siblingIds.length;
+
+ const orderedSiblingIds = [...siblingIds];
+ orderedSiblingIds.splice(insertIndex, 0, importedRootId);
+
+ const orderedSiblings = orderedSiblingIds
+ .map((siblingId) => siblingMap.get(siblingId))
+ .filter(Boolean) as RemixerSubPage[];
+
+ const reordered: RemixerSubPage[] = [];
+ let insertedSiblings = false;
+ bookNodes.forEach((bookNode) => {
+ if ((bookNode.parentID ?? "-1") === targetParentId) {
+ if (!insertedSiblings) {
+ reordered.push(...orderedSiblings);
+ insertedSiblings = true;
+ }
+ return;
+ }
+ reordered.push(bookNode);
+ });
+ if (!insertedSiblings) {
+ reordered.push(...orderedSiblings);
+ }
+ return reordered;
+};
diff --git a/client/src/components/remixer/style.ts b/client/src/components/remixer/style.ts
new file mode 100644
index 00000000..e1dbe4a0
--- /dev/null
+++ b/client/src/components/remixer/style.ts
@@ -0,0 +1,14 @@
+const buttonActiveStyle = { color: "#0288d1" };
+const buttonStyle = { backgroundColor: "rgb(255, 255, 255)" };
+const handleMouseEnter: React.MouseEventHandler = (
+ event,
+) => {
+ event.currentTarget.style.backgroundColor = "#afafaf";
+};
+const handleMouseLeave: React.MouseEventHandler = (
+ event,
+) => {
+ event.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 1)";
+};
+
+export { buttonActiveStyle, buttonStyle, handleMouseEnter, handleMouseLeave };
\ No newline at end of file
diff --git a/client/src/index.jsx b/client/src/index.jsx
index e857d370..04dd328f 100644
--- a/client/src/index.jsx
+++ b/client/src/index.jsx
@@ -10,7 +10,5 @@ import "./styles/index.css";
const container = document.getElementById("root");
const root = createRoot(container);
root.render(
-
-
);
diff --git a/client/src/types/Project.ts b/client/src/types/Project.ts
index de36d332..d753e124 100644
--- a/client/src/types/Project.ts
+++ b/client/src/types/Project.ts
@@ -218,3 +218,8 @@ export type AddableProjectTeamMember = Pick<
> & {
orgs: { name: string }[];
};
+
+
+export type AuthenBrowser ={
+ [key: string]: string;
+}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 3b831818..4d78faac 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "conductor",
- "version": "2.113.5",
+ "version": "2.117.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "conductor",
- "version": "2.113.5",
+ "version": "2.117.1",
"license": "MIT",
"devDependencies": {
"@commitlint/cli": "^17.4.4",
diff --git a/server/api.js b/server/api.js
index 76dcc419..638a953f 100644
--- a/server/api.js
+++ b/server/api.js
@@ -6,7 +6,9 @@
import express from "express";
import cors from "cors";
import bodyParser from "body-parser";
+import { createHmac } from "crypto";
import { catchInternal } from "./util/helpers.js";
+import { getLibraryCredentials } from "./util/librariesclient.js";
import middleware from "./middleware.js"; // Route middleware
import assetTagFrameworkAPI from "./api/assettagframeworks.js";
import authorsAPI from "./api/authors.js";
@@ -38,6 +40,7 @@ import analyticsAPI from "./api/analytics.js";
import orgEventsAPI from "./api/orgevents.js";
import paymentsAPI from "./api/payments.js";
import kbAPI from "./api/kb.js";
+import remixerAPI from "./api/remixer.js";
import supportAPI from "./api/support.js";
import supportQueuesAPI from "./api/supportqueues.js";
import projectInvitationsAPI from "./api/projectinvitations.js";
@@ -50,6 +53,7 @@ import * as LibraryValidators from "./api/validators/libraries.js";
import * as supportValidators from "./api/validators/support.js";
import * as supportQueueValidators from "./api/validators/supportqueues.js";
import * as ProjectValidators from "./api/validators/projects.js";
+import * as RemixerValidators from "./api/validators/remixer.js";
import * as ProjectFileValidators from "./api/validators/projectfiles.js";
import * as SearchValidators from "./api/validators/search.js";
import * as AssetTagFrameworkValidators from "./api/validators/assettagframeworks.js";
@@ -88,7 +92,7 @@ const corsMiddleware = cors({
if (allowed instanceof RegExp) return allowed.test(origin);
return false;
});
-
+
if (foundOrigin) {
return callback(null, origin);
}
@@ -295,15 +299,15 @@ router
)
router.route("/central-identity/users/:id/app-licenses")
-.get(
- middleware.checkCentralIdentityConfig,
- authAPI.verifyRequest,
- authAPI.getUserAttributes,
- authAPI.checkHasRoleMiddleware("libretexts", "superadmin"),
- centralIdentityAPI.validate("getUserAppLicenses"),
- middleware.checkValidationErrors,
- centralIdentityAPI.getUserAppLicenses
-)
+ .get(
+ middleware.checkCentralIdentityConfig,
+ authAPI.verifyRequest,
+ authAPI.getUserAttributes,
+ authAPI.checkHasRoleMiddleware("libretexts", "superadmin"),
+ centralIdentityAPI.validate("getUserAppLicenses"),
+ middleware.checkValidationErrors,
+ centralIdentityAPI.getUserAppLicenses
+ )
router
.route("/central-identity/users/:userId/notes")
@@ -396,37 +400,37 @@ router.route("/central-identity/app-licenses").get(
);
router.route("/central-identity/app-licenses/grant")
-.post(
- middleware.checkCentralIdentityConfig,
- authAPI.verifyRequest,
- authAPI.getUserAttributes,
- authAPI.checkHasRoleMiddleware("libretexts", "superadmin"),
- centralIdentityAPI.validate("grantAppLicense"),
- middleware.checkValidationErrors,
- centralIdentityAPI.grantAppLicense
-);
+ .post(
+ middleware.checkCentralIdentityConfig,
+ authAPI.verifyRequest,
+ authAPI.getUserAttributes,
+ authAPI.checkHasRoleMiddleware("libretexts", "superadmin"),
+ centralIdentityAPI.validate("grantAppLicense"),
+ middleware.checkValidationErrors,
+ centralIdentityAPI.grantAppLicense
+ );
router.route("/central-identity/app-licenses/revoke")
-.post(
- middleware.checkCentralIdentityConfig,
- authAPI.verifyRequest,
- authAPI.getUserAttributes,
- authAPI.checkHasRoleMiddleware("libretexts", "superadmin"),
- centralIdentityAPI.validate("revokeAppLicense"),
- middleware.checkValidationErrors,
- centralIdentityAPI.revokeAppLicense
-);
+ .post(
+ middleware.checkCentralIdentityConfig,
+ authAPI.verifyRequest,
+ authAPI.getUserAttributes,
+ authAPI.checkHasRoleMiddleware("libretexts", "superadmin"),
+ centralIdentityAPI.validate("revokeAppLicense"),
+ middleware.checkValidationErrors,
+ centralIdentityAPI.revokeAppLicense
+ );
router.route("/central-identity/app-licenses/:id/bulk-generate")
-.post(
- middleware.checkCentralIdentityConfig,
- authAPI.verifyRequest,
- authAPI.getUserAttributes,
- authAPI.checkHasRoleMiddleware("libretexts", "superadmin"),
- centralIdentityAPI.validate("bulkGenerateAccessCodes"),
- middleware.checkValidationErrors,
- centralIdentityAPI.bulkGenerateAccessCodes
-);
+ .post(
+ middleware.checkCentralIdentityConfig,
+ authAPI.verifyRequest,
+ authAPI.getUserAttributes,
+ authAPI.checkHasRoleMiddleware("libretexts", "superadmin"),
+ centralIdentityAPI.validate("bulkGenerateAccessCodes"),
+ middleware.checkValidationErrors,
+ centralIdentityAPI.bulkGenerateAccessCodes
+ );
router
.route("/central-identity/apps")
@@ -447,7 +451,7 @@ router
router
.route("/central-identity/orgs")
- .get(
+ .get(
middleware.checkCentralIdentityConfig,
authAPI.verifyRequest,
authAPI.getUserAttributes,
@@ -461,7 +465,7 @@ router
authAPI.verifyRequest,
authAPI.getUserAttributes,
authAPI.checkHasRoleMiddleware("libretexts", "superadmin"),
- centralIdentityAPI.validate("createOrg"),
+ centralIdentityAPI.validate("createOrg"),
middleware.checkValidationErrors,
centralIdentityAPI.createOrg
);
@@ -482,7 +486,7 @@ router
authAPI.verifyRequest,
authAPI.getUserAttributes,
authAPI.checkHasRoleMiddleware("libretexts", "superadmin"),
- centralIdentityAPI.validate("getOrg"),
+ centralIdentityAPI.validate("getOrg"),
middleware.checkValidationErrors,
centralIdentityAPI.getOrg
)
@@ -491,7 +495,7 @@ router
authAPI.verifyRequest,
authAPI.getUserAttributes,
authAPI.checkHasRoleMiddleware("libretexts", "superadmin"),
- centralIdentityAPI.validate("updateOrg"),
+ centralIdentityAPI.validate("updateOrg"),
middleware.checkValidationErrors,
centralIdentityAPI.updateOrg
);
@@ -889,15 +893,15 @@ router.route("/store/sync").put(
);
router.route("/store/webhooks/stripe").post(
- express.raw({ type: "application/json" }),
- storeAPI.processStripeWebhook
- );
+ express.raw({ type: "application/json" }),
+ storeAPI.processStripeWebhook
+);
router.route("/store/webhooks/lulu").post(
express.raw({ type: "application/json" }),
storeAPI.processLuluWebhook
)
-
+
/* Translation Feedback */
// (submission route can be anonymous)
router
@@ -1076,6 +1080,22 @@ router
booksAPI.importPressBooksBook
);
+router
+ .route("/commons/import-pressbooks/active")
+ .get(
+ authAPI.verifyRequest,
+ middleware.validateZod(BookValidators.getActivePressbooksImportJobSchema),
+ booksAPI.getActivePressBooksImportJob
+ );
+
+router
+ .route("/commons/import-pressbooks/:jobID")
+ .get(
+ authAPI.verifyRequest,
+ middleware.validateZod(BookValidators.getPressbooksImportJobStatusSchema),
+ booksAPI.getPressBooksImportJobStatus
+ );
+
router
.route("/commons/book/:bookID")
.get(
@@ -2534,12 +2554,12 @@ router
kbAPI.getKBPage
);
- router
+router
.route("/kb/page/slug/:slug/embeddings")
.post(
- authAPI.verifyRequest,
- authAPI.getUserAttributes,
- authAPI.checkHasRoleMiddleware("libretexts", "superadmin"),
+ authAPI.verifyRequest,
+ authAPI.getUserAttributes,
+ authAPI.checkHasRoleMiddleware("libretexts", "superadmin"),
middleware.validateZod(kbValidators.GetKBPageValidator),
kbAPI.generateKBPageEmbeddings
);
@@ -2695,14 +2715,14 @@ router
);
router
-.route("/support/ticket/bulk-update")
-.patch(
- authAPI.verifyRequest,
- authAPI.getUserAttributes,
- authAPI.checkHasRoleMiddleware("libretexts", ["support", "harvester"]),
- middleware.validateZod(supportValidators.BulkUpdateTicketsValidator),
- supportAPI.bulkUpdateTickets
-);
+ .route("/support/ticket/bulk-update")
+ .patch(
+ authAPI.verifyRequest,
+ authAPI.getUserAttributes,
+ authAPI.checkHasRoleMiddleware("libretexts", ["support", "harvester"]),
+ middleware.validateZod(supportValidators.BulkUpdateTicketsValidator),
+ supportAPI.bulkUpdateTickets
+ );
router
.route("/support/ticket/:uuid/assign")
@@ -2730,14 +2750,14 @@ router
);
router
-.route("/support/ticket/:uuid/create-project-from-harvesting-request")
-.post(
- authAPI.verifyRequest,
- authAPI.getUserAttributes,
- authAPI.checkHasRoleMiddleware("libretexts", ["support", "harvester"]),
- middleware.validateZod(supportValidators.TicketUUIDParams),
- supportAPI.createAndAttachProjectFromHarvestingRequest
-);
+ .route("/support/ticket/:uuid/create-project-from-harvesting-request")
+ .post(
+ authAPI.verifyRequest,
+ authAPI.getUserAttributes,
+ authAPI.checkHasRoleMiddleware("libretexts", ["support", "harvester"]),
+ middleware.validateZod(supportValidators.TicketUUIDParams),
+ supportAPI.createAndAttachProjectFromHarvestingRequest
+ );
router
.route("/support/ticket/:uuid/msg")
@@ -2905,40 +2925,113 @@ router
projectInvitationsAPI.updateProjectInvitation
);
- router
+router
.route("/kb/migrate-to-qdrant")
.post(
middleware.checkLibreAPIKey,
- middleware.validateZod(kbValidators.MigrateToQdrantValidator),
+ middleware.validateZod(kbValidators.MigrateToQdrantValidator),
kbAPI.migrateKBPagesToQdrant
);
- router
- .route("/kb/create-single-page-embedding/:uuid")
- .post(
- authAPI.verifyRequest,
- authAPI.getUserAttributes,
- authAPI.checkHasRoleMiddleware("libretexts", "superadmin"),
- middleware.validateZod(kbValidators.KBUUIDParams),
- kbAPI.createSinglePageEmbedding
- );
-
- router
- .route("/agent/create-session")
- .post(
- authAPI.optionalVerifyRequest,
- authAPI.optionalGetUserAttributes,
- kbAPI.createSessionHandler
- );
-
- router
- .route("/agent/query-langgraph")
- .post(
- authAPI.optionalVerifyRequest,
- authAPI.optionalGetUserAttributes,
- middleware.validateZod(kbValidators.AgentQueryLangGraphValidator),
- kbAPI.agentQueryLangGraph
- );
+router
+ .route("/kb/create-single-page-embedding/:uuid")
+ .post(
+ authAPI.verifyRequest,
+ authAPI.getUserAttributes,
+ authAPI.checkHasRoleMiddleware("libretexts", "superadmin"),
+ middleware.validateZod(kbValidators.KBUUIDParams),
+ kbAPI.createSinglePageEmbedding
+ );
+
+router
+ .route("/agent/create-session")
+ .post(
+ authAPI.optionalVerifyRequest,
+ authAPI.optionalGetUserAttributes,
+ kbAPI.createSessionHandler
+ );
+
+router
+ .route("/agent/query-langgraph")
+ .post(
+ authAPI.optionalVerifyRequest,
+ authAPI.optionalGetUserAttributes,
+ middleware.validateZod(kbValidators.AgentQueryLangGraphValidator),
+ kbAPI.agentQueryLangGraph
+ );
+
+router
+ .route("/remixer/:id/project")
+ .get(
+ authAPI.optionalVerifyRequest,
+ authAPI.optionalGetUserAttributes,
+ remixerAPI.getRemixerProject
+ )
+ .put(
+ authAPI.optionalVerifyRequest,
+ authAPI.optionalGetUserAttributes,
+ middleware.validateZod(RemixerValidators.SaveRemixerProjectStateSchema),
+ remixerAPI.saveRemixerProjectState
+ )
+ .post(
+ authAPI.optionalVerifyRequest,
+ authAPI.optionalGetUserAttributes,
+ middleware.validateZod(RemixerValidators.GetRemixerProjectStateSchema),
+ remixerAPI.getRemixerProjectState
+ )
+ .delete(
+ authAPI.optionalVerifyRequest,
+ authAPI.optionalGetUserAttributes,
+ middleware.validateZod(RemixerValidators.GetRemixerProjectStateSchema),
+ remixerAPI.deleteRemixerProjectState
+ );
+
+router
+ .route("/remixer/:id/page")
+ .post(
+ authAPI.optionalVerifyRequest,
+ authAPI.optionalGetUserAttributes,
+ middleware.validateZod(RemixerValidators.GetRemixerPageSchema),
+ remixerAPI.fetchPage
+ );
+
+
+
+router
+ .route("/remixer/:id/publish")
+ .post(
+ authAPI.optionalVerifyRequest,
+ authAPI.optionalGetUserAttributes,
+ middleware.validateZod(RemixerValidators.SaveRemixerProjectStateSchema),
+ remixerAPI.publishRemixerProject
+ )
+ .get(
+ authAPI.optionalVerifyRequest,
+ authAPI.optionalGetUserAttributes,
+ remixerAPI.getRemixerJobStatus
+ );
+
+router.route("/tt/:lib").get((req, res, next) => {
+ console.log(req.query);
+ const lib = req.params.lib || "dev";
+ getLibraryCredentials(lib)
+ .then((creds) => {
+ if (!creds || !creds.keyPair) {
+ return res.status(500).send({
+ err: true,
+ errMsg: "Could not load library credentials",
+ });
+ }
+ const epoch = Math.floor(Date.now() / 1000);
+ const hmac = createHmac("sha256", creds.keyPair.secret);
+ hmac.update(`${creds.keyPair.key}${epoch}=${creds.apiUsername}`);
+ res.send({
+ "X-Deki-Token": `${creds.keyPair.key}_${epoch}_=${creds.apiUsername}_${hmac.digest("hex")}`,
+ "X-Requested-With": "XMLHttpRequest",
+ });
+ })
+ .catch(next);
+});
export default router;
diff --git a/server/api/adoptionreports.js b/server/api/adoptionreports.js
index b8bebe70..5bde70bf 100644
--- a/server/api/adoptionreports.js
+++ b/server/api/adoptionreports.js
@@ -3,7 +3,6 @@
// adoptionreports.js
//
-'use strict';
import { body, query } from 'express-validator';
import AdoptionReport from '../models/adoptionreport.js';
import conductorErrors from '../conductor-errors.js';
@@ -20,23 +19,22 @@ import { threePartDateStringValidator } from '../validators.js';
* VALIDATION: 'submitReport'
*/
const submitReport = (req, res) => {
- var newReport = new AdoptionReport(req.body);
- newReport.save().then((newDoc) => {
- if (newDoc) {
- return res.send({
- err: false,
- msg: "Adoption report successfully submitted."
- });
- } else {
- throw(conductorErrors.err3);
- }
- }).catch((err) => {
- debugError(err);
- return res.status(500).send({
- err: true,
- errMsg: conductorErrors.err6
- });
+ const newReport = new AdoptionReport(req.body);
+ newReport.save().then((newDoc) => {
+ if (newDoc) {
+ return res.send({
+ err: false,
+ msg: 'Adoption report successfully submitted.',
+ });
+ }
+ throw (conductorErrors.err3);
+ }).catch((err) => {
+ debugError(err);
+ return res.status(500).send({
+ err: true,
+ errMsg: conductorErrors.err6,
});
+ });
};
/**
@@ -44,63 +42,63 @@ const submitReport = (req, res) => {
* VALIDATION: 'getReports'
*/
const getReports = (req, res) => {
- try {
- var sComp = String(req.query.startDate).split('-');
- var eComp = String(req.query.endDate).split('-');
- var sM, sD, sY;
- var eM, eD, eY;
- if ((sComp.length == 3) && (eComp.length == 3)) {
- sM = parseInt(sComp[0]) - 1;
- sD = parseInt(sComp[1]);
- sY = parseInt(sComp[2]);
- eM = parseInt(eComp[0]) - 1;
- eD = parseInt(eComp[1]);
- eY = parseInt(eComp[2]);
- }
- if (!isNaN(sM) && !isNaN(sD) && !isNaN(sY) && !isNaN(eM) && !isNaN(eD) && !isNaN(eY)) {
- var start = new Date(sY, sM, sD);
- start.setHours(0,0,0,0);
- var end = new Date(eY, eM, eD);
- end.setHours(23,59,59,999);
- AdoptionReport.aggregate([
- {
- $match: {
- createdAt: {
- $gte: start,
- $lte: end
- }
- }
- }, {
- $project: {
- _v: 0,
- }
- }, {
- $sort: {
- createdAt: 1
- }
- }
- ]).then((reports) => {
- return res.status(200).send({
- err: false,
- reports: reports
- });
- }).catch((err) => {
- debugError(err);
- return res.status(500).send({
- err: true,
- errMsg: conductorErrors.err6
- });
- });
- } else {
- throw('timeparse-err')
- }
- } catch (err) {
+ try {
+ const sComp = String(req.query.startDate).split('-');
+ const eComp = String(req.query.endDate).split('-');
+ let sM; let sD; let
+ sY;
+ let eM; let eD; let
+ eY;
+ if ((sComp.length == 3) && (eComp.length == 3)) {
+ sM = parseInt(sComp[0]) - 1;
+ sD = parseInt(sComp[1]);
+ sY = parseInt(sComp[2]);
+ eM = parseInt(eComp[0]) - 1;
+ eD = parseInt(eComp[1]);
+ eY = parseInt(eComp[2]);
+ }
+ if (!isNaN(sM) && !isNaN(sD) && !isNaN(sY) && !isNaN(eM) && !isNaN(eD) && !isNaN(eY)) {
+ const start = new Date(sY, sM, sD);
+ start.setHours(0, 0, 0, 0);
+ const end = new Date(eY, eM, eD);
+ end.setHours(23, 59, 59, 999);
+ AdoptionReport.aggregate([
+ {
+ $match: {
+ createdAt: {
+ $gte: start,
+ $lte: end,
+ },
+ },
+ }, {
+ $project: {
+ _v: 0,
+ },
+ }, {
+ $sort: {
+ createdAt: 1,
+ },
+ },
+ ]).then((reports) => res.status(200).send({
+ err: false,
+ reports,
+ })).catch((err) => {
debugError(err);
- return res.status(400).send({
- err: true,
- errMsg: emmErrors.err3
+ return res.status(500).send({
+ err: true,
+ errMsg: conductorErrors.err6,
});
+ });
+ } else {
+ throw ('timeparse-err');
}
+ } catch (err) {
+ debugError(err);
+ return res.status(400).send({
+ err: true,
+ errMsg: emmErrors.err3,
+ });
+ }
};
/**
@@ -111,35 +109,33 @@ const getReports = (req, res) => {
* VALIDATION: 'deleteReport'
*/
const deleteReport = (req, res) => {
- AdoptionReport.deleteOne({ _id: req.body.reportID }).then((deleteRes) => {
- if (deleteRes.deletedCount === 1) {
- return res.send({
- err: false,
- msg: "Adoption Report successfully deleted.",
- });
- } else {
- throw(conductorErrors.err3);
- }
- }).catch((err) => {
- debugError(err);
- return res.status(500).send({
- err: true,
- errMsg: conductorErrors.err6
- });
- })
+ AdoptionReport.deleteOne({ _id: req.body.reportID }).then((deleteRes) => {
+ if (deleteRes.deletedCount === 1) {
+ return res.send({
+ err: false,
+ msg: 'Adoption Report successfully deleted.',
+ });
+ }
+ throw (conductorErrors.err3);
+ }).catch((err) => {
+ debugError(err);
+ return res.status(500).send({
+ err: true,
+ errMsg: conductorErrors.err6,
+ });
+ });
};
/**
* Confirm the @role parameter is one of 'instructor' or 'student'
*/
const validateRole = (value) => {
- if ((value === 'instructor') || (value === 'student')) {
- return true;
- }
- return false;
+ if ((value === 'instructor') || (value === 'student')) {
+ return true;
+ }
+ return false;
};
-
/**
* Confirm the @instructor parameter is an object and that each field,
* if it exists, is the expected type.
@@ -147,61 +143,61 @@ const validateRole = (value) => {
* and @instructor.printCost to the expected Number type.
*/
const validateInstructorObj = (value) => {
- if (typeof(value) === 'object') {
- if (value.hasOwnProperty('isLibreNet') && typeof(value.isLibreNet) !== 'string') {
- return false;
- }
- if (value.hasOwnProperty('institution') && typeof(value.institution) !== 'string') {
- return false;
- }
- if (value.hasOwnProperty('class') && typeof(value.class) !== 'string') {
- return false;
- }
- if (value.hasOwnProperty('term') && typeof(value.term) !== 'string') {
- return false;
- }
- if (value.hasOwnProperty('students')) {
- if (!isEmptyString(value.students)) {
- const parsed = parseInt(value.students);
- if (!isNaN(parsed)) {
- value.students = parsed;
- } else {
- return false;
- }
- } else {
- delete value.students;
- }
- }
- if (value.hasOwnProperty('replaceCost')) {
- if (!isEmptyString(value.students)) {
- const parsed = parseInt(value.replaceCost);
- if (!isNaN(parsed)) {
- value.replaceCost = parsed;
- } else {
- return false;
- }
- } else {
- delete value.replaceCost;
- }
+ if (typeof (value) === 'object') {
+ if (value.hasOwnProperty('isLibreNet') && typeof (value.isLibreNet) !== 'string') {
+ return false;
+ }
+ if (value.hasOwnProperty('institution') && typeof (value.institution) !== 'string') {
+ return false;
+ }
+ if (value.hasOwnProperty('class') && typeof (value.class) !== 'string') {
+ return false;
+ }
+ if (value.hasOwnProperty('term') && typeof (value.term) !== 'string') {
+ return false;
+ }
+ if (value.hasOwnProperty('students')) {
+ if (!isEmptyString(value.students)) {
+ const parsed = parseInt(value.students);
+ if (!isNaN(parsed)) {
+ value.students = parsed;
+ } else {
+ return false;
}
- if (value.hasOwnProperty('printCost')) {
- if (!isEmptyString(value.students)) {
- const parsed = parseInt(value.printCost);
- if (!isNaN(parsed)) {
- value.printCost = parsed;
- } else {
- return false;
- }
- } else {
- delete value.printCost;
- }
+ } else {
+ delete value.students;
+ }
+ }
+ if (value.hasOwnProperty('replaceCost')) {
+ if (!isEmptyString(value.students)) {
+ const parsed = parseInt(value.replaceCost);
+ if (!isNaN(parsed)) {
+ value.replaceCost = parsed;
+ } else {
+ return false;
}
- if (value.hasOwnProperty('access') && !Array.isArray(value.access)) {
- return false;
+ } else {
+ delete value.replaceCost;
+ }
+ }
+ if (value.hasOwnProperty('printCost')) {
+ if (!isEmptyString(value.students)) {
+ const parsed = parseInt(value.printCost);
+ if (!isNaN(parsed)) {
+ value.printCost = parsed;
+ } else {
+ return false;
}
- return true;
+ } else {
+ delete value.printCost;
+ }
+ }
+ if (value.hasOwnProperty('access') && !Array.isArray(value.access)) {
+ return false;
}
- return false;
+ return true;
+ }
+ return false;
};
/**
@@ -211,80 +207,80 @@ const validateInstructorObj = (value) => {
* and @student.printCost to the expected Number type.
*/
const validateStudentObj = (value) => {
- if (typeof(value) === 'object') {
- if (value.hasOwnProperty('use') && typeof(value.use) !== 'string') {
- return false;
- }
- if (value.hasOwnProperty('institution') && typeof(value.institution) !== 'string') {
- return false;
- }
- if (value.hasOwnProperty('class') && typeof(value.class) !== 'string') {
- return false;
- }
- if (value.hasOwnProperty('instructor') && typeof(value.instructor) !== 'string') {
- return false;
- }
- if (value.hasOwnProperty('quality')) {
- const parsed = parseInt(value.quality);
- if (!isNaN(parsed)) {
- value.quality = parsed;
- } else {
- return false;
- }
- }
- if (value.hasOwnProperty('navigation')) {
- const parsed = parseInt(value.navigation);
- if (!isNaN(parsed)) {
- value.navigation = parsed;
- } else {
- return false;
- }
- }
- if (value.hasOwnProperty('printCost')) {
- const parsed = parseInt(value.printCost);
- if (!isNaN(parsed)) {
- value.printCost = parsed;
- } else {
- return false;
- }
- }
- if (value.hasOwnProperty('access') && !Array.isArray(value.access)) {
- return false;
- }
- return true;
+ if (typeof (value) === 'object') {
+ if (value.hasOwnProperty('use') && typeof (value.use) !== 'string') {
+ return false;
+ }
+ if (value.hasOwnProperty('institution') && typeof (value.institution) !== 'string') {
+ return false;
+ }
+ if (value.hasOwnProperty('class') && typeof (value.class) !== 'string') {
+ return false;
+ }
+ if (value.hasOwnProperty('instructor') && typeof (value.instructor) !== 'string') {
+ return false;
}
- return false;
+ if (value.hasOwnProperty('quality')) {
+ const parsed = parseInt(value.quality);
+ if (!isNaN(parsed)) {
+ value.quality = parsed;
+ } else {
+ return false;
+ }
+ }
+ if (value.hasOwnProperty('navigation')) {
+ const parsed = parseInt(value.navigation);
+ if (!isNaN(parsed)) {
+ value.navigation = parsed;
+ } else {
+ return false;
+ }
+ }
+ if (value.hasOwnProperty('printCost')) {
+ const parsed = parseInt(value.printCost);
+ if (!isNaN(parsed)) {
+ value.printCost = parsed;
+ } else {
+ return false;
+ }
+ }
+ if (value.hasOwnProperty('access') && !Array.isArray(value.access)) {
+ return false;
+ }
+ return true;
+ }
+ return false;
};
/**
* Sets up the validation chain(s) for methods in this file.
*/
const validate = (method) => {
- switch (method) {
- case 'submitReport':
- return [
- body('email', conductorErrors.err1).exists().isEmail(),
- body('name', conductorErrors.err1).exists().isLength({ min: 1 }),
- body('role', conductorErrors.err1).exists().custom(validateRole),
- body('resource', conductorErrors.err1).exists().isObject(),
- body('instructor', conductorErrors.err1).optional({ checkFalsy: true }).isObject().custom(validateInstructorObj),
- body('student', conductorErrors.err1).optional({ checkFalsy: true }).isObject().custom(validateStudentObj)
- ]
- case 'getReports':
- return [
- query('startDate', conductorErrors.err1).exists().custom(threePartDateStringValidator),
- query('endDate', conductorErrors.err1).exists().custom(threePartDateStringValidator)
- ]
- case 'deleteReport':
- return [
- body('reportID', conductorErrors.err1).exists().isMongoId()
- ]
- }
+ switch (method) {
+ case 'submitReport':
+ return [
+ body('email', conductorErrors.err1).exists().isEmail(),
+ body('name', conductorErrors.err1).exists().isLength({ min: 1 }),
+ body('role', conductorErrors.err1).exists().custom(validateRole),
+ body('resource', conductorErrors.err1).exists().isObject(),
+ body('instructor', conductorErrors.err1).optional({ checkFalsy: true }).isObject().custom(validateInstructorObj),
+ body('student', conductorErrors.err1).optional({ checkFalsy: true }).isObject().custom(validateStudentObj),
+ ];
+ case 'getReports':
+ return [
+ query('startDate', conductorErrors.err1).exists().custom(threePartDateStringValidator),
+ query('endDate', conductorErrors.err1).exists().custom(threePartDateStringValidator),
+ ];
+ case 'deleteReport':
+ return [
+ body('reportID', conductorErrors.err1).exists().isMongoId(),
+ ];
+ }
};
export default {
- submitReport,
- getReports,
- deleteReport,
- validate
-}
\ No newline at end of file
+ submitReport,
+ getReports,
+ deleteReport,
+ validate,
+};
diff --git a/server/api/apiclients.js b/server/api/apiclients.js
index 8f2a1714..781c3fb1 100644
--- a/server/api/apiclients.js
+++ b/server/api/apiclients.js
@@ -82,7 +82,7 @@ function validate(method) {
case 'getAPIClient':
return [
param('clientID', conductorErrors.err1).exists().isString({ min: 1 }),
- ]
+ ];
}
}
@@ -91,4 +91,4 @@ export default {
getAPIClient,
updateAPIClientLastUsed,
validate,
-}
\ No newline at end of file
+};
diff --git a/server/api/mail.js b/server/api/mail.js
index 4e019e67..799444e8 100644
--- a/server/api/mail.js
+++ b/server/api/mail.js
@@ -7,8 +7,8 @@ import Mailgun from 'mailgun.js';
import formData from 'form-data';
import { marked } from 'marked';
import util from 'util';
-import { isEmptyString, truncateString } from '../util/helpers.js';
import { format } from 'date-fns';
+import { isEmptyString, truncateString } from '../util/helpers.js';
let mailgun = null;
@@ -33,7 +33,7 @@ const autoGenNoticeText = '. This message was auto-generated by the Conductor pl
const autoGenNoticeHTML = 'This message was auto-generated by the Conductor platform. Replies to this address are not monitored.
';
const DEFAULT_MAIL_FROM = 'LibreTexts Conductor ';
-const DEFAULT_MAIL_TO = "noreply@libretexts.org";
+const DEFAULT_MAIL_TO = 'noreply@libretexts.org';
/**
* Sends a standard Reset Password email to a user via the Mailgun API.
@@ -44,16 +44,13 @@ const DEFAULT_MAIL_TO = "noreply@libretexts.org";
* @param {string} resetLink - the URL to use the access the reset form with token
* @returns {Promise} a Mailgun API promise
*/
-const sendPasswordReset = (recipientAddress, recipientName, resetLink) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: [recipientAddress],
- subject: 'Reset Your Conductor Password',
- text: `Hi ${recipientName}, we received a request to reset your Conductor password. Please follow this link to reset your password: ${resetLink}. This link will expire in 30 minutes. If you did not initiate this request, you can ignore this email. Sincerely, The LibreTexts team` + autoGenNoticeText,
- html: `Hi ${recipientName},
We received a request to reset your Conductor password. Please follow this link to reset your password:
Reset Conductor Password This link will expire in 30 minutes. If you did not initiate this request, you can ignore this email.
Sincerely,
The LibreTexts team
` + autoGenNoticeHTML
- });
-};
-
+const sendPasswordReset = (recipientAddress, recipientName, resetLink) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: [recipientAddress],
+ subject: 'Reset Your Conductor Password',
+ text: `Hi ${recipientName}, we received a request to reset your Conductor password. Please follow this link to reset your password: ${resetLink}. This link will expire in 30 minutes. If you did not initiate this request, you can ignore this email. Sincerely, The LibreTexts team${autoGenNoticeText}`,
+ html: `Hi ${recipientName},
We received a request to reset your Conductor password. Please follow this link to reset your password:
Reset Conductor Password This link will expire in 30 minutes. If you did not initiate this request, you can ignore this email.
Sincerely,
The LibreTexts team
${autoGenNoticeHTML}`,
+});
/**
* Sends a standard Welcome email to a user via the Mailgun API.
@@ -63,16 +60,13 @@ const sendPasswordReset = (recipientAddress, recipientName, resetLink) => {
* @param {string} recipientName - the user's name ('firstName' or 'firstName lastName')
* @returns {Promise} a Mailgun API promise
*/
-const sendRegistrationConfirmation = (recipientAddress, recipientName) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: recipientAddress,
- subject: 'Welcome to Conductor',
- text: `Hi ${recipientName}, welcome to Conductor! You can access your account using this email and the password you set during registration. Remember, Conductor accounts are universal — you can use this account on any Conductor instance in the LibreNet. Sincerely, The LibreTexts team` + autoGenNoticeText,
- html: `Hi ${recipientName},
Welcome to Conductor! You can access your account using your email and the password you set during registration.
Remember, Conductor accounts are universal — you can use this account on any Conductor instance in the LibreNet.
If you are an educator and want to contribute to the LibreTexts libraries and other tools, please submit an Account Request at commons.libretexts.org/accountrequest in order for us to review your credentials.
Sincerely,
The LibreTexts team
` + autoGenNoticeHTML
- })
-};
-
+const sendRegistrationConfirmation = (recipientAddress, recipientName) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: recipientAddress,
+ subject: 'Welcome to Conductor',
+ text: `Hi ${recipientName}, welcome to Conductor! You can access your account using this email and the password you set during registration. Remember, Conductor accounts are universal — you can use this account on any Conductor instance in the LibreNet. Sincerely, The LibreTexts team${autoGenNoticeText}`,
+ html: `Hi ${recipientName},
Welcome to Conductor! You can access your account using your email and the password you set during registration.
Remember, Conductor accounts are universal — you can use this account on any Conductor instance in the LibreNet.
If you are an educator and want to contribute to the LibreTexts libraries and other tools, please submit an Account Request at commons.libretexts.org/accountrequest in order for us to review your credentials.
Sincerely,
The LibreTexts team
${autoGenNoticeHTML}`,
+});
/**
* Sends a standard Password Change Notification email to a user via the Mailgun API.
@@ -82,16 +76,13 @@ const sendRegistrationConfirmation = (recipientAddress, recipientName) => {
* @param {string} recipientName - the user's name ('firstName' or 'firstName lastName')
* @returns {Promise} a Mailgun API promise
*/
-const sendPasswordChangeNotification = (recipientAddress, recipientName) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: [recipientAddress],
- subject: 'Conductor Password Updated',
- text: `Hi ${recipientName}, You're receiving this email because your Conductor password was recently updated via the Conductor web application. If this was you, you can safely ignore this message. If this wasn't you, please reach out to us as soon as possible at info@libretexts.org. Sincerely, The LibreTexts team` + autoGenNoticeText,
- html: `Hi ${recipientName},
You're receiving this email because your Conductor password was recently updated via the Conductor web application.
If this was you, you can safely ignore this message. If this wasn't you, please reach out to us as soon as possible at info@libretexts.org .
Sincerely,
The LibreTexts team
` + autoGenNoticeHTML
- })
-};
-
+const sendPasswordChangeNotification = (recipientAddress, recipientName) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: [recipientAddress],
+ subject: 'Conductor Password Updated',
+ text: `Hi ${recipientName}, You're receiving this email because your Conductor password was recently updated via the Conductor web application. If this was you, you can safely ignore this message. If this wasn't you, please reach out to us as soon as possible at info@libretexts.org. Sincerely, The LibreTexts team${autoGenNoticeText}`,
+ html: `Hi ${recipientName},
You're receiving this email because your Conductor password was recently updated via the Conductor web application.
If this was you, you can safely ignore this message. If this wasn't you, please reach out to us as soon as possible at info@libretexts.org .
Sincerely,
The LibreTexts team
${autoGenNoticeHTML}`,
+});
/**
* Sends a standard email to all project members regarding a new team member via the Mailgun API.
@@ -103,16 +94,13 @@ const sendPasswordChangeNotification = (recipientAddress, recipientName) => {
* @param {string} projectName - the project's title/name
* @returns {Promise} a Mailgun API Promise
*/
-const sendAddedAsMemberNotification = (newMemberName, memberAddress, projectID, projectName) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: memberAddress,
- subject: `${projectName} - New Team Member Added`,
- text: `Hi, You're receiving this email because ${newMemberName} was added as a team member in the "${projectName}" project on the LibreTexts Conductor Platform. You can access this project by visiting ${process.env.LIBRE_SUBDOMAIN}.libretexts.org and opening the Projects tab. Sincerely, The LibreTexts team` + autoGenNoticeText,
- html: `Hi,
You're receiving this email because ${newMemberName} was added as a team member in the ${projectName} project on the LibreTexts Conductor Platform.
You can access this project by clicking the project's name in this email, or by visiting Conductor and opening the Projects tab.Sincerely,
The LibreTexts team
` + autoGenNoticeHTML
- });
-};
-
+const sendAddedAsMemberNotification = (newMemberName, memberAddress, projectID, projectName) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: memberAddress,
+ subject: `${projectName} - New Team Member Added`,
+ text: `Hi, You're receiving this email because ${newMemberName} was added as a team member in the "${projectName}" project on the LibreTexts Conductor Platform. You can access this project by visiting ${process.env.LIBRE_SUBDOMAIN}.libretexts.org and opening the Projects tab. Sincerely, The LibreTexts team${autoGenNoticeText}`,
+ html: `Hi,
You're receiving this email because ${newMemberName} was added as a team member in the ${projectName} project on the LibreTexts Conductor Platform.
You can access this project by clicking the project's name in this email, or by visiting Conductor and opening the Projects tab.Sincerely,
The LibreTexts team
${autoGenNoticeHTML}`,
+});
/**
* Sends a standard LibreText Publishing Requested email to the LibreTexts team via the Mailgun API.
@@ -126,26 +114,26 @@ const sendAddedAsMemberNotification = (newMemberName, memberAddress, projectID,
* @returns {Promise} a Mailgun API Promise
*/
const sendPublishingRequestedNotification = (requesterName, projectID, projectName, projectLibrary, projectCoverID) => {
- let textToSend = `Attention: A user, ${requesterName}, has requested that their project, ${projectName}, be formally published on LibreTexts using the Conductor platform. `;
- let htmlToSend = `Attention:
A user, ${requesterName}, has requested that their project, ${projectName} , be formally published on LibreTexts using the Conductor platform.
`;
- if (projectLibrary !== null) {
- if (projectCoverID !== null) {
- textToSend += `The LibreText is located in the ${projectLibrary} library, with pageID: ${projectCoverID}.`;
- htmlToSend += `The LibreText is located in the ${projectLibrary} library.
`;
- } else {
- textToSend += `The LibreText is located in the ${projectLibrary} library.`;
- htmlToSend += `The LibreText is located in the ${projectLibrary} library.
`;
- }
+ let textToSend = `Attention: A user, ${requesterName}, has requested that their project, ${projectName}, be formally published on LibreTexts using the Conductor platform. `;
+ let htmlToSend = `Attention:
A user, ${requesterName}, has requested that their project, ${projectName} , be formally published on LibreTexts using the Conductor platform.
`;
+ if (projectLibrary !== null) {
+ if (projectCoverID !== null) {
+ textToSend += `The LibreText is located in the ${projectLibrary} library, with pageID: ${projectCoverID}.`;
+ htmlToSend += `The LibreText is located in the ${projectLibrary} library.
`;
+ } else {
+ textToSend += `The LibreText is located in the ${projectLibrary} library.`;
+ htmlToSend += `The LibreText is located in the ${projectLibrary} library.
`;
}
- textToSend += `The Conductor project is available in the ${process.env.ORG_ID} instance.` + autoGenNoticeText;
- htmlToSend += `The Conductor project is available in the ${process.env.ORG_ID} instance.
This message was auto-generated by the Conductor platform.
` + autoGenNoticeHTML;
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: ['info@libretexts.org'],
- subject: `LibreText Publishing Requested: ${projectName}`,
- text: textToSend,
- html: htmlToSend
- });
+ }
+ textToSend += `The Conductor project is available in the ${process.env.ORG_ID} instance.${autoGenNoticeText}`;
+ htmlToSend += `The Conductor project is available in the ${process.env.ORG_ID} instance.
This message was auto-generated by the Conductor platform.
${autoGenNoticeHTML}`;
+ return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: ['info@libretexts.org'],
+ subject: `LibreText Publishing Requested: ${projectName}`,
+ text: textToSend,
+ html: htmlToSend,
+ });
};
/**
@@ -161,26 +149,25 @@ const sendPublishingRequestedNotification = (requesterName, projectID, projectNa
* @returns {Promise} a Mailgun API Promise
*/
const sendProjectFlaggedNotification = (recipients, projectID, projectTitle, projectOrg, flaggingGroup, flagDescrip) => {
- let textToSend = `Attention: A team member of the "${projectTitle}" project (available in the ${projectOrg} instance) on Conductor has flagged their project and requested that you (${flaggingGroup}) review it.`;
- let htmlToSend = `Attention:
A team member of the ${projectTitle} project (available in the ${projectOrg} instance) on Conductor has flagged their project and requested that you (${flaggingGroup} ) review it.
`;
- if (!isEmptyString(flagDescrip)) {
- let truncDescrip = truncateString(flagDescrip, 500);
- textToSend += `Reason for flagging (full description available on Conductor): ${truncDescrip}.`;
- textToSend += `Reason for flagging (full description available on Conductor):
${marked.parseInline(truncDescrip)}
`;
- }
- textToSend += `You can find this project in the "Flagged Projects" option under the "Projects" tab in Conductor. Sincerely, The LibreTexts team` + autoGenNoticeText;
- htmlToSend += `You can find this project in the Flagged Projects option under the Projects tab in Conductor.
Sincerely,
The LibreTexts team
` + autoGenNoticeHTML;
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: ['conductor@noreply.libretexts.org'],
- bcc: recipients,
- subject: `Conductor Project Flagged for Review: ${projectTitle}`,
- text: textToSend,
- html: htmlToSend
- });
+ let textToSend = `Attention: A team member of the "${projectTitle}" project (available in the ${projectOrg} instance) on Conductor has flagged their project and requested that you (${flaggingGroup}) review it.`;
+ let htmlToSend = `Attention:
A team member of the ${projectTitle} project (available in the ${projectOrg} instance) on Conductor has flagged their project and requested that you (${flaggingGroup} ) review it.
`;
+ if (!isEmptyString(flagDescrip)) {
+ const truncDescrip = truncateString(flagDescrip, 500);
+ textToSend += `Reason for flagging (full description available on Conductor): ${truncDescrip}.`;
+ textToSend += `Reason for flagging (full description available on Conductor):
${marked.parseInline(truncDescrip)}
`;
+ }
+ textToSend += `You can find this project in the "Flagged Projects" option under the "Projects" tab in Conductor. Sincerely, The LibreTexts team${autoGenNoticeText}`;
+ htmlToSend += `You can find this project in the Flagged Projects option under the Projects tab in Conductor.
Sincerely,
The LibreTexts team
${autoGenNoticeHTML}`;
+ return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: ['conductor@noreply.libretexts.org'],
+ bcc: recipients,
+ subject: `Conductor Project Flagged for Review: ${projectTitle}`,
+ text: textToSend,
+ html: htmlToSend,
+ });
};
-
/**
* Sends a standard New Project Messages notification to the respective group via the Mailgun API.
* NOTE: Do NOT use this method directly from a Conductor API route. Use internally
@@ -195,20 +182,20 @@ const sendProjectFlaggedNotification = (recipients, projectID, projectTitle, pro
* @returns {Promise} a Mailgun API Promise
*/
const sendNewProjectMessagesNotification = (recipients, projectID, projectTitle, projectOrg, messagesKind, threadTitle, messageText, authorName) => {
- let msgAuthor = authorName || 'A Conductor user';
- let htmlToSend = `A new message is available in the ${threadTitle} thread (${messagesKind} Discussion ) of the ${projectTitle} project on Conductor.
`;
- if (!isEmptyString(messageText)) {
- let truncMsg = truncateString(messageText, 500);
- htmlToSend += `${msgAuthor} said:
${marked.parseInline(truncMsg)}
`;
- }
- htmlToSend += autoGenNoticeHTML;
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: ['conductor@noreply.libretexts.org'],
- bcc: recipients,
- subject: `New Messages in ${projectTitle}`,
- html: htmlToSend
- });
+ const msgAuthor = authorName || 'A Conductor user';
+ let htmlToSend = `A new message is available in the ${threadTitle} thread (${messagesKind} Discussion ) of the ${projectTitle} project on Conductor.
`;
+ if (!isEmptyString(messageText)) {
+ const truncMsg = truncateString(messageText, 500);
+ htmlToSend += `${msgAuthor} said:
${marked.parseInline(truncMsg)}
`;
+ }
+ htmlToSend += autoGenNoticeHTML;
+ return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: ['conductor@noreply.libretexts.org'],
+ bcc: recipients,
+ subject: `New Messages in ${projectTitle}`,
+ html: htmlToSend,
+ });
};
/**
@@ -225,7 +212,7 @@ const sendNewProjectMessagesNotification = (recipients, projectID, projectTitle,
* @param {string} message.author - Name of the message author.
*/
async function sendProjectSupportRequest(recipients, project, message) {
- let html = `
+ let html = `
A member of
${project.title}
(ID: ${project.projectID})
@@ -233,19 +220,19 @@ async function sendProjectSupportRequest(recipients, project, message) {
In the ${message.threadTitle} thread (${message.kind} Discussion ), ${message.author} said:
`;
- if (!isEmptyString(message.body)) {
- html = `
+ if (!isEmptyString(message.body)) {
+ html = `
${html}
${marked.parseInline(truncateString(message.body, 500))}
`;
- }
- html = `${html}${autoGenNoticeHTML}`;
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- html,
- from: 'LibreTexts Conductor ',
- to: recipients,
- subject: `Support Requested: ${project.title}`,
- });
+ }
+ html = `${html}${autoGenNoticeHTML}`;
+ return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ html,
+ from: 'LibreTexts Conductor ',
+ to: recipients,
+ subject: `Support Requested: ${project.title}`,
+ });
}
/**
@@ -259,19 +246,18 @@ async function sendProjectSupportRequest(recipients, project, message) {
* @returns {Promise} A Mailgun API Promise.
*/
const sendProjectCompletedAlert = (recipients, projectID, projectTitle, projectOrg) => {
- let textToSend = `Attention: The "${projectTitle}" project on Conductor has been marked as completed. This project is available in the ${projectOrg} instance. You are receiving this message because you are a member of this project, or you submitted the OER Integration Request this project originated from. Sincerely, The LibreTexts team` + autoGenNoticeText;
- let htmlToSend = `Attention:
The ${projectTitle} project on Conductor has been marked as completed. This project is available in the ${projectOrg} instance.
You are receiving this message because you are a member of this project, or you submitted the OER Integration Request this project originated from.
Sincerely,
The LibreTexts team
` + autoGenNoticeHTML;
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: ['conductor@noreply.libretexts.org'],
- bcc: recipients,
- subject: `Project Completed: ${projectTitle}`,
- text: textToSend,
- html: htmlToSend
- });
+ const textToSend = `Attention: The "${projectTitle}" project on Conductor has been marked as completed. This project is available in the ${projectOrg} instance. You are receiving this message because you are a member of this project, or you submitted the OER Integration Request this project originated from. Sincerely, The LibreTexts team${autoGenNoticeText}`;
+ const htmlToSend = `Attention:
The ${projectTitle} project on Conductor has been marked as completed. This project is available in the ${projectOrg} instance.
You are receiving this message because you are a member of this project, or you submitted the OER Integration Request this project originated from.
Sincerely,
The LibreTexts team
${autoGenNoticeHTML}`;
+ return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: ['conductor@noreply.libretexts.org'],
+ bcc: recipients,
+ subject: `Project Completed: ${projectTitle}`,
+ text: textToSend,
+ html: htmlToSend,
+ });
};
-
/**
* Sends a standard Assigned to Task email to the respective user
* via the Mailgun API.
@@ -285,18 +271,17 @@ const sendProjectCompletedAlert = (recipients, projectID, projectTitle, projectO
* @returns {Promise} a Mailgun API Promise
*/
const sendAssignedToTaskNotification = (recipient, projectID, projectTitle, projectOrg, taskTitle) => {
- let textToSend = `Attention: You have been assigned to the "${taskTitle}" task in the ${projectTitle} project on Conductor. This project is available in the ${projectOrg} instance. Sincerely, The LibreTexts team` + autoGenNoticeText;
- let htmlToSend = `Attention:
You have been assigned to the ${taskTitle} task in the ${projectTitle} project on Conductor. This project is available in the ${projectOrg} instance.
Sincerely,
The LibreTexts team
` + autoGenNoticeHTML;
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: [recipient],
- subject: `Task Assigned: ${taskTitle}`,
- text: textToSend,
- html: htmlToSend
- });
+ const textToSend = `Attention: You have been assigned to the "${taskTitle}" task in the ${projectTitle} project on Conductor. This project is available in the ${projectOrg} instance. Sincerely, The LibreTexts team${autoGenNoticeText}`;
+ const htmlToSend = `Attention:
You have been assigned to the ${taskTitle} task in the ${projectTitle} project on Conductor. This project is available in the ${projectOrg} instance.
Sincerely,
The LibreTexts team
${autoGenNoticeHTML}`;
+ return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: [recipient],
+ subject: `Task Assigned: ${taskTitle}`,
+ text: textToSend,
+ html: htmlToSend,
+ });
};
-
/**
* Sends a standard Account(s) Request Confirmation notification to the requester via the Mailgun API.
* NOTE: Do NOT use this method directly from a Conductor API route. Use internally
@@ -306,18 +291,17 @@ const sendAssignedToTaskNotification = (recipient, projectID, projectTitle, proj
* @returns {Promise} a Mailgun API Promise
*/
const sendAccountRequestConfirmation = (requesterName, recipientAddress) => {
- let textToSend = `Hi ${requesterName}, LibreTexts has received your Instructor Account(s) Request. You should receive an email when we have reviewed your request. If you have any questions, please contact us at info@libretexts.org. Sincerely, The LibreTexts team` + autoGenNoticeText;
- let htmlToSend = `Hi ${requesterName},
LibreTexts has received your Instructor Account(s) Request. You should receive an email when we have reviewed your request. If you have any questions, please contact us at info@libretexts.org .
Sincerely,
The LibreTexts team
` + autoGenNoticeHTML;
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: [recipientAddress],
- subject: 'LibreTexts Instructor Account(s) Request Received',
- text: textToSend,
- html: htmlToSend
- });
+ const textToSend = `Hi ${requesterName}, LibreTexts has received your Instructor Account(s) Request. You should receive an email when we have reviewed your request. If you have any questions, please contact us at info@libretexts.org. Sincerely, The LibreTexts team${autoGenNoticeText}`;
+ const htmlToSend = `Hi ${requesterName},
LibreTexts has received your Instructor Account(s) Request. You should receive an email when we have reviewed your request. If you have any questions, please contact us at info@libretexts.org .
Sincerely,
The LibreTexts team
${autoGenNoticeHTML}`;
+ return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: [recipientAddress],
+ subject: 'LibreTexts Instructor Account(s) Request Received',
+ text: textToSend,
+ html: htmlToSend,
+ });
};
-
/**
* Sends a standard New Account(s) Request notification to the LibreTexts team via the Mailgun API.
* NOTE: Do NOT use this method directly from a Conductor API route. Use internally
@@ -325,18 +309,17 @@ const sendAccountRequestConfirmation = (requesterName, recipientAddress) => {
* @returns {Promise} a Mailgun API Promise
*/
const sendAccountRequestAdminNotif = () => {
- let textToSend = `Attention: A user has submitted a new Instructor Account(s) Request for LibreTexts libraries access. This request is available in Conductor.` + autoGenNoticeText;
- let htmlToSend = `Attention:
A user has submitted a new Instructor Account(s) Request for LibreTexts libraries access.
This request is available in Conductor.
` + autoGenNoticeHTML;
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: ['info@libretexts.org'],
- subject: 'New Instructor Account(s) Request',
- text: textToSend,
- html: htmlToSend
- });
+ const textToSend = `Attention: A user has submitted a new Instructor Account(s) Request for LibreTexts libraries access. This request is available in Conductor.${autoGenNoticeText}`;
+ const htmlToSend = `Attention:
A user has submitted a new Instructor Account(s) Request for LibreTexts libraries access.
This request is available in Conductor.
${autoGenNoticeHTML}`;
+ return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: ['info@libretexts.org'],
+ subject: 'New Instructor Account(s) Request',
+ text: textToSend,
+ html: htmlToSend,
+ });
};
-
/**
* Sends a standard Account(s) Request Approval notification to the requester via the Mailgun API.
* NOTE: Do NOT use this method directly from a Conductor API route. Use internally
@@ -346,18 +329,17 @@ const sendAccountRequestAdminNotif = () => {
* @returns {Promise} a Mailgun API Promise
*/
const sendAccountRequestApprovalNotification = (requesterName, recipientAddress) => {
- let textToSend = `Hi ${requesterName}, LibreTexts has approved your Instructor Account(s) Request. You should have received seperate emails with information about your new account(s). Please check your spam/junk folder if you have not received them. Otherwise, contact LibreTexts at info@libretexts.org for assistance. Sincerely, The LibreTexts team` + autoGenNoticeText;
- let htmlToSend = `Hi ${requesterName},
LibreTexts has approved your Instructor Account(s) Request.
You should have received seperate emails with information about your new account(s).
Please check your spam/junk folder if you have not received them. Otherwise, contact LibreTexts at info@libretexts.org for assistance.
Sincerely,
The LibreTexts team
` + autoGenNoticeHTML;
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: [recipientAddress],
- subject: `LibreTexts Instructor Account(s) Request Approved`,
- text: textToSend,
- html: htmlToSend
- });
+ const textToSend = `Hi ${requesterName}, LibreTexts has approved your Instructor Account(s) Request. You should have received seperate emails with information about your new account(s). Please check your spam/junk folder if you have not received them. Otherwise, contact LibreTexts at info@libretexts.org for assistance. Sincerely, The LibreTexts team${autoGenNoticeText}`;
+ const htmlToSend = `Hi ${requesterName},
LibreTexts has approved your Instructor Account(s) Request.
You should have received seperate emails with information about your new account(s).
Please check your spam/junk folder if you have not received them. Otherwise, contact LibreTexts at info@libretexts.org for assistance.
Sincerely,
The LibreTexts team
${autoGenNoticeHTML}`;
+ return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: [recipientAddress],
+ subject: 'LibreTexts Instructor Account(s) Request Approved',
+ text: textToSend,
+ html: htmlToSend,
+ });
};
-
/**
* Sends a standard Invitation to Review notification to the specified email via the Mailgun API.
* NOTE: Do NOT use this method directly from a Conductor API route. Use internally
@@ -370,25 +352,24 @@ const sendAccountRequestApprovalNotification = (requesterName, recipientAddress)
* @returns {Promise} a Mailgun API Promise
*/
const sendPeerReviewInvitation = (inviterName, recipientAddress, projectID, projectTitle, projectURL) => {
- let reviewLink = `https://commons.libretexts.org/peerreview/${projectID}`;
- let textToSend = `Hello! ${inviterName} has invited you to review their project, ${projectTitle}, on the LibreTexts Conductor platform.`;
- let htmlToSend = `Hello!
${inviterName} has invited you to review their project, ${projectTitle} , on the LibreTexts Conductor platform.
`;
- if (typeof(projectURL) === 'string' && projectURL.length > 0) {
- textToSend += ` You can view their project's resource at ${projectURL}.`
- htmlToSend += `You can view their project's resource here.
`;
- }
- textToSend += `You can access the Peer Review form for the project at: ${reviewLink}. Sincerely, The LibreTexts team.` + autoGenNoticeText;
- htmlToSend += `You can access the Peer Review form for the project here .
Sincerely,
The LibreTexts team
` + autoGenNoticeHTML;
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: [recipientAddress],
- subject: `Invitation to Review: ${projectTitle}`,
- text: textToSend,
- html: htmlToSend
- });
+ const reviewLink = `https://commons.libretexts.org/peerreview/${projectID}`;
+ let textToSend = `Hello! ${inviterName} has invited you to review their project, ${projectTitle}, on the LibreTexts Conductor platform.`;
+ let htmlToSend = `Hello!
${inviterName} has invited you to review their project, ${projectTitle} , on the LibreTexts Conductor platform.
`;
+ if (typeof (projectURL) === 'string' && projectURL.length > 0) {
+ textToSend += ` You can view their project's resource at ${projectURL}.`;
+ htmlToSend += `You can view their project's resource here.
`;
+ }
+ textToSend += `You can access the Peer Review form for the project at: ${reviewLink}. Sincerely, The LibreTexts team.${autoGenNoticeText}`;
+ htmlToSend += `You can access the Peer Review form for the project here .
Sincerely,
The LibreTexts team
${autoGenNoticeHTML}`;
+ return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: [recipientAddress],
+ subject: `Invitation to Review: ${projectTitle}`,
+ text: textToSend,
+ html: htmlToSend,
+ });
};
-
/**
* Sends a standard New Peer Review notification to the specified recipient emails via the Mailgun API.
* NOTE: Do NOT use this method directly from a Conductor API route. Use internally
@@ -400,18 +381,17 @@ const sendPeerReviewInvitation = (inviterName, recipientAddress, projectID, proj
* @returns {Promise} a Mailgun API Promise
*/
const sendPeerReviewNotification = (recipients, projectID, projectTitle, projectOrg) => {
- let textToSend = `Attention: A new Peer Review has been submitted to the "${projectTitle}" project (available in the ${projectOrg} instance) on Conductor. You can view this Peer Review by visiting the Project's page and selecting 'Peer Review' from the top menu bar.` + autoGenNoticeText;
- let htmlToSend = `Attention:
A new Peer Review has been submitted to the ${projectTitle} project (available in the ${projectOrg} instance) on Conductor.
You can view this Peer Review by visiting the Project's page and selecting 'Peer Review' from the top menu bar.
` + autoGenNoticeHTML;
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: recipients,
- subject: `New Peer Review for ${projectTitle}`,
- text: textToSend,
- html: htmlToSend
- });
+ const textToSend = `Attention: A new Peer Review has been submitted to the "${projectTitle}" project (available in the ${projectOrg} instance) on Conductor. You can view this Peer Review by visiting the Project's page and selecting 'Peer Review' from the top menu bar.${autoGenNoticeText}`;
+ const htmlToSend = `Attention:
A new Peer Review has been submitted to the ${projectTitle} project (available in the ${projectOrg} instance) on Conductor.
You can view this Peer Review by visiting the Project's page and selecting 'Peer Review' from the top menu bar.
${autoGenNoticeHTML}`;
+ return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: recipients,
+ subject: `New Peer Review for ${projectTitle}`,
+ text: textToSend,
+ html: htmlToSend,
+ });
};
-
/**
* Sends a standard New Autogenerated Projects notification to LibreTexts Administrators via the Mailgun API.
* NOTE: Do NOT use this method directly from a Conductor API route. Use internally
@@ -421,27 +401,26 @@ const sendPeerReviewNotification = (recipients, projectID, projectTitle, project
* @returns {Promise} A Mailgun API Promise.
*/
const sendAutogeneratedProjectsNotification = (recipients, projects) => {
- let htmlToSend = `Attention:
New Conductor projects were created during a Commons-Libraries sync.
`;
- if (Array.isArray(projects) && projects.length > 0) {
- let listString = '';
- projects.forEach((item) => {
- if (typeof (item.projectID) === 'string' && typeof (item.title) === 'string') {
- listString += `${item.title} `;
- }
- });
- htmlToSend += ``;
- }
- htmlToSend += autoGenNoticeHTML;
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: ['conductor@noreply.libretexts.org'],
- bcc: recipients,
- subject: 'New Autogenerated Projects Available',
- html: htmlToSend
+ let htmlToSend = 'Attention:
New Conductor projects were created during a Commons-Libraries sync.
';
+ if (Array.isArray(projects) && projects.length > 0) {
+ let listString = '';
+ projects.forEach((item) => {
+ if (typeof (item.projectID) === 'string' && typeof (item.title) === 'string') {
+ listString += `${item.title} `;
+ }
});
+ htmlToSend += ``;
+ }
+ htmlToSend += autoGenNoticeHTML;
+ return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: ['conductor@noreply.libretexts.org'],
+ bcc: recipients,
+ subject: 'New Autogenerated Projects Available',
+ html: htmlToSend,
+ });
};
-
/**
* Sends a standard Alert Activated notification to a user via the Mailgun API.
* NOTE: Do NOT use this method directly from a Conductor API route. Use internally
@@ -455,41 +434,41 @@ const sendAutogeneratedProjectsNotification = (recipients, projects) => {
* @returns {Promise} A Mailgun API Promise.
*/
const sendAlertActivatedNotification = (recipientEmail, recipientName, alertTitle, projects, books, homeworks) => {
- let htmlToSend = `Hi ${recipientName},
New resources matching your "${alertTitle} " alert have been created or found in Conductor.
`;
- if (Array.isArray(projects) && projects.length > 0) {
- let listString = '';
- projects.forEach((item) => {
- if (typeof (item.projectID) === 'string' && typeof (item.title) === 'string') {
- listString += `${item.title} `;
- }
- });
- htmlToSend += `Projects:
`;
- }
- if (Array.isArray(books) && books.length > 0) {
- let listString = '';
- books.forEach((item) => {
- if (typeof (item.bookID) === 'string' && typeof (item.title) === 'string') {
- listString += `${item.title} `;
- }
- });
- htmlToSend += `Books:
`;
- }
- if (Array.isArray(homeworks) && homeworks.length > 0) {
- let listString = '';
- homeworks.forEach((item) => {
- if (typeof (item.title) === 'string') {
- listString += `${item.title} `;
- }
- });
- htmlToSend += `Homework/Assessments:
`;
- }
- htmlToSend += autoGenNoticeHTML;
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: [recipientEmail],
- subject: `Alert Activated: ${alertTitle}`,
- html: htmlToSend
+ let htmlToSend = `Hi ${recipientName},
New resources matching your "${alertTitle} " alert have been created or found in Conductor.
`;
+ if (Array.isArray(projects) && projects.length > 0) {
+ let listString = '';
+ projects.forEach((item) => {
+ if (typeof (item.projectID) === 'string' && typeof (item.title) === 'string') {
+ listString += `${item.title} `;
+ }
});
+ htmlToSend += `Projects:
`;
+ }
+ if (Array.isArray(books) && books.length > 0) {
+ let listString = '';
+ books.forEach((item) => {
+ if (typeof (item.bookID) === 'string' && typeof (item.title) === 'string') {
+ listString += `${item.title} `;
+ }
+ });
+ htmlToSend += `Books:
`;
+ }
+ if (Array.isArray(homeworks) && homeworks.length > 0) {
+ let listString = '';
+ homeworks.forEach((item) => {
+ if (typeof (item.title) === 'string') {
+ listString += `${item.title} `;
+ }
+ });
+ htmlToSend += `Homework/Assessments:
`;
+ }
+ htmlToSend += autoGenNoticeHTML;
+ return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: [recipientEmail],
+ subject: `Alert Activated: ${alertTitle}`,
+ html: htmlToSend,
+ });
};
/**
@@ -498,19 +477,17 @@ const sendAlertActivatedNotification = (recipientEmail, recipientName, alertTitl
*
* @returns {Promise} A Mailgun API promise.
*/
-const sendAnalyticsAccessRequestCreated = () => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: ['support@libretexts.org'],
- subject: 'New Analytics Access Request',
- html: `
+const sendAnalyticsAccessRequestCreated = () => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: ['support@libretexts.org'],
+ subject: 'New Analytics Access Request',
+ html: `
Attention:
A user has submitted a new Learning Analytics Access Request.
This request is available in Conductor.
${autoGenNoticeHTML}
`,
- });
-};
+});
/**
* Sends a notification of an Analytics Access Request approval to the original requester
@@ -524,18 +501,16 @@ const sendAnalyticsAccessRequestCreated = () => {
* @param {string} course.title - UI title of the Course.
* @returns {Promise} A Mailgun API promise.
*/
-const sendAnalyticsAccessRequestApproved = (requester, course) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: [requester.email],
- subject: 'Analytics Access Request Approved',
- html: `
+const sendAnalyticsAccessRequestApproved = (requester, course) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: [requester.email],
+ subject: 'Analytics Access Request Approved',
+ html: `
Hi ${requester.firstName},
Your request to view LibreTexts textbook analytics data in ${course.title} has been approved!
${autoGenNoticeHTML}
`,
- });
-};
+});
/**
* Sends a notification of an Analytics Access Request denial to the original requester
@@ -550,25 +525,23 @@ const sendAnalyticsAccessRequestApproved = (requester, course) => {
* @param {string} [message] - An optional message to the requester to include.
* @returns {Promise} A Mailgun API promise.
*/
-const sendAnalyticsAccessRequestDenied = (requester, course, message = null) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: [requester.email],
- subject: 'Analytics Access Request Denied',
- html: `
+const sendAnalyticsAccessRequestDenied = (requester, course, message = null) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: [requester.email],
+ subject: 'Analytics Access Request Denied',
+ html: `
Hi ${requester.firstName}
Your request to view LibreTexts textbook analytics data in ${course.title} has been denied.
${message
- ? `
+ ? `
The team member reviewing your request said:
${marked.parseInline(message)}
`
- : ''
- }
+ : ''
+}
${autoGenNoticeHTML}
`,
- });
-};
+});
/**
* Sends a notification that a user has been invited to join an Analytics Course via the
@@ -585,20 +558,18 @@ const sendAnalyticsAccessRequestDenied = (requester, course, message = null) =>
* @param {string} inviteID - Identifier of the new invitation.
* @returns {Promise} A Mailgun API promise.
*/
-const sendAnalyticsInvite = (sender, invitee, course, _inviteID) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: [invitee.email],
- subject: 'New Invitation to Join Analytics Course',
- html: `
+const sendAnalyticsInvite = (sender, invitee, course, _inviteID) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: [invitee.email],
+ subject: 'New Invitation to Join Analytics Course',
+ html: `
Hi ${invitee.firstName},
${sender.firstName} ${sender.lastName} has invited you to join their Analytics course for ${course.title} on Conductor.
To accept or ignore this invitation, visit your Analytics Portal in Conductor.
Note: This invitation will expire in 14 days. If you haven't done so already, you'll need to verify your status as an instructor at an academic institution in order to view Analytics.
${autoGenNoticeHTML}
`,
- });
-};
+});
/**
* Sends a notification that a user has accepted an invite to join an Analytics Course via
@@ -613,20 +584,18 @@ const sendAnalyticsInvite = (sender, invitee, course, _inviteID) => {
* @param {object} course - The course being shared.
* @param {string} course.title - UI title of the course.
*/
-const sendAnalyticsInviteAccepted = (sender, invitee, course) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: [sender.email],
- subject: 'Invitation to Analytics Course Accepted',
- html: `
+const sendAnalyticsInviteAccepted = (sender, invitee, course) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: [sender.email],
+ subject: 'Invitation to Analytics Course Accepted',
+ html: `
Hi ${sender.firstName},
We're just writing to let you know that ${invitee.firstName} ${invitee.lastName} accepted your invitation to join your Analytics course for ${course.title} .
Sincerely,
The LibreTexts team
${autoGenNoticeHTML}
`,
- })
-};
+});
/**
* Sends a notification that a user's registration for an Event has been confirmed.
@@ -636,12 +605,11 @@ const sendAnalyticsInviteAccepted = (sender, invitee, course) => {
* @param {string} participantName - The name of the user who registered.
* @param {string} formattedStartTime - The start time and date of the Event with timezone formatted for presentation.
*/
-const sendOrgEventRegistrationConfirmation = (addresses, orgEvent, participantName, formattedStartTime) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Conductor ',
- to: addresses,
- subject: 'Registration Confirmation',
- html: `
+const sendOrgEventRegistrationConfirmation = (addresses, orgEvent, participantName, formattedStartTime) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Conductor ',
+ to: addresses,
+ subject: 'Registration Confirmation',
+ html: `
Hi ${participantName},
We're just writing to let you know that your registration for ${orgEvent.title} has been confirmed! We look forward to having you join us!
Remember, ${orgEvent.title} starts at ${formattedStartTime}.
@@ -649,8 +617,7 @@ const sendOrgEventRegistrationConfirmation = (addresses, orgEvent, participantNa
The LibreTexts team
${autoGenNoticeHTML}
`,
- })
-};
+});
/**
* Sends a notification to the specified email addresses that page AI metadata has been generated and applied.
@@ -663,18 +630,17 @@ const sendOrgEventRegistrationConfirmation = (addresses, orgEvent, participantNa
* @param {string} resultsString - the results of the job,
* @param {string} bookTitle - the title of the book that was updated
*/
-const sendBatchBookAIMetadataFinished = (recipientAddresses, projectID, jobID, jobType, updatedMeta, updatedImage, resultsString, bookTitle) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Support ',
- to: recipientAddresses,
- subject: `AI Metadata Generation Finished - ${bookTitle}`,
- html: `
+const sendBatchBookAIMetadataFinished = (recipientAddresses, projectID, jobID, jobType, updatedMeta, updatedImage, resultsString, bookTitle) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Support ',
+ to: recipientAddresses,
+ subject: `AI Metadata Generation Finished - ${bookTitle}`,
+ html: `
Hi,
We're just writing to let you know that we've finished applying AI-generated ${jobType} for ${bookTitle}.
Succesfully Updated: ${updatedMeta} pages with summaries and/or tags
Succesfully Updated: ${updatedImage} pages with image alt text
- Could Not Update: ${resultsString || "N/A"}
+ Could Not Update: ${resultsString || 'N/A'}
You can access this project at https://${process.env.LIBRE_SUBDOMAIN}.libretexts.org/projects/${projectID} .
@@ -686,8 +652,7 @@ const sendBatchBookAIMetadataFinished = (recipientAddresses, projectID, jobID, j
${autoGenNoticeHTML}
`,
- });
-};
+});
/**
* Sends a notification to the specified email addresses that page AI metadata has been generated and applied.
@@ -699,17 +664,16 @@ const sendBatchBookAIMetadataFinished = (recipientAddresses, projectID, jobID, j
* @param {string} resultsString - the results of the job
* @param {string} bookTitle - the title of the book that was updated
*/
-const sendBatchBookUpdateFinished = (recipientAddresses, projectID, jobID, updated, resultsString, bookTitle) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Support ',
- to: recipientAddresses,
- subject: `Bulk Update Job Finished - ${bookTitle}`,
- html: `
+const sendBatchBookUpdateFinished = (recipientAddresses, projectID, jobID, updated, resultsString, bookTitle) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Support ',
+ to: recipientAddresses,
+ subject: `Bulk Update Job Finished - ${bookTitle}`,
+ html: `
Hi,
We're just writing to let you know that we've finished applying your updates to ${bookTitle}
Successfully Updated: ${updated} pages
- Could Not Update: ${resultsString || "N/A"}
+ Could Not Update: ${resultsString || 'N/A'}
You can access this project at https://${process.env.LIBRE_SUBDOMAIN}.libretexts.org/projects/${projectID} .
@@ -722,8 +686,7 @@ const sendBatchBookUpdateFinished = (recipientAddresses, projectID, jobID, updat
${autoGenNoticeHTML}
`,
- });
-};
+});
/**
* Sends a confirmation email to the user who submitted a support ticket.
@@ -731,26 +694,24 @@ const sendBatchBookUpdateFinished = (recipientAddresses, projectID, jobID, updat
* @param {string} recipientAddress - the user's email address
* @param {string} ticketID - the ticket's uuid
*/
-const sendSupportTicketCreateConfirmation = (ticketType, recipientAddress, ticketID, params) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Support ',
- to: [recipientAddress],
- subject: `${ticketType} Created`,
- html: `
+const sendSupportTicketCreateConfirmation = (ticketType, recipientAddress, ticketID, params) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Support ',
+ to: [recipientAddress],
+ subject: `${ticketType} Created`,
+ html: `
Hi,
We're just writing to let you know that your ${ticketType.toLowerCase()} has been created. You can view your ${ticketType.toLowerCase()} at https://commons.libretexts.org/support/ticket/${ticketID}${params ? `?${params}` : ''} .
Sincerely,
The LibreTexts team
${autoGenNoticeHTML}
`,
- })
-};
+});
/**
* Sends a notification to the LibreTexts team that a new support ticket has been created.
* @param {string} ticketType - the type of ticket submitted
* @param {string[]} recipientAddresses - the email addresses to send the notification to
- * @param {string} ticketID - the ticket's uuid
+ * @param {string} ticketID - the ticket's uuid
* @param {string} ticketTitle - the ticket's title/subject
* @param {string} ticketBody - the ticket's body/description
* @param {string} ticketAuthor - the ticket's author
@@ -759,12 +720,11 @@ const sendSupportTicketCreateConfirmation = (ticketType, recipientAddress, ticke
* @param {string | undefined} capturedURL - the URL of the page where the ticket was created or provided by the user
* @param {object} metadata - any additional metadata associated with the ticket
*/
-const sendSupportTicketCreateInternalNotification = (ticketType, recipientAddresses, ticketID, ticketTitle, ticketBody, ticketAuthor, ticketCategory, ticketPriority, capturedURL, metadata) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Support ',
- to: recipientAddresses,
- subject: `New ${ticketType} Created (ID #${ticketID.slice(-7)})`,
- html: `
+const sendSupportTicketCreateInternalNotification = (ticketType, recipientAddresses, ticketID, ticketTitle, ticketBody, ticketAuthor, ticketCategory, ticketPriority, capturedURL, metadata) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Support ',
+ to: recipientAddresses,
+ subject: `New ${ticketType} Created (ID #${ticketID.slice(-7)})`,
+ html: `
Hi,
A new ${ticketType} has been created.
Title: ${ticketTitle}
@@ -788,8 +748,7 @@ const sendSupportTicketCreateInternalNotification = (ticketType, recipientAddres
The LibreTexts team
${autoGenNoticeHTML}
`,
- });
-}
+});
/**
* Sends a notification to the specified email addresses that a new message has been posted to a support ticket.
@@ -801,13 +760,12 @@ const sendSupportTicketCreateInternalNotification = (ticketType, recipientAddres
* @param {string} messageSender - the message's author
* @param {string} params - any URL parameters to append to the ticket URL
*/
-const sendNewTicketMessageNotification = (ticketType, recipientAddresses, ticketID, ticketSubject, message, messageSender, params) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: DEFAULT_MAIL_FROM,
- to: DEFAULT_MAIL_TO,
- bcc: recipientAddresses,
- subject: `New Message on ${ticketType}: ${truncateString(ticketSubject, 30)}`,
- html: `
+const sendNewTicketMessageNotification = (ticketType, recipientAddresses, ticketID, ticketSubject, message, messageSender, params) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: DEFAULT_MAIL_FROM,
+ to: DEFAULT_MAIL_TO,
+ bcc: recipientAddresses,
+ subject: `New Message on ${ticketType}: ${truncateString(ticketSubject, 30)}`,
+ html: `
Hi,
A new message has been posted a ${ticketType.toLowerCase()} you have subscribed to.
${messageSender} said:
@@ -819,8 +777,7 @@ const sendNewTicketMessageNotification = (ticketType, recipientAddresses, ticket
Ticket ID: ${ticketID}
${autoGenNoticeHTML}
`,
- });
-};
+});
/**
* Sends a notification to the specified email addresses (of assigned staff) that a new internal message has been posted to a support ticket.
@@ -832,12 +789,11 @@ const sendNewTicketMessageNotification = (ticketType, recipientAddresses, ticket
* @param {string} priority - the ticket's priority
* @param {string} subject - the ticket's subject
*/
-const sendNewInternalTicketMessageAssignedStaffNotification = (ticketType, recipientAddresses, ticketID, message, messageSender, priority, subject) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Support ',
- to: recipientAddresses,
- subject: `New Internal Message on ${ticketType}: ${truncateString(subject, 30)} (P: ${priority})`,
- html: `
+const sendNewInternalTicketMessageAssignedStaffNotification = (ticketType, recipientAddresses, ticketID, message, messageSender, priority, subject) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Support ',
+ to: recipientAddresses,
+ subject: `New Internal Message on ${ticketType}: ${truncateString(subject, 30)} (P: ${priority})`,
+ html: `
Hi,
A new internal message has been posted to a ${ticketType.toLowerCase()} you have subscribed to: "${subject}"
@@ -851,8 +807,7 @@ const sendNewInternalTicketMessageAssignedStaffNotification = (ticketType, recip
Ticket ID: ${ticketID}
${autoGenNoticeHTML}
`,
- });
-};
+});
/**
* Sends a warning that a ticket will be closed in 3 days if no response is received.
@@ -861,13 +816,12 @@ const sendNewInternalTicketMessageAssignedStaffNotification = (ticketType, recip
* @param {string} subject - the ticket's subject
* @param {string} params - any URL parameters to append to the ticket URL
*/
-const sendSupportTicketAutoCloseWarning = (recipientAddresses, ticketID, subject, params) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: DEFAULT_MAIL_FROM,
- to: DEFAULT_MAIL_TO,
- bcc: recipientAddresses,
- subject: `Ticket Will Automatically Close Soon (Subject: ${truncateString(subject, 30)})`,
- html: `
+const sendSupportTicketAutoCloseWarning = (recipientAddresses, ticketID, subject, params) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: DEFAULT_MAIL_FROM,
+ to: DEFAULT_MAIL_TO,
+ bcc: recipientAddresses,
+ subject: `Ticket Will Automatically Close Soon (Subject: ${truncateString(subject, 30)})`,
+ html: `
Hi,
We're writing to let you know that a support ticket you have subscribed to will automatically close for inactivity in three days from this notification: "${subject}"
@@ -880,20 +834,18 @@ const sendSupportTicketAutoCloseWarning = (recipientAddresses, ticketID, subject
Ticket ID: ${ticketID}
${autoGenNoticeHTML}
`,
- });
-};
+});
/**
* Sends an order confirmation email to the customer.
* @param {string} recipientAddress - the email address to send the notification to
* @param {string} orderID - the order's id (checkout session id)
*/
-const sendStoreOrderConfirmation = (recipientAddress, orderID) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: DEFAULT_MAIL_FROM,
- to: [recipientAddress],
- subject: `LibreTexts Store Order Confirmation: ${orderID}`,
- html: `
+const sendStoreOrderConfirmation = (recipientAddress, orderID) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: DEFAULT_MAIL_FROM,
+ to: [recipientAddress],
+ subject: `LibreTexts Store Order Confirmation: ${orderID}`,
+ html: `
Hi,
Thank you for your order! You can view your order details and check its status at any time by using the link below:
@@ -907,21 +859,19 @@ const sendStoreOrderConfirmation = (recipientAddress, orderID) => {
Order ID: ${orderID}
${autoGenNoticeHTML}
`,
- });
-};
+});
/**
* Sends a notification email to the customer when their order is in production.
- * @param {String} recipientAddress
- * @param {String} orderID
- * @returns
+ * @param {String} recipientAddress
+ * @param {String} orderID
+ * @returns
*/
-const sendStoreOrderInProductionUpdate = (recipientAddress, orderID) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: DEFAULT_MAIL_FROM,
- to: [recipientAddress],
- subject: `LibreTexts Store Order Update: ${orderID.slice(-6)}`,
- html: `
+const sendStoreOrderInProductionUpdate = (recipientAddress, orderID) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: DEFAULT_MAIL_FROM,
+ to: [recipientAddress],
+ subject: `LibreTexts Store Order Update: ${orderID.slice(-6)}`,
+ html: `
Hi,
We're writing to let you know that your order is currently in production. We will notify you when your item(s) have shipped.
@@ -936,26 +886,24 @@ const sendStoreOrderInProductionUpdate = (recipientAddress, orderID) => {
Order ID: ${orderID}
${autoGenNoticeHTML}
`,
- });
-}
+});
/**
* Sends a notification email to the customer when their order has shipped.
- * @param {String} recipientAddress
- * @param {String} orderID
- * @param {String[]} trackingURLs
- * @returns
+ * @param {String} recipientAddress
+ * @param {String} orderID
+ * @param {String[]} trackingURLs
+ * @returns
*/
-const sendStoreOrderShippedUpdate = (recipientAddress, orderID, trackingURLs) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: DEFAULT_MAIL_FROM,
- to: [recipientAddress],
- subject: `LibreTexts Store Order Shipped: ${orderID.slice(-6)}`,
- html: `
+const sendStoreOrderShippedUpdate = (recipientAddress, orderID, trackingURLs) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: DEFAULT_MAIL_FROM,
+ to: [recipientAddress],
+ subject: `LibreTexts Store Order Shipped: ${orderID.slice(-6)}`,
+ html: `
Hi,
We're writing to let you know that one or more items in your order have shipped! You can track these items using the link(s) below:
- ${trackingURLs.map(url => `${url}
`).join('')}
+ ${trackingURLs.map((url) => `${url}
`).join('')}
You can also view your order details and check its status at any time by using the link below:
@@ -968,15 +916,13 @@ const sendStoreOrderShippedUpdate = (recipientAddress, orderID, trackingURLs) =>
Order ID: ${orderID}
${autoGenNoticeHTML}
`,
- });
-}
+});
-const sendStoreOrderRejectedInternalNotification = (recipientAddresses, orderID, reason) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: DEFAULT_MAIL_FROM,
- to: recipientAddresses,
- subject: `[Action Required] LibreTexts Store Order Rejected: ${orderID.slice(-6)}`,
- html: `
+const sendStoreOrderRejectedInternalNotification = (recipientAddresses, orderID, reason) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: DEFAULT_MAIL_FROM,
+ to: recipientAddresses,
+ subject: `[Action Required] LibreTexts Store Order Rejected: ${orderID.slice(-6)}`,
+ html: `
A store order has been rejected for the following reason: ${reason}.
Please review it in the Control Panel for more details and resubmission.
@@ -986,16 +932,15 @@ const sendStoreOrderRejectedInternalNotification = (recipientAddresses, orderID,
Order ID: ${orderID}
${autoGenNoticeHTML}
`,
- });
-}
+});
const sendStoreOrderFailedInternalNotification = (recipientAddresses, orderID, error) => {
- const stringifiedError = typeof error === 'string' ? error : JSON.stringify(error);
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: DEFAULT_MAIL_FROM,
- to: recipientAddresses,
- subject: `[Action Required] LibreTexts Store Order Failed: ${orderID.slice(-6)}`,
- html: `
+ const stringifiedError = typeof error === 'string' ? error : JSON.stringify(error);
+ return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: DEFAULT_MAIL_FROM,
+ to: recipientAddresses,
+ subject: `[Action Required] LibreTexts Store Order Failed: ${orderID.slice(-6)}`,
+ html: `
A store order has failed with the following error: ${stringifiedError.substring(0, 100)}.
An error of this nature typically indicates an issue outside of the book itself and may require assistance from Engineering to resolve.
@@ -1006,15 +951,14 @@ const sendStoreOrderFailedInternalNotification = (recipientAddresses, orderID, e
Order ID: ${orderID}
${autoGenNoticeHTML}
`,
- });
-}
+ });
+};
-const sendZIPFileReadyNotification = (url, recipientAddress) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Support ',
- to: [recipientAddress],
- subject: 'ZIP File Ready',
- html: `
+const sendZIPFileReadyNotification = (url, recipientAddress) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Support ',
+ to: [recipientAddress],
+ subject: 'ZIP File Ready',
+ html: `
Hi,
The ZIP file you requested with project assets is ready. You can download it ${url} .
Please note that this link will expire in 1 week.
@@ -1022,28 +966,26 @@ const sendZIPFileReadyNotification = (url, recipientAddress) => {
The LibreTexts team
${autoGenNoticeHTML}
`,
- });
-}
+});
/**
* Sends a notification to the specified email addresses that a support ticket has been assigned to them.
* @param {string} ticketType
- * @param {string[]} recipientAddresses
- * @param {string} ticketID
- * @param {string} ticketTitle
- * @param {string} assignerName
- * @param {string} ticketAuthor
- * @param {string} ticketCategory
- * @param {string} ticketPriority
- * @param {string} ticketBody
- * @returns
+ * @param {string[]} recipientAddresses
+ * @param {string} ticketID
+ * @param {string} ticketTitle
+ * @param {string} assignerName
+ * @param {string} ticketAuthor
+ * @param {string} ticketCategory
+ * @param {string} ticketPriority
+ * @param {string} ticketBody
+ * @returns
*/
-const sendSupportTicketAssignedNotification = (ticketType, recipientAddresses, ticketID, ticketTitle, assignerName, ticketAuthor, ticketCategory, ticketPriority, ticketBody) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Support ',
- to: recipientAddresses,
- subject: `${ticketType} Assigned: ${truncateString(ticketTitle, 30)} (P: ${ticketPriority})`,
- html: `
+const sendSupportTicketAssignedNotification = (ticketType, recipientAddresses, ticketID, ticketTitle, assignerName, ticketAuthor, ticketCategory, ticketPriority, ticketBody) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Support ',
+ to: recipientAddresses,
+ subject: `${ticketType} Assigned: ${truncateString(ticketTitle, 30)} (P: ${ticketPriority})`,
+ html: `
Hi,
${assignerName} has assigned you to the following ${ticketType}:
@@ -1060,8 +1002,7 @@ const sendSupportTicketAssignedNotification = (ticketType, recipientAddresses, t
Ticket ID: ${ticketID}
${autoGenNoticeHTML}
`,
- });
-}
+});
/**
* Sends a notification to the specified email addresses that they have been invited to a project.
@@ -1072,14 +1013,13 @@ const sendSupportTicketAssignedNotification = (ticketType, recipientAddresses, t
* @param {string} inviteID
* @param {string} token
* @param {string} domain
- * @returns
+ * @returns
*/
-const sendProjectInvitation = (recipientAddresses, senderFirstName, senderLastName, projectTitle, inviteID, token, domain) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Support ',
- to: recipientAddresses,
- subject: "Invitation To Project",
- html: `
+const sendProjectInvitation = (recipientAddresses, senderFirstName, senderLastName, projectTitle, inviteID, token, domain) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Support ',
+ to: recipientAddresses,
+ subject: 'Invitation To Project',
+ html: `
Hi there,
${senderFirstName} ${senderLastName} has invited you to join ${projectTitle} in Conductor! If you were expecting this invitation, please click the link below to accept the invite.
@@ -1090,9 +1030,8 @@ const sendProjectInvitation = (recipientAddresses, senderFirstName, senderLastNa
Thanks,
The LibreTexts team
${autoGenNoticeHTML}
- `
- });
-}
+ `,
+});
/**
* Sends a notification to the specified email addresses that they've been added to receive updates on a support ticket.
@@ -1100,14 +1039,13 @@ const sendProjectInvitation = (recipientAddresses, senderFirstName, senderLastNa
* @param {string} ticketID - the ticket's uuid
* @param {string} ticketTitle - the ticket's title
* @param {string} accessKey - the ticket's guest access key
- * @returns
+ * @returns
*/
-const sendSupportTicketCCedNotification = (recipientAddress, ticketID, ticketTitle, accessKey) => {
- return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
- from: 'LibreTexts Support ',
- to: [recipientAddress],
- subject: `Added to Support Ticket: ${truncateString(ticketTitle, 30)}`,
- html: `
+const sendSupportTicketCCedNotification = (recipientAddress, ticketID, ticketTitle, accessKey) => mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: 'LibreTexts Support ',
+ to: [recipientAddress],
+ subject: `Added to Support Ticket: ${truncateString(ticketTitle, 30)}`,
+ html: `
Hi,
You've been added to the following support ticket:
@@ -1119,48 +1057,47 @@ const sendSupportTicketCCedNotification = (recipientAddress, ticketID, ticketTit
Ticket ID: ${ticketID}
${autoGenNoticeHTML}
- `
- });
-}
+ `,
+});
export default {
- sendPasswordReset,
- sendRegistrationConfirmation,
- sendPasswordChangeNotification,
- sendAddedAsMemberNotification,
- sendPublishingRequestedNotification,
- sendProjectFlaggedNotification,
- sendNewProjectMessagesNotification,
- sendProjectSupportRequest,
- sendProjectCompletedAlert,
- sendAssignedToTaskNotification,
- sendAccountRequestConfirmation,
- sendAccountRequestAdminNotif,
- sendAccountRequestApprovalNotification,
- sendPeerReviewInvitation,
- sendPeerReviewNotification,
- sendAutogeneratedProjectsNotification,
- sendAlertActivatedNotification,
- sendAnalyticsAccessRequestCreated,
- sendAnalyticsAccessRequestApproved,
- sendAnalyticsAccessRequestDenied,
- sendAnalyticsInvite,
- sendAnalyticsInviteAccepted,
- sendOrgEventRegistrationConfirmation,
- sendBatchBookAIMetadataFinished,
- sendBatchBookUpdateFinished,
- sendSupportTicketCreateConfirmation,
- sendSupportTicketCreateInternalNotification,
- sendNewTicketMessageNotification,
- sendNewInternalTicketMessageAssignedStaffNotification,
- sendSupportTicketAssignedNotification,
- sendSupportTicketAutoCloseWarning,
- sendStoreOrderConfirmation,
- sendStoreOrderInProductionUpdate,
- sendStoreOrderShippedUpdate,
- sendStoreOrderRejectedInternalNotification,
- sendStoreOrderFailedInternalNotification,
- sendZIPFileReadyNotification,
- sendProjectInvitation,
- sendSupportTicketCCedNotification
-}
+ sendPasswordReset,
+ sendRegistrationConfirmation,
+ sendPasswordChangeNotification,
+ sendAddedAsMemberNotification,
+ sendPublishingRequestedNotification,
+ sendProjectFlaggedNotification,
+ sendNewProjectMessagesNotification,
+ sendProjectSupportRequest,
+ sendProjectCompletedAlert,
+ sendAssignedToTaskNotification,
+ sendAccountRequestConfirmation,
+ sendAccountRequestAdminNotif,
+ sendAccountRequestApprovalNotification,
+ sendPeerReviewInvitation,
+ sendPeerReviewNotification,
+ sendAutogeneratedProjectsNotification,
+ sendAlertActivatedNotification,
+ sendAnalyticsAccessRequestCreated,
+ sendAnalyticsAccessRequestApproved,
+ sendAnalyticsAccessRequestDenied,
+ sendAnalyticsInvite,
+ sendAnalyticsInviteAccepted,
+ sendOrgEventRegistrationConfirmation,
+ sendBatchBookAIMetadataFinished,
+ sendBatchBookUpdateFinished,
+ sendSupportTicketCreateConfirmation,
+ sendSupportTicketCreateInternalNotification,
+ sendNewTicketMessageNotification,
+ sendNewInternalTicketMessageAssignedStaffNotification,
+ sendSupportTicketAssignedNotification,
+ sendSupportTicketAutoCloseWarning,
+ sendStoreOrderConfirmation,
+ sendStoreOrderInProductionUpdate,
+ sendStoreOrderShippedUpdate,
+ sendStoreOrderRejectedInternalNotification,
+ sendStoreOrderFailedInternalNotification,
+ sendZIPFileReadyNotification,
+ sendProjectInvitation,
+ sendSupportTicketCCedNotification,
+};
diff --git a/server/api/oauth.js b/server/api/oauth.js
index 58264a70..920305d6 100644
--- a/server/api/oauth.js
+++ b/server/api/oauth.js
@@ -9,7 +9,7 @@ import bcrypt from 'bcryptjs';
import OAuth2Server, {
OAuthError,
Request,
- Response
+ Response,
} from '@node-oauth/oauth2-server';
import AccessToken from '../models/accesstoken.js';
import AuthCode from '../models/authcode.js';
@@ -79,7 +79,7 @@ async function saveToken(token, client, user) {
refresh_expires_in: Math.floor((newRefresh.expiresAt - new Date()) / 1000), // custom attribute
client: { id: newToken.clientID },
user: { id: newToken.user },
- }
+ };
}
/**
@@ -118,14 +118,14 @@ async function saveAuthorizationCode(code, client, user) {
isNewAuth: newAuthCode.isNewAuth,
scopesUpdatedSinceAuth: user.scopesUpdatedSinceAuth,
},
- }
+ };
}
/**
* Retrieves an existing AccessToken from the database.
*
* @param {String} accessToken - Access token value to lookup.
- * @returns {Promise} Information about the found token.
+ * @returns {Promise} Information about the found token.
*/
async function getAccessToken(accessToken) {
const foundToken = await AccessToken.findOne({ token: accessToken }).lean();
@@ -138,7 +138,7 @@ async function getAccessToken(accessToken) {
scope: foundToken.scope,
client: { id: foundToken.clientID },
user: { id: foundToken.user },
- }
+ };
}
/**
@@ -163,14 +163,14 @@ async function getAuthorizationCode(authorizationCode) {
id: foundCode.user,
isNewAuth: foundCode.isNewAuth,
},
- }
+ };
}
/**
* Retreives an API Client from the database and verifies the provided secret, if necessary.
*
* @param {string} clientID - Identifier of the client to lookup.
- * @param {string} [clientSecret] - Client secret, if required by the grant type.
+ * @param {string} [clientSecret] - Client secret, if required by the grant type.
* @returns {Promise} Information about the client, or false if no match found.
*/
async function getClient(clientID, clientSecret) {
@@ -203,7 +203,7 @@ async function getClient(clientID, clientSecret) {
accessTokenLifetime: hasCustomAccessLifetime ? foundClient.accessTokenLifetime : ACCESS_TOKEN_LIFETIME,
refreshTokenLifetime: hasCustomRefreshLifetime ? foundClient.refreshTokenLifetime : REFRESH_TOKEN_LIFETIME,
scopesLastUpdated: foundClient.scopesLastUpdated,
- }
+ };
}
/**
@@ -223,7 +223,7 @@ async function getRefreshToken(refreshToken) {
scope: foundRefresh.scope,
client: { id: foundRefresh.clientID },
user: { id: foundRefresh.user },
- }
+ };
}
/**
@@ -291,7 +291,7 @@ class ConductorOAuthServer {
/**
* Authenticates a request that uses credentials obtained from an OAuth2 flow
- *
+ *
* @returns {function} An Express-type middleware function.
*/
async authenticate(req, res, next) {
@@ -330,7 +330,7 @@ class ConductorOAuthServer {
const response = new Response(res);
const code = await serverScope.server.authorize(request, response, {
authenticateHandler: {
- handle: async function (authReq) {
+ async handle(authReq) {
const id = authReq.user.decoded.uuid;
const authorizedApps = await users.getUserAuthorizedApplications(id);
const clientID = authReq.body.client_id || authReq.query.client_id;
@@ -352,7 +352,7 @@ class ConductorOAuthServer {
authorizedApps,
isNewAuth,
scopesUpdatedSinceAuth,
- }
+ };
},
},
});
@@ -361,7 +361,7 @@ class ConductorOAuthServer {
} catch (e) {
return serverScope.handleOAuthError(res, e);
}
- }
+ };
}
/**
@@ -382,7 +382,7 @@ class ConductorOAuthServer {
} catch (e) {
return serverScope.handleOAuthError(res, e);
}
- }
+ };
}
/**
@@ -390,7 +390,7 @@ class ConductorOAuthServer {
* then closing the request/response pipeline.
*
* @param {express.Response} res - The initial HTTP response object.
- * @param {OAuthError} e - The flow error to process.
+ * @param {OAuthError} e - The flow error to process.
* @returns {express.Response} A consumed HTTP response object, with error handled.
*/
handleOAuthError(res, e) {
@@ -440,14 +440,14 @@ class ConductorOAuthServer {
/**
* Handles OAuth2 flow success by setting appropriate response headers and closing the pipeline.
*
- * @param {express.Response} res - The initial HTTP response object.
+ * @param {express.Response} res - The initial HTTP response object.
* @param {Response} response - The OAuth2 server generated response.
* @returns {express.Response} A consumed HTTP response object, with necessary
* headers and redirects added.
*/
handleOAuthResponse(res, response) {
if (response.status === 302) {
- const location = response.headers.location;
+ const { location } = response.headers;
delete response.headers.location;
res.set(response.headers);
return res.redirect(location);
diff --git a/server/api/remixer.ts b/server/api/remixer.ts
new file mode 100644
index 00000000..208d2c6e
--- /dev/null
+++ b/server/api/remixer.ts
@@ -0,0 +1,484 @@
+import type { Request, Response } from "express";
+import Project from "../models/project.js";
+import PrejectRemixer from "../models/projectremixer.js";
+import { authKeys } from "./services/authkey.js";
+import remixerService from "./services/remixer-service.js";
+import PrejectRemixerJob from "../models/projectremixerjob.js";
+import base62 from "base62-random";
+import CXOnePageAPIEndpoints from "../util/CXOne/CXOnePageAPIEndpoints.js";
+import {
+ extractPagePath,
+ getUserWorkbenchProjects,
+} from "../util/remixerutils.js";
+import { createHmac } from "crypto";
+import { generateAPIRequestHeaders } from "../util/librariesclient.js";
+
+class FetchPageError extends Error {
+ statusCode: number;
+
+ constructor(message: string, statusCode = 500) {
+ super(message);
+ this.name = "FetchPageError";
+ this.statusCode = statusCode;
+ }
+}
+
+const getFetchPageErrorMessage = (error: unknown): string => {
+ if (error instanceof Error && error.message) {
+ return error.message;
+ }
+ return "Failed to fetch remixer page.";
+};
+
+const normalizeUpstreamErrorMessage = (message: string): string => {
+ const trimmedMessage = message.trim();
+ if (!trimmedMessage) return "";
+
+ // LibreTexts can sometimes return full HTML for errors.
+ if (
+ trimmedMessage.startsWith(" {
+ const accessCookie = req.cookies?.conductor_access_v2;
+ const signedCookie = req.cookies?.conductor_signed_v2;
+ const cookieParts = [
+ accessCookie
+ ? `conductor_access_v2=${encodeURIComponent(accessCookie)}`
+ : "",
+ signedCookie
+ ? `conductor_signed_v2=${encodeURIComponent(signedCookie)}`
+ : "",
+ ].filter(Boolean);
+ if (cookieParts.length === 0) return undefined;
+ return cookieParts.join("; ");
+};
+
+const getRemixerProject = async (req: Request, res: Response) => {
+ const { id } = req.params;
+ // Only select specific fields for remixer: libreCoverID, libreLibrary, projectID, and title
+ const projection = {
+ libreCoverID: 1,
+ libreLibrary: 1,
+ projectID: 1,
+ title: 1,
+ _id: 0,
+ };
+ const project = await Project.findOne({ projectID: id }, projection);
+ if (!project) {
+ return res.status(404).send({
+ err: true,
+ errMsg: "Project not found",
+ });
+ }
+ res.send({
+ err: false,
+ project: project,
+ });
+};
+
+const saveRemixerProjectState = async (req: Request, res: Response) => {
+ const { id } = req.params;
+ const { currentBook, autoNumbering, copyModeState, pathLevelFormats } =
+ req.body as {
+ currentBook: unknown[];
+ autoNumbering?: boolean;
+ copyModeState?: string;
+ pathLevelFormats?: unknown[];
+ };
+ const actorUUID = (req as any).user?.decoded?.uuid ?? "";
+
+ const project = await Project.findOne(
+ { projectID: id },
+ { projectID: 1, _id: 0 },
+ );
+
+ if (!project) {
+ return res.status(404).send({
+ err: true,
+ errMsg: "Project not found",
+ });
+ }
+ // Check for an existing pending or running remixer job before allowing state save
+ const existingJob = await PrejectRemixerJob.findOne({
+ projectID: id,
+ status: { $in: ["pending", "running"] },
+ });
+ if (existingJob) {
+ return res.status(400).send({
+ err: true,
+ errMsg: "A remixer job is already pending or running for this project.",
+ });
+ }
+
+ const remixerState = await PrejectRemixer.findOneAndUpdate(
+ { projectID: id, archived: false },
+ {
+ $set: {
+ remixerCurrentBook: currentBook,
+ ...(autoNumbering !== undefined && { autoNumbering }),
+ ...(copyModeState !== undefined && { copyModeState }),
+ ...(pathLevelFormats !== undefined && { pathLevelFormats }),
+ updatedBy: actorUUID,
+ },
+ $setOnInsert: {
+ remixerID: base62(10),
+ createdBy: actorUUID,
+ archived: false,
+ projectID: project.projectID,
+ },
+ },
+ {
+ new: true,
+ upsert: true,
+ projection: {
+ projectID: 1,
+ remixerCurrentBook: 1,
+ remixerID: 1,
+ autoNumbering: 1,
+ copyModeState: 1,
+ pathLevelFormats: 1,
+ _id: 0,
+ },
+ },
+ );
+
+ return res.send({
+ err: false,
+ projectID: project.projectID,
+ currentBook: remixerState?.remixerCurrentBook ?? [],
+ autoNumbering: remixerState?.autoNumbering,
+ copyModeState: remixerState?.copyModeState,
+ pathLevelFormats: remixerState?.pathLevelFormats ?? [],
+ });
+};
+const publishRemixerProject = async (req: Request, res: Response) => {
+ const { id } = req.params;
+ const { currentBook, autoNumbering, copyModeState, pathLevelFormats } =
+ req.body as {
+ currentBook: unknown[];
+ autoNumbering?: boolean;
+ copyModeState?: string;
+ pathLevelFormats?: unknown[];
+ };
+ const actorUUID = (req as any).user?.decoded?.uuid ?? "";
+ const existingJob = await PrejectRemixerJob.findOne({
+ projectID: id,
+ status: { $in: ["pending", "running"] },
+ });
+ if (existingJob) {
+ return res.status(400).send({
+ err: true,
+ errMsg: "A remixer job is already pending or running for this project.",
+ });
+ }
+
+ const project = await Project.findOne(
+ { projectID: id },
+ { projectID: 1, libreLibrary: 1, libreCoverID: 1, _id: 0 },
+ );
+
+ if (!project) {
+ return res.status(404).send({
+ err: true,
+ errMsg: "Project not found",
+ });
+ }
+ const subdomain = project.libreLibrary;
+ if (!subdomain) {
+ return res.status(400).send({
+ err: true,
+ errMsg: "Project libreLibrary is missing",
+ });
+ }
+
+ const remixerState = await PrejectRemixer.findOneAndUpdate(
+ { projectID: id, archived: false },
+ {
+ $set: {
+ remixerCurrentBook: currentBook,
+ ...(autoNumbering !== undefined && { autoNumbering }),
+ ...(copyModeState !== undefined && { copyModeState }),
+ ...(pathLevelFormats !== undefined && { pathLevelFormats }),
+ updatedBy: actorUUID,
+ archived: true,
+ updatedAt: new Date(),
+ },
+ $setOnInsert: {
+ createdBy: actorUUID,
+ remixerID: base62(10),
+ createdAt: new Date(),
+ projectID: project.projectID,
+ },
+ },
+ {
+ new: true,
+ upsert: true,
+ projection: { projectID: 1, remixerCurrentBook: 1, remixerID: 1, _id: 0 },
+ },
+ );
+ const job = await PrejectRemixerJob.create({
+ jobID: base62(10),
+ projectID: id,
+ userID: actorUUID,
+ remixerID: remixerState?.remixerID ?? "",
+ status: "pending",
+ messages: ["Remixer job created."],
+ });
+ const bookAPIURL: string = `https://${subdomain}.libretexts.org/@api/deki/pages/${project.libreCoverID ?? "home"}/${CXOnePageAPIEndpoints.GET_Page_Info}`;
+ const bookDetailsResponse = await fetch(bookAPIURL, {
+ headers: {
+ "X-Requested-With": "XMLHttpRequest",
+ "x-deki-token": authKeys[subdomain as keyof typeof authKeys],
+ },
+ });
+ const bookDetails = await bookDetailsResponse.json();
+
+ let bookURL = bookDetails["uri.ui"];
+ bookURL = bookURL.replace(/\/+$/, "");
+ if (!bookURL) {
+ return res.status(400).send({
+ err: true,
+ errMsg: "Book URL not found",
+ });
+ }
+
+ const bookPath = extractPagePath(bookURL);
+
+ remixerService
+ .runRemixerJob({
+ jobID: job.jobID,
+ projectID: id,
+ bookURL: bookPath,
+ subdomain,
+ })
+ .catch((error: unknown) => {
+ console.error("Failed to run remixer job", error);
+ });
+
+ return res.send({
+ err: false,
+ projectID: project.projectID,
+ currentBook: remixerState?.remixerCurrentBook ?? [],
+ });
+};
+
+const getRemixerJobStatus = async (req: Request, res: Response) => {
+ const { id } = req.params;
+ const job = await PrejectRemixerJob.findOne(
+ { projectID: id },
+ { status: 1, messages: 1, errorMessage: 1, _id: 0 },
+ ).sort({ _id: -1 });
+ return res.send({
+ err: false,
+ job: job,
+ });
+};
+
+const getRemixerProjectState = async (req: Request, res: Response) => {
+ const { id } = req.params;
+ const project = await Project.findOne(
+ { projectID: id },
+ {
+ projectID: 1,
+ _id: 0,
+ },
+ );
+
+ if (!project) {
+ return res.status(404).send({
+ err: true,
+ errMsg: "Project not found",
+ });
+ }
+
+ const remixerState = await PrejectRemixer.findOne(
+ { projectID: id },
+ {
+ projectID: 1,
+ archived: 1,
+ remixerCurrentBook: 1,
+ autoNumbering: 1,
+ copyModeState: 1,
+ pathLevelFormats: 1,
+ _id: 0,
+ },
+ )
+ .sort({ updatedAt: -1 })
+ .exec();
+
+ return res.send({
+ err: false,
+ projectID: project.projectID,
+ currentBook: remixerState?.archived
+ ? []
+ : (remixerState?.remixerCurrentBook ?? []),
+ autoNumbering: remixerState?.autoNumbering,
+ copyModeState: remixerState?.copyModeState,
+ pathLevelFormats: remixerState?.pathLevelFormats ?? [],
+ });
+};
+
+const deleteRemixerProjectState = async (req: Request, res: Response) => {
+ const { id } = req.params;
+ const project = await Project.findOne(
+ { projectID: id },
+ { projectID: 1, _id: 0 },
+ );
+
+ if (!project) {
+ return res.status(404).send({
+ err: true,
+ errMsg: "Project not found",
+ });
+ }
+
+ await PrejectRemixer.deleteOne({ projectID: id });
+
+ return res.send({
+ err: false,
+ projectID: project.projectID,
+ currentBook: [],
+ });
+};
+
+// Zod validator ensures body/params shape; use it as the request body type.
+const fetchPage = async (req: Request, res: Response) => {
+ try {
+ const pageDetailsApi = CXOnePageAPIEndpoints.DREAM_OUT_FORMAT_LIMIT(1000);
+ const subpageApi = CXOnePageAPIEndpoints.GET_Subpages;
+ const { subdomain, path, option, pageDetails, currentbook } = req.body;
+ const {
+ includeMatter = false,
+ linkTitle = false,
+ full = false,
+ } = option ?? {};
+
+ const numericPath = Number(path);
+ const isNumber = !isNaN(numericPath);
+ let normalizedPath = path;
+ if (isNumber && numericPath <= 0) {
+ normalizedPath = "home";
+ }
+
+ if (!normalizedPath.endsWith("/")) {
+ normalizedPath += "/";
+ }
+
+ const isHomePath = String(normalizedPath).toLowerCase() === "home";
+ const pathPrefix = isNumber || isHomePath ? "" : "=";
+
+ const url = `https://${subdomain}.libretexts.org/@api/deki/pages/${
+ pathPrefix
+ }${normalizedPath}${pageDetails ? pageDetailsApi : subpageApi}`;
+ const conductorCookieHeader = buildConductorCookieHeader(req);
+
+ const options = {
+ headers: {
+ ...((await generateAPIRequestHeaders(subdomain)) ?? {}),
+ ...(conductorCookieHeader ? { Cookie: conductorCookieHeader } : {}),
+ },
+ };
+ const response = await fetch(url, options);
+ if (response.status !== 200) {
+ const errorBody = await response.text();
+ let upstreamMessage = "";
+
+ try {
+ const parsedBody = JSON.parse(errorBody) as {
+ message?: string;
+ error?: string;
+ };
+ upstreamMessage = parsedBody.message ?? parsedBody.error ?? "";
+ } catch {
+ upstreamMessage = errorBody;
+ }
+
+ const statusMessage = response.statusText || "Request failed";
+ const cleanUpstreamMessage =
+ normalizeUpstreamErrorMessage(upstreamMessage);
+ const messageSuffix = cleanUpstreamMessage
+ ? `: ${cleanUpstreamMessage}`
+ : "";
+ throw new FetchPageError(
+ `Failed to fetch remixer page (${response.status} ${statusMessage})${messageSuffix}`,
+ response.status,
+ );
+ }
+ const text = await response.text();
+ if (pageDetails) {
+ const responseData = JSON.parse(text);
+
+ const remixerPageDetails = remixerService.mapToRemixerPageDetailsResponse(
+ responseData,
+ currentbook,
+ );
+ return res.send({
+ err: false,
+ response: remixerPageDetails,
+ });
+ }
+ let parentID: string | undefined = isNumber ? path : undefined;
+
+ if (!parentID) {
+ const detailsUrl = `https://${subdomain}.libretexts.org/@api/deki/pages/${
+ pathPrefix
+ }${normalizedPath}${pageDetailsApi}`;
+ const detailsRes = await fetch(detailsUrl, options);
+ if (detailsRes.ok) {
+ const detailsData = (await detailsRes.json()) as Record<
+ string,
+ unknown
+ >;
+ parentID = String(detailsData["@id"] ?? "");
+ }
+ }
+
+ let responseData = remixerService.mapToRemixerSubPageResponse(
+ JSON.parse(text),
+ parentID,
+ );
+ console.log(responseData.length);
+
+ const userId = (req as any).user?.decoded?.uuid as string | undefined;
+ const isWorkbenchRoot =
+ String(path).toLowerCase() === "home" || (isNumber && numericPath <= 0);
+
+ if (path.toLowerCase().includes("workbench") && userId) {
+ const allowedCoverIDs = await getUserWorkbenchProjects(subdomain, userId);
+ if (allowedCoverIDs.length > 0) {
+ const allowedSet = new Set(allowedCoverIDs);
+ responseData = responseData.filter((page) =>
+ allowedSet.has(page["@id"]),
+ );
+ }
+ }
+
+ return res.send({
+ err: false,
+ response: responseData,
+ });
+ } catch (error) {
+ const statusCode = error instanceof FetchPageError ? error.statusCode : 500;
+ return res.status(statusCode).send({
+ err: true,
+ errMsg: getFetchPageErrorMessage(error),
+ });
+ }
+};
+
+export default {
+ getRemixerProject,
+ saveRemixerProjectState,
+ publishRemixerProject,
+ getRemixerJobStatus,
+ getRemixerProjectState,
+ deleteRemixerProjectState,
+ fetchPage,
+};
diff --git a/server/api/services/authkey.ts b/server/api/services/authkey.ts
new file mode 100644
index 00000000..cc8e2cdd
--- /dev/null
+++ b/server/api/services/authkey.ts
@@ -0,0 +1,19 @@
+export const authKeys = {
+ "bio": "b344357b05fc8a22c77e34a9ff866009fac7481b9f90cd8a006565866e7083a5",
+ "biz": "b6369fbdf9ac07da7df8d92ee1a0b66b0c877b1e817619f6019f4b16fe91c095",
+ "chem": "9d5efbaecd87ffd908e405cc527f3c4637717aea8ca124f9072e2b74229b7fb9",
+ "dev": "0c0129046d58d30c12ced6fd19b5e130135a93c4a9ad8d8f071c21b338b95f38",
+ "eng": "83eea62eaa3fd981b8390811e5299bb57d0a02094758552fee8b004926131840",
+ "espanol": "4b15ba81724cdd7cad2115c4f5a4bb45312820695ee688bb5ca99d3e28b66f9c",
+ "geo": "c760e2f8305fac5587ad103e39cf84dad1400ebb3fc94f7c3ab190bb6473ac36",
+ "human": "17b722ac184ed50c138ee129dc11fd4a5cb5b7bb27aab72df1549350781cfaae",
+ "k12": "d2e44cb60fe309b369e61517f8a96048ce2b8e95dc0ffa6c0e36225e17ea0360",
+ "math": "ebe992d2e4533d23df1460eeb61cbc4b16c523e5afc2c94cb3bc4e5cc5528d52",
+ "med": "1e79b53fd40cc42d88b1c8f67e8a137ee22ec14d776c0b9ca9fbee0e2638ff25",
+ "phys": "b4a23d453fd057cfd10ee71877df0d2ec048dbf3595dd336e83294cd2097537b",
+ "socialsci": "5e7c3deb533bf8e237247ee6c735ff87a3eaf202ba1d4ed28e6846a6e2c2096e",
+ "stats": "d811b813b7dd4aff5bc4c067c991e8720ce958c479275063c9910749b4ad10c5",
+ "ukrayinska": "c691f14b81c249118e2fa1f30b1acc7a9f48c65c68c715a0fa378a89b61274c4",
+ "workforce": "c6c6fb113396ff595b824383e3718e6e4618628fd45618ad70e7655094fc5489",
+ "query": "a448c425eb772bcb52c076f975c34d1c8daade15cb76de17d1d90f87f1f44471"
+ }
\ No newline at end of file
diff --git a/server/api/services/remixer-service.ts b/server/api/services/remixer-service.ts
new file mode 100644
index 00000000..dd5abfb2
--- /dev/null
+++ b/server/api/services/remixer-service.ts
@@ -0,0 +1,862 @@
+import base62 from "base62-random";
+import PrejectRemixerJob from "../../models/projectremixerjob";
+import PrejectRemixer, {
+ RemixerSubPageState,
+} from "../../models/projectremixer";
+import RemixerTemplates from "../../util/CXOne/CXOneRemixerTemplates";
+import CXOnePageAPIEndpoints from "../../util/CXOne/CXOnePageAPIEndpoints";
+import {
+ addPageProperty,
+ CXOneFetch,
+ generateAPIRequestHeaders,
+ getPage,
+} from "../../util/librariesclient";
+import MindTouch from "../../util/CXOne/index.js";
+import {
+ extractLibretextsSubdomain,
+ extractPagePath,
+ slugifyNode,
+} from "../../util/remixerutils";
+
+export interface RemixerSubPage {
+ "@id": string;
+ "@title": string;
+ "@href": string;
+ "@subpages": boolean;
+ article: string;
+ namespace: string;
+ title: string;
+ "uri.ui": string;
+ parentID?: string;
+ formattedPath?: string;
+}
+
+export type RemixerCopyMode = "Transclude" | "Fork" | "Full";
+
+const normalizeRemixerCopyMode = (
+ copyModeState: string | undefined,
+): RemixerCopyMode => {
+ if (copyModeState === "Fork" || copyModeState === "Full") {
+ return copyModeState;
+ }
+ return "Transclude";
+};
+
+const mapToRemixerSubPagesResponse = (
+ response: any,
+ parentID?: string,
+): RemixerSubPage[] => {
+ const rawSubpages = response?.["page.subpage"];
+ const subpages = Array.isArray(rawSubpages)
+ ? rawSubpages
+ : rawSubpages
+ ? [rawSubpages]
+ : [];
+
+ return subpages.map((subpage: any) => ({
+ "@id": subpage["@id"],
+ "@title": subpage["@title"],
+ "@href": subpage["@href"],
+ "@subpages": String(subpage["@subpages"]) === "true",
+ article: subpage["article"],
+ namespace: subpage["namespace"],
+ title: subpage["title"],
+ "uri.ui": subpage["uri.ui"],
+ parentID,
+ }));
+};
+
+const mapToRemixerPageDetailsResponse = (
+ response: any,
+ currentbook: boolean = true,
+ parentID: string = "-1",
+): RemixerSubPage | RemixerSubPage[] => {
+ let resolvedParentID =
+ parentID ??
+ response?.["page.parent.@id"] ??
+ response?.["page.parent"]?.["@id"] ??
+ response?.page?.parent?.["@id"] ??
+ response?.parent?.["@id"] ??
+ "-1";
+
+ if(!currentbook){
+ resolvedParentID =
+ response?.["page.parent.@id"] ??
+ response?.["page.parent"]?.["@id"] ??
+ response?.page?.parent?.["@id"] ??
+ response?.parent?.["@id"] ??
+ parentID ??
+ "-1";
+ }
+ return {
+ "@id": response["@id"],
+ "@title": response["title"],
+ "@href": response["uri.ui"],
+ "@subpages": response["subpages"]?.length > 0,
+ article: response["article"],
+ namespace: response["namespace"],
+ title: response["title"],
+ "uri.ui": response["uri.ui"],
+ parentID: resolvedParentID,
+ };
+};
+
+const stripLeadingNumbering = (value: string): string =>
+ value.replace(/^\s*\d+(?:\.\d+)*\s*[:.\-]\s*/, "").trim();
+
+/**
+ * Thrown when a MindTouch request fails for reasons that are likely temporary
+ * (timeouts, gateway errors, rate-limiting, transient network failures).
+ * Callers can retry the same operation safely from the user's perspective.
+ */
+class TransientMindTouchError extends Error {
+ constructor(message: string, public cause?: unknown) {
+ super(message);
+ this.name = "TransientMindTouchError";
+ }
+}
+
+const isTransientStatus = (status: number): boolean =>
+ status === 408 || status === 425 || status === 429 || status >= 500;
+
+/** Throws an appropriate error for a non-OK MindTouch response. */
+const throwForMindTouchResponse = (
+ response: Response,
+ prefix: string,
+): never => {
+ const message = `${prefix}: ${response.status} ${response.statusText}`;
+ if (isTransientStatus(response.status)) {
+ throw new TransientMindTouchError(message);
+ }
+ throw new Error(message);
+};
+
+const TRANSIENT_ERROR_PATTERNS = [
+ "timeout",
+ "timed out",
+ "etimedout",
+ "econnreset",
+ "econnrefused",
+ "econnaborted",
+ "enotfound",
+ "socket hang up",
+ "network",
+ "fetch failed",
+];
+
+const isTransientError = (error: unknown): boolean => {
+ if (error instanceof TransientMindTouchError) return true;
+ // fetch throws TypeError for network-level failures in Node/undici.
+ if (error instanceof TypeError) return true;
+ if (error instanceof Error) {
+ const msg = error.message.toLowerCase();
+ return TRANSIENT_ERROR_PATTERNS.some((pattern) => msg.includes(pattern));
+ }
+ return false;
+};
+
+const sleep = (ms: number) =>
+ new Promise((resolve) => setTimeout(resolve, ms));
+
+/**
+ * Runs `fn` and retries on transient MindTouch failures using exponential
+ * backoff. Non-transient errors propagate immediately.
+ */
+const withRetryOnTransient = async (
+ fn: () => Promise,
+ {
+ attempts = 3,
+ baseDelayMs = 1000,
+ maxDelayMs = 8000,
+ onRetry,
+ }: {
+ attempts?: number;
+ baseDelayMs?: number;
+ maxDelayMs?: number;
+ onRetry?: (attempt: number, error: unknown) => void | Promise;
+ } = {},
+): Promise => {
+ let lastError: unknown;
+ for (let attempt = 1; attempt <= attempts; attempt++) {
+ try {
+ return await fn();
+ } catch (error) {
+ lastError = error;
+ if (attempt >= attempts || !isTransientError(error)) {
+ throw error;
+ }
+ if (onRetry) {
+ await onRetry(attempt, error);
+ }
+ const delay = Math.min(
+ maxDelayMs,
+ baseDelayMs * Math.pow(2, attempt - 1),
+ );
+ await sleep(delay);
+ }
+ }
+ throw lastError;
+};
+
+/** CXOne / flat JSON use `"uri.ui"`; Mongoose maps that schema path to nested `uri.ui`. */
+const getRemixerPageUriUi = (page: RemixerSubPageState): string => {
+ const rec = page as unknown as Record;
+ const flat = rec["uri.ui"];
+ if (typeof flat === "string" && flat.length > 0) return flat;
+ const uri = rec.uri as Record | undefined;
+ if (uri && typeof uri.ui === "string") return uri.ui as string;
+ return page["@href"] ?? "";
+};
+
+const setRemixerPageUriUi = (page: RemixerSubPageState, uri: string) => {
+ const rec = page as unknown as Record;
+ rec["uri.ui"] = uri;
+ if (typeof rec.uri === "object" && rec.uri !== null) {
+ (rec.uri as Record).ui = uri;
+ } else {
+ rec.uri = { ui: uri };
+ }
+};
+
+const isMatterNode = (page: {
+ "@title": string;
+ title: string;
+ "uri.ui": string;
+ "@href": string;
+}): boolean => {
+ const normalized = stripLeadingNumbering(
+ page["@title"] || page.title || "",
+ ).toLowerCase();
+ if (normalized === "front matter" || normalized === "back matter")
+ return true;
+ const uri = getRemixerPageUriUi(page as RemixerSubPageState).toLowerCase();
+ return uri.includes("front_matter") || uri.includes("back_matter");
+};
+
+const getDisplayTitle = (
+ page: RemixerSubPageState,
+ inMatterBranch: boolean,
+ inDeletedBranch: boolean,
+ autoNumbering: boolean,
+): string => {
+ const rawTitle = page["@title"] || page.title || "Untitled";
+ const cleanTitle = stripLeadingNumbering(rawTitle);
+
+ if (!autoNumbering || inDeletedBranch || inMatterBranch) return cleanTitle;
+
+ // Empty pathNumber means this is the book root — no numbering
+ const numberPath = page.pathNumber ?? [];
+ if (numberPath.length === 0) return cleanTitle;
+
+ // formattedPath is pre-computed by buildBookPaths (already handles formattedPathOverride)
+ const formattedPath = page.formattedPath?.trim() ?? "";
+ return formattedPath ? `${formattedPath}: ${cleanTitle}` : cleanTitle;
+};
+
+type PageStatus = "unchaned" | "modeified" | "new" | "imported" | "deleted";
+
+const getPageStatus = (page: RemixerSubPageState): PageStatus => {
+ if (page.isDeleted) return "deleted";
+ if (page.addedItem && !page.isDeleted && page["@id"].startsWith("new-"))
+ return "new";
+
+ if (page.isImported || page.addedItem) return "imported";
+ if (
+ page.movedItem ||
+ page.isPlacementChanged ||
+ page.movedItem ||
+ page.renamedItem
+ )
+ return "modeified";
+
+ return "unchaned";
+};
+
+const applyDefaultRemixerPageProperties = async (
+ subdomain: string,
+ pageID: string,
+): Promise => {
+ await addPageProperty(subdomain, pageID, "GuideTabs", "");
+ await addPageProperty(subdomain, pageID, "GuideDisplay", "single", "PUT");
+ await addPageProperty(subdomain, pageID, "SubPageListing", "simple");
+ await addPageProperty(
+ subdomain,
+ pageID,
+ "GuideTabs",
+ MindTouch.Templates.PROP_GuideTabs,
+ "PUT",
+ );
+ await addPageProperty(subdomain, pageID, "WelcomeHidden", true);
+ await addPageProperty(subdomain, pageID, "ArticleType", "Topic");
+};
+
+const handleNewPage = async (
+ page: RemixerSubPageState,
+ parent: RemixerSubPageState,
+ title: string,
+ subdomain: string,
+): Promise<{ pageID: string; pageURI: string }> => {
+ const content =
+ page["@subpages"] === true
+ ? RemixerTemplates.POST_CreateBlankTopicGuide
+ : RemixerTemplates.POST_CreateBlankPage("topic");
+
+ const numberedPath = page.pathNumber?.join("_") ?? "";
+ // TODO: POST new page to bookURL using title, numberedPath, content
+ const slug = slugifyNode(`${numberedPath}: ${title}`);
+
+ const parentUri = getRemixerPageUriUi(parent);
+ const pagePath = encodeURIComponent(`${extractPagePath(parentUri)}/${slug}`);
+ const url = `https://${subdomain}.libretexts.org/@api/deki/pages/=${encodeURIComponent(pagePath)}/${CXOnePageAPIEndpoints.POST_Contents_Title(title)}`;
+ const dekiHeaders = await generateAPIRequestHeaders(subdomain);
+ if (!dekiHeaders) {
+ throw new Error(
+ "Error generating library API headers for remixer request.",
+ );
+ }
+ const response = await fetch(url, {
+ method: "POST",
+ body: content,
+ headers: {
+ "Content-Type": "text/plain",
+ ...dekiHeaders,
+ },
+ });
+
+ if (!response.ok) {
+ throwForMindTouchResponse(response, `Error creating page "${title}"`);
+ }
+ const createdPage = await getPage(pagePath, subdomain);
+ const pageID = createdPage?.["@id"]?.toString();
+ const pageURI = (
+ createdPage?.["uri.ui"] ||
+ createdPage?.["@href"] ||
+ ""
+ ).toString();
+ if (!pageID) {
+ throw new Error(`Error locating CXOne page ID for "${pagePath}"`);
+ }
+
+ await applyDefaultRemixerPageProperties(subdomain, pageID);
+
+ return { pageID, pageURI };
+};
+
+/** Path segment for move `to` / rename `name` (LibreTexts-style padded slug). */
+const remixerPagePaddedSlug = (
+ page: RemixerSubPageState,
+ displayTitle: string,
+): string => {
+ const numberedPath = page.pathNumber?.join("_") ?? "";
+ return slugifyNode(`${numberedPath}: ${displayTitle}`);
+};
+
+const handleDeletedPage = async (
+ page: RemixerSubPageState,
+ subdomain: string,
+): Promise => {
+ const pageId = page["@id"];
+ if (!pageId || pageId.startsWith("new-")) return;
+
+ const pid = parseInt(pageId, 10);
+ if (Number.isNaN(pid)) return;
+
+ const dekiHeaders = await generateAPIRequestHeaders(subdomain);
+ if (!dekiHeaders) {
+ throw new Error("Error generating library API headers for delete.");
+ }
+
+ const response = await fetch(
+ `https://${subdomain}.libretexts.org/@api/deki/pages/${pid}?dream.out.format=json&origin=mt-web&recursive=true`,
+ {
+ method: "DELETE",
+ headers: {
+ ...dekiHeaders,
+ },
+ },
+ );
+
+ if (!response.ok) {
+ throwForMindTouchResponse(response, "Error deleting page");
+ }
+};
+
+const handleModifiedPage = async (
+ page: RemixerSubPageState,
+ parent: RemixerSubPageState | undefined,
+ title: string,
+ subdomain: string,
+): Promise => {
+ if (!page.renamedItem && !page.movedItem && !page.isPlacementChanged) {
+ return;
+ }
+
+ const pageId = page["@id"];
+ if (!pageId || pageId.startsWith("new-")) return;
+
+ const pid = parseInt(pageId, 10);
+ if (Number.isNaN(pid)) return;
+
+ const isMoved = page.movedItem === true || page.isPlacementChanged === true;
+ const isRenamed = page.renamedItem === true;
+
+ if (isMoved && (!parent || parent["@id"]?.startsWith("new-"))) {
+ throw new Error(
+ "Moving or reordering a page requires a published parent in the target book.",
+ );
+ }
+
+ const dekiHeaders = await generateAPIRequestHeaders(subdomain);
+ if (!dekiHeaders) {
+ throw new Error("Error generating library API headers for move/rename.");
+ }
+
+ const titleEnc = encodeURIComponent(title);
+ const base = `https://${subdomain}.libretexts.org/@api/deki/pages/${pid}`;
+
+ let moveUrl: string;
+
+ if (isMoved && !isRenamed) {
+ const pageUri = getRemixerPageUriUi(page);
+ const currentPath = extractPagePath(pageUri);
+ const segments = currentPath.split("/").filter(Boolean);
+ const leaf =
+ segments.length > 0
+ ? segments[segments.length - 1]!
+ : remixerPagePaddedSlug(page, title);
+ const parentPath = extractPagePath(getRemixerPageUriUi(parent!));
+ const newPathPlain = `${parentPath}/${leaf}`;
+ const toEnc = encodeURIComponent(encodeURIComponent(newPathPlain));
+ moveUrl = `${base}/move?title=${titleEnc}&to=${toEnc}&allow=deleteredirects&dream.out.format=json`;
+ } else if (isRenamed && !isMoved) {
+ const padded = remixerPagePaddedSlug(page, title);
+ const nameEnc = encodeURIComponent(padded);
+ moveUrl = `${base}/move?title=${titleEnc}&name=${nameEnc}&allow=deleteredirects&dream.out.format=json`;
+ } else if (isMoved && isRenamed) {
+ const parentPath = extractPagePath(getRemixerPageUriUi(parent!));
+ const padded = remixerPagePaddedSlug(page, title);
+ const newPathPlain = `${parentPath}/${padded}`;
+ const toEnc = encodeURIComponent(encodeURIComponent(newPathPlain));
+ moveUrl = `${base}/move?title=${titleEnc}&to=${toEnc}&allow=deleteredirects&dream.out.format=json`;
+ } else {
+ return;
+ }
+
+ const response = await fetch(moveUrl, {
+ method: "POST",
+ body: "",
+ headers: {
+ "Content-Type": "text/plain",
+ ...dekiHeaders,
+ },
+ });
+
+ if (!response.ok) {
+ throwForMindTouchResponse(response, "Error moving/renaming page");
+ }
+};
+
+const CROSS_TRANSLUDE_SOURCE_RE =
+ /template\(\s*['"]CrossTransclude\/Web['"]\s*,\s*\{[\s\S]*?['"]Library['"]\s*:\s*['"]([^'"]+)['"][\s\S]*?['"]PageID['"]\s*:\s*(\d+)/i;
+
+const resolveTranscludeSource = async ({
+ subdomain,
+ pageId,
+ fallbackUri,
+}: {
+ subdomain: string;
+ pageId: number;
+ fallbackUri: string;
+}): Promise<{ sourceSubdomain: string; sourceId: number; sourceUri: string }> => {
+ const sourceHeaders = await generateAPIRequestHeaders(subdomain);
+ const rawRes = await CXOneFetch({
+ scope: "page",
+ path: pageId,
+ api: MindTouch.API.Page.GET_page_RawContents,
+ subdomain,
+ options: {
+ headers: {
+ ...sourceHeaders,
+ },
+ },
+ });
+
+ if (!rawRes.ok) {
+ return {
+ sourceSubdomain: subdomain,
+ sourceId: pageId,
+ sourceUri: fallbackUri,
+ };
+ }
+
+ const rawContents = await rawRes.text();
+ const match = rawContents.match(CROSS_TRANSLUDE_SOURCE_RE);
+ if (!match) {
+ return {
+ sourceSubdomain: subdomain,
+ sourceId: pageId,
+ sourceUri: fallbackUri,
+ };
+ }
+
+ const nestedSubdomain = match[1];
+ const nestedId = parseInt(match[2] ?? "", 10);
+ if (!nestedSubdomain || Number.isNaN(nestedId)) {
+ return {
+ sourceSubdomain: subdomain,
+ sourceId: pageId,
+ sourceUri: fallbackUri,
+ };
+ }
+
+ const nestedHeaders = await generateAPIRequestHeaders(nestedSubdomain);
+ const nestedPageRes = await CXOneFetch({
+ scope: "page",
+ path: nestedId,
+ api: MindTouch.API.Page.GET_Page,
+ subdomain: nestedSubdomain,
+ options: {
+ headers: {
+ ...nestedHeaders,
+ },
+ },
+ });
+
+ let nestedUri = fallbackUri;
+ if (nestedPageRes.ok) {
+ const nestedPage = (await nestedPageRes.json()) as Record;
+ const resolvedUri = nestedPage["uri.ui"];
+ if (typeof resolvedUri === "string" && resolvedUri.length > 0) {
+ nestedUri = resolvedUri;
+ }
+ }
+
+ return resolveTranscludeSource({
+ subdomain: nestedSubdomain,
+ pageId: nestedId,
+ fallbackUri: nestedUri,
+ });
+};
+
+const handleImportedPage = async (
+ page: RemixerSubPageState,
+ parent: RemixerSubPageState,
+ title: string,
+ subdomain: string,
+ copyModeState: RemixerCopyMode,
+): Promise<{ pageID: string; pageURI: string }> => {
+ const sourceUri = getRemixerPageUriUi(page);
+ const sourceSubdomain = extractLibretextsSubdomain(sourceUri);
+ if (!sourceSubdomain) {
+ throw new Error(
+ "Could not determine source library subdomain for imported page.",
+ );
+ }
+
+ const sourceId = parseInt(page.sourceID ?? page["@id"], 10);
+ if (Number.isNaN(sourceId)) {
+ throw new Error("Imported page is missing a numeric source page id.");
+ }
+
+ const { pageID, pageURI } = await handleNewPage(
+ page,
+ parent,
+ title,
+ subdomain,
+ );
+
+ let contentsBody: string;
+ let postComment: string;
+ const dekiHeaders = await generateAPIRequestHeaders(subdomain);
+ if (copyModeState === "Transclude") {
+ const resolvedSource = await resolveTranscludeSource({
+ subdomain: sourceSubdomain,
+ pageId: sourceId,
+ fallbackUri: sourceUri,
+ });
+ contentsBody = RemixerTemplates.POST_TranscludeCrossLibrary(
+ resolvedSource.sourceSubdomain,
+ resolvedSource.sourceId,
+ resolvedSource.sourceUri,
+ [],
+ );
+ postComment = "Remixer transclude";
+ } else {
+ const htmlRes = await CXOneFetch({
+ scope: "page",
+ path: sourceId,
+ api: MindTouch.API.Page.GET_Page_Contents("html"),
+ subdomain: sourceSubdomain,
+ options: {
+ headers: {
+ ...dekiHeaders,
+ },
+ },
+ });
+ if (!htmlRes.ok) {
+ throwForMindTouchResponse(htmlRes, "Error reading source page contents");
+ }
+ const rawHtml = await htmlRes.text();
+ contentsBody = RemixerTemplates.POST_ForkPage(rawHtml, sourceSubdomain, []);
+ postComment = "Remixer fork";
+ }
+
+ const postRes = await CXOneFetch({
+ scope: "page",
+ path: parseInt(pageID, 10),
+ api: MindTouch.API.Page.POST_Contents,
+ subdomain,
+ query: { edittime: "now", comment: postComment },
+ options: {
+ method: "POST",
+ body: contentsBody,
+ headers: { "Content-Type": "text/plain; charset=utf-8", ...dekiHeaders },
+ },
+ });
+
+ if (!postRes.ok) {
+ throwForMindTouchResponse(postRes, "Error posting imported content");
+ }
+
+ return { pageID, pageURI };
+};
+
+interface RunRemixerJobParams {
+ jobID: string;
+ projectID: string;
+ bookURL: string;
+ subdomain: string;
+}
+
+const runRemixerJob = async ({
+ jobID,
+ projectID,
+ bookURL,
+ subdomain,
+}: RunRemixerJobParams) => {
+ const job = await PrejectRemixerJob.findOne({ jobID }).sort({ _id: -1 });
+ const remixerState = await PrejectRemixer.findOne({ projectID });
+
+ if (!remixerState) {
+ throw new Error("Remixer state not found");
+ }
+ if (!job) {
+ throw new Error("Job not found");
+ }
+
+ try {
+ if (job.status === "pending") {
+ job.status = "running";
+ job.messages.push("Remixer job started.");
+ await job.save();
+ }
+
+ const pages = remixerState.remixerCurrentBook;
+ const copyModeState = normalizeRemixerCopyMode(remixerState.copyModeState);
+ const byId = new Map(pages.map((p) => [p["@id"], p]));
+
+ // Topological sort: parents before children (BFS from roots)
+ const childrenOf = new Map();
+ const roots: string[] = [];
+ pages.forEach((p) => {
+ const pid = p.parentID ?? "-1";
+ if (pid === "-1" || !byId.has(pid)) {
+ roots.push(p["@id"]);
+ } else {
+ const siblings = childrenOf.get(pid) ?? [];
+ siblings.push(p["@id"]);
+ childrenOf.set(pid, siblings);
+ }
+ });
+
+ // BFS — propagate inMatterBranch and inDeletedBranch to children
+ type OrderedEntry = {
+ page: RemixerSubPageState;
+ inMatterBranch: boolean;
+ inDeletedBranch: boolean;
+ };
+ const ordered: OrderedEntry[] = [];
+ const queue: Array<{
+ id: string;
+ inMatterBranch: boolean;
+ inDeletedBranch: boolean;
+ }> = roots.map((id) => ({
+ id,
+ inMatterBranch: false,
+ inDeletedBranch: false,
+ }));
+ const visited = new Set();
+
+ while (queue.length > 0) {
+ const { id, inMatterBranch, inDeletedBranch } = queue.shift()!;
+ if (visited.has(id)) continue;
+ visited.add(id);
+ const node = byId.get(id);
+ if (!node) continue;
+ const nodeMatter = inMatterBranch || isMatterNode(node);
+ const nodeDeleted = inDeletedBranch || node.deletedItem === true;
+ ordered.push({
+ page: node,
+ inMatterBranch: nodeMatter,
+ inDeletedBranch: nodeDeleted,
+ });
+ (childrenOf.get(id) ?? []).forEach((childId) =>
+ queue.push({
+ id: childId,
+ inMatterBranch: nodeMatter,
+ inDeletedBranch: nodeDeleted,
+ }),
+ );
+ }
+ // Append any disconnected nodes not reached from roots
+ pages.forEach((p) => {
+ if (!visited.has(p["@id"]))
+ ordered.push({
+ page: p,
+ inMatterBranch: false,
+ inDeletedBranch: false,
+ });
+ });
+
+ const autoNumbering = remixerState.autoNumbering === true;
+
+ for (const { page, inMatterBranch, inDeletedBranch } of ordered) {
+ const title = getDisplayTitle(
+ page,
+ inMatterBranch,
+ inDeletedBranch,
+ autoNumbering,
+ );
+ const pathLen = page.pathNumber?.length ?? 0;
+ const isBookRoot = pathLen === 0;
+ const status = getPageStatus(page);
+ const shouldSkip = isBookRoot || inMatterBranch || status === "unchaned";
+ const message = shouldSkip
+ ? `${title} - skipped`
+ : `${title} - processed, status: ${status}`;
+ // Retry MindTouch-facing work on transient failures (timeouts, 5xx,
+ // rate limits, network blips). Non-transient errors propagate.
+ const logRetry = async (attempt: number, error: unknown) => {
+ const msg = error instanceof Error ? error.message : String(error);
+ job.messages.push(
+ `${title} - transient failure on attempt ${attempt}; retrying (${msg})`,
+ );
+ await job.save();
+ };
+
+ if (status === "new") {
+ const parentId = page.parentID ?? "-1";
+ const parent = parentId !== "-1" ? byId.get(parentId) : undefined;
+ if (parent) {
+ const oldPageId = page["@id"];
+ const { pageID, pageURI } = await withRetryOnTransient(
+ () => handleNewPage(page, parent, title, subdomain),
+ { onRetry: logRetry },
+ );
+ page["@id"] = pageID;
+ setRemixerPageUriUi(page, pageURI || getRemixerPageUriUi(page));
+ page["@href"] = pageURI || page["@href"];
+
+ // Keep references coherent for upcoming items in the same run.
+ byId.delete(oldPageId);
+ byId.set(pageID, page);
+ pages.forEach((candidate) => {
+ if (candidate.parentID === oldPageId) {
+ candidate.parentID = pageID;
+ }
+ });
+ }
+ } else if (status === "imported") {
+ const parentId = page.parentID ?? "-1";
+ const parent = parentId !== "-1" ? byId.get(parentId) : undefined;
+ if (parent) {
+ const oldPageId = page["@id"];
+ const { pageID, pageURI } = await withRetryOnTransient(
+ () =>
+ handleImportedPage(page, parent, title, subdomain, copyModeState),
+ { onRetry: logRetry },
+ );
+ page["@id"] = pageID;
+ setRemixerPageUriUi(page, pageURI || getRemixerPageUriUi(page));
+ page["@href"] = pageURI || page["@href"];
+
+ byId.delete(oldPageId);
+ byId.set(pageID, page);
+ pages.forEach((candidate) => {
+ if (candidate.parentID === oldPageId) {
+ candidate.parentID = pageID;
+ }
+ });
+ }
+ } else if (status === "modeified") {
+ const parentId = page.parentID ?? "-1";
+ const parent = parentId !== "-1" ? byId.get(parentId) : undefined;
+ await withRetryOnTransient(
+ () => handleModifiedPage(page, parent, title, subdomain),
+ { onRetry: logRetry },
+ );
+ const pid = parseInt(page["@id"], 10);
+ if (!Number.isNaN(pid)) {
+ const info = await getPage(pid, subdomain);
+ const uri = info?.["uri.ui"] ?? info?.["@href"];
+ if (uri) {
+ const uriStr = String(uri);
+ setRemixerPageUriUi(page, uriStr);
+ page["@href"] = uriStr;
+ }
+ }
+ } else if (status === "deleted") {
+ try {
+ await withRetryOnTransient(
+ () => handleDeletedPage(page, subdomain),
+ { onRetry: logRetry },
+ );
+ } catch (error) {
+ }
+ }
+
+ await new Promise((resolve) =>
+ setTimeout(resolve, shouldSkip ? 100 : 100),
+ );
+ job.messages.push(message);
+ await job.save();
+ }
+
+ // Archive the remixer state that was just published and persist a fresh
+ // snapshot as the new active record. The snapshot captures the fully
+ // processed currentbook (with updated page IDs / URIs) plus the settings
+ // that drove this run (autoNumbering, copyModeState, pathLevelFormats).
+ remixerState.archived = true;
+ await remixerState.save();
+
+ // await PrejectRemixer.create({
+ // projectID: remixerState.projectID,
+ // createdBy: remixerState.createdBy,
+ // updatedBy: remixerState.updatedBy,
+ // remixerID: base62(10),
+ // remixerCurrentBook: pages,
+ // autoNumbering: remixerState.autoNumbering,
+ // copyModeState: remixerState.copyModeState,
+ // pathLevelFormats: remixerState.pathLevelFormats,
+ // archived: false,
+ // });
+
+ job.status = "success";
+ job.messages.push("Remixer job completed successfully.");
+ await job.save();
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : "Unknown remixer publish error";
+ job.status = "error";
+ job.errorMessage = errorMessage;
+ job.messages.push(`Remixer job failed: ${errorMessage}`);
+ await job.save();
+ throw error;
+ }
+};
+
+export default {
+ mapToRemixerSubPageResponse: mapToRemixerSubPagesResponse,
+ mapToRemixerPageDetailsResponse: mapToRemixerPageDetailsResponse,
+ runRemixerJob: runRemixerJob,
+};
diff --git a/server/api/tasks.js b/server/api/tasks.js
index 8ef2e38e..86a890ad 100644
--- a/server/api/tasks.js
+++ b/server/api/tasks.js
@@ -3,10 +3,10 @@
// tasks.js
//
-'use strict';
import b62 from 'base62-random';
import date from 'date-and-time';
import { body, query } from 'express-validator';
+import { get } from 'http';
import Task from '../models/task.js';
import Project from '../models/project.js';
import conductorErrors from '../conductor-errors.js';
@@ -15,7 +15,6 @@ import { validateUUIDArray } from '../util/helpers.js';
import projectsAPI from './projects.js';
import mailAPI from './mail.js';
import usersAPI from './users.js';
-import { get } from 'http';
/**
* Creates a new Task within the specified Project using the values specified in the
@@ -26,138 +25,136 @@ import { get } from 'http';
* @param {Object} res - the express.js response object.
*/
const createTask = (req, res) => {
- let newTaskData = {
- orgID: process.env.ORG_ID,
- projectID: req.body.projectID,
- taskID: b62(16),
- title: req.body.title,
- description: '',
- status: 'available',
- assignees: [],
- parent: '',
- dependencies: [],
- createdBy: req.decoded.uuid
- };
- let projectData = {};
- Project.findOne({
- projectID: req.body.projectID
- }).lean().then((project) => {
- if (project) {
- if (projectsAPI.checkProjectMemberPermission(project, req.user)) {
- projectData = project;
- if (req.body.hasOwnProperty('parent')) {
- return Task.findOne({
- taskID: req.body.parent
- }).lean();
- } else {
- return {};
- }
- } else {
- throw(new Error('unauth'));
- }
- } else {
- throw(new Error('notfound'));
+ const newTaskData = {
+ orgID: process.env.ORG_ID,
+ projectID: req.body.projectID,
+ taskID: b62(16),
+ title: req.body.title,
+ description: '',
+ status: 'available',
+ assignees: [],
+ parent: '',
+ dependencies: [],
+ createdBy: req.decoded.uuid,
+ };
+ let projectData = {};
+ Project.findOne({
+ projectID: req.body.projectID,
+ }).lean().then((project) => {
+ if (project) {
+ if (projectsAPI.checkProjectMemberPermission(project, req.user)) {
+ projectData = project;
+ if (req.body.hasOwnProperty('parent')) {
+ return Task.findOne({
+ taskID: req.body.parent,
+ }).lean();
}
- }).then((parentTaskRes) => {
- if (parentTaskRes) {
- newTaskData.parent = parentTaskRes.taskID;
- } else if (!parentTaskRes && req.body.hasOwnProperty('parent')) {
- throw(new Error('ptnotfound'));
- }
- if (req.body.hasOwnProperty('status')) newTaskData.status = req.body.status;
- if (req.body.hasOwnProperty('description')) newTaskData.description = req.body.description;
- if (req.body.hasOwnProperty('assignees')) newTaskData.assignees = req.body.assignees;
- if (req.body.hasOwnProperty('dependencies')) newTaskData.dependencies = req.body.dependencies;
- if (req.body.hasOwnProperty('startDate')) newTaskData.startDate = req.body.startDate;
- if (req.body.hasOwnProperty('endDate')) newTaskData.endDate = req.body.endDate;
- if (newTaskData.startDate && newTaskData.endDate) {
- let startObj = date.parse(newTaskData.startDate, 'YYYY-MM-DD');
- let endObj = date.parse(newTaskData.endDate, 'YYYY-MM-DD');
- if (startObj instanceof Date && !isNaN(startObj.valueOf())
+ return {};
+ }
+ throw (new Error('unauth'));
+ } else {
+ throw (new Error('notfound'));
+ }
+ }).then((parentTaskRes) => {
+ if (parentTaskRes) {
+ newTaskData.parent = parentTaskRes.taskID;
+ } else if (!parentTaskRes && req.body.hasOwnProperty('parent')) {
+ throw (new Error('ptnotfound'));
+ }
+ if (req.body.hasOwnProperty('status')) newTaskData.status = req.body.status;
+ if (req.body.hasOwnProperty('description')) newTaskData.description = req.body.description;
+ if (req.body.hasOwnProperty('assignees')) newTaskData.assignees = req.body.assignees;
+ if (req.body.hasOwnProperty('dependencies')) newTaskData.dependencies = req.body.dependencies;
+ if (req.body.hasOwnProperty('startDate')) newTaskData.startDate = req.body.startDate;
+ if (req.body.hasOwnProperty('endDate')) newTaskData.endDate = req.body.endDate;
+ if (newTaskData.startDate && newTaskData.endDate) {
+ const startObj = date.parse(newTaskData.startDate, 'YYYY-MM-DD');
+ const endObj = date.parse(newTaskData.endDate, 'YYYY-MM-DD');
+ if (startObj instanceof Date && !isNaN(startObj.valueOf())
&& endObj instanceof Date && !isNaN(endObj.valueOf())
&& (endObj < startObj)) {
- throw(new Error('dateorder'));
- }
- }
- if (newTaskData.assignees.length > 0) {
- // verify all assignees are project members
- let validAssignees = true;
- newTaskData.assignees.forEach((item) => {
- if (item !== projectData.owner) {
- validAssignees = false;
- } else if (!projectData.collaborators.includes(item)) {
- validAssignees = false;
- }
- });
- if (!validAssignees) {
- throw(new Error('assignees'));
- }
- }
- if (newTaskData.dependencies.length > 0) {
- // dependencies need to be resolved/sanitized
- if (newTaskData.parent !== '') {
- // a task can't be dependent on its own parent task (circular dependency)
- let foundParent = newTaskData.dependencies.find((item) => item === newTaskData.parent);
- if (foundParent !== undefined) throw(new Error('parentdep'));
- }
- return Task.aggregate([
- {
- $match: {
- taskID: {
- $in: newTaskData.dependencies
- }
- }
- }
- ]);
- } else {
- return [];
- }
- }).then((dependencies) => {
- if (newTaskData.dependencies.length > 0 && dependencies.length > 0) {
- if (newTaskData.status === 'completed') {
- // a task can't be marked completed until its dependencies are completed
- let foundNotCompleted = dependencies.find((item) => item.status !== 'completed');
- if (foundNotCompleted !== undefined) {
- throw(new Error('depnotcompleted'));
- }
- }
- let depIDs = dependencies.map((item) => item.taskID).filter((item) => (item !== undefined && item !== null));
- newTaskData.dependencies = depIDs;
- } else if (newTaskData.dependencies.length > 0 && dependencies.length == 0) {
- // dependencies were provided but none were found, empty the array
- newTaskData.dependencies = [];
+ throw (new Error('dateorder'));
+ }
+ }
+ if (newTaskData.assignees.length > 0) {
+ // verify all assignees are project members
+ let validAssignees = true;
+ newTaskData.assignees.forEach((item) => {
+ if (item !== projectData.owner) {
+ validAssignees = false;
+ } else if (!projectData.collaborators.includes(item)) {
+ validAssignees = false;
}
- let newTask = new Task(newTaskData);
- return newTask.save();
- }).then((newDoc) => {
- if (newDoc) {
- return res.send({
- err: false,
- msg: 'Task successfully created.',
- taskID: newDoc.taskID
- });
- } else {
- throw(new Error('createfail'));
+ });
+ if (!validAssignees) {
+ throw (new Error('assignees'));
+ }
+ }
+ if (newTaskData.dependencies.length > 0) {
+ // dependencies need to be resolved/sanitized
+ if (newTaskData.parent !== '') {
+ // a task can't be dependent on its own parent task (circular dependency)
+ const foundParent = newTaskData.dependencies.find((item) => item === newTaskData.parent);
+ if (foundParent !== undefined) throw (new Error('parentdep'));
+ }
+ return Task.aggregate([
+ {
+ $match: {
+ taskID: {
+ $in: newTaskData.dependencies,
+ },
+ },
+ },
+ ]);
+ }
+ return [];
+ })
+ .then((dependencies) => {
+ if (newTaskData.dependencies.length > 0 && dependencies.length > 0) {
+ if (newTaskData.status === 'completed') {
+ // a task can't be marked completed until its dependencies are completed
+ const foundNotCompleted = dependencies.find((item) => item.status !== 'completed');
+ if (foundNotCompleted !== undefined) {
+ throw (new Error('depnotcompleted'));
+ }
}
- }).catch((err) => {
- var errMsg = conductorErrors.err6;
- if (err.message === 'notfound') errMsg = conductorErrors.err11;
- else if (err.message === 'unauth') errMsg = conductorErrors.err8;
- else if (err.message === 'ptnotfound') errMsg = conductorErrors.err24;
- else if (err.message === 'createfail') errMsg = conductorErrors.err3;
- else if (err.message === 'assignees') errMsg = conductorErrors.err26;
- else if (err.message === 'parentdep') errMsg = conductorErrors.err27;
- else if (err.message === 'depnotcompleted') errMsg = conductorErrors.err25;
- else if (err.message === 'dateorder') errMsg = conductorErrors.err33;
- else debugError(err);
+ const depIDs = dependencies.map((item) => item.taskID).filter((item) => (item !== undefined && item !== null));
+ newTaskData.dependencies = depIDs;
+ } else if (newTaskData.dependencies.length > 0 && dependencies.length == 0) {
+ // dependencies were provided but none were found, empty the array
+ newTaskData.dependencies = [];
+ }
+ const newTask = new Task(newTaskData);
+ return newTask.save();
+ })
+ .then((newDoc) => {
+ if (newDoc) {
return res.send({
- err: true,
- errMsg: errMsg
+ err: false,
+ msg: 'Task successfully created.',
+ taskID: newDoc.taskID,
});
+ }
+ throw (new Error('createfail'));
+ })
+ .catch((err) => {
+ let errMsg = conductorErrors.err6;
+ if (err.message === 'notfound') errMsg = conductorErrors.err11;
+ else if (err.message === 'unauth') errMsg = conductorErrors.err8;
+ else if (err.message === 'ptnotfound') errMsg = conductorErrors.err24;
+ else if (err.message === 'createfail') errMsg = conductorErrors.err3;
+ else if (err.message === 'assignees') errMsg = conductorErrors.err26;
+ else if (err.message === 'parentdep') errMsg = conductorErrors.err27;
+ else if (err.message === 'depnotcompleted') errMsg = conductorErrors.err25;
+ else if (err.message === 'dateorder') errMsg = conductorErrors.err33;
+ else debugError(err);
+ return res.send({
+ err: true,
+ errMsg,
+ });
});
};
-
/**
* Creates the number of new Tasks specified by the value in the request body.
* If requested, the specified number of subtasks is also created for each.
@@ -168,80 +165,77 @@ const createTask = (req, res) => {
* @param {Object} res - the express.js response object.
*/
const batchCreateTask = (req, res) => {
- let taskOps = [];
- let titlePrefix = String(req.body.titlePrefix).trim();
- let subtitlePrefix = '';
- let addSubtasks = false;
- if (req.body.addSubtasks === true && req.body.subtitlePrefix && req.body.hasOwnProperty('subtasks')) {
- addSubtasks = true;
- subtitlePrefix = String(req.body.subtitlePrefix).trim();
- }
- Project.findOne({
- projectID: req.body.projectID
- }).lean().then((project) => {
- if (project) {
- if (projectsAPI.checkProjectMemberPermission(project, req.user)) {
- if (isNaN(req.body.tasks) || (addSubtasks && isNaN(req.body.subtasks))) {
- throw(new Error('number'));
- }
- for (let i = 1; i <= req.body.tasks; i++) {
- let newTaskID = b62(16);
- taskOps.push({
- orgID: process.env.ORG_ID,
- projectID: project.projectID,
- taskID: newTaskID,
- title: `${titlePrefix} ${i}`,
- description: '',
- status: 'available',
- assignees: [],
- parent: '',
- dependencies: [],
- createdBy: req.decoded.uuid
- });
- if (addSubtasks) {
- for (let j = 1; j <= req.body.subtasks; j++) {
- taskOps.push({
- orgID: process.env.ORG_ID,
- projectID: project.projectID,
- taskID: b62(16),
- title: `${subtitlePrefix} ${i}.${j}`,
- description: '',
- status: 'available',
- assignees: [],
- parent: newTaskID,
- dependencies: [],
- createdBy: req.decoded.uuid
- });
- }
- }
- }
- return Task.insertMany(taskOps);
- } else {
- throw(new Error('unauth'));
+ const taskOps = [];
+ const titlePrefix = String(req.body.titlePrefix).trim();
+ let subtitlePrefix = '';
+ let addSubtasks = false;
+ if (req.body.addSubtasks === true && req.body.subtitlePrefix && req.body.hasOwnProperty('subtasks')) {
+ addSubtasks = true;
+ subtitlePrefix = String(req.body.subtitlePrefix).trim();
+ }
+ Project.findOne({
+ projectID: req.body.projectID,
+ }).lean().then((project) => {
+ if (project) {
+ if (projectsAPI.checkProjectMemberPermission(project, req.user)) {
+ if (isNaN(req.body.tasks) || (addSubtasks && isNaN(req.body.subtasks))) {
+ throw (new Error('number'));
+ }
+ for (let i = 1; i <= req.body.tasks; i++) {
+ const newTaskID = b62(16);
+ taskOps.push({
+ orgID: process.env.ORG_ID,
+ projectID: project.projectID,
+ taskID: newTaskID,
+ title: `${titlePrefix} ${i}`,
+ description: '',
+ status: 'available',
+ assignees: [],
+ parent: '',
+ dependencies: [],
+ createdBy: req.decoded.uuid,
+ });
+ if (addSubtasks) {
+ for (let j = 1; j <= req.body.subtasks; j++) {
+ taskOps.push({
+ orgID: process.env.ORG_ID,
+ projectID: project.projectID,
+ taskID: b62(16),
+ title: `${subtitlePrefix} ${i}.${j}`,
+ description: '',
+ status: 'available',
+ assignees: [],
+ parent: newTaskID,
+ dependencies: [],
+ createdBy: req.decoded.uuid,
+ });
}
- } else {
- throw(new Error('notfound'));
+ }
}
- }).then((_insertRes) => {
- return res.send({
- err: false,
- msg: 'Successfully added tasks.'
- });
- }).catch((err) => {
- var errMsg = conductorErrors.err6;
- if (err.message === 'notfound') errMsg = conductorErrors.err11;
- else if (err.message === 'unauth') errMsg = conductorErrors.err8;
- else if (err.message === 'insertfail') errMsg = conductorErrors.err3;
- else if (err.message === 'number') errMsg = conductorErrors.err2;
- else debugError(err);
- return res.send({
- err: true,
- errMsg: errMsg
- });
+ return Task.insertMany(taskOps);
+ }
+ throw (new Error('unauth'));
+ } else {
+ throw (new Error('notfound'));
+ }
+ }).then((_insertRes) => res.send({
+ err: false,
+ msg: 'Successfully added tasks.',
+ }))
+ .catch((err) => {
+ let errMsg = conductorErrors.err6;
+ if (err.message === 'notfound') errMsg = conductorErrors.err11;
+ else if (err.message === 'unauth') errMsg = conductorErrors.err8;
+ else if (err.message === 'insertfail') errMsg = conductorErrors.err3;
+ else if (err.message === 'number') errMsg = conductorErrors.err2;
+ else debugError(err);
+ return res.send({
+ err: true,
+ errMsg,
+ });
});
};
-
/**
* Updates the Task identified by the taskID in the request body.
* NOTE: This function should only be called AFTER the validation chain.
@@ -250,161 +244,161 @@ const batchCreateTask = (req, res) => {
* @param {Object} res - the express.js response object.
*/
const updateTask = (req, res) => {
- let task;
- let project;
- let updateObj = {};
- let updateDependencies = false;
- let doUpdate = false;
- getTaskProjectAndCheckPermission(req.body.taskID, req.user)
+ let task;
+ let project;
+ const updateObj = {};
+ let updateDependencies = false;
+ let doUpdate = false;
+ getTaskProjectAndCheckPermission(req.body.taskID, req.user)
.then(({ taskData, projectData }) => {
- task = taskData;
- project = projectData;
- // build update object
- if (req.body.hasOwnProperty('title') && req.body.title !== task.title) {
- updateObj.title = req.body.title;
- }
- if (req.body.hasOwnProperty('description') && req.body.description !== task.description) {
- updateObj.description = req.body.description;
- }
- if (req.body.hasOwnProperty('status') && req.body.status !== task.status) {
- updateObj.status = req.body.status;
- }
- if (req.body.hasOwnProperty('startDate') && req.body.startDate !== task.startDate) {
- updateObj.startDate = req.body.startDate;
- }
- if (req.body.hasOwnProperty('endDate') && req.body.endDate !== task.endDate) {
- updateObj.endDate = req.body.endDate;
- }
- if ((updateObj.startDate || task.startDate) && (updateObj.endDate || task.endDate)) {
- let startDate = null;
- let endDate = null;
- if (updateObj.startDate) startDate = updateObj.startDate;
- else if (task.startDate) startDate = task.startDate;
- if (updateObj.endDate) endDate = updateObj.endDate;
- else if (task.endDate) endDate = task.endDate;
- let startObj = date.parse(startDate, 'YYYY-MM-DD');
- let endObj = date.parse(endDate, 'YYYY-MM-DD');
- if (startObj instanceof Date && !isNaN(startObj.valueOf())
+ task = taskData;
+ project = projectData;
+ // build update object
+ if (req.body.hasOwnProperty('title') && req.body.title !== task.title) {
+ updateObj.title = req.body.title;
+ }
+ if (req.body.hasOwnProperty('description') && req.body.description !== task.description) {
+ updateObj.description = req.body.description;
+ }
+ if (req.body.hasOwnProperty('status') && req.body.status !== task.status) {
+ updateObj.status = req.body.status;
+ }
+ if (req.body.hasOwnProperty('startDate') && req.body.startDate !== task.startDate) {
+ updateObj.startDate = req.body.startDate;
+ }
+ if (req.body.hasOwnProperty('endDate') && req.body.endDate !== task.endDate) {
+ updateObj.endDate = req.body.endDate;
+ }
+ if ((updateObj.startDate || task.startDate) && (updateObj.endDate || task.endDate)) {
+ let startDate = null;
+ let endDate = null;
+ if (updateObj.startDate) startDate = updateObj.startDate;
+ else if (task.startDate) startDate = task.startDate;
+ if (updateObj.endDate) endDate = updateObj.endDate;
+ else if (task.endDate) endDate = task.endDate;
+ const startObj = date.parse(startDate, 'YYYY-MM-DD');
+ const endObj = date.parse(endDate, 'YYYY-MM-DD');
+ if (startObj instanceof Date && !isNaN(startObj.valueOf())
&& endObj instanceof Date && !isNaN(endObj.valueOf())
&& (endObj < startObj)) {
- throw(new Error('dateorder'));
- }
+ throw (new Error('dateorder'));
}
- if (updateObj.status === 'completed') {
- return Task.aggregate([
- {
- $match: {
- parent: task.taskID
- }
- }
- ]);
- } else return [];
+ }
+ if (updateObj.status === 'completed') {
+ return Task.aggregate([
+ {
+ $match: {
+ parent: task.taskID,
+ },
+ },
+ ]);
+ } return [];
}).then((subtasks) => {
- // check that a task is not being marked completed before subtasks are complete
- if (subtasks.length > 0) {
- let foundNotCompleted = subtasks.find((item) => item.status !== 'completed');
- if (foundNotCompleted !== undefined) {
- throw(new Error('subnotcomplete'));
- }
+ // check that a task is not being marked completed before subtasks are complete
+ if (subtasks.length > 0) {
+ const foundNotCompleted = subtasks.find((item) => item.status !== 'completed');
+ if (foundNotCompleted !== undefined) {
+ throw (new Error('subnotcomplete'));
}
- // continue building update object
- if (req.body.hasOwnProperty('assignees') && Array.isArray(req.body.assignees)) {
- // verify all assignees are project members
- let validAssignees = true;
- req.body.assignees.forEach((item) => {
- let isValid = projectsAPI.checkProjectMemberPermission(project, item);
- return isValid;
- });
+ }
+ // continue building update object
+ if (req.body.hasOwnProperty('assignees') && Array.isArray(req.body.assignees)) {
+ // verify all assignees are project members
+ const validAssignees = true;
+ req.body.assignees.forEach((item) => {
+ const isValid = projectsAPI.checkProjectMemberPermission(project, item);
+ return isValid;
+ });
- if (validAssignees) {
- updateObj.assignees = req.body.assignees;
- } else {
- throw(new Error('assignees'));
- }
+ if (validAssignees) {
+ updateObj.assignees = req.body.assignees;
+ } else {
+ throw (new Error('assignees'));
}
- if (req.body.hasOwnProperty('dependencies') && Array.isArray(req.body.dependencies)) {
- if (req.body.dependencies.length > 0) {
- updateDependencies = true;
- // dependencies need to be resolved/sanitized
- if (task.parent !== '') {
- // a task can't be dependent on its own parent task (circular dependency)
- let foundParent = req.body.dependencies.find((item) => item === task.parent);
- if (foundParent !== undefined) throw(new Error('parentdep'));
- }
- // a task can't be dependent on itself (circular dependency)
- let foundSelf = req.body.dependencies.find((item) => item === task.taskID);
- if (foundSelf !== undefined) throw(new Error('selfdep'));
- // lookup valid dependencies
- return Task.aggregate([
- {
- $match: {
- taskID: {
- $in: req.body.dependencies
- }
- }
- }
- ]);
- } else if (req.body.dependencies.length === 0) {
- updateDependencies = true;
- }
+ }
+ if (req.body.hasOwnProperty('dependencies') && Array.isArray(req.body.dependencies)) {
+ if (req.body.dependencies.length > 0) {
+ updateDependencies = true;
+ // dependencies need to be resolved/sanitized
+ if (task.parent !== '') {
+ // a task can't be dependent on its own parent task (circular dependency)
+ const foundParent = req.body.dependencies.find((item) => item === task.parent);
+ if (foundParent !== undefined) throw (new Error('parentdep'));
+ }
+ // a task can't be dependent on itself (circular dependency)
+ const foundSelf = req.body.dependencies.find((item) => item === task.taskID);
+ if (foundSelf !== undefined) throw (new Error('selfdep'));
+ // lookup valid dependencies
+ return Task.aggregate([
+ {
+ $match: {
+ taskID: {
+ $in: req.body.dependencies,
+ },
+ },
+ },
+ ]);
+ } if (req.body.dependencies.length === 0) {
+ updateDependencies = true;
}
- return [];
+ }
+ return [];
}).then((dependencies) => {
- if (updateDependencies) {
- if (req.body.dependencies.length > 0 && dependencies.length > 0) {
- let depIDs = dependencies.map((item) => item.taskID).filter((item) => (item !== undefined && item !== null));
- updateObj.dependencies = depIDs;
- if (task.status === 'completed' || updateObj.status === 'completed') {
- // a task can't be marked completed until its dependencies are completed
- let foundNotCompleted = dependencies.find((item) => item.status !== 'completed');
- if (foundNotCompleted !== undefined) {
- throw(new Error('depnotcompleted'));
- }
- }
- } else if (req.body.dependencies.length > 0 && dependencies.length === 0) {
- // dependencies were provided but none were found, empty the array
- updateObj.dependencies = [];
- } else if (req.body.dependencies.length === 0) {
- // dependencies were cleared
- updateObj.dependencies = [];
+ if (updateDependencies) {
+ if (req.body.dependencies.length > 0 && dependencies.length > 0) {
+ const depIDs = dependencies.map((item) => item.taskID).filter((item) => (item !== undefined && item !== null));
+ updateObj.dependencies = depIDs;
+ if (task.status === 'completed' || updateObj.status === 'completed') {
+ // a task can't be marked completed until its dependencies are completed
+ const foundNotCompleted = dependencies.find((item) => item.status !== 'completed');
+ if (foundNotCompleted !== undefined) {
+ throw (new Error('depnotcompleted'));
}
+ }
+ } else if (req.body.dependencies.length > 0 && dependencies.length === 0) {
+ // dependencies were provided but none were found, empty the array
+ updateObj.dependencies = [];
+ } else if (req.body.dependencies.length === 0) {
+ // dependencies were cleared
+ updateObj.dependencies = [];
}
- // only send update op if there are changes to be saved
- if (Object.keys(updateObj).length > 0) {
- doUpdate = true;
- return Task.updateOne({
- taskID: task.taskID
- }, updateObj);
- } else return {};
- }).then((updateRes) => {
- if (!doUpdate || (doUpdate && updateRes.modifiedCount === 1)) {
- return res.send({
- err: false,
- msg: "Successfully updated task."
- });
- } else {
- throw(new Error('updatefail'));
- }
- }).catch((err) => {
- var errMsg = conductorErrors.err6;
- if (err.message === 'notfound') errMsg = conductorErrors.err11;
- else if (err.message === 'unauth') errMsg = conductorErrors.err8;
- else if (err.message === 'updatefail') errMsg = conductorErrors.err3;
- else if (err.message === 'assignees') errMsg = conductorErrors.err26;
- else if (err.message === 'depnotcompleted') errMsg = conductorErrors.err25;
- else if (err.message === 'subnotcomplete') errMsg = conductorErrors.err30;
- else if (err.message === 'parentdep') errMsg = conductorErrors.err27;
- else if (err.message === 'selfdep') errMsg = conductorErrors.err31;
- else if (err.message === 'dateorder') errMsg = conductorErrors.err33;
- else debugError(err);
+ }
+ // only send update op if there are changes to be saved
+ if (Object.keys(updateObj).length > 0) {
+ doUpdate = true;
+ return Task.updateOne({
+ taskID: task.taskID,
+ }, updateObj);
+ } return {};
+ })
+ .then((updateRes) => {
+ if (!doUpdate || (doUpdate && updateRes.modifiedCount === 1)) {
return res.send({
- err: true,
- errMsg: errMsg
+ err: false,
+ msg: 'Successfully updated task.',
});
+ }
+ throw (new Error('updatefail'));
+ })
+ .catch((err) => {
+ let errMsg = conductorErrors.err6;
+ if (err.message === 'notfound') errMsg = conductorErrors.err11;
+ else if (err.message === 'unauth') errMsg = conductorErrors.err8;
+ else if (err.message === 'updatefail') errMsg = conductorErrors.err3;
+ else if (err.message === 'assignees') errMsg = conductorErrors.err26;
+ else if (err.message === 'depnotcompleted') errMsg = conductorErrors.err25;
+ else if (err.message === 'subnotcomplete') errMsg = conductorErrors.err30;
+ else if (err.message === 'parentdep') errMsg = conductorErrors.err27;
+ else if (err.message === 'selfdep') errMsg = conductorErrors.err31;
+ else if (err.message === 'dateorder') errMsg = conductorErrors.err33;
+ else debugError(err);
+ return res.send({
+ err: true,
+ errMsg,
+ });
});
};
-
/**
* Delete the Task identified by the taskID in the request body and any of
* its subtasks.
@@ -414,55 +408,54 @@ const updateTask = (req, res) => {
* @param {Object} res - the express.js response object.
*/
const deleteTask = (req, res) => {
- var task;
- let tasksToDelete = [];
- getTaskProjectAndCheckPermission(req.body.taskID, req.user)
+ let task;
+ let tasksToDelete = [];
+ getTaskProjectAndCheckPermission(req.body.taskID, req.user)
.then(({ taskData, projectData }) => {
- task = taskData;
- // find subtasks
- return Task.aggregate([
- {
- $match: {
- parent: task.taskID
- }
- }
- ]);
+ task = taskData;
+ // find subtasks
+ return Task.aggregate([
+ {
+ $match: {
+ parent: task.taskID,
+ },
+ },
+ ]);
}).then((subtasks) => {
- if (subtasks.length > 0) {
- subtasks.forEach((item) => {
- tasksToDelete.push(item.taskID);
- });
- }
- tasksToDelete.push(task.taskID);
- tasksToDelete = tasksToDelete.filter(item => (item !== undefined && item !== null));
- return Task.deleteMany({
- taskID: {
- $in: tasksToDelete
- }
+ if (subtasks.length > 0) {
+ subtasks.forEach((item) => {
+ tasksToDelete.push(item.taskID);
});
+ }
+ tasksToDelete.push(task.taskID);
+ tasksToDelete = tasksToDelete.filter((item) => (item !== undefined && item !== null));
+ return Task.deleteMany({
+ taskID: {
+ $in: tasksToDelete,
+ },
+ });
}).then((deleteRes) => {
- if (deleteRes.deletedCount === tasksToDelete.length) {
- return res.send({
- err: false,
- msg: "Successfully deleted task and any subtasks."
- });
- } else {
- throw(new Error('deletefail'));
- }
- }).catch((err) => {
- var errMsg = conductorErrors.err6;
- if (err.message === 'notfound') errMsg = conductorErrors.err11;
- else if (err.message === 'unauth') errMsg = conductorErrors.err8;
- else if (err.message === 'deletefail') errMsg = conductorErrors.err3;
- else debugError(err);
+ if (deleteRes.deletedCount === tasksToDelete.length) {
return res.send({
- err: true,
- errMsg: errMsg
+ err: false,
+ msg: 'Successfully deleted task and any subtasks.',
});
+ }
+ throw (new Error('deletefail'));
+ })
+ .catch((err) => {
+ let errMsg = conductorErrors.err6;
+ if (err.message === 'notfound') errMsg = conductorErrors.err11;
+ else if (err.message === 'unauth') errMsg = conductorErrors.err8;
+ else if (err.message === 'deletefail') errMsg = conductorErrors.err3;
+ else debugError(err);
+ return res.send({
+ err: true,
+ errMsg,
+ });
});
};
-
/**
* Retrieves information about the Task identified by the taskID in the request
* query.
@@ -472,145 +465,141 @@ const deleteTask = (req, res) => {
* @param {Object} res - the express.js response object.
*/
const getTask = (req, res) => {
- getTaskProjectAndCheckPermission(req.query.taskID, req.user)
- .then(({ taskData, projectData }) => {
- return Task.aggregate([
+ getTaskProjectAndCheckPermission(req.query.taskID, req.user)
+ .then(({ taskData, projectData }) => Task.aggregate([
+ {
+ $match: {
+ taskID: req.query.taskID,
+ },
+ }, {
+ $project: {
+ _id: 0,
+ __v: 0,
+ },
+ }, {
+ // lookup parent
+ $lookup: {
+ from: 'tasks',
+ let: {
+ parent: '$parent',
+ },
+ pipeline: [
{
- $match: {
- taskID: req.query.taskID
- }
- }, {
- $project: {
- _id: 0,
- __v: 0
- }
- }, {
- // lookup parent
- $lookup: {
- from: 'tasks',
- let: {
- parent: '$parent'
- },
- pipeline: [
- {
- $match: {
- $expr: {
- $eq: ['$taskID', '$$parent']
- }
- }
- }, {
- $project: {
- _id: 0,
- __v: 0
- }
- }
- ],
- as: 'parent'
- }
+ $match: {
+ $expr: {
+ $eq: ['$taskID', '$$parent'],
+ },
+ },
}, {
- $addFields: {
- parent: {
- $arrayElemAt: ['$parent', 0]
- }
- }
+ $project: {
+ _id: 0,
+ __v: 0,
+ },
+ },
+ ],
+ as: 'parent',
+ },
+ }, {
+ $addFields: {
+ parent: {
+ $arrayElemAt: ['$parent', 0],
+ },
+ },
+ }, {
+ // lookup dependencies
+ $lookup: {
+ from: 'tasks',
+ let: {
+ deps: '$dependencies',
+ },
+ pipeline: [
+ {
+ $match: {
+ $expr: {
+ $in: ['$taskID', '$$deps'],
+ },
+ },
}, {
- // lookup dependencies
- $lookup: {
- from: 'tasks',
- let: {
- deps: '$dependencies'
- },
- pipeline: [
- {
- $match: {
- $expr: {
- $in: ['$taskID', '$$deps']
- }
- }
- }, {
- $project: {
- _id: 0,
- __v: 0
- }
- }
- ],
- as: 'dependencies'
- }
+ $project: {
+ _id: 0,
+ __v: 0,
+ },
+ },
+ ],
+ as: 'dependencies',
+ },
+ }, {
+ // lookup tasks being blocked
+ $lookup: {
+ from: 'tasks',
+ let: {
+ taskID: '$taskID',
+ },
+ pipeline: [
+ {
+ $match: {
+ $expr: {
+ $in: ['$$taskID', '$dependencies'],
+ },
+ },
}, {
- // lookup tasks being blocked
- $lookup: {
- from: 'tasks',
- let: {
- taskID: '$taskID'
- },
- pipeline: [
- {
- $match: {
- $expr: {
- $in: ['$$taskID', '$dependencies']
- }
- }
- }, {
- $project: {
- _id: 0,
- __v: 0
- }
- }
- ],
- as: 'blocking'
- }
+ $project: {
+ _id: 0,
+ __v: 0,
+ },
+ },
+ ],
+ as: 'blocking',
+ },
+ }, {
+ // lookup assignees
+ $lookup: {
+ from: 'users',
+ let: {
+ assignees: '$assignees',
+ },
+ pipeline: [
+ {
+ $match: {
+ $expr: {
+ $in: ['$uuid', '$$assignees'],
+ },
+ },
}, {
- // lookup assignees
- $lookup: {
- from: 'users',
- let: {
- assignees: '$assignees'
- },
- pipeline: [
- {
- $match: {
- $expr: {
- $in: ['$uuid', '$$assignees']
- }
- }
- }, {
- $project: {
- _id: 0,
- uuid: 1,
- firstName: 1,
- lastName: 1,
- avatar: 1,
- email: 1
- }
- }
- ],
- as: 'assignees'
- }
- }
- ]);
- }).then((taskRes) => {
- if (taskRes.length > 0) {
- let task = taskRes[0];
- return res.send({
- err: false,
- task: task
- });
- } else {
- throw(new Error('notfound'));
- }
- }).catch((err) => {
- var errMsg = conductorErrors.err6;
- if (err.message === 'notfound') errMsg = conductorErrors.err11;
- else if (err.message === 'unauth') errMsg = conductorErrors.err8;
- else debugError(err);
+ $project: {
+ _id: 0,
+ uuid: 1,
+ firstName: 1,
+ lastName: 1,
+ avatar: 1,
+ email: 1,
+ },
+ },
+ ],
+ as: 'assignees',
+ },
+ },
+ ])).then((taskRes) => {
+ if (taskRes.length > 0) {
+ const task = taskRes[0];
return res.send({
- err: true,
- errMsg: errMsg
+ err: false,
+ task,
});
+ }
+ throw (new Error('notfound'));
+ }).catch((err) => {
+ let errMsg = conductorErrors.err6;
+ if (err.message === 'notfound') errMsg = conductorErrors.err11;
+ else if (err.message === 'unauth') errMsg = conductorErrors.err8;
+ else debugError(err);
+ return res.send({
+ err: true,
+ errMsg,
+ });
});
};
-
/**
* Retrieves all Tasks and subtasks belonging to the Project identified by
* the projectID in the request query.
@@ -620,300 +609,301 @@ const getTask = (req, res) => {
* @param {Object} res - the express.js response object.
*/
const getProjectTasks = (req, res) => {
- Project.findOne({
- projectID: req.query.projectID
- }).lean().then((project) => {
- if (project) {
- if (projectsAPI.checkProjectGeneralPermission(project, req.user)) {
- return Task.aggregate([
- {
- $match: {
- $and: [
- {
- projectID: req.query.projectID
- }, {
- parent: {
- $in: ['', null]
- }
- }
- ]
- }
- }, {
- // lookup assignees
- $lookup: {
- from: 'users',
- let: {
- assignees: '$assignees'
- },
- pipeline: [
- {
- $match: {
- $expr: {
- $in: ['$uuid', '$$assignees']
- }
- }
- }, {
- $project: {
- _id: 0,
- uuid: 1,
- firstName: 1,
- lastName: 1,
- avatar: 1,
- email: 1
- }
- }
- ],
- as: 'assignees'
- }
- }, {
- // lookup dependencies
- $graphLookup: {
- from: 'tasks',
- startWith: '$dependencies',
- connectFromField: 'dependencies',
- connectToField: 'taskID',
- as: 'dependencies',
- maxDepth: 0
- }
- }, {
- // lookup tasks being blocked
- $graphLookup: {
- from: 'tasks',
- startWith: '$taskID',
- connectFromField: 'taskID',
- connectToField: 'dependencies',
- as: 'blocking',
- maxDepth: 0
- }
- }, {
- // lookup subtasks
- $graphLookup: {
- from: 'tasks',
- startWith: '$taskID',
- connectFromField: 'taskID',
- connectToField: 'parent',
- as: 'subtasks'
- }
- }, {
- $unwind: {
- path: '$subtasks',
- preserveNullAndEmptyArrays: true
- }
- }, {
- // lookup subtasks dependencies
- $lookup: {
- from: 'tasks',
- let: {
- subDeps: '$subtasks.dependencies'
- },
- pipeline: [
- {
- $match: {
- $expr: {
- $eq: [{ $type: '$$subDeps' }, 'array']
- }
- }
- }, {
- $match: {
- $expr: {
- $in: ['$taskID', '$$subDeps']
- }
- }
- }
- ],
- as: 'subtasks.dependencies'
- }
- }, {
- // lookup subtasks assignees
- $lookup: {
- from: 'users',
- let: {
- subAssigns: '$subtasks.assignees'
- },
- pipeline: [
- {
- $match: {
- $expr: {
- $eq: [{ $type: '$$subAssigns' }, 'array']
- }
- }
- }, {
- $match: {
- $expr: {
- $in: ['$uuid', '$$subAssigns']
- }
- }
- }, {
- $project: {
- _id: 0,
- uuid: 1,
- firstName: 1,
- lastName: 1,
- avatar: 1,
- email: 1
- }
- }
- ],
- as: 'subtasks.assignees'
- }
- }, {
- $addFields: {
- subtasks: {
- $cond: {
- if: {
- $ifNull: ['$subtasks.taskID', false]
- },
- then: '$subtasks',
- else: []
- }
- }
- }
- }, {
- $group: {
- _id: '$_id',
- orgID: { $first: '$orgID' },
- projectID: { $first: '$projectID' },
- taskID: { $first: '$taskID'},
- title: { $first: '$title' },
- description: { $first: '$description' },
- status: { $first: '$status' },
- assignees: { $first: '$assignees' },
- parent: { $first: '$parent' },
- createdBy: { $first: '$createdBy' },
- subtasks: { $push: '$subtasks' },
- dependencies: { $first: '$dependencies' },
- blocking: { $first: '$blocking' },
- startDate: { $first: '$startDate' },
- endDate: { $first: '$endDate' },
- createdAt: { $first: '$createdAt' },
- createdBy: { $first: '$createdBy' }
- }
- }, {
- $addFields: {
- subtasks: {
- $filter: {
- input: '$subtasks',
- as: 'subtask',
- cond: {
- $eq: [{ $type: '$$subtask' }, 'object']
- }
- }
- }
- }
- }, {
- $project: {
- _id: 0,
- __v: 0,
- subtasks: {
- _id: 0,
- __v: 0
- }
- }
- }, {
- $sort: {
- title: 1,
- 'subtasks.title': -1
- }
- }
- ]);
- } else {
- throw(new Error('unauth'));
- }
- } else {
- throw(new Error('notfound'));
- }
- }).then((tasks) => {
- //console.log(tasks);
- return res.send({
- err: false,
- tasks: tasks
- });
- }).catch((err) => {
- let errMsg = conductorErrors.err6;
- if (err.message === 'notfound') errMsg = conductorErrors.err11;
- else if (err.message === 'unauth') errMsg = conductorErrors.err8;
- else debugError(err);
- return res.send({
- err: true,
- errMsg: errMsg
- });
+ Project.findOne({
+ projectID: req.query.projectID,
+ }).lean().then((project) => {
+ if (project) {
+ if (projectsAPI.checkProjectGeneralPermission(project, req.user)) {
+ return Task.aggregate([
+ {
+ $match: {
+ $and: [
+ {
+ projectID: req.query.projectID,
+ }, {
+ parent: {
+ $in: ['', null],
+ },
+ },
+ ],
+ },
+ }, {
+ // lookup assignees
+ $lookup: {
+ from: 'users',
+ let: {
+ assignees: '$assignees',
+ },
+ pipeline: [
+ {
+ $match: {
+ $expr: {
+ $in: ['$uuid', '$$assignees'],
+ },
+ },
+ }, {
+ $project: {
+ _id: 0,
+ uuid: 1,
+ firstName: 1,
+ lastName: 1,
+ avatar: 1,
+ email: 1,
+ },
+ },
+ ],
+ as: 'assignees',
+ },
+ }, {
+ // lookup dependencies
+ $graphLookup: {
+ from: 'tasks',
+ startWith: '$dependencies',
+ connectFromField: 'dependencies',
+ connectToField: 'taskID',
+ as: 'dependencies',
+ maxDepth: 0,
+ },
+ }, {
+ // lookup tasks being blocked
+ $graphLookup: {
+ from: 'tasks',
+ startWith: '$taskID',
+ connectFromField: 'taskID',
+ connectToField: 'dependencies',
+ as: 'blocking',
+ maxDepth: 0,
+ },
+ }, {
+ // lookup subtasks
+ $graphLookup: {
+ from: 'tasks',
+ startWith: '$taskID',
+ connectFromField: 'taskID',
+ connectToField: 'parent',
+ as: 'subtasks',
+ },
+ }, {
+ $unwind: {
+ path: '$subtasks',
+ preserveNullAndEmptyArrays: true,
+ },
+ }, {
+ // lookup subtasks dependencies
+ $lookup: {
+ from: 'tasks',
+ let: {
+ subDeps: '$subtasks.dependencies',
+ },
+ pipeline: [
+ {
+ $match: {
+ $expr: {
+ $eq: [{ $type: '$$subDeps' }, 'array'],
+ },
+ },
+ }, {
+ $match: {
+ $expr: {
+ $in: ['$taskID', '$$subDeps'],
+ },
+ },
+ },
+ ],
+ as: 'subtasks.dependencies',
+ },
+ }, {
+ // lookup subtasks assignees
+ $lookup: {
+ from: 'users',
+ let: {
+ subAssigns: '$subtasks.assignees',
+ },
+ pipeline: [
+ {
+ $match: {
+ $expr: {
+ $eq: [{ $type: '$$subAssigns' }, 'array'],
+ },
+ },
+ }, {
+ $match: {
+ $expr: {
+ $in: ['$uuid', '$$subAssigns'],
+ },
+ },
+ }, {
+ $project: {
+ _id: 0,
+ uuid: 1,
+ firstName: 1,
+ lastName: 1,
+ avatar: 1,
+ email: 1,
+ },
+ },
+ ],
+ as: 'subtasks.assignees',
+ },
+ }, {
+ $addFields: {
+ subtasks: {
+ $cond: {
+ if: {
+ $ifNull: ['$subtasks.taskID', false],
+ },
+ then: '$subtasks',
+ else: [],
+ },
+ },
+ },
+ }, {
+ $group: {
+ _id: '$_id',
+ orgID: { $first: '$orgID' },
+ projectID: { $first: '$projectID' },
+ taskID: { $first: '$taskID' },
+ title: { $first: '$title' },
+ description: { $first: '$description' },
+ status: { $first: '$status' },
+ assignees: { $first: '$assignees' },
+ parent: { $first: '$parent' },
+ createdBy: { $first: '$createdBy' },
+ subtasks: { $push: '$subtasks' },
+ dependencies: { $first: '$dependencies' },
+ blocking: { $first: '$blocking' },
+ startDate: { $first: '$startDate' },
+ endDate: { $first: '$endDate' },
+ createdAt: { $first: '$createdAt' },
+ createdBy: { $first: '$createdBy' },
+ },
+ }, {
+ $addFields: {
+ subtasks: {
+ $filter: {
+ input: '$subtasks',
+ as: 'subtask',
+ cond: {
+ $eq: [{ $type: '$$subtask' }, 'object'],
+ },
+ },
+ },
+ },
+ }, {
+ $project: {
+ _id: 0,
+ __v: 0,
+ subtasks: {
+ _id: 0,
+ __v: 0,
+ },
+ },
+ }, {
+ $sort: {
+ title: 1,
+ 'subtasks.title': -1,
+ },
+ },
+ ]);
+ }
+ throw (new Error('unauth'));
+ } else {
+ throw (new Error('notfound'));
+ }
+ }).then((tasks) =>
+ // console.log(tasks);
+ res.send({
+ err: false,
+ tasks,
+ }))
+ .catch((err) => {
+ let errMsg = conductorErrors.err6;
+ if (err.message === 'notfound') errMsg = conductorErrors.err11;
+ else if (err.message === 'unauth') errMsg = conductorErrors.err8;
+ else debugError(err);
+ return res.send({
+ err: true,
+ errMsg,
+ });
});
};
-
/**
* Assign a user to a task.
* @param {Object} req - the express.js request object.
* @param {Object} res - the express.js response object.
*/
const addTaskAssignee = (req, res) => {
- let assignedTask = false;
- let project = {};
- let task = {};
- getTaskProjectAndCheckPermission(req.body.taskID, req.user)
+ let assignedTask = false;
+ let project = {};
+ let task = {};
+ getTaskProjectAndCheckPermission(req.body.taskID, req.user)
.then(({ taskData, projectData }) => {
- task = taskData;
- project = projectData;
- let isValid = projectsAPI.checkProjectMemberPermission(project, req.body.assignee);
- if (isValid) {
- let matchObj = {
- taskID: task.taskID
- };
- if (req.body.hasOwnProperty('subtasks') && req.body.subtasks === true
+ task = taskData;
+ project = projectData;
+ const isValid = projectsAPI.checkProjectMemberPermission(project, req.body.assignee);
+ if (isValid) {
+ let matchObj = {
+ taskID: task.taskID,
+ };
+ if (req.body.hasOwnProperty('subtasks') && req.body.subtasks === true
&& (!task.parent || task.parent === '')) {
- // allow assigning to subtasks if requested & task is a parent
- matchObj = {
- $or: [
- { taskID: task.taskID },
- { parent: task.taskID }
- ]
- };
- }
- return Task.updateMany(matchObj, {
- $addToSet: {
- assignees: req.body.assignee
- }
- });
- } else {
- throw(new Error('notteam'));
+ // allow assigning to subtasks if requested & task is a parent
+ matchObj = {
+ $or: [
+ { taskID: task.taskID },
+ { parent: task.taskID },
+ ],
+ };
}
+ return Task.updateMany(matchObj, {
+ $addToSet: {
+ assignees: req.body.assignee,
+ },
+ });
+ }
+ throw (new Error('notteam'));
}).then((updateRes) => {
- if (updateRes.modifiedCount > 0) {
- assignedTask = true;
- return usersAPI.getUserEmails([req.body.assignee]);
- } else {
- throw(new Error('updatefail'));
- }
+ if (updateRes.modifiedCount > 0) {
+ assignedTask = true;
+ return usersAPI.getUserEmails([req.body.assignee]);
+ }
+ throw (new Error('updatefail'));
}).then((userEmails) => {
- if (userEmails && Array.isArray(userEmails) && userEmails.length > 0) {
- return mailAPI.sendAssignedToTaskNotification(userEmails[0],
- project.projectID, project.title, project.orgID, task.title);
- } else return {};
- }).then(() => {
- // ignore return value of Mailgun call
+ if (userEmails && Array.isArray(userEmails) && userEmails.length > 0) {
+ return mailAPI.sendAssignedToTaskNotification(
+ userEmails[0],
+ project.projectID,
+ project.title,
+ project.orgID,
+ task.title,
+ );
+ } return {};
+ })
+ .then(() =>
+ // ignore return value of Mailgun call
+ res.send({
+ err: false,
+ msg: 'Successfully assigned user!',
+ }))
+ .catch((err) => {
+ if (assignedTask) {
+ // return success even if notification fails
return res.send({
- err: false,
- msg: 'Successfully assigned user!'
+ err: false,
+ msg: 'Successfully assigned user!',
});
- }).catch((err) => {
- if (assignedTask) {
- // return success even if notification fails
- return res.send({
- err: false,
- msg: 'Successfully assigned user!'
- });
- } else {
- let errMsg = conductorErrors.err6;
- if (err.message === 'notfound') errMsg = conductorErrors.err11;
- else if (err.message === 'unauth') errMsg = conductorErrors.err8;
- else if (err.message === 'notteam') errMsg = conductorErrors.err26;
- else if (err.message === 'updatefail') errMsg = conductorErrors.err3;
- else debugError(err);
- return res.send({
- err: true,
- errMsg: errMsg
- });
- }
+ }
+ let errMsg = conductorErrors.err6;
+ if (err.message === 'notfound') errMsg = conductorErrors.err11;
+ else if (err.message === 'unauth') errMsg = conductorErrors.err8;
+ else if (err.message === 'notteam') errMsg = conductorErrors.err26;
+ else if (err.message === 'updatefail') errMsg = conductorErrors.err3;
+ else debugError(err);
+ return res.send({
+ err: true,
+ errMsg,
+ });
});
};
@@ -923,70 +913,70 @@ const addTaskAssignee = (req, res) => {
* @param {Object} res - the express.js response object.
*/
const assignAllMembersToTask = async (req, res) => {
- try {
- const { taskData, projectData } = await getTaskProjectAndCheckPermission(
- req.body.taskID,
- req.user
- );
+ try {
+ const { taskData, projectData } = await getTaskProjectAndCheckPermission(
+ req.body.taskID,
+ req.user,
+ );
- let matchObj = {
- taskID: taskData.taskID,
- };
+ let matchObj = {
+ taskID: taskData.taskID,
+ };
- if (
- req.body.hasOwnProperty("subtasks") &&
- req.body.subtasks === true &&
- (!taskData.parent || taskData.parent === "")
- ) {
- // allow assigning to subtasks if requested & task is a parent
- matchObj = {
- $or: [{ taskID: taskData.taskID }, { parent: taskData.taskID }],
- };
- }
+ if (
+ req.body.hasOwnProperty('subtasks')
+ && req.body.subtasks === true
+ && (!taskData.parent || taskData.parent === '')
+ ) {
+ // allow assigning to subtasks if requested & task is a parent
+ matchObj = {
+ $or: [{ taskID: taskData.taskID }, { parent: taskData.taskID }],
+ };
+ }
- const updateRes = await Task.updateMany(matchObj, {
- $addToSet: {
- assignees: projectData.members,
- },
- });
+ const updateRes = await Task.updateMany(matchObj, {
+ $addToSet: {
+ assignees: projectData.members,
+ },
+ });
- const userEmails = [];
- if (updateRes.modifiedCount > 0) {
- const foundEmails = await usersAPI.getUserEmails([...projectData.members]);
- userEmails.push(...foundEmails);
- } else {
- throw new Error("updatefail");
- }
+ const userEmails = [];
+ if (updateRes.modifiedCount > 0) {
+ const foundEmails = await usersAPI.getUserEmails([...projectData.members]);
+ userEmails.push(...foundEmails);
+ } else {
+ throw new Error('updatefail');
+ }
- if (userEmails && Array.isArray(userEmails) && userEmails.length > 0) {
- for (let i = 0; i < userEmails.length; i++) {
- const mailRes = mailAPI.sendAssignedToTaskNotification(
- userEmails[i],
- projectData.projectID,
- projectData.title,
- projectData.orgID,
- taskData.title
- );
- }
+ if (userEmails && Array.isArray(userEmails) && userEmails.length > 0) {
+ for (let i = 0; i < userEmails.length; i++) {
+ const mailRes = mailAPI.sendAssignedToTaskNotification(
+ userEmails[i],
+ projectData.projectID,
+ projectData.title,
+ projectData.orgID,
+ taskData.title,
+ );
}
+ }
- // ignore return value of Mailgun call
- return res.send({
- err: false,
- msg: "Successfully assigned users!",
- });
- } catch (err) {
- let errMsg = conductorErrors.err6;
- if (err.message === "updatefail") {
- errMsg = conductorErrors.err3;
- } else {
- debugError(err);
- }
- return res.send({
- err: true,
- errMsg: errMsg,
- });
+ // ignore return value of Mailgun call
+ return res.send({
+ err: false,
+ msg: 'Successfully assigned users!',
+ });
+ } catch (err) {
+ let errMsg = conductorErrors.err6;
+ if (err.message === 'updatefail') {
+ errMsg = conductorErrors.err3;
+ } else {
+ debugError(err);
}
+ return res.send({
+ err: true,
+ errMsg,
+ });
+ }
};
/**
@@ -995,49 +985,47 @@ const assignAllMembersToTask = async (req, res) => {
* @param {Object} res - the express.js response object.
*/
const removeTaskAssignee = (req, res) => {
- getTaskProjectAndCheckPermission(req.body.taskID, req.user)
+ getTaskProjectAndCheckPermission(req.body.taskID, req.user)
.then(({ taskData: task, projectData }) => {
- let matchObj = {
- taskID: task.taskID
- };
- if (req.body.hasOwnProperty('subtasks') && req.body.subtasks === true
+ let matchObj = {
+ taskID: task.taskID,
+ };
+ if (req.body.hasOwnProperty('subtasks') && req.body.subtasks === true
&& (!task.parent || task.parent === '')) {
- // allow removing from subtasks if requested & task is a parent
- matchObj = {
- $or: [
- { taskID: task.taskID },
- { parent: task.taskID }
- ]
- };
- }
- return Task.updateMany(matchObj, {
- $pull: {
- assignees: req.body.assignee
- }
- });
+ // allow removing from subtasks if requested & task is a parent
+ matchObj = {
+ $or: [
+ { taskID: task.taskID },
+ { parent: task.taskID },
+ ],
+ };
+ }
+ return Task.updateMany(matchObj, {
+ $pull: {
+ assignees: req.body.assignee,
+ },
+ });
}).then((updateRes) => {
- if (updateRes.modifiedCount > 0) {
- return res.send({
- err: false,
- msg: 'Successfully unassigned user!'
- });
- } else {
- throw(new Error('updatefail'));
- }
- }).catch((err) => {
- let errMsg = conductorErrors.err6;
- if (err.message === 'notfound') errMsg = conductorErrors.err11;
- else if (err.message === 'unauth') errMsg = conductorErrors.err8;
- else if (err.message === 'updatefail') errMsg = conductorErrors.err3;
- else debugError(err);
+ if (updateRes.modifiedCount > 0) {
return res.send({
- err: true,
- errMsg: errMsg
+ err: false,
+ msg: 'Successfully unassigned user!',
});
+ }
+ throw (new Error('updatefail'));
+ }).catch((err) => {
+ let errMsg = conductorErrors.err6;
+ if (err.message === 'notfound') errMsg = conductorErrors.err11;
+ else if (err.message === 'unauth') errMsg = conductorErrors.err8;
+ else if (err.message === 'updatefail') errMsg = conductorErrors.err3;
+ else debugError(err);
+ return res.send({
+ err: true,
+ errMsg,
+ });
});
};
-
/**
* Lookup a task and it's corresponding project, then check
* if the given user has member permission(s) in the project.
@@ -1047,141 +1035,138 @@ const removeTaskAssignee = (req, res) => {
* information, or throw an error
*/
const getTaskProjectAndCheckPermission = (taskID, user) => {
- let task = {};
- let project = {};
- return new Promise((resolve, reject) => {
- resolve(Task.findOne({
- taskID: taskID
- }).lean());
- }).then((taskData) => {
- if (taskData) {
- task = taskData;
- return Project.findOne({
- projectID: taskData.projectID
- }).lean();
- } else throw(new Error('notfound'));
- }).then((projectData) => {
- if (projectData) {
- project = projectData;
- if (projectsAPI.checkProjectMemberPermission(project, user)) {
- return {
- taskData: task,
- projectData: project
- };
- } else throw(new Error('unauth'));
- } else throw(new Error('notfound'));
- })
+ let task = {};
+ let project = {};
+ return new Promise((resolve, reject) => {
+ resolve(Task.findOne({
+ taskID,
+ }).lean());
+ }).then((taskData) => {
+ if (taskData) {
+ task = taskData;
+ return Project.findOne({
+ projectID: taskData.projectID,
+ }).lean();
+ } throw (new Error('notfound'));
+ }).then((projectData) => {
+ if (projectData) {
+ project = projectData;
+ if (projectsAPI.checkProjectMemberPermission(project, user)) {
+ return {
+ taskData: task,
+ projectData: project,
+ };
+ } throw (new Error('unauth'));
+ } else throw (new Error('notfound'));
+ });
};
-
/**
* Validate a provided Task Status option during a task creation or update.
* @returns {Boolean} true if valid option, false otherwise.
*/
const validateStatus = (status) => {
- if ((status === 'available')
+ if ((status === 'available')
|| (status === 'inprogress')
|| (status === 'completed')) return true;
- return false;
+ return false;
};
-
/**
* Validates that an array of strings contains only TaskIDs.
* @param {string[]} arr - the array of strings to validate
* @returns {Boolean} true if valid array, false otherwise.
*/
const validateTaskIDArray = (arr) => {
- if (Array.isArray(arr)) {
- let validArray = true;
- arr.forEach((item) => {
- if (typeof(item) === 'string') {
- if (item.length !== 16) validArray = false;
- } else validArray = false;
- });
- return validArray;
- }
- return false;
+ if (Array.isArray(arr)) {
+ let validArray = true;
+ arr.forEach((item) => {
+ if (typeof (item) === 'string') {
+ if (item.length !== 16) validArray = false;
+ } else validArray = false;
+ });
+ return validArray;
+ }
+ return false;
};
-
/**
* Middleware(s) to verify requests contain
* necessary and/or valid fields.
*/
const validate = (method) => {
- switch (method) {
- case 'createTask':
- return [
- body('projectID', conductorErrors.err1).exists().isString().isLength({ min: 10, max: 10 }),
- body('title', conductorErrors.err1).exists().isString().isLength({ min: 1, max: 250 }),
- body('description', conductorErrors.err1).optional({ checkFalsy: true }).isString(),
- body('status', conductorErrors.err1).optional({ checkFalsy: true }).isString().custom(validateStatus),
- body('assignees', conductorErrors.err1).optional({ checkFalsy: true }).custom(validateUUIDArray),
- body('parent', conductorErrors.err1).optional({ checkFalsy: true }).isString().isLength({ min: 16, max: 16 }),
- body('dependencies', conductorErrors.err1).optional({ checkFalsy: true }).custom(validateTaskIDArray)
- ]
- case 'batchCreateTask':
- return [
- body('projectID', conductorErrors.err1).exists().isString().isLength({ min: 10, max: 10 }),
- body('tasks', conductorErrors.err1).exists().isNumeric().toInt(),
- body('titlePrefix', conductorErrors.err1).exists().isString().isLength({ min: 1, max: 150 }),
- body('addSubtasks', conductorErrors.err1).optional({ checkFalsy: true }).isBoolean().toBoolean(),
- body('subtasks', conductorErrors.err1).optional({ checkFalsy: true }).isNumeric().toInt(),
- body('subtitlePrefix', conductorErrors.err1).optional({ checkFalsy: true }).isString().isLength({ min: 1, max: 150 })
- ]
- case 'updateTask':
- return [
- body('taskID', conductorErrors.err1).exists().isString().isLength({ min: 16, max: 16 }),
- body('title', conductorErrors.err1).optional({ checkFalsy: true }).isString().isLength({ min: 1, max: 250 }),
- body('description', conductorErrors.err1).optional({ checkFalsy: true }).isString(),
- body('status', conductorErrors.err1).optional({ checkFalsy: true }).isString().custom(validateStatus),
- body('assignees', conductorErrors.err1).optional({ checkFalsy: true }).custom(validateUUIDArray),
- body('dependencies', conductorErrors.err1).optional({ checkFalsy: true }).custom(validateTaskIDArray),
- body('startDate', conductorErrors.err1).optional({ checkFalsy: true }).isDate({ format: 'YYYY-MM-DD', strictMode: true }),
- body('endDate', conductorErrors.err1).optional({ checkFalsy: true }).isDate({ format: 'YYYY-MM-DD', strictMode: true })
- ]
- case 'deleteTask':
- return [
- body('taskID', conductorErrors.err1).exists().isString().isLength({ min: 16, max: 16 })
- ]
- case 'getTask':
- return [
- query('taskID', conductorErrors.err1).exists().isString().isLength({ min: 16, max: 16 })
- ]
- case 'getProjectTasks':
- return [
- query('projectID', conductorErrors.err1).exists().isString().isLength({ min: 10, max: 10 })
- ]
- case 'addTaskAssignee':
- return [
- body('taskID', conductorErrors.err1).exists().isString().isLength({ min: 16, max: 16}),
- body('assignee', conductorErrors.err1).exists().isString().isUUID(),
- body('subtasks', conductorErrors.err1).optional({ checkFalsy: true }).isBoolean().toBoolean()
- ]
- case 'assignAllToTask':
- return [
- body('taskID', conductorErrors.err1).exists().isString().isLength({ min: 16, max: 16}),
- body('subtasks', conductorErrors.err1).optional({ checkFalsy: true }).isBoolean().toBoolean()
- ]
- case 'removeTaskAssignee':
- return [
- body('taskID', conductorErrors.err1).exists().isString().isLength({ min: 16, max: 16}),
- body('assignee', conductorErrors.err1).exists().isString().isUUID(),
- body('subtasks', conductorErrors.err1).optional({ checkFalsy: true }).isBoolean().toBoolean()
- ]
- }
+ switch (method) {
+ case 'createTask':
+ return [
+ body('projectID', conductorErrors.err1).exists().isString().isLength({ min: 10, max: 10 }),
+ body('title', conductorErrors.err1).exists().isString().isLength({ min: 1, max: 250 }),
+ body('description', conductorErrors.err1).optional({ checkFalsy: true }).isString(),
+ body('status', conductorErrors.err1).optional({ checkFalsy: true }).isString().custom(validateStatus),
+ body('assignees', conductorErrors.err1).optional({ checkFalsy: true }).custom(validateUUIDArray),
+ body('parent', conductorErrors.err1).optional({ checkFalsy: true }).isString().isLength({ min: 16, max: 16 }),
+ body('dependencies', conductorErrors.err1).optional({ checkFalsy: true }).custom(validateTaskIDArray),
+ ];
+ case 'batchCreateTask':
+ return [
+ body('projectID', conductorErrors.err1).exists().isString().isLength({ min: 10, max: 10 }),
+ body('tasks', conductorErrors.err1).exists().isNumeric().toInt(),
+ body('titlePrefix', conductorErrors.err1).exists().isString().isLength({ min: 1, max: 150 }),
+ body('addSubtasks', conductorErrors.err1).optional({ checkFalsy: true }).isBoolean().toBoolean(),
+ body('subtasks', conductorErrors.err1).optional({ checkFalsy: true }).isNumeric().toInt(),
+ body('subtitlePrefix', conductorErrors.err1).optional({ checkFalsy: true }).isString().isLength({ min: 1, max: 150 }),
+ ];
+ case 'updateTask':
+ return [
+ body('taskID', conductorErrors.err1).exists().isString().isLength({ min: 16, max: 16 }),
+ body('title', conductorErrors.err1).optional({ checkFalsy: true }).isString().isLength({ min: 1, max: 250 }),
+ body('description', conductorErrors.err1).optional({ checkFalsy: true }).isString(),
+ body('status', conductorErrors.err1).optional({ checkFalsy: true }).isString().custom(validateStatus),
+ body('assignees', conductorErrors.err1).optional({ checkFalsy: true }).custom(validateUUIDArray),
+ body('dependencies', conductorErrors.err1).optional({ checkFalsy: true }).custom(validateTaskIDArray),
+ body('startDate', conductorErrors.err1).optional({ checkFalsy: true }).isDate({ format: 'YYYY-MM-DD', strictMode: true }),
+ body('endDate', conductorErrors.err1).optional({ checkFalsy: true }).isDate({ format: 'YYYY-MM-DD', strictMode: true }),
+ ];
+ case 'deleteTask':
+ return [
+ body('taskID', conductorErrors.err1).exists().isString().isLength({ min: 16, max: 16 }),
+ ];
+ case 'getTask':
+ return [
+ query('taskID', conductorErrors.err1).exists().isString().isLength({ min: 16, max: 16 }),
+ ];
+ case 'getProjectTasks':
+ return [
+ query('projectID', conductorErrors.err1).exists().isString().isLength({ min: 10, max: 10 }),
+ ];
+ case 'addTaskAssignee':
+ return [
+ body('taskID', conductorErrors.err1).exists().isString().isLength({ min: 16, max: 16 }),
+ body('assignee', conductorErrors.err1).exists().isString().isUUID(),
+ body('subtasks', conductorErrors.err1).optional({ checkFalsy: true }).isBoolean().toBoolean(),
+ ];
+ case 'assignAllToTask':
+ return [
+ body('taskID', conductorErrors.err1).exists().isString().isLength({ min: 16, max: 16 }),
+ body('subtasks', conductorErrors.err1).optional({ checkFalsy: true }).isBoolean().toBoolean(),
+ ];
+ case 'removeTaskAssignee':
+ return [
+ body('taskID', conductorErrors.err1).exists().isString().isLength({ min: 16, max: 16 }),
+ body('assignee', conductorErrors.err1).exists().isString().isUUID(),
+ body('subtasks', conductorErrors.err1).optional({ checkFalsy: true }).isBoolean().toBoolean(),
+ ];
+ }
};
export default {
- createTask,
- batchCreateTask,
- updateTask,
- deleteTask,
- getTask,
- getProjectTasks,
- validate,
- addTaskAssignee,
- assignAllMembersToTask,
- removeTaskAssignee
-}
+ createTask,
+ batchCreateTask,
+ updateTask,
+ deleteTask,
+ getTask,
+ getProjectTasks,
+ validate,
+ addTaskAssignee,
+ assignAllMembersToTask,
+ removeTaskAssignee,
+};
diff --git a/server/api/translationfeedback.js b/server/api/translationfeedback.js
index e8393d7c..8e0b8618 100644
--- a/server/api/translationfeedback.js
+++ b/server/api/translationfeedback.js
@@ -3,7 +3,6 @@
// translationfeedback.js
//
-'use strict';
import { body, query } from 'express-validator';
import TranslationFeedback from '../models/translationfeedback.js';
import conductorErrors from '../conductor-errors.js';
@@ -20,38 +19,36 @@ import { debugError } from '../debug.js';
* VALIDATION: 'submitFeedback'
*/
const submitFeedback = (req, res) => {
- let newFeedbackData = {
- language: req.body.language,
- accurate: req.body.accurate,
- page: req.body.page,
- feedback: []
- };
- if (req.body.hasOwnProperty('feedback') && req.body.accurate === false) {
- // data should already be sanitized
- newFeedbackData.feedback = req.body.feedback
- };
- let newFeedback = new TranslationFeedback(newFeedbackData);
- newFeedback.save().then((newDoc) => {
- if (newDoc) {
- return res.send({
- err: false,
- msg: "Translation feedback successfully submitted."
- });
- } else {
- throw('createfail');
- }
- }).catch((err) => {
- let errMsg = conductorErrors.err6;
- if (err.message === 'createfail') errMsg = conductorErrors.err3;
- else debugError(err);
- return res.status(500).send({
- err: true,
- errMsg: errMsg
- });
+ const newFeedbackData = {
+ language: req.body.language,
+ accurate: req.body.accurate,
+ page: req.body.page,
+ feedback: [],
+ };
+ if (req.body.hasOwnProperty('feedback') && req.body.accurate === false) {
+ // data should already be sanitized
+ newFeedbackData.feedback = req.body.feedback;
+ }
+ const newFeedback = new TranslationFeedback(newFeedbackData);
+ newFeedback.save().then((newDoc) => {
+ if (newDoc) {
+ return res.send({
+ err: false,
+ msg: 'Translation feedback successfully submitted.',
+ });
+ }
+ throw ('createfail');
+ }).catch((err) => {
+ let errMsg = conductorErrors.err6;
+ if (err.message === 'createfail') errMsg = conductorErrors.err3;
+ else debugError(err);
+ return res.status(500).send({
+ err: true,
+ errMsg,
});
+ });
};
-
/**
* Retrieves and formats TranslationFeedback records within the date range
* specified in the request body.
@@ -61,87 +58,86 @@ const submitFeedback = (req, res) => {
* VALIDATION: 'exportFeedback'
*/
const exportFeedback = (req, res) => {
- if (req.query.startDate === null) {
- req.query.startDate = new Date(2021, 0, 1);
- }
- if (req.query.endDate === null) {
- req.query.endDate = new Date(2050, 11, 31);
- }
- req.query.startDate.setHours(0,0,0,0);
- req.query.endDate.setHours(23,59,59,999);
- TranslationFeedback.aggregate([
- {
- $match: {
- createdAt: {
- $gte: req.query.startDate,
- $lte: req.query.endDate,
- }
+ if (req.query.startDate === null) {
+ req.query.startDate = new Date(2021, 0, 1);
+ }
+ if (req.query.endDate === null) {
+ req.query.endDate = new Date(2050, 11, 31);
+ }
+ req.query.startDate.setHours(0, 0, 0, 0);
+ req.query.endDate.setHours(23, 59, 59, 999);
+ TranslationFeedback.aggregate([
+ {
+ $match: {
+ createdAt: {
+ $gte: req.query.startDate,
+ $lte: req.query.endDate,
+ },
+ },
+ }, {
+ $sort: {
+ createdAt: 1,
+ },
+ }, {
+ $project: {
+ _id: 0,
+ __v: 0,
+ updatedAt: 0,
+ 'feedback._id': 0,
+ },
+ },
+ ]).then((records) => {
+ const startDateString = `${req.query.startDate.getMonth() + 1}-${req.query.startDate.getDate()}-${req.query.startDate.getFullYear()}`;
+ const endDateString = `${req.query.endDate.getMonth() + 1}-${req.query.endDate.getDate()}-${req.query.endDate.getFullYear()}`;
+ let fileName = `translationfeedback_${startDateString}_${endDateString}`;
+ let fileBuff;
+ if (req.query.format === 'json') {
+ fileName += '.json';
+ const jsonOutput = {
+ submissions: records,
+ };
+ fileBuff = Buffer.from(JSON.stringify(jsonOutput));
+ } else {
+ fileName += '.csv';
+ let csvString = 'language,accurate,page,date,feedback_1,feedback_1_corrected,feedback_2,feedback_2_corrected,feedback_3,feedback_3_corrected,feedback_4,feedback_4_corrected\n';
+ records.forEach((item) => {
+ const itemDate = new Date(item.createdAt);
+ let newCSVString = `${item.language},${item.accurate},${item.page},${itemDate.toUTCString().replace(',', '')},`;
+ for (let idx = 0; idx < 4; idx++) {
+ if (typeof (item.feedback[idx]) !== 'undefined') {
+ if (item.feedback[idx].incorrect) {
+ newCSVString += `${item.feedback[idx].incorrect},`;
+ } else {
+ newCSVString += 'null,';
}
- }, {
- $sort: {
- createdAt: 1
- }
- }, {
- $project: {
- _id: 0,
- __v: 0,
- updatedAt: 0,
- 'feedback._id': 0
+ if (item.feedback[idx].corrected) {
+ newCSVString += `${item.feedback[idx].corrected},`;
+ } else {
+ newCSVString += 'null,';
}
+ }
}
- ]).then((records) => {
- let startDateString = `${req.query.startDate.getMonth() + 1}-${req.query.startDate.getDate()}-${req.query.startDate.getFullYear()}`;
- let endDateString = `${req.query.endDate.getMonth() + 1}-${req.query.endDate.getDate()}-${req.query.endDate.getFullYear()}`;
- let fileName = `translationfeedback_${startDateString}_${endDateString}`;
- let fileBuff;
- if (req.query.format === 'json') {
- fileName += '.json';
- let jsonOutput = {
- submissions: records
- };
- fileBuff = Buffer.from(JSON.stringify(jsonOutput));
- } else {
- fileName += '.csv';
- let csvString = 'language,accurate,page,date,feedback_1,feedback_1_corrected,feedback_2,feedback_2_corrected,feedback_3,feedback_3_corrected,feedback_4,feedback_4_corrected\n';
- records.forEach((item) => {
- let itemDate = new Date(item.createdAt);
- let newCSVString = `${item.language},${item.accurate},${item.page},${itemDate.toUTCString().replace(',', '')},`;
- for (let idx = 0; idx < 4; idx++) {
- if (typeof(item.feedback[idx]) !== 'undefined') {
- if (item.feedback[idx].incorrect) {
- newCSVString += `${item.feedback[idx].incorrect},`;
- } else {
- newCSVString += `null,`;
- }
- if (item.feedback[idx].corrected) {
- newCSVString += `${item.feedback[idx].corrected},`;
- } else {
- newCSVString += 'null,';
- }
- }
- }
- // remove trailing commas
- if (newCSVString.endsWith(',')) {
- newCSVString = newCSVString.substring(0, newCSVString.length-1);
- }
- newCSVString += '\n';
- csvString += newCSVString;
- });
- fileBuff = Buffer.from(csvString, 'utf8');
+ // remove trailing commas
+ if (newCSVString.endsWith(',')) {
+ newCSVString = newCSVString.substring(0, newCSVString.length - 1);
}
- res.attachment(fileName);
- return res.send(fileBuff);
- }).catch((err) => {
- debugError(err);
- let errMsg = conductorErrors.err6;
- return res.status(500).send({
- err: true,
- errMsg: errMsg
- });
+ newCSVString += '\n';
+ csvString += newCSVString;
+ });
+ fileBuff = Buffer.from(csvString, 'utf8');
+ }
+ res.attachment(fileName);
+ return res.send(fileBuff);
+ }).catch((err) => {
+ debugError(err);
+ const errMsg = conductorErrors.err6;
+ return res.status(500).send({
+ err: true,
+ errMsg,
});
+ });
};
-
/**
* Sanitizes an array of incorrect translated terms and their (optional)
* corrections to remove extraneous information.
@@ -149,58 +145,56 @@ const exportFeedback = (req, res) => {
* @returns {Object[]} the sanitized array of feedback objects
*/
const sanitizeFeedbackArray = (feedback) => {
- if (Array.isArray(feedback)) {
- return feedback.map((item) => {
- let sanitized = {
- incorrect: '',
- corrected: ''
- };
- if (item.hasOwnProperty('incorrect') && typeof(item.incorrect) === 'string') {
- sanitized.incorrect = item.incorrect;
- }
- if (item.hasOwnProperty('corrected') && typeof(item.corrected) === 'string') {
- sanitized.corrected = item.corrected;
- }
- return sanitized;
- });
- }
- return [];
+ if (Array.isArray(feedback)) {
+ return feedback.map((item) => {
+ const sanitized = {
+ incorrect: '',
+ corrected: '',
+ };
+ if (item.hasOwnProperty('incorrect') && typeof (item.incorrect) === 'string') {
+ sanitized.incorrect = item.incorrect;
+ }
+ if (item.hasOwnProperty('corrected') && typeof (item.corrected) === 'string') {
+ sanitized.corrected = item.corrected;
+ }
+ return sanitized;
+ });
+ }
+ return [];
};
-
/**
* Validates a requested format string for Translation Feedback Export.
* @param {String} format - the string to validate
* @returns {Boolean} true if valid, false otherwise
*/
-const validateExportFormat = (format) => {
- return ['json', 'csv'].includes(format);
-};
-
+const validateExportFormat = (format) => ['json', 'csv'].includes(format);
/**
* Sets up the validation chain(s) for methods in this file.
*/
const validate = (method) => {
- switch (method) {
- case 'submitFeedback':
- return [
- body('language', conductorErrors.err1).exists().isString().isLength({ min: 1, max: 100 }),
- body('accurate', conductorErrors.err1).exists().isBoolean(),
- body('page', conductorErrors.err1).exists().isString().isURL(),
- body('feedback', conductorErrors.err1).optional({ checkFalsy: true }).isArray().customSanitizer(sanitizeFeedbackArray)
- ]
- case 'exportFeedback':
- return [
- query('startDate', conductorErrors.err1).exists().isString().custom(threePartDateStringValidator).customSanitizer(threePartDateStringToDate),
- query('endDate', conductorErrors.err1).exists().isString().custom(threePartDateStringValidator).customSanitizer(threePartDateStringToDate),
- query('format', conductorErrors.err1).exists().custom(validateExportFormat)
- ]
- }
+ switch (method) {
+ case 'submitFeedback':
+ return [
+ body('language', conductorErrors.err1).exists().isString().isLength({ min: 1, max: 100 }),
+ body('accurate', conductorErrors.err1).exists().isBoolean(),
+ body('page', conductorErrors.err1).exists().isString().isURL(),
+ body('feedback', conductorErrors.err1).optional({ checkFalsy: true }).isArray().customSanitizer(sanitizeFeedbackArray),
+ ];
+ case 'exportFeedback':
+ return [
+ query('startDate', conductorErrors.err1).exists().isString().custom(threePartDateStringValidator)
+ .customSanitizer(threePartDateStringToDate),
+ query('endDate', conductorErrors.err1).exists().isString().custom(threePartDateStringValidator)
+ .customSanitizer(threePartDateStringToDate),
+ query('format', conductorErrors.err1).exists().custom(validateExportFormat),
+ ];
+ }
};
export default {
- submitFeedback,
- exportFeedback,
- validate
-}
+ submitFeedback,
+ exportFeedback,
+ validate,
+};
diff --git a/server/api/validators/remixer.ts b/server/api/validators/remixer.ts
new file mode 100644
index 00000000..df99ce8a
--- /dev/null
+++ b/server/api/validators/remixer.ts
@@ -0,0 +1,42 @@
+import { z } from "zod";
+
+export const GetRemixerPageSchema = z.object({
+ params: z.object({
+ id: z.string(),
+ }),
+ body: z.object({
+ path: z.string().min(1),
+ subdomain: z.string().min(1),
+ pageDetails: z.boolean().default(false),
+ currentbook: z.boolean().default(true),
+ option: z
+ .object({
+ includeMatter: z.boolean().default(false),
+ linkTitle: z.boolean().default(false),
+ full: z.boolean().default(false),
+ })
+ .optional(),
+ }),
+ query: z.object({}).optional(),
+});
+
+export const SaveRemixerProjectStateSchema = z.object({
+ params: z.object({
+ id: z.string(),
+ }),
+ body: z.object({
+ currentBook: z.array(z.record(z.string(), z.any())),
+ pathLevelFormats: z.array(z.record(z.string(), z.any())),
+ autoNumbering: z.boolean().optional(),
+ copyModeState: z.string().optional(),
+ }),
+ query: z.object({}).optional(),
+});
+
+export const GetRemixerProjectStateSchema = z.object({
+ params: z.object({
+ id: z.string(),
+ }),
+ body: z.object({}).optional(),
+ query: z.object({}).optional(),
+});
diff --git a/server/migrations/AddRegisteredByToOrgEventParticipant.js b/server/migrations/AddRegisteredByToOrgEventParticipant.js
index 9ffcc608..d2549112 100644
--- a/server/migrations/AddRegisteredByToOrgEventParticipant.js
+++ b/server/migrations/AddRegisteredByToOrgEventParticipant.js
@@ -1,5 +1,5 @@
-import { debug, debugError } from '../debug.js';
import { v4 as uuidv4 } from 'uuid';
+import { debug, debugError } from '../debug.js';
import OrgEventParticipant from '../models/orgeventparticipant.js';
/**
@@ -12,15 +12,13 @@ export async function runMigration() {
debug(`Running migration "${migrationTitle}"...`);
const results = await OrgEventParticipant.find({}).lean();
- const operations = results.map((participant) =>
- OrgEventParticipant.updateOne(
- { _id: participant._id },
- {
- regID: uuidv4(),
- registeredBy: participant.user
- },
- )
- );
+ const operations = results.map((participant) => OrgEventParticipant.updateOne(
+ { _id: participant._id },
+ {
+ regID: uuidv4(),
+ registeredBy: participant.user,
+ },
+ ));
await Promise.all(operations);
debug(`Updated ${operations.length} Participants.`);
diff --git a/server/migrations/Projects_CIDDescriptorsSingleToMultiple.js b/server/migrations/Projects_CIDDescriptorsSingleToMultiple.js
index 788cc772..3c5539b7 100644
--- a/server/migrations/Projects_CIDDescriptorsSingleToMultiple.js
+++ b/server/migrations/Projects_CIDDescriptorsSingleToMultiple.js
@@ -4,7 +4,7 @@ import Project from '../models/project';
/**
* Updates the Projects collection to convert the 'cidDescriptor' string field to the
* 'cidDescriptors' string-array field.
- * If using MongoDB Shell, set the
+ * If using MongoDB Shell, set the
*/
async function ProjectsCIDDescriptorSingleToMultiple() {
try {
@@ -25,7 +25,7 @@ async function ProjectsCIDDescriptorSingleToMultiple() {
},
},
}, {
- $unset: "cidDescriptor",
+ $unset: 'cidDescriptor',
},
]);
if (results.acknowledged) {
diff --git a/server/models/pressbooksimportjob.ts b/server/models/pressbooksimportjob.ts
index 126c1549..97b84966 100644
--- a/server/models/pressbooksimportjob.ts
+++ b/server/models/pressbooksimportjob.ts
@@ -74,6 +74,8 @@ const PressbooksImportJobSchema = new Schema(
},
);
+PressbooksImportJobSchema.index({ projectID: 1, userID: 1 }, { unique: false });
+
const PressbooksImportJob = model(
"PressbooksImportJob",
PressbooksImportJobSchema,
diff --git a/server/models/projectremixer.ts b/server/models/projectremixer.ts
new file mode 100644
index 00000000..b59be6a7
--- /dev/null
+++ b/server/models/projectremixer.ts
@@ -0,0 +1,145 @@
+import base62 from "base62-random";
+import { Document, model, Schema } from "mongoose";
+
+export interface RemixerSubPageState {
+ "@id": string;
+ "@title": string;
+ "@href": string;
+ "@subpages": boolean;
+ article: "article" | "topic-category" | "topic-guide";
+ parentID?: string;
+ namespace: string;
+ title: string;
+ "uri.ui": string;
+ originalPathNumber?: string[];
+ pathNumber?: string[];
+ numberedPath?: string;
+ formattedPath?: string;
+ formattedPathOverride?: boolean;
+ isDeleted?: boolean;
+ isImported?: boolean;
+ isRenamed?: boolean;
+ isPlacementChanged?: boolean;
+ addedItem?: boolean;
+ movedItem?: boolean;
+ renamedItem?: boolean;
+ deletedItem?: boolean;
+ sourceID?: string;
+}
+
+export interface PathLevelFormatState {
+ level: number;
+ excludeParent?: boolean;
+ delimiter?: string;
+ prefix: string;
+ start: number;
+ type: "numeric" | "alphabetic" | "roman" | "none";
+}
+
+export interface PrejectRemixerInterface extends Document {
+ projectID: string;
+ remixerID: string;
+ createdBy: string;
+ updatedBy: string;
+ remixerCurrentBook: RemixerSubPageState[];
+ autoNumbering?: boolean;
+ copyModeState?: string;
+ pathLevelFormats?: PathLevelFormatState[];
+ createdAt: Date;
+ updatedAt: Date;
+ archived: boolean;
+}
+
+const RemixerSubPageStateSchema = new Schema(
+ {
+ "@id": { type: String, required: true },
+ "@title": { type: String, required: true },
+ "@href": { type: String, required: true },
+ "@subpages": { type: Boolean, required: true },
+ article: { type: String, required: true },
+ parentID: { type: String },
+ namespace: { type: String, required: true },
+ title: { type: String, required: true },
+ "uri.ui": { type: String, required: true },
+ originalPathNumber: [{ type: String }],
+ pathNumber: [{ type: String }],
+ numberedPath: { type: String },
+ formattedPath: { type: String },
+ formattedPathOverride: { type: Boolean },
+ isDeleted: { type: Boolean },
+ isImported: { type: Boolean },
+ isRenamed: { type: Boolean },
+ isPlacementChanged: { type: Boolean },
+ addedItem: { type: Boolean },
+ movedItem: { type: Boolean },
+ renamedItem: { type: Boolean },
+ deletedItem: { type: Boolean },
+ },
+ {
+ _id: false,
+ strict: false,
+ },
+);
+
+const PathLevelFormatSchema = new Schema(
+ {
+ level: { type: Number, required: true },
+ excludeParent: { type: Boolean },
+ delimiter: { type: String },
+ prefix: { type: String, required: true, default: "" },
+ start: { type: Number, required: true, default: 1 },
+ type: { type: String, required: true, default: "numeric" },
+ },
+ { _id: false },
+);
+
+const PrejectRemixerSchema = new Schema(
+ {
+ projectID: {
+ type: String,
+ required: true,
+ index: true,
+ },
+ createdBy: {
+ type: String,
+ required: true,
+ default: "",
+ },
+ updatedBy: {
+ type: String,
+ required: true,
+ default: "",
+ },
+ remixerCurrentBook: {
+ type: [RemixerSubPageStateSchema],
+ default: () => [],
+ },
+ autoNumbering: { type: Boolean },
+ copyModeState: { type: String },
+ pathLevelFormats: { type: [PathLevelFormatSchema], default: () => [] },
+ archived:{type:Boolean, default:false},
+ remixerID: {
+ type: String,
+ required: true,
+ default: base62(10),
+ },
+ },
+ {
+ timestamps: true,
+ },
+);
+
+// Only one active (non-archived) remixer state is allowed per project;
+// archived historical snapshots can accumulate freely.
+PrejectRemixerSchema.index(
+ { projectID: 1 },
+ { unique: true, partialFilterExpression: { archived: false } },
+);
+
+const PrejectRemixer = model(
+ "PrejectRemixer",
+ PrejectRemixerSchema,
+ "prejectremixer",
+);
+
+export default PrejectRemixer;
diff --git a/server/models/projectremixerjob.ts b/server/models/projectremixerjob.ts
new file mode 100644
index 00000000..f18e456b
--- /dev/null
+++ b/server/models/projectremixerjob.ts
@@ -0,0 +1,68 @@
+import { model, Schema, Document } from "mongoose";
+
+export type PrejectRemixerJobStatus =
+ | "pending"
+ | "running"
+ | "success"
+ | "error";
+
+export interface PrejectRemixerJobInterface extends Document {
+ jobID: string;
+ remixerID: string;
+ projectID: string;
+ userID: string;
+ status: PrejectRemixerJobStatus;
+ messages: string[];
+ errorMessage?: string;
+}
+
+const PrejectRemixerJobSchema = new Schema(
+ {
+ jobID: {
+ type: String,
+ required: true,
+ unique: true,
+ index: true,
+ },
+ projectID: {
+ type: String,
+ required: true,
+ index: true,
+ },
+ userID: {
+ type: String,
+ required: true,
+ index: true,
+ },
+ status: {
+ type: String,
+ enum: ["pending", "running", "success", "error"],
+ default: "pending",
+ index: true,
+ },
+ messages: {
+ type: [String],
+ default: [],
+ },
+ errorMessage: {
+ type: String,
+ },
+ remixerID: {
+ type: String,
+ required: false,
+ index: true,
+ },
+ },
+ {
+ timestamps: true,
+ },
+);
+
+PrejectRemixerJobSchema.index({ projectID: 1, userID: 1 }, { unique: false });
+
+const PrejectRemixerJob = model(
+ "PrejectRemixerJob",
+ PrejectRemixerJobSchema,
+);
+
+export default PrejectRemixerJob;
\ No newline at end of file
diff --git a/server/package-lock.json b/server/package-lock.json
index b732e2e4..d0ef8f76 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -59,6 +59,7 @@
"mongoose": "^8.15.0",
"multer": "^2.0.2",
"node-cache": "^5.1.2",
+ "node-html-parser": "^7.1.0",
"openai": "^6.0.0",
"path": "^0.12.7",
"react-timezone-select": "^2.1.1",
@@ -7295,6 +7296,14 @@
"node": ">= 0.4"
}
},
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "bin": {
+ "he": "bin/he"
+ }
+ },
"node_modules/helmet": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-4.6.0.tgz",
@@ -10139,6 +10148,15 @@
"webidl-conversions": "^3.0.0"
}
},
+ "node_modules/node-html-parser": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.1.0.tgz",
+ "integrity": "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==",
+ "dependencies": {
+ "css-select": "^5.1.0",
+ "he": "1.2.0"
+ }
+ },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
diff --git a/server/package.json b/server/package.json
index f9c312fc..4d10bfe6 100644
--- a/server/package.json
+++ b/server/package.json
@@ -61,6 +61,7 @@
"mongoose": "^8.15.0",
"multer": "^2.0.2",
"node-cache": "^5.1.2",
+ "node-html-parser": "^7.1.0",
"openai": "^6.0.0",
"path": "^0.12.7",
"react-timezone-select": "^2.1.1",
diff --git a/server/server.ts b/server/server.ts
index 7c6c118d..eb489ec8 100644
--- a/server/server.ts
+++ b/server/server.ts
@@ -44,8 +44,8 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
mongoose.Promise = Promise;
-mongoose.set("debug", process.env.NODE_ENV === "development");
-
+// mongoose.set("debug", process.env.NODE_ENV === "development");
+mongoose.set("autoIndex", process.env.NODE_ENV !== "development");
await mongoose
.connect(process.env.MONGOOSEURI ?? "", {
maxPoolSize: process.env.ORG_ID === "libretexts" ? 100 : 25,
@@ -112,10 +112,10 @@ app.use("/health", (_req, res) =>
);
// Serve frontend assets. Use directories relative to server/dist
-app.use(express.static(path.join(__dirname, "../../client/dist")));
+app.use(express.static(path.join(__dirname, "../client/dist")));
let cliRouter = express.Router();
cliRouter.route("*").get((_req, res) => {
- res.sendFile(path.resolve(__dirname, "../../client/dist/index.html"));
+ res.sendFile(path.resolve(__dirname, "../client/dist/index.html"));
});
app.use("/", cliRouter);
diff --git a/server/util/CXOne/CXOnePageAPIEndpoints.ts b/server/util/CXOne/CXOnePageAPIEndpoints.ts
index 7726f141..32483ccd 100644
--- a/server/util/CXOne/CXOnePageAPIEndpoints.ts
+++ b/server/util/CXOne/CXOnePageAPIEndpoints.ts
@@ -3,6 +3,7 @@ const DREAM_OUT_FORMAT = "dream.out.format=json";
const CXOnePageAPIEndpoints = {
GET_Page: `?${DREAM_OUT_FORMAT}`,
GET_Page_Contents: (format: 'html' | 'json') => `contents${format === 'json' ? `?${DREAM_OUT_FORMAT}` : ''}`,
+ GET_page_RawContents: `contents?mode=raw&${DREAM_OUT_FORMAT}`,
GET_Page_Files: `files?${DREAM_OUT_FORMAT}`,
GET_Page_File: (fileName: string) =>
`files/${encodeURIComponent(fileName)}`,
@@ -11,7 +12,7 @@ const CXOnePageAPIEndpoints = {
GET_Page_Properties: `properties?${DREAM_OUT_FORMAT}`,
GET_Page_Security: `security?${DREAM_OUT_FORMAT}`,
GET_Page_Tree: `tree?${DREAM_OUT_FORMAT}&include=properties,lastmodified`,
- GET_Subpages: `subpages?${DREAM_OUT_FORMAT}`,
+ GET_Subpages: `subpages?${DREAM_OUT_FORMAT}&limit=all`,
GET_Page_Tags: `tags?${DREAM_OUT_FORMAT}`,
POST_Contents: `contents?${DREAM_OUT_FORMAT}`,
POST_Contents_Title: (title: string) =>
@@ -24,6 +25,8 @@ const CXOnePageAPIEndpoints = {
`properties/${encodeURIComponent(property)}?${DREAM_OUT_FORMAT}`,
PUT_Page_Tags: `tags?${DREAM_OUT_FORMAT}`,
PUT_Security: `security?${DREAM_OUT_FORMAT}`,
+ DREAM_OUT_FORMAT: `?${DREAM_OUT_FORMAT}`,
+ DREAM_OUT_FORMAT_LIMIT: (limit: number):string => `?${DREAM_OUT_FORMAT}&limit=${limit}`,
};
export default CXOnePageAPIEndpoints;
diff --git a/server/util/CXOne/CXOneRemixerTemplates.ts b/server/util/CXOne/CXOneRemixerTemplates.ts
new file mode 100644
index 00000000..3e99d83b
--- /dev/null
+++ b/server/util/CXOne/CXOneRemixerTemplates.ts
@@ -0,0 +1,152 @@
+const RemixerTemplates = {
+ // ── New blank pages ──────────────────────────────────────────────────────────
+
+ POST_CreateBlankTopicCategory: `{{template.ShowOrg()}}
+
+ Tags recommended by the template:
+ article:topic-category
+
`,
+
+ POST_CreateBlankTopicGuide: `{{template.ShowOrg()}}
+
+ Tags recommended by the template:
+ article:topic-guide
+
`,
+
+ POST_CreateBlankCoverPage: `{{template.ShowOrg()}}
+
+ Tags recommended by the template:
+ article:topic-category coverpage:yes
+
`,
+
+ POST_CreateBlankPage: (articleType: string, tags: string[] = []) =>
+ `
+ Tags recommended by the template:
+ article:${articleType} ${tags.map((t) => `${t} `).join("")}
+
`,
+
+ // ── Transclusion ─────────────────────────────────────────────────────────────
+
+ POST_TranscludeCrossLibrary: (
+ sourceSubdomain: string,
+ sourceID: number | string,
+ sourceURL: string,
+ tags: string[],
+ ) =>
+ `
+
+
+ template('CrossTransclude/Web',{'Library':'${sourceSubdomain}','PageID':${sourceID}});
+
+
+
+ Tags recommended by the template:
+ ${tags.map((t) => `${t} `).join("")}
+
`,
+
+ POST_TranscludeSameLibrary: (sourcePath: string, tags: string[]) =>
+ `
+
+ wiki.page("${sourcePath}", NULL)
+
+
+ Tags recommended by the template:
+ ${tags.map((t) => `${t} `).join("")}
+
`,
+
+ // ── Fork / Full-copy ─────────────────────────────────────────────────────────
+
+ /**
+ * Rewrites relative API paths to absolute source URLs and strips stale
+ * fileid attributes. Pass the raw HTML body from the source page.
+ */
+ POST_ForkPage: (
+ rawSourceHTML: string,
+ sourceSubdomain: string,
+ tags: string[],
+ ) =>
+ rawSourceHTML
+ .replace(
+ /\/@api\/deki/g,
+ `https://${sourceSubdomain}.libretexts.org/@api/deki`,
+ )
+ .replace(/ fileid=".*?"/g, "") +
+ `\n
+ Tags recommended by the template:
+ ${tags.map((t) => `${t} `).join("")}
+
`,
+
+ /**
+ * Applies file migration results (from processFile()) to rewrite all file
+ * URLs and IDs in the source HTML, then appends the tag block.
+ */
+ POST_FullCopyPage: (
+ rawSourceHTML: string,
+ fileMigrations: {
+ original: string;
+ final: string;
+ oldID: string;
+ newID: string;
+ }[],
+ tags: string[],
+ ) => {
+ let contents = rawSourceHTML;
+ for (const m of fileMigrations) {
+ contents = contents.replace(m.original, m.final);
+ contents = contents.replace(`fileid="${m.oldID}"`, `fileid="${m.newID}"`);
+ }
+ return (
+ contents +
+ `\n
+ Tags recommended by the template:
+ ${tags.map((t) => `${t} `).join("")}
+
`
+ );
+ },
+
+ // ── Tags ─────────────────────────────────────────────────────────────────────
+
+ PUT_PageTags: (tags: string[]) =>
+ `
+ ${tags.map((t) => ` `).join("\n ")}
+ `,
+
+ // ── Properties ───────────────────────────────────────────────────────────────
+
+ PUT_Properties: (
+ properties: { name: string; value: string; etag?: string }[],
+ ) =>
+ `
+ ${properties
+ .map(
+ (p) =>
+ `
+ ${p.value}
+ `,
+ )
+ .join("\n ")}
+ `,
+
+ // ── Well-known property values ────────────────────────────────────────────────
+
+ PROP_GuideTabs: JSON.stringify([
+ {
+ templateKey: "Topic_hierarchy",
+ templateTitle: "Topic hierarchy",
+ templatePath: "MindTouch/IDF3/Views/Topic_hierarchy",
+ guid: "fc488b5c-f7e1-1cad-1a9a-343d5c8641f5",
+ },
+ ]),
+
+ PROP_GuideDisplay: "single",
+
+ PROP_SubpageListing: "simple",
+
+ PROP_WelcomeHidden: "true",
+};
+
+export default RemixerTemplates;
diff --git a/server/util/helpers.js b/server/util/helpers.js
index bad6cdbd..1281c4d0 100644
--- a/server/util/helpers.js
+++ b/server/util/helpers.js
@@ -3,10 +3,10 @@
// helpers.js
//
import { validate as uuidValidate } from 'uuid';
-import { format as formatDate, parseISO } from "date-fns";
-import { z } from "zod";
-import { debugError } from "../debug.js";
-import conductorErrors from "../conductor-errors.js";
+import { format as formatDate, parseISO } from 'date-fns';
+import { z } from 'zod';
+import { debugError } from '../debug.js';
+import conductorErrors from '../conductor-errors.js';
/**
* Checks that a string has a (trimmed) length greater than 0.
@@ -14,11 +14,10 @@ import conductorErrors from "../conductor-errors.js";
* @returns {Boolean} True if non-empty or not a string, false otherwise.
*/
export const isEmptyString = (str) => {
- if (typeof(str) === 'string') return (!str || str.trim().length === 0);
- return false;
+ if (typeof (str) === 'string') return (!str || str.trim().length === 0);
+ return false;
};
-
/**
* Accepts a string and returns it truncated to the
* specified length. If the string is already shorter
@@ -29,15 +28,14 @@ export const isEmptyString = (str) => {
* @returns {string} the truncated (if applicable) string
*/
export const truncateString = (str, len) => {
- if (typeof(str) !== 'string' || str.length === 0) {
- return '';
- }
- if (str.length > len) {
- let subString = str.substring(0, len);
- return subString + "...";
- } else {
- return str;
- }
+ if (typeof (str) !== 'string' || str.length === 0) {
+ return '';
+ }
+ if (str.length > len) {
+ const subString = str.substring(0, len);
+ return `${subString}...`;
+ }
+ return str;
};
/**
@@ -46,49 +44,45 @@ export const truncateString = (str, len) => {
* @returns {String} The modified string.
*/
export const capitalizeFirstLetter = (str) => {
- if (typeof(str) !== 'string' || str.length === 0) {
+ if (typeof (str) !== 'string' || str.length === 0) {
return '';
}
- if(str.length === 1) return str[0].toUpperCase();
+ if (str.length === 1) return str[0].toUpperCase();
return str.charAt(0).toUpperCase() + str.slice(1);
};
-
/**
* Constructs a basic array with OrgIDs given
* an array of Role objects.
*/
export const buildOrgArray = (roles) => {
- var orgs = [];
- roles.forEach((item) => {
- if (item.org) {
- orgs.push(item.org);
- }
- });
- return orgs;
+ const orgs = [];
+ roles.forEach((item) => {
+ if (item.org) {
+ orgs.push(item.org);
+ }
+ });
+ return orgs;
};
-
/**
* Validates that an array of strings contains only UUIDs.
* @param {string[]} arr - the array of strings to validate
* @returns {Boolean} true if valid array, false otherwise.
*/
export const validateUUIDArray = (arr) => {
- if (Array.isArray(arr)) {
- let validArray = true;
- arr.forEach((item) => {
- if (typeof(item) !== 'string' || !uuidValidate(item)) {
- validArray = false;
- }
- });
- return validArray
- }
- return false;
+ if (Array.isArray(arr)) {
+ let validArray = true;
+ arr.forEach((item) => {
+ if (typeof (item) !== 'string' || !uuidValidate(item)) {
+ validArray = false;
+ }
+ });
+ return validArray;
+ }
+ return false;
};
-
-
/**
* Attempts to convert a given string in the format 'MM-DD-YYYY' to
* a native Date object.
@@ -96,37 +90,36 @@ export const validateUUIDArray = (arr) => {
* @returns {(Date|null)} Date if valid, null otherwise
*/
export const threePartDateStringToDate = (value) => {
- try {
- let dateComp = String(value).split('-');
- let month, day, year;
- if (dateComp.length == 3) {
- month = parseInt(dateComp[0]) - 1;
- day = parseInt(dateComp[1]);
- year = parseInt(dateComp[2]);
- }
- if (!isNaN(month) && !isNaN(day) && !isNaN(year)) {
- return new Date(year, month, day);
- }
- } catch (err) {
- return null;
+ try {
+ const dateComp = String(value).split('-');
+ let month; let day; let
+ year;
+ if (dateComp.length == 3) {
+ month = parseInt(dateComp[0]) - 1;
+ day = parseInt(dateComp[1]);
+ year = parseInt(dateComp[2]);
}
+ if (!isNaN(month) && !isNaN(day) && !isNaN(year)) {
+ return new Date(year, month, day);
+ }
+ } catch (err) {
return null;
+ }
+ return null;
};
-
/**
* Returns an array of strings with duplicates filtered out.
* @param {String[]} arr - the array to filter.
* @returns {String[]} the unique array of strings
*/
export const ensureUniqueStringArray = (arr) => {
- if (Array.isArray(arr)) {
- return Array.from(new Set(arr.filter((item) => item))); // filter empty strings
- }
- return [];
+ if (Array.isArray(arr)) {
+ return Array.from(new Set(arr.filter((item) => item))); // filter empty strings
+ }
+ return [];
};
-
/**
* Check if a string contains any of the substrings in the provided array.
* @param {String} str - the string to check.
@@ -135,34 +128,30 @@ export const ensureUniqueStringArray = (arr) => {
* @returns {Boolean|Object} Boolean of search result, or an object with the result boolean and the first matched substring.
*/
export const stringContainsOneOfSubstring = (str, arr, returnStr) => {
- let contains = false;
- let foundSubstring = '';
- if (typeof(str) === 'string' && Array.isArray(arr)) {
- arr.forEach((item) => {
- if (!contains && typeof(item) === 'string' && str.includes(item)) {
- contains = true;
- foundSubstring = item;
- }
- });
- }
- if (typeof(returnStr) === 'boolean' && returnStr === true) {
- return {
- result: contains,
- substr: foundSubstring
- };
- }
- return contains;
+ let contains = false;
+ let foundSubstring = '';
+ if (typeof (str) === 'string' && Array.isArray(arr)) {
+ arr.forEach((item) => {
+ if (!contains && typeof (item) === 'string' && str.includes(item)) {
+ contains = true;
+ foundSubstring = item;
+ }
+ });
+ }
+ if (typeof (returnStr) === 'boolean' && returnStr === true) {
+ return {
+ result: contains,
+ substr: foundSubstring,
+ };
+ }
+ return contains;
};
-
/**
* Checks if a native Date is properly instantiated.
* @param {Object} date - The Date object to validate.
*/
-export const isValidDateObject = (date) => {
- return date instanceof Date && !isNaN(date);
-};
-
+export const isValidDateObject = (date) => date instanceof Date && !isNaN(date);
/**
* Computes the difference (in milliseconds) between two dates.
@@ -171,23 +160,22 @@ export const isValidDateObject = (date) => {
* @returns {number} The difference in milliseconds.
*/
export const computeDateDifference = (date1, date2) => {
- if (isValidDateObject(date1) && isValidDateObject(date2)) {
- return Math.abs(date2 - date1);
- }
- return 0;
+ if (isValidDateObject(date1) && isValidDateObject(date2)) {
+ return Math.abs(date2 - date1);
+ }
+ return 0;
};
-
/**
* Creates and validates a new Date from a provided date string.
* @param {string} dateString - The date string to use in instantiation.
* @returns {Date|null} A date object if successfully created, null otherwise.
*/
export const createAndValidateDateObject = (dateString) => {
- let newDate = null;
- const dateObj = new Date(dateString);
- if (isValidDateObject(dateObj)) newDate = dateObj;
- return newDate;
+ let newDate = null;
+ const dateObj = new Date(dateString);
+ if (isValidDateObject(dateObj)) newDate = dateObj;
+ return newDate;
};
/**
@@ -197,26 +185,23 @@ export const createAndValidateDateObject = (dateString) => {
* @returns Formatted date string.
*/
export function parseAndFormatDate(date, formatString) {
- try {
- if (date instanceof Date) {
- return formatDate(date, formatString);
- }
- return formatDate(parseISO(date), formatString);
- } catch (e) {
- console.error(e);
+ try {
+ if (date instanceof Date) {
+ return formatDate(date, formatString);
}
- return "Unknown Date";
+ return formatDate(parseISO(date), formatString);
+ } catch (e) {
+ console.error(e);
}
-
+ return 'Unknown Date';
+}
/**
* Returns the production URL set in the server's environment variables.
*
* @returns {string} The production URL or an empty string if not found.
*/
-export const getProductionURL = () => {
- return process.env.CONDUCTOR_DOMAIN || '';
-};
+export const getProductionURL = () => process.env.CONDUCTOR_DOMAIN || '';
/**
* Removes a leading slash, if any, from a string.
@@ -288,11 +273,11 @@ const hexColorSchema = z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/);
* @returns {string} - sanitized hex code with '#' prepended, empty string if sanitizing failed
*/
export function sanitizeCustomColor(hexString) {
- if (typeof hexString !== "string" || hexString.length < 6) {
- return "";
+ if (typeof hexString !== 'string' || hexString.length < 6) {
+ return '';
}
- if (hexString.charAt(0) !== "#") {
+ if (hexString.charAt(0) !== '#') {
hexString = `#${hexString}`;
}
@@ -302,7 +287,7 @@ export function sanitizeCustomColor(hexString) {
return hexString;
}
- return "";
+ return '';
}
/**
@@ -329,7 +314,7 @@ export function getPaginationOffset(page, offsetMultiplier = 25) {
/**
* Generates a random number between 0 and a given limit
* @param {Number} total - The maximum number to generate a random number for
- * @param {Number} limit - The limit for the random number
+ * @param {Number} limit - The limit for the random number
* @returns {Number} - A random number between 0 and max
*/
export function getRandomOffset(total, limit) {
@@ -346,22 +331,21 @@ export function getRandomOffset(total, limit) {
* @returns {string[]}
*/
export function parseLibreTextsURL(url) {
- if (url.includes('?')) //strips any query parameters
- url = url.split('?')[0];
+ if (url.includes('?')) // strips any query parameters
+ { url = url.split('?')[0]; }
if (url && url.match(/https?:\/\/.*?\.libretexts\.org/)) {
- return [url.match(/(?<=https?:\/\/).*?(?=\.)/)[0], url.match(/(?<=https?:\/\/.*?\/).*/)[0]]
- }
- else {
- return [];
+ return [url.match(/(?<=https?:\/\/).*?(?=\.)/)[0], url.match(/(?<=https?:\/\/.*?\/).*/)[0]];
}
+
+ return [];
}
export async function sleep(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
+ return new Promise((resolve) => setTimeout(resolve, ms));
}
-export function getSubdomainFromUrl(url){
- const hostname = new URL(url).hostname;
+export function getSubdomainFromUrl(url) {
+ const { hostname } = new URL(url);
const parts = hostname.split('.');
if (parts.length > 2) {
return parts[0];
@@ -370,10 +354,10 @@ export function getSubdomainFromUrl(url){
}
export function extractEmailDomain(email) {
- if(!email) return null;
- const parts = email.split("@");
+ if (!email) return null;
+ const parts = email.split('@');
if (parts.length === 2) {
- return parts[1];
+ return parts[1];
}
return null;
}
diff --git a/server/util/librariesclient.ts b/server/util/librariesclient.ts
index 2f67638a..8a6dbb37 100644
--- a/server/util/librariesclient.ts
+++ b/server/util/librariesclient.ts
@@ -115,6 +115,11 @@ class LibrariesSSMClient {
}
}
+/** Exposes SSM-backed library credentials (used by admin/debug routes). */
+export async function getLibraryCredentials(lib: string) {
+ return LibrariesSSMClient.getInstance().getLibraryCredentials(lib);
+}
+
/**
* Generates the set of request headers required for interacting with a library's API,
* including the API token.
@@ -219,6 +224,9 @@ export async function CXOneFetch(params: CXOneFetchParams): Promise {
query,
queryIsFirst
)}`;
+ if(api.includes("properties")){
+ console.log(finalOptions);
+ }
request = fetch(url, finalOptions);
}
@@ -256,7 +264,8 @@ export async function addPageProperty(
subdomain: string,
path: string | number,
property: keyof typeof CXOne.PageProps,
- value: string | boolean | number
+ value: string | boolean | number,
+ method: "POST" | "PUT" | "DELETE" | "GET" = "POST"
): Promise {
try {
const addRes = await CXOneFetch({
@@ -265,9 +274,10 @@ export async function addPageProperty(
api: CXOne.API.Page.POST_Properties,
subdomain: subdomain,
options: {
- method: "POST",
+ method,
body: value,
headers: { Slug: CXOne.PageProps[property] },
+
},
});
diff --git a/server/util/mtkeys.js b/server/util/mtkeys.js
index 24a89fd9..1a4ce5c5 100644
--- a/server/util/mtkeys.js
+++ b/server/util/mtkeys.js
@@ -7,31 +7,29 @@ import base64 from 'base-64';
let browserKeys = {};
const decodeBrowserKeys = () => {
- if (process.env.BROWSERKEYS) {
- const keyVar = String(process.env.BROWSERKEYS);
- const decodedKeys = base64.decode(keyVar);
- browserKeys = JSON.parse(decodedKeys);
- }
+ if (process.env.BROWSERKEYS) {
+ const keyVar = String(process.env.BROWSERKEYS);
+ const decodedKeys = base64.decode(keyVar);
+ browserKeys = JSON.parse(decodedKeys);
+ }
};
const checkKeyStatus = () => {
- if (Object.keys(browserKeys).length > 0) {
- return true;
- }
- return false;
+ if (Object.keys(browserKeys).length > 0) {
+ return true;
+ }
+ return false;
};
export const getBrowserKeyForLib = (lib) => {
- // check if keys are already decoded, otherwise do it now
- if (checkKeyStatus()) {
- return browserKeys[lib];
- } else {
- decodeBrowserKeys();
- // check if the decode attempt worked
- if (checkKeyStatus()) {
- return browserKeys[lib];
- } else {
- return 'err';
- }
- }
+ // check if keys are already decoded, otherwise do it now
+ if (checkKeyStatus()) {
+ return browserKeys[lib];
+ }
+ decodeBrowserKeys();
+ // check if the decode attempt worked
+ if (checkKeyStatus()) {
+ return browserKeys[lib];
+ }
+ return 'err';
};
diff --git a/server/util/peerreviewutils.js b/server/util/peerreviewutils.js
index 219a6ea4..cce36302 100644
--- a/server/util/peerreviewutils.js
+++ b/server/util/peerreviewutils.js
@@ -1,14 +1,13 @@
/**
* LibreTexts Conductor
* peerreviewutils.js
- * @file Exposes helper functions and objects for Conductor Peer Review features.
+ * @file Exposes helper functions and objects for Conductor Peer Review features.
*/
export const peerReviewPromptTypes = ['3-likert', '5-likert', '7-likert', 'text', 'dropdown', 'checkbox'];
export const peerReviewAuthorTypes = ['student', 'instructor'];
-
/**
* Builds a MongoDB Aggregation pipeline for a Project's Peer Reviews.
* @param {String} identifier - A ProjectID or PeerReviewID to query on. PeerReviewID requires 'review' set to true.
@@ -16,120 +15,115 @@ export const peerReviewAuthorTypes = ['student', 'instructor'];
* @returns {Object[]|Error} The compiled aggregation pipeline, or an error if an identifier is unspecified.
*/
export const buildPeerReviewAggregation = (identifier, review = false) => {
- let matchObj = {};
- if (typeof(identifier) === 'string' && identifier.length > 0) {
- if (review) { // peerReviewID
- matchObj = {
- $match: {
- peerReviewID: identifier
- }
- };
- } else { // projectID
- matchObj = {
- $match: {
- projectID: identifier
- }
- };
- }
- }
- if (Object.keys(matchObj).length > 0) {
- return [matchObj, {
- $lookup: {
- from: 'users',
- let: {
- author: '$author',
- anonAuthor: '$anonAuthor'
- },
- pipeline: [
- {
- $match: {
- $expr: {
- $and: [{
- $eq: ['$$anonAuthor', false]
- }, {
- $eq: ['$uuid', '$$author']
- }]
- }
- }
- }, {
- $project: {
- firstName: 1,
- lastName: 1
- }
- }
- ],
- as: 'authorInfo'
- }
- }, {
- $addFields: {
- authorInfo: {
- $arrayElemAt: ['$authorInfo', 0]
- }
- }
- }, {
- $addFields: {
- author: {
- $cond: {
- if: {
- $eq: ['$anonAuthor', false]
- },
- then: {
- $concat: ['$authorInfo.firstName', ' ', '$authorInfo.lastName']
- },
- else: '$author'
- }
- }
- }
- }, {
- $project: {
- _id: 0,
- __v: 0,
- authorEmail: 0,
- authorInfo: 0
- }
- }
- ];
+ let matchObj = {};
+ if (typeof (identifier) === 'string' && identifier.length > 0) {
+ if (review) { // peerReviewID
+ matchObj = {
+ $match: {
+ peerReviewID: identifier,
+ },
+ };
+ } else { // projectID
+ matchObj = {
+ $match: {
+ projectID: identifier,
+ },
+ };
}
- throw ('reviewidentifier');
+ }
+ if (Object.keys(matchObj).length > 0) {
+ return [matchObj, {
+ $lookup: {
+ from: 'users',
+ let: {
+ author: '$author',
+ anonAuthor: '$anonAuthor',
+ },
+ pipeline: [
+ {
+ $match: {
+ $expr: {
+ $and: [{
+ $eq: ['$$anonAuthor', false],
+ }, {
+ $eq: ['$uuid', '$$author'],
+ }],
+ },
+ },
+ }, {
+ $project: {
+ firstName: 1,
+ lastName: 1,
+ },
+ },
+ ],
+ as: 'authorInfo',
+ },
+ }, {
+ $addFields: {
+ authorInfo: {
+ $arrayElemAt: ['$authorInfo', 0],
+ },
+ },
+ }, {
+ $addFields: {
+ author: {
+ $cond: {
+ if: {
+ $eq: ['$anonAuthor', false],
+ },
+ then: {
+ $concat: ['$authorInfo.firstName', ' ', '$authorInfo.lastName'],
+ },
+ else: '$author',
+ },
+ },
+ },
+ }, {
+ $project: {
+ _id: 0,
+ __v: 0,
+ authorEmail: 0,
+ authorInfo: 0,
+ },
+ },
+ ];
+ }
+ throw ('reviewidentifier');
};
-
/**
* Calculates an average rating (out of 5) given all applicable Peer Reviews.
* @param {Object[]} peerReviews - An array of applicable Peer Review objects.
* @returns {Number|null} The average rating or null if an error was encountered.
*/
export const calculateAveragePeerReviewRating = (peerReviews) => {
- if (Array.isArray(peerReviews) && peerReviews.length > 0) {
- let ratingsCount = 0;
- let totalRating = 0;
- let averageRating = 0;
- peerReviews.forEach((review) => {
- if (typeof(review.rating) === 'number') {
- totalRating += review.rating;
- ratingsCount++;
- }
- });
- if (ratingsCount > 0) {
- // round to nearest half
- averageRating = Math.round((totalRating / ratingsCount) * 2) / 2;
- if (!isNaN(averageRating)) return averageRating;
- }
+ if (Array.isArray(peerReviews) && peerReviews.length > 0) {
+ let ratingsCount = 0;
+ let totalRating = 0;
+ let averageRating = 0;
+ peerReviews.forEach((review) => {
+ if (typeof (review.rating) === 'number') {
+ totalRating += review.rating;
+ ratingsCount++;
+ }
+ });
+ if (ratingsCount > 0) {
+ // round to nearest half
+ averageRating = Math.round((totalRating / ratingsCount) * 2) / 2;
+ if (!isNaN(averageRating)) return averageRating;
}
- return null;
+ }
+ return null;
};
-
/**
* Validates that a given Peer Review Prompt type is one of the
* pre-defined, acceptable prompt types.
* @param {String} promptType - The Prompt type identifier to validate.
* @returns {Boolean} True if valid type, false otherwise.
*/
-export const validatePeerReviewPromptType = (promptType) => {
- return peerReviewPromptTypes.includes(promptType);
-};
-
+export const validatePeerReviewPromptType = (promptType) => peerReviewPromptTypes.includes(promptType);
/**
* Validates that a given Peer Review Author type is one of the
@@ -137,6 +131,4 @@ export const validatePeerReviewPromptType = (promptType) => {
* @param {String} authorType - The author type identifier to validate.
* @returns {Boolean} True if valid type, false otherwise.
*/
-export const validatePeerReviewAuthorType = (authorType) => {
- return peerReviewAuthorTypes.includes(authorType);
-};
+export const validatePeerReviewAuthorType = (authorType) => peerReviewAuthorTypes.includes(authorType);
diff --git a/server/util/pressbookutils.ts b/server/util/pressbookutils.ts
index 6d262753..671e1a85 100644
--- a/server/util/pressbookutils.ts
+++ b/server/util/pressbookutils.ts
@@ -600,7 +600,8 @@ export class PressBookScraper {
this.subdomain,
pagePath,
"GuideTabs",
- MindTouch.Templates.PROP_GuideTabs,
+ MindTouch.Templates.PROP_GuideTabs,
+ "PUT"
),
);
}
diff --git a/server/util/remixerutils.ts b/server/util/remixerutils.ts
new file mode 100644
index 00000000..874fd045
--- /dev/null
+++ b/server/util/remixerutils.ts
@@ -0,0 +1,46 @@
+import Project from "../models/project";
+
+export const slugifyNode =(title:string): string =>{
+
+ const cleaned = title
+ .trim()
+ .replace(/\s+/g, "_")
+ .replace(/[^A-Za-z0-9_\-]/g, "");
+ return cleaned.length > 0 ? cleaned : "Section";
+ }
+
+export const generatePagePath = (parent: string, title: string): string => {
+ const slug = slugifyNode(title);
+ return encodeURIComponent(`${parent}/${slug}`);
+}
+
+export const extractPagePath = (pagePath: string): string => {
+ const withoutHost = pagePath.replace(
+ /^https?:\/\/[^/]*libretexts\.org\//i,
+ "",
+ );
+ return withoutHost.replace(/\/+$/, "");
+};
+
+/** Subdomain label from a LibreTexts page URL (e.g. dev from https://dev.libretexts.org/...). */
+export const extractLibretextsSubdomain = (uri: string): string | null => {
+ const m = uri.trim().match(/^https?:\/\/([^.]+)\.libretexts\.org/i);
+ return m?.[1] ?? null;
+};
+
+
+export const getUserWorkbenchProjects = async (subdomain: string, userId: string): Promise => {
+ const projects = await Project.find({
+ $or: [
+ { leads: userId },
+ { liaisons: userId },
+ { members: userId },
+ { auditors: userId },
+ ],
+ didCreateWorkbench: true,
+ libreCoverID: { $exists: true, $ne: "" },
+ libreLibrary: subdomain,
+ }).lean();
+
+ return projects.map((project) => project.libreCoverID);
+}
\ No newline at end of file
diff --git a/server/util/scopes.js b/server/util/scopes.js
index 483fc146..edddb62c 100644
--- a/server/util/scopes.js
+++ b/server/util/scopes.js
@@ -11,17 +11,17 @@ import { removeLeadingSlash } from './helpers.js';
* on a specific resource or type.
*/
const accessLevelDescriptions = {
- 'read': 'Read',
- 'write': 'Read or modify',
+ read: 'Read',
+ write: 'Read or modify',
};
/**
* Descriptions of resource sets, that should contain more specific scopes/resource identifiers.
*/
const scopeSetDescriptions = {
- 'user': 'Account and Profile',
- 'projects': 'Projects',
- 'analytics': 'Analytics',
+ user: 'Account and Profile',
+ projects: 'Projects',
+ analytics: 'Analytics',
};
/**
@@ -71,7 +71,7 @@ function getScopeDescriptions(scopes) {
splitScopes.push({
access,
set,
- resource
+ resource,
});
}
@@ -87,7 +87,7 @@ function getScopeDescriptions(scopes) {
access: scope.access,
description: scopeDescriptions[`${set}:${scope.resource}`] || 'Unknown',
accessDescription: accessLevelDescriptions[scope.access] || 'Unknown',
- }
+ };
}
return null;
}).filter((scope) => scope !== null),
@@ -108,7 +108,7 @@ function getEndpointAsScope(endpoint, method) {
if (!endpoint || !method) {
return 'unknown';
}
-
+
const paramRegex = /:([a-z]?[A-Z]?)*(\?)?/gi;
const procEndpoint = removeLeadingSlash(endpoint).replace(paramRegex, '*').replace(/\//g, ':');
let procMethod = 'unknown';
@@ -124,4 +124,4 @@ function getEndpointAsScope(endpoint, method) {
export default {
getScopeDescriptions,
getEndpointAsScope,
-}
+};
From bf1c8ce40551ae5f4611fac9b0e012181dcc5b14 Mon Sep 17 00:00:00 2001
From: Yashar Ghaemi
Date: Fri, 24 Apr 2026 11:27:02 -0400
Subject: [PATCH 5/8] feat(book-import): add BookImportModal for importing
pages with selection and expansion
---
client/package-lock.json | 643 -----
.../remixer/BookContent/Dashboard.tsx | 12 +-
.../components/remixer/BookImportModal.tsx | 240 ++
.../src/components/remixer/ControlPanel.tsx | 43 +-
.../src/components/remixer/PathNameFormat.tsx | 44 +-
.../components/remixer/RemixerDashboard.tsx | 2571 +++++++++--------
client/src/components/remixer/services.ts | 328 ++-
client/src/components/remixer/style.ts | 13 +-
client/src/components/util/LicenseOptions.js | 12 +-
server/api/remixer.ts | 4 +-
server/api/services/remixer-service.ts | 151 +-
server/package-lock.json | 203 --
server/util/CXOne/CXOnePageProperties.ts | 2 +-
server/util/CXOne/CXOneTemplates.ts | 11 +
server/util/librariesclient.ts | 10 +-
15 files changed, 2135 insertions(+), 2152 deletions(-)
create mode 100644 client/src/components/remixer/BookImportModal.tsx
diff --git a/client/package-lock.json b/client/package-lock.json
index 7f29285c..16324a62 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -7558,42 +7558,6 @@
"color-name": "^1.0.0"
}
},
- "node_modules/@mapbox/node-pre-gyp": {
- "version": "1.0.11",
- "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
- "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
- "license": "BSD-3-Clause",
- "optional": true,
- "peer": true,
- "dependencies": {
- "detect-libc": "^2.0.0",
- "https-proxy-agent": "^5.0.0",
- "make-dir": "^3.1.0",
- "node-fetch": "^2.6.7",
- "nopt": "^5.0.0",
- "npmlog": "^5.0.1",
- "rimraf": "^3.0.2",
- "semver": "^7.3.5",
- "tar": "^6.1.11"
- },
- "bin": {
- "node-pre-gyp": "bin/node-pre-gyp"
- }
- },
- "node_modules/@mapbox/node-pre-gyp/node_modules/semver": {
- "version": "7.7.3",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
- "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
- "license": "ISC",
- "optional": true,
- "peer": true,
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/@marsidev/react-turnstile": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-0.5.4.tgz",
@@ -10207,14 +10171,6 @@
"deprecated": "Use your platform's native atob() and btoa() methods instead",
"license": "BSD-3-Clause"
},
- "node_modules/abbrev": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
- "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
- "license": "ISC",
- "optional": true,
- "peer": true
- },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -10343,30 +10299,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
- "node_modules/aproba": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
- "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
- "license": "ISC",
- "optional": true,
- "peer": true
- },
- "node_modules/are-we-there-yet": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
- "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
- "deprecated": "This package is no longer supported.",
- "license": "ISC",
- "optional": true,
- "peer": true,
- "dependencies": {
- "delegates": "^1.0.0",
- "readable-stream": "^3.6.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@@ -10473,14 +10405,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
- "node_modules/balanced-match": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -10540,18 +10464,6 @@
"integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==",
"license": "MIT"
},
- "node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@@ -11084,17 +10996,6 @@
"color-name": "^2.0.0"
}
},
- "node_modules/color-support": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
- "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
- "license": "ISC",
- "optional": true,
- "peer": true,
- "bin": {
- "color-support": "bin.js"
- }
- },
"node_modules/combine-errors": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/combine-errors/-/combine-errors-3.0.3.tgz",
@@ -11135,22 +11036,6 @@
"node": ">= 10"
}
},
- "node_modules/concat-map": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
- "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
- "license": "MIT",
- "optional": true,
- "peer": true
- },
- "node_modules/console-control-strings": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
- "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
- "license": "ISC",
- "optional": true,
- "peer": true
- },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -11863,14 +11748,6 @@
"node": ">=0.4.0"
}
},
- "node_modules/delegates": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
- "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -12046,14 +11923,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -12516,50 +12385,6 @@
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
- "node_modules/fs-minipass": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
- "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
- "license": "ISC",
- "optional": true,
- "peer": true,
- "dependencies": {
- "minipass": "^3.0.0"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/fs-minipass/node_modules/minipass": {
- "version": "3.3.6",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
- "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
- "license": "ISC",
- "optional": true,
- "peer": true,
- "dependencies": {
- "yallist": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/fs-minipass/node_modules/yallist": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
- "license": "ISC",
- "optional": true,
- "peer": true
- },
- "node_modules/fs.realpath": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
- "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
- "license": "ISC",
- "optional": true,
- "peer": true
- },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -12599,29 +12424,6 @@
"license": "MIT",
"peer": true
},
- "node_modules/gauge": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
- "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
- "deprecated": "This package is no longer supported.",
- "license": "ISC",
- "optional": true,
- "peer": true,
- "dependencies": {
- "aproba": "^1.0.3 || ^2.0.0",
- "color-support": "^1.1.2",
- "console-control-strings": "^1.0.0",
- "has-unicode": "^2.0.1",
- "object-assign": "^4.1.1",
- "signal-exit": "^3.0.0",
- "string-width": "^4.2.3",
- "strip-ansi": "^6.0.1",
- "wide-align": "^1.1.2"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -12675,29 +12477,6 @@
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
- "node_modules/glob": {
- "version": "7.2.3",
- "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
- "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
- "deprecated": "Glob versions prior to v9 are no longer supported",
- "license": "ISC",
- "optional": true,
- "peer": true,
- "dependencies": {
- "fs.realpath": "^1.0.0",
- "inflight": "^1.0.4",
- "inherits": "2",
- "minimatch": "^3.1.1",
- "once": "^1.3.0",
- "path-is-absolute": "^1.0.0"
- },
- "engines": {
- "node": "*"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -12776,14 +12555,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/has-unicode": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
- "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
- "license": "ISC",
- "optional": true,
- "peer": true
- },
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -13265,19 +13036,6 @@
"node": ">=8"
}
},
- "node_modules/inflight": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
- "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
- "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
- "license": "ISC",
- "optional": true,
- "peer": true,
- "dependencies": {
- "once": "^1.3.0",
- "wrappy": "1"
- }
- },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -13414,17 +13172,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/is-fullwidth-code-point": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
- "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -14007,23 +13754,6 @@
"lz-string": "bin/bin.js"
}
},
- "node_modules/make-dir": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
- "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "semver": "^6.0.0"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -15047,20 +14777,6 @@
"mini-svg-data-uri": "cli.js"
}
},
- "node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
- "license": "ISC",
- "optional": true,
- "peer": true,
- "dependencies": {
- "brace-expansion": "^1.1.7"
- },
- "engines": {
- "node": "*"
- }
- },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -15070,68 +14786,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/minipass": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
- "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
- "license": "ISC",
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/minizlib": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
- "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "minipass": "^3.0.0",
- "yallist": "^4.0.0"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/minizlib/node_modules/minipass": {
- "version": "3.3.6",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
- "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
- "license": "ISC",
- "optional": true,
- "peer": true,
- "dependencies": {
- "yallist": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/minizlib/node_modules/yallist": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
- "license": "ISC",
- "optional": true,
- "peer": true
- },
- "node_modules/mkdirp": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
- "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "bin": {
- "mkdirp": "bin/cmd.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
@@ -15182,14 +14836,6 @@
"integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==",
"license": "MIT"
},
- "node_modules/nan": {
- "version": "2.24.0",
- "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz",
- "integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==",
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/nanoid": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
@@ -15256,28 +14902,6 @@
"node": ">= 8.0.0"
}
},
- "node_modules/node-fetch": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
- "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "whatwg-url": "^5.0.0"
- },
- "engines": {
- "node": "4.x || >=6.0.0"
- },
- "peerDependencies": {
- "encoding": "^0.1.0"
- },
- "peerDependenciesMeta": {
- "encoding": {
- "optional": true
- }
- }
- },
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -15285,23 +14909,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/nopt": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
- "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
- "license": "ISC",
- "optional": true,
- "peer": true,
- "dependencies": {
- "abbrev": "1"
- },
- "bin": {
- "nopt": "bin/nopt.js"
- },
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -15311,21 +14918,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/npmlog": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
- "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
- "deprecated": "This package is no longer supported.",
- "license": "ISC",
- "optional": true,
- "peer": true,
- "dependencies": {
- "are-we-there-yet": "^2.0.0",
- "console-control-strings": "^1.1.0",
- "gauge": "^3.0.0",
- "set-blocking": "^2.0.0"
- }
- },
"node_modules/nwsapi": {
"version": "2.2.23",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
@@ -15503,17 +15095,6 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
- "node_modules/path-is-absolute": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
- "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -16882,24 +16463,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/rimraf": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
- "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
- "deprecated": "Rimraf versions prior to v4 are no longer supported",
- "license": "ISC",
- "optional": true,
- "peer": true,
- "dependencies": {
- "glob": "^7.1.3"
- },
- "bin": {
- "rimraf": "bin.js"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
@@ -17100,14 +16663,6 @@
"semver": "bin/semver.js"
}
},
- "node_modules/set-blocking": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
- "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
- "license": "ISC",
- "optional": true,
- "peer": true
- },
"node_modules/shallow-equal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-3.1.0.tgz",
@@ -17335,22 +16890,6 @@
"safe-buffer": "~5.2.0"
}
},
- "node_modules/string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -17365,20 +16904,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
- "node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -17698,25 +17223,6 @@
"node": ">=8.10.0"
}
},
- "node_modules/tar": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
- "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
- "license": "ISC",
- "optional": true,
- "peer": true,
- "dependencies": {
- "chownr": "^2.0.0",
- "fs-minipass": "^2.0.0",
- "minipass": "^5.0.0",
- "minizlib": "^2.1.1",
- "mkdirp": "^1.0.3",
- "yallist": "^4.0.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
@@ -17745,25 +17251,6 @@
"node": ">=6"
}
},
- "node_modules/tar/node_modules/chownr": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
- "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
- "license": "ISC",
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/tar/node_modules/yallist": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
- "license": "ISC",
- "optional": true,
- "peer": true
- },
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -17872,14 +17359,6 @@
"node": ">=6"
}
},
- "node_modules/tr46": {
- "version": "0.0.3",
- "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
- "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -17950,37 +17429,6 @@
"integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==",
"license": "MIT"
},
- "node_modules/turndown/node_modules/canvas": {
- "version": "2.11.2",
- "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz",
- "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==",
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@mapbox/node-pre-gyp": "^1.0.0",
- "nan": "^2.17.0",
- "simple-get": "^3.0.3"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/turndown/node_modules/decompress-response": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz",
- "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "mimic-response": "^2.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/turndown/node_modules/form-data": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz",
@@ -18043,39 +17491,12 @@
}
}
},
- "node_modules/turndown/node_modules/mimic-response": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz",
- "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/turndown/node_modules/parse5": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
"integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==",
"license": "MIT"
},
- "node_modules/turndown/node_modules/simple-get": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz",
- "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "decompress-response": "^4.2.0",
- "once": "^1.3.1",
- "simple-concat": "^1.0.0"
- }
- },
"node_modules/turndown/node_modules/tr46": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz",
@@ -18573,22 +17994,6 @@
}
}
},
- "node_modules/vite-tsconfig-paths/node_modules/typescript": {
- "version": "5.9.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
- "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
- "license": "Apache-2.0",
- "optional": true,
- "peer": true,
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
"node_modules/w3c-hr-time": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
@@ -18637,14 +18042,6 @@
"integrity": "sha512-6BjspCO9VriYy12z356nL6JBS0GYeEcA457YyRzD+dD6XYCQ75NKhcOHUMHentOE7OcVCIXXDvOm0jKFfQG2Gg==",
"license": "Apache-2.0"
},
- "node_modules/webidl-conversions": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
- "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
- "license": "BSD-2-Clause",
- "optional": true,
- "peer": true
- },
"node_modules/whatwg-encoding": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz",
@@ -18673,29 +18070,6 @@
"integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==",
"license": "MIT"
},
- "node_modules/whatwg-url": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
- "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "tr46": "~0.0.3",
- "webidl-conversions": "^3.0.0"
- }
- },
- "node_modules/wide-align": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
- "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
- "license": "ISC",
- "optional": true,
- "peer": true,
- "dependencies": {
- "string-width": "^1.0.2 || 2 || 3 || 4"
- }
- },
"node_modules/wildcard": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-1.1.2.tgz",
@@ -18753,23 +18127,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/yaml": {
- "version": "2.8.2",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
- "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
- "license": "ISC",
- "optional": true,
- "peer": true,
- "bin": {
- "yaml": "bin.mjs"
- },
- "engines": {
- "node": ">= 14.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/eemeli"
- }
- },
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
diff --git a/client/src/components/remixer/BookContent/Dashboard.tsx b/client/src/components/remixer/BookContent/Dashboard.tsx
index 4d0687aa..9a5eca5b 100644
--- a/client/src/components/remixer/BookContent/Dashboard.tsx
+++ b/client/src/components/remixer/BookContent/Dashboard.tsx
@@ -9,20 +9,12 @@ import {
stripLeadingNumbering,
} from "../services";
import TreeNodeContainer from "./TreeNodeContainer";
+import { STATUS_PALETTE } from "../style";
type DropPosition = "before" | "inside" | "after";
type TreeId = "library" | "book";
-const STATUS_PALETTE = {
- info: "#0288d1",
- infoBg: "#bbdefb",
- error: "#d32f2f",
- errorBg: "#ffcdd2",
- success: "#2e7d32",
- successBg: "#c8e6c9",
- warning: "#ed6c02",
- warningBg: "#ffe0b2",
-};
+
interface ExternalDropPayload {
sourceTreeId: TreeId;
diff --git a/client/src/components/remixer/BookImportModal.tsx b/client/src/components/remixer/BookImportModal.tsx
new file mode 100644
index 00000000..13ed4a23
--- /dev/null
+++ b/client/src/components/remixer/BookImportModal.tsx
@@ -0,0 +1,240 @@
+import React from "react";
+import { Button, Checkbox, Icon, Loader, Modal, Popup } from "semantic-ui-react";
+import { RemixerSubPage } from "./model";
+
+const collectDescendantIds = (
+ nodes: RemixerSubPage[],
+ rootId: string,
+): string[] => {
+ const childrenBy = new Map();
+ for (const n of nodes) {
+ const pid = n.parentID ?? "";
+ if (!childrenBy.has(pid)) childrenBy.set(pid, []);
+ childrenBy.get(pid)!.push(n);
+ }
+ const out: string[] = [];
+ const walk = (nid: string) => {
+ out.push(nid);
+ for (const c of childrenBy.get(nid) ?? []) walk(c["@id"]);
+ };
+ walk(rootId);
+ return out;
+};
+
+interface BookImportModalProps {
+ open: boolean;
+ bookTitle: string;
+ rootId: string | null;
+ subtree: RemixerSubPage[] | null;
+ subtreeLoading: boolean;
+ selectedIds: Set;
+ setSelectedIds: React.Dispatch>>;
+ expandedIds: Set;
+ setExpandedIds: React.Dispatch>>;
+ isImporting: boolean;
+ onCancel: () => void;
+ onConfirm: () => void;
+}
+
+const BookImportModal: React.FC = ({
+ open,
+ bookTitle,
+ rootId,
+ subtree,
+ subtreeLoading,
+ selectedIds,
+ setSelectedIds,
+ expandedIds,
+ setExpandedIds,
+ isImporting,
+ onCancel,
+ onConfirm,
+}) => {
+ const toggleSelection = (pageId: string, checked: boolean) => {
+ if (!subtree) return;
+ const ids = collectDescendantIds(subtree, pageId);
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ ids.forEach((id) => {
+ if (checked) next.add(id);
+ else next.delete(id);
+ });
+ return next;
+ });
+ };
+
+ const toggleExpanded = (pageId: string) => {
+ setExpandedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(pageId)) next.delete(pageId);
+ else next.add(pageId);
+ return next;
+ });
+ };
+
+ const selectableIds = React.useMemo(() => {
+ if (!subtree || !rootId) return [];
+ return subtree
+ .filter((n) => n["@id"] !== rootId)
+ .map((n) => n["@id"]);
+ }, [subtree, rootId]);
+
+ const allSelected =
+ selectableIds.length > 0 &&
+ selectableIds.every((id) => selectedIds.has(id));
+
+ const toggleSelectAll = () => {
+ if (allSelected) {
+ setSelectedIds(new Set());
+ } else {
+ setSelectedIds(new Set(selectableIds));
+ }
+ };
+
+ const expandableIds = React.useMemo(() => {
+ if (!subtree || !rootId) return [];
+ const hasChildren = new Set(
+ subtree.map((n) => n.parentID).filter((pid): pid is string => !!pid),
+ );
+ return subtree
+ .filter((n) => n["@id"] !== rootId && hasChildren.has(n["@id"]))
+ .map((n) => n["@id"]);
+ }, [subtree, rootId]);
+
+ const allExpanded =
+ expandableIds.length > 0 &&
+ expandableIds.every((id) => expandedIds.has(id));
+
+ const toggleExpandAll = () => {
+ if (allExpanded) {
+ setExpandedIds(new Set());
+ } else {
+ setExpandedIds(new Set(expandableIds));
+ }
+ };
+
+ const renderRows = (parentId: string, depth: number): React.ReactNode => {
+ if (!subtree) return null;
+ const children = subtree.filter((n) => n.parentID === parentId);
+ return children.map((child) => {
+ const id = child["@id"];
+ const title = child["@title"] || child.title || "Untitled";
+ const hasChildren = subtree.some((n) => n.parentID === id);
+ const expanded = expandedIds.has(id);
+ const selected = selectedIds.has(id);
+ return (
+
+
+
+ {hasChildren ? (
+ toggleExpanded(id)}
+ />
+ ) : null}
+
+ toggleSelection(id, Boolean(d.checked))}
+ />
+ {title}
+
+ {hasChildren && expanded ? renderRows(id, depth + 1) : null}
+
+ );
+ });
+ };
+
+ return (
+
+ Import from book
+
+
+ Choose pages from {bookTitle} to copy into the
+ current book. Folders toggle with all nested pages.
+
+ {subtreeLoading ? (
+
+
+
+ ) : subtree && rootId ? (
+ <>
+
+
+ {selectedIds.size} of {selectableIds.length} selected
+
+
+
+
+
+ }
+ />
+
+ {allSelected ? "Deselect all" : "Select all"}
+
+
+
+
+ {renderRows(rootId, 0)}
+
+ >
+ ) : null}
+
+
+ Cancel
+
+ Import selected
+
+
+
+ );
+};
+
+export default BookImportModal;
diff --git a/client/src/components/remixer/ControlPanel.tsx b/client/src/components/remixer/ControlPanel.tsx
index 507b57a6..c0a61c9c 100644
--- a/client/src/components/remixer/ControlPanel.tsx
+++ b/client/src/components/remixer/ControlPanel.tsx
@@ -5,6 +5,7 @@ import {
buttonStyle,
handleMouseEnter,
handleMouseLeave,
+ STATUS_PALETTE,
} from "./style";
import { CopyMode, copyModeStates, defaultCopyModeState } from "./model";
@@ -17,6 +18,7 @@ interface ControlPanelProps {
copyModeState?: CopyMode;
onCopyModeChange?: (value: CopyMode) => void;
isAdmin?: boolean;
+ autoNumbering: boolean;
}
const ControlPanel: React.FC = ({
@@ -29,6 +31,7 @@ const ControlPanel: React.FC = ({
copyModeState,
onCopyModeChange,
isAdmin = false,
+ autoNumbering,
}) => {
const [confirmStartOverOpen, setConfirmStartOverOpen] = useState(false);
const [startOverLoading, setStartOverLoading] = useState(false);
@@ -50,7 +53,7 @@ const ControlPanel: React.FC = ({
return (
= ({
content="Path Name Format"
position="bottom center"
trigger={
-
- Auto number
-
+ autoNumbering ? (
+ ) => {
+ event.currentTarget.style.backgroundColor =
+ STATUS_PALETTE.success;
+ event.currentTarget.style.color = "#ffffff";
+ }}
+ onMouseLeave={(event: React.MouseEvent) => {
+ event.currentTarget.style.backgroundColor =
+ STATUS_PALETTE.successBg;
+ event.currentTarget.style.color = STATUS_PALETTE.success;
+ }}
+ >
+ Autonumber
+
+ ) : (
+
+ Autonumber
+
+ )
}
/>
diff --git a/client/src/components/remixer/PathNameFormat.tsx b/client/src/components/remixer/PathNameFormat.tsx
index 6a69c4a4..22f9c5cf 100644
--- a/client/src/components/remixer/PathNameFormat.tsx
+++ b/client/src/components/remixer/PathNameFormat.tsx
@@ -8,7 +8,7 @@ import {
PathLevelFormat,
PrefixOption,
} from "./model";
-import { getStartToken } from "./services";
+import { getStartToken, joinLeveledPathParts } from "./services";
interface PathNameFormatProps {
open: boolean;
@@ -36,9 +36,12 @@ const PathNameFormat: React.FC = (props) => {
const [levelFormats, setLevelFormats] = useState([]);
const [prefixOptions, setPrefixOptions] =
useState(DEFAULT_PREFIX_OPTIONS);
+ const [localAutoNumbering, setLocalAutoNumbering] =
+ useState(autoNumbering);
useEffect(() => {
if (!open) return;
+ setLocalAutoNumbering(autoNumbering);
const savedByLevel = new Map(
(pathLevelFormats ?? []).map((format) => [format.level, format]),
);
@@ -65,25 +68,35 @@ const PathNameFormat: React.FC