diff --git a/installer-rp/renderer.js b/installer-rp/renderer.js index 8d705ca..57b2fe0 100644 --- a/installer-rp/renderer.js +++ b/installer-rp/renderer.js @@ -141,14 +141,36 @@ function renderStep1() { } // 약관 동의 페이지: 1단계 직후, 2단계 설치 진입 전에 노출. -// rp 인스톨러는 리소스팩·설치기 두 약관만 확인·동의하면 된다. +// v0.3.4~ : 사이트의 visibility 토글에 따라 표시할 약관이 결정된다. 목록이 비면 단계를 건너뛴다. function renderAgreement() { setActiveStep(1) clearPage() - var KINDS = [ - { id: 'resourcepack', tab: tt('agreement.tabResourcepack') }, - { id: 'installer-rp', tab: tt('agreement.tabInstaller') } - ] + var loadingSection = document.createElement('section') + loadingSection.className = 'page' + loadingSection.innerHTML = '

' + escapeHtml(tt('agreement.heading')) + '

' + + '

' + escapeHtml(tt('agreement.loading')) + '

' + pageHost.appendChild(loadingSection) + + api.getTermsList().then(function (res) { + if (!res || !res.ok) { + renderStep2() + return + } + var terms = (res.terms || []).map(function (t) { + return { id: t.kind, tab: t.label } + }) + if (terms.length === 0) { + renderStep2() + return + } + clearPage() + renderAgreementWithKinds(terms) + }).catch(function () { + renderStep2() + }) +} + +function renderAgreementWithKinds(KINDS) { var section = document.createElement('section') section.className = 'page' section.innerHTML = diff --git a/installer/renderer.js b/installer/renderer.js index 6ed3820..634bc69 100644 --- a/installer/renderer.js +++ b/installer/renderer.js @@ -149,15 +149,38 @@ function renderStep1() { } // 약관 동의 페이지: 음악퀴즈 선택 직후, 싱글/멀티 선택(step2) 진입 전에 노출. -// 메인 설치기는 맵·모드·설치기 세 약관을 모두 확인·동의해야 다음 단계로 갈 수 있다. +// v0.3.4~ : 어떤 약관을 표시할지는 사이트(/manifest/terms//index.json) 가 +// 결정. 메인 인스톨러용으로 표시 토글된 항목만 받아 탭을 만든다. 목록이 비면 약관 단계를 건너뛴다. function renderAgreement() { setActiveStep(1) clearPage() - var KINDS = [ - { id: 'map', tab: tt('agreement.tabMap') }, - { id: 'mod', tab: tt('agreement.tabMod') }, - { id: 'installer', tab: tt('agreement.tabInstaller') } - ] + var loadingSection = document.createElement('section') + loadingSection.className = 'page' + loadingSection.innerHTML = '

' + tt('agreement.heading') + '

' + + '

' + tt('agreement.loading') + '

