diff --git a/console.txt b/console.txt new file mode 100644 index 0000000..b39d35b --- /dev/null +++ b/console.txt @@ -0,0 +1,6 @@ +Total messages: 5 (Errors: 3, Warnings: 0) +Returning 3 messages for level "error" + +[ERROR] Manifest: Line: 1, column: 1, Syntax error. @ http://localhost:3000/site.webmanifest:0 +[ERROR] Manifest: Line: 1, column: 1, Syntax error. @ http://localhost:3000/site.webmanifest:0 +The requested module '/node_modules/lru_map/dist/lru.js?v=e2db4b71' does not provide an export named 'default' \ No newline at end of file diff --git a/counter.py b/counter.py new file mode 100644 index 0000000..f384dcb --- /dev/null +++ b/counter.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +import os +import sys +import argparse +from pathlib import Path + +if sys.platform == "win32": + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + + +def count_lines(file_path: Path) -> int: + try: + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: + return sum(1 for _ in f) + except Exception: + return 0 + + +def should_include(file_path: Path, extensions: list[str]) -> bool: + if not extensions: + return True + return file_path.suffix in extensions + + +def walk_dir(root_path: Path, extensions: list[str], exclude_dirs: set[str]): + results = [] + total_lines = 0 + + for root, dirs, files in os.walk(root_path): + dirs[:] = [d for d in dirs if d not in exclude_dirs] + + rel_root = Path(root).relative_to(root_path) + indent = len(rel_root.parts) if str(rel_root) != "." else 0 + prefix = " " * indent + + if indent > 0: + print(f"{prefix}└── {rel_root.name}/") + + for i, file in enumerate(files): + file_path = Path(root) / file + + if not should_include(file_path, extensions): + continue + + lines = count_lines(file_path) + total_lines += lines + results.append((file_path, lines, indent + 1)) + + is_last = (i == len(files) - 1) and not any( + should_include(Path(root) / f, extensions) for f in dirs + ) + connector = "└──" if is_last else "├──" + + print(f"{' ' * (indent + 1)}{connector} {file} ({lines} lines)") + + return total_lines + + +def main_all(): + directories = { + "src": [".ts", ".tsx", ".vue", ".js"], + "api": [".ts", ".py"], + "electron": [".ts"], + "remote": [".js"], + "shared": [".ts"], + "tools": [".py"], + } + + total_all = 0 + results = [] + + for directory, extensions in directories.items(): + root_path = Path(directory) + if not root_path.exists(): + continue + + exclude_dirs = { + ".git", + "node_modules", + "dist", + "dist-api", + "dist-electron", + "__pycache__", + ".ruff_cache", + } + + print(f"\n{'=' * 60}") + print(f"项目文件统计: {root_path}") + print(f"后缀过滤: {extensions}") + print(f"{'=' * 60}\n") + + total = walk_dir(root_path, extensions, exclude_dirs) + results.append((directory, total)) + total_all += total + + print(f"\n{'=' * 60}") + print("汇总统计") + print(f"{'=' * 60}") + for name, lines in results: + print(f"{name:15} {lines:>10,} 行") + print(f"{'=' * 60}") + print(f"{'总计':15} {total_all:>10,} 行") + print(f"{'=' * 60}\n") + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="统计项目文件行数") + parser.add_argument( + "-e", "--extension", action="append", help="指定后缀名,如: .py .ts .js" + ) + parser.add_argument("-d", "--directory", default=".", help="指定子文件夹路径") + parser.add_argument( + "-x", "--exclude", action="append", default=[], help="排除的文件夹" + ) + parser.add_argument( + "--all", + action="store_true", + help="统计所有源码目录 (src/api/electron/remote/shared/tools)", + ) + + args = parser.parse_args() + + if args.all: + main_all() + else: + root_path = Path(args.directory).resolve() + extensions = args.extension + exclude_dirs = { + ".git", + "node_modules", + "dist", + "dist-api", + "dist-electron", + "__pycache__", + ".ruff_cache", + } + exclude_dirs.update(args.exclude) + + if not root_path.exists(): + print(f"错误: 目录 {root_path} 不存在") + sys.exit(1) + + print(f"\n{'=' * 60}") + print(f"项目文件统计: {root_path}") + if extensions: + print(f"后缀过滤: {extensions}") + print(f"{'=' * 60}\n") + + total = walk_dir(root_path, extensions, exclude_dirs) + + print(f"\n{'=' * 60}") + print(f"总行数: {total}") + print(f"{'=' * 60}\n") diff --git a/dist-api/ai-GLAEBMJM.js b/dist-api/ai-GLAEBMJM.js new file mode 100644 index 0000000..b1cb65c --- /dev/null +++ b/dist-api/ai-GLAEBMJM.js @@ -0,0 +1,12 @@ +import { + ai_default, + createAiModule, + createAiRoutes +} from "./chunk-TSQNCXAS.js"; +import "./chunk-ER4KPD22.js"; +import "./chunk-74TMTGBG.js"; +export { + createAiModule, + createAiRoutes, + ai_default as default +}; diff --git a/dist-api/chunk-47DJ6YUB.js b/dist-api/chunk-47DJ6YUB.js new file mode 100644 index 0000000..ad0a3c9 --- /dev/null +++ b/dist-api/chunk-47DJ6YUB.js @@ -0,0 +1,15 @@ +// api/utils/logger.ts +var createLogger = () => { + const isProd = process.env.NODE_ENV === "production"; + const debug = isProd ? () => { + } : console.debug.bind(console); + const info = console.info.bind(console); + const warn = console.warn.bind(console); + const error = console.error.bind(console); + return { debug, info, warn, error }; +}; +var logger = createLogger(); + +export { + logger +}; diff --git a/dist-api/chunk-5EGA6GHY.js b/dist-api/chunk-5EGA6GHY.js new file mode 100644 index 0000000..8a27427 --- /dev/null +++ b/dist-api/chunk-5EGA6GHY.js @@ -0,0 +1,39 @@ +import { + ValidationError +} from "./chunk-ER4KPD22.js"; + +// api/middlewares/validate.ts +import { ZodError } from "zod"; +var validateBody = (schema) => { + return (req, _res, next) => { + try { + req.body = schema.parse(req.body); + next(); + } catch (error) { + if (error instanceof ZodError) { + next(new ValidationError("Request validation failed", { issues: error.issues })); + } else { + next(error); + } + } + }; +}; +var validateQuery = (schema) => { + return (req, _res, next) => { + try { + req.query = schema.parse(req.query); + next(); + } catch (error) { + if (error instanceof ZodError) { + next(new ValidationError("Query validation failed", { issues: error.issues })); + } else { + next(error); + } + } + }; +}; + +export { + validateBody, + validateQuery +}; diff --git a/dist-api/chunk-74TMTGBG.js b/dist-api/chunk-74TMTGBG.js new file mode 100644 index 0000000..d06f109 --- /dev/null +++ b/dist-api/chunk-74TMTGBG.js @@ -0,0 +1,110 @@ +var __glob = (map) => (path2) => { + var fn = map[path2]; + if (fn) return fn(); + throw new Error("Module not found in bundle: " + path2); +}; + +// api/infra/createModule.ts +function createApiModule(config2, options) { + const metadata = { + id: config2.id, + name: config2.name, + version: config2.version, + basePath: config2.basePath, + order: config2.order, + dependencies: config2.dependencies + }; + const lifecycle = options.lifecycle ? options.lifecycle : options.services ? { + onLoad: options.services + } : void 0; + return { + metadata, + lifecycle, + createRouter: options.routes + }; +} + +// api/utils/asyncHandler.ts +var asyncHandler = (fn) => (req, res, next) => { + Promise.resolve(fn(req, res, next)).catch(next); +}; + +// api/utils/response.ts +var successResponse = (res, data, statusCode = 200) => { + const response = { + success: true, + data + }; + res.status(statusCode).json(response); +}; + +// api/config/index.ts +import path from "path"; +import { fileURLToPath } from "url"; +import os from "os"; +var __filename = fileURLToPath(import.meta.url); +var __dirname = path.dirname(__filename); +var config = { + get projectRoot() { + if (__dirname.includes("app.asar")) { + return path.resolve(__dirname, "..").replace("app.asar", "app.asar.unpacked"); + } + return path.resolve(__dirname, "../../"); + }, + get notebookRoot() { + return process.env.NOTEBOOK_ROOT ? path.resolve(process.env.NOTEBOOK_ROOT) : path.join(this.projectRoot, "notebook"); + }, + get tempRoot() { + return path.join(os.tmpdir(), "xcdesktop_uploads"); + }, + get serverPort() { + return parseInt(process.env.PORT || "3001", 10); + }, + get isVercel() { + return !!process.env.VERCEL; + }, + get isElectron() { + return __dirname.includes("app.asar"); + }, + get isDev() { + return !this.isElectron && !this.isVercel; + } +}; +var PATHS = { + get PROJECT_ROOT() { + return config.projectRoot; + }, + get NOTEBOOK_ROOT() { + return config.notebookRoot; + }, + get TEMP_ROOT() { + return config.tempRoot; + } +}; + +// api/config/paths.ts +var PROJECT_ROOT = PATHS.PROJECT_ROOT; +var NOTEBOOK_ROOT = PATHS.NOTEBOOK_ROOT; +var TEMP_ROOT = PATHS.TEMP_ROOT; + +// shared/modules/types.ts +function defineApiModule(config2) { + return config2; +} +function defineEndpoints(endpoints) { + return endpoints; +} + +export { + __glob, + asyncHandler, + successResponse, + config, + PATHS, + PROJECT_ROOT, + NOTEBOOK_ROOT, + TEMP_ROOT, + createApiModule, + defineApiModule, + defineEndpoints +}; diff --git a/dist-api/chunk-ER4KPD22.js b/dist-api/chunk-ER4KPD22.js new file mode 100644 index 0000000..c8a2458 --- /dev/null +++ b/dist-api/chunk-ER4KPD22.js @@ -0,0 +1,163 @@ +import { + NOTEBOOK_ROOT +} from "./chunk-74TMTGBG.js"; + +// shared/errors/index.ts +var AppError = class extends Error { + constructor(code, message, statusCode = 500, details) { + super(message); + this.code = code; + this.name = "AppError"; + this.statusCode = statusCode; + this.details = details; + } + statusCode; + details; + toJSON() { + return { + name: this.name, + code: this.code, + message: this.message, + statusCode: this.statusCode, + details: this.details + }; + } +}; +var ValidationError = class extends AppError { + constructor(message, details) { + super("VALIDATION_ERROR", message, 400, details); + this.name = "ValidationError"; + } +}; +var NotFoundError = class extends AppError { + constructor(message = "Resource not found", details) { + super("NOT_FOUND", message, 404, details); + this.name = "NotFoundError"; + } +}; +var AccessDeniedError = class extends AppError { + constructor(message = "Access denied", details) { + super("ACCESS_DENIED", message, 403, details); + this.name = "AccessDeniedError"; + } +}; +var BadRequestError = class extends AppError { + constructor(message, details) { + super("BAD_REQUEST", message, 400, details); + this.name = "BadRequestError"; + } +}; +var NotADirectoryError = class extends AppError { + constructor(message = "\u4E0D\u662F\u76EE\u5F55", details) { + super("NOT_A_DIRECTORY", message, 400, details); + this.name = "NotADirectoryError"; + } +}; +var AlreadyExistsError = class extends AppError { + constructor(message = "Resource already exists", details) { + super("ALREADY_EXISTS", message, 409, details); + this.name = "AlreadyExistsError"; + } +}; +var ForbiddenError = class extends AppError { + constructor(message = "\u7981\u6B62\u8BBF\u95EE", details) { + super("FORBIDDEN", message, 403, details); + this.name = "ForbiddenError"; + } +}; +var UnsupportedMediaTypeError = class extends AppError { + constructor(message = "\u4E0D\u652F\u6301\u7684\u5A92\u4F53\u7C7B\u578B", details) { + super("UNSUPPORTED_MEDIA_TYPE", message, 415, details); + this.name = "UnsupportedMediaTypeError"; + } +}; +var ResourceLockedError = class extends AppError { + constructor(message = "\u8D44\u6E90\u5DF2\u9501\u5B9A", details) { + super("RESOURCE_LOCKED", message, 423, details); + this.name = "ResourceLockedError"; + } +}; +var InternalError = class extends AppError { + constructor(message = "\u670D\u52A1\u5668\u5185\u90E8\u9519\u8BEF", details) { + super("INTERNAL_ERROR", message, 500, details); + this.name = "InternalError"; + } +}; +function isAppError(error) { + return error instanceof AppError; +} +function isNodeError(error) { + return error instanceof Error && "code" in error; +} + +// api/utils/pathSafety.ts +import path from "path"; +var DANGEROUS_PATTERNS = [ + /\.\./, + /\0/, + /%2e%2e[%/]/i, + /%252e%252e[%/]/i, + /\.\.%2f/i, + /\.\.%5c/i, + /%c0%ae/i, + /%c1%9c/i, + /%c0%ae%c0%ae/i, + /%c1%9c%c1%9c/i, + /\.\.%c0%af/i, + /\.\.%c1%9c/i, + /%252e/i, + /%uff0e/i, + /%u002e/i +]; +var DOUBLE_ENCODE_PATTERNS = [ + /%25[0-9a-fA-F]{2}/, + /%[0-9a-fA-F]{2}%[0-9a-fA-F]{2}/ +]; +var normalizeRelPath = (input) => { + const trimmed = input.replace(/\0/g, "").trim(); + return trimmed.replace(/^[/\\]+/, ""); +}; +var containsPathTraversal = (input) => { + const decoded = decodeURIComponentSafe(input); + return DANGEROUS_PATTERNS.some((pattern) => pattern.test(input) || pattern.test(decoded)); +}; +var containsDoubleEncoding = (input) => { + return DOUBLE_ENCODE_PATTERNS.some((pattern) => pattern.test(input)); +}; +var hasPathSecurityIssues = (input) => { + return containsPathTraversal(input) || containsDoubleEncoding(input); +}; +var decodeURIComponentSafe = (input) => { + try { + return decodeURIComponent(input); + } catch { + return input; + } +}; +var resolveNotebookPath = (relPath) => { + if (hasPathSecurityIssues(relPath)) { + throw new AccessDeniedError("Path traversal detected"); + } + const safeRelPath = normalizeRelPath(relPath); + const notebookRoot = path.resolve(NOTEBOOK_ROOT); + const fullPath = path.resolve(notebookRoot, safeRelPath); + if (!fullPath.startsWith(notebookRoot)) { + throw new AccessDeniedError("Access denied"); + } + return { safeRelPath, fullPath }; +}; + +export { + ValidationError, + NotFoundError, + BadRequestError, + NotADirectoryError, + AlreadyExistsError, + ForbiddenError, + UnsupportedMediaTypeError, + ResourceLockedError, + InternalError, + isAppError, + isNodeError, + resolveNotebookPath +}; diff --git a/dist-api/chunk-FTVFWJFJ.js b/dist-api/chunk-FTVFWJFJ.js new file mode 100644 index 0000000..e79caf7 --- /dev/null +++ b/dist-api/chunk-FTVFWJFJ.js @@ -0,0 +1,20 @@ +import { + PATHS +} from "./chunk-74TMTGBG.js"; + +// api/utils/tempDir.ts +import { existsSync, mkdirSync } from "fs"; +var tempDir = null; +var getTempDir = () => { + if (!tempDir) { + tempDir = PATHS.TEMP_ROOT; + if (!existsSync(tempDir)) { + mkdirSync(tempDir, { recursive: true }); + } + } + return tempDir; +}; + +export { + getTempDir +}; diff --git a/dist-api/chunk-M2SZ5AIA.js b/dist-api/chunk-M2SZ5AIA.js new file mode 100644 index 0000000..72c60a7 --- /dev/null +++ b/dist-api/chunk-M2SZ5AIA.js @@ -0,0 +1,234 @@ +import { + AlreadyExistsError, + BadRequestError, + NotFoundError, + ValidationError, + resolveNotebookPath +} from "./chunk-ER4KPD22.js"; +import { + asyncHandler, + createApiModule, + defineApiModule, + defineEndpoints, + successResponse +} from "./chunk-74TMTGBG.js"; + +// shared/modules/recycle-bin/api.ts +var RECYCLE_BIN_ENDPOINTS = defineEndpoints({ + list: { path: "/", method: "GET" }, + restore: { path: "/restore", method: "POST" }, + permanent: { path: "/permanent", method: "DELETE" }, + empty: { path: "/empty", method: "DELETE" } +}); + +// shared/modules/recycle-bin/index.ts +var RECYCLE_BIN_MODULE = defineApiModule({ + id: "recycle-bin", + name: "\u56DE\u6536\u7AD9", + basePath: "/recycle-bin", + order: 40, + version: "1.0.0", + endpoints: RECYCLE_BIN_ENDPOINTS +}); + +// api/modules/recycle-bin/routes.ts +import express from "express"; +import fs2 from "fs/promises"; +import path2 from "path"; + +// api/modules/recycle-bin/recycleBinService.ts +import fs from "fs/promises"; +import path from "path"; +async function restoreFile(srcPath, destPath, deletedDate, year, month, day) { + const { fullPath: imagesDir } = resolveNotebookPath(`images/${year}/${month}/${day}`); + let content = await fs.readFile(srcPath, "utf-8"); + const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; + let match; + const imageReplacements = []; + while ((match = imageRegex.exec(content)) !== null) { + const imagePath = match[2]; + const imageName = path.basename(imagePath); + const rbImageName = `${deletedDate}_${imageName}`; + const { fullPath: srcImagePath } = resolveNotebookPath(`RB/${rbImageName}`); + try { + await fs.access(srcImagePath); + await fs.mkdir(imagesDir, { recursive: true }); + const destImagePath = path.join(imagesDir, imageName); + await fs.rename(srcImagePath, destImagePath); + const newImagePath = `images/${year}/${month}/${day}/${imageName}`; + imageReplacements.push({ oldPath: imagePath, newPath: newImagePath }); + } catch { + } + } + for (const { oldPath, newPath } of imageReplacements) { + content = content.replace(new RegExp(oldPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), newPath); + } + await fs.writeFile(destPath, content, "utf-8"); + await fs.unlink(srcPath); +} +async function restoreFolder(srcPath, destPath, deletedDate, year, month, day) { + await fs.mkdir(destPath, { recursive: true }); + const entries = await fs.readdir(srcPath, { withFileTypes: true }); + for (const entry of entries) { + const srcEntryPath = path.join(srcPath, entry.name); + const destEntryPath = path.join(destPath, entry.name); + if (entry.isDirectory()) { + await restoreFolder(srcEntryPath, destEntryPath, deletedDate, year, month, day); + } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) { + await restoreFile(srcEntryPath, destEntryPath, deletedDate, year, month, day); + } else { + await fs.rename(srcEntryPath, destEntryPath); + } + } + const remaining = await fs.readdir(srcPath); + if (remaining.length === 0) { + await fs.rmdir(srcPath); + } +} + +// api/modules/recycle-bin/routes.ts +var router = express.Router(); +router.get( + "/", + asyncHandler(async (req, res) => { + const { fullPath: rbDir } = resolveNotebookPath("RB"); + try { + await fs2.access(rbDir); + } catch { + successResponse(res, { groups: [] }); + return; + } + const entries = await fs2.readdir(rbDir, { withFileTypes: true }); + const items = []; + for (const entry of entries) { + const match = entry.name.match(/^(\d{8})_(.+)$/); + if (!match) continue; + const [, dateStr, originalName] = match; + if (entry.isDirectory()) { + items.push({ + name: entry.name, + originalName, + type: "dir", + deletedDate: dateStr, + path: `RB/${entry.name}` + }); + } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) { + items.push({ + name: entry.name, + originalName, + type: "file", + deletedDate: dateStr, + path: `RB/${entry.name}` + }); + } + } + const groupedMap = /* @__PURE__ */ new Map(); + for (const item of items) { + const existing = groupedMap.get(item.deletedDate) || []; + existing.push(item); + groupedMap.set(item.deletedDate, existing); + } + const groups = Array.from(groupedMap.entries()).map(([date, items2]) => ({ + date, + items: items2.sort((a, b) => a.originalName.localeCompare(b.originalName)) + })).sort((a, b) => b.date.localeCompare(a.date)); + successResponse(res, { groups }); + }) +); +router.post( + "/restore", + asyncHandler(async (req, res) => { + const { path: relPath, type } = req.body; + if (!relPath || !type) { + throw new ValidationError("Path and type are required"); + } + const { fullPath: itemPath } = resolveNotebookPath(relPath); + try { + await fs2.access(itemPath); + } catch { + throw new NotFoundError("Item not found in recycle bin"); + } + const match = path2.basename(itemPath).match(/^(\d{8})_(.+)$/); + if (!match) { + throw new BadRequestError("Invalid recycle bin item name"); + } + const [, dateStr, originalName] = match; + const year = dateStr.substring(0, 4); + const month = dateStr.substring(4, 6); + const day = dateStr.substring(6, 8); + const { fullPath: markdownsDir } = resolveNotebookPath("markdowns"); + await fs2.mkdir(markdownsDir, { recursive: true }); + const destPath = path2.join(markdownsDir, originalName); + const existing = await fs2.stat(destPath).catch(() => null); + if (existing) { + throw new AlreadyExistsError("A file or folder with this name already exists"); + } + if (type === "dir") { + await restoreFolder(itemPath, destPath, dateStr, year, month, day); + } else { + await restoreFile(itemPath, destPath, dateStr, year, month, day); + } + successResponse(res, null); + }) +); +router.delete( + "/permanent", + asyncHandler(async (req, res) => { + const { path: relPath, type } = req.body; + if (!relPath || !type) { + throw new ValidationError("Path and type are required"); + } + const { fullPath: itemPath } = resolveNotebookPath(relPath); + try { + await fs2.access(itemPath); + } catch { + throw new NotFoundError("Item not found in recycle bin"); + } + if (type === "dir") { + await fs2.rm(itemPath, { recursive: true, force: true }); + } else { + await fs2.unlink(itemPath); + } + successResponse(res, null); + }) +); +router.delete( + "/empty", + asyncHandler(async (req, res) => { + const { fullPath: rbDir } = resolveNotebookPath("RB"); + try { + await fs2.access(rbDir); + } catch { + successResponse(res, null); + return; + } + const entries = await fs2.readdir(rbDir, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path2.join(rbDir, entry.name); + if (entry.isDirectory()) { + await fs2.rm(entryPath, { recursive: true, force: true }); + } else { + await fs2.unlink(entryPath); + } + } + successResponse(res, null); + }) +); +var routes_default = router; + +// api/modules/recycle-bin/index.ts +var createRecycleBinModule = () => { + return createApiModule(RECYCLE_BIN_MODULE, { + routes: (_container) => { + return routes_default; + } + }); +}; +var recycle_bin_default = createRecycleBinModule; + +export { + restoreFile, + restoreFolder, + createRecycleBinModule, + recycle_bin_default +}; diff --git a/dist-api/chunk-QS2CMBFP.js b/dist-api/chunk-QS2CMBFP.js new file mode 100644 index 0000000..3f92d5e --- /dev/null +++ b/dist-api/chunk-QS2CMBFP.js @@ -0,0 +1,912 @@ +import { + logger +} from "./chunk-47DJ6YUB.js"; +import { + NOTEBOOK_ROOT, + asyncHandler, + createApiModule, + defineApiModule, + defineEndpoints, + successResponse +} from "./chunk-74TMTGBG.js"; + +// shared/modules/time-tracking/api.ts +var TIME_TRACKING_ENDPOINTS = defineEndpoints({ + current: { path: "/current", method: "GET" }, + event: { path: "/event", method: "POST" }, + day: { path: "/day/:date", method: "GET" }, + week: { path: "/week/:startDate", method: "GET" }, + month: { path: "/month/:yearMonth", method: "GET" }, + year: { path: "/year/:year", method: "GET" }, + stats: { path: "/stats", method: "GET" } +}); + +// shared/modules/time-tracking/index.ts +var TIME_TRACKING_MODULE = defineApiModule({ + id: "time-tracking", + name: "\u65F6\u95F4\u7EDF\u8BA1", + basePath: "/time", + order: 20, + version: "1.0.0", + endpoints: TIME_TRACKING_ENDPOINTS +}); + +// shared/utils/tabType.ts +var KNOWN_MODULE_IDS = [ + "home", + "settings", + "search", + "weread", + "recycle-bin", + "todo", + "time-tracking", + "pydemos" +]; +function getTabTypeFromPath(filePath) { + if (!filePath) return "other"; + if (filePath.startsWith("file-transfer-panel")) { + return "file-transfer"; + } + if (filePath.startsWith("remote-git://")) { + return "remote-git"; + } + if (filePath.startsWith("remote-desktop://")) { + return "remote-desktop"; + } + if (filePath.startsWith("remote-") && filePath !== "remote-tab") { + return "remote-desktop"; + } + if (filePath === "remote-tab" || filePath === "remote") { + return "remote"; + } + for (const moduleId of KNOWN_MODULE_IDS) { + if (filePath === `${moduleId}-tab` || filePath === moduleId) { + if (moduleId === "home" || moduleId === "settings" || moduleId === "search" || moduleId === "weread") { + return "other"; + } + return moduleId; + } + } + if (filePath.endsWith(".md")) { + return "markdown"; + } + return "other"; +} +function getFileNameFromPath(filePath) { + if (!filePath) return "\u672A\u77E5"; + if (filePath.startsWith("file-transfer-panel")) { + const params = new URLSearchParams(filePath.split("?")[1] || ""); + const deviceName = params.get("device") || ""; + return deviceName ? `\u6587\u4EF6\u4F20\u8F93 - ${deviceName}` : "\u6587\u4EF6\u4F20\u8F93"; + } + for (const moduleId of KNOWN_MODULE_IDS) { + if (filePath === `${moduleId}-tab` || filePath === moduleId) { + const names = { + "home": "\u9996\u9875", + "settings": "\u8BBE\u7F6E", + "search": "\u641C\u7D22", + "weread": "\u5FAE\u4FE1\u8BFB\u4E66", + "recycle-bin": "\u56DE\u6536\u7AD9", + "todo": "TODO", + "time-tracking": "\u65F6\u95F4\u7EDF\u8BA1", + "pydemos": "Python Demo", + "remote": "\u8FDC\u7A0B\u684C\u9762" + }; + return names[moduleId] ?? moduleId; + } + } + const parts = filePath.split("/"); + return parts[parts.length - 1] || filePath; +} + +// api/modules/time-tracking/heartbeatService.ts +var DEFAULT_HEARTBEAT_INTERVAL = 6e4; +var HeartbeatService = class { + interval = null; + lastHeartbeat = /* @__PURE__ */ new Date(); + intervalMs; + callback = null; + constructor(intervalMs = DEFAULT_HEARTBEAT_INTERVAL) { + this.intervalMs = intervalMs; + } + setCallback(callback) { + this.callback = callback; + } + start() { + if (this.interval) { + this.stop(); + } + this.interval = setInterval(async () => { + if (this.callback) { + try { + this.lastHeartbeat = /* @__PURE__ */ new Date(); + await this.callback(); + } catch (err) { + logger.error("Heartbeat callback failed:", err); + } + } + }, this.intervalMs); + this.lastHeartbeat = /* @__PURE__ */ new Date(); + } + stop() { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } + isRunning() { + return this.interval !== null; + } + getLastHeartbeat() { + return this.lastHeartbeat; + } + updateHeartbeat() { + this.lastHeartbeat = /* @__PURE__ */ new Date(); + } + getState() { + return { + lastHeartbeat: this.lastHeartbeat, + isRunning: this.isRunning() + }; + } + restoreState(state) { + this.lastHeartbeat = new Date(state.lastHeartbeat); + } +}; +var createHeartbeatService = (intervalMs) => { + return new HeartbeatService(intervalMs); +}; + +// api/modules/time-tracking/sessionPersistence.ts +import fs from "fs/promises"; +import path from "path"; +var TIME_ROOT = path.join(NOTEBOOK_ROOT, "time"); +var getDayFilePath = (year, month, day) => { + const monthStr = month.toString().padStart(2, "0"); + const dayStr = day.toString().padStart(2, "0"); + return path.join(TIME_ROOT, year.toString(), `${year}${monthStr}${dayStr}.json`); +}; +var getMonthFilePath = (year, month) => { + const monthStr = month.toString().padStart(2, "0"); + return path.join(TIME_ROOT, year.toString(), `${year}${monthStr}.json`); +}; +var getYearFilePath = (year) => { + return path.join(TIME_ROOT, "summary", `${year}.json`); +}; +var ensureDirExists = async (filePath) => { + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); +}; +var createEmptyDayData = (year, month, day) => ({ + date: `${year}-${month.toString().padStart(2, "0")}-${day.toString().padStart(2, "0")}`, + totalDuration: 0, + sessions: [], + tabSummary: {}, + lastUpdated: (/* @__PURE__ */ new Date()).toISOString() +}); +var createEmptyMonthData = (year, month) => ({ + year, + month, + days: {}, + monthlyTotal: 0, + averageDaily: 0, + activeDays: 0, + lastUpdated: (/* @__PURE__ */ new Date()).toISOString() +}); +var createEmptyYearData = (year) => ({ + year, + months: {}, + yearlyTotal: 0, + averageMonthly: 0, + averageDaily: 0, + totalActiveDays: 0 +}); +var SessionPersistenceService = class { + stateFilePath; + constructor() { + this.stateFilePath = path.join(TIME_ROOT, ".current-session.json"); + } + async loadCurrentState() { + try { + const content = await fs.readFile(this.stateFilePath, "utf-8"); + const state = JSON.parse(content); + return { + session: state.session || null, + currentTabRecord: state.currentTabRecord || null, + isPaused: state.isPaused || false, + lastHeartbeat: state.lastHeartbeat || (/* @__PURE__ */ new Date()).toISOString() + }; + } catch (err) { + logger.debug("No existing session to load or session file corrupted"); + return { + session: null, + currentTabRecord: null, + isPaused: false, + lastHeartbeat: (/* @__PURE__ */ new Date()).toISOString() + }; + } + } + async saveCurrentState(state) { + await ensureDirExists(this.stateFilePath); + await fs.writeFile(this.stateFilePath, JSON.stringify({ + session: state.session, + currentTabRecord: state.currentTabRecord, + isPaused: state.isPaused, + lastHeartbeat: state.lastHeartbeat + }), "utf-8"); + } + async clearCurrentState() { + try { + await fs.unlink(this.stateFilePath); + } catch (err) { + logger.debug("Session state file already removed or does not exist"); + } + } + async saveSessionToDay(session) { + const startTime = new Date(session.startTime); + const year = startTime.getFullYear(); + const month = startTime.getMonth() + 1; + const day = startTime.getDate(); + const filePath = getDayFilePath(year, month, day); + await ensureDirExists(filePath); + let dayData = await this.getDayData(year, month, day); + dayData.sessions.push(session); + dayData.totalDuration += session.duration; + for (const record of session.tabRecords) { + const key = record.filePath || record.fileName; + if (!dayData.tabSummary[key]) { + dayData.tabSummary[key] = { + fileName: record.fileName, + tabType: record.tabType, + totalDuration: 0, + focusCount: 0 + }; + } + dayData.tabSummary[key].totalDuration += record.duration; + dayData.tabSummary[key].focusCount += record.focusedPeriods.length; + } + dayData.lastUpdated = (/* @__PURE__ */ new Date()).toISOString(); + await fs.writeFile(filePath, JSON.stringify(dayData, null, 2), "utf-8"); + await this.updateMonthSummary(year, month, day, session.duration); + await this.updateYearSummary(year, month, session.duration); + } + async getDayData(year, month, day) { + const filePath = getDayFilePath(year, month, day); + try { + const content = await fs.readFile(filePath, "utf-8"); + return JSON.parse(content); + } catch (err) { + return createEmptyDayData(year, month, day); + } + } + async getMonthData(year, month) { + const filePath = getMonthFilePath(year, month); + try { + const content = await fs.readFile(filePath, "utf-8"); + const data = JSON.parse(content); + data.activeDays = Object.values(data.days).filter((d) => d.totalDuration > 0).length; + return data; + } catch (err) { + return createEmptyMonthData(year, month); + } + } + async getYearData(year) { + const filePath = getYearFilePath(year); + try { + const content = await fs.readFile(filePath, "utf-8"); + const data = JSON.parse(content); + data.totalActiveDays = Object.values(data.months).filter((m) => m.totalDuration > 0).length; + return data; + } catch (err) { + return createEmptyYearData(year); + } + } + async updateDayDataRealtime(year, month, day, session, currentTabRecord) { + const filePath = getDayFilePath(year, month, day); + await ensureDirExists(filePath); + let dayData = await this.getDayData(year, month, day); + const currentSessionDuration = session.tabRecords.reduce((sum, r) => sum + r.duration, 0) + (currentTabRecord?.duration || 0); + const existingSessionIndex = dayData.sessions.findIndex((s) => s.id === session.id); + const realtimeSession = { + ...session, + duration: currentSessionDuration, + tabRecords: currentTabRecord ? [...session.tabRecords, currentTabRecord] : session.tabRecords + }; + if (existingSessionIndex >= 0) { + const oldDuration = dayData.sessions[existingSessionIndex].duration; + dayData.sessions[existingSessionIndex] = realtimeSession; + dayData.totalDuration = dayData.totalDuration - oldDuration + currentSessionDuration; + } else { + dayData.sessions.push(realtimeSession); + dayData.totalDuration += currentSessionDuration; + } + dayData.tabSummary = {}; + for (const s of dayData.sessions) { + for (const record of s.tabRecords) { + const key = record.filePath || record.fileName; + if (!dayData.tabSummary[key]) { + dayData.tabSummary[key] = { + fileName: record.fileName, + tabType: record.tabType, + totalDuration: 0, + focusCount: 0 + }; + } + dayData.tabSummary[key].totalDuration += record.duration; + dayData.tabSummary[key].focusCount += record.focusedPeriods.length; + } + } + dayData.lastUpdated = (/* @__PURE__ */ new Date()).toISOString(); + await fs.writeFile(filePath, JSON.stringify(dayData, null, 2), "utf-8"); + return dayData; + } + async updateMonthSummary(year, month, day, duration) { + const filePath = getMonthFilePath(year, month); + await ensureDirExists(filePath); + let monthData = await this.getMonthData(year, month); + const dayStr = day.toString().padStart(2, "0"); + if (!monthData.days[dayStr]) { + monthData.days[dayStr] = { totalDuration: 0, sessions: 0, topTabs: [] }; + } + monthData.days[dayStr].totalDuration += duration; + monthData.days[dayStr].sessions += 1; + monthData.monthlyTotal += duration; + monthData.activeDays = Object.values(monthData.days).filter((d) => d.totalDuration > 0).length; + monthData.averageDaily = monthData.activeDays > 0 ? Math.floor(monthData.monthlyTotal / monthData.activeDays) : 0; + monthData.lastUpdated = (/* @__PURE__ */ new Date()).toISOString(); + await fs.writeFile(filePath, JSON.stringify(monthData, null, 2), "utf-8"); + } + async updateYearSummary(year, month, duration) { + const filePath = getYearFilePath(year); + await ensureDirExists(filePath); + let yearData = await this.getYearData(year); + const monthStr = month.toString().padStart(2, "0"); + if (!yearData.months[monthStr]) { + yearData.months[monthStr] = { totalDuration: 0, activeDays: 0 }; + } + yearData.months[monthStr].totalDuration += duration; + yearData.yearlyTotal += duration; + yearData.totalActiveDays = Object.values(yearData.months).reduce((sum, m) => { + const hasActiveDays = m.totalDuration > 0 ? 1 : 0; + return sum + hasActiveDays; + }, 0); + const activeMonthCount = Object.values(yearData.months).filter((m) => m.totalDuration > 0).length; + yearData.averageMonthly = activeMonthCount > 0 ? Math.floor(yearData.yearlyTotal / activeMonthCount) : 0; + yearData.averageDaily = yearData.totalActiveDays > 0 ? Math.floor(yearData.yearlyTotal / yearData.totalActiveDays) : 0; + await fs.writeFile(filePath, JSON.stringify(yearData, null, 2), "utf-8"); + } + async recalculateMonthSummary(year, month, todayDuration) { + const monthFilePath = getMonthFilePath(year, month); + await ensureDirExists(monthFilePath); + let monthData = await this.getMonthData(year, month); + const dayStr = (/* @__PURE__ */ new Date()).getDate().toString().padStart(2, "0"); + if (!monthData.days[dayStr]) { + monthData.days[dayStr] = { totalDuration: 0, sessions: 0, topTabs: [] }; + } + const oldDayDuration = monthData.days[dayStr].totalDuration; + monthData.days[dayStr].totalDuration = todayDuration; + monthData.monthlyTotal = monthData.monthlyTotal - oldDayDuration + todayDuration; + monthData.activeDays = Object.values(monthData.days).filter((d) => d.totalDuration > 0).length; + monthData.averageDaily = monthData.activeDays > 0 ? Math.floor(monthData.monthlyTotal / monthData.activeDays) : 0; + monthData.lastUpdated = (/* @__PURE__ */ new Date()).toISOString(); + await fs.writeFile(monthFilePath, JSON.stringify(monthData, null, 2), "utf-8"); + } + async recalculateYearSummary(year) { + const yearFilePath = getYearFilePath(year); + await ensureDirExists(yearFilePath); + let yearData = await this.getYearData(year); + const monthStr = ((/* @__PURE__ */ new Date()).getMonth() + 1).toString().padStart(2, "0"); + const monthFilePath = getMonthFilePath(year, (/* @__PURE__ */ new Date()).getMonth() + 1); + try { + const monthContent = await fs.readFile(monthFilePath, "utf-8"); + const monthData = JSON.parse(monthContent); + if (!yearData.months[monthStr]) { + yearData.months[monthStr] = { totalDuration: 0, activeDays: 0 }; + } + const oldMonthTotal = yearData.months[monthStr].totalDuration; + yearData.months[monthStr].totalDuration = monthData.monthlyTotal; + yearData.months[monthStr].activeDays = monthData.activeDays; + yearData.yearlyTotal = yearData.yearlyTotal - oldMonthTotal + monthData.monthlyTotal; + yearData.totalActiveDays = Object.values(yearData.months).reduce((sum, m) => { + const hasActiveDays = m.totalDuration > 0 ? 1 : 0; + return sum + hasActiveDays; + }, 0); + const activeMonthCount = Object.values(yearData.months).filter((m) => m.totalDuration > 0).length; + yearData.averageMonthly = activeMonthCount > 0 ? Math.floor(yearData.yearlyTotal / activeMonthCount) : 0; + yearData.averageDaily = yearData.totalActiveDays > 0 ? Math.floor(yearData.yearlyTotal / yearData.totalActiveDays) : 0; + } catch (err) { + logger.debug("Month file not found for year summary calculation"); + } + await fs.writeFile(yearFilePath, JSON.stringify(yearData, null, 2), "utf-8"); + } +}; +var createSessionPersistence = () => { + return new SessionPersistenceService(); +}; + +// api/modules/time-tracking/timeService.ts +var generateId = () => { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +}; +var TimeTrackerService = class _TimeTrackerService { + currentSession = null; + currentTabRecord = null; + isPaused = false; + todayDuration = 0; + _initialized = false; + static _initializationPromise = null; + heartbeatService; + persistence; + constructor(dependencies) { + this.heartbeatService = dependencies.heartbeatService; + this.persistence = dependencies.persistence; + } + static async create(config) { + const heartbeatService = createHeartbeatService(config?.heartbeatIntervalMs); + const persistence = createSessionPersistence(); + const instance = new _TimeTrackerService({ + heartbeatService, + persistence + }); + await instance.initialize(); + return instance; + } + static async createWithDependencies(dependencies) { + const instance = new _TimeTrackerService(dependencies); + await instance.initialize(); + return instance; + } + async initialize() { + if (this._initialized) { + return; + } + if (_TimeTrackerService._initializationPromise) { + await _TimeTrackerService._initializationPromise; + return; + } + _TimeTrackerService._initializationPromise = this.loadCurrentState(); + await _TimeTrackerService._initializationPromise; + this._initialized = true; + _TimeTrackerService._initializationPromise = null; + this.heartbeatService.setCallback(async () => { + if (this.currentSession && !this.isPaused) { + try { + this.heartbeatService.updateHeartbeat(); + await this.updateCurrentTabDuration(); + await this.saveCurrentState(); + await this.updateTodayDataRealtime(); + } catch (err) { + logger.error("Heartbeat update failed:", err); + } + } + }); + } + ensureInitialized() { + if (!this._initialized) { + throw new Error("TimeTrackerService \u672A\u521D\u59CB\u5316\uFF0C\u8BF7\u4F7F\u7528 TimeTrackerService.create() \u521B\u5EFA\u5B9E\u4F8B"); + } + } + async loadCurrentState() { + const now = /* @__PURE__ */ new Date(); + const todayData = await this.persistence.getDayData(now.getFullYear(), now.getMonth() + 1, now.getDate()); + this.todayDuration = todayData.totalDuration; + const state = await this.persistence.loadCurrentState(); + if (state.session && state.session.status === "active") { + const sessionStart = new Date(state.session.startTime); + const now2 = /* @__PURE__ */ new Date(); + if (now2.getTime() - sessionStart.getTime() < 24 * 60 * 60 * 1e3) { + this.currentSession = state.session; + this.isPaused = state.isPaused; + if (state.currentTabRecord) { + this.currentTabRecord = state.currentTabRecord; + } + this.heartbeatService.restoreState({ lastHeartbeat: state.lastHeartbeat }); + } else { + await this.endSession(); + } + } + } + async saveCurrentState() { + await this.persistence.saveCurrentState({ + session: this.currentSession, + currentTabRecord: this.currentTabRecord, + isPaused: this.isPaused, + lastHeartbeat: this.heartbeatService.getLastHeartbeat().toISOString() + }); + } + async startSession() { + if (this.currentSession && this.currentSession.status === "active") { + return this.currentSession; + } + const now = /* @__PURE__ */ new Date(); + this.currentSession = { + id: generateId(), + startTime: now.toISOString(), + duration: 0, + status: "active", + tabRecords: [] + }; + this.isPaused = false; + this.heartbeatService.updateHeartbeat(); + this.heartbeatService.start(); + await this.saveCurrentState(); + return this.currentSession; + } + async pauseSession() { + if (!this.currentSession || this.isPaused) return; + this.isPaused = true; + await this.updateCurrentTabDuration(); + await this.saveCurrentState(); + } + async resumeSession() { + if (!this.currentSession || !this.isPaused) return; + this.isPaused = false; + this.heartbeatService.updateHeartbeat(); + if (this.currentTabRecord) { + const now = /* @__PURE__ */ new Date(); + const timeStr = `${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}:${now.getSeconds().toString().padStart(2, "0")}`; + this.currentTabRecord.focusedPeriods.push({ start: timeStr, end: timeStr }); + } + await this.saveCurrentState(); + } + async endSession() { + if (!this.currentSession) return; + this.heartbeatService.stop(); + await this.updateCurrentTabDuration(); + const now = /* @__PURE__ */ new Date(); + this.currentSession.endTime = now.toISOString(); + this.currentSession.status = "ended"; + const startTime = new Date(this.currentSession.startTime); + this.currentSession.duration = Math.floor((now.getTime() - startTime.getTime()) / 1e3); + await this.persistence.saveSessionToDay(this.currentSession); + this.todayDuration += this.currentSession.duration; + this.currentSession = null; + this.currentTabRecord = null; + this.isPaused = false; + await this.persistence.clearCurrentState(); + } + async updateCurrentTabDuration() { + if (!this.currentSession || !this.currentTabRecord) return; + const now = /* @__PURE__ */ new Date(); + const periods = this.currentTabRecord.focusedPeriods; + if (periods.length > 0) { + const lastPeriod = periods[periods.length - 1]; + const [h, m, s] = lastPeriod.start.split(":").map(Number); + const startSeconds = h * 3600 + m * 60 + s; + const currentSeconds = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds(); + this.currentTabRecord.duration = currentSeconds - startSeconds; + lastPeriod.end = `${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}:${now.getSeconds().toString().padStart(2, "0")}`; + } + } + async updateTodayDataRealtime() { + if (!this.currentSession) return; + const now = /* @__PURE__ */ new Date(); + const year = now.getFullYear(); + const month = now.getMonth() + 1; + const day = now.getDate(); + const dayData = await this.persistence.updateDayDataRealtime( + year, + month, + day, + this.currentSession, + this.currentTabRecord + ); + this.todayDuration = dayData.totalDuration; + await this.persistence.recalculateMonthSummary(year, month, this.todayDuration); + await this.persistence.recalculateYearSummary(year); + } + async handleTabSwitch(tabInfo) { + if (!this.currentSession || this.isPaused) return; + await this.updateCurrentTabDuration(); + if (this.currentTabRecord && this.currentTabRecord.duration > 0) { + this.currentSession.tabRecords.push({ ...this.currentTabRecord }); + } + const now = /* @__PURE__ */ new Date(); + const timeStr = `${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}:${now.getSeconds().toString().padStart(2, "0")}`; + this.currentTabRecord = { + tabId: tabInfo.tabId, + filePath: tabInfo.filePath, + fileName: getFileNameFromPath(tabInfo.filePath), + tabType: getTabTypeFromPath(tabInfo.filePath), + duration: 0, + focusedPeriods: [{ start: timeStr, end: timeStr }] + }; + await this.saveCurrentState(); + } + async handleEvent(event) { + switch (event.type) { + case "window-focus": + if (!this.currentSession) { + await this.startSession(); + if (event.tabInfo) { + await this.handleTabSwitch(event.tabInfo); + } + } else { + await this.resumeSession(); + await this.updateTodayDataRealtime(); + } + break; + case "window-blur": + await this.pauseSession(); + await this.updateTodayDataRealtime(); + break; + case "app-quit": + await this.endSession(); + break; + case "tab-switch": + case "tab-open": + if (!this.currentSession) { + await this.startSession(); + } + if (event.tabInfo) { + await this.handleTabSwitch(event.tabInfo); + } + await this.updateTodayDataRealtime(); + break; + case "tab-close": + await this.updateCurrentTabDuration(); + await this.updateTodayDataRealtime(); + break; + case "heartbeat": + if (this.currentSession && !this.isPaused) { + this.heartbeatService.updateHeartbeat(); + await this.updateCurrentTabDuration(); + await this.saveCurrentState(); + await this.updateTodayDataRealtime(); + } + break; + } + } + async getDayData(year, month, day) { + return this.persistence.getDayData(year, month, day); + } + async getWeekData(startDate) { + const result = []; + for (let i = 0; i < 7; i++) { + const date = new Date(startDate); + date.setDate(date.getDate() + i); + const data = await this.persistence.getDayData(date.getFullYear(), date.getMonth() + 1, date.getDate()); + result.push(data); + } + return result; + } + async getMonthData(year, month) { + return this.persistence.getMonthData(year, month); + } + async getYearData(year) { + return this.persistence.getYearData(year); + } + getCurrentState() { + return { + isRunning: this.currentSession !== null, + isPaused: this.isPaused, + currentSession: this.currentSession, + todayDuration: this.todayDuration, + currentTabRecord: this.currentTabRecord + }; + } + async getStats(year, month) { + const now = /* @__PURE__ */ new Date(); + const targetYear = year || now.getFullYear(); + const targetMonth = month; + let totalDuration = 0; + let activeDays = 0; + let longestDay = null; + let longestSession = null; + const tabDurations = {}; + const tabTypeDurations = {}; + if (targetMonth) { + const monthData = await this.persistence.getMonthData(targetYear, targetMonth); + totalDuration = monthData.monthlyTotal; + activeDays = Object.values(monthData.days).filter((d) => d.totalDuration > 0).length; + for (const [day, summary] of Object.entries(monthData.days)) { + if (!longestDay || summary.totalDuration > longestDay.duration) { + longestDay = { date: `${targetYear}-${targetMonth.toString().padStart(2, "0")}-${day}`, duration: summary.totalDuration }; + } + for (const tab of summary.topTabs || []) { + tabDurations[tab.fileName] = (tabDurations[tab.fileName] || 0) + tab.duration; + } + } + const dayData = await this.persistence.getDayData(targetYear, targetMonth, 1); + for (const session of dayData.sessions) { + for (const record of session.tabRecords) { + const key = record.filePath || record.fileName; + tabDurations[key] = (tabDurations[key] || 0) + record.duration; + tabTypeDurations[record.tabType] = (tabTypeDurations[record.tabType] || 0) + record.duration; + } + } + } else { + const yearData = await this.persistence.getYearData(targetYear); + totalDuration = yearData.yearlyTotal; + activeDays = Object.values(yearData.months).reduce((sum, m) => sum + m.activeDays, 0); + for (const [month2, summary] of Object.entries(yearData.months)) { + if (!longestDay || summary.totalDuration > longestDay.duration) { + longestDay = { date: `${targetYear}-${month2}`, duration: summary.totalDuration }; + } + } + for (let m = 1; m <= 12; m++) { + const monthStr = m.toString().padStart(2, "0"); + const monthData = await this.persistence.getMonthData(targetYear, m); + for (const dayData of Object.values(monthData.days)) { + for (const tab of dayData.topTabs || []) { + tabDurations[tab.fileName] = (tabDurations[tab.fileName] || 0) + tab.duration; + } + } + } + } + return { + totalDuration, + activeDays, + averageDaily: activeDays > 0 ? Math.floor(totalDuration / activeDays) : 0, + longestDay, + longestSession, + topTabs: Object.entries(tabDurations).map(([fileName, duration]) => ({ + fileName, + duration, + percentage: totalDuration > 0 ? Math.round(duration / totalDuration * 100) : 0 + })).sort((a, b) => b.duration - a.duration).slice(0, 10), + tabTypeDistribution: Object.entries(tabTypeDurations).map(([tabType, duration]) => ({ + tabType, + duration, + percentage: totalDuration > 0 ? Math.round(duration / totalDuration * 100) : 0 + })).sort((a, b) => b.duration - a.duration) + }; + } +}; +var _timeTrackerService = null; +var getTimeTrackerService = () => { + if (!_timeTrackerService) { + throw new Error("TimeTrackerService \u672A\u521D\u59CB\u5316\uFF0C\u8BF7\u5148\u8C03\u7528 initializeTimeTrackerService()"); + } + return _timeTrackerService; +}; +var initializeTimeTrackerService = async (config) => { + if (_timeTrackerService) { + return _timeTrackerService; + } + _timeTrackerService = await TimeTrackerService.create(config); + return _timeTrackerService; +}; +var initializeTimeTrackerServiceWithDependencies = async (dependencies) => { + if (_timeTrackerService) { + return _timeTrackerService; + } + _timeTrackerService = await TimeTrackerService.createWithDependencies(dependencies); + return _timeTrackerService; +}; + +// api/modules/time-tracking/routes.ts +import express from "express"; +var createTimeTrackingRoutes = (deps) => { + const router = express.Router(); + const { timeTrackerService } = deps; + router.get( + "/current", + asyncHandler(async (_req, res) => { + const state = timeTrackerService.getCurrentState(); + successResponse(res, { + isRunning: state.isRunning, + isPaused: state.isPaused, + currentSession: state.currentSession ? { + id: state.currentSession.id, + startTime: state.currentSession.startTime, + duration: state.currentSession.duration, + currentTab: state.currentTabRecord ? { + tabId: state.currentTabRecord.tabId, + fileName: state.currentTabRecord.fileName, + tabType: state.currentTabRecord.tabType + } : null + } : null, + todayDuration: state.todayDuration + }); + }) + ); + router.post( + "/event", + asyncHandler(async (req, res) => { + const event = req.body; + await timeTrackerService.handleEvent(event); + successResponse(res, null); + }) + ); + router.get( + "/day/:date", + asyncHandler(async (req, res) => { + const { date } = req.params; + const [year, month, day] = date.split("-").map(Number); + const data = await timeTrackerService.getDayData(year, month, day); + const sessionsCount = data.sessions.length; + const averageSessionDuration = sessionsCount > 0 ? Math.floor(data.totalDuration / sessionsCount) : 0; + const longestSession = data.sessions.reduce((max, s) => s.duration > max ? s.duration : max, 0); + const topTabs = Object.entries(data.tabSummary).map(([_, summary]) => ({ + fileName: summary.fileName, + duration: summary.totalDuration + })).sort((a, b) => b.duration - a.duration).slice(0, 5); + successResponse(res, { + ...data, + stats: { + sessionsCount, + averageSessionDuration, + longestSession, + topTabs + } + }); + }) + ); + router.get( + "/week/:startDate", + asyncHandler(async (req, res) => { + const { startDate } = req.params; + const [year, month, day] = startDate.split("-").map(Number); + const start = new Date(year, month - 1, day); + const data = await timeTrackerService.getWeekData(start); + const totalDuration = data.reduce((sum, d) => sum + d.totalDuration, 0); + const activeDays = data.filter((d) => d.totalDuration > 0).length; + successResponse(res, { + days: data, + totalDuration, + activeDays, + averageDaily: activeDays > 0 ? Math.floor(totalDuration / activeDays) : 0 + }); + }) + ); + router.get( + "/month/:yearMonth", + asyncHandler(async (req, res) => { + const { yearMonth } = req.params; + const [year, month] = yearMonth.split("-").map(Number); + const data = await timeTrackerService.getMonthData(year, month); + successResponse(res, data); + }) + ); + router.get( + "/year/:year", + asyncHandler(async (req, res) => { + const { year } = req.params; + const data = await timeTrackerService.getYearData(parseInt(year)); + successResponse(res, data); + }) + ); + router.get( + "/stats", + asyncHandler(async (req, res) => { + const year = req.query.year ? parseInt(req.query.year) : void 0; + const month = req.query.month ? parseInt(req.query.month) : void 0; + const stats = await timeTrackerService.getStats(year, month); + successResponse(res, stats); + }) + ); + return router; +}; + +// api/modules/time-tracking/index.ts +var createTimeTrackingModule = (moduleConfig = {}) => { + let serviceInstance; + return createApiModule(TIME_TRACKING_MODULE, { + routes: (container) => { + const timeTrackerService = container.getSync("timeTrackerService"); + return createTimeTrackingRoutes({ timeTrackerService }); + }, + lifecycle: { + onLoad: async (container) => { + serviceInstance = await initializeTimeTrackerService(moduleConfig.config); + container.register("timeTrackerService", () => serviceInstance); + } + } + }); +}; +var time_tracking_default = createTimeTrackingModule; + +export { + HeartbeatService, + createHeartbeatService, + SessionPersistenceService, + createSessionPersistence, + TimeTrackerService, + getTimeTrackerService, + initializeTimeTrackerService, + initializeTimeTrackerServiceWithDependencies, + createTimeTrackingRoutes, + createTimeTrackingModule, + time_tracking_default +}; diff --git a/dist-api/chunk-R5LQJNQE.js b/dist-api/chunk-R5LQJNQE.js new file mode 100644 index 0000000..adaacd1 --- /dev/null +++ b/dist-api/chunk-R5LQJNQE.js @@ -0,0 +1,586 @@ +import { + logger +} from "./chunk-47DJ6YUB.js"; +import { + getTempDir +} from "./chunk-FTVFWJFJ.js"; +import { + InternalError, + ValidationError, + resolveNotebookPath +} from "./chunk-ER4KPD22.js"; +import { + NOTEBOOK_ROOT, + PROJECT_ROOT, + TEMP_ROOT, + asyncHandler, + createApiModule, + defineApiModule, + successResponse +} from "./chunk-74TMTGBG.js"; + +// api/modules/document-parser/index.ts +import express3 from "express"; + +// shared/modules/document-parser/index.ts +var DOCUMENT_PARSER_MODULE = defineApiModule({ + id: "document-parser", + name: "Document Parser", + basePath: "/document-parser", + order: 60, + version: "1.0.0", + frontend: { + enabled: false + }, + backend: { + enabled: true + } +}); + +// api/modules/document-parser/blogRoutes.ts +import express from "express"; +import path3 from "path"; +import fs3 from "fs/promises"; +import { existsSync as existsSync2 } from "fs"; +import axios from "axios"; + +// api/utils/file.ts +import fs from "fs/promises"; +import path from "path"; +var getUniqueFilename = async (imagesDirFullPath, baseName, ext) => { + const maxAttempts = 1e3; + for (let i = 0; i < maxAttempts; i++) { + const suffix = i === 0 ? "" : `-${i + 1}`; + const filename = `${baseName}${suffix}${ext}`; + const fullPath = path.join(imagesDirFullPath, filename); + try { + await fs.access(fullPath); + } catch { + return filename; + } + } + throw new InternalError("Failed to generate unique filename"); +}; +var mimeToExt = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp" +}; +var IMAGE_MAGIC_BYTES = { + "image/png": { bytes: [137, 80, 78, 71, 13, 10, 26, 10] }, + "image/jpeg": { bytes: [255, 216, 255] }, + "image/gif": { bytes: [71, 73, 70, 56] }, + "image/webp": { bytes: [82, 73, 70, 70], offset: 0 } +}; +var WEBP_WEBP_MARKER = [87, 69, 66, 80]; +var MIN_IMAGE_SIZE = 16; +var MAX_IMAGE_SIZE = 8 * 1024 * 1024; +var validateImageBuffer = (buffer, claimedMimeType) => { + if (buffer.byteLength < MIN_IMAGE_SIZE) { + throw new ValidationError("Image file is too small or corrupted"); + } + if (buffer.byteLength > MAX_IMAGE_SIZE) { + throw new ValidationError("Image file is too large"); + } + const magicInfo = IMAGE_MAGIC_BYTES[claimedMimeType]; + if (!magicInfo) { + throw new ValidationError("Unsupported image type for content validation"); + } + const offset = magicInfo.offset || 0; + const expectedBytes = magicInfo.bytes; + for (let i = 0; i < expectedBytes.length; i++) { + if (buffer[offset + i] !== expectedBytes[i]) { + throw new ValidationError("Image content does not match the claimed file type"); + } + } + if (claimedMimeType === "image/webp") { + if (buffer.byteLength < 12) { + throw new ValidationError("WebP image is corrupted"); + } + for (let i = 0; i < WEBP_WEBP_MARKER.length; i++) { + if (buffer[8 + i] !== WEBP_WEBP_MARKER[i]) { + throw new ValidationError("WebP image content is invalid"); + } + } + } +}; +var detectImageMimeType = (buffer) => { + if (buffer.byteLength < 8) return null; + if (buffer[0] === 137 && buffer[1] === 80 && buffer[2] === 78 && buffer[3] === 71 && buffer[4] === 13 && buffer[5] === 10 && buffer[6] === 26 && buffer[7] === 10) { + return "image/png"; + } + if (buffer[0] === 255 && buffer[1] === 216 && buffer[2] === 255) { + return "image/jpeg"; + } + if (buffer[0] === 71 && buffer[1] === 73 && buffer[2] === 70 && buffer[3] === 56) { + return "image/gif"; + } + if (buffer[0] === 82 && buffer[1] === 73 && buffer[2] === 70 && buffer[3] === 70 && buffer[8] === 87 && buffer[9] === 69 && buffer[10] === 66 && buffer[11] === 80) { + return "image/webp"; + } + return null; +}; + +// shared/utils/date.ts +var pad2 = (n) => String(n).padStart(2, "0"); +var pad3 = (n) => String(n).padStart(3, "0"); +var formatTimestamp = (d) => { + const yyyy = d.getFullYear(); + const mm = pad2(d.getMonth() + 1); + const dd = pad2(d.getDate()); + const hh = pad2(d.getHours()); + const mi = pad2(d.getMinutes()); + const ss = pad2(d.getSeconds()); + const ms = pad3(d.getMilliseconds()); + return `${yyyy}${mm}${dd}_${hh}${mi}${ss}_${ms}`; +}; + +// api/modules/document-parser/documentParser.ts +import path2 from "path"; +import { spawn } from "child_process"; +import fs2 from "fs/promises"; +import { existsSync, mkdirSync } from "fs"; +if (!existsSync(TEMP_ROOT)) { + mkdirSync(TEMP_ROOT, { recursive: true }); +} +var createJobContext = async (prefix) => { + const now = /* @__PURE__ */ new Date(); + const jobDir = path2.join(TEMP_ROOT, `${prefix}_${formatTimestamp(now)}`); + await fs2.mkdir(jobDir, { recursive: true }); + const year = now.getFullYear(); + const month = pad2(now.getMonth() + 1); + const day = pad2(now.getDate()); + const imagesSubDir = `images/${year}/${month}/${day}`; + const destImagesDir = path2.join(NOTEBOOK_ROOT, imagesSubDir); + await fs2.mkdir(destImagesDir, { recursive: true }); + return { jobDir, now, imagesSubDir, destImagesDir }; +}; +var spawnPythonScript = async (options) => { + const { scriptPath, args, cwd, inputContent } = options; + return new Promise((resolve, reject) => { + const pythonProcess = spawn("python", ["-X", "utf8", scriptPath, ...args], { + cwd, + env: { ...process.env, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1" } + }); + let stdout = ""; + let stderr = ""; + pythonProcess.stdout.on("data", (data) => { + stdout += data.toString(); + }); + pythonProcess.stderr.on("data", (data) => { + stderr += data.toString(); + }); + pythonProcess.on("close", (code) => { + if (code !== 0) { + logger.error("Python script error:", stderr); + reject(new Error(`Process exited with code ${code}. Error: ${stderr}`)); + } else { + resolve(stdout); + } + }); + pythonProcess.on("error", (err) => { + reject(err); + }); + if (inputContent !== void 0) { + pythonProcess.stdin.write(inputContent); + pythonProcess.stdin.end(); + } + }); +}; +var findImageDestinations = (md) => { + const results = []; + let i = 0; + while (i < md.length) { + const bang = md.indexOf("![", i); + if (bang === -1) break; + const closeBracket = md.indexOf("]", bang + 2); + if (closeBracket === -1) break; + if (md[closeBracket + 1] !== "(") { + i = closeBracket + 1; + continue; + } + const urlStart = closeBracket + 2; + let depth = 1; + let j = urlStart; + for (; j < md.length; j++) { + const ch = md[j]; + if (ch === "(") depth++; + else if (ch === ")") { + depth--; + if (depth === 0) break; + } + } + if (depth !== 0) break; + results.push({ url: md.slice(urlStart, j), start: urlStart, end: j }); + i = j + 1; + } + return results; +}; +var applyReplacements = (md, replacements) => { + const sorted = [...replacements].sort((a, b) => b.start - a.start); + let result = md; + for (const r of sorted) { + result = `${result.slice(0, r.start)}${r.replacement}${result.slice(r.end)}`; + } + return result; +}; +var copyLocalImage = async (src, jobDir, htmlDir, destImagesDir, imagesSubDir, now) => { + const s0 = src.trim().replace(/^<|>$/g, ""); + if (!s0) return null; + let decoded = s0; + try { + decoded = decodeURI(s0); + } catch { + } + const s1 = decoded.replace(/\\/g, "/"); + const s2 = s1.startsWith("./") ? s1.slice(2) : s1; + const candidates = s2.startsWith("/") ? [path2.join(jobDir, s2.slice(1)), path2.join(htmlDir, s2.slice(1))] : [path2.resolve(htmlDir, s2), path2.resolve(jobDir, s2)]; + let foundFile = null; + for (const c of candidates) { + if (existsSync(c)) { + foundFile = c; + break; + } + } + if (!foundFile) return null; + const ext = path2.extname(foundFile) || ".jpg"; + const baseName = formatTimestamp(now); + const newFilename = await getUniqueFilename(destImagesDir, baseName, ext); + const newPath = path2.join(destImagesDir, newFilename); + await fs2.copyFile(foundFile, newPath); + return { newLink: `/${imagesSubDir}/${newFilename}` }; +}; +var cleanupJob = async (jobDir, additionalPaths = []) => { + await fs2.rm(jobDir, { recursive: true, force: true }).catch(() => { + }); + for (const p of additionalPaths) { + await fs2.unlink(p).catch(() => { + }); + } +}; +var getScriptPath = (toolName, scriptName) => { + return path2.join(PROJECT_ROOT, "tools", toolName, scriptName); +}; +var ensureScriptExists = (scriptPath) => { + return existsSync(scriptPath); +}; + +// api/modules/document-parser/blogRoutes.ts +var router = express.Router(); +var tempDir = getTempDir(); +router.post( + "/parse-local", + asyncHandler(async (req, res) => { + const { htmlPath, htmlDir, assetsDirName, assetsFiles, targetPath } = req.body; + if (!htmlPath || !htmlDir || !targetPath) { + throw new ValidationError("htmlPath, htmlDir and targetPath are required"); + } + let fullTargetPath; + try { + const resolved = resolveNotebookPath(targetPath); + fullTargetPath = resolved.fullPath; + } catch (error) { + throw error; + } + const scriptPath = getScriptPath("blog", "parse_blog.py"); + if (!ensureScriptExists(scriptPath)) { + throw new InternalError("Parser script not found"); + } + const jobContext = await createJobContext("blog"); + let htmlPathInJob = ""; + try { + htmlPathInJob = path3.join(jobContext.jobDir, "input.html"); + await fs3.copyFile(htmlPath, htmlPathInJob); + if (assetsDirName && assetsFiles && assetsFiles.length > 0) { + const assetsDirPath = path3.join(htmlDir, assetsDirName); + for (const relPath of assetsFiles) { + const srcPath = path3.join(assetsDirPath, relPath); + if (existsSync2(srcPath)) { + const destPath = path3.join(jobContext.jobDir, assetsDirName, relPath); + await fs3.mkdir(path3.dirname(destPath), { recursive: true }); + await fs3.copyFile(srcPath, destPath); + } + } + } + } catch (err) { + await cleanupJob(jobContext.jobDir); + throw err; + } + processHtmlInBackground({ + jobDir: jobContext.jobDir, + htmlPath: htmlPathInJob, + targetPath: fullTargetPath, + cwd: path3.dirname(scriptPath), + jobContext, + originalHtmlDir: htmlDir, + originalAssetsDirName: assetsDirName + }).catch((err) => { + logger.error("Background HTML processing failed:", err); + fs3.writeFile(fullTargetPath, `# \u89E3\u6790\u5931\u8D25 + +> \u9519\u8BEF\u4FE1\u606F: ${err.message}`, "utf-8").catch(() => { + }); + cleanupJob(jobContext.jobDir).catch(() => { + }); + }); + successResponse(res, { + message: "HTML parsing started in background.", + status: "processing" + }); + }) +); +async function processHtmlInBackground(args) { + const { jobDir, htmlPath, targetPath, cwd, jobContext, originalHtmlDir, originalAssetsDirName } = args; + try { + await spawnPythonScript({ + scriptPath: "parse_blog.py", + args: [htmlPath], + cwd + }); + const parsedPathObj = path3.parse(htmlPath); + const markdownPath = path3.join(parsedPathObj.dir, `${parsedPathObj.name}.md`); + if (!existsSync2(markdownPath)) { + throw new Error("Markdown result file not found"); + } + let mdContent = await fs3.readFile(markdownPath, "utf-8"); + const ctx = await jobContext; + const htmlDir = path3.dirname(htmlPath); + const replacements = []; + const destinations = findImageDestinations(mdContent); + for (const dest of destinations) { + const originalSrc = dest.url; + if (!originalSrc) continue; + if (originalSrc.startsWith("http://") || originalSrc.startsWith("https://")) { + try { + const response = await axios.get(originalSrc, { responseType: "arraybuffer", timeout: 1e4 }); + const contentType = response.headers["content-type"]; + let ext = ".jpg"; + if (contentType) { + if (contentType.includes("png")) ext = ".png"; + else if (contentType.includes("gif")) ext = ".gif"; + else if (contentType.includes("webp")) ext = ".webp"; + else if (contentType.includes("svg")) ext = ".svg"; + else if (contentType.includes("jpeg") || contentType.includes("jpg")) ext = ".jpg"; + } + const urlExt = path3.extname(originalSrc.split("?")[0]); + if (urlExt) ext = urlExt; + const baseName = formatTimestamp(ctx.now); + const newFilename = await getUniqueFilename(ctx.destImagesDir, baseName, ext); + const newPath = path3.join(ctx.destImagesDir, newFilename); + await fs3.writeFile(newPath, response.data); + replacements.push({ + start: dest.start, + end: dest.end, + original: originalSrc, + replacement: `/${ctx.imagesSubDir}/${newFilename}` + }); + } catch { + } + continue; + } + if (originalSrc.startsWith("data:")) continue; + let result = await copyLocalImage( + originalSrc, + jobDir, + htmlDir, + ctx.destImagesDir, + ctx.imagesSubDir, + ctx.now + ); + if (!result && originalHtmlDir && originalAssetsDirName) { + const srcWithFiles = originalSrc.replace(/^\.\//, "").replace(/^\//, ""); + const possiblePaths = [ + path3.join(originalHtmlDir, originalAssetsDirName, srcWithFiles), + path3.join(originalHtmlDir, originalAssetsDirName, path3.basename(srcWithFiles)) + ]; + for (const p of possiblePaths) { + if (existsSync2(p)) { + const ext = path3.extname(p) || ".jpg"; + const baseName = formatTimestamp(ctx.now); + const newFilename = await getUniqueFilename(ctx.destImagesDir, baseName, ext); + const newPath = path3.join(ctx.destImagesDir, newFilename); + await fs3.copyFile(p, newPath); + result = { newLink: `/${ctx.imagesSubDir}/${newFilename}` }; + break; + } + } + } + if (result) { + replacements.push({ + start: dest.start, + end: dest.end, + original: originalSrc, + replacement: result.newLink + }); + } + } + mdContent = applyReplacements(mdContent, replacements); + await fs3.writeFile(targetPath, mdContent, "utf-8"); + await fs3.unlink(markdownPath).catch(() => { + }); + } finally { + await cleanupJob(jobDir); + } +} +var blogRoutes_default = router; + +// api/modules/document-parser/mineruRoutes.ts +import express2 from "express"; +import multer from "multer"; +import path4 from "path"; +import fs4 from "fs/promises"; +import { existsSync as existsSync3 } from "fs"; +var router2 = express2.Router(); +var tempDir2 = getTempDir(); +var upload = multer({ + dest: tempDir2, + limits: { + fileSize: 50 * 1024 * 1024 + } +}); +router2.post( + "/parse", + upload.single("file"), + asyncHandler(async (req, res) => { + if (!req.file) { + throw new ValidationError("File is required"); + } + const { targetPath } = req.body; + if (!targetPath) { + await fs4.unlink(req.file.path).catch(() => { + }); + throw new ValidationError("Target path is required"); + } + let fullTargetPath; + try { + const resolved = resolveNotebookPath(targetPath); + fullTargetPath = resolved.fullPath; + } catch (error) { + await fs4.unlink(req.file.path).catch(() => { + }); + throw error; + } + const scriptPath = getScriptPath("mineru", "mineru_parser.py"); + if (!ensureScriptExists(scriptPath)) { + await fs4.unlink(req.file.path).catch(() => { + }); + throw new InternalError("Parser script not found"); + } + processPdfInBackground(req.file.path, fullTargetPath, path4.dirname(scriptPath)).catch((err) => { + logger.error("Background PDF processing failed:", err); + fs4.writeFile(fullTargetPath, `# \u89E3\u6790\u5931\u8D25 + +> \u9519\u8BEF\u4FE1\u606F: ${err.message}`, "utf-8").catch(() => { + }); + }); + successResponse(res, { + message: "PDF upload successful. Parsing started in background.", + status: "processing" + }); + }) +); +async function processPdfInBackground(filePath, targetPath, cwd) { + try { + const output = await spawnPythonScript({ + scriptPath: "mineru_parser.py", + args: [filePath], + cwd + }); + const match = output.match(/JSON_RESULT:(.*)/); + if (!match) { + throw new Error("Failed to parse Python script output: JSON_RESULT not found"); + } + const result = JSON.parse(match[1]); + const markdownPath = result.markdown_file; + const outputDir = result.output_dir; + if (!existsSync3(markdownPath)) { + throw new Error("Markdown result file not found"); + } + let mdContent = await fs4.readFile(markdownPath, "utf-8"); + const imagesDir = path4.join(outputDir, "images"); + if (existsSync3(imagesDir)) { + const jobContext = await createJobContext("pdf_images"); + const destinations = findImageDestinations(mdContent); + const replacements = []; + for (const dest of destinations) { + const originalSrc = dest.url; + if (!originalSrc) continue; + const possibleFilenames = [originalSrc, path4.basename(originalSrc)]; + let foundFile = null; + for (const fname of possibleFilenames) { + const localPath = path4.join(imagesDir, fname); + if (existsSync3(localPath)) { + foundFile = localPath; + break; + } + const directPath = path4.join(outputDir, originalSrc); + if (existsSync3(directPath)) { + foundFile = directPath; + break; + } + } + if (foundFile) { + const ext = path4.extname(foundFile); + const baseName = formatTimestamp(jobContext.now); + const newFilename = await getUniqueFilename(jobContext.destImagesDir, baseName, ext); + const newPath = path4.join(jobContext.destImagesDir, newFilename); + await fs4.copyFile(foundFile, newPath); + replacements.push({ + start: dest.start, + end: dest.end, + original: originalSrc, + replacement: `${jobContext.imagesSubDir}/${newFilename}` + }); + } + } + mdContent = applyReplacements(mdContent, replacements); + } + await fs4.writeFile(targetPath, mdContent, "utf-8"); + await fs4.unlink(markdownPath).catch(() => { + }); + if (outputDir && outputDir.includes("temp")) { + await fs4.rm(outputDir, { recursive: true, force: true }).catch(() => { + }); + } + } finally { + await fs4.unlink(filePath).catch(() => { + }); + } +} +var mineruRoutes_default = router2; + +// api/modules/document-parser/index.ts +var createDocumentParserModule = () => { + return createApiModule(DOCUMENT_PARSER_MODULE, { + routes: (_container) => { + const router3 = express3.Router(); + router3.use("/blog", blogRoutes_default); + router3.use("/mineru", mineruRoutes_default); + return router3; + } + }); +}; +var document_parser_default = createDocumentParserModule; + +export { + pad2, + formatTimestamp, + getUniqueFilename, + mimeToExt, + validateImageBuffer, + detectImageMimeType, + createJobContext, + spawnPythonScript, + findImageDestinations, + applyReplacements, + copyLocalImage, + cleanupJob, + getScriptPath, + ensureScriptExists, + blogRoutes_default, + mineruRoutes_default, + createDocumentParserModule, + document_parser_default +}; diff --git a/dist-api/chunk-T5RPAMG6.js b/dist-api/chunk-T5RPAMG6.js new file mode 100644 index 0000000..bedbeac --- /dev/null +++ b/dist-api/chunk-T5RPAMG6.js @@ -0,0 +1,313 @@ +import { + validateBody, + validateQuery +} from "./chunk-5EGA6GHY.js"; +import { + getTempDir +} from "./chunk-FTVFWJFJ.js"; +import { + AlreadyExistsError, + NotFoundError, + ValidationError, + isNodeError, + resolveNotebookPath +} from "./chunk-ER4KPD22.js"; +import { + asyncHandler, + createApiModule, + defineApiModule, + defineEndpoints, + successResponse +} from "./chunk-74TMTGBG.js"; + +// shared/modules/pydemos/api.ts +var PYDEMOS_ENDPOINTS = defineEndpoints({ + list: { path: "/", method: "GET" }, + create: { path: "/create", method: "POST" }, + delete: { path: "/delete", method: "DELETE" }, + rename: { path: "/rename", method: "POST" } +}); + +// shared/modules/pydemos/index.ts +var PYDEMOS_MODULE = defineApiModule({ + id: "pydemos", + name: "Python Demos", + basePath: "/pydemos", + order: 50, + version: "1.0.0", + endpoints: PYDEMOS_ENDPOINTS +}); + +// api/modules/pydemos/routes.ts +import express from "express"; +import fs from "fs/promises"; +import path from "path"; +import multer from "multer"; + +// api/schemas/files.ts +import { z } from "zod"; +var listFilesQuerySchema = z.object({ + path: z.string().optional().default("") +}); +var contentQuerySchema = z.object({ + path: z.string().min(1) +}); +var rawQuerySchema = z.object({ + path: z.string().min(1) +}); +var pathSchema = z.object({ + path: z.string().min(1) +}); +var saveFileSchema = z.object({ + path: z.string().min(1), + content: z.string() +}); +var renameSchema = z.object({ + oldPath: z.string().min(1), + newPath: z.string().min(1) +}); +var searchSchema = z.object({ + keywords: z.array(z.string()).min(1) +}); +var existsSchema = z.object({ + path: z.string().min(1) +}); +var createDirSchema = z.object({ + path: z.string().min(1) +}); +var createFileSchema = z.object({ + path: z.string().min(1) +}); + +// api/schemas/pydemos.ts +import { z as z2 } from "zod"; +var listPyDemosQuerySchema = z2.object({ + year: z2.string().optional() +}); +var createPyDemoSchema = z2.object({ + name: z2.string().min(1), + year: z2.string().min(1), + month: z2.string().min(1), + folderStructure: z2.string().optional() +}); +var deletePyDemoSchema = z2.object({ + path: z2.string().min(1) +}); +var renamePyDemoSchema = z2.object({ + oldPath: z2.string().min(1), + newName: z2.string().min(1) +}); + +// api/modules/pydemos/routes.ts +var tempDir = getTempDir(); +var upload = multer({ + dest: tempDir, + limits: { + fileSize: 50 * 1024 * 1024 + } +}); +var toPosixPath = (p) => p.replace(/\\/g, "/"); +var getYearPath = (year) => { + const relPath = `pydemos/${year}`; + const { fullPath } = resolveNotebookPath(relPath); + return { relPath, fullPath }; +}; +var getMonthPath = (year, month) => { + const monthStr = month.toString().padStart(2, "0"); + const relPath = `pydemos/${year}/${monthStr}`; + const { fullPath } = resolveNotebookPath(relPath); + return { relPath, fullPath }; +}; +var countFilesInDir = async (dirPath) => { + try { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + return entries.filter((e) => e.isFile()).length; + } catch { + return 0; + } +}; +var createPyDemosRoutes = () => { + const router = express.Router(); + router.get( + "/", + validateQuery(listPyDemosQuerySchema), + asyncHandler(async (req, res) => { + const year = parseInt(req.query.year) || (/* @__PURE__ */ new Date()).getFullYear(); + const { fullPath: yearPath } = getYearPath(year); + const months = []; + try { + await fs.access(yearPath); + } catch { + successResponse(res, { months }); + return; + } + const monthEntries = await fs.readdir(yearPath, { withFileTypes: true }); + for (const monthEntry of monthEntries) { + if (!monthEntry.isDirectory()) continue; + const monthNum = parseInt(monthEntry.name); + if (isNaN(monthNum) || monthNum < 1 || monthNum > 12) continue; + const monthPath = path.join(yearPath, monthEntry.name); + const demoEntries = await fs.readdir(monthPath, { withFileTypes: true }); + const demos = []; + for (const demoEntry of demoEntries) { + if (!demoEntry.isDirectory()) continue; + const demoPath = path.join(monthPath, demoEntry.name); + const relDemoPath = `pydemos/${year}/${monthEntry.name}/${demoEntry.name}`; + let created; + try { + const stats = await fs.stat(demoPath); + created = stats.birthtime.toISOString(); + } catch { + created = (/* @__PURE__ */ new Date()).toISOString(); + } + const fileCount = await countFilesInDir(demoPath); + demos.push({ + name: demoEntry.name, + path: toPosixPath(relDemoPath), + created, + fileCount + }); + } + demos.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()); + if (demos.length > 0) { + months.push({ + month: monthNum, + demos + }); + } + } + months.sort((a, b) => a.month - b.month); + successResponse(res, { months }); + }) + ); + router.post( + "/create", + upload.array("files"), + validateBody(createPyDemoSchema), + asyncHandler(async (req, res) => { + const { name, year, month, folderStructure } = req.body; + const yearNum = parseInt(year); + const monthNum = parseInt(month); + if (!/^[a-zA-Z0-9_\-\u4e00-\u9fa5]+$/.test(name)) { + throw new ValidationError("Invalid name format"); + } + const { fullPath: monthPath, relPath: monthRelPath } = getMonthPath(yearNum, monthNum); + const demoPath = path.join(monthPath, name); + const relDemoPath = `${monthRelPath}/${name}`; + try { + await fs.access(demoPath); + throw new AlreadyExistsError("Demo already exists"); + } catch (err) { + if (isNodeError(err) && err.code === "ENOENT") { + } else if (err instanceof AlreadyExistsError) { + throw err; + } else { + throw err; + } + } + await fs.mkdir(demoPath, { recursive: true }); + const files = req.files; + let fileCount = 0; + if (files && files.length > 0) { + let structure = {}; + if (folderStructure) { + try { + structure = JSON.parse(folderStructure); + } catch { + structure = {}; + } + } + for (const file of files) { + const relativePath = structure[file.originalname] || file.originalname; + const targetPath = path.join(demoPath, relativePath); + const targetDir = path.dirname(targetPath); + await fs.mkdir(targetDir, { recursive: true }); + await fs.copyFile(file.path, targetPath); + await fs.unlink(file.path).catch(() => { + }); + fileCount++; + } + } + successResponse(res, { path: toPosixPath(relDemoPath), fileCount }); + }) + ); + router.delete( + "/delete", + validateBody(deletePyDemoSchema), + asyncHandler(async (req, res) => { + const { path: demoPath } = req.body; + if (!demoPath.startsWith("pydemos/")) { + throw new ValidationError("Invalid path"); + } + const { fullPath } = resolveNotebookPath(demoPath); + try { + await fs.access(fullPath); + } catch { + throw new NotFoundError("Demo not found"); + } + await fs.rm(fullPath, { recursive: true, force: true }); + successResponse(res, null); + }) + ); + router.post( + "/rename", + validateBody(renamePyDemoSchema), + asyncHandler(async (req, res) => { + const { oldPath, newName } = req.body; + if (!oldPath.startsWith("pydemos/")) { + throw new ValidationError("Invalid path"); + } + if (!/^[a-zA-Z0-9_\-\u4e00-\u9fa5]+$/.test(newName)) { + throw new ValidationError("Invalid name format"); + } + const { fullPath: oldFullPath } = resolveNotebookPath(oldPath); + try { + await fs.access(oldFullPath); + } catch { + throw new NotFoundError("Demo not found"); + } + const parentDir = path.dirname(oldFullPath); + const newFullPath = path.join(parentDir, newName); + const newPath = toPosixPath(path.join(path.dirname(oldPath), newName)); + try { + await fs.access(newFullPath); + throw new AlreadyExistsError("Demo with this name already exists"); + } catch (err) { + if (isNodeError(err) && err.code === "ENOENT") { + } else if (err instanceof AlreadyExistsError) { + throw err; + } else { + throw err; + } + } + await fs.rename(oldFullPath, newFullPath); + successResponse(res, { newPath }); + }) + ); + return router; +}; +var routes_default = createPyDemosRoutes(); + +// api/modules/pydemos/index.ts +var createPyDemosModule = () => { + return createApiModule(PYDEMOS_MODULE, { + routes: (_container) => { + return createPyDemosRoutes(); + } + }); +}; +var pydemos_default = createPyDemosModule; + +export { + listFilesQuerySchema, + contentQuerySchema, + rawQuerySchema, + pathSchema, + saveFileSchema, + renameSchema, + createDirSchema, + createFileSchema, + createPyDemosRoutes, + createPyDemosModule, + pydemos_default +}; diff --git a/dist-api/chunk-TSQNCXAS.js b/dist-api/chunk-TSQNCXAS.js new file mode 100644 index 0000000..9205719 --- /dev/null +++ b/dist-api/chunk-TSQNCXAS.js @@ -0,0 +1,125 @@ +import { + InternalError, + NotFoundError, + ValidationError, + resolveNotebookPath +} from "./chunk-ER4KPD22.js"; +import { + asyncHandler, + createApiModule, + defineApiModule, + successResponse +} from "./chunk-74TMTGBG.js"; + +// shared/modules/ai/index.ts +var AI_MODULE = defineApiModule({ + id: "ai", + name: "AI", + basePath: "/ai", + order: 70, + version: "1.0.0", + frontend: { + enabled: false + }, + backend: { + enabled: true + } +}); + +// api/modules/ai/routes.ts +import express from "express"; +import { spawn } from "child_process"; +import path from "path"; +import { fileURLToPath } from "url"; +import fs from "fs/promises"; +import fsSync from "fs"; +var __filename = fileURLToPath(import.meta.url); +var __dirname = path.dirname(__filename); +var router = express.Router(); +var PYTHON_TIMEOUT_MS = 3e4; +var spawnPythonWithTimeout = (scriptPath, args, stdinContent, timeoutMs = PYTHON_TIMEOUT_MS) => { + return new Promise((resolve, reject) => { + const pythonProcess = spawn("python", args, { + env: { ...process.env } + }); + let stdout = ""; + let stderr = ""; + let timeoutId = null; + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + }; + timeoutId = setTimeout(() => { + cleanup(); + pythonProcess.kill(); + reject(new Error(`Python script timed out after ${timeoutMs}ms`)); + }, timeoutMs); + pythonProcess.stdout.on("data", (data) => { + stdout += data.toString(); + }); + pythonProcess.stderr.on("data", (data) => { + stderr += data.toString(); + }); + pythonProcess.on("close", (code) => { + cleanup(); + if (code !== 0) { + reject(new Error(`Python script exited with code ${code}. Stderr: ${stderr}`)); + } else { + resolve(stdout); + } + }); + pythonProcess.on("error", (err) => { + cleanup(); + reject(new Error(`Failed to start python process: ${err.message}`)); + }); + pythonProcess.stdin.write(stdinContent); + pythonProcess.stdin.end(); + }); +}; +router.post( + "/doubao", + asyncHandler(async (req, res) => { + const { task, path: relPath } = req.body; + if (!task) throw new ValidationError("Task is required"); + if (!relPath) throw new ValidationError("Path is required"); + const { fullPath } = resolveNotebookPath(relPath); + try { + await fs.access(fullPath); + } catch { + throw new NotFoundError("File not found"); + } + const content = await fs.readFile(fullPath, "utf-8"); + const projectRoot = path.resolve(__dirname, "..", "..", ".."); + const scriptPath = path.join(projectRoot, "tools", "doubao", "main.py"); + if (!fsSync.existsSync(scriptPath)) { + throw new InternalError(`Python script not found: ${scriptPath}`); + } + try { + const result = await spawnPythonWithTimeout(scriptPath, ["--task", task], content); + await fs.writeFile(fullPath, result, "utf-8"); + successResponse(res, { message: "Task completed successfully" }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + throw new InternalError(`AI task failed: ${message}`); + } + }) +); +var createAiRoutes = () => router; + +// api/modules/ai/index.ts +var createAiModule = () => { + return createApiModule(AI_MODULE, { + routes: (_container) => { + return createAiRoutes(); + } + }); +}; +var ai_default = createAiModule; + +export { + createAiRoutes, + createAiModule, + ai_default +}; diff --git a/dist-api/chunk-V2OWYGQG.js b/dist-api/chunk-V2OWYGQG.js new file mode 100644 index 0000000..19fb8f4 --- /dev/null +++ b/dist-api/chunk-V2OWYGQG.js @@ -0,0 +1,399 @@ +import { + validateBody, + validateQuery +} from "./chunk-5EGA6GHY.js"; +import { + NotFoundError, + resolveNotebookPath +} from "./chunk-ER4KPD22.js"; +import { + asyncHandler, + createApiModule, + defineApiModule, + defineEndpoints, + successResponse +} from "./chunk-74TMTGBG.js"; + +// shared/modules/todo/api.ts +var TODO_ENDPOINTS = defineEndpoints({ + list: { path: "/", method: "GET" }, + save: { path: "/save", method: "POST" }, + add: { path: "/add", method: "POST" }, + toggle: { path: "/toggle", method: "POST" }, + update: { path: "/update", method: "POST" }, + delete: { path: "/delete", method: "DELETE" } +}); + +// shared/modules/todo/index.ts +var TODO_MODULE = defineApiModule({ + id: "todo", + name: "TODO", + basePath: "/todo", + order: 30, + version: "1.0.0", + endpoints: TODO_ENDPOINTS +}); + +// api/modules/todo/service.ts +import fs from "fs/promises"; +import path from "path"; + +// api/modules/todo/parser.ts +var parseTodoContent = (content) => { + const lines = content.split("\n"); + const result = []; + let currentDate = null; + let currentItems = []; + let itemId = 0; + for (const line of lines) { + const dateMatch = line.match(/^##\s*(\d{4}-\d{2}-\d{2})/); + if (dateMatch) { + if (currentDate) { + result.push({ date: currentDate, items: currentItems }); + } + currentDate = dateMatch[1]; + currentItems = []; + } else if (currentDate) { + const todoMatch = line.match(/^- (√|○) (.*)$/); + if (todoMatch) { + currentItems.push({ + id: `${currentDate}-${itemId++}`, + content: todoMatch[2], + completed: todoMatch[1] === "\u221A" + }); + } + } + } + if (currentDate) { + result.push({ date: currentDate, items: currentItems }); + } + return result; +}; +var generateTodoContent = (dayTodos) => { + const lines = []; + const sortedDays = [...dayTodos].sort((a, b) => a.date.localeCompare(b.date)); + for (const day of sortedDays) { + lines.push(`## ${day.date}`); + for (const item of day.items) { + const checkbox = item.completed ? "\u221A" : "\u25CB"; + lines.push(`- ${checkbox} ${item.content}`); + } + lines.push(""); + } + return lines.join("\n").trimEnd(); +}; + +// api/modules/todo/service.ts +var TodoService = class { + constructor(deps = {}) { + this.deps = deps; + } + getTodoFilePath(year, month) { + const yearStr = year.toString(); + const monthStr = month.toString().padStart(2, "0"); + const relPath = `TODO/${yearStr}/${yearStr}${monthStr}TODO.md`; + const { fullPath } = resolveNotebookPath(relPath); + return { relPath, fullPath }; + } + async ensureTodoFileExists(fullPath) { + const dir = path.dirname(fullPath); + await fs.mkdir(dir, { recursive: true }); + try { + await fs.access(fullPath); + } catch { + await fs.writeFile(fullPath, "", "utf-8"); + } + } + async loadAndParseTodoFile(year, month) { + const { fullPath } = this.getTodoFilePath(year, month); + try { + await fs.access(fullPath); + } catch { + throw new NotFoundError("TODO file not found"); + } + const content = await fs.readFile(fullPath, "utf-8"); + return { fullPath, dayTodos: parseTodoContent(content) }; + } + async saveTodoFile(fullPath, dayTodos) { + const content = generateTodoContent(dayTodos); + await fs.writeFile(fullPath, content, "utf-8"); + } + async getTodo(year, month) { + const { fullPath } = this.getTodoFilePath(year, month); + let dayTodos = []; + try { + await fs.access(fullPath); + const content = await fs.readFile(fullPath, "utf-8"); + dayTodos = parseTodoContent(content); + } catch { + } + const now = /* @__PURE__ */ new Date(); + const todayStr = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, "0")}-${now.getDate().toString().padStart(2, "0")}`; + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + const yesterdayStr = `${yesterday.getFullYear()}-${(yesterday.getMonth() + 1).toString().padStart(2, "0")}-${yesterday.getDate().toString().padStart(2, "0")}`; + if (year === now.getFullYear() && month === now.getMonth() + 1) { + const migrated = this.migrateIncompleteItems(dayTodos, todayStr, yesterdayStr); + if (migrated) { + const newContent = generateTodoContent(dayTodos); + await this.ensureTodoFileExists(fullPath); + await fs.writeFile(fullPath, newContent, "utf-8"); + } + } + return { dayTodos, year, month }; + } + migrateIncompleteItems(dayTodos, todayStr, yesterdayStr) { + let migrated = false; + const yesterdayTodo = dayTodos.find((d) => d.date === yesterdayStr); + if (yesterdayTodo) { + const incompleteItems = yesterdayTodo.items.filter((item) => !item.completed); + if (incompleteItems.length > 0) { + const todayTodo = dayTodos.find((d) => d.date === todayStr); + if (todayTodo) { + const existingIds = new Set(todayTodo.items.map((i) => i.id)); + const itemsToAdd = incompleteItems.map((item, idx) => ({ + ...item, + id: existingIds.has(item.id) ? `${todayStr}-migrated-${idx}` : item.id + })); + todayTodo.items = [...itemsToAdd, ...todayTodo.items]; + } else { + dayTodos.push({ + date: todayStr, + items: incompleteItems.map((item, idx) => ({ + ...item, + id: `${todayStr}-migrated-${idx}` + })) + }); + } + yesterdayTodo.items = yesterdayTodo.items.filter((item) => item.completed); + if (yesterdayTodo.items.length === 0) { + const index = dayTodos.findIndex((d) => d.date === yesterdayStr); + if (index !== -1) { + dayTodos.splice(index, 1); + } + } + migrated = true; + } + } + return migrated; + } + async saveTodo(year, month, dayTodos) { + const { fullPath } = this.getTodoFilePath(year, month); + await this.ensureTodoFileExists(fullPath); + const content = generateTodoContent(dayTodos); + await fs.writeFile(fullPath, content, "utf-8"); + } + async addTodo(year, month, date, todoContent) { + const { fullPath } = this.getTodoFilePath(year, month); + await this.ensureTodoFileExists(fullPath); + let fileContent = await fs.readFile(fullPath, "utf-8"); + const dayTodos = parseTodoContent(fileContent); + const existingDay = dayTodos.find((d) => d.date === date); + if (existingDay) { + const newId = `${date}-${existingDay.items.length}`; + existingDay.items.push({ + id: newId, + content: todoContent, + completed: false + }); + } else { + dayTodos.push({ + date, + items: [{ + id: `${date}-0`, + content: todoContent, + completed: false + }] + }); + } + fileContent = generateTodoContent(dayTodos); + await fs.writeFile(fullPath, fileContent, "utf-8"); + return dayTodos; + } + async toggleTodo(year, month, date, itemIndex, completed) { + const { fullPath, dayTodos } = await this.loadAndParseTodoFile(year, month); + const day = dayTodos.find((d) => d.date === date); + if (!day || itemIndex >= day.items.length) { + throw new NotFoundError("TODO item not found"); + } + day.items[itemIndex].completed = completed; + await this.saveTodoFile(fullPath, dayTodos); + return dayTodos; + } + async updateTodo(year, month, date, itemIndex, newContent) { + const { fullPath, dayTodos } = await this.loadAndParseTodoFile(year, month); + const day = dayTodos.find((d) => d.date === date); + if (!day || itemIndex >= day.items.length) { + throw new NotFoundError("TODO item not found"); + } + day.items[itemIndex].content = newContent; + await this.saveTodoFile(fullPath, dayTodos); + return dayTodos; + } + async deleteTodo(year, month, date, itemIndex) { + const { fullPath, dayTodos } = await this.loadAndParseTodoFile(year, month); + const dayIndex = dayTodos.findIndex((d) => d.date === date); + if (dayIndex === -1) { + throw new NotFoundError("Day not found"); + } + const day = dayTodos[dayIndex]; + if (itemIndex >= day.items.length) { + throw new NotFoundError("TODO item not found"); + } + day.items.splice(itemIndex, 1); + if (day.items.length === 0) { + dayTodos.splice(dayIndex, 1); + } + await this.saveTodoFile(fullPath, dayTodos); + return dayTodos; + } +}; +var createTodoService = (deps) => { + return new TodoService(deps); +}; + +// api/modules/todo/routes.ts +import express from "express"; + +// api/modules/todo/schemas.ts +import { z } from "zod"; +var todoItemSchema = z.object({ + id: z.string(), + content: z.string(), + completed: z.boolean() +}); +var dayTodoSchema = z.object({ + date: z.string(), + items: z.array(todoItemSchema) +}); +var getTodoQuerySchema = z.object({ + year: z.string().optional(), + month: z.string().optional() +}); +var saveTodoSchema = z.object({ + year: z.number().int().positive(), + month: z.number().int().min(1).max(12), + dayTodos: z.array(dayTodoSchema) +}); +var addTodoSchema = z.object({ + year: z.number().int().positive(), + month: z.number().int().min(1).max(12), + date: z.string(), + content: z.string() +}); +var toggleTodoSchema = z.object({ + year: z.number().int().positive(), + month: z.number().int().min(1).max(12), + date: z.string(), + itemIndex: z.number().int().nonnegative(), + completed: z.boolean() +}); +var updateTodoSchema = z.object({ + year: z.number().int().positive(), + month: z.number().int().min(1).max(12), + date: z.string(), + itemIndex: z.number().int().nonnegative(), + content: z.string() +}); +var deleteTodoSchema = z.object({ + year: z.number().int().positive(), + month: z.number().int().min(1).max(12), + date: z.string(), + itemIndex: z.number().int().nonnegative() +}); + +// api/modules/todo/routes.ts +var createTodoRoutes = (deps) => { + const router = express.Router(); + const { todoService: todoService2 } = deps; + router.get( + "/", + validateQuery(getTodoQuerySchema), + asyncHandler(async (req, res) => { + const year = parseInt(req.query.year) || (/* @__PURE__ */ new Date()).getFullYear(); + const month = parseInt(req.query.month) || (/* @__PURE__ */ new Date()).getMonth() + 1; + const result = await todoService2.getTodo(year, month); + successResponse(res, result); + }) + ); + router.post( + "/save", + validateBody(saveTodoSchema), + asyncHandler(async (req, res) => { + const { year, month, dayTodos } = req.body; + await todoService2.saveTodo(year, month, dayTodos); + successResponse(res, null); + }) + ); + router.post( + "/add", + validateBody(addTodoSchema), + asyncHandler(async (req, res) => { + const { year, month, date, content: todoContent } = req.body; + const dayTodos = await todoService2.addTodo(year, month, date, todoContent); + successResponse(res, { dayTodos }); + }) + ); + router.post( + "/toggle", + validateBody(toggleTodoSchema), + asyncHandler(async (req, res) => { + const { year, month, date, itemIndex, completed } = req.body; + const dayTodos = await todoService2.toggleTodo(year, month, date, itemIndex, completed); + successResponse(res, { dayTodos }); + }) + ); + router.post( + "/update", + validateBody(updateTodoSchema), + asyncHandler(async (req, res) => { + const { year, month, date, itemIndex, content: newContent } = req.body; + const dayTodos = await todoService2.updateTodo(year, month, date, itemIndex, newContent); + successResponse(res, { dayTodos }); + }) + ); + router.delete( + "/delete", + validateBody(deleteTodoSchema), + asyncHandler(async (req, res) => { + const { year, month, date, itemIndex } = req.body; + const dayTodos = await todoService2.deleteTodo(year, month, date, itemIndex); + successResponse(res, { dayTodos }); + }) + ); + return router; +}; +var todoService = new TodoService(); +var routes_default = createTodoRoutes({ todoService }); + +// api/modules/todo/index.ts +var createTodoModule = () => { + return createApiModule(TODO_MODULE, { + routes: (container) => { + const todoService2 = container.getSync("todoService"); + return createTodoRoutes({ todoService: todoService2 }); + }, + lifecycle: { + onLoad: (container) => { + container.register("todoService", () => new TodoService()); + } + } + }); +}; +var todo_default = createTodoModule; + +export { + parseTodoContent, + generateTodoContent, + TodoService, + createTodoService, + getTodoQuerySchema, + saveTodoSchema, + addTodoSchema, + toggleTodoSchema, + updateTodoSchema, + deleteTodoSchema, + createTodoRoutes, + createTodoModule, + todo_default +}; diff --git a/dist-api/chunk-W5TDYTXE.js b/dist-api/chunk-W5TDYTXE.js new file mode 100644 index 0000000..3b3bb90 --- /dev/null +++ b/dist-api/chunk-W5TDYTXE.js @@ -0,0 +1,280 @@ +import { + resolveNotebookPath +} from "./chunk-ER4KPD22.js"; +import { + asyncHandler, + createApiModule, + defineApiModule, + defineEndpoints, + successResponse +} from "./chunk-74TMTGBG.js"; + +// shared/modules/remote/api.ts +var REMOTE_ENDPOINTS = defineEndpoints({ + getConfig: { path: "/config", method: "GET" }, + saveConfig: { path: "/config", method: "POST" }, + getScreenshot: { path: "/screenshot", method: "GET" }, + saveScreenshot: { path: "/screenshot", method: "POST" }, + getData: { path: "/data", method: "GET" }, + saveData: { path: "/data", method: "POST" } +}); + +// shared/modules/remote/index.ts +var REMOTE_MODULE = defineApiModule({ + id: "remote", + name: "\u8FDC\u7A0B", + basePath: "/remote", + order: 25, + version: "1.0.0", + endpoints: REMOTE_ENDPOINTS +}); + +// api/modules/remote/service.ts +import fs from "fs/promises"; +import path from "path"; +var REMOTE_DIR = "remote"; +var RemoteService = class { + constructor(deps = {}) { + this.deps = deps; + } + getRemoteDir() { + const { fullPath } = resolveNotebookPath(REMOTE_DIR); + return { relPath: REMOTE_DIR, fullPath }; + } + getDeviceDir(deviceName) { + const safeName = this.sanitizeFileName(deviceName); + const { fullPath } = resolveNotebookPath(path.join(REMOTE_DIR, safeName)); + return { relPath: path.join(REMOTE_DIR, safeName), fullPath }; + } + sanitizeFileName(name) { + return name.replace(/[<>:"/\\|?*]/g, "_").trim() || "unnamed"; + } + getDeviceConfigPath(deviceName) { + const { fullPath } = this.getDeviceDir(deviceName); + return path.join(fullPath, "config.json"); + } + getDeviceScreenshotPath(deviceName) { + const { fullPath } = this.getDeviceDir(deviceName); + return path.join(fullPath, "screenshot.png"); + } + getDeviceDataPath(deviceName) { + const { fullPath } = this.getDeviceDir(deviceName); + return path.join(fullPath, "data.json"); + } + async ensureDir(dirPath) { + await fs.mkdir(dirPath, { recursive: true }); + } + async getDeviceNames() { + const { fullPath } = this.getRemoteDir(); + try { + const entries = await fs.readdir(fullPath, { withFileTypes: true }); + const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); + return dirs; + } catch { + return []; + } + } + async getConfig() { + const deviceNames = await this.getDeviceNames(); + const devices = await Promise.all( + deviceNames.map(async (name) => { + try { + const configPath = this.getDeviceConfigPath(name); + const content = await fs.readFile(configPath, "utf-8"); + const deviceConfig = JSON.parse(content); + return { + id: deviceConfig.id || name, + deviceName: name, + serverHost: deviceConfig.serverHost || "", + desktopPort: deviceConfig.desktopPort || 3e3, + gitPort: deviceConfig.gitPort || 3001, + openCodePort: deviceConfig.openCodePort || 3002, + fileTransferPort: deviceConfig.fileTransferPort || 3003, + password: deviceConfig.password || "" + }; + } catch { + return { + id: name, + deviceName: name, + serverHost: "", + desktopPort: 3e3, + gitPort: 3001, + openCodePort: 3002, + fileTransferPort: 3003, + password: "" + }; + } + }) + ); + return { devices }; + } + async saveConfig(config) { + const { fullPath: remoteDirFullPath } = this.getRemoteDir(); + await this.ensureDir(remoteDirFullPath); + const existingDevices = await this.getDeviceNames(); + const newDeviceNames = config.devices.map((d) => this.sanitizeFileName(d.deviceName)); + for (const oldDevice of existingDevices) { + if (!newDeviceNames.includes(oldDevice)) { + try { + const oldDir = path.join(remoteDirFullPath, oldDevice); + await fs.rm(oldDir, { recursive: true, force: true }); + } catch { + } + } + } + for (const device of config.devices) { + const deviceDir = this.getDeviceDir(device.deviceName); + await this.ensureDir(deviceDir.fullPath); + const deviceConfigPath = this.getDeviceConfigPath(device.deviceName); + const deviceConfig = { + id: device.id, + serverHost: device.serverHost, + desktopPort: device.desktopPort, + gitPort: device.gitPort, + openCodePort: device.openCodePort, + fileTransferPort: device.fileTransferPort, + password: device.password || "" + }; + await fs.writeFile(deviceConfigPath, JSON.stringify(deviceConfig, null, 2), "utf-8"); + } + } + async getScreenshot(deviceName) { + if (!deviceName) { + return null; + } + const screenshotPath = this.getDeviceScreenshotPath(deviceName); + try { + return await fs.readFile(screenshotPath); + } catch { + return null; + } + } + async saveScreenshot(dataUrl, deviceName) { + console.log("[RemoteService] saveScreenshot:", { deviceName, dataUrlLength: dataUrl?.length }); + if (!deviceName || deviceName.trim() === "") { + console.warn("[RemoteService] saveScreenshot skipped: no deviceName"); + return; + } + const deviceDir = this.getDeviceDir(deviceName); + await this.ensureDir(deviceDir.fullPath); + const base64Data = dataUrl.replace(/^data:image\/png;base64,/, ""); + const buffer = Buffer.from(base64Data, "base64"); + const screenshotPath = this.getDeviceScreenshotPath(deviceName); + await fs.writeFile(screenshotPath, buffer); + } + async getData(deviceName) { + if (!deviceName || deviceName.trim() === "") { + return null; + } + const dataPath = this.getDeviceDataPath(deviceName); + try { + const content = await fs.readFile(dataPath, "utf-8"); + return JSON.parse(content); + } catch { + return null; + } + } + async saveData(data, deviceName) { + if (!deviceName || deviceName.trim() === "") { + console.warn("[RemoteService] saveData skipped: no deviceName"); + return; + } + const deviceDir = this.getDeviceDir(deviceName); + await this.ensureDir(deviceDir.fullPath); + const dataPath = this.getDeviceDataPath(deviceName); + await fs.writeFile(dataPath, JSON.stringify(data, null, 2), "utf-8"); + } +}; +var createRemoteService = (deps) => { + return new RemoteService(deps); +}; + +// api/modules/remote/routes.ts +import express from "express"; +var createRemoteRoutes = (deps) => { + const router = express.Router(); + const { remoteService: remoteService2 } = deps; + router.get( + "/config", + asyncHandler(async (req, res) => { + const config = await remoteService2.getConfig(); + successResponse(res, config); + }) + ); + router.post( + "/config", + asyncHandler(async (req, res) => { + const config = req.body; + await remoteService2.saveConfig(config); + successResponse(res, null); + }) + ); + router.get( + "/screenshot", + asyncHandler(async (req, res) => { + const deviceName = req.query.device; + const buffer = await remoteService2.getScreenshot(deviceName); + if (!buffer) { + return successResponse(res, ""); + } + const base64 = `data:image/png;base64,${buffer.toString("base64")}`; + successResponse(res, base64); + }) + ); + router.post( + "/screenshot", + asyncHandler(async (req, res) => { + const { dataUrl, deviceName } = req.body; + console.log("[Remote] saveScreenshot called:", { deviceName, hasDataUrl: !!dataUrl }); + await remoteService2.saveScreenshot(dataUrl, deviceName); + successResponse(res, null); + }) + ); + router.get( + "/data", + asyncHandler(async (req, res) => { + const deviceName = req.query.device; + const data = await remoteService2.getData(deviceName); + successResponse(res, data); + }) + ); + router.post( + "/data", + asyncHandler(async (req, res) => { + const { deviceName, lastConnected } = req.body; + const data = {}; + if (lastConnected !== void 0) { + data.lastConnected = lastConnected; + } + await remoteService2.saveData(data, deviceName); + successResponse(res, null); + }) + ); + return router; +}; +var remoteService = new RemoteService(); +var routes_default = createRemoteRoutes({ remoteService }); + +// api/modules/remote/index.ts +var createRemoteModule = () => { + return createApiModule(REMOTE_MODULE, { + routes: (container) => { + const remoteService2 = container.getSync("remoteService"); + return createRemoteRoutes({ remoteService: remoteService2 }); + }, + lifecycle: { + onLoad: (container) => { + container.register("remoteService", () => new RemoteService()); + } + } + }); +}; +var remote_default = createRemoteModule; + +export { + RemoteService, + createRemoteService, + createRemoteRoutes, + createRemoteModule, + remote_default +}; diff --git a/dist-api/document-parser-FDZMXJVD.js b/dist-api/document-parser-FDZMXJVD.js new file mode 100644 index 0000000..8ce010f --- /dev/null +++ b/dist-api/document-parser-FDZMXJVD.js @@ -0,0 +1,32 @@ +import { + applyReplacements, + blogRoutes_default, + cleanupJob, + copyLocalImage, + createDocumentParserModule, + createJobContext, + document_parser_default, + ensureScriptExists, + findImageDestinations, + getScriptPath, + mineruRoutes_default, + spawnPythonScript +} from "./chunk-R5LQJNQE.js"; +import "./chunk-47DJ6YUB.js"; +import "./chunk-FTVFWJFJ.js"; +import "./chunk-ER4KPD22.js"; +import "./chunk-74TMTGBG.js"; +export { + applyReplacements, + blogRoutes_default as blogRoutes, + cleanupJob, + copyLocalImage, + createDocumentParserModule, + createJobContext, + document_parser_default as default, + ensureScriptExists, + findImageDestinations, + getScriptPath, + mineruRoutes_default as mineruRoutes, + spawnPythonScript +}; diff --git a/dist-api/pydemos-W2QKOORB.js b/dist-api/pydemos-W2QKOORB.js new file mode 100644 index 0000000..8f6577d --- /dev/null +++ b/dist-api/pydemos-W2QKOORB.js @@ -0,0 +1,14 @@ +import { + createPyDemosModule, + createPyDemosRoutes, + pydemos_default +} from "./chunk-T5RPAMG6.js"; +import "./chunk-5EGA6GHY.js"; +import "./chunk-FTVFWJFJ.js"; +import "./chunk-ER4KPD22.js"; +import "./chunk-74TMTGBG.js"; +export { + createPyDemosModule, + createPyDemosRoutes, + pydemos_default as default +}; diff --git a/dist-api/recycle-bin-VT62BD42.js b/dist-api/recycle-bin-VT62BD42.js new file mode 100644 index 0000000..a1f3fa3 --- /dev/null +++ b/dist-api/recycle-bin-VT62BD42.js @@ -0,0 +1,14 @@ +import { + createRecycleBinModule, + recycle_bin_default, + restoreFile, + restoreFolder +} from "./chunk-M2SZ5AIA.js"; +import "./chunk-ER4KPD22.js"; +import "./chunk-74TMTGBG.js"; +export { + createRecycleBinModule, + recycle_bin_default as default, + restoreFile, + restoreFolder +}; diff --git a/dist-api/remote-KLYC364F.js b/dist-api/remote-KLYC364F.js new file mode 100644 index 0000000..09e766b --- /dev/null +++ b/dist-api/remote-KLYC364F.js @@ -0,0 +1,16 @@ +import { + RemoteService, + createRemoteModule, + createRemoteRoutes, + createRemoteService, + remote_default +} from "./chunk-W5TDYTXE.js"; +import "./chunk-ER4KPD22.js"; +import "./chunk-74TMTGBG.js"; +export { + RemoteService, + createRemoteModule, + createRemoteRoutes, + createRemoteService, + remote_default as default +}; diff --git a/dist-api/time-tracking-5F7NPQRY.js b/dist-api/time-tracking-5F7NPQRY.js new file mode 100644 index 0000000..ef43b82 --- /dev/null +++ b/dist-api/time-tracking-5F7NPQRY.js @@ -0,0 +1,28 @@ +import { + HeartbeatService, + SessionPersistenceService, + TimeTrackerService, + createHeartbeatService, + createSessionPersistence, + createTimeTrackingModule, + createTimeTrackingRoutes, + getTimeTrackerService, + initializeTimeTrackerService, + initializeTimeTrackerServiceWithDependencies, + time_tracking_default +} from "./chunk-QS2CMBFP.js"; +import "./chunk-47DJ6YUB.js"; +import "./chunk-74TMTGBG.js"; +export { + HeartbeatService, + SessionPersistenceService, + TimeTrackerService, + createHeartbeatService, + createSessionPersistence, + createTimeTrackingModule, + createTimeTrackingRoutes, + time_tracking_default as default, + getTimeTrackerService, + initializeTimeTrackerService, + initializeTimeTrackerServiceWithDependencies +}; diff --git a/dist-api/todo-GDTZZEQA.js b/dist-api/todo-GDTZZEQA.js new file mode 100644 index 0000000..f421ab3 --- /dev/null +++ b/dist-api/todo-GDTZZEQA.js @@ -0,0 +1,33 @@ +import { + TodoService, + addTodoSchema, + createTodoModule, + createTodoRoutes, + createTodoService, + deleteTodoSchema, + generateTodoContent, + getTodoQuerySchema, + parseTodoContent, + saveTodoSchema, + todo_default, + toggleTodoSchema, + updateTodoSchema +} from "./chunk-V2OWYGQG.js"; +import "./chunk-5EGA6GHY.js"; +import "./chunk-ER4KPD22.js"; +import "./chunk-74TMTGBG.js"; +export { + TodoService, + addTodoSchema, + createTodoModule, + createTodoRoutes, + createTodoService, + todo_default as default, + deleteTodoSchema, + generateTodoContent, + getTodoQuerySchema, + parseTodoContent, + saveTodoSchema, + toggleTodoSchema, + updateTodoSchema +}; diff --git a/docs/vercel/VERCEL_GUIDE.md b/docs/vercel/VERCEL_GUIDE.md new file mode 100644 index 0000000..b9fff13 --- /dev/null +++ b/docs/vercel/VERCEL_GUIDE.md @@ -0,0 +1,2114 @@ +# Vercel 完整指南 + +> 本文档基于 Vercel 官方文档整理,涵盖 Vercel 平台的所有核心功能和使用方法。 + +--- + +## 目录 + +1. [平台概述](#一平台概述) +2. [快速入门](#二快速入门) +3. [模板市场](#三模板市场) +4. [项目与部署](#四项目与部署) +5. [Git 集成](#五git-集成) +6. [Vercel Functions](#六vercel-functions) +7. [路由中间件](#七路由中间件) +8. [环境变量](#八环境变量) +9. [域名管理](#九域名管理) +10. [存储服务](#十存储服务) +11. [AI SDK](#十一ai-sdk) +12. [分析与监控](#十二分析与监控) +13. [安全功能](#十三安全功能) +14. [图片优化](#十四图片优化) +15. [定时任务](#十五定时任务) +16. [CLI 命令行工具](#十六cli-命令行工具) +17. [REST API](#十七rest-api) +18. [框架支持](#十八框架支持) +19. [部署保护](#十九部署保护) +20. [计费与限制](#二十计费与限制) +21. [最佳实践](#二十一最佳实践) + +--- + +## 一、平台概述 + +### 什么是 Vercel? + +Vercel 是 AI 云平台,为开发者提供构建、部署和扩展 AI 驱动应用的统一平台。支持部署 Web 应用、代理工作负载以及介于两者之间的所有内容。 + +### 核心特性 + +- **零配置部署**:支持主流框架的零配置部署 +- **全球 CDN**:内容分发至全球数据中心 +- **自动 HTTPS**:免费 SSL 证书 +- **预览环境**:每次 Git 推送自动生成预览 URL +- **Serverless Functions**:无需管理服务器的后端函数 +- **边缘网络**:全球低延迟执行 + +### 订阅计划 + +| 计划 | 适用场景 | 价格 | +|------|----------|------| +| **Hobby** | 个人项目、学习 | 免费 | +| **Pro** | 专业开发者、团队 | $20/用户/月 | +| **Enterprise** | 大型企业 | 定制 | + +--- + +## 二、快速入门 + +### 创建账户 + +1. 访问 [vercel.com/signup](https://vercel.com/signup) +2. 选择 Git 提供商登录或使用邮箱 +3. 完成邮箱和手机验证 + +### 安装 CLI + +```bash +# npm +npm i -g vercel + +# pnpm +pnpm i -g vercel + +# yarn +yarn global add vercel + +# bun +bun i -g vercel +``` + +### 首次部署 + +```bash +# 进入项目目录 +cd your-project + +# 部署到生产环境 +vercel --prod +``` + +### 部署流程 + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ 本地开发 │ ──▶ │ Git 推送 │ ──▶ │ 自动构建 │ +└─────────────┘ └─────────────┘ └─────────────┘ + │ + ▼ + ┌─────────────┐ ┌─────────────┐ + │ 生成 URL │ ◀── │ 部署成功 │ + └─────────────┘ └─────────────┘ +``` + +--- + +## 三、模板市场 + +### 概述 + +Vercel Templates 是官方提供的模板市场,包含大量预构建的项目模板,帮助开发者快速启动项目开发。所有模板都是**免费**使用的。 + +**访问地址**:[vercel.com/templates](https://vercel.com/templates) + +### 模板分类 + +#### 按用途分类 + +| 类别 | 说明 | 适用场景 | +|------|------|----------| +| **AI** | AI 应用模板 | 聊天机器人、AI Agent、语音接口 | +| **Starter** | 入门模板 | 快速启动新项目 | +| **E-commerce** | 电商模板 | 在线商店、商品展示 | +| **SaaS** | SaaS 应用模板 | 多租户平台、企业应用 | +| **Blog** | 博客模板 | 个人博客、技术文章 | +| **Portfolio** | 作品集模板 | 个人简历、作品展示 | +| **Documentation** | 文档模板 | 技术文档、API 文档 | +| **Backend** | 后端模板 | API 服务、微服务 | +| **CMS** | 内容管理模板 | 内容网站、新闻站点 | +| **Marketing Sites** | 营销网站模板 | 产品官网、落地页 | +| **Authentication** | 认证模板 | 登录系统、用户管理 | +| **Admin Dashboard** | 管理后台模板 | 数据管理、控制面板 | +| **Web3** | 区块链模板 | DApp、NFT 市场 | +| **Monorepos** | 单体仓库模板 | 多包项目管理 | + +#### 按技术栈分类 + +| 类别 | 说明 | +|------|------| +| **Framework** | Next.js、Nuxt、SvelteKit、Astro、Remix 等 | +| **CSS** | Tailwind CSS、CSS Modules、Styled Components 等 | +| **Database** | PostgreSQL、MongoDB、Redis、PlanetScale 等 | +| **CMS** | Contentful、Sanity、Prismic、Strapi 等 | +| **Authentication** | NextAuth.js、Auth0、Clerk、Supabase Auth 等 | + +### 热门模板详解 + +#### 1. Next.js Boilerplate + +最基础的 Next.js 起手架模板。 + +``` +技术栈: +- Next.js 14+ (App Router) +- TypeScript +- ESLint + Prettier +- Tailwind CSS + +适用场景: +- 快速启动新项目 +- 学习 Next.js +- 自定义项目基础 +``` + +**使用方式**: +```bash +# CLI 方式 +vercel init nextjs-boilerplate + +# 或访问 Dashboard +# vercel.com/templates/next.js/nextjs-boilerplate +``` + +#### 2. AI Chatbot + +Vercel 官方出品的完整 AI 聊天机器人模板。 + +``` +技术栈: +- Next.js App Router +- Vercel AI SDK +- OpenAI / Anthropic / 其他 LLM +- Tailwind CSS +- Supabase(可选,用于数据持久化) + +功能特性: +- 流式响应 +- 多模型支持 +- 聊天历史 +- 代码高亮 +- Markdown 渲染 +``` + +**使用方式**: +```bash +vercel init ai-chatbot +``` + +**配置环境变量**: +```bash +# OpenAI +OPENAI_API_KEY=sk-xxx + +# 或 Anthropic +ANTHROPIC_API_KEY=sk-xxx +``` + +#### 3. Next.js Commerce + +高性能电商网站模板,支持多种电商平台集成。 + +``` +技术栈: +- Next.js 14 +- Shopify Storefront API +- Tailwind CSS +- Framer Motion + +功能特性: +- 商品列表与详情 +- 购物车 +- 结账流程 +- 商品搜索 +- 响应式设计 +- SEO 优化 +``` + +**支持的电商平台**: +- Shopify +- BigCommerce +- Saleor +- Swell +- Vendure + +**使用方式**: +```bash +vercel init nextjs-commerce +``` + +**配置 Shopify**: +```bash +SHOPIFY_STORE_DOMAIN=your-store.myshopify.com +SHOPIFY_STOREFRONT_ACCESS_TOKEN=xxx +``` + +#### 4. Platforms Starter Kit + +多租户 SaaS 平台模板,用于构建类似 Vercel 的平台。 + +``` +技术栈: +- Next.js App Router +- Redis (Upstash) +- Tailwind CSS +- Prisma + +功能特性: +- 多租户架构 +- 自定义域名 +- 子域名路由 +- 用户认证 +- 计费集成 +- Dashboard 管理 +``` + +**使用场景**: +- 博客平台(类似 Medium) +- 电商托管平台 +- CMS 托管服务 +- 任何需要多租户的 SaaS + +**使用方式**: +```bash +vercel init platforms-starter-kit +``` + +#### 5. Nextra: Docs Starter Kit + +Markdown 驱动的文档站点模板。 + +``` +技术栈: +- Next.js +- Nextra +- MDX +- Tailwind CSS + +功能特性: +- Markdown/MDX 支持 +- 自动侧边栏 +- 全文搜索 +- 深色模式 +- 代码高亮 +- 版本管理 +``` + +**使用场景**: +- 技术文档 +- API 文档 +- 知识库 +- 教程网站 + +**使用方式**: +```bash +vercel init documentation-starter-kit +``` + +#### 6. Blog Starter Kit + +静态博客模板,支持 Markdown 写作。 + +``` +技术栈: +- Next.js +- TypeScript +- Tailwind CSS +- Markdown + +功能特性: +- Markdown 写作 +- 标签分类 +- RSS 订阅 +- SEO 优化 +- 深色模式 +``` + +**使用方式**: +```bash +vercel init blog-starter-kit +``` + +#### 7. Next.js Enterprise Boilerplate + +企业级 Next.js 模板,包含完整的工程化配置。 + +``` +技术栈: +- Next.js +- TypeScript +- Tailwind CSS +- Radix UI +- ESLint + Prettier +- Jest + Playwright +- Storybook +- GitHub Actions + +功能特性: +- 企业级代码规范 +- 完整的测试配置 +- CI/CD 流程 +- 组件文档 +- 性能监控 +``` + +**适用场景**: +- 企业项目 +- 大型团队协作 +- 需要完整工程化的项目 + +#### 8. Portfolio Starter Kit + +个人作品集模板。 + +``` +技术栈: +- Next.js +- TypeScript +- Tailwind CSS +- MDX + +功能特性: +- Markdown 管理内容 +- 响应式设计 +- 深色模式 +- SEO 优化 +- 项目展示 +``` + +#### 9. Express on Bun / Hono on Bun + +后端服务模板,使用 Bun 运行时。 + +``` +Express on Bun: +- Express.js +- Bun 运行时 +- TypeScript + +Hono on Bun: +- Hono 框架 +- Bun 运行时 +- TypeScript +- Edge-ready +``` + +**使用方式**: +```bash +vercel init express-on-bun +vercel init hono-on-bun +``` + +#### 10. Nuxt AI Chatbot + +Nuxt.js 版本的 AI 聊天机器人。 + +``` +技术栈: +- Nuxt 3 +- Vercel AI SDK +- Nuxt MDC +- Tailwind CSS +``` + +### 使用模板的方式 + +#### 方式一:通过 Dashboard(推荐) + +``` +1. 访问 vercel.com/templates +2. 浏览或搜索模板 +3. 点击模板查看详情 +4. 点击 "Deploy" 按钮 +5. 系统自动 Fork 到你的 GitHub +6. 自动部署到 Vercel +``` + +#### 方式二:通过 CLI + +```bash +# 列出可用模板 +vercel init + +# 初始化特定模板 +vercel init nextjs-boilerplate +vercel init ai-chatbot +vercel init nextjs-commerce + +# 查看模板帮助 +vercel init --help +``` + +#### 方式三:克隆 GitHub 仓库 + +```bash +# 直接克隆模板仓库 +git clone https://github.com/vercel/next.js/tree/canary/packages/create-next-app + +# 或从模板创建新仓库 +# GitHub → Use this template → Create a new repository +``` + +### 模板筛选 + +在模板市场可以使用多种条件筛选: + +``` +按用途筛选: +- AI +- Starter +- Ecommerce +- SaaS +- Blog +- Portfolio +- CMS +- Backend +- ... + +按框架筛选: +- Next.js +- Nuxt +- SvelteKit +- Astro +- Remix +- Vue +- React +- ... + +按数据库筛选: +- PostgreSQL +- MongoDB +- MySQL +- Redis +- PlanetScale +- Supabase +- Neon +- ... + +按 CMS 筛选: +- Contentful +- Sanity +- Prismic +- Strapi +- WordPress +- ... +``` + +### 创建自定义模板 + +你可以将自己的项目变成模板供他人使用: + +1. **创建示例仓库** + ```bash + # 项目结构清晰 + # 包含 README.md + # 配置好所有必要文件 + ``` + +2. **添加模板元数据** + ```json + // vercel.json + { + "name": "My Template", + "description": "A custom template", + "public": true + } + ``` + +3. **提交到 Vercel 模板库** + - 发送 PR 到 [vercel/templates](https://github.com/vercel/templates) + - 或在 Vercel Dashboard 标记项目为模板 + +### 模板最佳实践 + +#### 选择模板时的考虑 + +``` +1. 技术栈匹配 + - 团队熟悉的框架 + - 项目需求的技术支持 + +2. 功能完整性 + - 是否包含所需功能 + - 是否需要大量修改 + +3. 维护状态 + - 最近更新时间 + - 社区活跃度 + - Issue 处理情况 + +4. 文档质量 + - README 是否清晰 + - 配置说明是否完整 +``` + +#### 使用模板后的步骤 + +``` +1. 阅读模板文档 + - README.md + - 配置要求 + - 环境变量 + +2. 安装依赖 + npm install + # 或 + pnpm install + +3. 配置环境变量 + cp .env.example .env.local + # 编辑 .env.local 填入实际值 + +4. 本地开发 + npm run dev + +5. 自定义修改 + - 修改品牌信息 + - 调整样式 + - 添加功能 + +6. 部署上线 + git push + # 或 + vercel --prod +``` + +### 官方模板仓库 + +Vercel 官方维护的模板仓库: + +| 仓库 | 说明 | +|------|------| +| [vercel/next.js](https://github.com/vercel/next.js) | Next.js 官方示例 | +| [vercel/templates](https://github.com/vercel/templates) | Vercel 模板集合 | +| [vercel/examples](https://github.com/vercel/examples) | 示例项目集合 | + +### 模板 vs 从零开始 + +| 对比项 | 使用模板 | 从零开始 | +|--------|----------|----------| +| 启动速度 | ⚡ 分钟级 | 🐢 小时级 | +| 最佳实践 | ✅ 已内置 | ❌ 需自行配置 | +| 学习成本 | 低 | 高 | +| 自由度 | 中等 | 完全自由 | +| 代码质量 | 取决于模板 | 取决于自己 | + +**建议**: +- 快速原型 / 学习 / 中小型项目 → 使用模板 +- 特殊需求 / 大型定制项目 → 从零开始 + +--- + +## 四、项目与部署 + +### 项目概念 + +项目是部署到 Vercel 的应用程序,来自单个 Git 仓库。每个项目可以有多个部署: +- 一个生产部署 +- 多个预生产部署 + +### 创建项目 + +1. **通过 Dashboard**:点击 "New Project" 按钮 +2. **通过 CLI**:`vercel project add` +3. **通过 API**:POST 请求到 `/v9/projects` + +### 部署方法 + +#### 1. Git 部署(推荐) + +```bash +# 连接 Git 仓库后,每次推送自动部署 +git push origin main +``` + +#### 2. CLI 部署 + +```bash +# 预览部署 +vercel + +# 生产部署 +vercel --prod +``` + +#### 3. Deploy Hooks + +用于触发部署的唯一 URL: + +```bash +# 创建 Deploy Hook 后 +curl -X POST https://api.vercel.com/v1/integrations/deploy/xxx +``` + +#### 4. REST API 部署 + +```typescript +const response = await fetch('https://api.vercel.com/v13/deployments', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: 'my-project', + files: [...], + projectSettings: {...} + }) +}); +``` + +### 部署环境 + +| 环境 | 说明 | URL 格式 | +|------|------|----------| +| **Local** | 本地开发 | `localhost:3000` | +| **Preview** | 预览环境 | `project-branch-hash.vercel.app` | +| **Production** | 生产环境 | `your-domain.com` | + +### 管理部署 + +```bash +# 查看部署列表 +vercel list + +# 查看部署详情 +vercel inspect [deployment-url] + +# 重新部署 +vercel redeploy [deployment-id] + +# 回滚生产部署 +vercel rollback [deployment-id] + +# 删除部署 +vercel remove [deployment-url] +``` + +--- + +## 五、Git 集成 + +### 支持的 Git 提供商 + +- GitHub(免费/团队/企业云) +- GitLab(免费/Premium/Ultimate/企业) +- Bitbucket(免费/标准/高级) +- Azure DevOps Pipelines + +### 连接 Git 仓库 + +1. 在 Dashboard 点击 "New Project" +2. 选择 Git 仓库 +3. 配置项目设置 +4. 点击 "Deploy" + +### 生产分支 + +默认规则(优先级从高到低): +1. `main` 分支 +2. `master` 分支 +3. Git 仓库的默认分支 + +**自定义生产分支**: +1. 进入项目 Settings → Environments +2. 选择 Production 环境 +3. 修改 Branch Tracking + +### 预览分支 + +所有非生产分支自动创建预览部署: + +``` +main (生产) → https://your-domain.com +feature/a (预览) → https://project-feature-a-hash.vercel.app +develop (预览) → https://project-develop-hash.vercel.app +``` + +### 私有仓库部署 + +**Pro 团队**: +- 提交作者必须是团队成员 +- 非成员提交需要申请加入 + +**Hobby 团队**: +- 无法从组织私有仓库部署 +- 需要升级到 Pro 或公开仓库 + +--- + +## 六、Vercel Functions + +### 概述 + +Vercel Functions 允许运行服务器端代码,无需管理服务器。自动扩展、处理 API 和数据库连接。 + +### 创建函数 + +#### 标准方式(推荐) + +```typescript +// api/hello.ts +export default { + fetch(request: Request) { + return new Response('Hello from Vercel!'); + }, +}; +``` + +#### HTTP 方法 + +```typescript +// api/user.ts +export function GET(request: Request) { + return new Response(JSON.stringify({ name: 'User' }), { + headers: { 'Content-Type': 'application/json' } + }); +} + +export function POST(request: Request) { + return new Response('Created', { status: 201 }); +} +``` + +#### Next.js App Router + +```typescript +// app/api/hello/route.ts +export function GET(request: Request) { + return new Response('Hello from Vercel!'); +} +``` + +### 函数配置 + +```typescript +// api/configured.ts +export const config = { + runtime: 'nodejs', // 'nodejs' | 'edge' | 'python' | 'go' | 'ruby' + regions: ['iad1', 'sfo1'], // 部署区域 + maxDuration: 60, // 最大执行时间(秒) + memory: 1024, // 内存(MB) +}; + +export default function handler(request: Request) { + return new Response('Configured function'); +} +``` + +### 运行时支持 + +| 运行时 | 说明 | +|--------|------| +| **Node.js** | 默认运行时,完整 Node.js API | +| **Edge** | 全球边缘执行,低延迟 | +| **Python** | 支持 Python 函数 | +| **Go** | 支持 Go 函数 | +| **Ruby** | 支持 Ruby 函数 | +| **Bun** | Bun 运行时 | + +### 流式响应 + +```typescript +// api/stream.ts +import { streamText } from 'ai'; + +export default async function handler(request: Request) { + const result = await streamText({ + model: 'openai/gpt-4', + prompt: 'Hello', + }); + + return result.toDataStreamResponse(); +} +``` + +### 函数限制 + +| 限制 | Hobby | Pro | Enterprise | +|------|-------|-----|------------| +| 最大执行时间 | 10秒 | 60秒 | 900秒 | +| 内存 | 1024 MB | 3008 MB | 自定义 | +| 包大小 | 50 MB | 50 MB | 自定义 | + +### 定价 + +基于以下因素计费: +- **Active CPU**:CPU 使用时间 +- **Provisioned Memory**:内存分配 +- **Invocations**:调用次数 + +--- + +## 七、路由中间件 + +### 概述 + +路由中间件在请求被处理之前执行代码,用于: +- 重定向 +- 重写 URL +- 添加头部 +- 认证检查 +- A/B 测试 + +### 创建中间件 + +```typescript +// middleware.ts +export default function middleware(request: Request) { + const url = new URL(request.url); + + // 重定向旧路径 + if (url.pathname === '/old-page') { + return new Response(null, { + status: 302, + headers: { Location: '/new-page' }, + }); + } + + // 添加自定义头部 + const response = new Response('Hello'); + response.headers.set('X-Custom-Header', 'value'); + return response; +} +``` + +### 配置匹配路径 + +```typescript +// middleware.ts +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export function middleware(request: NextRequest) { + return NextResponse.next(); +} + +// 配置匹配规则 +export const config = { + matcher: [ + '/api/:path*', // 匹配 /api/* 路径 + '/admin/:path*', // 匹配 /admin/* 路径 + ], + runtime: 'edge', // 或 'nodejs' +}; +``` + +### 使用 Edge Config + +```typescript +// middleware.ts +import { get } from '@vercel/edge-config'; + +export default async function middleware(request: Request) { + // 从 Edge Config 读取功能开关 + const featureEnabled = await get('feature-flag'); + + if (featureEnabled) { + return new Response('Feature enabled'); + } + + return new Response('Feature disabled'); +} +``` + +### 请求限制 + +| 限制 | 值 | +|------|-----| +| 最大 URL 长度 | 14 KB | +| 最大请求体长度 | 4 MB | +| 最大请求头数量 | 64 | +| 最大请求头长度 | 16 KB | + +--- + +## 八、环境变量 + +### 创建环境变量 + +**通过 Dashboard**: +1. 进入项目 Settings → Environment Variables +2. 添加键值对 +3. 选择适用的环境 + +**通过 CLI**: + +```bash +# 添加环境变量 +vercel env add DATABASE_URL + +# 拉取环境变量到本地 +vercel env pull .env.local + +# 列出所有环境变量 +vercel env ls + +# 删除环境变量 +vercel env rm DATABASE_URL production +``` + +### 环境类型 + +| 环境 | 说明 | +|------|------| +| **Production** | 生产部署时使用 | +| **Preview** | 预览部署时使用 | +| **Development** | 本地开发时使用(`vercel dev`) | + +### 代码中使用 + +```typescript +// 服务端 +const dbUrl = process.env.DATABASE_URL; + +// 客户端(需要 NEXT_PUBLIC_ 前缀) +const apiKey = process.env.NEXT_PUBLIC_API_KEY; +``` + +### 预览分支特定变量 + +可以为特定分支设置不同的变量值: + +```bash +# 为 feature 分支设置特定变量 +vercel env add API_URL preview --branch feature +``` + +### 系统环境变量 + +Vercel 自动注入以下变量: + +```bash +VERCEL=1 +VERCEL_ENV=production|preview|development +VERCEL_URL=project-hash.vercel.app +VERCEL_REGION=iad1 +VERCEL_GIT_PROVIDER=github +VERCEL_GIT_REPO_SLUG=my-repo +VERCEL_GIT_COMMIT_SHA=abc123 +VERCEL_GIT_COMMIT_MESSAGE="Initial commit" +``` + +### 限制 + +- 每个环境最多 **1000** 个变量 +- 总大小限制 **64 KB** +- Edge Function 单个变量限制 **5 KB** + +--- + +## 九、域名管理 + +### 添加域名 + +**通过 Dashboard**: +1. 进入项目 Settings → Domains +2. 输入域名 +3. 选择验证方式 + +**通过 CLI**: + +```bash +# 添加域名 +vercel domains add example.com + +# 验证域名 +vercel domains inspect example.com +``` + +### DNS 配置 + +#### A 记录 + +``` +类型: A +名称: @ +值: 76.76.21.21 +``` + +#### CNAME 记录 + +``` +类型: CNAME +名称: www +值: cname.vercel-dns.com +``` + +### 域名类型 + +| 类型 | 说明 | 示例 | +|------|------|------| +| **根域名** | 主域名 | `example.com` | +| **子域名** | 域名的子集 | `blog.example.com` | +| **通配符** | 匹配所有子域名 | `*.example.com` | + +### 分配域名到分支 + +```bash +# 将域名分配给特定分支 +# staging.example.com → staging 分支 +``` + +1. 进入 Domains 设置 +2. 添加域名 +3. 选择 Git Branch + +### SSL 证书 + +Vercel 自动为所有域名生成 SSL 证书: +- 自动续期 +- 免费 +- 支持 Let's Encrypt + +```bash +# 查看证书 +vercel certs ls + +# 手动颁发证书 +vercel certs issue example.com +``` + +--- + +## 十、存储服务 + +### 存储产品概览 + +| 产品 | 用途 | 读取速度 | 写入速度 | +|------|------|----------|----------| +| **Vercel Blob** | 大文件存储 | 快 | 毫秒级 | +| **Edge Config** | 全局配置 | 超快(<1ms) | 秒级 | +| **Marketplace** | 数据库(Postgres/Redis/Mongo) | 取决于提供商 | 取决于提供商 | + +### Vercel Blob + +用于存储图片、视频等大文件。 + +```typescript +import { put } from '@vercel/blob'; + +// 上传文件 +const blob = await put('profile.jpg', file, { + access: 'public', +}); + +console.log(blob.url); // https://xxx.public.blob.vercel-storage.com/profile.jpg +``` + +```typescript +import { list, del } from '@vercel/blob'; + +// 列出文件 +const { blobs } = await list(); + +// 删除文件 +await del(blob.url); +``` + +### Edge Config + +用于存储频繁读取、很少更改的数据。 + +```typescript +import { get } from '@vercel/edge-config'; + +// 读取值 +const featureFlag = await get('new-feature'); + +// 读取多个值 +const values = await get(['feature-a', 'feature-b']); +``` + +**使用场景**: +- 功能开关 +- A/B 测试配置 +- 关键重定向 +- IP 黑名单 + +### Marketplace 数据库 + +通过 Vercel Marketplace 直接配置数据库: + +| 类型 | 提供商 | +|------|--------| +| **PostgreSQL** | Neon, Supabase, PlanetScale | +| **Redis/KV** | Upstash, Redis | +| **NoSQL** | MongoDB, DynamoDB | +| **Vector** | Pinecone, Weaviate | + +--- + +## 十一、AI SDK + +### 概述 + +Vercel AI SDK 是构建 AI 应用的 TypeScript 工具包,支持: +- React / Next.js +- Vue / Nuxt +- Svelte / SvelteKit +- Node.js + +### 安装 + +```bash +# 安装 AI SDK +npm install ai + +# 安装模型提供商 +npm install @ai-sdk/openai +# 或 +npm install @ai-sdk/anthropic +``` + +### 文本生成 + +```typescript +import { generateText } from 'ai'; +import { openai } from '@ai-sdk/openai'; + +const { text } = await generateText({ + model: openai('gpt-4'), + prompt: '解释量子纠缠的概念。', +}); + +console.log(text); +``` + +### 流式文本生成 + +```typescript +import { streamText } from 'ai'; +import { openai } from '@ai-sdk/openai'; + +const result = await streamText({ + model: openai('gpt-4'), + prompt: '写一首关于春天的诗。', +}); + +// 流式输出 +for await (const textPart of result.textStream) { + process.stdout.write(textPart); +} +``` + +### 结构化输出 + +```typescript +import { generateObject } from 'ai'; +import { openai } from '@ai-sdk/openai'; +import { z } from 'zod'; + +const { object } = await generateObject({ + model: openai('gpt-4'), + schema: z.object({ + name: z.string(), + ingredients: z.array(z.string()), + steps: z.array(z.string()), + }), + prompt: '生成一个意大利面食谱。', +}); +``` + +### 工具调用 + +```typescript +import { generateText, tool } from 'ai'; +import { openai } from '@ai-sdk/openai'; +import { z } from 'zod'; + +const { text } = await generateText({ + model: openai('gpt-4'), + prompt: '旧金山今天的天气如何?', + tools: { + getWeather: tool({ + description: '获取指定位置的天气', + parameters: z.object({ + location: z.string().describe('位置'), + }), + execute: async ({ location }) => ({ + location, + temperature: 22, + condition: '晴天', + }), + }), + }, +}); +``` + +### 聊天界面(React) + +```tsx +'use client'; +import { useChat } from 'ai/react'; + +export default function Chat() { + const { messages, input, handleInputChange, handleSubmit } = useChat(); + + return ( +