Files
ttstd-3d-director/src/editor/io/projectCatalog.ts

241 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 多工程目录(project catalog)
*
* 单工程时代,整个控制台只有一个场景槽位,写在 localStorage 的基础 key 上。
* 支持多工程后,把「一个工程 = 一个命名存储槽」这个已有机制复用起来:
* 每个工程分配一个稳定 id,其状态写在 `${DIRECTOR_SCENE_STORAGE_KEY}:${projectId}` 上,
* 目录本身(有哪些工程、当前打开的是哪个)写在独立的一份 JSON 里。
*
* 这样切工程 = 换存储槽 + 换目录里的 activeProjectId,不改动既有的
* readPersistedDirectorState / writePersistedDirectorState 读写路径。
*
* 首次升级(没有目录、但基础 key 上有旧场景)会把旧场景搬迁成第一个工程,保证老用户不丢数据。
*/
export const DIRECTOR_SCENE_STORAGE_KEY = "storyai-3d-director-desk-demo";
export const DIRECTOR_SCENE_STORAGE_KEY_PREFIX = `${DIRECTOR_SCENE_STORAGE_KEY}:`;
export const PROJECT_CATALOG_STORAGE_KEY = "storyai-3d-director-project-catalog";
export const DEFAULT_PROJECT_NAME = "默认工程";
export const UNTITLED_PROJECT_NAME = "未命名工程";
export interface DirectorProjectMeta {
id: string;
name: string;
createdAt: number;
updatedAt: number;
}
export interface DirectorProjectCatalog {
version: 1;
activeProjectId: string;
projects: DirectorProjectMeta[];
}
function getStorage(): Storage | null {
if (typeof localStorage === "undefined") return null;
return localStorage;
}
/** 归一化工程 / 宿主实例的作用域 id:非字符串一律视为「没有作用域」(落回基础 key) */
export function normalizeProjectScopeId(scopeId: string | null | undefined) {
return typeof scopeId === "string" ? scopeId.trim() : "";
}
/** 由作用域 id 得到该工程在 localStorage 里的场景 key;没有作用域时退回基础 key */
export function getScopedSceneStorageKey(scopeId: string | null | undefined) {
const normalizedScopeId = normalizeProjectScopeId(scopeId);
return normalizedScopeId
? `${DIRECTOR_SCENE_STORAGE_KEY_PREFIX}${normalizedScopeId}`
: DIRECTOR_SCENE_STORAGE_KEY;
}
export function generateProjectId(): string {
return `proj_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
/** 「未命名工程」「未命名工程 2」「未命名工程 3」…… 自动避开重名 */
export function createUntitledProjectName(existing: DirectorProjectMeta[]): string {
const used = new Set(existing.map((project) => project.name));
if (!used.has(UNTITLED_PROJECT_NAME)) return UNTITLED_PROJECT_NAME;
let index = 2;
while (used.has(`${UNTITLED_PROJECT_NAME} ${index}`)) {
index += 1;
}
return `${UNTITLED_PROJECT_NAME} ${index}`;
}
export function createProjectMeta(
existing: DirectorProjectMeta[],
name?: string | null,
now = Date.now(),
id: string = generateProjectId()
): DirectorProjectMeta {
const trimmedName = typeof name === "string" ? name.trim() : "";
return {
id,
name: trimmedName || createUntitledProjectName(existing),
createdAt: now,
updatedAt: now,
};
}
function isProjectMeta(value: unknown): value is DirectorProjectMeta {
if (!value || typeof value !== "object") return false;
const meta = value as Partial<DirectorProjectMeta>;
return (
typeof meta.id === "string" &&
meta.id.length > 0 &&
typeof meta.name === "string" &&
typeof meta.createdAt === "number" &&
typeof meta.updatedAt === "number"
);
}
/**
* 读取工程目录;不存在 / 损坏 / 空目录一律返回 null,由 ensureProjectCatalog 负责重建。
* activeProjectId 不在列表里时回落到第一个工程,避免目录自相矛盾。
*/
export function readProjectCatalog(): DirectorProjectCatalog | null {
const storage = getStorage();
if (!storage) return null;
try {
const raw = storage.getItem(PROJECT_CATALOG_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<DirectorProjectCatalog> | null;
if (!parsed || typeof parsed !== "object") return null;
const projects = Array.isArray(parsed.projects) ? parsed.projects.filter(isProjectMeta) : [];
if (projects.length === 0) return null;
const activeProjectId =
typeof parsed.activeProjectId === "string" &&
projects.some((project) => project.id === parsed.activeProjectId)
? parsed.activeProjectId
: projects[0].id;
return { version: 1, activeProjectId, projects };
} catch {
return null;
}
}
export function writeProjectCatalog(catalog: DirectorProjectCatalog): void {
const storage = getStorage();
if (!storage) return;
try {
storage.setItem(PROJECT_CATALOG_STORAGE_KEY, JSON.stringify(catalog));
} catch {
// 目录很小,几乎不会写失败;真失败也不该让编辑器崩掉。
}
}
/**
* 保证目录存在:不存在就建一个,并把基础 key 上的旧单工程场景搬迁为第一个工程。
* 只在「完全没有目录」时创建,已有目录原样返回,不覆盖用户数据。
*/
export function ensureProjectCatalog(now = Date.now()): DirectorProjectCatalog {
const existing = readProjectCatalog();
if (existing) return existing;
const storage = getStorage();
const project = createProjectMeta([], DEFAULT_PROJECT_NAME, now);
// 旧数据搬迁:单工程时代写在基础 key 上,这里复制(不删除)到新工程的槽位。
if (storage) {
try {
const legacy = storage.getItem(DIRECTOR_SCENE_STORAGE_KEY);
if (legacy) {
storage.setItem(getScopedSceneStorageKey(project.id), legacy);
}
} catch {
// 搬迁失败不影响目录创建:新工程从默认场景开始。
}
}
const catalog: DirectorProjectCatalog = {
version: 1,
activeProjectId: project.id,
projects: [project],
};
writeProjectCatalog(catalog);
return catalog;
}
/** 更新某个工程的 updatedAt(每次落盘时调用),工程不存在则静默忽略 */
export function touchProjectUpdatedAt(projectId: string | null | undefined, now = Date.now()): void {
const normalizedId = normalizeProjectScopeId(projectId);
if (!normalizedId) return;
const catalog = readProjectCatalog();
if (!catalog) return;
const index = catalog.projects.findIndex((project) => project.id === normalizedId);
if (index < 0) return;
const projects = catalog.projects.map((project, projectIndex) =>
projectIndex === index ? { ...project, updatedAt: now } : project
);
writeProjectCatalog({ ...catalog, projects });
}
/** 读取某个工程槽位里的原始场景 JSON 字符串(未做迁移 / 校验) */
export function readScopedSceneRaw(scopeId: string | null | undefined): string | null {
const storage = getStorage();
if (!storage) return null;
try {
return storage.getItem(getScopedSceneStorageKey(scopeId));
} catch {
return null;
}
}
/** 直接写入某个工程槽位的原始场景 JSON(用于复制 / 导入工程文件) */
export function writeScopedSceneRaw(scopeId: string | null | undefined, raw: string): void {
const storage = getStorage();
if (!storage) return;
try {
storage.setItem(getScopedSceneStorageKey(scopeId), raw);
} catch {
// 场景可能含 base64 全景图,超配额时静默失败,保持编辑器可用。
}
}
export function removeScopedScene(scopeId: string | null | undefined): void {
const storage = getStorage();
if (!storage) return;
try {
storage.removeItem(getScopedSceneStorageKey(scopeId));
} catch {
// 忽略:删除失败最多留一份孤儿数据。
}
}
/**
* 启动时决定应该打开哪个工程槽位:
* - 宿主通过 `?instanceId=` 指定了实例作用域时,优先用宿主作用域(嵌入场景不抢多工程的槽位);
* - 否则打开目录里当前激活的工程(首次运行会先建目录并搬迁旧场景)。
*/
export function resolveStartupProjectScopeId(hostScopeId: string | null | undefined): string | null {
const normalizedHostId = normalizeProjectScopeId(hostScopeId);
if (normalizedHostId) return normalizedHostId;
try {
return ensureProjectCatalog().activeProjectId;
} catch {
return null;
}
}