brightspace-d2l-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +269 -0
  3. package/build/api/cache.js +49 -0
  4. package/build/api/client.js +345 -0
  5. package/build/api/errors.js +48 -0
  6. package/build/api/index.js +12 -0
  7. package/build/api/paginate.js +70 -0
  8. package/build/api/rate-limiter.js +52 -0
  9. package/build/api/retry.js +39 -0
  10. package/build/api/types.js +11 -0
  11. package/build/api/version-discovery.js +43 -0
  12. package/build/auth/authenticator.js +146 -0
  13. package/build/auth/browser.js +82 -0
  14. package/build/auth/index.js +6 -0
  15. package/build/auth/login.js +227 -0
  16. package/build/auth/session-store.js +46 -0
  17. package/build/auth/token-manager.js +150 -0
  18. package/build/auth/token-mint.js +75 -0
  19. package/build/cli/clients.js +344 -0
  20. package/build/cli/main.js +162 -0
  21. package/build/cli/school.js +74 -0
  22. package/build/cli/setup.js +189 -0
  23. package/build/cli/ui.js +39 -0
  24. package/build/index.js +13 -0
  25. package/build/server.js +175 -0
  26. package/build/tools/download-file.js +167 -0
  27. package/build/tools/get-announcements.js +116 -0
  28. package/build/tools/get-assignment-files.js +159 -0
  29. package/build/tools/get-assignments.js +343 -0
  30. package/build/tools/get-classlist-emails.js +38 -0
  31. package/build/tools/get-course-content.js +175 -0
  32. package/build/tools/get-discussions.js +192 -0
  33. package/build/tools/get-my-courses.js +42 -0
  34. package/build/tools/get-my-grades.js +93 -0
  35. package/build/tools/get-roster.js +99 -0
  36. package/build/tools/get-syllabus.js +154 -0
  37. package/build/tools/get-todo.js +61 -0
  38. package/build/tools/get-upcoming-due-dates.js +138 -0
  39. package/build/tools/index.js +18 -0
  40. package/build/tools/schemas.js +91 -0
  41. package/build/tools/submission-status.js +71 -0
  42. package/build/tools/tool-helpers.js +62 -0
  43. package/build/types/index.js +2 -0
  44. package/build/utils/atomic-write.js +66 -0
  45. package/build/utils/config.js +60 -0
  46. package/build/utils/course-filter.js +24 -0
  47. package/build/utils/deep-links.js +13 -0
  48. package/build/utils/download-errors.js +14 -0
  49. package/build/utils/download-helpers.js +73 -0
  50. package/build/utils/errors.js +31 -0
  51. package/build/utils/file-validator.js +121 -0
  52. package/build/utils/html-converter.js +20 -0
  53. package/build/utils/logger.js +69 -0
  54. package/build/utils/paths.js +15 -0
  55. package/build/utils/pdf-extractor.js +18 -0
  56. package/build/utils/version.js +18 -0
  57. package/build/utils/zip-extract.js +139 -0
  58. package/package.json +104 -0