' + pageHost.appendChild(loadingSection) + + installerApi.getTermsList().then(function (res) { + if (!res || !res.ok) { + // 목록 조회 실패면 약관 단계를 건너뛴다 (서버가 구버전일 수도 있으므로 차단보다 통과 선호). + renderStep2() + return + } + var terms = (res.terms || []).map(function (t) { + return { id: t.kind, tab: t.label } + }) + if (terms.length === 0) { + renderStep2() + return + } + clearPage() + renderAgreementWithKinds(terms) + }).catch(function () { + renderStep2() + }) +} + +function renderAgreementWithKinds(KINDS) { var section = document.createElement('section') section.className = 'page' section.innerHTML = diff --git a/locales/server/ko-kr.json b/locales/server/ko-kr.json index 42360ee..7817d0d 100644 --- a/locales/server/ko-kr.json +++ b/locales/server/ko-kr.json @@ -160,7 +160,11 @@ "slashQuote": "인용", "slashCode": "코드", "leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?", - "builtinBadge": "기본", + "visibilityHeading": "표시 대상 (중복 선택 가능)", + "visibilityInstaller": "설치기에 표시", + "visibilityInstallerRp": "리소스팩 설치기에 표시", + "visibilityInstallerShort": "설치기", + "visibilityInstallerRpShort": "리소스팩", "addHeading": "약관 추가", "kindLabel": "식별자", "kindPlaceholder": "예: privacy", diff --git a/package.json b/package.json index 4f40de1..9f158d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minecraft-music-quiz-installer", - "version": "0.3.3", + "version": "0.3.4", "description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트", "main": "dist/installer/main.js", "scripts": { diff --git a/public/termsEditor.js b/public/termsEditor.js index 567d322..39e1e45 100644 --- a/public/termsEditor.js +++ b/public/termsEditor.js @@ -15,6 +15,8 @@ var dirtyMark = document.getElementById('dirty-mark') var saveBtn = document.getElementById('saveBtn') var tabBtns = document.querySelectorAll('.tabBar .tabBtn') + var visInstaller = document.getElementById('visInstaller') + var visInstallerRp = document.getElementById('visInstallerRp') editor.value = INITIAL || '' var dirty = false @@ -23,6 +25,10 @@ dirtyMark.hidden = !v } + // 토글이 바뀌어도 dirty 표시. 저장 시 함께 전송된다. + if (visInstaller) visInstaller.addEventListener('change', function () { setDirty(true) }) + if (visInstallerRp) visInstallerRp.addEventListener('change', function () { setDirty(true) }) + // ─── markdown 미리 보기용 미니 렌더러 ──────────────────────────────── // 정식 markdown 파서는 아니지만, 본 편집기가 만들어 내는 형태(#, ##, ###, // - , 1. , > , ---, ``` , 토글 details) 정도는 충실히 처리한다. @@ -162,10 +168,13 @@ function save() { status.classList.remove('error') status.textContent = I18N.saving + var payload = { content: editor.value } + if (visInstaller) payload.showInInstaller = !!visInstaller.checked + if (visInstallerRp) payload.showInInstallerRp = !!visInstallerRp.checked fetch('/op/agreement/' + encodeURIComponent(PACK_KEY) + '/' + encodeURIComponent(TERM_KIND), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content: editor.value }) + body: JSON.stringify(payload) }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok && j && j.ok !== false, body: j } }) }).then(function (res) { diff --git a/src/installer-rp/main.ts b/src/installer-rp/main.ts index fe68299..ecf7a6f 100644 --- a/src/installer-rp/main.ts +++ b/src/installer-rp/main.ts @@ -252,12 +252,13 @@ ipcMain.handle('rp:packs:select', async (_event, packKey: string) => { ipcMain.handle('rp:i18n:dict', () => localeDict) // ── IPC: 약관 다운로드 ────────────────────────────── -// 사이트가 /manifest/terms//.md 로 노출하는 md 파일을 그대로 받아 본문(string) 으로 반환. -// rp 인스톨러에서는 'resourcepack' 과 'installer-rp' 두 종류만 실제로 사용하지만, 메인 -// 인스톨러와 동일한 화이트리스트를 둬서 사이트 컴포넌트 분류와 1:1 로 매칭되게 한다. -const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp']) +// v0.3.4~ : 사이트에서 임의 kind 가 만들어질 수 있으니 5종 화이트리스트 대신 +// kind 형식만 검증한다. 어떤 약관을 rp 인스톨러에 보여줄지는 사이트의 visibility 토글이 결정. +const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/ ipcMain.handle('rp:terms:get', async (_event, kind: string) => { - if (!TERM_KIND_WHITELIST.has(kind)) return { ok: false, message: 'unknown term kind' } + if (typeof kind !== 'string' || !TERM_KIND_RE.test(kind)) { + return { ok: false, message: 'invalid term kind' } + } if (!state.selectedKey) return { ok: false, message: 'pack not selected' } try { const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/${encodeURIComponent(kind)}.md` @@ -268,6 +269,31 @@ ipcMain.handle('rp:terms:get', async (_event, kind: string) => { } }) +// rp 인스톨러용 약관 목록. /manifest/terms//index.json 을 받아 +// showInInstallerRp=true 인 항목만 추려 반환. 비어 있으면 렌더러가 약관 단계를 건너뛴다. +ipcMain.handle('rp:terms:list', async (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> => { + if (!state.selectedKey) return { ok: false, message: 'pack not selected' } + try { + const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/index.json` + const buf = await fetchBuffer(url) + const parsed = JSON.parse(buf.toString('utf8')) as { terms?: unknown } + const items = Array.isArray(parsed.terms) ? parsed.terms : [] + const terms: Array<{ kind: string; label: string }> = [] + for (const it of items) { + if (!it || typeof it !== 'object') continue + const entry = it as Record + if (entry.showInInstallerRp !== true) continue + const kind = typeof entry.kind === 'string' ? entry.kind : '' + const label = typeof entry.label === 'string' ? entry.label : '' + if (!TERM_KIND_RE.test(kind) || label.length === 0) continue + terms.push({ kind, label }) + } + return { ok: true, terms } + } catch (error) { + return { ok: false, message: (error as Error).message } + } +}) + // ── IPC: 2단계 설치 ────────────────────────────────── ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string }> => { if (!state.selectedKey) throw new Error(t('errors.selectPackFirst')) diff --git a/src/installer-rp/preload.ts b/src/installer-rp/preload.ts index ff5e43e..58c6fb8 100644 --- a/src/installer-rp/preload.ts +++ b/src/installer-rp/preload.ts @@ -12,10 +12,14 @@ const api = { selectPack: (packKey: string): Promise => ipcRenderer.invoke('rp:packs:select', packKey), - /** 약관(Markdown) 다운로드. kind: 'resourcepack' | 'installer-rp'. */ + /** 약관(Markdown) 다운로드. v0.3.4~ : 임의 kind 허용 (사이트에서 설정). */ getTerm: (kind: string): Promise<{ ok: boolean; content?: string; message?: string }> => ipcRenderer.invoke('rp:terms:get', kind), + /** rp 인스톨러에 표시할 약관 목록 (사이트의 visibility 토글로 필터링). */ + getTermsList: (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> => + ipcRenderer.invoke('rp:terms:list'), + /** 리소스팩 빌드/설치 시작. 완료 또는 취소될 때까지 resolve 되지 않을 수 있음. */ startInstall: (): Promise<{ resourcepackPath: string }> => ipcRenderer.invoke('rp:install:start'), diff --git a/src/installer/main.ts b/src/installer/main.ts index b595517..6c5cc48 100644 --- a/src/installer/main.ts +++ b/src/installer/main.ts @@ -155,11 +155,11 @@ ipcMain.handle('packs:load', async (_event, manifestUrlInput?: string): Promise< }) // 약관(Markdown) 을 사이트(/manifest/terms//.md) 에서 받아와 그대로 돌려준다. -// 화이트리스트로 5종 제한. pack 미선택 상태에서는 에러를 돌려준다. 네트워크 실패 시 에러 메시지가 그대로 화면에 노출된다. -const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp']) +// v0.3.4~ : 사이트에서 임의 kind 등록 가능 → 하드코딩 5종 화이트리스트 대신 kind 형식만 검증. +const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/ 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 (typeof kind !== 'string' || !TERM_KIND_RE.test(kind)) { + return { ok: false, message: 'invalid term kind' } } if (!state.selectedKey) { return { ok: false, message: 'pack not selected' } @@ -173,6 +173,31 @@ ipcMain.handle('terms:get', async (_event, kind: string): Promise<{ ok: boolean; } }) +// 메인 인스톨러용 약관 목록. /manifest/terms//index.json 을 받아 +// showInInstaller=true 인 항목만 추려 반환. 비어 있으면 렌더러가 약관 단계를 건너뛴다. +ipcMain.handle('terms:list', async (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> => { + if (!state.selectedKey) return { ok: false, message: 'pack not selected' } + try { + const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/index.json` + const buf = await fetchBuffer(url) + const parsed = JSON.parse(buf.toString('utf8')) as { terms?: unknown } + const items = Array.isArray(parsed.terms) ? parsed.terms : [] + const terms: Array<{ kind: string; label: string }> = [] + for (const it of items) { + if (!it || typeof it !== 'object') continue + const entry = it as Record + if (entry.showInInstaller !== true) continue + const kind = typeof entry.kind === 'string' ? entry.kind : '' + const label = typeof entry.label === 'string' ? entry.label : '' + if (!TERM_KIND_RE.test(kind) || label.length === 0) continue + terms.push({ kind, label }) + } + return { ok: true, terms } + } catch (error) { + return { ok: false, message: (error as Error).message } + } +}) + ipcMain.handle('packs:select', async (_event, packKey: string) => { if (!state.packs.has(packKey)) { throw new Error(t('errors.packNotFound')) diff --git a/src/installer/preload.ts b/src/installer/preload.ts index dc80a47..df8e39a 100644 --- a/src/installer/preload.ts +++ b/src/installer/preload.ts @@ -14,6 +14,9 @@ const api = { // 약관(Markdown) 다운로드 getTerm: (kind: string): Promise<{ ok: boolean; content?: string; message?: string }> => ipcRenderer.invoke('terms:get', kind), + // 메인 인스톨러용 약관 목록 (사이트의 visibility 토글에 따라 필터링됨) + getTermsList: (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> => + ipcRenderer.invoke('terms:list'), // 3-1 pickFolder: (): Promise => ipcRenderer.invoke('dialog:pickFolder'), diff --git a/src/server/app.ts b/src/server/app.ts index 815cb90..7389a69 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -6,7 +6,9 @@ import { manifestRootPath, manifestDirPath, manifestTermsDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths.js' -import { ensurePackTermsDir, isPublicTermsFile, loadPackDefinition } from '../shared/store.js' +import { + ensurePackTermsDir, isPublicTermsFile, listTermsWithLabels, loadPackDefinition +} from '../shared/store.js' import { loadEnv } from '../shared/env.js' import { t, localeDict } from './i18n.js' import { indexRouter } from './routes/index.js' @@ -71,6 +73,29 @@ app.get('/manifest.json', (_req, res) => { // 요청하는 경우에도 작동하도록, 실제 pack 이면 ensurePackTermsDir 로 v0.3.1 // 전역 .md 들을 시드 복사한 뒤 sendFile 한다. 임의 packKey 로 빈 폴더가 // 생성되는 것은 loadPackDefinition 으로 차단. +// 설치기가 자기에게 표시할 약관 목록을 받아갈 수 있도록 packKey 별 index.json. +// 응답: [{ kind, label, showInInstaller, showInInstallerRp }]. v0.3.4~ builtin 개념이 +// 없어졌으므로 인스톨러는 이 목록을 받아 자기 인스톨러용(`showInInstaller` / `showInInstallerRp`) +// 으로 필터링해서 탭을 만든다. +app.get('/manifest/terms/:packKey/index.json', async (req, res, next) => { + try { + const { packKey } = req.params + if (!/^[a-zA-Z0-9_\-]+$/.test(packKey)) { + res.status(404).json({ terms: [] }) + return + } + const pack = await loadPackDefinition(packKey) + if (!pack) { + res.status(404).json({ terms: [] }) + return + } + const terms = await listTermsWithLabels(packKey) + res.json({ terms }) + } catch (error) { + next(error) + } +}) + app.get('/manifest/terms/:packKey/:fileName', async (req, res, next) => { try { const { packKey, fileName } = req.params diff --git a/src/server/routes/op.ts b/src/server/routes/op.ts index c0c7d97..be0c27c 100644 --- a/src/server/routes/op.ts +++ b/src/server/routes/op.ts @@ -5,9 +5,8 @@ import { createTerm, deletePackKeys, deleteTerm, - getTermLabel, + getTermEntry, importTerms, - isBuiltinTermKind, isTermKind, listPackKeys, listTermsWithLabels, @@ -20,7 +19,8 @@ import { renamePack, sanitizePackKey, saveTerm, - savePackList + savePackList, + setTermVisibility } from '../../shared/store.js' import { fetchReleaseVersions } from '../../shared/mojang.js' import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js' @@ -306,8 +306,9 @@ opRouter.get('/op/datapack/:packName/images-zip', requireAuth, async (req, res, // ─── /op/agreement ───────────────────────────────────────────────────── // 약관(Markdown) 편집기. 음악퀴즈(pack) 단위로 따로 저장한다. -// builtin 5종은 어느 pack 에서나 항상 존재하고 삭제 불가, 그 외 임의 kind 는 -// 사이트에서 추가/삭제 가능. 인스톨러는 /manifest/terms//.md 로 받아 표시한다. +// 5종 기본 약관(map/mod/installer/resourcepack/installer-rp) 은 첫 접근 시 시드되지만 +// 사용자가 자유롭게 삭제/추가/표시 대상 변경할 수 있다 (v0.3.4~). 인스톨러는 +// /manifest/terms//index.json 으로 자신에게 표시할 약관 목록을 받는다. // /op/agreement → 음악퀴즈 선택(/op/list 와 동일한 카드 형식). opRouter.get('/op/agreement', requireAuth, async (req, res, next) => { @@ -411,10 +412,6 @@ opRouter.post('/op/agreement/:packName/:kind/delete', requireAuth, async (req, r res.status(400).send(t('terms.invalidKind')) return } - if (isBuiltinTermKind(kind)) { - res.status(400).send(t('terms.cannotDeleteBuiltin')) - return - } await deleteTerm(packKey, kind) res.redirect(`/op/agreement/${packKey}`) } catch (error) { @@ -435,14 +432,20 @@ opRouter.get('/op/agreement/:packName/:kind', requireAuth, async (req, res, next res.status(404).send(t('errors.unknown')) return } + const entry = await getTermEntry(packKey, kind) + if (!entry) { + res.status(404).send(t('errors.unknown')) + return + } 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, + label: entry.label, + showInInstaller: entry.showInInstaller, + showInInstallerRp: entry.showInInstallerRp, content }) } catch (error) { @@ -465,6 +468,17 @@ opRouter.post('/op/agreement/:packName/:kind', requireAuth, async (req, res, nex } const content = typeof req.body?.content === 'string' ? req.body.content : '' await saveTerm(packKey, kind, content) + // visibility 토글이 함께 전송되면 동시에 갱신. 두 값이 모두 false 면 어디에도 + // 표시되지 않지만 사용자가 의도적으로 선택한 결과이므로 그대로 저장한다. + if ( + typeof req.body?.showInInstaller === 'boolean' + || typeof req.body?.showInInstallerRp === 'boolean' + ) { + await setTermVisibility(packKey, kind, { + showInInstaller: req.body.showInInstaller === true, + showInInstallerRp: req.body.showInInstallerRp === true + }) + } res.json({ ok: true }) } catch (error) { next(error) diff --git a/src/shared/store.ts b/src/shared/store.ts index 7564253..39516b6 100644 --- a/src/shared/store.ts +++ b/src/shared/store.ts @@ -318,24 +318,31 @@ export async function savePackList(packKey: string, list: PackList): Promise/`) 에 저장한다. -// - 5개 builtin 종류는 인스톨러가 직접 참조하므로 삭제할 수 없다. -// - 그 외 임의 kind 는 사이트에서 추가/삭제 가능. 라벨은 `/_meta.json` 에 저장. +// - 각 약관(.md) 은 `_meta.json` 의 `terms.` 엔트리로 라벨/표시 대상이 관리된다. +// 엔트리: { 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 = { - '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 + terms: Record } const TERMS_META_FILE = '_meta.json' @@ -375,58 +383,148 @@ function isValidPackKey(packKey: string): boolean { */ export async function ensurePackTermsDir(packKey: string): Promise { 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 { + 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).terms) { + const meta = parsed as { terms: Record } + 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 = {} + if (parsed && typeof parsed === 'object' && (parsed as Record).customLabels + && typeof (parsed as Record).customLabels === 'object') { + for (const [k, v] of Object.entries((parsed as { customLabels: Record }).customLabels)) { + if (typeof v === 'string' && TERM_KIND_RE.test(k)) oldCustomLabels[k] = v + } + } + + const terms: Record = {} + // 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 { 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') { - for (const [k, v] of Object.entries(parsed.customLabels as Record)) { - 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).terms + && typeof (parsed as Record).terms === 'object') { + for (const [k, v] of Object.entries((parsed as { terms: Record }).terms)) { + if (!TERM_KIND_RE.test(k)) continue + if (!v || typeof v !== 'object') continue + const entry = v as Record + 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 { 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 { 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() + const mdKinds = new Set() 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() + // 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 { - 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 { + const meta = await loadTermsMeta(packKey) + return meta.terms[kind] ?? null +} + +export async function setTermVisibility( + packKey: string, + kind: string, + visibility: { showInInstaller: boolean; showInInstallerRp: boolean } +): Promise { + 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 { @@ -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 { 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 { 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 { 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 { 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 = { ...targetMeta.customLabels } - for (const [k, v] of Object.entries(sourceMeta.customLabels)) { - mergedLabels[k] = v + // 약관 엔트리도 source 기준으로 머지 (덮어쓰기). + const mergedTerms: Record = { ...targetMeta.terms } + for (const [k, v] of Object.entries(sourceMeta.terms)) { + mergedTerms[k] = { ...v } } - await saveTermsMeta(targetPackKey, { customLabels: mergedLabels }) + await saveTermsMeta(targetPackKey, { terms: mergedTerms }) } /** diff --git a/views/op/terms-pack.ejs b/views/op/terms-pack.ejs index c15df0d..e836a63 100644 --- a/views/op/terms-pack.ejs +++ b/views/op/terms-pack.ejs @@ -21,11 +21,19 @@ .termsRow .termsRowLabel h2 { margin: 0; font-size: 16px; } .termsRow .termsRowSub { color: var(--text-muted); font-size: 12px; margin-top: 2px; } .termsRow .termsRowActions { display: flex; gap: 8px; align-items: center; } - .builtinBadge { - display: inline-block; padding: 2px 8px; border-radius: 999px; - background: rgba(255,255,255,0.08); color: var(--text-muted); + .visibilityBadges { + display: flex; gap: 6px; flex-wrap: wrap; + } + .visibilityBadge { + display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px; + background: rgba(76, 175, 80, 0.15); color: #8ed68f; + border: 1px solid rgba(76, 175, 80, 0.35); font-size: 11px; } + .visibilityBadge.off { + background: rgba(255,255,255,0.05); color: var(--text-muted); + border-color: rgba(255,255,255,0.12); + } .termsSideBySide { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 24px; } @@ -76,21 +84,20 @@

<%= item.label %>

- <% if (item.builtin) { %> - <%= t('terms.builtinBadge') %> - <% } %> + + <%= t('terms.visibilityInstallerShort') %> + <%= t('terms.visibilityInstallerRpShort') %> +
<%= item.kind %>.md
<%= t('terms.edit') %> - <% if (!item.builtin) { %> -
');" - style="margin:0;"> - -
- <% } %> +
');" + style="margin:0;"> + +
<% }) %> diff --git a/views/op/termsEditor.ejs b/views/op/termsEditor.ejs index 1c99e99..3820a33 100644 --- a/views/op/termsEditor.ejs +++ b/views/op/termsEditor.ejs @@ -29,6 +29,19 @@ + +
+ <%= t('terms.visibilityHeading') %> + + +
+

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