open-kimiPPT
Nested detail page

编辑器桥接与保存事务 深度解析

这一页专门拆 File System Access、PPTD manifest 载入、图片解析、远端 iframe RPC 和保存事务。核心问题是:怎样让远端编辑器能改本地 PPTD,同时不越权、不乱序、不吞错。

§ 00 · IMPORT CHAIN

导入链与契约

▸安全路径库是所有文件操作的入口

编辑器 app 一开始从 lib/editor-lib.js:1 导入 normalizeRelativePath,并在 lib/editor-app.js:2 到 lib/editor-app.js:8 引入 assertWritableChangePath、dirname、extractPagePaths、joinDeckPath 等函数。这个导入链说明文件安全不是 UI 事件临时判断,而是所有 manifest、page、media、save change 的共同底座。

lib/editor-app.js · L1-9JavaScript
1import {
2  assertWritableChangePath,
3  basename,
4  dirname,
5  extractPagePaths,
6  joinDeckPath,
7  normalizeRelativePath,
8  titleFromManifest,
9} from "./lib.js";
§ 01 · CORE IMPL

核心实现,逐段拆

▸文件夹索引与 manifest 选择

lib/editor-app.js:266 递归遍历 directoryHandle 并建立 fileIndex,lib/editor-app.js:293 在多个 .pptd 之间弹出选择。随后 lib/editor-app.js:330 读取 manifest,lib/editor-app.js:332 提取 pages,lib/editor-app.js:336 最多加载 500 页,lib/editor-app.js:347 取标题。这个流程把目录扫描、清单解析和 deck payload 构造清晰分层。