@@ -0,0 +1,60 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { writeFileAtomicSync } from "./atomic-write.js";
4
+ import { configPath } from "./paths.js";
5
+ export function readStoredConfig() {
6
+ try {
7
+ return JSON.parse(fs.readFileSync(configPath(), "utf8"));
8
+ }
9
+ catch {
10
+ return {};
11
+ }
12
+ }
13
+ export function writeStoredConfig(update) {
14
+ const next = { ...readStoredConfig(), ...update };
15
+ fs.mkdirSync(path.dirname(configPath()), { recursive: true, mode: 0o700 });
16
+ writeFileAtomicSync(configPath(), JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
17
+ return next;
18
+ }
19
+ export function normalizeSchoolUrl(input) {
20
+ const trimmed = input.trim().replace(/^["']|["']$/g, "");
21
+ if (!trimmed)
22
+ return null;
23
+ try {
24
+ const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`);
25
+ if (url.username || url.password || !url.hostname.includes("."))
26
+ return null;
27
+ url.protocol = "https:";
28
+ return url.origin;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ function parseIds(value) {
35
+ if (!value)
36
+ return undefined;
37
+ return value.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
38
+ }
39
+ export function loadConfig() {
40
+ const store = readStoredConfig();
41
+ const rawUrl = process.env.D2L_BASE_URL || store.baseUrl;
42
+ const baseUrl = rawUrl ? normalizeSchoolUrl(rawUrl) : null;
43
+ const activeOnly = process.env.D2L_ACTIVE_ONLY !== undefined
44
+ ? process.env.D2L_ACTIVE_ONLY !== "false"
45
+ : store.activeOnly ?? true;
46
+ const autoLogin = process.env.D2L_AUTO_LOGIN !== undefined
47
+ ? process.env.D2L_AUTO_LOGIN !== "false"
48
+ : store.autoLogin ?? true;
49
+ return {
50
+ baseUrl: baseUrl ?? "",
51
+ tokenTtl: process.env.D2L_TOKEN_TTL ? parseInt(process.env.D2L_TOKEN_TTL, 10) : 3600,
52
+ autoLogin,
53
+ courseFilter: {
54
+ includeCourseIds: parseIds(process.env.D2L_INCLUDE_COURSES) ?? store.includeCourses,
55
+ excludeCourseIds: parseIds(process.env.D2L_EXCLUDE_COURSES) ?? store.excludeCourses,
56
+ activeOnly,
57
+ },
58
+ };
59
+ }
60
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,24 @@
1
+ import { log } from "./logger.js";
2
+ export function applyCourseFilter(courses, config) {
3
+ let filtered = courses;
4
+ const originalCount = courses.length;
5
+ if (config.activeOnly) {
6
+ filtered = filtered.filter(c => c.isActive);
7
+ filtered = filtered.filter(c => c.canAccess !== false);
8
+ }
9
+ if (config.includeCourseIds && config.includeCourseIds.length > 0) {
10
+ filtered = filtered.filter(c => config.includeCourseIds.includes(c.id));
11
+ }
12
+ if (config.excludeCourseIds && config.excludeCourseIds.length > 0) {
13
+ filtered = filtered.filter(c => !config.excludeCourseIds.includes(c.id));
14
+ }
15
+ if (filtered.length !== originalCount) {
16
+ log("DEBUG", `Course filter: ${originalCount} -> ${filtered.length} courses`, {
17
+ activeOnly: config.activeOnly,
18
+ include: config.includeCourseIds,
19
+ exclude: config.excludeCourseIds,
20
+ });
21
+ }
22
+ return filtered;
23
+ }
24
+ //# sourceMappingURL=course-filter.js.map
@@ -0,0 +1,13 @@
1
+ function trimBaseUrl(baseUrl) {
2
+ return baseUrl.replace(/\/+$/, "");
3
+ }
4
+ export function assignmentUrl(baseUrl, courseId, folderId) {
5
+ return `${trimBaseUrl(baseUrl)}/d2l/lms/dropbox/user/folder_submit_files.d2l?db=${folderId}&grpid=0&ou=${courseId}`;
6
+ }
7
+ export function quizUrl(baseUrl, courseId, quizId) {
8
+ return `${trimBaseUrl(baseUrl)}/d2l/lms/quizzing/user/quiz_summary.d2l?qi=${quizId}&ou=${courseId}`;
9
+ }
10
+ export function gradebookUrl(baseUrl, courseId) {
11
+ return `${trimBaseUrl(baseUrl)}/d2l/lms/grades/my_grades/main.d2l?ou=${courseId}`;
12
+ }
13
+ //# sourceMappingURL=deep-links.js.map
@@ -0,0 +1,14 @@
1
+ export class DownloadError extends Error {
2
+ kind;
3
+ detail;
4
+ constructor(kind, message, detail) {
5
+ super(message);
6
+ this.kind = kind;
7
+ this.detail = detail;
8
+ this.name = "DownloadError";
9
+ }
10
+ }
11
+ export function isSafeDetail(value) {
12
+ return value.length <= 64 && /^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/.test(value);
13
+ }
14
+ //# sourceMappingURL=download-errors.js.map
@@ -0,0 +1,73 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { validateDownloadPath, validateFileType, MAX_FILE_SIZE } from "./file-validator.js";
4
+ import { log } from "./logger.js";
5
+ export async function resolveFilenameConflict(dir, filename) {
6
+ const fullPath = path.join(dir, filename);
7
+ try {
8
+ await fs.access(fullPath);
9
+ // File exists, need to resolve conflict
10
+ }
11
+ catch {
12
+ // File doesn't exist, use original name
13
+ return filename;
14
+ }
15
+ // Parse filename into name and extension
16
+ const ext = path.extname(filename);
17
+ const basename = path.basename(filename, ext);
18
+ // Try filename(1), filename(2), etc.
19
+ for (let i = 1; i <= 100; i++) {
20
+ const candidate = `${basename}(${i})${ext}`;
21
+ const candidatePath = path.join(dir, candidate);
22
+ try {
23
+ await fs.access(candidatePath);
24
+ // File exists, try next
25
+ }
26
+ catch {
27
+ // File doesn't exist, use this name
28
+ return candidate;
29
+ }
30
+ }
31
+ throw new Error("Could not resolve filename conflict after 100 attempts");
32
+ }
33
+ export async function secureDownload(options) {
34
+ const { targetDir, filename, data, allowedTypes } = options;
35
+ log("DEBUG", `secureDownload: starting download of ${filename} to ${targetDir}`);
36
+ // Validate target directory exists and is a directory
37
+ try {
38
+ const stats = await fs.stat(targetDir);
39
+ if (!stats.isDirectory()) {
40
+ throw new Error(`Target path is not a directory: ${targetDir}`);
41
+ }
42
+ }
43
+ catch (error) {
44
+ if (error.code === "ENOENT") {
45
+ throw new Error(`Target directory does not exist: ${targetDir}`);
46
+ }
47
+ throw error;
48
+ }
49
+ // Validate file size
50
+ const size = data.byteLength;
51
+ if (size > MAX_FILE_SIZE) {
52
+ throw new Error(`File size (${size} bytes) exceeds maximum allowed (${MAX_FILE_SIZE} bytes)`);
53
+ }
54
+ log("DEBUG", `secureDownload: file size ${size} bytes (within limit)`);
55
+ const { mime } = await validateFileType(data, allowedTypes, filename);
56
+ log("DEBUG", `secureDownload: file type validated as ${mime}`);
57
+ // Validate download path (prevent path traversal)
58
+ const validatedPath = validateDownloadPath(targetDir, filename);
59
+ log("DEBUG", `secureDownload: path validated as ${validatedPath}`);
60
+ // Resolve filename conflicts
61
+ const resolvedFilename = await resolveFilenameConflict(targetDir, filename);
62
+ const finalPath = path.join(targetDir, resolvedFilename);
63
+ log("DEBUG", `secureDownload: resolved filename to ${resolvedFilename}`);
64
+ // Write file to disk
65
+ await fs.writeFile(finalPath, data);
66
+ log("INFO", `Downloaded file to ${finalPath} (${size} bytes, ${mime})`);
67
+ return {
68
+ path: finalPath,
69
+ size,
70
+ mime,
71
+ };
72
+ }
73
+ //# sourceMappingURL=download-helpers.js.map
@@ -0,0 +1,31 @@
1
+ export class AuthError extends Error {
2
+ cause;
3
+ constructor(message, cause) {
4
+ super(`[PBMCP-1001] ${message}`);
5
+ this.cause = cause;
6
+ this.name = "AuthError";
7
+ }
8
+ }
9
+ export class TokenExpiredError extends AuthError {
10
+ expiredAt;
11
+ constructor(expiredAt) {
12
+ super(`[PBMCP-1002] Token expired at ${new Date(expiredAt).toISOString()}`);
13
+ this.expiredAt = expiredAt;
14
+ this.name = "TokenExpiredError";
15
+ }
16
+ }
17
+ export class BrowserAuthError extends AuthError {
18
+ step;
19
+ constructor(message, step, cause) {
20
+ super(`[PBMCP-1003] Browser auth failed at step "${step}": ${message}`, cause);
21
+ this.step = step;
22
+ this.name = "BrowserAuthError";
23
+ }
24
+ }
25
+ export class SessionStoreError extends AuthError {
26
+ constructor(message, cause) {
27
+ super(`[PBMCP-1004] Session store error: ${message}`, cause);
28
+ this.name = "SessionStoreError";
29
+ }
30
+ }
31
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1,121 @@
1
+ import path from "node:path";
2
+ import { fileTypeFromBuffer } from "file-type";
3
+ import sanitizeFilename from "sanitize-filename";
4
+ import { DownloadError } from "./download-errors.js";
5
+ const CFB_MIME = "application/x-cfb";
6
+ const CFB_EXTENSION_MIMES = {
7
+ ".doc": "application/msword",
8
+ ".dot": "application/msword",
9
+ ".xls": "application/vnd.ms-excel",
10
+ ".xlt": "application/vnd.ms-excel",
11
+ ".ppt": "application/vnd.ms-powerpoint",
12
+ ".pot": "application/vnd.ms-powerpoint",
13
+ ".pps": "application/vnd.ms-powerpoint",
14
+ };
15
+ export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
16
+ export const ALLOWED_MIME_TYPES = [
17
+ // Documents
18
+ "application/pdf",
19
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
20
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
21
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
22
+ "application/msword",
23
+ "application/vnd.ms-powerpoint",
24
+ "application/vnd.ms-excel",
25
+ "image/png",
26
+ "image/jpeg",
27
+ "image/gif",
28
+ "image/webp",
29
+ "image/svg+xml",
30
+ // Text
31
+ "text/plain",
32
+ "text/csv",
33
+ "text/html",
34
+ // Data
35
+ "application/json",
36
+ // Archives
37
+ "application/zip",
38
+ "application/x-zip-compressed",
39
+ // Media
40
+ "video/mp4",
41
+ "audio/mpeg",
42
+ "audio/wav",
43
+ ];
44
+ export function validateDownloadPath(baseDir, filename) {
45
+ let decoded;
46
+ try {
47
+ decoded = decodeURIComponent(filename);
48
+ }
49
+ catch {
50
+ decoded = filename;
51
+ }
52
+ // Sanitize filename (removes path separators, null bytes, etc.)
53
+ const sanitized = sanitizeFilename(decoded);
54
+ if (!sanitized || sanitized.length === 0) {
55
+ throw new DownloadError("badFilename", "Invalid filename after sanitization");
56
+ }
57
+ // Resolve full path
58
+ const fullPath = path.resolve(baseDir, sanitized);
59
+ const resolvedBase = path.resolve(baseDir);
60
+ // Verify resolved path is within base directory
61
+ if (!fullPath.startsWith(resolvedBase + path.sep) &&
62
+ fullPath !== resolvedBase) {
63
+ throw new DownloadError("pathTraversal", "Path traversal detected");
64
+ }
65
+ return fullPath;
66
+ }
67
+ export async function validateFileType(buffer, allowedTypes = ALLOWED_MIME_TYPES, filename) {
68
+ // Try magic byte detection first
69
+ const detected = await fileTypeFromBuffer(buffer);
70
+ if (detected) {
71
+ if (detected.mime === CFB_MIME) {
72
+ const ext = filename ? path.extname(filename).toLowerCase() : "";
73
+ const resolved = CFB_EXTENSION_MIMES[ext];
74
+ if (resolved && allowedTypes.includes(resolved)) {
75
+ return { mime: resolved, ext: ext.slice(1) };
76
+ }
77
+ throw new DownloadError("unsupportedType", `Compound File Binary with extension '${ext || "none"}' is not an allowed Office format`, CFB_MIME);
78
+ }
79
+ if (!allowedTypes.includes(detected.mime)) {
80
+ throw new DownloadError("unsupportedType", `File type '${detected.mime}' not allowed`, detected.mime);
81
+ }
82
+ return { mime: detected.mime, ext: detected.ext };
83
+ }
84
+ // Fallback for text-based files with no magic-byte signature: accept if the
85
+ // buffer decodes as UTF-8 (rejecting binaries and NUL bytes), then sniff HTML.
86
+ if (!buffer.includes(0)) {
87
+ let decoded = null;
88
+ try {
89
+ decoded = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
90
+ }
91
+ catch {
92
+ // Invalid UTF-8, so treat as binary.
93
+ }
94
+ if (decoded !== null) {
95
+ const noBom = decoded.charCodeAt(0) === 0xfeff ? decoded.slice(1) : decoded;
96
+ const head = noBom.trimStart().toLowerCase();
97
+ const isHtml = head.startsWith("<!doctype html") || head.startsWith("<html");
98
+ const mime = isHtml ? "text/html" : "text/plain";
99
+ const ext = isHtml ? "html" : "txt";
100
+ if (allowedTypes.includes(mime)) {
101
+ return { mime, ext };
102
+ }
103
+ }
104
+ }
105
+ throw new DownloadError("undetectableType", "Could not determine file type or type not allowed");
106
+ }
107
+ export function validateContentId(id) {
108
+ if (typeof id !== "number") {
109
+ throw new Error("Content ID must be a number");
110
+ }
111
+ if (!Number.isInteger(id) || id <= 0) {
112
+ throw new Error("Content ID must be a positive integer");
113
+ }
114
+ return id;
115
+ }
116
+ export function validateBaseUrl(url, expectedBaseUrl) {
117
+ if (!url.startsWith(expectedBaseUrl)) {
118
+ throw new Error(`URL must start with ${expectedBaseUrl}, got: ${url.substring(0, 50)}...`);
119
+ }
120
+ }
121
+ //# sourceMappingURL=file-validator.js.map
@@ -0,0 +1,20 @@
1
+ import TurndownService from "turndown";
2
+ const turndownService = new TurndownService({
3
+ headingStyle: "atx",
4
+ codeBlockStyle: "fenced",
5
+ });
6
+ export function convertHtmlToMarkdown(html) {
7
+ // Handle null/empty input
8
+ if (!html || html.trim().length === 0) {
9
+ return { markdown: "", html: "" };
10
+ }
11
+ try {
12
+ const markdown = turndownService.turndown(html);
13
+ return { markdown, html };
14
+ }
15
+ catch (error) {
16
+ // If conversion fails, fallback to raw HTML
17
+ return { markdown: html, html };
18
+ }
19
+ }
20
+ //# sourceMappingURL=html-converter.js.map
@@ -0,0 +1,69 @@
1
+ let currentLevel = "INFO";
2
+ const LEVEL_ORDER = {
3
+ DEBUG: 0,
4
+ INFO: 1,
5
+ WARN: 2,
6
+ ERROR: 3,
7
+ };
8
+ export function setLogLevel(level) {
9
+ currentLevel = level;
10
+ }
11
+ function redact(value) {
12
+ // JSON web tokens, wherever they appear
13
+ value = value.replace(/eyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]*)?/g, "eyJ...REDACTED");
14
+ // Redact Bearer tokens
15
+ value = value.replace(/Bearer\s+([A-Za-z0-9._~+/=-]{8})[A-Za-z0-9._~+/=-]*/g, "Bearer $1...REDACTED");
16
+ // Redact cookie: prefixed tokens
17
+ value = value.replace(/cookie:([^\s]{8})[^\s]*/g, "cookie:$1...REDACTED");
18
+ // JSON-serialized header fields: {"Authorization":"..."}, {"Cookie":"..."}
19
+ value = value.replace(/("(?:authorization|cookie|set-cookie)"\s*:\s*")[^"]*(")/gi, "$1...REDACTED$2");
20
+ // Credentials embedded in a URL: scheme://user:pass@host
21
+ value = value.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/@]+@/gi, "$1***:***@");
22
+ // Redact anything that looks like a long token (40+ chars of base64-like)
23
+ value = value.replace(/([A-Za-z0-9._~+/=-]{40,})/g, (match) => match.substring(0, 8) + "...REDACTED");
24
+ return value;
25
+ }
26
+ const MAX_ARG_LENGTH = 2000;
27
+ function serializeArg(arg) {
28
+ if (arg instanceof Error) {
29
+ const frame = arg.stack?.split("\n").find((line) => line.trim().startsWith("at ")) ?? "";
30
+ return `${arg.name}: ${arg.message}${frame ? ` ${frame.trim()}` : ""}`;
31
+ }
32
+ if (typeof arg === "string")
33
+ return arg;
34
+ if (typeof arg === "object" && arg !== null) {
35
+ const seen = new WeakSet();
36
+ try {
37
+ const json = JSON.stringify(arg, (_key, value) => {
38
+ if (typeof value === "bigint")
39
+ return value.toString();
40
+ if (typeof value === "object" && value !== null) {
41
+ if (seen.has(value))
42
+ return "[Circular]";
43
+ seen.add(value);
44
+ }
45
+ return value;
46
+ });
47
+ return json.length > MAX_ARG_LENGTH ? `${json.slice(0, MAX_ARG_LENGTH)}...` : json;
48
+ }
49
+ catch {
50
+ return String(arg);
51
+ }
52
+ }
53
+ return String(arg);
54
+ }
55
+ export function log(level, message, ...args) {
56
+ if (LEVEL_ORDER[level] < LEVEL_ORDER[currentLevel])
57
+ return;
58
+ const timestamp = new Date().toISOString();
59
+ const safeMessage = redact(message);
60
+ const safeArgs = args.map((arg) => redact(serializeArg(arg)));
61
+ console.error(`[${timestamp}] [${level}] ${safeMessage}`, ...safeArgs);
62
+ }
63
+ // Override console.log in production to prevent accidental stdout writes
64
+ export function enableStdoutGuard() {
65
+ console.log = (...args) => {
66
+ console.error("[WARN] console.log intercepted (would corrupt stdio):", ...args);
67
+ };
68
+ }
69
+ //# sourceMappingURL=logger.js.map
@@ -0,0 +1,15 @@
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
+ export const PACKAGE_NAME = "brightspace-d2l-mcp";
4
+ export const SERVER_NAME = "brightspace";
5
+ export const SETUP_COMMAND = `npx -y ${PACKAGE_NAME}@latest setup`;
6
+ export const LOGIN_COMMAND = `npx -y ${PACKAGE_NAME}@latest login`;
7
+ export function dataDir() {
8
+ return process.env.BRIGHTSPACE_MCP_HOME || path.join(os.homedir(), ".brightspace-d2l-mcp");
9
+ }
10
+ export const configPath = () => path.join(dataDir(), "config.json");
11
+ export const sessionPath = () => path.join(dataDir(), "session.json");
12
+ export const browserProfileDir = () => path.join(dataDir(), "browser-profile");
13
+ export const savedCookiesPath = () => path.join(dataDir(), "browser-cookies.json");
14
+ export const loginLockPath = () => path.join(dataDir(), "login.lock");
15
+ //# sourceMappingURL=paths.js.map
@@ -0,0 +1,18 @@
1
+ import { extractText } from "unpdf";
2
+ import { log } from "./logger.js";
3
+ export async function extractPdfText(buffer) {
4
+ try {
5
+ const result = await extractText(new Uint8Array(buffer), {
6
+ mergePages: true,
7
+ });
8
+ return {
9
+ text: result.text,
10
+ totalPages: result.totalPages,
11
+ };
12
+ }
13
+ catch (error) {
14
+ log("ERROR", "Failed to extract text from PDF", error);
15
+ return null;
16
+ }
17
+ }
18
+ //# sourceMappingURL=pdf-extractor.js.map
@@ -0,0 +1,18 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ let cached = null;
5
+ export function packageVersion() {
6
+ if (cached)
7
+ return cached;
8
+ try {
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+ // build/utils/version.js -> package.json two levels up
11
+ cached = JSON.parse(readFileSync(resolve(here, "..", "..", "package.json"), "utf8")).version ?? "0.0.0";
12
+ }
13
+ catch {
14
+ cached = "0.0.0";
15
+ }
16
+ return cached;
17
+ }
18
+ //# sourceMappingURL=version.js.map
@@ -0,0 +1,139 @@
1
+ import { inflateRawSync } from "node:zlib";
2
+ const EOCD_SIGNATURE = 0x06054b50;
3
+ const CENTRAL_FILE_SIGNATURE = 0x02014b50;
4
+ const EOCD_MIN_SIZE = 22;
5
+ const EOCD_MAX_COMMENT = 0xffff;
6
+ const ZIP64_MARKER = 0xffffffff;
7
+ const METHOD_STORED = 0;
8
+ const METHOD_DEFLATE = 8;
9
+ function findEocd(buffer) {
10
+ const earliest = Math.max(0, buffer.length - EOCD_MIN_SIZE - EOCD_MAX_COMMENT);
11
+ for (let i = buffer.length - EOCD_MIN_SIZE; i >= earliest; i--) {
12
+ if (buffer.readUInt32LE(i) === EOCD_SIGNATURE)
13
+ return i;
14
+ }
15
+ return null;
16
+ }
17
+ function readCentralDirectory(buffer) {
18
+ const eocd = findEocd(buffer);
19
+ if (eocd === null)
20
+ return null;
21
+ const count = buffer.readUInt16LE(eocd + 10);
22
+ let offset = buffer.readUInt32LE(eocd + 16);
23
+ if (offset === ZIP64_MARKER)
24
+ return null; // ZIP64 is out of scope.
25
+ const entries = [];
26
+ for (let i = 0; i < count; i++) {
27
+ if (offset + 46 > buffer.length)
28
+ return null;
29
+ if (buffer.readUInt32LE(offset) !== CENTRAL_FILE_SIGNATURE)
30
+ return null;
31
+ const method = buffer.readUInt16LE(offset + 10);
32
+ const compressedSize = buffer.readUInt32LE(offset + 20);
33
+ const uncompressedSize = buffer.readUInt32LE(offset + 24);
34
+ const nameLength = buffer.readUInt16LE(offset + 28);
35
+ const extraLength = buffer.readUInt16LE(offset + 30);
36
+ const commentLength = buffer.readUInt16LE(offset + 32);
37
+ const localHeaderOffset = buffer.readUInt32LE(offset + 42);
38
+ const nameStart = offset + 46;
39
+ if (nameStart + nameLength > buffer.length)
40
+ return null;
41
+ entries.push({
42
+ name: buffer.subarray(nameStart, nameStart + nameLength).toString("utf-8"),
43
+ method,
44
+ compressedSize,
45
+ uncompressedSize,
46
+ localHeaderOffset,
47
+ });
48
+ offset = nameStart + nameLength + extraLength + commentLength;
49
+ }
50
+ return entries;
51
+ }
52
+ function readEntryData(buffer, entry) {
53
+ const header = entry.localHeaderOffset;
54
+ if (header + 30 > buffer.length)
55
+ return null;
56
+ // The local header repeats the name and extra lengths, and its extra field
57
+ // can differ in length from the central one, so read them from here.
58
+ const nameLength = buffer.readUInt16LE(header + 26);
59
+ const extraLength = buffer.readUInt16LE(header + 28);
60
+ const start = header + 30 + nameLength + extraLength;
61
+ const end = start + entry.compressedSize;
62
+ if (end > buffer.length)
63
+ return null;
64
+ const raw = buffer.subarray(start, end);
65
+ if (entry.method === METHOD_STORED)
66
+ return Buffer.from(raw);
67
+ if (entry.method !== METHOD_DEFLATE)
68
+ return null;
69
+ try {
70
+ return inflateRawSync(raw);
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ export function extractZipEntry(buffer, entryName) {
77
+ try {
78
+ const entries = readCentralDirectory(buffer);
79
+ if (!entries)
80
+ return null;
81
+ const entry = entries.find((e) => e.name === entryName);
82
+ if (!entry)
83
+ return null;
84
+ return readEntryData(buffer, entry);
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ export function listZipEntries(buffer) {
91
+ try {
92
+ return readCentralDirectory(buffer)?.map((e) => e.name) ?? null;
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ const XML_ENTITIES = {
99
+ "&amp;": "&",
100
+ "&lt;": "<",
101
+ "&gt;": ">",
102
+ "&quot;": '"',
103
+ "&apos;": "'",
104
+ "&#39;": "'",
105
+ };
106
+ function xmlToText(xml) {
107
+ return xml
108
+ .replace(/<[^>]+>/g, " ")
109
+ .replace(/&(?:amp|lt|gt|quot|apos|#39);/g, (m) => XML_ENTITIES[m] ?? m)
110
+ .replace(/\s+/g, " ")
111
+ .trim();
112
+ }
113
+ export function officeDocumentText(buffer) {
114
+ const names = listZipEntries(buffer);
115
+ if (!names)
116
+ return null;
117
+ const read = (name) => xmlToText(extractZipEntry(buffer, name)?.toString("utf-8") ?? "");
118
+ let text = "";
119
+ if (names.includes("word/document.xml")) {
120
+ text = read("word/document.xml");
121
+ }
122
+ else if (names.includes("xl/sharedStrings.xml")) {
123
+ text = read("xl/sharedStrings.xml");
124
+ }
125
+ else if (names.some((n) => n.startsWith("ppt/slides/slide"))) {
126
+ const slides = names
127
+ .filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n))
128
+ .sort((a, b) => slideNumber(a) - slideNumber(b));
129
+ text = slides.map(read).filter(Boolean).join("\n\n");
130
+ }
131
+ else {
132
+ return null;
133
+ }
134
+ return text.length > 0 ? text : null;
135
+ }
136
+ function slideNumber(name) {
137
+ return Number(/slide(\d+)\.xml$/.exec(name)?.[1] ?? 0);
138
+ }
139
+ //# sourceMappingURL=zip-extract.js.map