mixdog 0.9.151 → 0.9.153
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSES/browser-import-NOTICE.txt +11 -0
- package/NOTICE.md +11 -0
- package/README.md +108 -68
- package/package.json +17 -4
- package/scripts/computer-task-cost-report.mjs +144 -0
- package/scripts/run-office-live-tests.mjs +32 -0
- package/scripts/tool-stress.mjs +0 -2
- package/src/agents/front-worker/AGENT.md +13 -0
- package/src/agents/front-worker/agent.json +6 -0
- package/src/agents/heavy-worker/AGENT.md +10 -11
- package/src/agents/heavy-worker/agent.json +1 -1
- package/src/agents/maintainer/AGENT.md +0 -2
- package/src/agents/reviewer/AGENT.md +9 -13
- package/src/agents/reviewer/agent.json +1 -1
- package/src/agents/security/AGENT.md +14 -0
- package/src/agents/security/agent.json +6 -0
- package/src/agents/worker/AGENT.md +8 -8
- package/src/agents/worker/agent.json +1 -1
- package/src/agents/writer/AGENT.md +14 -0
- package/src/agents/writer/agent.json +6 -0
- package/src/defaults/skills/setup/SKILL.md +18 -27
- package/src/headless-exec.mjs +3 -10
- package/src/headless-exec.test.mjs +2 -7
- package/src/rules/agent/00-common.md +4 -7
- package/src/rules/lead/01-general.md +0 -3
- package/src/rules/shared/10-tool-workflow.md +10 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +183 -26
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.test.mjs +378 -0
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +4 -0
- package/src/runtime/agent/orchestrator/cache-break-trace.mjs +109 -0
- package/src/runtime/agent/orchestrator/cache-break-trace.test.mjs +115 -0
- package/src/runtime/agent/orchestrator/config.mjs +13 -68
- package/src/runtime/agent/orchestrator/context/collect-skills.test.mjs +153 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +49 -45
- package/src/runtime/agent/orchestrator/mcp/client.mjs +242 -15
- package/src/runtime/agent/orchestrator/mcp/features.test.mjs +66 -0
- package/src/runtime/agent/orchestrator/mcp/security.test.mjs +36 -20
- package/src/runtime/agent/orchestrator/providers/admission-scheduler-metrics.test.mjs +47 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +16 -3
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +54 -33
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +3 -8
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +19 -3
- package/src/runtime/agent/orchestrator/providers/lib/provider-replay.mjs +50 -0
- package/src/runtime/agent/orchestrator/providers/lib/sse-framing.mjs +93 -0
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +30 -9
- package/src/runtime/agent/orchestrator/providers/media-parity.test.mjs +59 -0
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +3 -3
- package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +71 -0
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +18 -26
- package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +8 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +25 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +47 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +103 -12
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +13 -0
- package/src/runtime/agent/orchestrator/providers/openai-turn-state.mjs +68 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta-parity.test.mjs +122 -6
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +36 -11
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +144 -93
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +18 -13
- package/src/runtime/agent/orchestrator/providers/provider-replay.test.mjs +288 -0
- package/src/runtime/agent/orchestrator/providers/sse-framing.test.mjs +719 -0
- package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +399 -39
- package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +86 -7
- package/src/runtime/agent/orchestrator/session/agent-loop.test.mjs +222 -0
- package/src/runtime/agent/orchestrator/session/browser-snapshot-supersession.mjs +61 -0
- package/src/runtime/agent/orchestrator/session/browser-snapshot-supersession.test.mjs +83 -0
- package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +26 -2
- package/src/runtime/agent/orchestrator/session/compact-policy.test.mjs +112 -0
- package/src/runtime/agent/orchestrator/session/compaction-read-reset.test.mjs +192 -0
- package/src/runtime/agent/orchestrator/session/context-usage-restart.test.mjs +72 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +100 -15
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +17 -1
- package/src/runtime/agent/orchestrator/session/eager-dispatch.test.mjs +74 -0
- package/src/runtime/agent/orchestrator/session/image-strip-recovery.mjs +12 -8
- package/src/runtime/agent/orchestrator/session/image-strip-recovery.test.mjs +20 -1
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +110 -2
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +3 -16
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.test.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -1
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +111 -39
- package/src/runtime/agent/orchestrator/session/manager/ask-session.test.mjs +46 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +30 -6
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +27 -3
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +26 -54
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.test.mjs +63 -1
- package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -110
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +580 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.test.mjs +593 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +119 -190
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +115 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +43 -8
- package/src/runtime/agent/orchestrator/session/provider-prefix-guard.mjs +129 -12
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +9 -2
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +8 -1
- package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +15 -1
- package/src/runtime/agent/orchestrator/session/store-live-publication.test.mjs +83 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +36 -2
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +157 -30
- package/src/runtime/agent/orchestrator/session/store-summary-reader.test.mjs +64 -0
- package/src/runtime/agent/orchestrator/session/store-summary-visibility.mjs +57 -0
- package/src/runtime/agent/orchestrator/session/store-summary-visibility.test.mjs +219 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +26 -2
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +22 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool-cwd-env.test.mjs +14 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +19 -10
- package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +30 -14
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +15 -4
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +73 -15
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +76 -2
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +24 -683
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +64 -72
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +2 -5
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.mjs +75 -0
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.test.mjs +24 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +6 -6
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +12 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +24 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.test.mjs +44 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +12 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +35 -21
- package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +28 -13
- package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.test.mjs +54 -0
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +12 -0
- package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
- package/src/runtime/attachments/pdf-extract.mjs +3 -0
- package/src/runtime/attachments/pdfjs-runtime.mjs +13 -0
- package/src/runtime/bridge-clients.test.mjs +827 -0
- package/src/runtime/browser-bridge/action-schema.mjs +251 -0
- package/src/runtime/browser-bridge/client.mjs +152 -0
- package/src/runtime/browser-bridge/tool-defs.mjs +159 -0
- package/src/runtime/computer-bridge/action-schema.mjs +653 -0
- package/src/runtime/computer-bridge/client.mjs +231 -0
- package/src/runtime/computer-bridge/tool-defs.mjs +33 -0
- package/src/runtime/media/store.mjs +18 -8
- package/src/runtime/media/store.test.mjs +14 -0
- package/src/runtime/memory/index.mjs +1 -1
- package/src/runtime/memory/lib/transcript-ingest.mjs +8 -6
- package/src/runtime/memory/lib/transcript-ingest.test.mjs +52 -0
- package/src/runtime/office/assurance-benchmark.mjs +358 -0
- package/src/runtime/office/assurance.mjs +833 -0
- package/src/runtime/office/benchmark.mjs +496 -0
- package/src/runtime/office/capabilities.mjs +551 -0
- package/src/runtime/office/com-adapter.mjs +283 -0
- package/src/runtime/office/content-model.mjs +183 -0
- package/src/runtime/office/contract-benchmark.mjs +105 -0
- package/src/runtime/office/design-library.mjs +1562 -0
- package/src/runtime/office/design-system.mjs +1966 -0
- package/src/runtime/office/index.mjs +2413 -0
- package/src/runtime/office/journal.mjs +82 -0
- package/src/runtime/office/office-assurance.test.mjs +371 -0
- package/src/runtime/office/office-com-host.ps1 +3781 -0
- package/src/runtime/office/office-com-session-host.ps1 +855 -0
- package/src/runtime/office/office-live-runtime.test.mjs +819 -0
- package/src/runtime/office/office-runtime.test.mjs +2070 -0
- package/src/runtime/office/ooxml-validator.mjs +271 -0
- package/src/runtime/office/pagination.mjs +132 -0
- package/src/runtime/office/pdf-adapter.mjs +591 -0
- package/src/runtime/office/pdf-analysis.mjs +372 -0
- package/src/runtime/office/pdf-render.mjs +167 -0
- package/src/runtime/office/pdf-security.mjs +63 -0
- package/src/runtime/office/polish-benchmark.mjs +190 -0
- package/src/runtime/office/portable-ooxml.mjs +1867 -0
- package/src/runtime/office/quality-live-benchmark.mjs +486 -0
- package/src/runtime/office/quality-pipeline.mjs +136 -0
- package/src/runtime/office/tabular.mjs +302 -0
- package/src/runtime/office/templates/mixdog-executive.pptx +0 -0
- package/src/runtime/office/templates/mixdog-executive.pptx.mixdog.json +190 -0
- package/src/runtime/office/tool-defs.mjs +113 -0
- package/src/runtime/office/visual-diff.mjs +89 -0
- package/src/runtime/office/xlsx-assertions.mjs +172 -0
- package/src/runtime/office/xlsx-contract.mjs +147 -0
- package/src/runtime/shared/agent-route-config.mjs +9 -0
- package/src/runtime/shared/child-spawn-gate.mjs +17 -0
- package/src/runtime/shared/child-spawn-remote.mjs +69 -7
- package/src/runtime/shared/child-spawn-remote.test.mjs +124 -0
- package/src/runtime/shared/pristine-execution-contract.json +2 -1
- package/src/runtime/shared/provider-api-key.mjs +1 -0
- package/src/runtime/shared/session-runtime-health.mjs +91 -0
- package/src/runtime/shared/session-runtime-health.test.mjs +53 -0
- package/src/runtime/shared/skill-document.mjs +86 -0
- package/src/runtime/shared/skill-document.test.mjs +81 -0
- package/src/runtime/shared/tool-execution-contract.mjs +16 -0
- package/src/runtime/shared/tool-surface.mjs +27 -0
- package/src/runtime/shared/turn-snapshot-store.mjs +1 -0
- package/src/runtime/shared/turn-snapshot.mjs +32 -9
- package/src/runtime/shared/user-cwd.mjs +33 -14
- package/src/runtime/shared/user-cwd.test.mjs +43 -0
- package/src/session-runtime/agent-disable.test.mjs +20 -1
- package/src/session-runtime/context-status.mjs +9 -3
- package/src/session-runtime/cwd-plugins.mjs +26 -26
- package/src/session-runtime/cwd-plugins.test.mjs +75 -0
- package/src/session-runtime/cwd-tool-routing.test.mjs +5 -0
- package/src/session-runtime/deferred-tool-delta.mjs +104 -0
- package/src/session-runtime/deferred-tool-delta.test.mjs +40 -0
- package/src/session-runtime/global-extensions.mjs +26 -0
- package/src/session-runtime/global-extensions.test.mjs +28 -0
- package/src/session-runtime/global-settings-session-identity.test.mjs +76 -0
- package/src/session-runtime/goal-runtime.mjs +1141 -0
- package/src/session-runtime/goal-runtime.test.mjs +645 -0
- package/src/session-runtime/lifecycle-api.mjs +58 -11
- package/src/session-runtime/lifecycle-api.test.mjs +183 -0
- package/src/session-runtime/mcp-glue.mjs +49 -47
- package/src/session-runtime/plugin-mcp-standard.test.mjs +177 -0
- package/src/session-runtime/plugin-mcp.mjs +96 -22
- package/src/session-runtime/prewarm.mjs +1 -10
- package/src/session-runtime/provider-request-snapshot.mjs +33 -9
- package/src/session-runtime/provider-request-snapshot.test.mjs +27 -5
- package/src/session-runtime/resource-api-global.test.mjs +109 -0
- package/src/session-runtime/resource-api.mjs +168 -116
- package/src/session-runtime/runtime-core.mjs +208 -29
- package/src/session-runtime/runtime-tunables.mjs +6 -8
- package/src/session-runtime/session-lifecycle.mjs +34 -12
- package/src/session-runtime/session-title.mjs +2 -2
- package/src/session-runtime/session-turn-api.mjs +37 -25
- package/src/session-runtime/skills-api.mjs +79 -25
- package/src/session-runtime/skills-api.test.mjs +99 -0
- package/src/session-runtime/tool-catalog-data.mjs +1 -1
- package/src/session-runtime/tool-catalog-schema.mjs +0 -5
- package/src/session-runtime/tool-catalog.mjs +42 -112
- package/src/session-runtime/tool-defs.mjs +6 -1
- package/src/session-runtime/tool-policy-surface.test.mjs +93 -17
- package/src/session-runtime/tool-surface.mjs +30 -8
- package/src/session-runtime/workflow-agents-api.mjs +8 -25
- package/src/session-runtime/workflow.mjs +8 -6
- package/src/standalone/agent-dispatch-broker.mjs +5 -9
- package/src/standalone/agent-tool/helpers.mjs +2 -2
- package/src/standalone/agent-tool/job-views.mjs +3 -1
- package/src/standalone/agent-tool/notify.mjs +11 -9
- package/src/standalone/agent-tool/notify.test.mjs +37 -0
- package/src/standalone/agent-tool/render.mjs +3 -0
- package/src/standalone/agent-tool/spawn-flow.mjs +61 -16
- package/src/standalone/agent-tool/spawn-flow.test.mjs +41 -0
- package/src/standalone/agent-tool/tool-def.mjs +1 -1
- package/src/standalone/agent-tool/worker-index.mjs +123 -0
- package/src/standalone/agent-tool/worker-index.test.mjs +132 -0
- package/src/standalone/agent-tool/worker-rows.mjs +2 -1
- package/src/standalone/agent-tool.mjs +57 -10
- package/src/standalone/daemon.mjs +113 -15
- package/src/standalone/hook-bus/config.mjs +1 -0
- package/src/standalone/plugin-admin.mjs +17 -0
- package/src/standalone/plugin-admin.test.mjs +7 -0
- package/src/standalone/provider-admin.mjs +1 -0
- package/src/standalone/session-protocol.mjs +5 -0
- package/src/standalone/session-runtime-agent-control-client.mjs +133 -0
- package/src/standalone/session-runtime-agent-control-client.test.mjs +71 -0
- package/src/standalone/session-runtime-dispatch-cancel.test.mjs +139 -0
- package/src/standalone/session-runtime-host-factory.mjs +17 -0
- package/src/standalone/session-runtime-host-factory.test.mjs +31 -0
- package/src/standalone/session-runtime-host-health.test.mjs +5 -0
- package/src/standalone/session-runtime-host.mjs +709 -58
- package/src/standalone/session-runtime-inline-host.mjs +258 -0
- package/src/standalone/session-runtime-inline-host.test.mjs +385 -0
- package/src/standalone/session-runtime-provider-cooldown.mjs +78 -0
- package/src/standalone/session-runtime-provider-cooldown.test.mjs +80 -0
- package/src/standalone/session-runtime-shard-host.test.mjs +642 -0
- package/src/standalone/session-runtime-shard-router.mjs +149 -0
- package/src/standalone/session-runtime-shard-router.test.mjs +101 -0
- package/src/standalone/session-runtime-worker.mjs +302 -26
- package/src/standalone/session-service-agent-tree.test.mjs +984 -0
- package/src/standalone/session-service-goal-recovery.test.mjs +177 -0
- package/src/standalone/session-service.mjs +711 -11
- package/src/tui/app/app-view.jsx +1 -0
- package/src/tui/app/core-memory-picker.mjs +2 -0
- package/src/tui/app/extension-pickers.mjs +1 -1
- package/src/tui/app/maintenance-pickers.mjs +3 -3
- package/src/tui/app/model-options.mjs +2 -0
- package/src/tui/app/model-picker.mjs +14 -4
- package/src/tui/app/panel-handoff.test.mjs +311 -0
- package/src/tui/app/project-picker.mjs +1 -0
- package/src/tui/app/route-pickers.mjs +21 -6
- package/src/tui/app/settings-picker.mjs +26 -3
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +73 -22
- package/src/tui/app/theme-effort-pickers.mjs +7 -2
- package/src/tui/components/Picker.jsx +10 -1
- package/src/tui/dist/index.mjs +187 -45
- package/src/tui/lib/voice-setup.mjs +4 -1
- package/src/tui/session/agent-message-source.test.mjs +37 -0
- package/src/tui/session/completion-card-restore.test.mjs +50 -0
- package/src/tui/session/context-state.mjs +3 -13
- package/src/tui/session/goal-continuation.mjs +141 -0
- package/src/tui/session/goal-continuation.test.mjs +203 -0
- package/src/tui/session/queue-helpers.mjs +5 -2
- package/src/tui/session/session-action-surface.test.mjs +190 -0
- package/src/tui/session/session-api-ext.mjs +48 -8
- package/src/tui/session/session-api.mjs +73 -1
- package/src/tui/session/session-flow.mjs +31 -1
- package/src/tui/session/turn.mjs +88 -15
- package/src/tui/session-local.mjs +39 -3
- package/src/vendor/statusline/src/gateway/route-meta.mjs +31 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +0 -301
|
@@ -0,0 +1,1562 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createHash,
|
|
3
|
+
createPublicKey,
|
|
4
|
+
randomUUID,
|
|
5
|
+
verify,
|
|
6
|
+
} from 'node:crypto';
|
|
7
|
+
import {
|
|
8
|
+
mkdir,
|
|
9
|
+
readFile,
|
|
10
|
+
readdir,
|
|
11
|
+
rename,
|
|
12
|
+
rm,
|
|
13
|
+
stat,
|
|
14
|
+
writeFile,
|
|
15
|
+
} from 'node:fs/promises';
|
|
16
|
+
import { delimiter } from 'node:path';
|
|
17
|
+
import {
|
|
18
|
+
dirname,
|
|
19
|
+
extname,
|
|
20
|
+
isAbsolute,
|
|
21
|
+
join,
|
|
22
|
+
resolve,
|
|
23
|
+
} from 'node:path';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import JSZip from 'jszip';
|
|
27
|
+
|
|
28
|
+
const SCHEMA_VERSION = 1;
|
|
29
|
+
const TEMPLATE_INSPECTOR_VERSION = 3;
|
|
30
|
+
const DEFAULT_CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
|
31
|
+
const MAX_MANIFEST_BYTES = 2 * 1024 * 1024;
|
|
32
|
+
const MAX_TEMPLATE_BYTES = 64 * 1024 * 1024;
|
|
33
|
+
const MAX_TEMPLATE_COUNT = 200;
|
|
34
|
+
const MAX_LOCAL_TEMPLATE_COUNT = 5_000;
|
|
35
|
+
const MAX_SCAN_DEPTH = 8;
|
|
36
|
+
const FORMATS = new Set(['docx', 'xlsx', 'pptx', 'pdf', 'csv', 'tsv']);
|
|
37
|
+
const TEMPLATE_FORMATS = Object.freeze({
|
|
38
|
+
'.docx': 'docx',
|
|
39
|
+
'.dotx': 'docx',
|
|
40
|
+
'.docm': 'docx',
|
|
41
|
+
'.dotm': 'docx',
|
|
42
|
+
'.xlsx': 'xlsx',
|
|
43
|
+
'.xltx': 'xlsx',
|
|
44
|
+
'.xlsm': 'xlsx',
|
|
45
|
+
'.xltm': 'xlsx',
|
|
46
|
+
'.pptx': 'pptx',
|
|
47
|
+
'.potx': 'pptx',
|
|
48
|
+
'.pptm': 'pptx',
|
|
49
|
+
'.potm': 'pptx',
|
|
50
|
+
});
|
|
51
|
+
const ALLOWED_LAYOUT_DEFAULTS = new Set([
|
|
52
|
+
'background',
|
|
53
|
+
'backgroundRole',
|
|
54
|
+
'slideRole',
|
|
55
|
+
'titleSize',
|
|
56
|
+
'layout',
|
|
57
|
+
'create',
|
|
58
|
+
'visualPlacement',
|
|
59
|
+
'density',
|
|
60
|
+
'variant',
|
|
61
|
+
]);
|
|
62
|
+
const LAYOUT_SLOT_TYPES = new Set(['text', 'image', 'chart', 'table', 'diagram']);
|
|
63
|
+
|
|
64
|
+
function physicalAsarPath(path) {
|
|
65
|
+
return path.replace(/([\\/][^\\/]+\.asar)([\\/])/i, '$1.unpacked$2');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const BUNDLED_TEMPLATE_DIRECTORY = physicalAsarPath(
|
|
69
|
+
fileURLToPath(new URL('./templates', import.meta.url)),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
function plainObject(value) {
|
|
73
|
+
return value && typeof value === 'object' && !Array.isArray(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function clone(value) {
|
|
77
|
+
return value == null ? value : JSON.parse(JSON.stringify(value));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function safeId(value, label = 'id') {
|
|
81
|
+
const normalized = String(value || '').trim().toLowerCase();
|
|
82
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(normalized)) {
|
|
83
|
+
throw new Error(`Office design ${label} must use 1-64 lowercase letters, digits, dots, underscores, or hyphens`);
|
|
84
|
+
}
|
|
85
|
+
return normalized;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function canonicalPath(path) {
|
|
89
|
+
const normalized = resolve(path);
|
|
90
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sha256(value) {
|
|
94
|
+
return createHash('sha256').update(value).digest('hex');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function sha256File(path) {
|
|
98
|
+
return sha256(await readFile(path));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function stableValue(value) {
|
|
102
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
103
|
+
if (!plainObject(value)) return value;
|
|
104
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function canonicalOfficeDesignPack(pack) {
|
|
108
|
+
return JSON.stringify(stableValue(pack));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseVersion(value) {
|
|
112
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(value || '').trim());
|
|
113
|
+
if (!match) return null;
|
|
114
|
+
return {
|
|
115
|
+
parts: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
116
|
+
prerelease: match[4] || '',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function compareOfficeDesignVersions(left, right) {
|
|
121
|
+
const a = parseVersion(left);
|
|
122
|
+
const b = parseVersion(right);
|
|
123
|
+
if (!a || !b) throw new Error(`Office design pack versions must be semantic versions: ${left}, ${right}`);
|
|
124
|
+
for (let index = 0; index < 3; index += 1) {
|
|
125
|
+
if (a.parts[index] !== b.parts[index]) return a.parts[index] > b.parts[index] ? 1 : -1;
|
|
126
|
+
}
|
|
127
|
+
if (a.prerelease && !b.prerelease) return -1;
|
|
128
|
+
if (!a.prerelease && b.prerelease) return 1;
|
|
129
|
+
if (a.prerelease === b.prerelease) return 0;
|
|
130
|
+
return a.prerelease > b.prerelease ? 1 : -1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function libraryPaths(dataDir) {
|
|
134
|
+
const root = join(resolve(dataDir), 'office', 'design-library');
|
|
135
|
+
return {
|
|
136
|
+
root,
|
|
137
|
+
config: join(root, 'config.json'),
|
|
138
|
+
state: join(root, 'state.json'),
|
|
139
|
+
packs: join(root, 'packs'),
|
|
140
|
+
templates: join(root, 'templates'),
|
|
141
|
+
templateIndex: join(root, 'template-index.json'),
|
|
142
|
+
bindings: join(root, 'bindings'),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function readJson(path, fallback = null) {
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
149
|
+
return plainObject(parsed) ? parsed : fallback;
|
|
150
|
+
} catch {
|
|
151
|
+
return fallback;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function writeJsonAtomic(path, value) {
|
|
156
|
+
await mkdir(dirname(path), { recursive: true });
|
|
157
|
+
const temporary = join(dirname(path), `.${randomUUID()}.tmp`);
|
|
158
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, {
|
|
159
|
+
encoding: 'utf8',
|
|
160
|
+
mode: 0o600,
|
|
161
|
+
});
|
|
162
|
+
try {
|
|
163
|
+
await rename(temporary, path);
|
|
164
|
+
} catch {
|
|
165
|
+
await rm(path, { force: true });
|
|
166
|
+
await rename(temporary, path);
|
|
167
|
+
} finally {
|
|
168
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function resolveConfigPath(path, base) {
|
|
173
|
+
const value = String(path || '').trim();
|
|
174
|
+
if (!value) return '';
|
|
175
|
+
return isAbsolute(value) ? resolve(value) : resolve(base, value);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function loadConfig(dataDir, override = null) {
|
|
179
|
+
const paths = libraryPaths(dataDir);
|
|
180
|
+
const configPath = resolveConfigPath(
|
|
181
|
+
process.env.MIXDOG_OFFICE_DESIGN_LIBRARY_CONFIG || paths.config,
|
|
182
|
+
process.cwd(),
|
|
183
|
+
);
|
|
184
|
+
const source = plainObject(override) ? override : await readJson(configPath, {});
|
|
185
|
+
const base = dirname(configPath);
|
|
186
|
+
const trustedKeys = plainObject(source.trustedKeys) ? { ...source.trustedKeys } : {};
|
|
187
|
+
const environmentKey = String(process.env.MIXDOG_OFFICE_DESIGN_PACK_PUBLIC_KEY || '').replaceAll('\\n', '\n').trim();
|
|
188
|
+
if (environmentKey) {
|
|
189
|
+
trustedKeys[String(process.env.MIXDOG_OFFICE_DESIGN_PACK_KEY_ID || 'environment')] = environmentKey;
|
|
190
|
+
}
|
|
191
|
+
const environmentDirectories = String(process.env.MIXDOG_OFFICE_TEMPLATE_DIRS || '').trim();
|
|
192
|
+
const hasExplicitDirectories = Array.isArray(source.templateDirectories) || Boolean(environmentDirectories);
|
|
193
|
+
const configuredDirectories = Array.isArray(source.templateDirectories)
|
|
194
|
+
? source.templateDirectories
|
|
195
|
+
: environmentDirectories.split(delimiter);
|
|
196
|
+
const discoverInstalledTemplates = source.discoverInstalledTemplates == null
|
|
197
|
+
? !hasExplicitDirectories
|
|
198
|
+
: source.discoverInstalledTemplates !== false;
|
|
199
|
+
const templateDirectories = [
|
|
200
|
+
paths.templates,
|
|
201
|
+
...(discoverInstalledTemplates ? defaultOfficeTemplateDirectories() : []),
|
|
202
|
+
...configuredDirectories.map((entry) => resolveConfigPath(entry, base)).filter(Boolean),
|
|
203
|
+
].filter((entry, index, values) => values.indexOf(entry) === index);
|
|
204
|
+
const defaultTemplates = plainObject(source.defaultTemplates) ? { ...source.defaultTemplates } : {};
|
|
205
|
+
if (discoverInstalledTemplates && !defaultTemplates.pptx) defaultTemplates.pptx = 'mixdog-executive';
|
|
206
|
+
return {
|
|
207
|
+
manifestUrl: String(process.env.MIXDOG_OFFICE_DESIGN_PACK_URL || source.manifestUrl || '').trim(),
|
|
208
|
+
trustedKeys,
|
|
209
|
+
packId: source.packId ? safeId(source.packId, 'config packId') : '',
|
|
210
|
+
channel: String(source.channel || 'stable').trim().toLowerCase(),
|
|
211
|
+
checkIntervalMs: Math.max(
|
|
212
|
+
10_000,
|
|
213
|
+
Math.min(24 * 60 * 60 * 1000, Number(source.checkIntervalMs) || DEFAULT_CHECK_INTERVAL_MS),
|
|
214
|
+
),
|
|
215
|
+
templateDirectories,
|
|
216
|
+
discoverInstalledTemplates,
|
|
217
|
+
defaultTemplates,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function defaultOfficeTemplateDirectories({
|
|
222
|
+
platform = process.platform,
|
|
223
|
+
environment = process.env,
|
|
224
|
+
home = homedir(),
|
|
225
|
+
} = {}) {
|
|
226
|
+
const directories = [BUNDLED_TEMPLATE_DIRECTORY];
|
|
227
|
+
if (platform === 'win32') {
|
|
228
|
+
const programFiles = String(environment.ProgramFiles || environment.PROGRAMFILES || '').trim();
|
|
229
|
+
const programFilesX86 = String(environment['ProgramFiles(x86)'] || environment.PROGRAMFILES_X86 || '').trim();
|
|
230
|
+
const appData = String(environment.APPDATA || '').trim();
|
|
231
|
+
for (const root of [programFiles, programFilesX86].filter(Boolean)) {
|
|
232
|
+
directories.push(join(root, 'Microsoft Office', 'root', 'Templates'));
|
|
233
|
+
}
|
|
234
|
+
if (appData) directories.push(join(appData, 'Microsoft', 'Templates'));
|
|
235
|
+
if (home) directories.push(join(home, 'Documents', 'Custom Office Templates'));
|
|
236
|
+
}
|
|
237
|
+
return directories
|
|
238
|
+
.map((entry) => resolve(entry))
|
|
239
|
+
.filter((entry, index, values) => values.indexOf(entry) === index);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function normalizeProfiles(value) {
|
|
243
|
+
if (!plainObject(value)) return {};
|
|
244
|
+
const entries = Object.entries(value);
|
|
245
|
+
if (entries.length > 32) throw new Error('Office design pack has too many profiles');
|
|
246
|
+
return Object.fromEntries(entries.map(([id, profile]) => {
|
|
247
|
+
const normalizedId = safeId(id, 'profile id');
|
|
248
|
+
if (!plainObject(profile)) throw new Error(`Office design profile ${normalizedId} must be an object`);
|
|
249
|
+
if (JSON.stringify(profile).length > 64 * 1024) throw new Error(`Office design profile ${normalizedId} is too large`);
|
|
250
|
+
if (profile.extends) safeId(profile.extends, 'profile extends');
|
|
251
|
+
for (const format of Object.keys(profile.formats || {})) {
|
|
252
|
+
if (!FORMATS.has(format)) throw new Error(`Office design profile ${normalizedId} has unsupported format ${format}`);
|
|
253
|
+
}
|
|
254
|
+
return [normalizedId, clone(profile)];
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function normalizeLayoutSlots(value) {
|
|
259
|
+
if (value == null) return [];
|
|
260
|
+
if (!Array.isArray(value) || value.length > 128) {
|
|
261
|
+
throw new Error('Office design layout slots must be an array of at most 128 entries');
|
|
262
|
+
}
|
|
263
|
+
return value.map((slot) => {
|
|
264
|
+
if (!plainObject(slot)) throw new Error('Office design layout slots must be objects');
|
|
265
|
+
const role = safeId(slot.role, 'layout slot role');
|
|
266
|
+
const type = String(slot.type || 'text').trim().toLowerCase();
|
|
267
|
+
if (!LAYOUT_SLOT_TYPES.has(type)) throw new Error(`Office design layout slot ${role} has unsupported type ${type}`);
|
|
268
|
+
const shape = Number(slot.shape);
|
|
269
|
+
if (!Number.isInteger(shape) || shape < 1) throw new Error(`Office design layout slot ${role} requires a positive shape index`);
|
|
270
|
+
return {
|
|
271
|
+
role,
|
|
272
|
+
type,
|
|
273
|
+
shape,
|
|
274
|
+
...(slot.placeholderType ? { placeholderType: String(slot.placeholderType) } : {}),
|
|
275
|
+
...(Number.isInteger(Number(slot.placeholderIndex)) ? { placeholderIndex: Number(slot.placeholderIndex) } : {}),
|
|
276
|
+
...(plainObject(slot.geometry) ? { geometry: clone(slot.geometry) } : {}),
|
|
277
|
+
required: slot.required === true,
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function normalizeLayoutCapacity(value) {
|
|
283
|
+
if (!plainObject(value)) return {};
|
|
284
|
+
const bounded = (name, maximum = 1_000_000_000) => {
|
|
285
|
+
const number = Number(value[name]);
|
|
286
|
+
return Number.isFinite(number) && number >= 0 ? Math.min(maximum, number) : 0;
|
|
287
|
+
};
|
|
288
|
+
return {
|
|
289
|
+
sampleTextChars: bounded('sampleTextChars', 1_000_000),
|
|
290
|
+
shapeCount: bounded('shapeCount', 10_000),
|
|
291
|
+
textSlots: bounded('textSlots', 1_000),
|
|
292
|
+
metricGroups: bounded('metricGroups', 100),
|
|
293
|
+
columnGroups: bounded('columnGroups', 100),
|
|
294
|
+
stepGroups: bounded('stepGroups', 100),
|
|
295
|
+
textArea: bounded('textArea'),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function normalizeLayouts(value, { templatePath = '' } = {}) {
|
|
300
|
+
if (value == null) return [];
|
|
301
|
+
if (!Array.isArray(value) || value.length > 1_000) throw new Error('Office design layouts must be an array of at most 1000 entries');
|
|
302
|
+
return value.map((layout) => {
|
|
303
|
+
if (!plainObject(layout)) throw new Error('Office design layout entries must be objects');
|
|
304
|
+
const id = safeId(layout.id, 'layout id');
|
|
305
|
+
const format = String(layout.format || 'pptx').toLowerCase();
|
|
306
|
+
if (!FORMATS.has(format)) throw new Error(`Office design layout ${id} has unsupported format ${format}`);
|
|
307
|
+
const kind = String(layout.kind || '').trim().toLowerCase();
|
|
308
|
+
if (!kind) throw new Error(`Office design layout ${id} requires kind`);
|
|
309
|
+
const defaults = plainObject(layout.defaults) ? clone(layout.defaults) : {};
|
|
310
|
+
const unknownDefaults = Object.keys(defaults).filter((key) => !ALLOWED_LAYOUT_DEFAULTS.has(key));
|
|
311
|
+
if (unknownDefaults.length) {
|
|
312
|
+
throw new Error(`Office design layout ${id} has unsupported default(s): ${unknownDefaults.join(', ')}`);
|
|
313
|
+
}
|
|
314
|
+
const sourceSlide = Number(layout.sourceSlide || 0);
|
|
315
|
+
if (sourceSlide && (!Number.isInteger(sourceSlide) || sourceSlide < 1)) {
|
|
316
|
+
throw new Error(`Office design layout ${id} has invalid sourceSlide`);
|
|
317
|
+
}
|
|
318
|
+
const sourceLayout = Number(layout.sourceLayout || 0);
|
|
319
|
+
if (sourceLayout && (!Number.isInteger(sourceLayout) || sourceLayout < 1)) {
|
|
320
|
+
throw new Error(`Office design layout ${id} has invalid sourceLayout`);
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
id,
|
|
324
|
+
format,
|
|
325
|
+
kind,
|
|
326
|
+
profile: layout.profile ? safeId(layout.profile, 'layout profile') : '',
|
|
327
|
+
density: String(layout.density || '').toLowerCase(),
|
|
328
|
+
variant: String(layout.variant || '').trim().toLowerCase(),
|
|
329
|
+
templateId: layout.templateId ? safeId(layout.templateId, 'layout templateId') : '',
|
|
330
|
+
templatePath: templatePath || String(layout.templatePath || ''),
|
|
331
|
+
sourceSlide,
|
|
332
|
+
sourceLayout,
|
|
333
|
+
slots: normalizeLayoutSlots(layout.slots),
|
|
334
|
+
capacity: normalizeLayoutCapacity(layout.capacity),
|
|
335
|
+
capabilities: [...new Set((Array.isArray(layout.capabilities) ? layout.capabilities : [])
|
|
336
|
+
.map((entry) => String(entry || '').trim().toLowerCase())
|
|
337
|
+
.filter(Boolean))].slice(0, 32),
|
|
338
|
+
priority: Math.max(-100, Math.min(100, Number(layout.priority) || 0)),
|
|
339
|
+
strict: layout.strict === true,
|
|
340
|
+
defaults,
|
|
341
|
+
};
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function normalizeLocalSamples(value) {
|
|
346
|
+
if (value == null) return [];
|
|
347
|
+
if (!Array.isArray(value) || value.length > 1_000) {
|
|
348
|
+
throw new Error('Office local template samples must be an array of at most 1000 entries');
|
|
349
|
+
}
|
|
350
|
+
return value.map((sample) => {
|
|
351
|
+
if (!plainObject(sample)) throw new Error('Office local template samples must be objects');
|
|
352
|
+
const slide = Number(sample.slide);
|
|
353
|
+
if (!Number.isInteger(slide) || slide < 1) {
|
|
354
|
+
throw new Error('Office local template sample requires a positive slide number');
|
|
355
|
+
}
|
|
356
|
+
const roles = plainObject(sample.roles)
|
|
357
|
+
? Object.fromEntries(Object.entries(sample.roles).map(([shape, role]) => {
|
|
358
|
+
const shapeIndex = Number(shape);
|
|
359
|
+
if (!Number.isInteger(shapeIndex) || shapeIndex < 1) {
|
|
360
|
+
throw new Error(`Office local template sample ${slide} has an invalid shape index`);
|
|
361
|
+
}
|
|
362
|
+
return [shapeIndex, safeId(role, `sample ${slide} slot role`)];
|
|
363
|
+
}))
|
|
364
|
+
: {};
|
|
365
|
+
const defaults = plainObject(sample.defaults) ? clone(sample.defaults) : {};
|
|
366
|
+
const unknownDefaults = Object.keys(defaults).filter((key) => !ALLOWED_LAYOUT_DEFAULTS.has(key));
|
|
367
|
+
if (unknownDefaults.length) {
|
|
368
|
+
throw new Error(`Office local template sample ${slide} has unsupported default(s): ${unknownDefaults.join(', ')}`);
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
slide,
|
|
372
|
+
id: sample.id ? safeId(sample.id, `sample ${slide} layout id`) : '',
|
|
373
|
+
kind: String(sample.kind || '').trim().toLowerCase(),
|
|
374
|
+
density: String(sample.density || '').trim().toLowerCase(),
|
|
375
|
+
variant: String(sample.variant || '').trim().toLowerCase(),
|
|
376
|
+
priority: Math.max(-100, Math.min(100, Number(sample.priority) || 0)),
|
|
377
|
+
strict: sample.strict === true,
|
|
378
|
+
roles,
|
|
379
|
+
capacity: normalizeLayoutCapacity(sample.capacity),
|
|
380
|
+
defaults,
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function normalizeRemoteTemplates(value) {
|
|
386
|
+
if (value == null) return [];
|
|
387
|
+
if (!Array.isArray(value) || value.length > MAX_TEMPLATE_COUNT) {
|
|
388
|
+
throw new Error(`Office design pack templates must be an array of at most ${MAX_TEMPLATE_COUNT} entries`);
|
|
389
|
+
}
|
|
390
|
+
const ids = new Set();
|
|
391
|
+
return value.map((template) => {
|
|
392
|
+
if (!plainObject(template)) throw new Error('Office design template entries must be objects');
|
|
393
|
+
const id = safeId(template.id, 'template id');
|
|
394
|
+
if (ids.has(id)) throw new Error(`Office design pack contains duplicate template id ${id}`);
|
|
395
|
+
ids.add(id);
|
|
396
|
+
const format = String(template.format || '').toLowerCase();
|
|
397
|
+
if (!['docx', 'xlsx', 'pptx'].includes(format)) {
|
|
398
|
+
throw new Error(`Office design template ${id} has unsupported format ${format}`);
|
|
399
|
+
}
|
|
400
|
+
const url = new URL(String(template.url || ''));
|
|
401
|
+
if (url.protocol !== 'https:') throw new Error(`Office design template ${id} requires an HTTPS URL`);
|
|
402
|
+
const digest = String(template.sha256 || '').toLowerCase();
|
|
403
|
+
if (!/^[a-f0-9]{64}$/.test(digest)) throw new Error(`Office design template ${id} requires sha256`);
|
|
404
|
+
const bytes = Number(template.bytes);
|
|
405
|
+
if (!Number.isInteger(bytes) || bytes < 1 || bytes > MAX_TEMPLATE_BYTES) {
|
|
406
|
+
throw new Error(`Office design template ${id} has an invalid byte size`);
|
|
407
|
+
}
|
|
408
|
+
const extension = extname(url.pathname).toLowerCase();
|
|
409
|
+
if (TEMPLATE_FORMATS[extension] !== format) {
|
|
410
|
+
throw new Error(`Office design template ${id} URL extension does not match ${format}`);
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
id,
|
|
414
|
+
label: String(template.label || id),
|
|
415
|
+
format,
|
|
416
|
+
url: url.href,
|
|
417
|
+
sha256: digest,
|
|
418
|
+
bytes,
|
|
419
|
+
extension,
|
|
420
|
+
layouts: normalizeLayouts(template.layouts || []),
|
|
421
|
+
};
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function normalizePack(value) {
|
|
426
|
+
if (!plainObject(value)) throw new Error('Office design pack payload must be an object');
|
|
427
|
+
const id = safeId(value.id, 'pack id');
|
|
428
|
+
const version = String(value.version || '').trim();
|
|
429
|
+
if (!parseVersion(version)) throw new Error(`Office design pack ${id} has invalid semantic version ${version}`);
|
|
430
|
+
const channel = String(value.channel || 'stable').trim().toLowerCase();
|
|
431
|
+
const defaultProfiles = plainObject(value.defaultProfiles) ? { ...value.defaultProfiles } : {};
|
|
432
|
+
for (const [format, profile] of Object.entries(defaultProfiles)) {
|
|
433
|
+
if (!FORMATS.has(format)) throw new Error(`Office design pack ${id} has unsupported default format ${format}`);
|
|
434
|
+
defaultProfiles[format] = safeId(profile, 'default profile');
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
schemaVersion: SCHEMA_VERSION,
|
|
438
|
+
id,
|
|
439
|
+
version,
|
|
440
|
+
channel,
|
|
441
|
+
publishedAt: String(value.publishedAt || ''),
|
|
442
|
+
profiles: normalizeProfiles(value.profiles),
|
|
443
|
+
defaultProfiles,
|
|
444
|
+
layouts: normalizeLayouts(value.layouts || []),
|
|
445
|
+
templates: normalizeRemoteTemplates(value.templates || []),
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function verifyOfficeDesignPackEnvelope(envelope, trustedKeys = {}) {
|
|
450
|
+
if (!plainObject(envelope) || Number(envelope.schemaVersion) !== SCHEMA_VERSION) {
|
|
451
|
+
throw new Error(`Office design pack envelope requires schemaVersion ${SCHEMA_VERSION}`);
|
|
452
|
+
}
|
|
453
|
+
const keyId = safeId(envelope.keyId, 'signing key id');
|
|
454
|
+
const publicKeyValue = trustedKeys[keyId];
|
|
455
|
+
if (!publicKeyValue) throw new Error(`Office design pack signing key is not trusted: ${keyId}`);
|
|
456
|
+
const signature = Buffer.from(String(envelope.signature || ''), 'base64');
|
|
457
|
+
if (signature.length !== 64) throw new Error('Office design pack signature is invalid');
|
|
458
|
+
const publicKey = createPublicKey(publicKeyValue);
|
|
459
|
+
if (publicKey.asymmetricKeyType !== 'ed25519') {
|
|
460
|
+
throw new Error(`Office design pack signing key ${keyId} must be Ed25519`);
|
|
461
|
+
}
|
|
462
|
+
const valid = verify(
|
|
463
|
+
null,
|
|
464
|
+
Buffer.from(canonicalOfficeDesignPack(envelope.pack)),
|
|
465
|
+
publicKey,
|
|
466
|
+
signature,
|
|
467
|
+
);
|
|
468
|
+
if (!valid) throw new Error('Office design pack signature verification failed');
|
|
469
|
+
return {
|
|
470
|
+
keyId,
|
|
471
|
+
pack: normalizePack(envelope.pack),
|
|
472
|
+
envelope: clone(envelope),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function responseBytes(response, maximum, label) {
|
|
477
|
+
if (!response?.ok) throw new Error(`${label} download failed with HTTP ${response?.status || 0}`);
|
|
478
|
+
const declared = Number(response.headers?.get?.('content-length') || 0);
|
|
479
|
+
if (declared > maximum) throw new Error(`${label} exceeds the download size limit`);
|
|
480
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
481
|
+
if (bytes.length > maximum) throw new Error(`${label} exceeds the download size limit`);
|
|
482
|
+
return bytes;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function fetchWithTimeout(fetchImpl, url, options = {}, timeoutMs = 10_000) {
|
|
486
|
+
const controller = new AbortController();
|
|
487
|
+
const abort = () => controller.abort();
|
|
488
|
+
options.signal?.addEventListener?.('abort', abort, { once: true });
|
|
489
|
+
const timer = setTimeout(abort, timeoutMs);
|
|
490
|
+
try {
|
|
491
|
+
return await fetchImpl(url, {
|
|
492
|
+
...options,
|
|
493
|
+
signal: controller.signal,
|
|
494
|
+
});
|
|
495
|
+
} finally {
|
|
496
|
+
clearTimeout(timer);
|
|
497
|
+
options.signal?.removeEventListener?.('abort', abort);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function cachedPackRoot(paths, id, version) {
|
|
502
|
+
return join(paths.packs, safeId(id, 'pack id'), String(version));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function materializePack(envelope, verified, paths, fetchImpl, signal) {
|
|
506
|
+
const target = cachedPackRoot(paths, verified.pack.id, verified.pack.version);
|
|
507
|
+
const staging = join(paths.packs, `.staging-${randomUUID()}`);
|
|
508
|
+
await mkdir(join(staging, 'templates'), { recursive: true });
|
|
509
|
+
try {
|
|
510
|
+
for (const template of verified.pack.templates) {
|
|
511
|
+
const response = await fetchWithTimeout(fetchImpl, template.url, { signal }, 30_000);
|
|
512
|
+
const bytes = await responseBytes(response, template.bytes, `Office design template ${template.id}`);
|
|
513
|
+
if (bytes.length !== template.bytes) {
|
|
514
|
+
throw new Error(`Office design template ${template.id} size mismatch`);
|
|
515
|
+
}
|
|
516
|
+
const digest = sha256(bytes);
|
|
517
|
+
if (digest !== template.sha256) {
|
|
518
|
+
throw new Error(`Office design template ${template.id} sha256 mismatch`);
|
|
519
|
+
}
|
|
520
|
+
await writeFile(join(staging, 'templates', `${template.id}${template.extension}`), bytes, { mode: 0o600 });
|
|
521
|
+
}
|
|
522
|
+
await writeJsonAtomic(join(staging, 'envelope.json'), envelope);
|
|
523
|
+
await mkdir(dirname(target), { recursive: true });
|
|
524
|
+
const backup = `${target}.previous-${randomUUID()}`;
|
|
525
|
+
let movedExisting = false;
|
|
526
|
+
try {
|
|
527
|
+
await rename(target, backup);
|
|
528
|
+
movedExisting = true;
|
|
529
|
+
} catch {}
|
|
530
|
+
try {
|
|
531
|
+
await rename(staging, target);
|
|
532
|
+
if (movedExisting) await rm(backup, { recursive: true, force: true });
|
|
533
|
+
} catch (error) {
|
|
534
|
+
if (movedExisting) await rename(backup, target).catch(() => {});
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
} finally {
|
|
538
|
+
await rm(staging, { recursive: true, force: true }).catch(() => {});
|
|
539
|
+
}
|
|
540
|
+
return target;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function loadCachedPack(paths, config, id, version) {
|
|
544
|
+
if (!id || !version) return null;
|
|
545
|
+
const root = cachedPackRoot(paths, id, version);
|
|
546
|
+
const envelope = await readJson(join(root, 'envelope.json'));
|
|
547
|
+
if (!envelope) return null;
|
|
548
|
+
const verified = verifyOfficeDesignPackEnvelope(envelope, config.trustedKeys);
|
|
549
|
+
const templates = [];
|
|
550
|
+
for (const template of verified.pack.templates) {
|
|
551
|
+
const path = join(root, 'templates', `${template.id}${template.extension}`);
|
|
552
|
+
const details = await stat(path).catch(() => null);
|
|
553
|
+
if (!details?.isFile() || details.size !== template.bytes || await sha256File(path) !== template.sha256) {
|
|
554
|
+
throw new Error(`Cached Office design template ${template.id} failed integrity validation`);
|
|
555
|
+
}
|
|
556
|
+
templates.push({
|
|
557
|
+
...template,
|
|
558
|
+
path,
|
|
559
|
+
source: 'remote-pack',
|
|
560
|
+
version: verified.pack.version,
|
|
561
|
+
layouts: template.layouts.map((layout) => ({
|
|
562
|
+
...layout,
|
|
563
|
+
templateId: layout.templateId || template.id,
|
|
564
|
+
templatePath: path,
|
|
565
|
+
})),
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
...verified.pack,
|
|
570
|
+
keyId: verified.keyId,
|
|
571
|
+
templates,
|
|
572
|
+
source: 'remote-pack',
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function packSummary(pack) {
|
|
577
|
+
return pack ? {
|
|
578
|
+
id: pack.id,
|
|
579
|
+
version: pack.version,
|
|
580
|
+
channel: pack.channel,
|
|
581
|
+
keyId: pack.keyId,
|
|
582
|
+
profiles: Object.keys(pack.profiles || {}),
|
|
583
|
+
layouts: pack.layouts?.length || 0,
|
|
584
|
+
templates: pack.templates?.length || 0,
|
|
585
|
+
} : null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function walkTemplateDirectory(root, output, depth = 0) {
|
|
589
|
+
if (depth > MAX_SCAN_DEPTH || output.length >= MAX_LOCAL_TEMPLATE_COUNT) return;
|
|
590
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
591
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
592
|
+
if (output.length >= MAX_LOCAL_TEMPLATE_COUNT) break;
|
|
593
|
+
const path = join(root, entry.name);
|
|
594
|
+
if (entry.isDirectory()) await walkTemplateDirectory(path, output, depth + 1);
|
|
595
|
+
else if (
|
|
596
|
+
entry.isFile()
|
|
597
|
+
&& !/\.mixdog-edit\.[^.]+$/i.test(entry.name)
|
|
598
|
+
&& TEMPLATE_FORMATS[extname(entry.name).toLowerCase()]
|
|
599
|
+
) output.push(path);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function xmlDecode(value = '') {
|
|
604
|
+
return String(value)
|
|
605
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, entry) => String.fromCodePoint(Number.parseInt(entry, 16)))
|
|
606
|
+
.replace(/&#(\d+);/g, (_, entry) => String.fromCodePoint(Number(entry)))
|
|
607
|
+
.replaceAll('"', '"')
|
|
608
|
+
.replaceAll(''', "'")
|
|
609
|
+
.replaceAll('<', '<')
|
|
610
|
+
.replaceAll('>', '>')
|
|
611
|
+
.replaceAll('&', '&');
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function xmlAttribute(source, name) {
|
|
615
|
+
const match = new RegExp(`\\b${name}="([^"]*)"`, 'i').exec(String(source || ''));
|
|
616
|
+
return match ? xmlDecode(match[1]) : '';
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function xmlTexts(source) {
|
|
620
|
+
return [...String(source || '').matchAll(/<a:t(?:\s[^>]*)?>([\s\S]*?)<\/a:t>/gi)]
|
|
621
|
+
.map((match) => xmlDecode(match[1]).trim())
|
|
622
|
+
.filter(Boolean);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function pptxPartPath(target) {
|
|
626
|
+
const raw = String(target || '').replaceAll('\\', '/').replace(/^\/+/, '');
|
|
627
|
+
const segments = (raw.startsWith('ppt/') ? raw : `ppt/${raw}`).split('/');
|
|
628
|
+
const normalized = [];
|
|
629
|
+
for (const segment of segments) {
|
|
630
|
+
if (!segment || segment === '.') continue;
|
|
631
|
+
if (segment === '..') normalized.pop();
|
|
632
|
+
else normalized.push(segment);
|
|
633
|
+
}
|
|
634
|
+
return normalized.join('/');
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function pptxSlideEntries(zip) {
|
|
638
|
+
const names = Object.keys(zip.files);
|
|
639
|
+
const numberFromPath = (value) => Number(/(\d+)(?=\.xml$)/.exec(value)?.[1] || 0);
|
|
640
|
+
const fallback = names
|
|
641
|
+
.filter((name) => /^ppt\/slides\/slide\d+\.xml$/i.test(name))
|
|
642
|
+
.sort((left, right) => numberFromPath(left) - numberFromPath(right));
|
|
643
|
+
const presentationFile = zip.file('ppt/presentation.xml');
|
|
644
|
+
const relationshipsFile = zip.file('ppt/_rels/presentation.xml.rels');
|
|
645
|
+
if (!presentationFile || !relationshipsFile) {
|
|
646
|
+
return fallback.map((name, index) => ({ name, slide: index + 1, part: numberFromPath(name) }));
|
|
647
|
+
}
|
|
648
|
+
const presentationXml = await presentationFile.async('string');
|
|
649
|
+
const relationshipsXml = await relationshipsFile.async('string');
|
|
650
|
+
const targetsById = new Map(
|
|
651
|
+
[...relationshipsXml.matchAll(/<(?:\w+:)?Relationship\b[^>]*\/?>/gi)]
|
|
652
|
+
.map((match) => [xmlAttribute(match[0], 'Id'), xmlAttribute(match[0], 'Target')])
|
|
653
|
+
.filter(([id, target]) => id && /(?:^|\/)slides\/slide\d+\.xml$/i.test(target)),
|
|
654
|
+
);
|
|
655
|
+
const ordered = [...presentationXml.matchAll(/<(?:\w+:)?sldId\b[^>]*\/?>/gi)]
|
|
656
|
+
.map((match) => ({
|
|
657
|
+
relationshipId: xmlAttribute(match[0], 'r:id'),
|
|
658
|
+
name: pptxPartPath(targetsById.get(xmlAttribute(match[0], 'r:id'))),
|
|
659
|
+
}))
|
|
660
|
+
.filter((entry) => zip.file(entry.name));
|
|
661
|
+
const slideEntries = ordered.length
|
|
662
|
+
? ordered
|
|
663
|
+
: fallback.map((name) => ({ relationshipId: '', name }));
|
|
664
|
+
return slideEntries.map((entry, index) => ({
|
|
665
|
+
...entry,
|
|
666
|
+
slide: index + 1,
|
|
667
|
+
part: numberFromPath(entry.name),
|
|
668
|
+
}));
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function xmlSetAttribute(source, name, value) {
|
|
672
|
+
const escaped = String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
673
|
+
return String(source).replace(
|
|
674
|
+
new RegExp(`(\\b${escaped}=")[^"]*(")`, 'i'),
|
|
675
|
+
`$1${String(value)}$2`,
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function relationshipType(block, suffix) {
|
|
680
|
+
return xmlAttribute(block, 'Type').toLowerCase().endsWith(`/${suffix.toLowerCase()}`);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function ensureContentTypeOverride(xml, partName, contentType) {
|
|
684
|
+
if (!xml || new RegExp(`\\bPartName="${String(partName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`, 'i').test(xml)) {
|
|
685
|
+
return xml;
|
|
686
|
+
}
|
|
687
|
+
return xml.replace(
|
|
688
|
+
/<\/Types>/i,
|
|
689
|
+
`<Override PartName="${partName}" ContentType="${contentType}"/></Types>`,
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
export async function createPptxSlideSelection(sourcePath, slides, outputPath) {
|
|
694
|
+
const selected = (Array.isArray(slides) ? slides : []).map(Number);
|
|
695
|
+
if (!selected.length || selected.some((slide) => !Number.isInteger(slide) || slide < 1)) {
|
|
696
|
+
throw new Error('PPTX slide selection requires positive integer slide numbers');
|
|
697
|
+
}
|
|
698
|
+
const zip = await JSZip.loadAsync(await readFile(sourcePath));
|
|
699
|
+
const entries = await pptxSlideEntries(zip);
|
|
700
|
+
if (selected.some((slide) => slide > entries.length)) {
|
|
701
|
+
throw new Error(`PPTX slide selection is outside source deck range 1-${entries.length}`);
|
|
702
|
+
}
|
|
703
|
+
const presentationFile = zip.file('ppt/presentation.xml');
|
|
704
|
+
const relationshipsFile = zip.file('ppt/_rels/presentation.xml.rels');
|
|
705
|
+
if (!presentationFile || !relationshipsFile) throw new Error('PPTX presentation relationships are missing');
|
|
706
|
+
const presentationXml = await presentationFile.async('string');
|
|
707
|
+
const relationshipsXml = await relationshipsFile.async('string');
|
|
708
|
+
const relationshipBlocks = [...relationshipsXml.matchAll(/<(?:\w+:)?Relationship\b[^>]*\/?>/gi)]
|
|
709
|
+
.map((match) => match[0]);
|
|
710
|
+
const slideRelationshipIds = entries.map((entry) => entry.relationshipId).filter(Boolean);
|
|
711
|
+
const nonSlideRelationshipIds = new Set(
|
|
712
|
+
relationshipBlocks
|
|
713
|
+
.filter((block) => !relationshipType(block, 'slide'))
|
|
714
|
+
.map((block) => xmlAttribute(block, 'Id')),
|
|
715
|
+
);
|
|
716
|
+
const relationshipIds = [];
|
|
717
|
+
let nextRelationshipId = 2;
|
|
718
|
+
for (let index = 0; index < selected.length; index += 1) {
|
|
719
|
+
let id = slideRelationshipIds[index] || '';
|
|
720
|
+
while (!id || nonSlideRelationshipIds.has(id) || relationshipIds.includes(id)) {
|
|
721
|
+
id = `rId${nextRelationshipId}`;
|
|
722
|
+
nextRelationshipId += 1;
|
|
723
|
+
}
|
|
724
|
+
relationshipIds.push(id);
|
|
725
|
+
}
|
|
726
|
+
const selectedParts = [];
|
|
727
|
+
for (const slide of selected) {
|
|
728
|
+
const entry = entries[slide - 1];
|
|
729
|
+
const slideXml = await zip.file(entry.name).async('string');
|
|
730
|
+
const slideRelationshipsPath = `ppt/slides/_rels/slide${entry.part}.xml.rels`;
|
|
731
|
+
const slideRelationshipsXml = await zip.file(slideRelationshipsPath)?.async('string') || '';
|
|
732
|
+
const notesRelationship = [...slideRelationshipsXml.matchAll(/<(?:\w+:)?Relationship\b[^>]*\/?>/gi)]
|
|
733
|
+
.map((match) => match[0])
|
|
734
|
+
.find((block) => relationshipType(block, 'notesSlide'));
|
|
735
|
+
const notesTarget = notesRelationship ? xmlAttribute(notesRelationship, 'Target') : '';
|
|
736
|
+
const notesPart = Number(/notesSlide(\d+)\.xml$/i.exec(notesTarget)?.[1] || 0);
|
|
737
|
+
const notesXml = notesPart
|
|
738
|
+
? await zip.file(`ppt/notesSlides/notesSlide${notesPart}.xml`)?.async('string') || ''
|
|
739
|
+
: '';
|
|
740
|
+
const notesRelationshipsXml = notesPart
|
|
741
|
+
? await zip.file(`ppt/notesSlides/_rels/notesSlide${notesPart}.xml.rels`)?.async('string') || ''
|
|
742
|
+
: '';
|
|
743
|
+
selectedParts.push({
|
|
744
|
+
slideXml,
|
|
745
|
+
slideRelationshipsXml,
|
|
746
|
+
notesXml,
|
|
747
|
+
notesRelationshipsXml,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
const slideListPattern = /<((?:\w+:)?)sldIdLst\b[^>]*>[\s\S]*?<\/\1sldIdLst>/i;
|
|
751
|
+
const slideListMatch = slideListPattern.exec(presentationXml);
|
|
752
|
+
if (!slideListMatch) throw new Error('PPTX presentation slide list is missing');
|
|
753
|
+
const presentationPrefix = slideListMatch[1] || '';
|
|
754
|
+
const slideIds = relationshipIds
|
|
755
|
+
.map((id, index) => `<${presentationPrefix}sldId id="${256 + index}" r:id="${id}"/>`)
|
|
756
|
+
.join('');
|
|
757
|
+
const nextPresentationXml = presentationXml.replace(
|
|
758
|
+
slideListPattern,
|
|
759
|
+
`<${presentationPrefix}sldIdLst>${slideIds}</${presentationPrefix}sldIdLst>`,
|
|
760
|
+
);
|
|
761
|
+
const slideRelationships = relationshipIds
|
|
762
|
+
.map((id, index) => `<Relationship Id="${id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide${index + 1}.xml"/>`)
|
|
763
|
+
.join('');
|
|
764
|
+
const nextRelationshipsXml = relationshipsXml
|
|
765
|
+
.replace(/<(?:\w+:)?Relationship\b[^>]*\/?>/gi, (block) => (
|
|
766
|
+
relationshipType(block, 'slide') ? '' : block
|
|
767
|
+
))
|
|
768
|
+
.replace(/<\/Relationships>/i, `${slideRelationships}</Relationships>`);
|
|
769
|
+
zip.file('ppt/presentation.xml', nextPresentationXml);
|
|
770
|
+
zip.file('ppt/_rels/presentation.xml.rels', nextRelationshipsXml);
|
|
771
|
+
let contentTypesXml = await zip.file('[Content_Types].xml')?.async('string') || '';
|
|
772
|
+
for (let index = 0; index < selectedParts.length; index += 1) {
|
|
773
|
+
const target = index + 1;
|
|
774
|
+
const part = selectedParts[index];
|
|
775
|
+
zip.file(`ppt/slides/slide${target}.xml`, part.slideXml);
|
|
776
|
+
if (part.slideRelationshipsXml) {
|
|
777
|
+
const slideRels = part.slideRelationshipsXml.replace(
|
|
778
|
+
/<(?:\w+:)?Relationship\b[^>]*\/?>/gi,
|
|
779
|
+
(block) => relationshipType(block, 'notesSlide')
|
|
780
|
+
? xmlSetAttribute(block, 'Target', `../notesSlides/notesSlide${target}.xml`)
|
|
781
|
+
: block,
|
|
782
|
+
);
|
|
783
|
+
zip.file(`ppt/slides/_rels/slide${target}.xml.rels`, slideRels);
|
|
784
|
+
} else {
|
|
785
|
+
zip.remove(`ppt/slides/_rels/slide${target}.xml.rels`);
|
|
786
|
+
}
|
|
787
|
+
contentTypesXml = ensureContentTypeOverride(
|
|
788
|
+
contentTypesXml,
|
|
789
|
+
`/ppt/slides/slide${target}.xml`,
|
|
790
|
+
'application/vnd.openxmlformats-officedocument.presentationml.slide+xml',
|
|
791
|
+
);
|
|
792
|
+
if (part.notesXml) {
|
|
793
|
+
zip.file(`ppt/notesSlides/notesSlide${target}.xml`, part.notesXml);
|
|
794
|
+
const notesRels = part.notesRelationshipsXml.replace(
|
|
795
|
+
/<(?:\w+:)?Relationship\b[^>]*\/?>/gi,
|
|
796
|
+
(block) => relationshipType(block, 'slide')
|
|
797
|
+
? xmlSetAttribute(block, 'Target', `../slides/slide${target}.xml`)
|
|
798
|
+
: block,
|
|
799
|
+
);
|
|
800
|
+
if (notesRels) zip.file(`ppt/notesSlides/_rels/notesSlide${target}.xml.rels`, notesRels);
|
|
801
|
+
contentTypesXml = ensureContentTypeOverride(
|
|
802
|
+
contentTypesXml,
|
|
803
|
+
`/ppt/notesSlides/notesSlide${target}.xml`,
|
|
804
|
+
'application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml',
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
if (contentTypesXml) zip.file('[Content_Types].xml', contentTypesXml);
|
|
809
|
+
const appFile = zip.file('docProps/app.xml');
|
|
810
|
+
if (appFile) {
|
|
811
|
+
const appXml = await appFile.async('string');
|
|
812
|
+
zip.file('docProps/app.xml', appXml.replace(/<Slides>\d+<\/Slides>/i, `<Slides>${selected.length}</Slides>`));
|
|
813
|
+
}
|
|
814
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
815
|
+
try {
|
|
816
|
+
await writeFile(outputPath, await zip.generateAsync({
|
|
817
|
+
type: 'nodebuffer',
|
|
818
|
+
compression: 'DEFLATE',
|
|
819
|
+
compressionOptions: { level: 6 },
|
|
820
|
+
}));
|
|
821
|
+
} catch (error) {
|
|
822
|
+
await rm(outputPath, { force: true }).catch(() => {});
|
|
823
|
+
throw error;
|
|
824
|
+
}
|
|
825
|
+
return {
|
|
826
|
+
output: outputPath,
|
|
827
|
+
source: sourcePath,
|
|
828
|
+
slides: selected,
|
|
829
|
+
count: selected.length,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function directPptxShapeBlocks(xml) {
|
|
834
|
+
const tree = /<p:spTree\b[^>]*>([\s\S]*?)<\/p:spTree>/i.exec(String(xml || ''))?.[1] || '';
|
|
835
|
+
const tags = /<\/?p:(sp|pic|graphicFrame|grpSp)\b[^>]*>/gi;
|
|
836
|
+
const stack = [];
|
|
837
|
+
const output = [];
|
|
838
|
+
let start = -1;
|
|
839
|
+
let rootType = '';
|
|
840
|
+
for (const match of tree.matchAll(tags)) {
|
|
841
|
+
const token = match[0];
|
|
842
|
+
const type = match[1];
|
|
843
|
+
const closing = token.startsWith('</');
|
|
844
|
+
if (!closing) {
|
|
845
|
+
if (!stack.length) {
|
|
846
|
+
start = match.index;
|
|
847
|
+
rootType = type;
|
|
848
|
+
}
|
|
849
|
+
stack.push(type);
|
|
850
|
+
} else {
|
|
851
|
+
const current = stack.pop();
|
|
852
|
+
if (current !== type) {
|
|
853
|
+
stack.length = 0;
|
|
854
|
+
start = -1;
|
|
855
|
+
rootType = '';
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if (!stack.length && start >= 0) {
|
|
859
|
+
output.push({ type: rootType, xml: tree.slice(start, match.index + token.length) });
|
|
860
|
+
start = -1;
|
|
861
|
+
rootType = '';
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return output;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function tokenSlotRole(text) {
|
|
869
|
+
const token = /\{\{([A-Z0-9_]+)\}\}/.exec(String(text || ''))?.[1] || '';
|
|
870
|
+
if (!token) return '';
|
|
871
|
+
const metric = /^METRIC_(\d+)_(VALUE|LABEL|DETAIL)$/.exec(token);
|
|
872
|
+
if (metric) return `metric-${metric[2].toLowerCase()}-${metric[1]}`;
|
|
873
|
+
const column = /^COLUMN_(\d+)_(TITLE|BODY)$/.exec(token);
|
|
874
|
+
if (column) return `column-${column[2].toLowerCase()}-${column[1]}`;
|
|
875
|
+
const step = /^STEP_(\d+)_(TITLE|DETAIL)$/.exec(token);
|
|
876
|
+
if (step) return `step-${step[2].toLowerCase()}-${step[1]}`;
|
|
877
|
+
return token.toLowerCase().replaceAll('_', '-');
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function pptxShapeMetadata(block, shape) {
|
|
881
|
+
const cNvPr = /<p:cNvPr\b[^>]*>/i.exec(block.xml)?.[0] || '';
|
|
882
|
+
const placeholder = /<p:ph\b[^>]*\/?>/i.exec(block.xml)?.[0] || '';
|
|
883
|
+
const placeholderType = xmlAttribute(placeholder, 'type') || '';
|
|
884
|
+
const placeholderIndex = Number(xmlAttribute(placeholder, 'idx'));
|
|
885
|
+
const texts = xmlTexts(block.xml);
|
|
886
|
+
const text = texts.join('\n');
|
|
887
|
+
const off = /<a:off\b[^>]*>/i.exec(block.xml)?.[0] || '';
|
|
888
|
+
const ext = /<a:ext\b[^>]*>/i.exec(block.xml)?.[0] || '';
|
|
889
|
+
const geometry = {
|
|
890
|
+
left: Number(xmlAttribute(off, 'x')) || 0,
|
|
891
|
+
top: Number(xmlAttribute(off, 'y')) || 0,
|
|
892
|
+
width: Number(xmlAttribute(ext, 'cx')) || 0,
|
|
893
|
+
height: Number(xmlAttribute(ext, 'cy')) || 0,
|
|
894
|
+
};
|
|
895
|
+
let type = 'text';
|
|
896
|
+
if (block.type === 'pic') type = 'image';
|
|
897
|
+
else if (block.type === 'graphicFrame' && /<c:chart\b/i.test(block.xml)) type = 'chart';
|
|
898
|
+
else if (block.type === 'graphicFrame' && /<a:tbl\b/i.test(block.xml)) type = 'table';
|
|
899
|
+
else if (block.type === 'graphicFrame' || block.type === 'grpSp') type = 'diagram';
|
|
900
|
+
let role = tokenSlotRole(text);
|
|
901
|
+
if (!role) {
|
|
902
|
+
if (['title', 'ctrTitle'].includes(placeholderType)) role = 'title';
|
|
903
|
+
else if (placeholderType === 'subTitle') role = 'subtitle';
|
|
904
|
+
else if (placeholderType === 'pic' || type === 'image') role = 'image';
|
|
905
|
+
else if (type === 'chart') role = 'chart';
|
|
906
|
+
else if (type === 'table') role = 'table';
|
|
907
|
+
else if (placeholderType) role = `body-${shape}`;
|
|
908
|
+
}
|
|
909
|
+
return {
|
|
910
|
+
shape,
|
|
911
|
+
name: xmlAttribute(cNvPr, 'name'),
|
|
912
|
+
type,
|
|
913
|
+
text,
|
|
914
|
+
placeholderType,
|
|
915
|
+
...(Number.isInteger(placeholderIndex) ? { placeholderIndex } : {}),
|
|
916
|
+
geometry,
|
|
917
|
+
...(role ? {
|
|
918
|
+
slot: {
|
|
919
|
+
role,
|
|
920
|
+
type,
|
|
921
|
+
shape,
|
|
922
|
+
...(placeholderType ? { placeholderType } : {}),
|
|
923
|
+
...(Number.isInteger(placeholderIndex) ? { placeholderIndex } : {}),
|
|
924
|
+
geometry,
|
|
925
|
+
required: ['title'].includes(role),
|
|
926
|
+
},
|
|
927
|
+
} : {}),
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function inferPptxSampleKind(sample, total) {
|
|
932
|
+
const roles = new Set(sample.slots.map((slot) => slot.role));
|
|
933
|
+
const title = String(sample.title || '').toLowerCase();
|
|
934
|
+
if (sample.slide === 1) return 'cover';
|
|
935
|
+
if (sample.slide === total && /(thank|next|close|감사|다음)/i.test(title)) return 'closing';
|
|
936
|
+
if ([...roles].some((role) => role.startsWith('step-'))) return 'process';
|
|
937
|
+
if ([...roles].some((role) => role.startsWith('column-'))) return 'comparison';
|
|
938
|
+
if (roles.has('chart') || [...roles].some((role) => role.startsWith('metric-'))) return 'metrics';
|
|
939
|
+
if (roles.has('image')) return 'split';
|
|
940
|
+
if (sample.textChars < 90 && sample.shapes.length <= 6) return 'statement';
|
|
941
|
+
return 'content';
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function numberedRoleGroups(slots, prefix) {
|
|
945
|
+
return new Set((slots || []).flatMap((slot) => {
|
|
946
|
+
const match = new RegExp(`^${prefix}-(?:value|label|detail|title|body)-(\\d+)$`).exec(String(slot.role || ''));
|
|
947
|
+
return match ? [Number(match[1])] : [];
|
|
948
|
+
})).size;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function pptxSampleCapacity(sample) {
|
|
952
|
+
const slots = Array.isArray(sample?.slots) ? sample.slots : [];
|
|
953
|
+
return {
|
|
954
|
+
sampleTextChars: Number(sample?.textChars) || 0,
|
|
955
|
+
shapeCount: Array.isArray(sample?.shapes) ? sample.shapes.length : 0,
|
|
956
|
+
textSlots: slots.filter((slot) => slot.type === 'text').length,
|
|
957
|
+
metricGroups: numberedRoleGroups(slots, 'metric'),
|
|
958
|
+
columnGroups: numberedRoleGroups(slots, 'column'),
|
|
959
|
+
stepGroups: numberedRoleGroups(slots, 'step'),
|
|
960
|
+
textArea: Math.round(slots
|
|
961
|
+
.filter((slot) => slot.type === 'text')
|
|
962
|
+
.reduce((total, slot) => (
|
|
963
|
+
total + Math.max(0, Number(slot.geometry?.width) || 0) * Math.max(0, Number(slot.geometry?.height) || 0)
|
|
964
|
+
), 0)),
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
export function officeTemplateCoverage(sampleSlides = []) {
|
|
969
|
+
const samples = Array.isArray(sampleSlides) ? sampleSlides : [];
|
|
970
|
+
const kinds = [...new Set(samples.map((sample) => String(sample.kind || '')).filter(Boolean))].sort();
|
|
971
|
+
const densities = [...new Set(samples.map((sample) => String(sample.density || '')).filter(Boolean))].sort();
|
|
972
|
+
const capabilities = [...new Set(samples.flatMap((sample) => sample.capabilities || []))].sort();
|
|
973
|
+
const recommendedKinds = ['cover', 'content', 'statement', 'comparison', 'process', 'metrics', 'split', 'closing'];
|
|
974
|
+
const recommendedDensities = ['light', 'balanced', 'dense'];
|
|
975
|
+
return {
|
|
976
|
+
sampleCount: samples.length,
|
|
977
|
+
kinds,
|
|
978
|
+
densities,
|
|
979
|
+
capabilities,
|
|
980
|
+
missingKinds: recommendedKinds.filter((kind) => !kinds.includes(kind)),
|
|
981
|
+
missingDensities: recommendedDensities.filter((density) => !densities.includes(density)),
|
|
982
|
+
nativeObjectCoverage: {
|
|
983
|
+
image: capabilities.includes('image'),
|
|
984
|
+
chart: capabilities.includes('chart'),
|
|
985
|
+
table: capabilities.includes('table'),
|
|
986
|
+
diagram: capabilities.includes('diagram'),
|
|
987
|
+
},
|
|
988
|
+
complete: recommendedKinds.every((kind) => kinds.includes(kind))
|
|
989
|
+
&& recommendedDensities.every((density) => densities.includes(density))
|
|
990
|
+
&& ['image', 'chart', 'table'].every((capability) => capabilities.includes(capability)),
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
export async function inspectOfficeTemplate(path, { format = '' } = {}) {
|
|
995
|
+
const normalizedFormat = format || TEMPLATE_FORMATS[extname(path).toLowerCase()] || '';
|
|
996
|
+
if (normalizedFormat !== 'pptx') return { sampleSlides: [], nativeLayouts: [], theme: null };
|
|
997
|
+
const zip = await JSZip.loadAsync(await readFile(path));
|
|
998
|
+
const names = Object.keys(zip.files);
|
|
999
|
+
const numberFromPath = (value) => Number(/(\d+)(?=\.xml$)/.exec(value)?.[1] || 0);
|
|
1000
|
+
const slideEntries = await pptxSlideEntries(zip);
|
|
1001
|
+
const sampleSlides = [];
|
|
1002
|
+
for (const { name, slide, part } of slideEntries) {
|
|
1003
|
+
const xml = await zip.file(name).async('string');
|
|
1004
|
+
const shapes = directPptxShapeBlocks(xml).map((block, index) => pptxShapeMetadata(block, index + 1));
|
|
1005
|
+
const slots = shapes.flatMap((shape) => shape.slot ? [shape.slot] : []);
|
|
1006
|
+
const textChars = shapes.reduce((total, shape) => total + shape.text.length, 0);
|
|
1007
|
+
const title = shapes.find((shape) => ['title', 'ctrTitle'].includes(shape.placeholderType))?.text
|
|
1008
|
+
|| shapes.find((shape) => shape.slot?.role === 'title')?.text
|
|
1009
|
+
|| '';
|
|
1010
|
+
const capabilities = [...new Set(shapes.map((shape) => shape.type).filter((type) => type !== 'text'))];
|
|
1011
|
+
sampleSlides.push({
|
|
1012
|
+
slide,
|
|
1013
|
+
part,
|
|
1014
|
+
title,
|
|
1015
|
+
textChars,
|
|
1016
|
+
density: textChars > 340 || shapes.length > 16 ? 'dense' : textChars > 120 || shapes.length > 8 ? 'balanced' : 'light',
|
|
1017
|
+
shapes,
|
|
1018
|
+
slots,
|
|
1019
|
+
capabilities,
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
for (const sample of sampleSlides) {
|
|
1023
|
+
sample.kind = inferPptxSampleKind(sample, sampleSlides.length);
|
|
1024
|
+
sample.capacity = pptxSampleCapacity(sample);
|
|
1025
|
+
}
|
|
1026
|
+
const layoutNames = names
|
|
1027
|
+
.filter((name) => /^ppt\/slideLayouts\/slideLayout\d+\.xml$/i.test(name))
|
|
1028
|
+
.sort((left, right) => numberFromPath(left) - numberFromPath(right));
|
|
1029
|
+
const nativeLayouts = [];
|
|
1030
|
+
for (const name of layoutNames) {
|
|
1031
|
+
const xml = await zip.file(name).async('string');
|
|
1032
|
+
const root = /<p:sldLayout\b[^>]*>/i.exec(xml)?.[0] || '';
|
|
1033
|
+
const common = /<p:cSld\b[^>]*>/i.exec(xml)?.[0] || '';
|
|
1034
|
+
const shapes = directPptxShapeBlocks(xml).map((block, index) => pptxShapeMetadata(block, index + 1));
|
|
1035
|
+
nativeLayouts.push({
|
|
1036
|
+
layout: numberFromPath(name),
|
|
1037
|
+
name: xmlAttribute(common, 'name'),
|
|
1038
|
+
type: xmlAttribute(root, 'type'),
|
|
1039
|
+
slots: shapes.flatMap((shape) => shape.slot ? [shape.slot] : []),
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
const themeName = names.find((name) => /^ppt\/theme\/theme\d+\.xml$/i.test(name));
|
|
1043
|
+
let theme = null;
|
|
1044
|
+
if (themeName) {
|
|
1045
|
+
const xml = await zip.file(themeName).async('string');
|
|
1046
|
+
const root = /<a:theme\b[^>]*>/i.exec(xml)?.[0] || '';
|
|
1047
|
+
theme = {
|
|
1048
|
+
name: xmlAttribute(root, 'name'),
|
|
1049
|
+
fonts: [...new Set([...xml.matchAll(/<a:(?:latin|ea|cs)\b[^>]*\btypeface="([^"]*)"/gi)]
|
|
1050
|
+
.map((match) => xmlDecode(match[1]))
|
|
1051
|
+
.filter(Boolean))],
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
return {
|
|
1055
|
+
sampleSlides,
|
|
1056
|
+
nativeLayouts,
|
|
1057
|
+
theme,
|
|
1058
|
+
coverage: officeTemplateCoverage(sampleSlides),
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function normalizeLocalMetadata(value, path) {
|
|
1063
|
+
if (!plainObject(value)) return {};
|
|
1064
|
+
return {
|
|
1065
|
+
...(value.id ? { id: safeId(value.id, 'local template id') } : {}),
|
|
1066
|
+
label: String(value.label || ''),
|
|
1067
|
+
profile: value.profile ? safeId(value.profile, 'local template profile') : '',
|
|
1068
|
+
version: String(value.version || ''),
|
|
1069
|
+
layouts: normalizeLayouts(value.layouts || [], { templatePath: path }),
|
|
1070
|
+
samples: normalizeLocalSamples(value.samples || []),
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
export async function indexOfficeTemplates({
|
|
1075
|
+
dataDir,
|
|
1076
|
+
config: configOverride = null,
|
|
1077
|
+
} = {}) {
|
|
1078
|
+
const paths = libraryPaths(dataDir);
|
|
1079
|
+
const config = await loadConfig(dataDir, configOverride);
|
|
1080
|
+
await mkdir(paths.templates, { recursive: true });
|
|
1081
|
+
const previous = await readJson(paths.templateIndex, { templates: [] });
|
|
1082
|
+
const previousByPath = new Map((previous.templates || []).map((entry) => [canonicalPath(entry.path), entry]));
|
|
1083
|
+
const files = [];
|
|
1084
|
+
for (const directory of config.templateDirectories) await walkTemplateDirectory(directory, files);
|
|
1085
|
+
const templates = [];
|
|
1086
|
+
for (const path of files) {
|
|
1087
|
+
const canonical = canonicalPath(path);
|
|
1088
|
+
const details = await stat(path);
|
|
1089
|
+
const sidecarPath = `${path}.mixdog.json`;
|
|
1090
|
+
const sidecarDetails = await stat(sidecarPath).catch(() => null);
|
|
1091
|
+
const previousEntry = previousByPath.get(canonical);
|
|
1092
|
+
const unchanged = previousEntry
|
|
1093
|
+
&& Number(previousEntry.bytes) === details.size
|
|
1094
|
+
&& Number(previousEntry.mtimeMs) === details.mtimeMs
|
|
1095
|
+
&& Number(previousEntry.sidecarMtimeMs || 0) === Number(sidecarDetails?.mtimeMs || 0)
|
|
1096
|
+
&& Number(previousEntry.inspectionVersion || 0) === TEMPLATE_INSPECTOR_VERSION;
|
|
1097
|
+
if (unchanged) {
|
|
1098
|
+
templates.push(previousEntry);
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
const digest = await sha256File(path);
|
|
1102
|
+
const metadata = normalizeLocalMetadata(await readJson(sidecarPath, {}), path);
|
|
1103
|
+
const id = metadata.id || `local-${sha256(canonical).slice(0, 16)}`;
|
|
1104
|
+
const format = TEMPLATE_FORMATS[extname(path).toLowerCase()];
|
|
1105
|
+
let inspected = {
|
|
1106
|
+
sampleSlides: [],
|
|
1107
|
+
nativeLayouts: [],
|
|
1108
|
+
theme: null,
|
|
1109
|
+
coverage: officeTemplateCoverage([]),
|
|
1110
|
+
};
|
|
1111
|
+
let inspectionWarning = '';
|
|
1112
|
+
try {
|
|
1113
|
+
inspected = await inspectOfficeTemplate(path, { format });
|
|
1114
|
+
} catch (error) {
|
|
1115
|
+
inspectionWarning = error?.message || String(error);
|
|
1116
|
+
}
|
|
1117
|
+
const autoLayouts = inspected.sampleSlides.map((sample) => {
|
|
1118
|
+
const sampleMetadata = metadata.samples.find((entry) => entry.slide === sample.slide);
|
|
1119
|
+
const slots = sampleMetadata && Object.keys(sampleMetadata.roles).length
|
|
1120
|
+
? Object.entries(sampleMetadata.roles).map(([shapeIndex, role]) => {
|
|
1121
|
+
const shape = sample.shapes.find((entry) => entry.shape === Number(shapeIndex));
|
|
1122
|
+
if (!shape) {
|
|
1123
|
+
throw new Error(`Office local template sample ${sample.slide} references missing shape ${shapeIndex}`);
|
|
1124
|
+
}
|
|
1125
|
+
return {
|
|
1126
|
+
role,
|
|
1127
|
+
type: shape.type,
|
|
1128
|
+
shape: shape.shape,
|
|
1129
|
+
...(shape.placeholderType ? { placeholderType: shape.placeholderType } : {}),
|
|
1130
|
+
...(Number.isInteger(shape.placeholderIndex) ? { placeholderIndex: shape.placeholderIndex } : {}),
|
|
1131
|
+
geometry: shape.geometry,
|
|
1132
|
+
required: role === 'title',
|
|
1133
|
+
};
|
|
1134
|
+
})
|
|
1135
|
+
: sample.slots;
|
|
1136
|
+
return {
|
|
1137
|
+
id: sampleMetadata?.id || `${id}-slide-${sample.slide}`,
|
|
1138
|
+
format: 'pptx',
|
|
1139
|
+
kind: sampleMetadata?.kind || sample.kind,
|
|
1140
|
+
profile: metadata.profile,
|
|
1141
|
+
density: sampleMetadata?.density || sample.density,
|
|
1142
|
+
variant: sampleMetadata?.variant || 'native',
|
|
1143
|
+
templateId: id,
|
|
1144
|
+
templatePath: path,
|
|
1145
|
+
sourceSlide: sample.slide,
|
|
1146
|
+
sourceLayout: 0,
|
|
1147
|
+
slots,
|
|
1148
|
+
capacity: {
|
|
1149
|
+
...sample.capacity,
|
|
1150
|
+
...(sampleMetadata?.capacity || {}),
|
|
1151
|
+
},
|
|
1152
|
+
capabilities: sample.capabilities,
|
|
1153
|
+
priority: sampleMetadata?.priority || 0,
|
|
1154
|
+
strict: sampleMetadata?.strict || false,
|
|
1155
|
+
defaults: sampleMetadata?.defaults || {},
|
|
1156
|
+
};
|
|
1157
|
+
});
|
|
1158
|
+
const layouts = metadata.layouts.length
|
|
1159
|
+
? metadata.layouts.map((layout) => {
|
|
1160
|
+
const sample = inspected.sampleSlides.find((entry) => entry.slide === layout.sourceSlide);
|
|
1161
|
+
return {
|
|
1162
|
+
...layout,
|
|
1163
|
+
templateId: layout.templateId || id,
|
|
1164
|
+
templatePath: path,
|
|
1165
|
+
slots: layout.slots.length ? layout.slots : sample?.slots || [],
|
|
1166
|
+
capacity: Object.keys(layout.capacity || {}).length ? layout.capacity : sample?.capacity || {},
|
|
1167
|
+
capabilities: layout.capabilities.length ? layout.capabilities : sample?.capabilities || [],
|
|
1168
|
+
};
|
|
1169
|
+
})
|
|
1170
|
+
: autoLayouts;
|
|
1171
|
+
const indexedSampleSlides = inspected.sampleSlides.map((sample) => {
|
|
1172
|
+
const layout = layouts.find((entry) => entry.sourceSlide === sample.slide);
|
|
1173
|
+
const titleShape = sample.shapes.find((shape) => (
|
|
1174
|
+
layout?.slots.some((slot) => slot.role === 'title' && slot.shape === shape.shape)
|
|
1175
|
+
));
|
|
1176
|
+
return {
|
|
1177
|
+
...sample,
|
|
1178
|
+
title: sample.title || titleShape?.text || '',
|
|
1179
|
+
kind: layout?.kind || sample.kind,
|
|
1180
|
+
density: layout?.density || sample.density,
|
|
1181
|
+
slots: layout?.slots || sample.slots,
|
|
1182
|
+
capacity: layout?.capacity || sample.capacity,
|
|
1183
|
+
};
|
|
1184
|
+
});
|
|
1185
|
+
const coverage = officeTemplateCoverage(indexedSampleSlides);
|
|
1186
|
+
templates.push({
|
|
1187
|
+
id,
|
|
1188
|
+
label: metadata.label || path.split(/[\\/]/).at(-1),
|
|
1189
|
+
format,
|
|
1190
|
+
fileKind: extname(path).slice(1).toLowerCase(),
|
|
1191
|
+
path: resolve(path),
|
|
1192
|
+
source: 'local-template',
|
|
1193
|
+
bytes: details.size,
|
|
1194
|
+
mtimeMs: details.mtimeMs,
|
|
1195
|
+
sidecarMtimeMs: Number(sidecarDetails?.mtimeMs || 0),
|
|
1196
|
+
inspectionVersion: TEMPLATE_INSPECTOR_VERSION,
|
|
1197
|
+
inspectionWarning,
|
|
1198
|
+
sha256: digest,
|
|
1199
|
+
version: metadata.version ? `${metadata.version}+${digest.slice(0, 12)}` : digest.slice(0, 16),
|
|
1200
|
+
profile: metadata.profile,
|
|
1201
|
+
layouts,
|
|
1202
|
+
sampleSlides: indexedSampleSlides,
|
|
1203
|
+
coverage,
|
|
1204
|
+
nativeLayouts: inspected.nativeLayouts,
|
|
1205
|
+
theme: inspected.theme,
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
templates.sort((left, right) => left.id.localeCompare(right.id) || left.path.localeCompare(right.path));
|
|
1209
|
+
const revision = sha256(JSON.stringify(templates.map((entry) => [
|
|
1210
|
+
entry.id,
|
|
1211
|
+
entry.path,
|
|
1212
|
+
entry.sha256,
|
|
1213
|
+
entry.sidecarMtimeMs,
|
|
1214
|
+
])));
|
|
1215
|
+
const changed = revision !== previous.revision;
|
|
1216
|
+
const index = {
|
|
1217
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1218
|
+
indexedAt: new Date().toISOString(),
|
|
1219
|
+
revision,
|
|
1220
|
+
directories: config.templateDirectories,
|
|
1221
|
+
templates,
|
|
1222
|
+
};
|
|
1223
|
+
if (changed || !previous.revision) await writeJsonAtomic(paths.templateIndex, index);
|
|
1224
|
+
return {
|
|
1225
|
+
...index,
|
|
1226
|
+
changed,
|
|
1227
|
+
count: templates.length,
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
async function readTemplateIndex(paths) {
|
|
1232
|
+
return await readJson(paths.templateIndex, {
|
|
1233
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1234
|
+
revision: '',
|
|
1235
|
+
templates: [],
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
async function writeState(paths, state) {
|
|
1240
|
+
await writeJsonAtomic(paths.state, {
|
|
1241
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1242
|
+
...state,
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
export async function syncOfficeDesignLibrary({
|
|
1247
|
+
dataDir,
|
|
1248
|
+
config: configOverride = null,
|
|
1249
|
+
fetchImpl = fetch,
|
|
1250
|
+
force = false,
|
|
1251
|
+
allowRemote = true,
|
|
1252
|
+
indexTemplates = true,
|
|
1253
|
+
signal = null,
|
|
1254
|
+
} = {}) {
|
|
1255
|
+
const paths = libraryPaths(dataDir);
|
|
1256
|
+
const config = await loadConfig(dataDir, configOverride);
|
|
1257
|
+
await mkdir(paths.root, { recursive: true });
|
|
1258
|
+
let warning = '';
|
|
1259
|
+
const addWarning = (message) => {
|
|
1260
|
+
warning = [warning, String(message || '')].filter(Boolean).join(' ');
|
|
1261
|
+
};
|
|
1262
|
+
let templateIndex;
|
|
1263
|
+
try {
|
|
1264
|
+
templateIndex = indexTemplates
|
|
1265
|
+
? await indexOfficeTemplates({ dataDir, config })
|
|
1266
|
+
: await readTemplateIndex(paths);
|
|
1267
|
+
} catch (error) {
|
|
1268
|
+
templateIndex = await readTemplateIndex(paths);
|
|
1269
|
+
addWarning(`Office template index was not updated: ${error?.message || String(error)}`);
|
|
1270
|
+
}
|
|
1271
|
+
let state = await readJson(paths.state, { schemaVersion: SCHEMA_VERSION });
|
|
1272
|
+
let activePack = null;
|
|
1273
|
+
try {
|
|
1274
|
+
activePack = await loadCachedPack(paths, config, state.active?.id, state.active?.version);
|
|
1275
|
+
} catch (error) {
|
|
1276
|
+
addWarning(error.message);
|
|
1277
|
+
}
|
|
1278
|
+
const now = Date.now();
|
|
1279
|
+
const due = force || !Number(state.lastCheckedAt) || now - Number(state.lastCheckedAt) >= config.checkIntervalMs;
|
|
1280
|
+
if (!allowRemote || !config.manifestUrl || !due) {
|
|
1281
|
+
return {
|
|
1282
|
+
ok: !warning,
|
|
1283
|
+
activePack,
|
|
1284
|
+
active: packSummary(activePack),
|
|
1285
|
+
templates: templateIndex,
|
|
1286
|
+
checked: false,
|
|
1287
|
+
warning,
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
try {
|
|
1291
|
+
const url = new URL(config.manifestUrl);
|
|
1292
|
+
if (url.protocol !== 'https:') throw new Error('Office design pack manifest requires an HTTPS URL');
|
|
1293
|
+
const response = await fetchWithTimeout(fetchImpl, url.href, {
|
|
1294
|
+
signal,
|
|
1295
|
+
headers: state.etag ? { 'If-None-Match': state.etag } : {},
|
|
1296
|
+
});
|
|
1297
|
+
if (response.status === 304) {
|
|
1298
|
+
state = {
|
|
1299
|
+
...state,
|
|
1300
|
+
lastCheckedAt: now,
|
|
1301
|
+
lastError: '',
|
|
1302
|
+
};
|
|
1303
|
+
await writeState(paths, state);
|
|
1304
|
+
return {
|
|
1305
|
+
ok: !warning,
|
|
1306
|
+
activePack,
|
|
1307
|
+
active: packSummary(activePack),
|
|
1308
|
+
templates: templateIndex,
|
|
1309
|
+
checked: true,
|
|
1310
|
+
updated: false,
|
|
1311
|
+
warning,
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
const bytes = await responseBytes(response, MAX_MANIFEST_BYTES, 'Office design pack manifest');
|
|
1315
|
+
const envelope = JSON.parse(bytes.toString('utf8'));
|
|
1316
|
+
const verified = verifyOfficeDesignPackEnvelope(envelope, config.trustedKeys);
|
|
1317
|
+
if (config.packId && verified.pack.id !== config.packId) {
|
|
1318
|
+
throw new Error(`Office design pack ${verified.pack.id} does not match configured pack ${config.packId}`);
|
|
1319
|
+
}
|
|
1320
|
+
if (config.channel && verified.pack.channel !== config.channel) {
|
|
1321
|
+
throw new Error(`Office design pack channel ${verified.pack.channel} does not match ${config.channel}`);
|
|
1322
|
+
}
|
|
1323
|
+
const currentIsSamePack = activePack?.id === verified.pack.id;
|
|
1324
|
+
const shouldActivate = !activePack
|
|
1325
|
+
|| !currentIsSamePack
|
|
1326
|
+
|| compareOfficeDesignVersions(verified.pack.version, activePack.version) > 0;
|
|
1327
|
+
if (shouldActivate) {
|
|
1328
|
+
await materializePack(envelope, verified, paths, fetchImpl, signal);
|
|
1329
|
+
activePack = await loadCachedPack(paths, config, verified.pack.id, verified.pack.version);
|
|
1330
|
+
}
|
|
1331
|
+
state = {
|
|
1332
|
+
...state,
|
|
1333
|
+
active: activePack ? { id: activePack.id, version: activePack.version } : state.active,
|
|
1334
|
+
lastCheckedAt: now,
|
|
1335
|
+
lastActivatedAt: shouldActivate ? now : state.lastActivatedAt,
|
|
1336
|
+
etag: String(response.headers?.get?.('etag') || state.etag || ''),
|
|
1337
|
+
lastError: '',
|
|
1338
|
+
};
|
|
1339
|
+
await writeState(paths, state);
|
|
1340
|
+
return {
|
|
1341
|
+
ok: !warning,
|
|
1342
|
+
activePack,
|
|
1343
|
+
active: packSummary(activePack),
|
|
1344
|
+
templates: templateIndex,
|
|
1345
|
+
checked: true,
|
|
1346
|
+
updated: shouldActivate,
|
|
1347
|
+
warning,
|
|
1348
|
+
};
|
|
1349
|
+
} catch (error) {
|
|
1350
|
+
addWarning(error?.message || String(error));
|
|
1351
|
+
await writeState(paths, {
|
|
1352
|
+
...state,
|
|
1353
|
+
lastCheckedAt: now,
|
|
1354
|
+
lastError: warning.slice(0, 1_000),
|
|
1355
|
+
}).catch(() => {});
|
|
1356
|
+
return {
|
|
1357
|
+
ok: false,
|
|
1358
|
+
activePack,
|
|
1359
|
+
active: packSummary(activePack),
|
|
1360
|
+
templates: templateIndex,
|
|
1361
|
+
checked: true,
|
|
1362
|
+
updated: false,
|
|
1363
|
+
warning,
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function bindingPath(paths, documentPath) {
|
|
1369
|
+
return join(paths.bindings, `${sha256(canonicalPath(documentPath))}.json`);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
export async function readOfficeDesignBinding(dataDir, documentPath) {
|
|
1373
|
+
const paths = libraryPaths(dataDir);
|
|
1374
|
+
const binding = await readJson(bindingPath(paths, documentPath));
|
|
1375
|
+
if (!binding || canonicalPath(binding.documentPath) !== canonicalPath(documentPath)) return null;
|
|
1376
|
+
return binding;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
export async function persistOfficeDesignBinding(dataDir, documentPath, binding) {
|
|
1380
|
+
if (!binding) return null;
|
|
1381
|
+
const paths = libraryPaths(dataDir);
|
|
1382
|
+
const record = {
|
|
1383
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1384
|
+
...clone(binding),
|
|
1385
|
+
documentPath: resolve(documentPath),
|
|
1386
|
+
updatedAt: new Date().toISOString(),
|
|
1387
|
+
};
|
|
1388
|
+
await writeJsonAtomic(bindingPath(paths, documentPath), record);
|
|
1389
|
+
return record;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function findTemplate(templates, selector, format) {
|
|
1393
|
+
const wanted = String(selector || '').trim();
|
|
1394
|
+
if (!wanted) return null;
|
|
1395
|
+
const canonical = isAbsolute(wanted) ? canonicalPath(wanted) : '';
|
|
1396
|
+
return templates.find((template) => (
|
|
1397
|
+
template.format === format
|
|
1398
|
+
&& (template.id === wanted.toLowerCase() || (canonical && canonicalPath(template.path) === canonical))
|
|
1399
|
+
)) || null;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
function layoutCandidates(pack, template, format) {
|
|
1403
|
+
const templates = pack?.templates || [];
|
|
1404
|
+
const packLayouts = (pack?.layouts || []).map((layout) => {
|
|
1405
|
+
const owner = templates.find((entry) => entry.id === layout.templateId);
|
|
1406
|
+
return owner ? { ...layout, templatePath: owner.path } : layout;
|
|
1407
|
+
});
|
|
1408
|
+
return [
|
|
1409
|
+
...packLayouts,
|
|
1410
|
+
...(template?.layouts || []),
|
|
1411
|
+
].filter((layout) => layout.format === format).map(clone);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function bindingFor({ pack, template, format, source }) {
|
|
1415
|
+
return {
|
|
1416
|
+
source,
|
|
1417
|
+
format,
|
|
1418
|
+
packId: pack?.id || '',
|
|
1419
|
+
packVersion: pack?.version || '',
|
|
1420
|
+
keyId: pack?.keyId || '',
|
|
1421
|
+
templateId: template?.id || '',
|
|
1422
|
+
templateVersion: template?.version || '',
|
|
1423
|
+
templateSource: template?.source || '',
|
|
1424
|
+
pinnedAt: new Date().toISOString(),
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
async function exactPack(paths, config, binding) {
|
|
1429
|
+
if (!binding?.packId || !binding?.packVersion) return null;
|
|
1430
|
+
return await loadCachedPack(paths, config, binding.packId, binding.packVersion);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
export async function resolveOfficeDesignLibrary({
|
|
1434
|
+
dataDir,
|
|
1435
|
+
documentPath,
|
|
1436
|
+
sourcePath = '',
|
|
1437
|
+
format,
|
|
1438
|
+
created = false,
|
|
1439
|
+
request = {},
|
|
1440
|
+
config: configOverride = null,
|
|
1441
|
+
fetchImpl = fetch,
|
|
1442
|
+
signal = null,
|
|
1443
|
+
} = {}) {
|
|
1444
|
+
const normalizedFormat = String(format || '').toLowerCase();
|
|
1445
|
+
if (!FORMATS.has(normalizedFormat)) throw new Error(`Unsupported Office design library format: ${format}`);
|
|
1446
|
+
const paths = libraryPaths(dataDir);
|
|
1447
|
+
const config = await loadConfig(dataDir, configOverride);
|
|
1448
|
+
const explicitUpgrade = request?.upgradeLibrary === true;
|
|
1449
|
+
const currentBinding = !created && !explicitUpgrade
|
|
1450
|
+
? await readOfficeDesignBinding(dataDir, documentPath)
|
|
1451
|
+
|| (sourcePath ? await readOfficeDesignBinding(dataDir, sourcePath) : null)
|
|
1452
|
+
: null;
|
|
1453
|
+
if (currentBinding) {
|
|
1454
|
+
let pack = null;
|
|
1455
|
+
let warning = '';
|
|
1456
|
+
try {
|
|
1457
|
+
pack = await exactPack(paths, config, currentBinding);
|
|
1458
|
+
} catch (error) {
|
|
1459
|
+
warning = `Pinned Office design pack is unavailable: ${error.message}`;
|
|
1460
|
+
}
|
|
1461
|
+
const index = await readTemplateIndex(paths);
|
|
1462
|
+
const allTemplates = [...(pack?.templates || []), ...(index.templates || [])];
|
|
1463
|
+
const template = allTemplates.find((entry) => (
|
|
1464
|
+
entry.id === currentBinding.templateId
|
|
1465
|
+
&& entry.version === currentBinding.templateVersion
|
|
1466
|
+
&& entry.format === normalizedFormat
|
|
1467
|
+
)) || null;
|
|
1468
|
+
if (currentBinding.templateId && !template) {
|
|
1469
|
+
warning ||= 'Pinned Office template version changed or is unavailable; the existing document remains unchanged.';
|
|
1470
|
+
}
|
|
1471
|
+
return {
|
|
1472
|
+
source: pack ? currentBinding.source : 'starter-fallback',
|
|
1473
|
+
binding: clone(currentBinding),
|
|
1474
|
+
pack,
|
|
1475
|
+
template,
|
|
1476
|
+
layouts: layoutCandidates(pack, template, normalizedFormat),
|
|
1477
|
+
coverage: template?.coverage || null,
|
|
1478
|
+
templateIndexRevision: index.revision || '',
|
|
1479
|
+
warning,
|
|
1480
|
+
pinned: true,
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
const synced = await syncOfficeDesignLibrary({
|
|
1484
|
+
dataDir,
|
|
1485
|
+
config,
|
|
1486
|
+
fetchImpl,
|
|
1487
|
+
force: request?.refreshLibrary === true,
|
|
1488
|
+
allowRemote: created || explicitUpgrade,
|
|
1489
|
+
indexTemplates: created || explicitUpgrade || Boolean(request?.template),
|
|
1490
|
+
signal,
|
|
1491
|
+
});
|
|
1492
|
+
let pack = synced.activePack;
|
|
1493
|
+
if (request?.packId || request?.packVersion) {
|
|
1494
|
+
if (!request.packId || !request.packVersion) {
|
|
1495
|
+
throw new Error('Office design pack selection requires both packId and packVersion');
|
|
1496
|
+
}
|
|
1497
|
+
pack = await loadCachedPack(
|
|
1498
|
+
paths,
|
|
1499
|
+
config,
|
|
1500
|
+
safeId(request.packId, 'requested packId'),
|
|
1501
|
+
String(request.packVersion),
|
|
1502
|
+
);
|
|
1503
|
+
if (!pack) throw new Error(`Requested Office design pack is not cached: ${request.packId}@${request.packVersion}`);
|
|
1504
|
+
}
|
|
1505
|
+
const allTemplates = [...(pack?.templates || []), ...(synced.templates.templates || [])];
|
|
1506
|
+
const explicitSelector = request?.template || '';
|
|
1507
|
+
const selector = explicitSelector || config.defaultTemplates[normalizedFormat] || '';
|
|
1508
|
+
const template = findTemplate(allTemplates, selector, normalizedFormat);
|
|
1509
|
+
if (explicitSelector && !template) {
|
|
1510
|
+
throw new Error(`Office design template is not indexed or does not match ${normalizedFormat}: ${selector}`);
|
|
1511
|
+
}
|
|
1512
|
+
const source = template
|
|
1513
|
+
? template.source
|
|
1514
|
+
: pack
|
|
1515
|
+
? 'remote-pack'
|
|
1516
|
+
: 'mixdog-starter';
|
|
1517
|
+
const binding = bindingFor({
|
|
1518
|
+
pack,
|
|
1519
|
+
template,
|
|
1520
|
+
format: normalizedFormat,
|
|
1521
|
+
source,
|
|
1522
|
+
});
|
|
1523
|
+
return {
|
|
1524
|
+
source,
|
|
1525
|
+
binding,
|
|
1526
|
+
pack,
|
|
1527
|
+
template,
|
|
1528
|
+
layouts: layoutCandidates(pack, template, normalizedFormat),
|
|
1529
|
+
coverage: template?.coverage || null,
|
|
1530
|
+
templateIndexRevision: synced.templates.revision || '',
|
|
1531
|
+
warning: synced.warning || '',
|
|
1532
|
+
pinned: false,
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
export async function inspectOfficeDesignLibrary({
|
|
1537
|
+
dataDir,
|
|
1538
|
+
config: configOverride = null,
|
|
1539
|
+
} = {}) {
|
|
1540
|
+
const paths = libraryPaths(dataDir);
|
|
1541
|
+
const config = await loadConfig(dataDir, configOverride);
|
|
1542
|
+
const state = await readJson(paths.state, {});
|
|
1543
|
+
const index = await readTemplateIndex(paths);
|
|
1544
|
+
let activePack = null;
|
|
1545
|
+
let warning = String(state.lastError || '');
|
|
1546
|
+
try {
|
|
1547
|
+
activePack = await loadCachedPack(paths, config, state.active?.id, state.active?.version);
|
|
1548
|
+
} catch (error) {
|
|
1549
|
+
warning = error.message;
|
|
1550
|
+
}
|
|
1551
|
+
return {
|
|
1552
|
+
enabled: Boolean(config.manifestUrl),
|
|
1553
|
+
active: packSummary(activePack),
|
|
1554
|
+
lastCheckedAt: Number(state.lastCheckedAt) || 0,
|
|
1555
|
+
templateIndex: {
|
|
1556
|
+
revision: index.revision || '',
|
|
1557
|
+
count: index.templates?.length || 0,
|
|
1558
|
+
indexedAt: index.indexedAt || '',
|
|
1559
|
+
},
|
|
1560
|
+
warning,
|
|
1561
|
+
};
|
|
1562
|
+
}
|