lib/editor-app.js · L266-360JavaScript
266async function indexDirectory(directoryHandle) {
267  const index = new Map();
268  async function walk(handle, prefix = "") {
269    for await (const [name, entry] of handle.entries()) {
270      if (name === ".DS_Store") continue;
271      const path = prefix ? `${prefix}/${name}` : name;
272      if (entry.kind === "directory") await walk(entry, path);
273      else index.set(normalizeRelativePath(path), entry);
274    }
275  }
276  await walk(directoryHandle);
277  return index;
278}
279
280function indexFallbackFiles(fileList) {
281  const index = new Map();
282  const files = [...fileList];
283  const firstPath = files[0]?.webkitRelativePath || files[0]?.name || "";
284  const rootName = firstPath.includes("/") ? firstPath.split("/")[0] : "";
285  for (const file of files) {
286    let path = file.webkitRelativePath || file.name;
287    if (rootName && path.startsWith(`${rootName}/`)) path = path.slice(rootName.length + 1);
288    index.set(normalizeRelativePath(path), { kind: "file", getFile: async () => file });
289  }
290  return index;
291}
292
293async function chooseManifest(paths) {
294  if (paths.length === 1) return paths[0];
295  elements.deckOptions.replaceChildren();
296  const choice = new Promise((resolve) => {
297    for (const path of paths) {
298      const button = document.createElement("button");
299      button.className = "deck-option";
300      button.type = "button";
301      button.innerHTML = `<span class="deck-file-icon">P</span><span><strong></strong><small></small></span><span>›</span>`;
302      button.querySelector("strong").textContent = basename(path);
303      button.querySelector("small").textContent = path;
304      button.addEventListener("click", () => {
305        elements.deckDialog.close();
306        resolve(path);
307      });
308      elements.deckOptions.append(button);
309    }
310    elements.deckDialog.addEventListener(
311      "close",
312      () => {
313        if (elements.deckDialog.returnValue === "cancel") resolve(null);
314      },
315      { once: true },
316    );
317  });
318  elements.deckDialog.showModal();
319  return choice;
320}
321
322async function textFromIndexedFile(path) {
323  const entry = state.fileIndex.get(normalizeRelativePath(path));
324  if (!entry) throw new Error(`找不到文件:${path}`);
325  const file = await entry.getFile();
326  if (file.size > MAX_TEXT_BYTES) throw new Error(`文件超过 20 MiB:${path}`);
327  return file.text();
328}
329
330async function loadManifest(manifestPath, sourceLabel) {
331  const manifestContent = await textFromIndexedFile(manifestPath);
332  const pagePaths = extractPagePaths(manifestContent);
333  const manifestDirectory = dirname(manifestPath);
334  const pages = [];
335  const missing = [];
336  for (const pagePath of pagePaths.slice(0, 500)) {
337    const indexedPath = joinDeckPath(manifestDirectory, pagePath);
338    try {
339      pages.push({ path: pagePath, content: await textFromIndexedFile(indexedPath) });
340    } catch {
341      missing.push(pagePath);
342    }
343  }
344  if (pages.length === 0) throw new Error(".pptd 清单引用的页面均无法读取");
345  if (missing.length) toast(`跳过 ${missing.length} 个无法读取的页面`, "warning");
346
347  const fallbackTitle = basename(manifestPath).replace(/\.pptd$/i, "");
348  const title = titleFromManifest(manifestContent, fallbackTitle);
349  await setDeck(
350    {
351      id: fallbackTitle,
352      title,
353      manifestPath,
354      manifestContent,
355      pages,
356      basePath: manifestDirectory ? `${manifestDirectory}/` : "",
357      isCreate: true,
358    },
359    sourceLabel,
360  );

▸图片解析只返回 data URL 或空字符串

图片资源解析在 lib/editor-app.js:442 先允许 data/http/blob,其他路径走 indexed file。lib/editor-app.js:486 到 lib/editor-app.js:495 读取文件并按 lastModified/size 缓存 data URL;lib/editor-app.js:498 会记录 miss 数量但返回空字符串而非中断整个 deck。这种降级适合编辑器预览,避免一张丢失图片阻断所有页面。

lib/editor-app.js · L442-503JavaScript
442function resolveIndexedPath(requestedPath) {
443  if (typeof requestedPath !== "string" || !requestedPath.trim()) return null;
444  if (/^(?:data:image\/|https?:\/\/|blob:)/i.test(requestedPath)) return requestedPath;
445  let candidate = requestedPath.replace(/^file:\/\/+/, "").replaceAll("\\", "/");
446  try {
447    candidate = decodeURIComponent(candidate);
448  } catch {
449    // Preserve literal percent signs.
450  }
451
452  const directCandidates = [];
453  try {
454    directCandidates.push(normalizeRelativePath(candidate));
455  } catch {
456    const parts = candidate.split("/").filter(Boolean);
457    for (let index = 0; index < parts.length; index += 1) {
458      try {
459        directCandidates.push(normalizeRelativePath(parts.slice(index).join("/")));
460      } catch {
461        // Keep looking for a safe suffix.
462      }
463    }
464  }
465  try {
466    directCandidates.push(joinDeckPath(state.manifestDirectory, candidate));
467  } catch {
468    // Absolute paths may still match a safe indexed suffix below.
469  }
470  for (const path of directCandidates) if (state.fileIndex.has(path)) return path;
471  for (const path of state.fileIndex.keys()) {
472    if (directCandidates.some((candidatePath) => path.endsWith(`/${candidatePath}`) || path === candidatePath)) return path;
473  }
474  return null;
475}
476
477function fileToDataUrl(file) {
478  return new Promise((resolve, reject) => {
479    const reader = new FileReader();
480    reader.addEventListener("load", () => resolve(String(reader.result)));
481    reader.addEventListener("error", () => reject(reader.error || new Error("图片读取失败")));
482    reader.readAsDataURL(file);
483  });
484}
485
486async function resolveImage(requestedPath) {
487  if (/^(?:data:image\/|https?:\/\/|blob:)/i.test(requestedPath)) return requestedPath;
488  const path = resolveIndexedPath(requestedPath);
489  if (!path) return "";
490  const entry = state.fileIndex.get(path);
491  const file = await entry.getFile();
492  if (file.size > MAX_IMAGE_BYTES) return "";
493  const cacheKey = `${path}:${file.lastModified}:${file.size}`;
494  if (!state.imageCache.has(cacheKey)) state.imageCache.set(cacheKey, fileToDataUrl(file));
495  return state.imageCache.get(cacheKey);
496}
497
498async function getImages(payload = {}) {
499  const paths = Array.isArray(payload.filePath) ? payload.filePath : [];
500  const result = await Promise.all(paths.map((path) => resolveImage(path).catch(() => "")));
501  const misses = result.filter((value) => !value).length;
502  if (misses) addActivity(`${paths.length} 张图片中有 ${misses} 张未找到`, "warning");
503  return result;

▸保存事务具备备份和回滚

保存路径的强约束来自 lib/editor-app.js:556 的 persistChanges。它在 lib/editor-app.js:559 限制一次最多 600 个变更,在 lib/editor-app.js:561 到 lib/editor-app.js:567 规范化变更,在 lib/editor-app.js:587 建立备份,失败时 lib/editor-app.js:595 回滚。外层 lib/editor-app.js:606 用 promise chain 让 onSave 串行执行,避免远端快速保存造成乱序写。

lib/editor-app.js · L506-604JavaScript
506async function getHandleForPath(root, path, create = false) {
507  const parts = normalizeRelativePath(path).split("/");
508  const fileName = parts.pop();
509  let directory = root;
510  for (const part of parts) directory = await directory.getDirectoryHandle(part, { create });
511  return { directory, fileName, file: await directory.getFileHandle(fileName, { create }) };
512}
513
514async function readBackup(path) {
515  try {
516    const { file } = await getHandleForPath(state.directoryHandle, path, false);
517    return { existed: true, bytes: await (await file.getFile()).arrayBuffer() };
518  } catch (error) {
519    if (error?.name === "NotFoundError") return { existed: false, bytes: null };
520    throw error;
521  }
522}
523
524async function writeFile(path, content) {
525  const { file } = await getHandleForPath(state.directoryHandle, path, true);
526  const writable = await file.createWritable();
527  try {
528    await writable.write(content);
529  } finally {
530    await writable.close();
531  }
532  state.fileIndex.set(path, file);
533}
534
535async function deleteFile(path) {
536  const parts = normalizeRelativePath(path).split("/");
537  const fileName = parts.pop();
538  let directory = state.directoryHandle;
539  for (const part of parts) directory = await directory.getDirectoryHandle(part, { create: false });
540  try {
541    await directory.removeEntry(fileName);
542  } catch (error) {
543    if (error?.name !== "NotFoundError") throw error;
544  }
545  state.fileIndex.delete(path);
546}
547
548async function rollbackChanges(backups) {
549  const entries = [...backups.entries()].reverse();
550  for (const [path, backup] of entries) {
551    if (backup.existed) await writeFile(path, backup.bytes);
552    else await deleteFile(path);
553  }
554}
555
556async function persistChanges(payload = {}) {
557  const changes = Array.isArray(payload.changes) ? payload.changes : [];
558  if (changes.length === 0) return { fileContent: payload.fileContent, lastModifiedTime: Date.now() };
559  if (changes.length > MAX_CHANGE_COUNT) throw new Error(`一次保存不能超过 ${MAX_CHANGE_COUNT} 个文件`);
560
561  const normalized = changes.map((change) => {
562    const path = assertWritableChangePath(state.manifestDirectory, change.path);
563    const operation = change.operate === "delete" ? "delete" : "put";
564    const content = operation === "put" ? String(change.content ?? "") : null;
565    if (content && new Blob([content]).size > MAX_TEXT_BYTES) throw new Error(`保存内容超过 20 MiB:${path}`);
566    return { path, operation, content };
567  });
568
569  if (state.source === "demo") {
570    for (const change of normalized) {
571      if (change.operation === "delete") state.memoryFiles.delete(change.path);
572      else state.memoryFiles.set(change.path, change.content);
573    }
574    setSaveState("刚刚保存到内存", "memory");
575    addActivity(`已在内存保存 ${normalized.length} 项变更`, "success");
576    return { fileContent: payload.fileContent, lastModifiedTime: Date.now() };
577  }
578
579  if (state.readOnlyFallback || !state.directoryHandle) {
580    throw new Error("当前是只读兼容模式,请用支持文件夹授权的 Chromium 浏览器打开");
581  }
582
583  const permission = await state.directoryHandle.queryPermission({ mode: "readwrite" });
584  if (permission !== "granted") throw new Error("项目文件夹的写入权限已经失效");
585
586  setSaveState("正在保存…", "saving");
587  const backups = new Map();
588  try {
589    for (const change of normalized) backups.set(change.path, await readBackup(change.path));
590    for (const change of normalized) {
591      if (change.operation === "delete") await deleteFile(change.path);
592      else await writeFile(change.path, change.content);
593    }
594  } catch (error) {
595    await rollbackChanges(backups).catch((rollbackError) => {
596      addActivity(`回滚未完全成功:${rollbackError.message}`, "error");
597    });
598    throw error;
599  }
600
601  setSaveState("刚刚已保存", "saved");
602  addActivity(`已保存 ${normalized.length} 个文件变更`, "success");
603  return { fileContent: payload.fileContent, lastModifiedTime: Date.now() };
604}
§ 02 · FLOW

关键流程

showDirectoryPicker ↓ query/request readwrite permission indexDirectory → chooseManifest → loadManifest ↓ setPPTD to iframe onSave → persistChanges → backup → write/delete → rollback on failure

这条流转从 lib/editor-app.js:410 的 pickDirectory 开始,经 lib/editor-app.js:364 申请授权,最后在 lib/editor-app.js:237 调用 remote.setPPTD。远端保存回调不会直接碰文件,而是进入 lib/editor-app.js:606 的 queue,再落到 lib/editor-app.js:556 的事务函数。

§ 03 · EDGE

边界 / 失败模式

  • 路径逃逸由 lib/editor-lib.js:14、lib/editor-lib.js:21 和 lib/editor-lib.js:25 拦截,测试在 tests/editor-lib.test.js:28 覆盖。
  • 非 Chromium 或无写权限场景进入只读 fallback,关键分支在 lib/editor-app.js:385 和 lib/editor-app.js:397。
  • RPC 失败时 lib/editor-app.js:695 设置 error 状态,并通过 lib/editor-app.js:697 给出公开资源可能变更的提示。
§ 04 · RELATED

关联模块

PPTD parser
继续看 lib/editor-lib.js:67 与 tests/editor-lib.test.js:21,它们决定 manifest 兼容范围。
导出管线
保存后的 PPTD 进入 scripts/export_pptx.py:196,再由浏览器侧写出 PPTX。