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:
2026-05-20 10:14:42 +09:00
parent 05dc9d7166
commit 9ba5dc6b7b
14 changed files with 445 additions and 130 deletions

View File

@@ -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

View File

@@ -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/<packKey>/<kind>.md 로 받아 표시한다.
// 5종 기본 약관(map/mod/installer/resourcepack/installer-rp) 은 첫 접근 시 시드되지만
// 사용자가 자유롭게 삭제/추가/표시 대상 변경할 수 있다 (v0.3.4~). 인스톨러는
// /manifest/terms/<packKey>/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)