feat(file-manager): suggest-name via naming engine; plan.naming source from override chain (W9-H G9)
- POST /files/suggest-name renders the effective template (entity > root > global) with the same variables as organize: file name segment for files, work/season dir segment for directories; identity_uncertain when no entity - planImport plan.naming.templateSource/templateVersion now come from effectiveNaming + the registered template version (fixes the leftover DEFAULT_TEMPLATES reference after the naming chain landed) Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8651a35594
commit
99a3e17210
5 files changed
+129
-14
No files matched your search
@@ -1,10 +1,98 @@
|
||||
import { extname } from "node:path";
|
||||
import type { FilesSuggestNameRequest, FilesSuggestNameResult } from "@umt/contracts";
|
||||
import { buildNamingVars, normalizeTitle, parseReleasePath, renderNamingPath } from "@umt/domain";
|
||||
import { effectiveNaming, loadNamingEntity } from "../acquisition-organize/services/naming.ts";
|
||||
import { FilesError, type FmDeps, resolveIn } from "./common.ts";
|
||||
|
||||
// G9 推荐名:按命名模板(作品 > 库根 > 全局)对选中文件渲染文件名/目录段。
|
||||
// 依赖 W9-C 的命名引擎(packages/domain/src/naming + POST /naming/preview);引擎提交前返回 501 not_implemented。
|
||||
// G9 推荐名:按命名模板(作品 > 库根 > 全局)对选中文件/目录渲染名字(与整理用同一引擎与变量来源)。
|
||||
// 作品:请求指定 > 已登记资产所属 > 解析标题在本地别名里唯一精确命中;都没有 → name=null, reason=identity_uncertain。
|
||||
// 文件给文件名段;目录按解析结果给季目录段(只有季号)或作品目录段。
|
||||
|
||||
const empty = (reason: string, mediaEntityId: string | null = null): FilesSuggestNameResult => ({
|
||||
name: null,
|
||||
relativePath: null,
|
||||
reason,
|
||||
mediaEntityId,
|
||||
templateSource: null,
|
||||
segments: [],
|
||||
});
|
||||
|
||||
function segmentsOf(rel: string) {
|
||||
const parts = rel.split("/");
|
||||
const file = parts.pop()!;
|
||||
const parent = parts.pop();
|
||||
const grandParent = parts.pop();
|
||||
return { file, ...(parent ? { parent } : {}), ...(grandParent ? { grandParent } : {}) };
|
||||
}
|
||||
|
||||
export async function suggestName(d: FmDeps, body: FilesSuggestNameRequest): Promise<FilesSuggestNameResult> {
|
||||
await resolveIn(d, body.root, body.path);
|
||||
throw new FilesError("not_implemented", "命名引擎尚未就绪,暂不能按模板生成推荐名");
|
||||
const db = d.ctx.db;
|
||||
const r = await resolveIn(d, body.root, body.path);
|
||||
if (r.rel === "") throw new FilesError("bad_request", "不能给登记目录本身生成推荐名");
|
||||
const isDir = !!r.lst?.isDirectory();
|
||||
const parsed = parseReleasePath(segmentsOf(r.rel));
|
||||
|
||||
let entityId = body.mediaEntityId ?? null;
|
||||
if (!entityId && r.def.libraryRootId) {
|
||||
const row = await db
|
||||
.selectFrom("asset_location as l")
|
||||
.innerJoin("media_asset as a", "a.id", "l.media_asset_id")
|
||||
.select(["a.media_entity_id"])
|
||||
.where("l.library_root_id", "=", r.def.libraryRootId)
|
||||
.where("l.status", "<>", "removed")
|
||||
.where((eb) =>
|
||||
isDir
|
||||
? eb("l.relative_path", "like", `${r.rel.replace(/[\\%_]/g, (m) => `\\${m}`)}/%`)
|
||||
: eb("l.relative_path", "=", r.rel),
|
||||
)
|
||||
.executeTakeFirst();
|
||||
entityId = row?.media_entity_id ?? null;
|
||||
}
|
||||
if (!entityId && parsed.title) {
|
||||
const n = normalizeTitle(parsed.title);
|
||||
const hits = n
|
||||
? await db
|
||||
.selectFrom("title_alias as a")
|
||||
.innerJoin("media_entity as e", "e.id", "a.media_entity_id")
|
||||
.select(["e.id", "e.year"])
|
||||
.distinct()
|
||||
.where("a.normalized", "=", n)
|
||||
.where("e.status", "not in", ["merged", "archived"])
|
||||
.limit(5)
|
||||
.execute()
|
||||
: [];
|
||||
const byYear = parsed.year ? hits.filter((h) => h.year === parsed.year) : hits;
|
||||
if (byYear.length === 1) entityId = byYear[0]!.id;
|
||||
}
|
||||
if (!entityId) return empty("identity_uncertain");
|
||||
|
||||
const entity = await db.selectFrom("media_entity").selectAll().where("id", "=", entityId).executeTakeFirst();
|
||||
if (!entity) throw new FilesError("not_found", "作品不存在");
|
||||
const root = r.def.libraryRootId
|
||||
? await db.selectFrom("library_root").selectAll().where("id", "=", r.def.libraryRootId).executeTakeFirst()
|
||||
: undefined;
|
||||
const namingSettings = await d.ctx.settings.get("naming").catch(() => undefined);
|
||||
const eff = effectiveNaming(namingSettings, root ?? null, entity);
|
||||
const namingEntity = await loadNamingEntity(db, entity, { libraryRoot: root?.name });
|
||||
const kind = namingEntity.kind ?? "movie";
|
||||
const ext = isDir ? "" : extname(r.rel);
|
||||
const vars = buildNamingVars(namingEntity, parsed, isDir ? {} : { fileExt: ext });
|
||||
const out = renderNamingPath(eff.templates[kind], vars, isDir ? { mode: "dirs" } : {});
|
||||
const segments = out.segments.map((s) => ({ text: s.text, role: s.role }));
|
||||
if (!out.ok)
|
||||
return {
|
||||
...empty(`naming_failed: ${out.errors.map((e) => e.message).join("; ")}`, entityId),
|
||||
templateSource: eff.sources[kind],
|
||||
segments,
|
||||
};
|
||||
const want = isDir ? (parsed.season !== undefined && !parsed.episodes ? "season" : "root") : "file";
|
||||
const seg = out.segments.find((s) => s.role === want);
|
||||
return {
|
||||
name: seg?.text || null,
|
||||
relativePath: out.relativePath,
|
||||
reason: seg?.text ? null : `no_${want}_segment`,
|
||||
mediaEntityId: entityId,
|
||||
templateSource: eff.sources[kind],
|
||||
segments,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user