From 25977d894bd8644030e929d970a41d94ff56291d Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 20 May 2026 01:29:04 +0900 Subject: [PATCH] terms: per-pack storage + import from another pack (v0.3.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - store.ts: 약관을 manifest/terms// 폴더별로 저장. 첫 접근 시 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 --- locales/server/ko-kr.json | 14 +++- package.json | 2 +- public/termsEditor.js | 2 +- src/installer-rp/main.ts | 5 +- src/installer/main.ts | 9 ++- src/server/app.ts | 9 ++- src/server/routes/op.ts | 115 +++++++++++++++++++++++---- src/shared/store.ts | 161 +++++++++++++++++++++++++++++++------- views/op/terms-pack.ejs | 147 ++++++++++++++++++++++++++++++++++ views/op/terms.ejs | 101 ++++-------------------- views/op/termsEditor.ejs | 5 +- 11 files changed, 429 insertions(+), 141 deletions(-) create mode 100644 views/op/terms-pack.ejs diff --git a/locales/server/ko-kr.json b/locales/server/ko-kr.json index 80583b6..42360ee 100644 --- a/locales/server/ko-kr.json +++ b/locales/server/ko-kr.json @@ -136,6 +136,9 @@ "terms": { "browserTitle": "약관 수정", "title": "약관 수정", + "pickPackHint": "약관을 수정할 음악퀴즈를 선택하세요. 각 음악퀴즈마다 약관을 따로 보관합니다.", + "packBrowserTitle": "{{name}} — 약관 수정", + "packTitle": "{{name}} 약관 수정", "hint": "수정할 약관을 선택하세요. 사이트에서 저장한 내용은 인스톨러가 약관 동의 화면에서 사용합니다.", "editorBrowserTitle": "{{label}} 편집", "editorTitle": "{{label}}", @@ -169,7 +172,16 @@ "deleteConfirm": "정말 \"{{label}}\" 약관을 삭제할까요? 이 동작은 되돌릴 수 없습니다.", "invalidKind": "식별자는 소문자/숫자/하이픈만, 32자 이내여야 합니다.", "createFailed": "약관 추가 실패", - "cannotDeleteBuiltin": "기본 약관은 삭제할 수 없습니다." + "cannotDeleteBuiltin": "기본 약관은 삭제할 수 없습니다.", + "importHeading": "다른 음악퀴즈에서 불러오기", + "importSourceLabel": "가져올 음악퀴즈", + "importSourcePlaceholder": "음악퀴즈를 선택하세요", + "importHint": "선택한 음악퀴즈의 모든 약관(.md + 라벨)을 현재 음악퀴즈로 복사합니다. 같은 식별자의 약관이 있으면 덮어씁니다.", + "importButton": "불러오기", + "importEmpty": "불러올 수 있는 다른 음악퀴즈가 없습니다.", + "importConfirm": "선택한 음악퀴즈의 약관을 현재 음악퀴즈로 복사합니다. 같은 식별자의 약관은 덮어쓰여집니다. 진행할까요?", + "importFailed": "약관 불러오기 실패", + "invalidImportSource": "올바르지 않은 음악퀴즈입니다." }, "datapack": { "browserTitle": "데이터팩 수정", diff --git a/package.json b/package.json index ae563bc..b4d47fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minecraft-music-quiz-installer", - "version": "0.3.1", + "version": "0.3.2", "description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트", "main": "dist/installer/main.js", "scripts": { diff --git a/public/termsEditor.js b/public/termsEditor.js index 75e7fd7..567d322 100644 --- a/public/termsEditor.js +++ b/public/termsEditor.js @@ -162,7 +162,7 @@ function save() { status.classList.remove('error') status.textContent = I18N.saving - fetch('/op/agreement/' + encodeURIComponent(TERM_KIND), { + fetch('/op/agreement/' + encodeURIComponent(PACK_KEY) + '/' + encodeURIComponent(TERM_KIND), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: editor.value }) diff --git a/src/installer-rp/main.ts b/src/installer-rp/main.ts index b53506f..fe68299 100644 --- a/src/installer-rp/main.ts +++ b/src/installer-rp/main.ts @@ -252,14 +252,15 @@ ipcMain.handle('rp:packs:select', async (_event, packKey: string) => { ipcMain.handle('rp:i18n:dict', () => localeDict) // ── IPC: 약관 다운로드 ────────────────────────────── -// 사이트가 /manifest/terms/.md 로 노출하는 md 파일을 그대로 받아 본문(string) 으로 반환. +// 사이트가 /manifest/terms//.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) { diff --git a/src/installer/main.ts b/src/installer/main.ts index 9f69bc7..b595517 100644 --- a/src/installer/main.ts +++ b/src/installer/main.ts @@ -154,15 +154,18 @@ ipcMain.handle('packs:load', async (_event, manifestUrlInput?: string): Promise< return results }) -// 약관(Markdown) 을 사이트(/manifest/terms/.md) 에서 받아와 그대로 돌려준다. -// 화이트리스트로 5종 제한. 네트워크 실패 시 에러 메시지가 그대로 화면에 노출된다. +// 약관(Markdown) 을 사이트(/manifest/terms//.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) { diff --git a/src/server/app.ts b/src/server/app.ts index c07e90c..82ee5c5 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -64,15 +64,16 @@ app.get('/manifest.json', (_req, res) => { }) // 설치기 + 사이트가 약관(markdown) 을 가져갈 수 있도록 .md 만 허용한다. +// 음악퀴즈(pack) 별로 manifest/terms//.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') }) diff --git a/src/server/routes/op.ts b/src/server/routes/op.ts index 029de72..c0c7d97 100644 --- a/src/server/routes/op.ts +++ b/src/server/routes/op.ts @@ -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/.md 로 받아 표시한다. +// 약관(Markdown) 편집기. 음악퀴즈(pack) 단위로 따로 저장한다. +// builtin 5종은 어느 pack 에서나 항상 존재하고 삭제 불가, 그 외 임의 kind 는 +// 사이트에서 추가/삭제 가능. 인스톨러는 /manifest/terms//.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) diff --git a/src/shared/store.ts b/src/shared/store.ts index e950e90..eb91d8d 100644 --- a/src/shared/store.ts +++ b/src/shared/store.ts @@ -296,9 +296,11 @@ export async function savePackList(packKey: string, list: PackList): Promise/`) 에 저장한다. // - 5개 builtin 종류는 인스톨러가 직접 참조하므로 삭제할 수 없다. -// - 그 외 임의 kind 는 사이트에서 추가/삭제 가능. 라벨은 _meta.json 에 저장. +// - 그 외 임의 kind 는 사이트에서 추가/삭제 가능. 라벨은 `/_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 { +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 { + 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 { + 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 = {} if (parsed && typeof parsed === 'object' && parsed.customLabels && typeof parsed.customLabels === 'object') { @@ -348,10 +406,10 @@ async function loadTermsMeta(): Promise { } } -async function saveTermsMeta(meta: TermsMeta): Promise { - await fsp.mkdir(manifestTermsDirPath, { recursive: true }) +async function saveTermsMeta(packKey: string, meta: TermsMeta): Promise { + 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 { - const meta = await loadTermsMeta() +export async function listTermsWithLabels(packKey: string): Promise { + 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 { // 디스크에 실제로 존재하는 사용자 정의 .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 { return items } -export async function getTermLabel(kind: string): Promise { +export async function getTermLabel(packKey: string, kind: string): Promise { 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 { +export async function loadTerm(packKey: string, kind: TermKind): Promise { 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 { } } -export async function saveTerm(kind: TermKind, markdown: string): Promise { +export async function saveTerm(packKey: string, kind: TermKind, markdown: string): Promise { 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 { +export async function createTerm(packKey: string, kind: string, label: string): Promise { 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 { } 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 { +export async function deleteTerm(packKey: string, kind: string): Promise { 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/`)에서 호출. _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 { + 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 = { ...targetMeta.customLabels } + for (const [k, v] of Object.entries(sourceMeta.customLabels)) { + mergedLabels[k] = v + } + await saveTermsMeta(targetPackKey, { customLabels: mergedLabels }) +} + +/** + * 공개 라우트(`/manifest/terms//`)에서 호출. + * - 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) diff --git a/views/op/terms-pack.ejs b/views/op/terms-pack.ejs new file mode 100644 index 0000000..c15df0d --- /dev/null +++ b/views/op/terms-pack.ejs @@ -0,0 +1,147 @@ + + + + + + <%= t('terms.packBrowserTitle', { name: pack.name }) %> + + + + + <%- include('../partials/navbar', { userId }) %> + +
+
+
+ <%= t('common.back') %> +

<%= t('terms.packTitle', { name: pack.name }) %>

+

<%= packKey %>.json

+
+
+ +

<%= t('terms.hint') %>

+ +
+ <% items.forEach(function (item) { %> + + <% }) %> +
+ +
+
+

<%= t('terms.addHeading') %>

+
+
+ + + <%= t('terms.kindHint') %> +
+
+ + +
+
+ +
+
+
+ +
+

<%= t('terms.importHeading') %>

+ <% if (sourceCandidates.length === 0) { %> +

<%= t('terms.importEmpty') %>

+ <% } else { %> +
');"> +
+ + + <%= t('terms.importHint') %> +
+
+ +
+
+ <% } %> +
+
+
+ + diff --git a/views/op/terms.ejs b/views/op/terms.ejs index 7122441..8b0f809 100644 --- a/views/op/terms.ejs +++ b/views/op/terms.ejs @@ -5,48 +5,6 @@ <%= t('terms.browserTitle') %> - <%- include('../partials/navbar', { userId }) %> @@ -59,55 +17,28 @@ -

<%= t('terms.hint') %>

+

<%= t('terms.pickPackHint') %>

-
+
+ <% if (items.length === 0) { %> +

<%= t('site.empty') %>

+ <% } %> <% items.forEach(function (item) { %> -
- -
-

<%= item.label %>

- <% if (item.builtin) { %> - <%= t('terms.builtinBadge') %> - <% } %> -
-
<%= item.kind %>.md
-
- +
<% }) %>
- -
-

<%= t('terms.addHeading') %>

-
-
- - - <%= t('terms.kindHint') %> -
-
- - -
-
- - -
-
-
diff --git a/views/op/termsEditor.ejs b/views/op/termsEditor.ejs index 137d5cc..1c99e99 100644 --- a/views/op/termsEditor.ejs +++ b/views/op/termsEditor.ejs @@ -13,9 +13,9 @@
- <%= t('common.back') %> + <%= t('common.back') %>

<%= t('terms.editorTitle', { label: label }) %>

-

<%= kind %>.md

+

<%= pack.name %> · <%= kind %>.md

@@ -39,6 +39,7 @@