renamePack 가 manifest 정의와 약관 폴더는 새 키로 옮기면서 정작 음악·사진 목록(file/list/<key>.json)은 옛 키 파일에 남겨, 이름 변경 후 목록이 비어 보이던 버그 수정. 약관 폴더와 동일하게 fsp.rename 으로 옮기고 옛 파일이 없으면(ENOENT) 무시한다.
768 lines
30 KiB
TypeScript
768 lines
30 KiB
TypeScript
import fs from 'node:fs'
|
|
import fsp from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import {
|
|
manifestRootPath, manifestDirPath, manifestTermsDirPath,
|
|
accountFilePath, fileListDirPath
|
|
} from './paths.js'
|
|
import type {
|
|
Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType,
|
|
PackList, MusicListEntry, ImageListEntry
|
|
} from './types.js'
|
|
|
|
export async function readManifest(): Promise<Manifest> {
|
|
try {
|
|
const raw = await fsp.readFile(manifestRootPath, 'utf8')
|
|
const parsed = JSON.parse(raw) as Partial<Manifest>
|
|
if (!parsed || !Array.isArray(parsed.packs)) {
|
|
return { packs: [] }
|
|
}
|
|
return {
|
|
packs: parsed.packs.filter((entry): entry is ManifestEntry =>
|
|
typeof entry?.name === 'string' && typeof entry?.file === 'string')
|
|
}
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
return { packs: [] }
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export async function writeManifest(manifest: Manifest): Promise<void> {
|
|
await fsp.writeFile(manifestRootPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
|
}
|
|
|
|
export function defaultPackDefinition(name: string): PackDefinition {
|
|
return {
|
|
name,
|
|
mcVersion: '1.20.1',
|
|
platform: { type: 'vanilla' },
|
|
modsFolder: '',
|
|
resourcepackPath: '',
|
|
outputPackName: '',
|
|
serverMinRam: 2048,
|
|
serverMaxRam: 4096,
|
|
clientMinRam: 2048,
|
|
clientRecommendedRam: 4096,
|
|
mapPath: '',
|
|
serverPath: ''
|
|
}
|
|
}
|
|
|
|
function sanitizeZipFileName(input: unknown): string {
|
|
if (typeof input !== 'string') return ''
|
|
const trimmed = input.trim().replace(/^\/+/, '')
|
|
if (trimmed.length === 0) return ''
|
|
// 빈 값 허용, .zip 으로 끝나야 함, 경로 탈출 방지
|
|
if (trimmed.includes('..') || trimmed.includes('\\')) return ''
|
|
if (!/\.zip$/i.test(trimmed)) return ''
|
|
return trimmed
|
|
}
|
|
|
|
// 모드 폴더명: 영문/숫자/언더스코어/하이픈만 허용. 빈 값 허용.
|
|
function sanitizeFolderName(input: unknown): string {
|
|
if (typeof input !== 'string') return ''
|
|
const trimmed = input.trim().replace(/^\/+|\/+$/g, '')
|
|
if (trimmed.length === 0) return ''
|
|
if (!/^[a-zA-Z0-9_\-]+$/.test(trimmed)) return ''
|
|
return trimmed
|
|
}
|
|
|
|
const ALLOWED_LOADERS: LoaderType[] = ['vanilla', 'forge', 'fabric', 'neoforge']
|
|
|
|
export function normalizePackDefinition(input: Partial<PackDefinition> & Record<string, unknown>): PackDefinition {
|
|
const fallback = defaultPackDefinition(typeof input.name === 'string' ? input.name : 'new')
|
|
const platform = (input.platform ?? {}) as Partial<PackDefinition['platform']>
|
|
const platformType = ALLOWED_LOADERS.includes(platform.type as LoaderType)
|
|
? (platform.type as LoaderType)
|
|
: 'vanilla'
|
|
|
|
return {
|
|
name: typeof input.name === 'string' && input.name.trim().length > 0 ? input.name.trim() : fallback.name,
|
|
mcVersion: typeof input.mcVersion === 'string' && input.mcVersion.trim().length > 0
|
|
? input.mcVersion.trim()
|
|
: fallback.mcVersion,
|
|
platform: {
|
|
type: platformType,
|
|
// vanilla 외에는 fabric/forge/neoforge 모두 downloadUrl 을 보관한다.
|
|
downloadUrl: platformType !== 'vanilla'
|
|
&& typeof platform.downloadUrl === 'string'
|
|
&& platform.downloadUrl.trim().length > 0
|
|
? platform.downloadUrl.trim()
|
|
: undefined,
|
|
loaderVersion: platformType === 'fabric'
|
|
&& typeof (platform as { loaderVersion?: unknown }).loaderVersion === 'string'
|
|
&& ((platform as { loaderVersion?: string }).loaderVersion ?? '').trim().length > 0
|
|
? ((platform as { loaderVersion?: string }).loaderVersion ?? '').trim()
|
|
: undefined
|
|
},
|
|
modsFolder: sanitizeFolderName(input.modsFolder),
|
|
resourcepackPath: sanitizeZipFileName(input.resourcepackPath),
|
|
// 표시명은 사용자 입력을 보존(공백/마침표 trim 만). 파일명 안전 처리는 설치기 측에서.
|
|
outputPackName: typeof input.outputPackName === 'string' ? input.outputPackName.trim() : '',
|
|
serverMinRam: clampNumber(input.serverMinRam, fallback.serverMinRam),
|
|
serverMaxRam: clampNumber(input.serverMaxRam, fallback.serverMaxRam),
|
|
clientMinRam: clampNumber(input.clientMinRam, fallback.clientMinRam),
|
|
clientRecommendedRam: clampNumber(input.clientRecommendedRam, fallback.clientRecommendedRam),
|
|
mapPath: sanitizeZipFileName(input.mapPath),
|
|
serverPath: sanitizeZipFileName(input.serverPath)
|
|
}
|
|
}
|
|
|
|
function clampNumber(input: unknown, fallback: number): number {
|
|
const value = typeof input === 'number' ? input : Number(input)
|
|
if (!Number.isFinite(value) || value <= 0) return fallback
|
|
return Math.floor(value)
|
|
}
|
|
|
|
export function packKeyFromFile(fileName: string): string {
|
|
return fileName.replace(/\.json$/i, '')
|
|
}
|
|
|
|
export async function loadPackDefinition(packKey: string): Promise<PackDefinition | null> {
|
|
const filePath = path.join(manifestDirPath, `${packKey}.json`)
|
|
try {
|
|
const raw = await fsp.readFile(filePath, 'utf8')
|
|
const parsed = JSON.parse(raw) as Partial<PackDefinition>
|
|
return normalizePackDefinition(parsed)
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export async function savePackDefinition(packKey: string, pack: PackDefinition): Promise<void> {
|
|
await fsp.mkdir(manifestDirPath, { recursive: true })
|
|
const filePath = path.join(manifestDirPath, `${packKey}.json`)
|
|
await fsp.writeFile(filePath, `${JSON.stringify(pack, null, 2)}\n`, 'utf8')
|
|
}
|
|
|
|
export async function listPackKeys(): Promise<string[]> {
|
|
try {
|
|
const entries = await fsp.readdir(manifestDirPath)
|
|
return entries
|
|
.filter((name) => name.toLowerCase().endsWith('.json'))
|
|
.map(packKeyFromFile)
|
|
.sort((a, b) => a.localeCompare(b, 'ko'))
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export async function nextNewKey(): Promise<string> {
|
|
const used = new Set(await listPackKeys())
|
|
if (!used.has('new')) return 'new'
|
|
for (let i = 2; i < 1000; i += 1) {
|
|
const candidate = `new${i}`
|
|
if (!used.has(candidate)) return candidate
|
|
}
|
|
return `new-${Date.now()}`
|
|
}
|
|
|
|
export async function createPack(): Promise<{ key: string; pack: PackDefinition }> {
|
|
const key = await nextNewKey()
|
|
const pack = defaultPackDefinition(key)
|
|
await savePackDefinition(key, pack)
|
|
await syncManifestWith(key, pack.name, 'add')
|
|
return { key, pack }
|
|
}
|
|
|
|
export async function deletePackKeys(keys: string[]): Promise<void> {
|
|
for (const key of keys) {
|
|
if (!key) continue
|
|
const filePath = path.join(manifestDirPath, `${key}.json`)
|
|
try {
|
|
await fsp.unlink(filePath)
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
}
|
|
// pack 이 삭제되면 약관 폴더도 함께 정리한다. 동일 packKey 로 재생성될 때
|
|
// 옛 약관이 부활하는 것을 막기 위함.
|
|
const termsDir = path.join(manifestTermsDirPath, key)
|
|
try {
|
|
await fsp.rm(termsDir, { recursive: true, force: true })
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
}
|
|
await syncManifestWith(key, '', 'remove')
|
|
}
|
|
}
|
|
|
|
export async function renamePack(oldKey: string, newKey: string, pack: PackDefinition): Promise<string> {
|
|
const safeNew = sanitizePackKey(newKey) || oldKey
|
|
const targetPath = path.join(manifestDirPath, `${safeNew}.json`)
|
|
const sourcePath = path.join(manifestDirPath, `${oldKey}.json`)
|
|
|
|
if (safeNew !== oldKey && fs.existsSync(targetPath)) {
|
|
throw new Error(`이미 ${safeNew}.json 이름의 음악퀴즈가 있습니다.`)
|
|
}
|
|
|
|
await savePackDefinition(safeNew, pack)
|
|
if (safeNew !== oldKey) {
|
|
try {
|
|
await fsp.unlink(sourcePath)
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
}
|
|
// 음악·사진 목록 JSON(file/list/<key>.json)도 함께 이름을 바꾼다. 이걸 빼먹으면
|
|
// manifest 정의는 새 키로 옮겨졌는데 정작 목록 데이터는 옛 키 파일에 남아,
|
|
// 새 packKey 로는 빈 목록만 보이고 인스톨러도 곡/사진을 받지 못한다.
|
|
const oldListFile = path.join(fileListDirPath, `${oldKey}.json`)
|
|
const newListFile = path.join(fileListDirPath, `${safeNew}.json`)
|
|
try {
|
|
await fsp.mkdir(fileListDirPath, { recursive: true })
|
|
await fsp.rename(oldListFile, newListFile)
|
|
} catch (error) {
|
|
// 옛 목록 파일이 없으면(한 번도 저장 안 한 새 pack) 그냥 둔다.
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
}
|
|
// 약관 폴더도 함께 이름을 바꾼다 (있는 경우만). pack 이름이 바뀌었는데 약관이
|
|
// 옛 폴더에 남아 있으면 인스톨러가 새 packKey 로 약관을 받지 못한다.
|
|
const oldTermsDir = path.join(manifestTermsDirPath, oldKey)
|
|
const newTermsDir = path.join(manifestTermsDirPath, safeNew)
|
|
try {
|
|
await fsp.rename(oldTermsDir, newTermsDir)
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException).code
|
|
// 옛 약관 폴더가 없으면 그대로 둔다. 새 폴더가 이미 있어 충돌하면 그것도 그냥 둔다
|
|
// (renamePack 단계에서 사용자에게 보낼 마땅한 UX 가 없고, 다음 약관 접근 때
|
|
// 새 폴더 내용이 정상적으로 사용된다).
|
|
if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST') throw error
|
|
}
|
|
await syncManifestWith(oldKey, '', 'remove')
|
|
}
|
|
await syncManifestWith(safeNew, pack.name, 'upsert')
|
|
return safeNew
|
|
}
|
|
|
|
export function sanitizePackKey(value: string): string {
|
|
return value.replace(/[^a-zA-Z0-9_\-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
|
|
}
|
|
|
|
type ManifestSyncAction = 'add' | 'remove' | 'upsert'
|
|
|
|
async function syncManifestWith(key: string, name: string, action: ManifestSyncAction): Promise<void> {
|
|
const manifest = await readManifest()
|
|
const filtered = manifest.packs.filter((entry) => entry.file !== key)
|
|
if (action === 'remove') {
|
|
await writeManifest({ packs: filtered })
|
|
return
|
|
}
|
|
filtered.push({ name: name || key, file: key })
|
|
await writeManifest({ packs: filtered })
|
|
}
|
|
|
|
function defaultPackList(): PackList {
|
|
return { musicPlaylistUrl: '', imagePlaylistUrl: '', music: [], images: [] }
|
|
}
|
|
|
|
function sanitizeStr(value: unknown): string {
|
|
return typeof value === 'string' ? value.trim() : ''
|
|
}
|
|
|
|
function sanitizeNumber(value: unknown): number {
|
|
const n = typeof value === 'number' ? value : Number(value)
|
|
if (!Number.isFinite(n) || n < 0) return 0
|
|
return Math.floor(n)
|
|
}
|
|
|
|
/** 별칭 배열을 정규화: 문자열만 받아 trim → 빈 값 제거 → 중복 제거. */
|
|
function sanitizeAliases(value: unknown): string[] {
|
|
if (!Array.isArray(value)) return []
|
|
const out: string[] = []
|
|
const seen = new Set<string>()
|
|
for (const item of value) {
|
|
const s = sanitizeStr(item)
|
|
if (!s) continue
|
|
if (seen.has(s)) continue
|
|
seen.add(s)
|
|
out.push(s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
export function normalizePackList(input: unknown): PackList {
|
|
const fallback = defaultPackList()
|
|
if (!input || typeof input !== 'object') return fallback
|
|
const obj = input as Record<string, unknown>
|
|
const music = Array.isArray(obj.music) ? obj.music : []
|
|
const images = Array.isArray(obj.images) ? obj.images : []
|
|
return {
|
|
musicPlaylistUrl: sanitizeStr(obj.musicPlaylistUrl),
|
|
imagePlaylistUrl: sanitizeStr(obj.imagePlaylistUrl),
|
|
music: music
|
|
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
|
|
.map((entry): MusicListEntry => ({
|
|
url: sanitizeStr(entry.url),
|
|
title: sanitizeStr(entry.title),
|
|
artist: sanitizeStr(entry.artist),
|
|
durationSec: sanitizeNumber(entry.durationSec),
|
|
aliases: sanitizeAliases(entry.aliases),
|
|
description: sanitizeStr(entry.description)
|
|
}))
|
|
.filter((entry) => entry.url.length > 0),
|
|
images: images
|
|
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
|
|
.map((entry): ImageListEntry => ({ url: sanitizeStr(entry.url) }))
|
|
.filter((entry) => entry.url.length > 0)
|
|
}
|
|
}
|
|
|
|
export async function loadPackList(packKey: string): Promise<PackList> {
|
|
const filePath = path.join(fileListDirPath, `${packKey}.json`)
|
|
try {
|
|
const raw = await fsp.readFile(filePath, 'utf8')
|
|
return normalizePackList(JSON.parse(raw))
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return defaultPackList()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export async function savePackList(packKey: string, list: PackList): Promise<void> {
|
|
await fsp.mkdir(fileListDirPath, { recursive: true })
|
|
const filePath = path.join(fileListDirPath, `${packKey}.json`)
|
|
const normalized = normalizePackList(list)
|
|
await fsp.writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
|
|
}
|
|
|
|
// ─── Terms (Markdown 약관) ─────────────────────────────────────────────
|
|
// 사이트와 인스톨러가 약관을 보여주기 위해 사용하는 markdown 파일.
|
|
// - 음악퀴즈(pack)별로 독립 폴더(`manifest/terms/<packKey>/`) 에 저장한다.
|
|
// - 각 약관(.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
|
|
|
|
/**
|
|
* 처음 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}$/
|
|
|
|
export function isTermKind(value: unknown): value is TermKind {
|
|
return typeof value === 'string' && TERM_KIND_RE.test(value)
|
|
}
|
|
|
|
export interface TermEntry {
|
|
label: string
|
|
showInInstaller: boolean
|
|
showInInstallerRp: boolean
|
|
}
|
|
|
|
interface TermsMeta {
|
|
terms: Record<string, TermEntry>
|
|
}
|
|
|
|
const TERMS_META_FILE = '_meta.json'
|
|
|
|
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`
|
|
* 파일이 남아 있는 경우 첫 접근 시 그 내용을 그대로 새 폴더에 복사해 시드한다.
|
|
* 시드는 한 번만 발생: 폴더가 이미 있으면 아무것도 안 한다.
|
|
*
|
|
* 공개 라우트(`/manifest/terms/<packKey>/<file>`) 에서도 호출되므로 export 한다.
|
|
* 라우트 측은 packKey 가 실제 존재하는 pack 인지 확인한 다음에 호출해야 한다
|
|
* (그렇지 않으면 임의 키로 빈 폴더가 생성될 수 있다).
|
|
*/
|
|
export async function ensurePackTermsDir(packKey: string): Promise<string> {
|
|
const dir = termsDirForPack(packKey)
|
|
let isNew = false
|
|
try {
|
|
await fsp.access(dir)
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
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 */ }
|
|
}
|
|
} 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
|
|
}
|
|
|
|
// 이미 새 스키마면 종료. 빠진 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) 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 result
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { terms: {} }
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function saveTermsMeta(packKey: string, meta: TermsMeta): Promise<void> {
|
|
const dir = await ensurePackTermsDir(packKey)
|
|
await fsp.writeFile(
|
|
path.join(dir, TERMS_META_FILE),
|
|
`${JSON.stringify(meta, null, 2)}\n`,
|
|
'utf8'
|
|
)
|
|
}
|
|
|
|
export interface TermItem {
|
|
kind: string
|
|
label: string
|
|
showInInstaller: boolean
|
|
showInInstallerRp: boolean
|
|
}
|
|
|
|
/**
|
|
* 디스크의 .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)
|
|
let onDisk: string[] = []
|
|
try {
|
|
onDisk = await fsp.readdir(dir)
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
}
|
|
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
|
|
mdKinds.add(kind)
|
|
}
|
|
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> {
|
|
const meta = await loadTermsMeta(packKey)
|
|
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> {
|
|
if (!isTermKind(kind)) return ''
|
|
const dir = await ensurePackTermsDir(packKey)
|
|
const filePath = path.join(dir, `${kind}.md`)
|
|
try {
|
|
return await fsp.readFile(filePath, 'utf8')
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export async function saveTerm(packKey: string, kind: TermKind, markdown: string): Promise<void> {
|
|
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
|
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 충돌은 예외. 빈 `.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')
|
|
const cleanLabel = label.trim()
|
|
if (cleanLabel.length === 0 || cleanLabel.length > 50) throw new Error('invalid label length')
|
|
const meta = await loadTermsMeta(packKey)
|
|
if (meta.terms[kind]) throw new Error('term kind already exists')
|
|
const dir = await ensurePackTermsDir(packKey)
|
|
const filePath = path.join(dir, `${kind}.md`)
|
|
// 파일 충돌도 막는다 (수동 생성된 .md 가 있을 수 있음).
|
|
try {
|
|
await fsp.access(filePath)
|
|
throw new Error('term file already exists')
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
}
|
|
await fsp.writeFile(filePath, `# ${cleanLabel}\n\n`, 'utf8')
|
|
// 기본 시드 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)
|
|
}
|
|
|
|
/** 약관 삭제. v0.3.4~: builtin 보호 없음 — 모든 kind 삭제 가능. */
|
|
export async function deleteTerm(packKey: string, kind: string): Promise<void> {
|
|
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
|
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(packKey)
|
|
if (meta.terms[kind]) {
|
|
delete meta.terms[kind]
|
|
await saveTermsMeta(packKey, meta)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 다른 음악퀴즈의 약관 전체를 현재 pack 으로 복사한다 (불러오기).
|
|
* - source 의 모든 .md 를 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 mergedTerms: Record<string, TermEntry> = { ...targetMeta.terms }
|
|
for (const [k, v] of Object.entries(sourceMeta.terms)) {
|
|
mergedTerms[k] = { ...v }
|
|
}
|
|
await saveTermsMeta(targetPackKey, { terms: mergedTerms })
|
|
}
|
|
|
|
/**
|
|
* 공개 라우트(`/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)
|
|
}
|
|
|
|
export async function readAccounts(): Promise<AccountEntry[]> {
|
|
try {
|
|
const raw = await fsp.readFile(accountFilePath, 'utf8')
|
|
const parsed = JSON.parse(raw)
|
|
if (!Array.isArray(parsed)) return []
|
|
return parsed.filter((entry): entry is AccountEntry =>
|
|
typeof entry?.id === 'string' && typeof entry?.password === 'string')
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
|
throw error
|
|
}
|
|
}
|