terms: per-term installer visibility toggles + universal delete (v0.3.4)
- _meta.json: customLabels -> terms.{label,showInInstaller,showInInstallerRp}
- Drop builtin protection; any term kind can be deleted/added/toggled
- New public route /manifest/terms/<pack>/index.json for installer term lists
- Installers fetch terms:list dynamically; skip agreement step if list empty
- Term editor: 2 visibility checkboxes (설치기 / 리소스팩 설치기), multi-select
- Migration from old schema preserves custom labels (default: visible in both)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -318,24 +318,31 @@ export async function savePackList(packKey: string, list: PackList): Promise<voi
|
||||
// ─── Terms (Markdown 약관) ─────────────────────────────────────────────
|
||||
// 사이트와 인스톨러가 약관을 보여주기 위해 사용하는 markdown 파일.
|
||||
// - 음악퀴즈(pack)별로 독립 폴더(`manifest/terms/<packKey>/`) 에 저장한다.
|
||||
// - 5개 builtin 종류는 인스톨러가 직접 참조하므로 삭제할 수 없다.
|
||||
// - 그 외 임의 kind 는 사이트에서 추가/삭제 가능. 라벨은 `<packKey>/_meta.json` 에 저장.
|
||||
// - 각 약관(.md) 은 `_meta.json` 의 `terms.<kind>` 엔트리로 라벨/표시 대상이 관리된다.
|
||||
// 엔트리: { label, showInInstaller, showInInstallerRp }
|
||||
// - 모든 약관은 추가/삭제 가능. builtin 같은 보호 개념은 더 이상 없음 (v0.3.4~).
|
||||
// 인스톨러는 하드코딩 5종 대신 `index.json` 에서 자기 인스톨러용 약관 목록을 받는다.
|
||||
// - 첫 접근 시 5개 기본 약관(map/mod/installer + resourcepack/installer-rp) 을 시드.
|
||||
// - 파일명 규칙: `[a-z0-9][a-z0-9-]{0,31}\.md` (소문자/숫자/하이픈, 32자 이내).
|
||||
// - 레거시(전역) `manifest/terms/*.md` 파일이 남아 있으면 packKey 폴더 첫 접근 시 자동 시드.
|
||||
export type TermKind = string
|
||||
|
||||
/** 인스톨러가 하드코딩으로 참조하는 builtin kind. 삭제 금지. */
|
||||
export const BUILTIN_TERM_KINDS = ['map', 'resourcepack', 'mod', 'installer', 'installer-rp'] as const
|
||||
export type BuiltinTermKind = typeof BUILTIN_TERM_KINDS[number]
|
||||
|
||||
/** builtin 라벨. 사용자 정의 kind 는 _meta.json 에 저장된 라벨을 쓴다. */
|
||||
const BUILTIN_TERM_LABELS: Record<BuiltinTermKind, string> = {
|
||||
'map': '맵 약관',
|
||||
'resourcepack': '리소스팩 약관',
|
||||
'mod': '모드 약관',
|
||||
'installer': '설치기 약관',
|
||||
'installer-rp': '리소스팩 설치기 약관'
|
||||
}
|
||||
/**
|
||||
* 처음 pack 폴더를 만들 때 시드되는 기본 약관 5종 + 기본 표시 대상.
|
||||
* 사용자는 이후 자유롭게 삭제하거나 표시 대상을 바꿀 수 있다.
|
||||
*/
|
||||
const DEFAULT_TERM_SEEDS: Array<{
|
||||
kind: string
|
||||
label: string
|
||||
showInInstaller: boolean
|
||||
showInInstallerRp: boolean
|
||||
}> = [
|
||||
{ kind: 'map', label: '맵 약관', showInInstaller: true, showInInstallerRp: false },
|
||||
{ kind: 'mod', label: '모드 약관', showInInstaller: true, showInInstallerRp: false },
|
||||
{ kind: 'installer', label: '설치기 약관', showInInstaller: true, showInInstallerRp: false },
|
||||
{ kind: 'resourcepack', label: '리소스팩 약관', showInInstaller: false, showInInstallerRp: true },
|
||||
{ kind: 'installer-rp', label: '리소스팩 설치기 약관', showInInstaller: false, showInInstallerRp: true }
|
||||
]
|
||||
|
||||
const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
||||
|
||||
@@ -343,13 +350,14 @@ export function isTermKind(value: unknown): value is TermKind {
|
||||
return typeof value === 'string' && TERM_KIND_RE.test(value)
|
||||
}
|
||||
|
||||
export function isBuiltinTermKind(value: string): value is BuiltinTermKind {
|
||||
return (BUILTIN_TERM_KINDS as readonly string[]).includes(value)
|
||||
export interface TermEntry {
|
||||
label: string
|
||||
showInInstaller: boolean
|
||||
showInInstallerRp: boolean
|
||||
}
|
||||
|
||||
interface TermsMeta {
|
||||
/** 사용자 정의 kind 라벨. builtin 은 들어가지 않는다. */
|
||||
customLabels: Record<string, string>
|
||||
terms: Record<string, TermEntry>
|
||||
}
|
||||
|
||||
const TERMS_META_FILE = '_meta.json'
|
||||
@@ -375,58 +383,148 @@ function isValidPackKey(packKey: string): boolean {
|
||||
*/
|
||||
export async function ensurePackTermsDir(packKey: string): Promise<string> {
|
||||
const dir = termsDirForPack(packKey)
|
||||
let isNew = false
|
||||
try {
|
||||
await fsp.access(dir)
|
||||
return dir
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
await fsp.mkdir(dir, { recursive: true })
|
||||
// 레거시 전역 파일을 시드로 복사.
|
||||
try {
|
||||
const legacyEntries = await fsp.readdir(manifestTermsDirPath, { withFileTypes: true })
|
||||
for (const ent of legacyEntries) {
|
||||
if (!ent.isFile()) continue
|
||||
const name = ent.name
|
||||
if (name === TERMS_META_FILE) {
|
||||
isNew = true
|
||||
await fsp.mkdir(dir, { recursive: true })
|
||||
// 레거시(전역) .md 파일이 남아 있으면 그대로 복사 (.md 만, _meta.json 은 새 스키마로 새로 씀).
|
||||
try {
|
||||
const legacyEntries = await fsp.readdir(manifestTermsDirPath, { withFileTypes: true })
|
||||
for (const ent of legacyEntries) {
|
||||
if (!ent.isFile()) continue
|
||||
const name = ent.name
|
||||
if (!name.toLowerCase().endsWith('.md')) continue
|
||||
const kind = name.slice(0, -3)
|
||||
if (!TERM_KIND_RE.test(kind)) continue
|
||||
try {
|
||||
await fsp.copyFile(
|
||||
path.join(manifestTermsDirPath, name),
|
||||
path.join(dir, name)
|
||||
)
|
||||
} catch { /* ignore */ }
|
||||
continue
|
||||
}
|
||||
if (!name.toLowerCase().endsWith('.md')) continue
|
||||
const kind = name.slice(0, -3)
|
||||
if (!TERM_KIND_RE.test(kind)) continue
|
||||
try {
|
||||
await fsp.copyFile(
|
||||
path.join(manifestTermsDirPath, name),
|
||||
path.join(dir, name)
|
||||
)
|
||||
} catch { /* ignore */ }
|
||||
} catch (error2) {
|
||||
if ((error2 as NodeJS.ErrnoException).code !== 'ENOENT') throw error2
|
||||
}
|
||||
}
|
||||
// 폴더가 새로 만들어졌든 기존이든, _meta.json 이 없거나 구 스키마면 5종 기본 + .md 매칭으로 보완.
|
||||
await ensureMetaInitialized(dir, isNew)
|
||||
return dir
|
||||
}
|
||||
|
||||
/**
|
||||
* `_meta.json` 이 없으면 5종 기본 + 디스크 .md 매칭으로 새로 작성한다.
|
||||
* 구 스키마(`customLabels`) 가 있으면 새 스키마(`terms`) 로 변환한다.
|
||||
* 이미 새 스키마면 그대로 둔다 (사용자가 끈 visibility 가 다시 켜지지 않도록).
|
||||
*/
|
||||
async function ensureMetaInitialized(dir: string, dirWasJustCreated: boolean): Promise<void> {
|
||||
const metaPath = path.join(dir, TERMS_META_FILE)
|
||||
let parsed: unknown = null
|
||||
try {
|
||||
const raw = await fsp.readFile(metaPath, 'utf8')
|
||||
parsed = JSON.parse(raw)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
return dir
|
||||
|
||||
// 이미 새 스키마면 종료. 빠진 default kind 가 디스크에 있다면 그것만 보충.
|
||||
if (parsed && typeof parsed === 'object' && (parsed as Record<string, unknown>).terms) {
|
||||
const meta = parsed as { terms: Record<string, unknown> }
|
||||
let changed = false
|
||||
for (const seed of DEFAULT_TERM_SEEDS) {
|
||||
if (meta.terms[seed.kind]) continue
|
||||
// .md 가 실제로 디스크에 있을 때만 보충 (없는 약관까지 자동 부활시키지 않음).
|
||||
try {
|
||||
await fsp.access(path.join(dir, `${seed.kind}.md`))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
meta.terms[seed.kind] = {
|
||||
label: seed.label,
|
||||
showInInstaller: seed.showInInstaller,
|
||||
showInInstallerRp: seed.showInInstallerRp
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if (changed) {
|
||||
await fsp.writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 구 스키마 customLabels 만 있던 경우 → 새 스키마로 변환.
|
||||
const oldCustomLabels: Record<string, string> = {}
|
||||
if (parsed && typeof parsed === 'object' && (parsed as Record<string, unknown>).customLabels
|
||||
&& typeof (parsed as Record<string, unknown>).customLabels === 'object') {
|
||||
for (const [k, v] of Object.entries((parsed as { customLabels: Record<string, unknown> }).customLabels)) {
|
||||
if (typeof v === 'string' && TERM_KIND_RE.test(k)) oldCustomLabels[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
const terms: Record<string, TermEntry> = {}
|
||||
// 5종 기본: 디스크에 .md 가 있을 때만 추가 (없는 건 사용자가 의도적으로 지운 것일 수 있음).
|
||||
// 다만 폴더가 막 생성된 경우는 5종을 무조건 시드 (legacy 시드가 비어 있어도).
|
||||
for (const seed of DEFAULT_TERM_SEEDS) {
|
||||
if (!dirWasJustCreated) {
|
||||
try {
|
||||
await fsp.access(path.join(dir, `${seed.kind}.md`))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// 폴더 새로 생성 케이스: .md 가 없으면 빈 파일 만들어 줌.
|
||||
const filePath = path.join(dir, `${seed.kind}.md`)
|
||||
try {
|
||||
await fsp.access(filePath)
|
||||
} catch {
|
||||
await fsp.writeFile(filePath, `# ${seed.label}\n\n`, 'utf8')
|
||||
}
|
||||
}
|
||||
terms[seed.kind] = {
|
||||
label: seed.label,
|
||||
showInInstaller: seed.showInInstaller,
|
||||
showInInstallerRp: seed.showInInstallerRp
|
||||
}
|
||||
}
|
||||
// 구 스키마의 사용자 정의 약관은 양쪽 인스톨러에 보이도록 기본값으로.
|
||||
for (const [k, label] of Object.entries(oldCustomLabels)) {
|
||||
if (terms[k]) continue
|
||||
try {
|
||||
await fsp.access(path.join(dir, `${k}.md`))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
terms[k] = { label, showInInstaller: true, showInInstallerRp: true }
|
||||
}
|
||||
await fsp.writeFile(metaPath, `${JSON.stringify({ terms }, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
async function loadTermsMeta(packKey: string): Promise<TermsMeta> {
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
try {
|
||||
const raw = await fsp.readFile(path.join(dir, TERMS_META_FILE), 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
const customLabels: Record<string, string> = {}
|
||||
if (parsed && typeof parsed === 'object' && parsed.customLabels && typeof parsed.customLabels === 'object') {
|
||||
for (const [k, v] of Object.entries(parsed.customLabels as Record<string, unknown>)) {
|
||||
if (typeof v === 'string' && TERM_KIND_RE.test(k)) customLabels[k] = v
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
const result: TermsMeta = { terms: {} }
|
||||
if (parsed && typeof parsed === 'object' && (parsed as Record<string, unknown>).terms
|
||||
&& typeof (parsed as Record<string, unknown>).terms === 'object') {
|
||||
for (const [k, v] of Object.entries((parsed as { terms: Record<string, unknown> }).terms)) {
|
||||
if (!TERM_KIND_RE.test(k)) continue
|
||||
if (!v || typeof v !== 'object') continue
|
||||
const entry = v as Record<string, unknown>
|
||||
const label = typeof entry.label === 'string' ? entry.label : k
|
||||
result.terms[k] = {
|
||||
label,
|
||||
showInInstaller: entry.showInInstaller === true,
|
||||
showInInstallerRp: entry.showInInstallerRp === true
|
||||
}
|
||||
}
|
||||
}
|
||||
return { customLabels }
|
||||
return result
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { customLabels: {} }
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { terms: {} }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -443,49 +541,83 @@ async function saveTermsMeta(packKey: string, meta: TermsMeta): Promise<void> {
|
||||
export interface TermItem {
|
||||
kind: string
|
||||
label: string
|
||||
builtin: boolean
|
||||
showInInstaller: boolean
|
||||
showInInstallerRp: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 디스크의 .md 파일 + _meta.json 을 합쳐 약관 목록을 만든다.
|
||||
* - builtin 5종은 파일 존재 여부와 무관하게 항상 포함된다 (인스톨러가 fetch 하므로).
|
||||
* - 디스크에 있고 _meta.json 에 라벨이 있는 사용자 정의 kind 도 포함.
|
||||
* - builtin → 사용자 정의 순서, builtin 내부는 BUILTIN_TERM_KINDS 정의 순서를 유지.
|
||||
* 디스크의 .md 파일과 매칭되면서 `_meta.json` 의 `terms` 에 등록된 약관 목록을 반환.
|
||||
* 정렬: 5종 기본(DEFAULT_TERM_SEEDS 순서) → 그 외 사용자 정의 (kind 사전순).
|
||||
*/
|
||||
export async function listTermsWithLabels(packKey: string): Promise<TermItem[]> {
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
const meta = await loadTermsMeta(packKey)
|
||||
const items: TermItem[] = []
|
||||
for (const kind of BUILTIN_TERM_KINDS) {
|
||||
items.push({ kind, label: BUILTIN_TERM_LABELS[kind], builtin: true })
|
||||
}
|
||||
// 디스크에 실제로 존재하는 사용자 정의 .md 파일만 노출.
|
||||
let onDisk: string[] = []
|
||||
try {
|
||||
onDisk = await fsp.readdir(dir)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
const customKinds = new Set<string>()
|
||||
const mdKinds = new Set<string>()
|
||||
for (const fname of onDisk) {
|
||||
if (!fname.toLowerCase().endsWith('.md')) continue
|
||||
const kind = fname.slice(0, -3)
|
||||
if (!TERM_KIND_RE.test(kind)) continue
|
||||
if (isBuiltinTermKind(kind)) continue
|
||||
customKinds.add(kind)
|
||||
mdKinds.add(kind)
|
||||
}
|
||||
// _meta.json 에 라벨이 등록된 것만 노출 (라벨 없는 orphan .md 는 무시).
|
||||
for (const kind of Object.keys(meta.customLabels).sort((a, b) => a.localeCompare(b, 'ko'))) {
|
||||
if (!customKinds.has(kind)) continue
|
||||
items.push({ kind, label: meta.customLabels[kind], builtin: false })
|
||||
const items: TermItem[] = []
|
||||
const seen = new Set<string>()
|
||||
// 1) 기본 시드 순서 우선.
|
||||
for (const seed of DEFAULT_TERM_SEEDS) {
|
||||
const entry = meta.terms[seed.kind]
|
||||
if (!entry) continue
|
||||
if (!mdKinds.has(seed.kind)) continue
|
||||
items.push({
|
||||
kind: seed.kind,
|
||||
label: entry.label,
|
||||
showInInstaller: entry.showInInstaller,
|
||||
showInInstallerRp: entry.showInInstallerRp
|
||||
})
|
||||
seen.add(seed.kind)
|
||||
}
|
||||
// 2) 그 외 사용자 정의: 사전순.
|
||||
const rest = Object.keys(meta.terms).filter((k) => !seen.has(k))
|
||||
rest.sort((a, b) => a.localeCompare(b, 'ko'))
|
||||
for (const kind of rest) {
|
||||
if (!mdKinds.has(kind)) continue
|
||||
const entry = meta.terms[kind]
|
||||
items.push({
|
||||
kind,
|
||||
label: entry.label,
|
||||
showInInstaller: entry.showInInstaller,
|
||||
showInInstallerRp: entry.showInInstallerRp
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export async function getTermLabel(packKey: string, kind: string): Promise<string> {
|
||||
if (isBuiltinTermKind(kind)) return BUILTIN_TERM_LABELS[kind]
|
||||
const meta = await loadTermsMeta(packKey)
|
||||
return meta.customLabels[kind] ?? kind
|
||||
return meta.terms[kind]?.label ?? kind
|
||||
}
|
||||
|
||||
export async function getTermEntry(packKey: string, kind: string): Promise<TermEntry | null> {
|
||||
const meta = await loadTermsMeta(packKey)
|
||||
return meta.terms[kind] ?? null
|
||||
}
|
||||
|
||||
export async function setTermVisibility(
|
||||
packKey: string,
|
||||
kind: string,
|
||||
visibility: { showInInstaller: boolean; showInInstallerRp: boolean }
|
||||
): Promise<void> {
|
||||
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||
const meta = await loadTermsMeta(packKey)
|
||||
const entry = meta.terms[kind]
|
||||
if (!entry) throw new Error('term not found')
|
||||
entry.showInInstaller = !!visibility.showInInstaller
|
||||
entry.showInInstallerRp = !!visibility.showInInstallerRp
|
||||
await saveTermsMeta(packKey, meta)
|
||||
}
|
||||
|
||||
export async function loadTerm(packKey: string, kind: TermKind): Promise<string> {
|
||||
@@ -508,14 +640,17 @@ export async function saveTerm(packKey: string, kind: TermKind, markdown: string
|
||||
await fsp.writeFile(filePath, normalized.endsWith('\n') ? normalized : `${normalized}\n`, 'utf8')
|
||||
}
|
||||
|
||||
/** 새로운 사용자 정의 약관 추가. kind 충돌/builtin 충돌은 예외. 빈 .md 파일을 만든다. */
|
||||
/**
|
||||
* 새 약관 추가. kind 충돌은 예외. 빈 `.md` 파일을 만든다.
|
||||
* v0.3.4~: builtin 보호 개념이 없어 임의 kind 를 추가/삭제할 수 있다. 다만
|
||||
* `meta.terms` 에 이미 있는 kind 와 충돌하면 거부. 표시 대상 기본값은 양쪽 인스톨러 모두.
|
||||
*/
|
||||
export async function createTerm(packKey: string, kind: string, label: string): Promise<void> {
|
||||
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||
if (isBuiltinTermKind(kind)) throw new Error('builtin term kind cannot be created')
|
||||
const cleanLabel = label.trim()
|
||||
if (cleanLabel.length === 0 || cleanLabel.length > 50) throw new Error('invalid label length')
|
||||
const meta = await loadTermsMeta(packKey)
|
||||
if (meta.customLabels[kind]) throw new Error('term kind already exists')
|
||||
if (meta.terms[kind]) throw new Error('term kind already exists')
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
const filePath = path.join(dir, `${kind}.md`)
|
||||
// 파일 충돌도 막는다 (수동 생성된 .md 가 있을 수 있음).
|
||||
@@ -526,14 +661,19 @@ export async function createTerm(packKey: string, kind: string, label: string):
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
await fsp.writeFile(filePath, `# ${cleanLabel}\n\n`, 'utf8')
|
||||
meta.customLabels[kind] = cleanLabel
|
||||
// 기본 시드 kind 면 그 시드의 visibility 기본을 따르고, 그 외는 양쪽 인스톨러 모두 표시.
|
||||
const seed = DEFAULT_TERM_SEEDS.find((s) => s.kind === kind)
|
||||
meta.terms[kind] = {
|
||||
label: cleanLabel,
|
||||
showInInstaller: seed ? seed.showInInstaller : true,
|
||||
showInInstallerRp: seed ? seed.showInInstallerRp : true
|
||||
}
|
||||
await saveTermsMeta(packKey, meta)
|
||||
}
|
||||
|
||||
/** 사용자 정의 약관 삭제. builtin 은 거부. */
|
||||
/** 약관 삭제. v0.3.4~: builtin 보호 없음 — 모든 kind 삭제 가능. */
|
||||
export async function deleteTerm(packKey: string, kind: string): Promise<void> {
|
||||
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||
if (isBuiltinTermKind(kind)) throw new Error('builtin term kind cannot be deleted')
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
const filePath = path.join(dir, `${kind}.md`)
|
||||
try {
|
||||
@@ -542,17 +682,17 @@ export async function deleteTerm(packKey: string, kind: string): Promise<void> {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
const meta = await loadTermsMeta(packKey)
|
||||
if (meta.customLabels[kind]) {
|
||||
delete meta.customLabels[kind]
|
||||
if (meta.terms[kind]) {
|
||||
delete meta.terms[kind]
|
||||
await saveTermsMeta(packKey, meta)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다른 음악퀴즈의 약관 전체를 현재 pack 으로 복사한다 (불러오기).
|
||||
* - source 의 모든 .md 와 _meta.json 을 target 에 덮어쓴다.
|
||||
* - target 에만 있던 사용자 정의 약관은 그대로 둔다 (source 에는 없으니 안 건드림).
|
||||
* - 동일한 kind 가 source 에도 있다면 source 값으로 덮어씀.
|
||||
* - source 의 모든 .md 를 target 에 덮어쓴다.
|
||||
* - target 에만 있던 약관 엔트리는 그대로 둔다 (source 에는 없으니 안 건드림).
|
||||
* - 동일한 kind 가 source 에도 있다면 source 의 라벨/표시 대상으로 덮어씀.
|
||||
*/
|
||||
export async function importTerms(targetPackKey: string, sourcePackKey: string): Promise<void> {
|
||||
if (!isValidPackKey(targetPackKey) || !isValidPackKey(sourcePackKey)) {
|
||||
@@ -579,12 +719,12 @@ export async function importTerms(targetPackKey: string, sourcePackKey: string):
|
||||
await fsp.copyFile(path.join(sourceDir, name), path.join(targetDir, name))
|
||||
}
|
||||
|
||||
// 사용자 정의 라벨도 source 기준으로 머지 (덮어쓰기).
|
||||
const mergedLabels: Record<string, string> = { ...targetMeta.customLabels }
|
||||
for (const [k, v] of Object.entries(sourceMeta.customLabels)) {
|
||||
mergedLabels[k] = v
|
||||
// 약관 엔트리도 source 기준으로 머지 (덮어쓰기).
|
||||
const mergedTerms: Record<string, TermEntry> = { ...targetMeta.terms }
|
||||
for (const [k, v] of Object.entries(sourceMeta.terms)) {
|
||||
mergedTerms[k] = { ...v }
|
||||
}
|
||||
await saveTermsMeta(targetPackKey, { customLabels: mergedLabels })
|
||||
await saveTermsMeta(targetPackKey, { terms: mergedTerms })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user