terms: per-pack storage + import from another pack (v0.3.2)
- store.ts: 약관을 manifest/terms/<packKey>/ 폴더별로 저장. 첫 접근 시 legacy 전역 .md 파일을 시드로 자동 복사한다. - importTerms() 추가: 다른 음악퀴즈의 .md + _meta.json 을 현재 pack 으로 복사한다. 동일 kind 는 source 값으로 덮어쓴다. - /op/agreement 라우트를 세 단계로 분리: · /op/agreement → 음악퀴즈 카드 선택 페이지 · /op/agreement/:packName → 해당 pack 의 약관 목록 + 추가 + 불러오기 · /op/agreement/:packName/:kind → 에디터 - 공개 라우트도 /manifest/terms/:packKey/:fileName 으로 변경. - 설치기 main.ts: state.selectedKey 를 약관 URL 에 포함하도록 수정 (메인 + rp 양쪽). pack 미선택 상태에서는 에러 반환. - termsEditor.js: PACK_KEY 를 받아 저장 URL 에 포함. - 다른 음악퀴즈 후보 select + 확인 모달 + locale 추가. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -252,14 +252,15 @@ ipcMain.handle('rp:packs:select', async (_event, packKey: string) => {
|
||||
ipcMain.handle('rp:i18n:dict', () => localeDict)
|
||||
|
||||
// ── IPC: 약관 다운로드 ──────────────────────────────
|
||||
// 사이트가 /manifest/terms/<kind>.md 로 노출하는 md 파일을 그대로 받아 본문(string) 으로 반환.
|
||||
// 사이트가 /manifest/terms/<packKey>/<kind>.md 로 노출하는 md 파일을 그대로 받아 본문(string) 으로 반환.
|
||||
// rp 인스톨러에서는 'resourcepack' 과 'installer-rp' 두 종류만 실제로 사용하지만, 메인
|
||||
// 인스톨러와 동일한 화이트리스트를 둬서 사이트 컴포넌트 분류와 1:1 로 매칭되게 한다.
|
||||
const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp'])
|
||||
ipcMain.handle('rp:terms:get', async (_event, kind: string) => {
|
||||
if (!TERM_KIND_WHITELIST.has(kind)) return { ok: false, message: 'unknown term kind' }
|
||||
if (!state.selectedKey) return { ok: false, message: 'pack not selected' }
|
||||
try {
|
||||
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(kind)}.md`
|
||||
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/${encodeURIComponent(kind)}.md`
|
||||
const buf = await fetchBuffer(url)
|
||||
return { ok: true, content: buf.toString('utf8') }
|
||||
} catch (error) {
|
||||
|
||||
@@ -154,15 +154,18 @@ ipcMain.handle('packs:load', async (_event, manifestUrlInput?: string): Promise<
|
||||
return results
|
||||
})
|
||||
|
||||
// 약관(Markdown) 을 사이트(/manifest/terms/<kind>.md) 에서 받아와 그대로 돌려준다.
|
||||
// 화이트리스트로 5종 제한. 네트워크 실패 시 에러 메시지가 그대로 화면에 노출된다.
|
||||
// 약관(Markdown) 을 사이트(/manifest/terms/<packKey>/<kind>.md) 에서 받아와 그대로 돌려준다.
|
||||
// 화이트리스트로 5종 제한. pack 미선택 상태에서는 에러를 돌려준다. 네트워크 실패 시 에러 메시지가 그대로 화면에 노출된다.
|
||||
const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp'])
|
||||
ipcMain.handle('terms:get', async (_event, kind: string): Promise<{ ok: boolean; content?: string; message?: string }> => {
|
||||
if (!TERM_KIND_WHITELIST.has(kind)) {
|
||||
return { ok: false, message: 'unknown term kind' }
|
||||
}
|
||||
if (!state.selectedKey) {
|
||||
return { ok: false, message: 'pack not selected' }
|
||||
}
|
||||
try {
|
||||
const url = `${state.baseUrl}/manifest/terms/${kind}.md`
|
||||
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/${kind}.md`
|
||||
const buf = await fetchBuffer(url)
|
||||
return { ok: true, content: buf.toString('utf8') }
|
||||
} catch (error) {
|
||||
|
||||
@@ -64,15 +64,16 @@ app.get('/manifest.json', (_req, res) => {
|
||||
})
|
||||
|
||||
// 설치기 + 사이트가 약관(markdown) 을 가져갈 수 있도록 .md 만 허용한다.
|
||||
// 음악퀴즈(pack) 별로 manifest/terms/<packKey>/<file>.md 에서 노출한다.
|
||||
// _meta.json 같은 시스템 파일이나 경로 탈출은 isPublicTermsFile 에서 차단.
|
||||
app.get('/manifest/terms/:fileName', (req, res) => {
|
||||
const fileName = req.params.fileName
|
||||
if (!isPublicTermsFile(fileName)) {
|
||||
app.get('/manifest/terms/:packKey/:fileName', (req, res) => {
|
||||
const { packKey, fileName } = req.params
|
||||
if (!isPublicTermsFile(packKey, fileName)) {
|
||||
res.status(404).send('Not Found')
|
||||
return
|
||||
}
|
||||
res.type('text/markdown; charset=utf-8')
|
||||
res.sendFile(path.join(manifestTermsDirPath, fileName), (err) => {
|
||||
res.sendFile(path.join(manifestTermsDirPath, packKey, fileName), (err) => {
|
||||
if (!err || res.headersSent) return
|
||||
res.status(404).send('Not Found')
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
deletePackKeys,
|
||||
deleteTerm,
|
||||
getTermLabel,
|
||||
importTerms,
|
||||
isBuiltinTermKind,
|
||||
isTermKind,
|
||||
listPackKeys,
|
||||
@@ -304,35 +305,107 @@ opRouter.get('/op/datapack/:packName/images-zip', requireAuth, async (req, res,
|
||||
})
|
||||
|
||||
// ─── /op/agreement ─────────────────────────────────────────────────────
|
||||
// 약관(Markdown) 편집기. builtin 5종은 항상 존재하고 삭제 불가, 그 외 임의 kind 는
|
||||
// 사이트에서 추가/삭제 가능. 인스톨러는 /manifest/terms/<kind>.md 로 받아 표시한다.
|
||||
// 약관(Markdown) 편집기. 음악퀴즈(pack) 단위로 따로 저장한다.
|
||||
// builtin 5종은 어느 pack 에서나 항상 존재하고 삭제 불가, 그 외 임의 kind 는
|
||||
// 사이트에서 추가/삭제 가능. 인스톨러는 /manifest/terms/<packKey>/<kind>.md 로 받아 표시한다.
|
||||
|
||||
// /op/agreement → 음악퀴즈 선택(/op/list 와 동일한 카드 형식).
|
||||
opRouter.get('/op/agreement', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const items = await listTermsWithLabels()
|
||||
const keys = await listPackKeys()
|
||||
const items = await Promise.all(keys.map(async (key) => ({
|
||||
key,
|
||||
definition: await loadPackDefinition(key)
|
||||
})))
|
||||
res.render('op/terms', { userId: req.session.userId, items })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/agreement/create', requireAuth, async (req, res, next) => {
|
||||
// /op/agreement/:packName → 해당 pack 의 약관 목록 + 추가/불러오기/삭제.
|
||||
opRouter.get('/op/agreement/:packName', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
const definition = await loadPackDefinition(packKey)
|
||||
if (!definition) {
|
||||
res.status(404).send(t('errors.packNotFound'))
|
||||
return
|
||||
}
|
||||
const items = await listTermsWithLabels(packKey)
|
||||
// 불러오기 source 후보: 현재 pack 을 제외한 나머지.
|
||||
const allKeys = await listPackKeys()
|
||||
const sourceCandidates = await Promise.all(
|
||||
allKeys
|
||||
.filter((k) => k !== packKey)
|
||||
.map(async (k) => ({ key: k, definition: await loadPackDefinition(k) }))
|
||||
)
|
||||
res.render('op/terms-pack', {
|
||||
userId: req.session.userId,
|
||||
packKey,
|
||||
pack: definition,
|
||||
items,
|
||||
sourceCandidates
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/agreement/:packName/create', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
const definition = await loadPackDefinition(packKey)
|
||||
if (!definition) {
|
||||
res.status(404).send(t('errors.packNotFound'))
|
||||
return
|
||||
}
|
||||
const kindInput = pickFirstValue(req.body.kind).trim().toLowerCase()
|
||||
const label = pickFirstValue(req.body.label)
|
||||
if (!isTermKind(kindInput)) {
|
||||
res.status(400).send(t('terms.invalidKind'))
|
||||
return
|
||||
}
|
||||
await createTerm(kindInput, label)
|
||||
res.redirect(`/op/agreement/${kindInput}`)
|
||||
await createTerm(packKey, kindInput, label)
|
||||
res.redirect(`/op/agreement/${packKey}/${kindInput}`)
|
||||
} catch (error) {
|
||||
res.status(400).send((error as Error).message || t('terms.createFailed'))
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/agreement/:kind/delete', requireAuth, async (req, res, next) => {
|
||||
opRouter.post('/op/agreement/:packName/import', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
const definition = await loadPackDefinition(packKey)
|
||||
if (!definition) {
|
||||
res.status(404).send(t('errors.packNotFound'))
|
||||
return
|
||||
}
|
||||
const sourceKey = sanitizePackKey(pickFirstValue(req.body.source))
|
||||
if (!sourceKey || sourceKey === packKey) {
|
||||
res.status(400).send(t('terms.invalidImportSource'))
|
||||
return
|
||||
}
|
||||
const sourceDefinition = await loadPackDefinition(sourceKey)
|
||||
if (!sourceDefinition) {
|
||||
res.status(404).send(t('terms.invalidImportSource'))
|
||||
return
|
||||
}
|
||||
await importTerms(packKey, sourceKey)
|
||||
res.redirect(`/op/agreement/${packKey}`)
|
||||
} catch (error) {
|
||||
res.status(400).send((error as Error).message || t('terms.importFailed'))
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/agreement/:packName/:kind/delete', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
const definition = await loadPackDefinition(packKey)
|
||||
if (!definition) {
|
||||
res.status(404).send(t('errors.packNotFound'))
|
||||
return
|
||||
}
|
||||
const kind = pickFirstValue(req.params.kind)
|
||||
if (!isTermKind(kind)) {
|
||||
res.status(400).send(t('terms.invalidKind'))
|
||||
@@ -342,24 +415,32 @@ opRouter.post('/op/agreement/:kind/delete', requireAuth, async (req, res, next)
|
||||
res.status(400).send(t('terms.cannotDeleteBuiltin'))
|
||||
return
|
||||
}
|
||||
await deleteTerm(kind)
|
||||
res.redirect('/op/agreement')
|
||||
await deleteTerm(packKey, kind)
|
||||
res.redirect(`/op/agreement/${packKey}`)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.get('/op/agreement/:kind', requireAuth, async (req, res, next) => {
|
||||
opRouter.get('/op/agreement/:packName/:kind', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
const definition = await loadPackDefinition(packKey)
|
||||
if (!definition) {
|
||||
res.status(404).send(t('errors.packNotFound'))
|
||||
return
|
||||
}
|
||||
const kind = pickFirstValue(req.params.kind)
|
||||
if (!isTermKind(kind)) {
|
||||
res.status(404).send(t('errors.unknown'))
|
||||
return
|
||||
}
|
||||
const content = await loadTerm(kind)
|
||||
const label = await getTermLabel(kind)
|
||||
const content = await loadTerm(packKey, kind)
|
||||
const label = await getTermLabel(packKey, kind)
|
||||
res.render('op/termsEditor', {
|
||||
userId: req.session.userId,
|
||||
packKey,
|
||||
pack: definition,
|
||||
kind,
|
||||
label,
|
||||
content
|
||||
@@ -369,15 +450,21 @@ opRouter.get('/op/agreement/:kind', requireAuth, async (req, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/agreement/:kind', requireAuth, async (req, res, next) => {
|
||||
opRouter.post('/op/agreement/:packName/:kind', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
const definition = await loadPackDefinition(packKey)
|
||||
if (!definition) {
|
||||
res.status(404).json({ ok: false, message: t('errors.packNotFoundJson') })
|
||||
return
|
||||
}
|
||||
const kind = pickFirstValue(req.params.kind)
|
||||
if (!isTermKind(kind)) {
|
||||
res.status(404).json({ ok: false, message: t('errors.unknown') })
|
||||
return
|
||||
}
|
||||
const content = typeof req.body?.content === 'string' ? req.body.content : ''
|
||||
await saveTerm(kind, content)
|
||||
await saveTerm(packKey, kind, content)
|
||||
res.json({ ok: true })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
|
||||
@@ -296,9 +296,11 @@ export async function savePackList(packKey: string, list: PackList): Promise<voi
|
||||
|
||||
// ─── Terms (Markdown 약관) ─────────────────────────────────────────────
|
||||
// 사이트와 인스톨러가 약관을 보여주기 위해 사용하는 markdown 파일.
|
||||
// - 음악퀴즈(pack)별로 독립 폴더(`manifest/terms/<packKey>/`) 에 저장한다.
|
||||
// - 5개 builtin 종류는 인스톨러가 직접 참조하므로 삭제할 수 없다.
|
||||
// - 그 외 임의 kind 는 사이트에서 추가/삭제 가능. 라벨은 _meta.json 에 저장.
|
||||
// - 그 외 임의 kind 는 사이트에서 추가/삭제 가능. 라벨은 `<packKey>/_meta.json` 에 저장.
|
||||
// - 파일명 규칙: `[a-z0-9][a-z0-9-]{0,31}\.md` (소문자/숫자/하이픈, 32자 이내).
|
||||
// - 레거시(전역) `manifest/terms/*.md` 파일이 남아 있으면 packKey 폴더 첫 접근 시 자동 시드.
|
||||
export type TermKind = string
|
||||
|
||||
/** 인스톨러가 하드코딩으로 참조하는 builtin kind. 삭제 금지. */
|
||||
@@ -331,9 +333,65 @@ interface TermsMeta {
|
||||
|
||||
const TERMS_META_FILE = '_meta.json'
|
||||
|
||||
async function loadTermsMeta(): Promise<TermsMeta> {
|
||||
function termsDirForPack(packKey: string): string {
|
||||
return path.join(manifestTermsDirPath, packKey)
|
||||
}
|
||||
|
||||
function isValidPackKey(packKey: string): boolean {
|
||||
return typeof packKey === 'string'
|
||||
&& packKey.length > 0
|
||||
&& /^[a-zA-Z0-9_\-]+$/.test(packKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 pack 폴더가 없으면 만든다. 이전 버전(v0.3.1) 의 전역 `manifest/terms/*.md`
|
||||
* 파일이 남아 있는 경우 첫 접근 시 그 내용을 그대로 새 폴더에 복사해 시드한다.
|
||||
* 시드는 한 번만 발생: 폴더가 이미 있으면 아무것도 안 한다.
|
||||
*/
|
||||
async function ensurePackTermsDir(packKey: string): Promise<string> {
|
||||
const dir = termsDirForPack(packKey)
|
||||
try {
|
||||
const raw = await fsp.readFile(path.join(manifestTermsDirPath, TERMS_META_FILE), 'utf8')
|
||||
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) {
|
||||
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 (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
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') {
|
||||
@@ -348,10 +406,10 @@ async function loadTermsMeta(): Promise<TermsMeta> {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTermsMeta(meta: TermsMeta): Promise<void> {
|
||||
await fsp.mkdir(manifestTermsDirPath, { recursive: true })
|
||||
async function saveTermsMeta(packKey: string, meta: TermsMeta): Promise<void> {
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
await fsp.writeFile(
|
||||
path.join(manifestTermsDirPath, TERMS_META_FILE),
|
||||
path.join(dir, TERMS_META_FILE),
|
||||
`${JSON.stringify(meta, null, 2)}\n`,
|
||||
'utf8'
|
||||
)
|
||||
@@ -369,8 +427,9 @@ export interface TermItem {
|
||||
* - 디스크에 있고 _meta.json 에 라벨이 있는 사용자 정의 kind 도 포함.
|
||||
* - builtin → 사용자 정의 순서, builtin 내부는 BUILTIN_TERM_KINDS 정의 순서를 유지.
|
||||
*/
|
||||
export async function listTermsWithLabels(): Promise<TermItem[]> {
|
||||
const meta = await loadTermsMeta()
|
||||
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 })
|
||||
@@ -378,7 +437,7 @@ export async function listTermsWithLabels(): Promise<TermItem[]> {
|
||||
// 디스크에 실제로 존재하는 사용자 정의 .md 파일만 노출.
|
||||
let onDisk: string[] = []
|
||||
try {
|
||||
onDisk = await fsp.readdir(manifestTermsDirPath)
|
||||
onDisk = await fsp.readdir(dir)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
@@ -398,15 +457,16 @@ export async function listTermsWithLabels(): Promise<TermItem[]> {
|
||||
return items
|
||||
}
|
||||
|
||||
export async function getTermLabel(kind: string): Promise<string> {
|
||||
export async function getTermLabel(packKey: string, kind: string): Promise<string> {
|
||||
if (isBuiltinTermKind(kind)) return BUILTIN_TERM_LABELS[kind]
|
||||
const meta = await loadTermsMeta()
|
||||
const meta = await loadTermsMeta(packKey)
|
||||
return meta.customLabels[kind] ?? kind
|
||||
}
|
||||
|
||||
export async function loadTerm(kind: TermKind): Promise<string> {
|
||||
export async function loadTerm(packKey: string, kind: TermKind): Promise<string> {
|
||||
if (!isTermKind(kind)) return ''
|
||||
const filePath = path.join(manifestTermsDirPath, `${kind}.md`)
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
const filePath = path.join(dir, `${kind}.md`)
|
||||
try {
|
||||
return await fsp.readFile(filePath, 'utf8')
|
||||
} catch (error) {
|
||||
@@ -415,24 +475,24 @@ export async function loadTerm(kind: TermKind): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveTerm(kind: TermKind, markdown: string): Promise<void> {
|
||||
export async function saveTerm(packKey: string, kind: TermKind, markdown: string): Promise<void> {
|
||||
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||
await fsp.mkdir(manifestTermsDirPath, { recursive: true })
|
||||
const filePath = path.join(manifestTermsDirPath, `${kind}.md`)
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
const filePath = path.join(dir, `${kind}.md`)
|
||||
const normalized = (markdown ?? '').replace(/\r\n/g, '\n')
|
||||
await fsp.writeFile(filePath, normalized.endsWith('\n') ? normalized : `${normalized}\n`, 'utf8')
|
||||
}
|
||||
|
||||
/** 새로운 사용자 정의 약관 추가. kind 충돌/builtin 충돌은 예외. 빈 .md 파일을 만든다. */
|
||||
export async function createTerm(kind: string, label: string): Promise<void> {
|
||||
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()
|
||||
const meta = await loadTermsMeta(packKey)
|
||||
if (meta.customLabels[kind]) throw new Error('term kind already exists')
|
||||
await fsp.mkdir(manifestTermsDirPath, { recursive: true })
|
||||
const filePath = path.join(manifestTermsDirPath, `${kind}.md`)
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
const filePath = path.join(dir, `${kind}.md`)
|
||||
// 파일 충돌도 막는다 (수동 생성된 .md 가 있을 수 있음).
|
||||
try {
|
||||
await fsp.access(filePath)
|
||||
@@ -442,29 +502,74 @@ export async function createTerm(kind: string, label: string): Promise<void> {
|
||||
}
|
||||
await fsp.writeFile(filePath, `# ${cleanLabel}\n\n`, 'utf8')
|
||||
meta.customLabels[kind] = cleanLabel
|
||||
await saveTermsMeta(meta)
|
||||
await saveTermsMeta(packKey, meta)
|
||||
}
|
||||
|
||||
/** 사용자 정의 약관 삭제. builtin 은 거부. */
|
||||
export async function deleteTerm(kind: string): Promise<void> {
|
||||
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 filePath = path.join(manifestTermsDirPath, `${kind}.md`)
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
const filePath = path.join(dir, `${kind}.md`)
|
||||
try {
|
||||
await fsp.unlink(filePath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
const meta = await loadTermsMeta()
|
||||
const meta = await loadTermsMeta(packKey)
|
||||
if (meta.customLabels[kind]) {
|
||||
delete meta.customLabels[kind]
|
||||
await saveTermsMeta(meta)
|
||||
await saveTermsMeta(packKey, meta)
|
||||
}
|
||||
}
|
||||
|
||||
/** 공개 라우트(`/manifest/terms/<file>`)에서 호출. _meta.json 같은 시스템 파일을 차단하기 위함. */
|
||||
export function isPublicTermsFile(fileName: string): boolean {
|
||||
// .md 만 허용, 이름 규칙 일치, builtin 또는 정상 kind 패턴.
|
||||
/**
|
||||
* 다른 음악퀴즈의 약관 전체를 현재 pack 으로 복사한다 (불러오기).
|
||||
* - source 의 모든 .md 와 _meta.json 을 target 에 덮어쓴다.
|
||||
* - target 에만 있던 사용자 정의 약관은 그대로 둔다 (source 에는 없으니 안 건드림).
|
||||
* - 동일한 kind 가 source 에도 있다면 source 값으로 덮어씀.
|
||||
*/
|
||||
export async function importTerms(targetPackKey: string, sourcePackKey: string): Promise<void> {
|
||||
if (!isValidPackKey(targetPackKey) || !isValidPackKey(sourcePackKey)) {
|
||||
throw new Error('invalid pack key')
|
||||
}
|
||||
if (targetPackKey === sourcePackKey) throw new Error('source and target are identical')
|
||||
const sourceDir = await ensurePackTermsDir(sourcePackKey)
|
||||
const targetDir = await ensurePackTermsDir(targetPackKey)
|
||||
|
||||
const sourceMeta = await loadTermsMeta(sourcePackKey)
|
||||
const targetMeta = await loadTermsMeta(targetPackKey)
|
||||
|
||||
// source 의 .md 파일을 모두 target 으로 복사.
|
||||
let entries: string[] = []
|
||||
try {
|
||||
entries = await fsp.readdir(sourceDir)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
for (const name of entries) {
|
||||
if (!name.toLowerCase().endsWith('.md')) continue
|
||||
const kind = name.slice(0, -3)
|
||||
if (!TERM_KIND_RE.test(kind)) continue
|
||||
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
|
||||
}
|
||||
await saveTermsMeta(targetPackKey, { customLabels: mergedLabels })
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 라우트(`/manifest/terms/<packKey>/<file>`)에서 호출.
|
||||
* - packKey 가 영문/숫자/언더스코어/하이픈만 사용했는지 검사.
|
||||
* - 파일명이 .md 로 끝나고 정상 kind 패턴인지 검사.
|
||||
* - _meta.json 같은 시스템 파일은 차단.
|
||||
*/
|
||||
export function isPublicTermsFile(packKey: string, fileName: string): boolean {
|
||||
if (!isValidPackKey(packKey)) return false
|
||||
if (!fileName.toLowerCase().endsWith('.md')) return false
|
||||
const kind = fileName.slice(0, -3)
|
||||
return TERM_KIND_RE.test(kind)
|
||||
|
||||
Reference in New Issue
Block a user