Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa5da6d052 | |||
| f6df5f936c | |||
| dfb7acba2f | |||
| f4c9504c1a | |||
| 60a52a9bec | |||
| fe0d2f75e3 | |||
| 399f4af808 | |||
| d5f88e0e76 | |||
| d9ba2b0f35 | |||
| 3baf84cfd1 | |||
| d22c6f17a3 | |||
| 0629aa54aa | |||
| 201043e289 | |||
| acd3dd995d | |||
| b4160aefc1 | |||
| 1ac13a03ff | |||
| 542f759585 | |||
| 3248d096e4 | |||
| 8c9dc88e8b | |||
| b769f453a3 | |||
| 5c13648f63 | |||
| 9efd4a696a | |||
| c580a50fd4 | |||
| 38df72e4f6 | |||
| 6447b1cb78 | |||
| 9ba5dc6b7b | |||
| 05dc9d7166 | |||
| 25977d894b | |||
| c14b0507c7 |
@@ -141,14 +141,55 @@ function renderStep1() {
|
||||
}
|
||||
|
||||
// 약관 동의 페이지: 1단계 직후, 2단계 설치 진입 전에 노출.
|
||||
// rp 인스톨러는 리소스팩·설치기 두 약관만 확인·동의하면 된다.
|
||||
// v0.3.4~ : 사이트의 visibility 토글에 따라 표시할 약관이 결정된다. 명시적으로 빈 목록(terms:[])
|
||||
// 정상 응답일 때만 단계를 건너뛰고, 네트워크/서버 오류는 차단 후 다시 시도 UI를 보여준다.
|
||||
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 = '<h2>' + escapeHtml(tt('agreement.heading')) + '</h2>' +
|
||||
'<p class="formMessage">' + escapeHtml(tt('agreement.loading')) + '</p>'
|
||||
pageHost.appendChild(loadingSection)
|
||||
|
||||
api.getTermsList().then(function (res) {
|
||||
if (!res || !res.ok) {
|
||||
showAgreementError((res && res.message) || 'unknown')
|
||||
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 (err) {
|
||||
showAgreementError(err && err.message ? err.message : 'unknown')
|
||||
})
|
||||
}
|
||||
|
||||
// 약관 목록을 못 받아왔을 때: 사용자에게 오류 + 다시 시도 옵션. 동의 없이 설치 단계로
|
||||
// 자동 진입하지 않도록 next 버튼을 두지 않는다.
|
||||
function showAgreementError(message) {
|
||||
clearPage()
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>' + escapeHtml(tt('agreement.heading')) + '</h2>' +
|
||||
'<p class="formMessage error">' + escapeHtml(tt('agreement.listLoadFailed', { message: message })) + '</p>' +
|
||||
'<div class="actionRow">' +
|
||||
'<button class="secondaryBtn" id="back">' + escapeHtml(tt('common.back')) + '</button>' +
|
||||
'<button class="primaryBtn" id="retry">' + escapeHtml(tt('agreement.retry')) + '</button>' +
|
||||
'</div>'
|
||||
pageHost.appendChild(section)
|
||||
section.querySelector('#back').addEventListener('click', renderStep1)
|
||||
section.querySelector('#retry').addEventListener('click', renderAgreement)
|
||||
}
|
||||
|
||||
function renderAgreementWithKinds(KINDS) {
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
@@ -444,10 +485,43 @@ function renderStep2() {
|
||||
}).catch(function (err) {
|
||||
state.installing = false
|
||||
if (stopProgress) stopProgress()
|
||||
if (!cancelInitiated) {
|
||||
alert(tt('common.installFailed', { message: (err && err.message) || err }))
|
||||
}
|
||||
if (cancelInitiated) {
|
||||
// 취소: backend 가 임시 파일을 이미 정리했음. 조용히 처음 단계로.
|
||||
renderStep1()
|
||||
return
|
||||
}
|
||||
// 그 외 오류: 받아둔 음악·사진은 보존되어 있으므로 '재시도' 로 이어받을 수 있다.
|
||||
showInstallError((err && err.message) || String(err))
|
||||
})
|
||||
}
|
||||
|
||||
// 설치 실패 화면: 이어받기('재시도')와 처음으로('처음으로') 선택지를 제공한다.
|
||||
// 재시도 시 이미 받아둔 곡·사진은 건너뛰고 실패한 지점부터 이어서 설치한다.
|
||||
function showInstallError(message) {
|
||||
setActiveStep(2)
|
||||
clearPage()
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>' + escapeHtml(tt('step2.heading')) + '</h2>' +
|
||||
'<p class="formMessage error">' + escapeHtml(tt('install.errorMessage', { message: message })) + '</p>' +
|
||||
'<p class="formMessage">' + escapeHtml(tt('install.resumeHint')) + '</p>' +
|
||||
'<div class="actionRow">' +
|
||||
' <button class="secondaryBtn" id="startOver">' + escapeHtml(tt('install.startOver')) + '</button>' +
|
||||
' <button class="primaryBtn" id="retry">' + escapeHtml(tt('install.retry')) + '</button>' +
|
||||
'</div>'
|
||||
pageHost.appendChild(section)
|
||||
section.querySelector('#retry').addEventListener('click', function () {
|
||||
// 같은 음악퀴즈로 설치를 다시 시작. backend 가 받아둔 산출물을 건너뛴다.
|
||||
renderStep2()
|
||||
})
|
||||
section.querySelector('#startOver').addEventListener('click', function () {
|
||||
// 이어받지 않고 처음으로: 받아둔 임시 파일을 정리한 뒤 1단계로.
|
||||
api.discardInstall().then(function () {
|
||||
renderStep1()
|
||||
}).catch(function () {
|
||||
renderStep1()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -149,15 +149,58 @@ function renderStep1() {
|
||||
}
|
||||
|
||||
// 약관 동의 페이지: 음악퀴즈 선택 직후, 싱글/멀티 선택(step2) 진입 전에 노출.
|
||||
// 메인 설치기는 맵·모드·설치기 세 약관을 모두 확인·동의해야 다음 단계로 갈 수 있다.
|
||||
// v0.3.4~ : 어떤 약관을 표시할지는 사이트(/manifest/terms/<pack>/index.json) 가
|
||||
// 결정. 메인 인스톨러용으로 표시 토글된 항목만 받아 탭을 만든다. 목록이 비어 있는 (terms:[])
|
||||
// 정상 응답일 때만 단계 자체를 건너뛴다. 네트워크 오류/404/서버 오류는 사용자가 약관 동의
|
||||
// 없이 설치로 넘어가는 것을 막기 위해 오류 화면 + 다시 시도 버튼으로 차단한다.
|
||||
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 = '<h2>' + tt('agreement.heading') + '</h2>' +
|
||||
'<p class="formMessage">' + tt('agreement.loading') + '</p>'
|
||||
pageHost.appendChild(loadingSection)
|
||||
|
||||
installerApi.getTermsList().then(function (res) {
|
||||
if (!res || !res.ok) {
|
||||
showAgreementError((res && res.message) || 'unknown')
|
||||
return
|
||||
}
|
||||
var terms = (res.terms || []).map(function (t) {
|
||||
return { id: t.kind, tab: t.label }
|
||||
})
|
||||
if (terms.length === 0) {
|
||||
// 명시적으로 표시 대상이 0개라고 서버가 알려준 정상 응답 → 약관 단계 스킵.
|
||||
renderStep2()
|
||||
return
|
||||
}
|
||||
clearPage()
|
||||
renderAgreementWithKinds(terms)
|
||||
}).catch(function (err) {
|
||||
showAgreementError(err && err.message ? err.message : 'unknown')
|
||||
})
|
||||
}
|
||||
|
||||
// 약관 목록을 못 받아왔을 때: 사용자에게 오류 + 다시 시도/뒤로 가기 옵션을 보여준다.
|
||||
// 동의 없이 설치 단계로 넘어가지 않도록 next 버튼을 두지 않는다.
|
||||
function showAgreementError(message) {
|
||||
clearPage()
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>' + tt('agreement.heading') + '</h2>' +
|
||||
'<p class="formMessage error">' + tt('agreement.listLoadFailed', { message: message }) + '</p>' +
|
||||
'<div class="actionRow">' +
|
||||
'<button class="secondaryBtn" id="back">' + tt('common.back') + '</button>' +
|
||||
'<button class="primaryBtn" id="retry">' + tt('agreement.retry') + '</button>' +
|
||||
'</div>'
|
||||
pageHost.appendChild(section)
|
||||
section.querySelector('#back').addEventListener('click', renderStep1)
|
||||
section.querySelector('#retry').addEventListener('click', renderAgreement)
|
||||
}
|
||||
|
||||
function renderAgreementWithKinds(KINDS) {
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
@@ -165,7 +208,7 @@ function renderAgreement() {
|
||||
'<p class="formMessage">' + tt('agreement.intro') + '</p>' +
|
||||
'<div class="tabBar" id="agTabs">' +
|
||||
KINDS.map(function (k, i) {
|
||||
return '<button type="button" class="tabBtn' + (i === 0 ? ' active' : '') + '" data-ag="' + k.id + '">' + k.tab + '</button>'
|
||||
return '<button type="button" class="tabBtn' + (i === 0 ? ' active' : '') + '" data-ag="' + escapeHtml(k.id) + '">' + escapeHtml(k.tab) + '</button>'
|
||||
}).join('') +
|
||||
'</div>' +
|
||||
'<div class="agreementBody" id="agBody">' + tt('agreement.loading') + '</div>' +
|
||||
@@ -917,6 +960,12 @@ function renderStep5() {
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : c === '"' ? '"' : '''
|
||||
})
|
||||
}
|
||||
|
||||
// 시작 진입점: 사전을 먼저 받아서 정적 텍스트 갱신 후 첫 페이지 렌더.
|
||||
;(async function () {
|
||||
try {
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
"tabInstaller": "리소스팩 설치기 약관",
|
||||
"loading": "약관을 불러오는 중...",
|
||||
"loadFailed": "약관 로드 실패: {{message}}",
|
||||
"agreeAll": "위 모든 약관(리소스팩·설치기)에 동의합니다.",
|
||||
"listLoadFailed": "약관 표시에 실패하여 설치를 진행할 수 없습니다.\n사유: {{message}}\n네트워크 상태를 확인하고 다시 시도하거나, 처음 단계로 돌아가 주세요.",
|
||||
"retry": "다시 시도",
|
||||
"agreeAll": "위 모든 약관에 동의합니다.",
|
||||
"agreeRequired": "약관에 동의해야 다음 단계로 진행할 수 있습니다.",
|
||||
"cancelling": "취소 중…"
|
||||
},
|
||||
@@ -63,6 +65,12 @@
|
||||
"heading": "완료",
|
||||
"message": "리소스팩 설치를 완료했습니다."
|
||||
},
|
||||
"install": {
|
||||
"errorMessage": "설치 중 오류가 발생했습니다: {{message}}",
|
||||
"resumeHint": "재시도를 누르면 이미 받아둔 음악·사진은 건너뛰고 실패한 지점부터 이어서 설치합니다. 처음으로를 누르거나 프로그램을 닫으면 지금까지 받아둔 파일은 삭제됩니다.",
|
||||
"retry": "재시도",
|
||||
"startOver": "처음으로"
|
||||
},
|
||||
"log": {
|
||||
"manifestDownload": "manifest 다운로드: {{url}}",
|
||||
"packDefFailed": "팩 정의 로드 실패 ({{file}}): {{message}} — mcVersion 폴백",
|
||||
@@ -80,9 +88,14 @@
|
||||
"musicStart": "음악 다운로드 시작 ({{total}}곡, 동시 {{concurrency}}개, 시차 {{stagger}}ms)",
|
||||
"musicTrackStart": "{{idx}}번 노래 다운로드 시작",
|
||||
"musicTrackDone": "{{idx}}번 노래 완료: {{name}}",
|
||||
"musicTrackSkip": "{{idx}}번 노래는 이전에 받아둠 → 건너뜀(이어받기)",
|
||||
"musicRefreshRetry": "{{count}}곡 다운로드 실패 → yt-dlp/ffmpeg 최신 버전으로 재설치 후 실패한 곡만 재시도",
|
||||
"ytdlpReinstall": "yt-dlp.exe 최신 버전으로 강제 재설치 중…",
|
||||
"ffmpegReinstall": "ffmpeg.exe 최신 버전으로 강제 재설치 중…",
|
||||
"imageStart": "사진 다운로드 시작 ({{total}}장)",
|
||||
"imageDownloading": "{{idx}}번 사진 다운로드 중…",
|
||||
"imageDone": "{{idx}}번 사진 완료: {{name}}",
|
||||
"imageSkip": "{{idx}}번 사진은 이전에 받아둠 → 건너뜀(이어받기)",
|
||||
"baseDownload": "베이스 리소스팩 다운로드: {{path}}",
|
||||
"baseUrl": " URL: {{url}}",
|
||||
"baseReceived": "베이스 리소스팩 받음 ({{kb}} KB)",
|
||||
@@ -104,6 +117,8 @@
|
||||
"packFormatFallback": "pack_format = {{format}} (mcVersion \"{{version}}\" 매칭 실패, 최신 폴백)",
|
||||
"packFormatRange": "호환 범위 선언: pack_format {{min}} ~ {{max}} (supported_formats / min_format / max_format 모두 기록)",
|
||||
"soundsMerged": "기존 sounds.json 병합 ({{count}}개 항목)",
|
||||
"tracksAdded": "음악 트랙 추가됨: {{count}}곡",
|
||||
"paintingsAdded": "사진 텍스처 추가됨: {{count}}장",
|
||||
"ytdlpLine": "yt-dlp> {{line}}"
|
||||
},
|
||||
"progress": {
|
||||
@@ -133,10 +148,13 @@
|
||||
"ytdlpNoStderr": "(stderr 없음)",
|
||||
"ytdlpMissingOutput": "예상 출력파일이 없음: {{path}}",
|
||||
"imageMetaUnknown": "이미지 크기를 읽지 못함",
|
||||
"imageDataUrlInvalid": "data: URL 형식이 올바르지 않아 이미지를 디코드하지 못했습니다.",
|
||||
"ytdlpVerifyFailed": "yt-dlp.exe 다운로드는 됐지만 실행 검증에 실패했습니다.",
|
||||
"ytdlpInstallFailed": "yt-dlp.exe 자동 설치 실패: {{message}}",
|
||||
"ffmpegNotInZip": "zip 내부에서 ffmpeg.exe 를 찾을 수 없습니다.",
|
||||
"ffmpegVerifyFailed": "ffmpeg.exe 다운로드는 됐지만 실행 검증에 실패했습니다.",
|
||||
"ffmpegInstallFailed": "ffmpeg.exe 자동 설치 실패: {{message}}"
|
||||
"ffmpegInstallFailed": "ffmpeg.exe 자동 설치 실패: {{message}}",
|
||||
"baseTrackCollision": "베이스 리소스팩에 같은 트랙 ID 가 이미 있어 설치를 중단합니다: {{trackId}}\n베이스 자산을 보존하면서 새 트랙을 같은 ID 로 추가할 수 없습니다. 베이스의 sounds.json 엔트리/sounds 폴더에서 충돌하는 항목을 제거하거나 다른 베이스를 사용하세요.",
|
||||
"basePaintingCollision": "베이스 리소스팩에 같은 사진 파일이 이미 있어 설치를 중단합니다: {{name}}\n베이스의 painting 텍스처를 보존하면서 같은 파일명을 추가할 수 없습니다. 베이스에서 충돌하는 파일을 제거하거나 다른 베이스를 사용하세요."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
"tabInstaller": "설치기 약관",
|
||||
"loading": "약관을 불러오는 중...",
|
||||
"loadFailed": "약관 로드 실패: {{message}}",
|
||||
"agreeAll": "위 모든 약관(맵·모드·설치기)에 동의합니다.",
|
||||
"listLoadFailed": "약관 표시에 실패하여 설치를 진행할 수 없습니다.\n사유: {{message}}\n네트워크 상태를 확인하고 다시 시도하거나, 처음 단계로 돌아가 주세요.",
|
||||
"retry": "다시 시도",
|
||||
"agreeAll": "위 모든 약관에 동의합니다.",
|
||||
"agreeRequired": "약관에 동의해야 다음 단계로 진행할 수 있습니다."
|
||||
},
|
||||
"step1": {
|
||||
|
||||
@@ -78,6 +78,11 @@
|
||||
"aliasPlaceholder": "별칭 입력",
|
||||
"aliasRemove": "삭제",
|
||||
"aliasHint": "정답으로 인정할 다른 표기·번역·약칭을 추가할 수 있습니다.",
|
||||
"descBtn": "설명",
|
||||
"descModalTitle": "설명 - {{title}}",
|
||||
"descBack": "← 돌아가기",
|
||||
"descPlaceholder": "이 곡에 대한 설명을 입력하세요",
|
||||
"descHint": "곡 소개·트리비아 등 자유 메모. 정답 채점이나 데이터팩에는 사용되지 않습니다.",
|
||||
"metaLoading": "메타데이터 가져오는 중…",
|
||||
"metaFailedShort": "메타 조회 실패",
|
||||
"metaFailedTitle": "메타데이터 조회 실패",
|
||||
@@ -136,6 +141,9 @@
|
||||
"terms": {
|
||||
"browserTitle": "약관 수정",
|
||||
"title": "약관 수정",
|
||||
"pickPackHint": "약관을 수정할 음악퀴즈를 선택하세요. 각 음악퀴즈마다 약관을 따로 보관합니다.",
|
||||
"packBrowserTitle": "{{name}} — 약관 수정",
|
||||
"packTitle": "{{name}} 약관 수정",
|
||||
"hint": "수정할 약관을 선택하세요. 사이트에서 저장한 내용은 인스톨러가 약관 동의 화면에서 사용합니다.",
|
||||
"editorBrowserTitle": "{{label}} 편집",
|
||||
"editorTitle": "{{label}}",
|
||||
@@ -156,7 +164,33 @@
|
||||
"slashDivider": "구분선",
|
||||
"slashQuote": "인용",
|
||||
"slashCode": "코드",
|
||||
"leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?"
|
||||
"leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?",
|
||||
"visibilityHeading": "표시 대상 (중복 선택 가능)",
|
||||
"visibilityInstaller": "설치기에 표시",
|
||||
"visibilityInstallerRp": "리소스팩 설치기에 표시",
|
||||
"visibilityInstallerShort": "설치기",
|
||||
"visibilityInstallerRpShort": "리소스팩",
|
||||
"addHeading": "약관 추가",
|
||||
"kindLabel": "식별자",
|
||||
"kindPlaceholder": "예: privacy",
|
||||
"kindHint": "소문자/숫자/하이픈만 사용, 32자 이내. 파일명과 URL 에 그대로 쓰입니다.",
|
||||
"labelLabel": "표시 이름",
|
||||
"labelPlaceholder": "예: 개인정보 처리방침",
|
||||
"addButton": "추가",
|
||||
"deleteButton": "삭제",
|
||||
"deleteConfirm": "정말 \"{{label}}\" 약관을 삭제할까요? 이 동작은 되돌릴 수 없습니다.",
|
||||
"invalidKind": "식별자는 소문자/숫자/하이픈만, 32자 이내여야 합니다.",
|
||||
"createFailed": "약관 추가 실패",
|
||||
"cannotDeleteBuiltin": "기본 약관은 삭제할 수 없습니다.",
|
||||
"importHeading": "다른 음악퀴즈에서 불러오기",
|
||||
"importSourceLabel": "가져올 음악퀴즈",
|
||||
"importSourcePlaceholder": "음악퀴즈를 선택하세요",
|
||||
"importHint": "선택한 음악퀴즈의 모든 약관(.md + 라벨)을 현재 음악퀴즈로 복사합니다. 같은 식별자의 약관이 있으면 덮어씁니다.",
|
||||
"importButton": "불러오기",
|
||||
"importEmpty": "불러올 수 있는 다른 음악퀴즈가 없습니다.",
|
||||
"importConfirm": "선택한 음악퀴즈의 약관을 현재 음악퀴즈로 복사합니다. 같은 식별자의 약관은 덮어쓰여집니다. 진행할까요?",
|
||||
"importFailed": "약관 불러오기 실패",
|
||||
"invalidImportSource": "올바르지 않은 음악퀴즈입니다."
|
||||
},
|
||||
"datapack": {
|
||||
"browserTitle": "데이터팩 수정",
|
||||
@@ -191,6 +225,7 @@
|
||||
"youtube": {
|
||||
"ytdlpUnavailable": "yt-dlp 를 준비하지 못했습니다. (수동 입력으로 진행)",
|
||||
"ytdlpVerifyFailed": "yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다.",
|
||||
"ytdlpVerifyFailedDetail": "yt-dlp 를 사용할 수 없습니다. 시도한 경로 진단: {{detail}}",
|
||||
"ytdlpInstallFailed": "yt-dlp 자동 설치에 실패했습니다: {{message}}",
|
||||
"ytdlpVideoFailed": "yt-dlp 영상 조회 실패 (code={{code}}): {{detail}}",
|
||||
"ytdlpPlaylistFailed": "yt-dlp 플레이리스트 조회 실패 (code={{code}}): {{detail}}",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "minecraft-music-quiz-installer",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.10",
|
||||
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
|
||||
"main": "dist/installer/main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
var aliasLabel = aliasCount > 0
|
||||
? tt('aliasBtnWithCount', { count: aliasCount })
|
||||
: tt('aliasBtn')
|
||||
var hasDesc = typeof entry.description === 'string' && entry.description.trim().length > 0
|
||||
li.innerHTML =
|
||||
'<span class="rowNum">' + (idx + 1) + '</span>' +
|
||||
'<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy" draggable="false"/>' +
|
||||
@@ -114,12 +115,16 @@
|
||||
escapeHtml(entry.artist || '') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<button type="button" class="descBtn' + (hasDesc ? ' hasDesc' : '') + '" data-desc-open="' + idx + '" draggable="false">' +
|
||||
escapeHtml(tt('descBtn')) +
|
||||
'</button>' +
|
||||
'<button type="button" class="aliasBtn' + (aliasCount > 0 ? ' hasAliases' : '') + '" data-alias-open="' + idx + '" draggable="false">' +
|
||||
escapeHtml(aliasLabel) +
|
||||
'</button>' +
|
||||
'<span class="rowDur">' + fmtTime(entry.durationSec) + '</span>'
|
||||
attachDraggable(li, 'music', idx)
|
||||
attachInlineEdit(li, idx)
|
||||
attachDescBtn(li, idx)
|
||||
attachAliasBtn(li, idx)
|
||||
ol.appendChild(li)
|
||||
})
|
||||
@@ -391,7 +396,10 @@
|
||||
url: meta.url || url,
|
||||
title: meta.title || prev.title || '',
|
||||
artist: meta.channel || prev.artist || '',
|
||||
durationSec: typeof meta.durationSec === 'number' ? meta.durationSec : (prev.durationSec || 0)
|
||||
durationSec: typeof meta.durationSec === 'number' ? meta.durationSec : (prev.durationSec || 0),
|
||||
// URL 만 바뀌었다고 운영자가 손으로 입력한 메타(별칭/설명)까지 날려선 안 된다.
|
||||
aliases: Array.isArray(prev.aliases) ? prev.aliases : [],
|
||||
description: typeof prev.description === 'string' ? prev.description : ''
|
||||
}
|
||||
markDirty()
|
||||
closeAllModals()
|
||||
@@ -527,6 +535,57 @@
|
||||
if (e.target === aliasModal) closeAliasModalSaving()
|
||||
})
|
||||
|
||||
// ── 설명 모달 (음악) ─────────────────────────────────
|
||||
// 별칭 모달과 같은 패턴: 모달 닫힐 때 textarea 값을 state.music[idx].description 에 저장.
|
||||
var descModal = document.getElementById('descModal')
|
||||
var descTextarea = document.getElementById('desc-textarea')
|
||||
var descModalTitleEl = document.getElementById('desc-modal-title')
|
||||
var descBackBtn = document.getElementById('desc-back')
|
||||
var descEditingIdx = -1
|
||||
|
||||
function attachDescBtn(li, idx) {
|
||||
var btn = li.querySelector('[data-desc-open]')
|
||||
if (!btn) return
|
||||
btn.addEventListener('mousedown', function (e) { e.stopPropagation() })
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation()
|
||||
openDescModal(idx)
|
||||
})
|
||||
}
|
||||
|
||||
function openDescModal(idx) {
|
||||
if (!state.music[idx]) return
|
||||
descEditingIdx = idx
|
||||
var entry = state.music[idx]
|
||||
descModalTitleEl.textContent = tt('descModalTitle', { title: entry.title || tt('titleFallback') })
|
||||
descTextarea.value = typeof entry.description === 'string' ? entry.description : ''
|
||||
descModal.hidden = false
|
||||
setTimeout(function () { descTextarea.focus() }, 0)
|
||||
}
|
||||
|
||||
function closeDescModalSaving() {
|
||||
if (descEditingIdx < 0 || !state.music[descEditingIdx]) {
|
||||
descModal.hidden = true
|
||||
descEditingIdx = -1
|
||||
return
|
||||
}
|
||||
// textarea 값을 그대로 저장하되, 줄바꿈은 보존하고 양끝 공백만 다듬는다.
|
||||
var nextDesc = (descTextarea.value || '').replace(/\r\n/g, '\n').trim()
|
||||
var prev = state.music[descEditingIdx].description || ''
|
||||
if (nextDesc !== prev) {
|
||||
state.music[descEditingIdx].description = nextDesc
|
||||
markDirty()
|
||||
renderMusic()
|
||||
}
|
||||
descModal.hidden = true
|
||||
descEditingIdx = -1
|
||||
}
|
||||
|
||||
descBackBtn.addEventListener('click', closeDescModalSaving)
|
||||
descModal.addEventListener('click', function (e) {
|
||||
if (e.target === descModal) closeDescModalSaving()
|
||||
})
|
||||
|
||||
// ── 사진목록: 음악목록 그대로 복사 ─────────────────
|
||||
document.getElementById('image-from-music').addEventListener('click', function () {
|
||||
if (state.music.length === 0) {
|
||||
@@ -637,7 +696,7 @@
|
||||
var entries = result.body.entries || []
|
||||
if (target === 'music') {
|
||||
state.music = entries.map(function (e) {
|
||||
return { url: e.url, title: e.title || '', artist: e.channel || '', durationSec: e.durationSec || 0 }
|
||||
return { url: e.url, title: e.title || '', artist: e.channel || '', durationSec: e.durationSec || 0, aliases: [], description: '' }
|
||||
})
|
||||
renderMusic()
|
||||
} else {
|
||||
|
||||
@@ -407,19 +407,24 @@ body.siteBody.centerLayout {
|
||||
.trackList { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.trackRow {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 80px 1fr auto auto;
|
||||
grid-template-columns: 36px 80px 1fr auto auto auto;
|
||||
gap: 12px; align-items: center;
|
||||
padding: 8px 12px; background: var(--bg-card);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
cursor: grab; user-select: none;
|
||||
}
|
||||
.aliasBtn {
|
||||
.aliasBtn, .descBtn {
|
||||
background: var(--bg); border: 1px solid var(--border); color: var(--text);
|
||||
padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.aliasBtn:hover { border-color: var(--accent); }
|
||||
.aliasBtn.hasAliases { border-color: var(--accent); color: var(--accent); }
|
||||
.aliasBtn:hover, .descBtn:hover { border-color: var(--accent); }
|
||||
.aliasBtn.hasAliases, .descBtn.hasDesc { border-color: var(--accent); color: var(--accent); }
|
||||
.descTextarea {
|
||||
width: 100%; min-height: 140px; resize: vertical;
|
||||
font-family: inherit; font-size: 13px; line-height: 1.5;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
/* 별칭 모달 */
|
||||
.aliasModalHeader {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Notion 스타일 약관 편집기 전용 스타일.
|
||||
* 텍스트영역과 미리보기 영역을 동일한 폭/타이포로 보여 주어 입력 ↔ 미리보기
|
||||
* 전환 시 시각적 점프가 최소화되도록 한다. 슬래시 메뉴는 caret 좌표 위에
|
||||
* 절대 위치로 띄운다. */
|
||||
* 절대 위치로 띄운다. 색은 사이트 다크 팔레트(var(--bg-card) 등)에 맞춘다. */
|
||||
|
||||
.termsEditorWrap {
|
||||
position: relative;
|
||||
@@ -12,9 +12,10 @@
|
||||
width: 100%;
|
||||
min-height: 60vh;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid #d5d5d5;
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
background: var(--bg-card, #1f242c);
|
||||
color: var(--text, #e6edf3);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
@@ -22,19 +23,21 @@
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
white-space: pre-wrap;
|
||||
caret-color: var(--accent, #58a6ff);
|
||||
}
|
||||
|
||||
.termsEditor:focus {
|
||||
border-color: #5b8def;
|
||||
box-shadow: 0 0 0 2px rgba(91, 141, 239, 0.2);
|
||||
border-color: var(--accent, #58a6ff);
|
||||
box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.25);
|
||||
}
|
||||
|
||||
.termsPreview {
|
||||
min-height: 60vh;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid #d5d5d5;
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
background: var(--bg-alt, #161b22);
|
||||
color: var(--text, #e6edf3);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
box-sizing: border-box;
|
||||
@@ -46,23 +49,30 @@
|
||||
.termsPreview p { margin: 6px 0; }
|
||||
.termsPreview ul, .termsPreview ol { margin: 6px 0; padding-left: 22px; }
|
||||
.termsPreview li { margin: 2px 0; }
|
||||
.termsPreview hr { border: none; border-top: 1px solid #e0e0e0; margin: 12px 0; }
|
||||
.termsPreview hr { border: none; border-top: 1px solid var(--border, #30363d); margin: 12px 0; }
|
||||
.termsPreview blockquote {
|
||||
margin: 8px 0; padding: 4px 12px; border-left: 3px solid #ddd; color: #555;
|
||||
margin: 8px 0; padding: 4px 12px;
|
||||
border-left: 3px solid var(--border, #30363d);
|
||||
color: var(--text-muted, #8b949e);
|
||||
}
|
||||
.termsPreview code {
|
||||
background: #eee; padding: 1px 5px; border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 1px 5px; border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.termsPreview pre {
|
||||
background: #f0f0f0; padding: 10px 12px; border-radius: 6px; overflow: auto;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 10px 12px; border-radius: 6px; overflow: auto;
|
||||
}
|
||||
.termsPreview pre code { background: transparent; padding: 0; }
|
||||
.termsPreview a { color: #2664d8; text-decoration: underline; word-break: break-all; }
|
||||
.termsPreview a { color: var(--accent, #58a6ff); text-decoration: underline; word-break: break-all; }
|
||||
.termsPreview details {
|
||||
margin: 6px 0; border: 1px solid #e0e0e0; border-radius: 6px;
|
||||
background: #fff; padding: 4px 10px;
|
||||
margin: 6px 0;
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card, #1f242c);
|
||||
padding: 4px 10px;
|
||||
}
|
||||
.termsPreview details > summary { cursor: pointer; font-weight: 600; padding: 4px 0; }
|
||||
|
||||
@@ -73,10 +83,11 @@
|
||||
min-width: 220px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
background: var(--bg-alt, #161b22);
|
||||
color: var(--text, #e6edf3);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
|
||||
padding: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -89,7 +100,7 @@
|
||||
}
|
||||
.slashMenu .slashItem:hover,
|
||||
.slashMenu .slashItem.active {
|
||||
background: #eef2ff;
|
||||
background: var(--bg-card, #1f242c);
|
||||
}
|
||||
.slashMenu .slashItem strong { font-size: 13px; }
|
||||
.slashMenu .slashItem span { color: #888; font-size: 11px; }
|
||||
.slashMenu .slashItem strong { font-size: 13px; color: var(--text, #e6edf3); }
|
||||
.slashMenu .slashItem span { color: var(--text-muted, #8b949e); font-size: 11px; }
|
||||
|
||||
@@ -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
|
||||
fetch('/op/agreement/' + encodeURIComponent(TERM_KIND), {
|
||||
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) {
|
||||
|
||||
@@ -8,26 +8,45 @@
|
||||
// build/ 폴더는 electron-builder 가 exe 아이콘으로만 쓰고 asar 에
|
||||
// 포함되지 않아서, 런타임에 그 파일을 읽을 수 없다. 대신 빌드(개발) 시점에
|
||||
// 이 스크립트를 돌려 PNG 를 소스 코드에 인라인한다.
|
||||
//
|
||||
// 마인크래프트 런처의 사용자 지정 설치 아이콘 규격은 "128x128 PNG" 로
|
||||
// 고정돼 있다(https://minecraft.wiki/w/Launcher). 이 규격과 다른 크기
|
||||
// (예: 원본 256x256)를 주면 런처가 아이콘을 무시하고 기본 아이콘(화로)으로
|
||||
// 폴백한다. 그래서 build/icon.png 를 정확히 128x128 로 리사이즈해서 박는다.
|
||||
// exe 아이콘(build/icon.ico, build/icon.png)은 256x256 그대로 둔다.
|
||||
|
||||
'use strict'
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const sharp = require('sharp')
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..')
|
||||
const pngPath = path.join(repoRoot, 'build', 'icon.png')
|
||||
const tsPath = path.join(repoRoot, 'src', 'installer', 'launcherIcon.ts')
|
||||
|
||||
const buf = fs.readFileSync(pngPath)
|
||||
const ICON_SIZE = 128
|
||||
|
||||
async function main() {
|
||||
const buf = await sharp(pngPath)
|
||||
.resize(ICON_SIZE, ICON_SIZE, { fit: 'cover' })
|
||||
.png({ compressionLevel: 9 })
|
||||
.toBuffer()
|
||||
const b64 = buf.toString('base64')
|
||||
|
||||
const ts = `// AUTO-GENERATED by scripts/build-launcher-icon.cjs from build/icon.png.
|
||||
// 마인크래프트 런처의 "설치 설정" 화면에서 보이는 프로필 아이콘. exe 와 같은
|
||||
// 이미지를 쓰기 위해 빌드 시점에 PNG 를 data URL 로 인라인한다. 변경하려면
|
||||
// build/icon.png 교체 후 \`node scripts/build-launcher-icon.cjs\` 재실행.
|
||||
// 이미지를 ${ICON_SIZE}x${ICON_SIZE} 로 줄여 빌드 시점에 data URL 로 인라인한다.
|
||||
// 변경하려면 build/icon.png 교체 후 \`node scripts/build-launcher-icon.cjs\` 재실행.
|
||||
export const LAUNCHER_PROFILE_ICON =
|
||||
'data:image/png;base64,${b64}'
|
||||
`
|
||||
|
||||
fs.writeFileSync(tsPath, ts, 'utf8')
|
||||
console.log(`wrote ${tsPath} (${buf.length} bytes PNG → ${b64.length} chars base64)`)
|
||||
console.log(`wrote ${tsPath} (${ICON_SIZE}x${ICON_SIZE}, ${buf.length} bytes PNG → ${b64.length} chars base64)`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
@@ -39,9 +39,15 @@ async function migrateLegacyExe(target: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** BtbN/FFmpeg-Builds 의 win64-gpl 빌드. zip 내부에 bin/ffmpeg.exe 가 들어 있음. */
|
||||
/**
|
||||
* BtbN/FFmpeg-Builds 의 win64-gpl 빌드. zip 내부에 bin/ffmpeg.exe 가 들어 있음.
|
||||
* `releases/download/latest/` 형태(=항상 최신 자산이 붙어 있는 롤링 `latest` 태그)를
|
||||
* 쓴다. `releases/latest/download/`(GitHub 의 "최신 릴리스" 자동 포인터)는 갓
|
||||
* 만들어진 `autobuild-<날짜>` 릴리스로 리다이렉트되는데, 그 릴리스에 자산이 아직
|
||||
* 업로드되지 않았거나 없으면 HTTP 404 가 나서 ffmpeg 설치가 실패한다.
|
||||
*/
|
||||
const FFMPEG_ZIP_URL =
|
||||
'https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip'
|
||||
'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip'
|
||||
|
||||
let installPromise: Promise<string> | null = null
|
||||
|
||||
@@ -50,14 +56,20 @@ let installPromise: Promise<string> | null = null
|
||||
* ffmpeg.exe 만 추출해 설치하고 절대경로를 돌려준다.
|
||||
*/
|
||||
export async function ensureFfmpegExe(
|
||||
log?: (line: string) => void
|
||||
log?: (line: string) => void,
|
||||
force = false
|
||||
): Promise<string> {
|
||||
const target = getFfmpegExePath()
|
||||
await migrateLegacyExe(target)
|
||||
if (await canExecute(target)) {
|
||||
if (!force && await canExecute(target)) {
|
||||
log?.(t('log.ffmpegExists', { path: target }))
|
||||
return target
|
||||
}
|
||||
if (force) {
|
||||
// 강제 재설치: 오래됐을 수 있는 캐시본을 지워 최신 버전을 받게 한다.
|
||||
log?.(t('log.ffmpegReinstall'))
|
||||
try { await fs.unlink(target) } catch { /* noop */ }
|
||||
}
|
||||
if (installPromise) return installPromise
|
||||
|
||||
installPromise = (async () => {
|
||||
|
||||
@@ -29,8 +29,35 @@ export function ytIdFromUrl(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** 단순 HTTP/HTTPS GET (302 따라감, 4xx/5xx 는 reject). */
|
||||
function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
|
||||
/**
|
||||
* 일시적(transient) 으로 보고 재시도할 HTTP 상태코드.
|
||||
* 429 = Too Many Requests (i.ytimg.com 썸네일 서버가 연속 요청을 속도제한).
|
||||
* 5xx 게이트웨이 계열도 잠깐 뒤 다시 받으면 성공하는 경우가 많다.
|
||||
*/
|
||||
const TRANSIENT_CODES = new Set([408, 425, 429, 500, 502, 503, 504])
|
||||
const MAX_RETRIES = 5
|
||||
/** 백오프 상한(ms). Retry-After 헤더가 비정상적으로 커도 이 이상은 기다리지 않는다. */
|
||||
const MAX_BACKOFF_MS = 60000
|
||||
|
||||
/** Retry-After 헤더(초 또는 HTTP-date) → 대기 ms. 못 읽으면 null. */
|
||||
function parseRetryAfter(h: string | string[] | undefined): number | null {
|
||||
if (!h) return null
|
||||
const v = Array.isArray(h) ? h[0] : h
|
||||
const secs = Number(v)
|
||||
if (Number.isFinite(secs)) return Math.min(MAX_BACKOFF_MS, Math.max(0, secs * 1000))
|
||||
const date = Date.parse(v)
|
||||
if (!Number.isNaN(date)) return Math.min(MAX_BACKOFF_MS, Math.max(0, date - Date.now()))
|
||||
return null
|
||||
}
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
/**
|
||||
* 단순 HTTP/HTTPS GET (302 따라감).
|
||||
* 429/5xx 등 일시적 오류는 지수 백오프(+jitter, Retry-After 우선)로 최대
|
||||
* MAX_RETRIES 회 재시도한다. 그 외 4xx 나 재시도 소진 시 reject.
|
||||
*/
|
||||
function fetchBuffer(url: string, redirects = 0, attempt = 0): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (redirects > 8) {
|
||||
reject(new Error(t('common.tooManyRedirects')))
|
||||
@@ -38,6 +65,11 @@ function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
|
||||
}
|
||||
const target = new URL(url)
|
||||
const lib = target.protocol === 'https:' ? https : http
|
||||
const retryLater = (headerDelay: number | null): void => {
|
||||
const backoff = Math.min(MAX_BACKOFF_MS, 1000 * 2 ** attempt) + Math.floor(Math.random() * 500)
|
||||
const delay = headerDelay ?? backoff
|
||||
sleep(delay).then(() => fetchBuffer(url, redirects, attempt + 1).then(resolve, reject))
|
||||
}
|
||||
const req = lib.get(target, {
|
||||
timeout: 30000,
|
||||
headers: { 'user-agent': 'mc-music-quiz-rp-installer' }
|
||||
@@ -45,10 +77,15 @@ function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
|
||||
const code = res.statusCode || 0
|
||||
if (code >= 300 && code < 400 && res.headers.location) {
|
||||
res.resume()
|
||||
fetchBuffer(new URL(res.headers.location, target).toString(), redirects + 1)
|
||||
fetchBuffer(new URL(res.headers.location, target).toString(), redirects + 1, attempt)
|
||||
.then(resolve, reject)
|
||||
return
|
||||
}
|
||||
if (TRANSIENT_CODES.has(code) && attempt < MAX_RETRIES) {
|
||||
res.resume()
|
||||
retryLater(parseRetryAfter(res.headers['retry-after']))
|
||||
return
|
||||
}
|
||||
if (code !== 200) {
|
||||
res.resume()
|
||||
reject(new Error(`HTTP ${code}`))
|
||||
@@ -58,18 +95,48 @@ function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
|
||||
res.on('data', (c: Buffer) => chunks.push(c))
|
||||
res.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
})
|
||||
req.on('error', reject)
|
||||
req.on('error', (err) => {
|
||||
// 연결 끊김/리셋 등 네트워크 오류도 몇 번은 재시도.
|
||||
if (attempt < MAX_RETRIES) {
|
||||
retryLater(null)
|
||||
return
|
||||
}
|
||||
reject(err)
|
||||
})
|
||||
req.on('timeout', () => req.destroy(new Error(t('common.requestTimeout'))))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* data: URL 이면 그 안에 들어 있는 바이트를 바로 Buffer 로 디코드한다.
|
||||
* data: URL 은 이미지 데이터 자체를 품고 있어 네트워크 요청이 필요 없으며,
|
||||
* http/https 만 다루는 fetchBuffer 에 넘기면 `Protocol "data:" not supported`
|
||||
* 로 터지므로 여기서 가로챈다. data: URL 이 아니면 null.
|
||||
*/
|
||||
function decodeDataUrl(url: string): Buffer | null {
|
||||
if (!/^data:/i.test(url)) return null
|
||||
const comma = url.indexOf(',')
|
||||
if (comma < 0) throw new Error(t('errors.imageDataUrlInvalid'))
|
||||
const meta = url.slice(5, comma)
|
||||
const data = url.slice(comma + 1)
|
||||
// `;base64` 가 있으면 base64, 없으면 percent-encoding 된 텍스트.
|
||||
const buf = /;base64/i.test(meta)
|
||||
? Buffer.from(data, 'base64')
|
||||
: Buffer.from(decodeURIComponent(data), 'utf8')
|
||||
if (buf.length === 0) throw new Error(t('errors.imageDataUrlInvalid'))
|
||||
return buf
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 URL 을 다운로드해 Buffer 로 돌려준다.
|
||||
* - data: URL 이면 내장 바이트를 바로 디코드 (네트워크 없음).
|
||||
* - 유튜브 영상 URL 이면 `i.ytimg.com/vi/<id>/maxresdefault.jpg` 1차 →
|
||||
* 실패하면 `hqdefault.jpg` 로 폴백.
|
||||
* - 그 외 URL 은 HTTP GET 으로 그대로 받음.
|
||||
*/
|
||||
export async function downloadImage(rawUrl: string): Promise<Buffer> {
|
||||
const dataBuf = decodeDataUrl(rawUrl)
|
||||
if (dataBuf) return dataBuf
|
||||
const ytId = ytIdFromUrl(rawUrl)
|
||||
if (ytId) {
|
||||
try {
|
||||
|
||||
@@ -89,6 +89,16 @@ function acquireMusicStartSlot(): Promise<void> {
|
||||
return slot
|
||||
}
|
||||
|
||||
/** 파일이 존재하면 true. 이어받기(재시도) 시 이미 받아둔 산출물 감지에 사용. */
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fsp.access(p)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_MANIFEST_URL = getManifestUrl()
|
||||
|
||||
const state: RpInstallerState = {
|
||||
@@ -252,14 +262,16 @@ ipcMain.handle('rp:packs:select', async (_event, packKey: string) => {
|
||||
ipcMain.handle('rp:i18n:dict', () => localeDict)
|
||||
|
||||
// ── IPC: 약관 다운로드 ──────────────────────────────
|
||||
// 사이트가 /manifest/terms/<kind>.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(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) {
|
||||
@@ -267,6 +279,31 @@ ipcMain.handle('rp:terms:get', async (_event, kind: string) => {
|
||||
}
|
||||
})
|
||||
|
||||
// rp 인스톨러용 약관 목록. /manifest/terms/<packKey>/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<string, unknown>
|
||||
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'))
|
||||
@@ -284,16 +321,30 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
// 2-1. yt-dlp / ffmpeg 준비 (%appdata%/.mc_custom/{yt-dlp,ffmpeg}.exe)
|
||||
sendLog(t('log.ytdlpPreparing'))
|
||||
sendProgress({ phase: 'prep', message: t('progress.ytdlpPreparing') })
|
||||
const ytDlpBin = await ensureYtDlpExe(sendLog)
|
||||
let ytDlpBin = await ensureYtDlpExe(sendLog)
|
||||
sendLog(t('log.ytdlpPath', { path: ytDlpBin }))
|
||||
throwIfCancelled()
|
||||
sendLog(t('log.ffmpegPreparing'))
|
||||
sendProgress({ phase: 'prep', message: t('progress.ffmpegPreparing') })
|
||||
const ffmpegBin = await ensureFfmpegExe(sendLog)
|
||||
let ffmpegBin = await ensureFfmpegExe(sendLog)
|
||||
sendLog(t('log.ffmpegPath', { path: ffmpegBin }))
|
||||
sendProgress({ phase: 'prep', message: t('progress.ready'), done: true })
|
||||
throwIfCancelled()
|
||||
|
||||
// 음악 다운로드가 실패하면 yt-dlp/ffmpeg 가 너무 오래된 버전이라 유튜브 변경을
|
||||
// 못 따라가는 경우일 수 있다. 그때 최신 버전으로 한 번만 강제 재설치한다.
|
||||
// 워커 여러 개가 동시에 실패해도 재설치는 단 한 번만 일어나도록 락으로 직렬화.
|
||||
let binRefreshPromise: Promise<void> | null = null
|
||||
async function refreshBinariesOnce(): Promise<void> {
|
||||
if (!binRefreshPromise) {
|
||||
binRefreshPromise = (async () => {
|
||||
ytDlpBin = await ensureYtDlpExe(sendLog, true)
|
||||
ffmpegBin = await ensureFfmpegExe(sendLog, true)
|
||||
})()
|
||||
}
|
||||
await binRefreshPromise
|
||||
}
|
||||
|
||||
// 2-2. 음악 다운로드 (CPU 코어 수 기반 자동 동시 다운로드, 시차 출발, ogg 변환)
|
||||
const musicDir = path.join(tempRoot, 'music')
|
||||
await fsp.mkdir(musicDir, { recursive: true })
|
||||
@@ -306,17 +357,17 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
|
||||
// 클로저 안에서 narrowing 이 풀리지 않도록 로컬 alias.
|
||||
const musicList = pack.list.music
|
||||
let nextIndex = 0
|
||||
async function musicWorker(): Promise<void> {
|
||||
while (true) {
|
||||
if (state.cancelRequested) return
|
||||
const i = nextIndex++
|
||||
if (i >= musicTotal) return
|
||||
// 시차 게이트: 새 다운로드 시작은 직전 시작과 최소 MUSIC_START_STAGGER_MS 간격을 둠.
|
||||
await acquireMusicStartSlot()
|
||||
if (state.cancelRequested) return
|
||||
// 곡별 마지막 실패 메시지(재시도 단계에서 최종 에러 메시지로 사용).
|
||||
const failedMessages = new Map<number, string>()
|
||||
|
||||
// 한 곡을 한 번 받아본다. 성공 true / 실패 false.
|
||||
// emitErrorProgress=false 면 실패해도 UI 에 'error' 상태를 보내지 않는다(재시도 예정).
|
||||
async function tryDownloadTrack(i: number, emitErrorProgress: boolean): Promise<boolean> {
|
||||
const entry = musicList[i]
|
||||
const idx = i + 1
|
||||
// 최종 산출물 경로. 실패 시 부분 생성된 파일을 지워, 다음 재시도(이어받기)에서
|
||||
// 완성본으로 오인해 건너뛰는 일을 막는다.
|
||||
const expectedOut = path.join(musicDir, String(idx).padStart(2, '0') + '.ogg')
|
||||
sendLog(t('log.musicTrackStart', { idx }))
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
|
||||
let child: ChildProcess | null = null
|
||||
@@ -343,15 +394,47 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
if (child) state.activeChildren.delete(child)
|
||||
sendLog(t('log.musicTrackDone', { idx, name: path.basename(outPath) }))
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
|
||||
return true
|
||||
} catch (err) {
|
||||
if (child) state.activeChildren.delete(child)
|
||||
// 부분 생성된 .ogg 를 제거(이어받기 시 완성본 오인 방지).
|
||||
await fsp.rm(expectedOut, { force: true }).catch(() => {})
|
||||
if (state.cancelRequested) {
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: t('progress.cancelled') })
|
||||
return
|
||||
return false
|
||||
}
|
||||
failedMessages.set(i, (err as Error).message)
|
||||
if (emitErrorProgress) {
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: (err as Error).message })
|
||||
throw new Error(t('errors.musicDownloadFailed', { idx, message: (err as Error).message }))
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 1차 다운로드: 동시 워커로 전부 받아보고, 실패한 곡 인덱스만 모은다.
|
||||
// 여기서는 yt-dlp/ffmpeg 재설치를 하지 않는다(다른 워커가 같은 exe 를 실행 중일 수
|
||||
// 있어 Windows 파일 잠금으로 삭제/덮어쓰기가 실패할 수 있기 때문).
|
||||
const failed: number[] = []
|
||||
let nextIndex = 0
|
||||
async function musicWorker(): Promise<void> {
|
||||
while (true) {
|
||||
if (state.cancelRequested) return
|
||||
const i = nextIndex++
|
||||
if (i >= musicTotal) return
|
||||
const idx = i + 1
|
||||
// 이전 시도에서 이미 받아둔 곡(.ogg 존재)은 시차 게이트 없이 즉시 완료 처리
|
||||
// 한다. '재시도' 로 이어받을 때 받았던 곡을 다시 받지 않기 위함.
|
||||
const outPath = path.join(musicDir, String(idx).padStart(2, '0') + '.ogg')
|
||||
if (await fileExists(outPath)) {
|
||||
sendLog(t('log.musicTrackSkip', { idx }))
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
|
||||
continue
|
||||
}
|
||||
// 시차 게이트: 새 다운로드 시작은 직전 시작과 최소 MUSIC_START_STAGGER_MS 간격을 둠.
|
||||
await acquireMusicStartSlot()
|
||||
if (state.cancelRequested) return
|
||||
const ok = await tryDownloadTrack(i, false)
|
||||
if (!ok && !state.cancelRequested) failed.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,6 +444,29 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
await Promise.all(workers)
|
||||
throwIfCancelled()
|
||||
|
||||
// 1차에서 실패한 곡이 있으면, 모든 워커가 끝나 실행 중인 yt-dlp/ffmpeg 자식
|
||||
// 프로세스가 하나도 없는 지금 시점에 단 한 번 최신 버전으로 강제 재설치한다.
|
||||
// (각 워커 promise 는 자식 프로세스 close 후 resolve 되므로 여기선 exe 가 잠겨
|
||||
// 있지 않다 → Windows 파일 잠금 문제 없음.) 그런 다음 실패한 곡만 순차 재시도.
|
||||
if (failed.length > 0) {
|
||||
failed.sort((a, b) => a - b)
|
||||
sendLog(t('log.musicRefreshRetry', { count: failed.length }))
|
||||
await refreshBinariesOnce()
|
||||
throwIfCancelled()
|
||||
nextMusicStartAt = Date.now()
|
||||
for (const i of failed) {
|
||||
throwIfCancelled()
|
||||
await acquireMusicStartSlot()
|
||||
throwIfCancelled()
|
||||
const ok = await tryDownloadTrack(i, true)
|
||||
if (!ok) {
|
||||
throwIfCancelled()
|
||||
const idx = i + 1
|
||||
throw new Error(t('errors.musicDownloadFailed', { idx, message: failedMessages.get(i) ?? '' }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2-3. 사진 다운로드 + painting variant 정규화
|
||||
const paintingDir = path.join(tempRoot, 'painting')
|
||||
await fsp.mkdir(paintingDir, { recursive: true })
|
||||
@@ -369,21 +475,32 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
throwIfCancelled()
|
||||
const entry = pack.list.images[i]
|
||||
const idx = i + 1
|
||||
// 이전 시도에서 이미 정규화해둔 사진은 건너뛴다(이어받기).
|
||||
const coverPath = path.join(paintingDir, coverFileName(idx))
|
||||
if (await fileExists(coverPath)) {
|
||||
sendLog(t('log.imageSkip', { idx }))
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
|
||||
continue
|
||||
}
|
||||
sendLog(t('log.imageDownloading', { idx }))
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running' })
|
||||
let buf: Buffer
|
||||
try {
|
||||
buf = await downloadImage(entry.url)
|
||||
} catch (err) {
|
||||
// 부분 생성됐을 수 있는 커버 파일 제거(이어받기 시 완성본 오인 방지).
|
||||
await fsp.rm(coverPath, { force: true }).catch(() => {})
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
|
||||
throw new Error(t('errors.imageDownloadFailed', { idx, message: (err as Error).message }))
|
||||
}
|
||||
throwIfCancelled()
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 60, status: 'running' })
|
||||
const outPath = path.join(paintingDir, coverFileName(idx))
|
||||
const outPath = coverPath
|
||||
try {
|
||||
await normalizeToCover(buf, outPath)
|
||||
} catch (err) {
|
||||
// 변환 중 부분 생성된 PNG 제거(이어받기 시 완성본 오인 방지).
|
||||
await fsp.rm(coverPath, { force: true }).catch(() => {})
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
|
||||
throw new Error(t('errors.imageNormalizeFailed', { idx, message: (err as Error).message }))
|
||||
}
|
||||
@@ -457,11 +574,23 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
}
|
||||
|
||||
sendProgress({ phase: 'package', message: t('progress.installComplete'), done: true })
|
||||
// 성공: 임시 파일 정리
|
||||
await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {})
|
||||
return { resourcepackPath }
|
||||
} finally {
|
||||
// 임시 파일 정리
|
||||
} catch (err) {
|
||||
// 사용자가 취소한 경우에만 임시 파일을 지운다(처음부터 새로 시작).
|
||||
// 그 외 오류는 받아둔 음악·사진을 보존해 '재시도' 시 실패 지점부터 이어받게 한다.
|
||||
// (재시도 없이 프로그램을 닫으면 window-all-closed 에서 .temp 를 정리한다.)
|
||||
if (state.cancelRequested) {
|
||||
await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
throw err
|
||||
}
|
||||
})
|
||||
|
||||
// '처음으로' 버튼: 재시도하지 않고 처음 단계로 돌아갈 때 받아둔 임시 파일을 정리한다.
|
||||
ipcMain.handle('rp:install:discard', async () => {
|
||||
await fsp.rm(path.join(getMcCustomDir(), '.temp'), { recursive: true, force: true }).catch(() => {})
|
||||
})
|
||||
|
||||
ipcMain.handle('rp:install:cancel', async () => {
|
||||
|
||||
@@ -131,6 +131,10 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
|
||||
opts.log?.(t('log.packFormatRange', { min: minFmt, max: maxFmt }))
|
||||
|
||||
// 2) 음악 파일 복사 + sounds.json 생성/병합
|
||||
// 핵심 정책: 베이스 리소스팩에 이미 있는 자산은 절대 덮어쓰지 않는다.
|
||||
// - 베이스 sounds.json 의 엔트리는 그대로 보존하고, 우리 트랙은 그 위에 "추가" 만 한다.
|
||||
// - 베이스 sounds/track_NN.ogg 가 이미 있으면 덮어쓰지 않고 건너뛴다.
|
||||
// - 키나 파일명이 충돌하면 우리 트랙을 스킵하고 로그로 알린다.
|
||||
const musicFiles = (await fs.readdir(opts.musicDir))
|
||||
.filter((n) => n.toLowerCase().endsWith('.ogg'))
|
||||
.sort()
|
||||
@@ -152,7 +156,19 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
|
||||
// NN.ogg → track_NN.ogg 로 리네임해 패키지.
|
||||
const stem = path.basename(fname, path.extname(fname)) // "01"
|
||||
const trackId = `track_${stem}`
|
||||
await fs.copyFile(path.join(opts.musicDir, fname), path.join(soundsDir, `${trackId}.ogg`))
|
||||
const destFile = path.join(soundsDir, `${trackId}.ogg`)
|
||||
// 베이스에 같은 trackId 의 엔트리/파일이 있으면 두 선택지 다 깨진다:
|
||||
// (a) 덮어쓰면 베이스의 기존 곡이 사라지고,
|
||||
// (b) 새 곡을 스킵하면 데이터팩이 가리키는 곡이 빠진 채로 설치된다.
|
||||
// 안전하게 설치를 즉시 실패시키고 어떤 키가 충돌했는지 알린다.
|
||||
let collides = soundsJson[trackId] !== undefined
|
||||
if (!collides) {
|
||||
try { await fs.access(destFile); collides = true } catch { /* 없음 → OK */ }
|
||||
}
|
||||
if (collides) {
|
||||
throw new Error(t('errors.baseTrackCollision', { trackId }))
|
||||
}
|
||||
await fs.copyFile(path.join(opts.musicDir, fname), destFile)
|
||||
soundsJson[trackId] = {
|
||||
sounds: [
|
||||
{ name: `${NAMESPACE}:${trackId}`, stream: true }
|
||||
@@ -160,16 +176,25 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
|
||||
}
|
||||
}
|
||||
await fs.writeFile(soundsJsonPath, JSON.stringify(soundsJson, null, 2) + '\n')
|
||||
opts.log?.(t('log.tracksAdded', { count: musicFiles.length }))
|
||||
throwIfCancelled(cancel)
|
||||
|
||||
// 3) painting 텍스처 복사 (이미 cover_NN.png 형태). 같은 파일명은 덮어씀.
|
||||
// 3) painting 텍스처 복사 (이미 cover_NN.png 형태).
|
||||
// 음악과 동일한 정책: 베이스에 같은 파일명이 이미 있으면 설치를 실패시킨다.
|
||||
const paintingFiles = (await fs.readdir(opts.paintingDir))
|
||||
.filter((n) => n.toLowerCase().endsWith('.png'))
|
||||
.sort()
|
||||
for (const fname of paintingFiles) {
|
||||
throwIfCancelled(cancel)
|
||||
await fs.copyFile(path.join(opts.paintingDir, fname), path.join(paintingOutDir, fname))
|
||||
const destFile = path.join(paintingOutDir, fname)
|
||||
let collides = false
|
||||
try { await fs.access(destFile); collides = true } catch { /* 없음 → OK */ }
|
||||
if (collides) {
|
||||
throw new Error(t('errors.basePaintingCollision', { name: fname }))
|
||||
}
|
||||
await fs.copyFile(path.join(opts.paintingDir, fname), destFile)
|
||||
}
|
||||
opts.log?.(t('log.paintingsAdded', { count: paintingFiles.length }))
|
||||
throwIfCancelled(cancel)
|
||||
|
||||
// 4) zip 으로 묶기. 이 단계가 가장 길어서 별도로 cancel 폴링이 들어간다.
|
||||
|
||||
@@ -12,10 +12,14 @@ const api = {
|
||||
selectPack: (packKey: string): Promise<void> =>
|
||||
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'),
|
||||
@@ -23,6 +27,10 @@ const api = {
|
||||
cancelInstall: (): Promise<void> =>
|
||||
ipcRenderer.invoke('rp:install:cancel'),
|
||||
|
||||
/** 재시도하지 않고 처음으로 돌아갈 때 받아둔 임시 파일을 정리한다. */
|
||||
discardInstall: (): Promise<void> =>
|
||||
ipcRenderer.invoke('rp:install:discard'),
|
||||
|
||||
/** %appdata%/.mc_custom/resourcepacks/ 폴더를 OS 파일 탐색기로 연다. */
|
||||
openResourcepackFolder: (): Promise<void> =>
|
||||
ipcRenderer.invoke('rp:finish:openFolder'),
|
||||
|
||||
@@ -47,14 +47,20 @@ let installPromise: Promise<string> | null = null
|
||||
* 의 최신 yt-dlp.exe 를 받아 설치하고, 그 절대경로를 돌려준다.
|
||||
*/
|
||||
export async function ensureYtDlpExe(
|
||||
log?: (line: string) => void
|
||||
log?: (line: string) => void,
|
||||
force = false
|
||||
): Promise<string> {
|
||||
const target = getYtDlpExePath()
|
||||
await migrateLegacyExe(target)
|
||||
if (await canExecute(target)) {
|
||||
if (!force && await canExecute(target)) {
|
||||
log?.(t('log.ytdlpExists', { path: target }))
|
||||
return target
|
||||
}
|
||||
if (force) {
|
||||
// 강제 재설치: 오래됐을 수 있는 캐시본을 지워 최신 버전을 받게 한다.
|
||||
log?.(t('log.ytdlpReinstall'))
|
||||
try { await fs.unlink(target) } catch { /* noop */ }
|
||||
}
|
||||
if (installPromise) return installPromise
|
||||
|
||||
installPromise = (async () => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -154,15 +154,18 @@ ipcMain.handle('packs:load', async (_event, manifestUrlInput?: string): Promise<
|
||||
return results
|
||||
})
|
||||
|
||||
// 약관(Markdown) 을 사이트(/manifest/terms/<kind>.md) 에서 받아와 그대로 돌려준다.
|
||||
// 화이트리스트로 5종 제한. 네트워크 실패 시 에러 메시지가 그대로 화면에 노출된다.
|
||||
const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp'])
|
||||
// 약관(Markdown) 을 사이트(/manifest/terms/<packKey>/<kind>.md) 에서 받아와 그대로 돌려준다.
|
||||
// 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' }
|
||||
}
|
||||
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) {
|
||||
@@ -170,6 +173,31 @@ ipcMain.handle('terms:get', async (_event, kind: string): Promise<{ ok: boolean;
|
||||
}
|
||||
})
|
||||
|
||||
// 메인 인스톨러용 약관 목록. /manifest/terms/<packKey>/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<string, unknown>
|
||||
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'))
|
||||
|
||||
@@ -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<string | null> => ipcRenderer.invoke('dialog:pickFolder'),
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
manifestRootPath, manifestDirPath, manifestTermsDirPath,
|
||||
fileDirPath, viewsDirPath, publicDirPath
|
||||
} from '../shared/paths.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'
|
||||
@@ -62,19 +65,58 @@ app.get('/manifest.json', (_req, res) => {
|
||||
res.sendFile(manifestRootPath)
|
||||
})
|
||||
|
||||
// 설치기에서 약관(markdown) 을 가져갈 수 있도록 화이트리스트 파일명만 허용.
|
||||
app.get('/manifest/terms/:fileName', (req, res) => {
|
||||
const fileName = req.params.fileName
|
||||
// 화이트리스트: map.md, resourcepack.md, mod.md, installer.md, installer-rp.md
|
||||
if (!/^(map|resourcepack|mod|installer|installer-rp)\.md$/.test(fileName)) {
|
||||
// 설치기 + 사이트가 약관(markdown) 을 가져갈 수 있도록 .md 만 허용한다.
|
||||
// 음악퀴즈(pack) 별로 manifest/terms/<packKey>/<file>.md 에서 노출한다.
|
||||
// _meta.json 같은 시스템 파일이나 경로 탈출은 isPublicTermsFile 에서 차단.
|
||||
//
|
||||
// fresh 배포에서 관리자가 약관 페이지를 한 번도 열지 않은 상태로 설치기가 약관을
|
||||
// 요청하는 경우에도 작동하도록, 실제 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
|
||||
if (!isPublicTermsFile(packKey, fileName)) {
|
||||
res.status(404).send('Not Found')
|
||||
return
|
||||
}
|
||||
const pack = await loadPackDefinition(packKey)
|
||||
if (!pack) {
|
||||
res.status(404).send('Not Found')
|
||||
return
|
||||
}
|
||||
await ensurePackTermsDir(packKey)
|
||||
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')
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// 설치기에서 개별 음악퀴즈 JSON을 가져갈 수 있도록 파일 단위로만 허용.
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import type { MusicListEntry, PackList } from '../shared/types.js'
|
||||
|
||||
/** SNBT 문자열 리터럴 안에 들어갈 문자열을 escape. */
|
||||
/**
|
||||
* SNBT 문자열 리터럴 안에 들어갈 문자열을 escape.
|
||||
* 백슬래시·따옴표 외에도 줄바꿈·탭을 이스케이프해서 `data modify` 한 줄 명령이
|
||||
* description 같은 멀티라인 입력 때문에 깨지지 않게 한다.
|
||||
*/
|
||||
function escapeSnbtString(input: string): string {
|
||||
return input.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
return input
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\t/g, '\\t')
|
||||
}
|
||||
|
||||
/** alias 배열을 SNBT 리스트 리터럴로 변환. 빈 배열도 `[]` 로 출력. */
|
||||
@@ -12,13 +21,16 @@ function aliasListSnbt(aliases: string[]): string {
|
||||
return `[${parts.join(',')}]`
|
||||
}
|
||||
|
||||
/** 한 곡(MusicListEntry) → `{title:"...", author:"...", alias:[...]}` SNBT. */
|
||||
/** 한 곡(MusicListEntry) → `{title:"...", author:"...", alias:[...], description:"...", volume:1.0}` SNBT. */
|
||||
function entrySnbt(entry: MusicListEntry): string {
|
||||
const title = escapeSnbtString(entry.title ?? '')
|
||||
// launcher 의 artist → 데이터팩 SNBT 의 author. 빈 값은 빈 문자열로 그대로 둔다.
|
||||
const author = escapeSnbtString(entry.artist ?? '')
|
||||
const alias = aliasListSnbt(entry.aliases ?? [])
|
||||
return `{title:"${title}", author:"${author}", alias:${alias}}`
|
||||
const description = escapeSnbtString(entry.description ?? '')
|
||||
// launcher 가 생성하는 항목에는 volume 기본값 1.0 을 항상 넣는다.
|
||||
// 운영자는 생성된 mcfunction 에서 곡별로 직접 값을 바꿔 사용한다.
|
||||
return `{title:"${title}", author:"${author}", alias:${alias}, description:"${description}", volume:1.0}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,11 +41,11 @@ function entrySnbt(entry: MusicListEntry): string {
|
||||
export function buildSongsMcfunction(list: PackList): string {
|
||||
const lines: string[] = []
|
||||
lines.push('# 곡 한 개 = 한 줄.')
|
||||
lines.push('# 필수 — title, author, alias')
|
||||
lines.push('# 필수 — title, author, alias, description')
|
||||
lines.push('# 선택 — volume (이 곡만의 /playsound 음량. 미지정시 init/config.mcfunction')
|
||||
lines.push('# 의 audio.volume 사용)')
|
||||
lines.push('# 곡 순서가 리소스팩의 track_NN / cover_NN 인덱스와 1:1 매칭된다.')
|
||||
lines.push('# 예) {title:"Quiet Song", author:"...", alias:[...], volume:2.0}')
|
||||
lines.push('# 예) {title:"Quiet Song", author:"...", alias:[...], description:"...", volume:1.0}')
|
||||
lines.push('data modify storage mq:main songs set value []')
|
||||
for (const entry of list.music) {
|
||||
lines.push(`data modify storage mq:main songs append value ${entrySnbt(entry)}`)
|
||||
|
||||
@@ -2,8 +2,14 @@ import { Router } from 'express'
|
||||
import archiver from 'archiver'
|
||||
import {
|
||||
createPack,
|
||||
createTerm,
|
||||
deletePackKeys,
|
||||
deleteTerm,
|
||||
getTermEntry,
|
||||
importTerms,
|
||||
isTermKind,
|
||||
listPackKeys,
|
||||
listTermsWithLabels,
|
||||
loadPackDefinition,
|
||||
loadPackList,
|
||||
loadTerm,
|
||||
@@ -14,10 +20,8 @@ import {
|
||||
sanitizePackKey,
|
||||
saveTerm,
|
||||
savePackList,
|
||||
isTermKind,
|
||||
TERM_KINDS
|
||||
setTermVisibility
|
||||
} from '../../shared/store.js'
|
||||
import type { TermKind } from '../../shared/store.js'
|
||||
import { fetchReleaseVersions } from '../../shared/mojang.js'
|
||||
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
@@ -289,8 +293,8 @@ opRouter.get('/op/datapack/:packName/images-zip', requireAuth, async (req, res,
|
||||
asset_id: `musicquiz:cover_${nn}`,
|
||||
width: size,
|
||||
height: size,
|
||||
title: { text: `Cover ${nn}` },
|
||||
author: { text: 'music quiz' }
|
||||
author: 'musicquiz',
|
||||
title: `cover_${nn}`
|
||||
}
|
||||
archive.append(JSON.stringify(json, null, 2) + '\n', { name: `cover_${nn}.json` })
|
||||
}
|
||||
@@ -301,33 +305,147 @@ opRouter.get('/op/datapack/:packName/images-zip', requireAuth, async (req, res,
|
||||
})
|
||||
|
||||
// ─── /op/agreement ─────────────────────────────────────────────────────
|
||||
// 약관(Markdown) 5종 편집기. 사이트에서는 노션 스타일의 슬래시 명령으로
|
||||
// 마크다운을 작성하고, 인스톨러는 /manifest/terms/<kind>.md 로 받아 표시한다.
|
||||
const TERM_LABELS: Record<TermKind, string> = {
|
||||
'map': '맵 약관',
|
||||
'resourcepack': '리소스팩 약관',
|
||||
'mod': '모드 약관',
|
||||
'installer': '설치기 약관',
|
||||
'installer-rp': '리소스팩 설치기 약관'
|
||||
}
|
||||
// 약관(Markdown) 편집기. 음악퀴즈(pack) 단위로 따로 저장한다.
|
||||
// 5종 기본 약관(map/mod/installer/resourcepack/installer-rp) 은 첫 접근 시 시드되지만
|
||||
// 사용자가 자유롭게 삭제/추가/표시 대상 변경할 수 있다 (v0.3.4~). 인스톨러는
|
||||
// /manifest/terms/<packKey>/index.json 으로 자신에게 표시할 약관 목록을 받는다.
|
||||
|
||||
opRouter.get('/op/agreement', requireAuth, (req, res) => {
|
||||
const items = TERM_KINDS.map((kind) => ({ kind, label: TERM_LABELS[kind] }))
|
||||
// /op/agreement → 음악퀴즈 선택(/op/list 와 동일한 카드 형식).
|
||||
opRouter.get('/op/agreement', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
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.get('/op/agreement/:kind', 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(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/: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'))
|
||||
return
|
||||
}
|
||||
await deleteTerm(packKey, kind)
|
||||
res.redirect(`/op/agreement/${packKey}`)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
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 entry = await getTermEntry(packKey, kind)
|
||||
if (!entry) {
|
||||
res.status(404).send(t('errors.unknown'))
|
||||
return
|
||||
}
|
||||
const content = await loadTerm(packKey, kind)
|
||||
res.render('op/termsEditor', {
|
||||
userId: req.session.userId,
|
||||
packKey,
|
||||
pack: definition,
|
||||
kind,
|
||||
label: TERM_LABELS[kind],
|
||||
label: entry.label,
|
||||
showInInstaller: entry.showInInstaller,
|
||||
showInInstallerRp: entry.showInInstallerRp,
|
||||
content
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -335,15 +453,32 @@ 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)
|
||||
// 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)
|
||||
|
||||
@@ -32,48 +32,63 @@ function getYtDlpAssetName(): string {
|
||||
return 'yt-dlp' // 그 외 OS: 순수 파이썬 zipapp. python3 가 PATH 에 있어야 동작
|
||||
}
|
||||
|
||||
/** 로컬 설치 경로: %appdata%/.mc_custom/<asset> */
|
||||
/**
|
||||
* 로컬 설치 경로: OS별 사용자 데이터 디렉터리 안의 .mc_custom/<asset>.
|
||||
* - Windows: %APPDATA%/.mc_custom/yt-dlp.exe
|
||||
* - macOS : ~/Library/Application Support/.mc_custom/yt-dlp_macos
|
||||
* - Linux 등: $XDG_CONFIG_HOME 또는 ~/.config/.mc_custom/yt-dlp_linux (arch 따라 다름)
|
||||
*/
|
||||
export function getYtDlpInstallPath(): string {
|
||||
return path.join(getMcCustomDir(), getYtDlpAssetName())
|
||||
}
|
||||
|
||||
/** 순수 파이썬 zipapp(`yt-dlp`) 의 로컬 설치 경로. python3 가 PATH 에 있어야 동작. */
|
||||
function getYtDlpZipappPath(): string {
|
||||
return path.join(getMcCustomDir(), 'yt-dlp_zipapp')
|
||||
}
|
||||
|
||||
/** 한 번에 한 다운로드만 진행하도록 락 (서버 동시 요청 보호). */
|
||||
let installPromise: Promise<string> | null = null
|
||||
|
||||
type ProbeResult = { ok: true } | { ok: false; detail: string }
|
||||
|
||||
/**
|
||||
* %appdata%/.mc_custom/ 에 yt-dlp 가 준비됐는지 확인하고, 없으면 GitHub Releases 에서
|
||||
* 현재 OS/아키텍처용 바이너리를 자동으로 받아 설치한다. 성공 시 실행 경로 반환.
|
||||
* .mc_custom/ 디렉터리에 yt-dlp 가 준비됐는지 확인하고, 없으면 GitHub Releases 에서
|
||||
* 현재 OS/아키텍처용 네이티브 바이너리를 자동으로 받아 설치한다. 성공 시 실행 경로 반환.
|
||||
*
|
||||
* 네이티브 바이너리가 실행되지 않는 환경(glibc 미스매치, musl libc, antivirus 차단 등)
|
||||
* 이면 다음 순서로 폴백한다:
|
||||
* 1) PATH 의 `yt-dlp(.exe)` (시스템에 따로 깐 거)
|
||||
* 2) (POSIX 한정) 범용 파이썬 zipapp `yt-dlp` 를 다운로드 후 shebang 실행 — python3 필요
|
||||
* 전부 실패하면 각 시도의 진단정보가 포함된 에러를 던진다.
|
||||
*/
|
||||
export async function ensureYtDlp(): Promise<string> {
|
||||
export async function ensureYtDlp(force = false): Promise<string> {
|
||||
const target = getYtDlpInstallPath()
|
||||
// 이미 설치돼 있고 실행 가능하면 그대로 사용
|
||||
if (await canExecute(target)) return target
|
||||
if (!force) {
|
||||
// Fast path: 이미 설치돼 있고 실행도 잘 되면 그대로 사용
|
||||
if (await fileExists(target)) {
|
||||
const probe = await probeVersion(target)
|
||||
if (probe.ok) return target
|
||||
}
|
||||
// Fast path: 네이티브가 안 도는 환경에서 이전에 받아둔 zipapp 이 살아있으면 그걸 재사용
|
||||
if (process.platform !== 'win32') {
|
||||
const zipappPath = getYtDlpZipappPath()
|
||||
if (await fileExists(zipappPath)) {
|
||||
const probe = await probeVersion(zipappPath)
|
||||
if (probe.ok) return zipappPath
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 강제 재설치: 캐시된(=오래됐을 수 있는) 바이너리를 지워 최신으로 다시 받게 한다.
|
||||
try { await fs.unlink(target) } catch { /* noop */ }
|
||||
if (process.platform !== 'win32') {
|
||||
try { await fs.unlink(getYtDlpZipappPath()) } catch { /* noop */ }
|
||||
}
|
||||
}
|
||||
if (installPromise) return installPromise
|
||||
installPromise = (async () => {
|
||||
try {
|
||||
const dir = getMcCustomDir()
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
const asset = getYtDlpAssetName()
|
||||
const url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}`
|
||||
await downloadToFile(url, target)
|
||||
// POSIX 계열은 실행 권한 부여
|
||||
if (process.platform !== 'win32') {
|
||||
await fs.chmod(target, 0o755)
|
||||
}
|
||||
// 검증
|
||||
const okVersion = await probeVersion(target)
|
||||
if (!okVersion) {
|
||||
throw new YtDlpUnavailableError(t('youtube.ytdlpVerifyFailed'))
|
||||
}
|
||||
return target
|
||||
} catch (err) {
|
||||
// 실패 흔적(부분 다운로드) 삭제
|
||||
try { await fs.unlink(target) } catch { /* noop */ }
|
||||
throw err instanceof YtDlpUnavailableError
|
||||
? err
|
||||
: new YtDlpUnavailableError(
|
||||
t('youtube.ytdlpInstallFailed', { message: err instanceof Error ? err.message : String(err) })
|
||||
)
|
||||
return await prepareYtDlp(target, force)
|
||||
} finally {
|
||||
installPromise = null
|
||||
}
|
||||
@@ -81,31 +96,121 @@ export async function ensureYtDlp(): Promise<string> {
|
||||
return installPromise
|
||||
}
|
||||
|
||||
async function canExecute(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath, fsConst.F_OK)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
// POSIX 면 X 비트도 확인
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
await fs.access(filePath, fsConst.X_OK)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 실제로 --version 으로 한 번 더 확인
|
||||
return probeVersion(filePath)
|
||||
async function prepareYtDlp(target: string, force = false): Promise<string> {
|
||||
const diagnostics: string[] = []
|
||||
|
||||
// 강제 재설치(force)면 기존 캐시·PATH 시도를 건너뛰고 곧장 최신 버전을 받는다.
|
||||
if (!force) {
|
||||
// 1a. 기존 네이티브 파일이 있으면 우선 그걸로 시도
|
||||
if (await fileExists(target)) {
|
||||
const probe = await probeVersion(target)
|
||||
if (probe.ok) return target
|
||||
diagnostics.push(`기존 ${path.basename(target)} 검증 실패: ${probe.detail}`)
|
||||
}
|
||||
|
||||
function probeVersion(bin: string): Promise<boolean> {
|
||||
// 1b. (POSIX) 기존 zipapp 이 있으면 재다운로드 전에 먼저 시도
|
||||
if (process.platform !== 'win32') {
|
||||
const existingZipapp = getYtDlpZipappPath()
|
||||
if (await fileExists(existingZipapp)) {
|
||||
const probe = await probeVersion(existingZipapp)
|
||||
if (probe.ok) return existingZipapp
|
||||
diagnostics.push(`기존 yt-dlp_zipapp 검증 실패: ${probe.detail}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. PATH 에 yt-dlp(.exe) 가 시스템 전역으로 설치돼 있으면 그걸 사용
|
||||
const pathCmd = process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp'
|
||||
const pathProbe = await probeVersion(pathCmd)
|
||||
if (pathProbe.ok) return pathCmd
|
||||
diagnostics.push(`PATH 의 ${pathCmd} 사용 불가: ${pathProbe.detail}`)
|
||||
}
|
||||
|
||||
// 3. 최후 수단: 새로 다운로드해서 시도
|
||||
try {
|
||||
await fs.mkdir(getMcCustomDir(), { recursive: true })
|
||||
const asset = getYtDlpAssetName()
|
||||
const url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}`
|
||||
try { await fs.unlink(target) } catch { /* noop */ }
|
||||
await downloadToFile(url, target)
|
||||
if (process.platform !== 'win32') {
|
||||
await fs.chmod(target, 0o755)
|
||||
} else {
|
||||
// Windows: 인터넷에서 받은 파일에는 NTFS ADS 'Zone.Identifier' 가 붙어
|
||||
// SmartScreen/Attachment Manager 가 실행을 막을 수 있다. 베스트에포트로 제거.
|
||||
try { await fs.unlink(`${target}:Zone.Identifier`) } catch { /* noop */ }
|
||||
}
|
||||
const probe = await probeVersion(target)
|
||||
if (probe.ok) return target
|
||||
diagnostics.push(`새로 받은 ${asset} 검증 실패: ${probe.detail}`)
|
||||
try { await fs.unlink(target) } catch { /* noop */ }
|
||||
} catch (err) {
|
||||
diagnostics.push(`다운로드 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
try { await fs.unlink(target) } catch { /* noop */ }
|
||||
}
|
||||
|
||||
// 4. POSIX 한정 최후 폴백: 범용 파이썬 zipapp `yt-dlp` 다운로드 후 shebang 실행.
|
||||
// 네이티브 바이너리가 glibc/musl/arch 문제로 못 도는 리눅스 환경이라도
|
||||
// python3 가 PATH 에 있으면 동작한다. ~ 3MB 짜리 스크립트.
|
||||
if (process.platform !== 'win32') {
|
||||
const zipappPath = getYtDlpZipappPath()
|
||||
try {
|
||||
try { await fs.unlink(zipappPath) } catch { /* noop */ }
|
||||
await downloadToFile('https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp', zipappPath)
|
||||
await fs.chmod(zipappPath, 0o755)
|
||||
const probe = await probeVersion(zipappPath)
|
||||
if (probe.ok) return zipappPath
|
||||
diagnostics.push(`zipapp yt-dlp 검증 실패: ${probe.detail} (python3 누락이거나 PATH 에 없음)`)
|
||||
try { await fs.unlink(zipappPath) } catch { /* noop */ }
|
||||
} catch (err) {
|
||||
diagnostics.push(`zipapp 다운로드 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
try { await fs.unlink(zipappPath) } catch { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
throw new YtDlpUnavailableError(
|
||||
t('youtube.ytdlpVerifyFailedDetail', { detail: diagnostics.join(' | ') })
|
||||
)
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath, fsConst.F_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function probeVersion(bin: string): Promise<ProbeResult> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let ok = false
|
||||
child.stdout.on('data', () => { ok = true })
|
||||
child.on('error', () => resolve(false))
|
||||
child.on('close', (code) => resolve(ok && code === 0))
|
||||
let child: ReturnType<typeof spawn>
|
||||
try {
|
||||
child = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
|
||||
} catch (err) {
|
||||
resolve({ ok: false, detail: `spawn throw: ${err instanceof Error ? err.message : String(err)}` })
|
||||
return
|
||||
}
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
|
||||
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
|
||||
child.on('error', (err: NodeJS.ErrnoException) => {
|
||||
const code = err.code ? `${err.code} ` : ''
|
||||
resolve({ ok: false, detail: `spawn error: ${code}${err.message}` })
|
||||
})
|
||||
child.on('close', (code, signal) => {
|
||||
const out = stdout.trim()
|
||||
if (out && code === 0) {
|
||||
resolve({ ok: true })
|
||||
return
|
||||
}
|
||||
const parts: string[] = []
|
||||
parts.push(`exit=${code === null ? `signal:${signal}` : code}`)
|
||||
if (!out) parts.push('stdout=(empty)')
|
||||
const errLine = stderr.trim().split('\n')[0]
|
||||
if (errLine) parts.push(`stderr="${errLine.slice(0, 200)}"`)
|
||||
resolve({ ok: false, detail: parts.join(', ') })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -141,37 +246,70 @@ function downloadToFile(url: string, dest: string, redirects = 0): Promise<void>
|
||||
})
|
||||
}
|
||||
|
||||
/** yt-dlp 를 한 번 실행하고 종료코드·stdout·stderr 를 모은다. reject 하지 않는다. */
|
||||
function spawnYtDlp(bin: string, args: string[]): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
let child: ReturnType<typeof spawn>
|
||||
try {
|
||||
child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
} catch (err) {
|
||||
resolve({ code: null, stdout: '', stderr: err instanceof Error ? err.message : String(err) })
|
||||
return
|
||||
}
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let settled = false
|
||||
const done = (r: { code: number | null; stdout: string; stderr: string }) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(r)
|
||||
}
|
||||
child.stdout?.on('data', (chunk: Buffer) => (stdout += chunk.toString('utf8')))
|
||||
child.stderr?.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
|
||||
child.on('error', (err) => done({ code: null, stdout, stderr: stderr || (err as Error).message }))
|
||||
child.on('close', (code) => done({ code, stdout, stderr }))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* yt-dlp 를 실행하고 stdout 을 돌려준다. 첫 시도가 실패(0 이 아닌 종료코드/실행 불가)하면
|
||||
* yt-dlp 가 오래돼 유튜브 변경을 못 따라가는 상황일 수 있으므로, 최신 버전으로 강제
|
||||
* 재설치한 뒤 한 번 더 시도한다. 그래도 실패하면 makeError 로 만든 에러를 던진다.
|
||||
*/
|
||||
async function runYtDlp(args: string[], makeError: (code: string, detail: string) => Error): Promise<string> {
|
||||
let bin = await ensureYtDlp()
|
||||
let res = await spawnYtDlp(bin, args)
|
||||
if (res.code !== 0) {
|
||||
let refreshed = false
|
||||
try {
|
||||
bin = await ensureYtDlp(true)
|
||||
refreshed = true
|
||||
} catch { /* 재설치 실패 시 아래에서 원래 실패로 보고 */ }
|
||||
if (refreshed) {
|
||||
res = await spawnYtDlp(bin, args)
|
||||
}
|
||||
if (res.code !== 0) {
|
||||
throw makeError(String(res.code), res.stderr.trim() || res.stdout.trim())
|
||||
}
|
||||
}
|
||||
return res.stdout
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 영상 URL 의 메타데이터를 가져온다.
|
||||
* `--no-playlist` 로 플레이리스트 URL 이 들어와도 단일 영상 정보만 뽑음.
|
||||
*/
|
||||
export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | null> {
|
||||
const bin = await ensureYtDlp()
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(bin, [
|
||||
'--dump-json',
|
||||
'--no-warnings',
|
||||
'--no-playlist',
|
||||
'--skip-download',
|
||||
url
|
||||
], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.on('data', (chunk: Buffer) => (stdout += chunk.toString('utf8')))
|
||||
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
|
||||
child.on('error', (err) => reject(err))
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(t('youtube.ytdlpVideoFailed', { code: String(code), detail: stderr.trim() || stdout.trim() })))
|
||||
return
|
||||
}
|
||||
const stdout = await runYtDlp(
|
||||
['--dump-json', '--no-warnings', '--no-playlist', '--skip-download', url],
|
||||
(code, detail) => new Error(t('youtube.ytdlpVideoFailed', { code, detail }))
|
||||
)
|
||||
const line = stdout.trim().split('\n').find((l) => l.trim().length > 0)
|
||||
if (!line) { resolve(null); return }
|
||||
try {
|
||||
if (!line) return null
|
||||
const obj = JSON.parse(line) as Record<string, unknown>
|
||||
const id = typeof obj.id === 'string' ? obj.id : ''
|
||||
if (!id) { resolve(null); return }
|
||||
resolve({
|
||||
if (!id) return null
|
||||
return {
|
||||
id,
|
||||
title: typeof obj.title === 'string' ? obj.title : '',
|
||||
channel: typeof obj.channel === 'string'
|
||||
@@ -181,12 +319,7 @@ export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | nul
|
||||
url: typeof obj.webpage_url === 'string' && obj.webpage_url.length > 0
|
||||
? obj.webpage_url
|
||||
: `https://www.youtube.com/watch?v=${id}`
|
||||
})
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,24 +327,10 @@ export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | nul
|
||||
* `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON.
|
||||
*/
|
||||
export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry[]> {
|
||||
const bin = await ensureYtDlp()
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(bin, [
|
||||
'--flat-playlist',
|
||||
'--dump-json',
|
||||
'--no-warnings',
|
||||
url
|
||||
], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.on('data', (chunk: Buffer) => (stdout += chunk.toString('utf8')))
|
||||
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
|
||||
child.on('error', (err) => reject(err))
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(t('youtube.ytdlpPlaylistFailed', { code: String(code), detail: stderr.trim() || stdout.trim() })))
|
||||
return
|
||||
}
|
||||
const stdout = await runYtDlp(
|
||||
['--flat-playlist', '--dump-json', '--no-warnings', url],
|
||||
(code, detail) => new Error(t('youtube.ytdlpPlaylistFailed', { code, detail }))
|
||||
)
|
||||
const lines = stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
|
||||
const parsed: YtPlaylistEntry[] = []
|
||||
for (const line of lines) {
|
||||
@@ -234,7 +353,5 @@ export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry
|
||||
// 한 줄이 깨져도 나머지는 살림
|
||||
}
|
||||
}
|
||||
resolve(parsed)
|
||||
})
|
||||
})
|
||||
return parsed
|
||||
}
|
||||
|
||||
@@ -178,6 +178,14 @@ export async function deletePackKeys(keys: string[]): Promise<void> {
|
||||
} 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')
|
||||
}
|
||||
}
|
||||
@@ -198,6 +206,31 @@ export async function renamePack(oldKey: string, newKey: string, pack: PackDefin
|
||||
} 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')
|
||||
@@ -266,7 +299,8 @@ export function normalizePackList(input: unknown): PackList {
|
||||
title: sanitizeStr(entry.title),
|
||||
artist: sanitizeStr(entry.artist),
|
||||
durationSec: sanitizeNumber(entry.durationSec),
|
||||
aliases: sanitizeAliases(entry.aliases)
|
||||
aliases: sanitizeAliases(entry.aliases),
|
||||
description: sanitizeStr(entry.description)
|
||||
}))
|
||||
.filter((entry) => entry.url.length > 0),
|
||||
images: images
|
||||
@@ -296,18 +330,313 @@ export async function savePackList(packKey: string, list: PackList): Promise<voi
|
||||
|
||||
// ─── Terms (Markdown 약관) ─────────────────────────────────────────────
|
||||
// 사이트와 인스톨러가 약관을 보여주기 위해 사용하는 markdown 파일.
|
||||
// 화이트리스트로 5종만 허용한다.
|
||||
export type TermKind = 'map' | 'resourcepack' | 'mod' | 'installer' | 'installer-rp'
|
||||
export const TERM_KINDS: readonly TermKind[] = [
|
||||
'map', 'resourcepack', 'mod', 'installer', 'installer-rp'
|
||||
// - 음악퀴즈(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_KINDS as readonly string[]).includes(value)
|
||||
return typeof value === 'string' && TERM_KIND_RE.test(value)
|
||||
}
|
||||
|
||||
export async function loadTerm(kind: TermKind): Promise<string> {
|
||||
const filePath = path.join(manifestTermsDirPath, `${kind}.md`)
|
||||
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) {
|
||||
@@ -316,13 +645,114 @@ export async function loadTerm(kind: TermKind): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveTerm(kind: TermKind, markdown: string): Promise<void> {
|
||||
await fsp.mkdir(manifestTermsDirPath, { recursive: true })
|
||||
const filePath = path.join(manifestTermsDirPath, `${kind}.md`)
|
||||
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')
|
||||
|
||||
@@ -57,6 +57,8 @@ export interface MusicListEntry {
|
||||
durationSec: number
|
||||
/** 정답으로 인정할 별칭 목록. 빈 배열이면 정답은 title 뿐. */
|
||||
aliases: string[]
|
||||
/** 곡 설명 / 트리비아 메모. 정답 채점이나 데이터팩 생성에는 사용되지 않는다. */
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface ImageListEntry {
|
||||
|
||||
@@ -124,6 +124,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description modal (music) -->
|
||||
<div class="modalOverlay" id="descModal" hidden>
|
||||
<div class="modalCard">
|
||||
<header class="aliasModalHeader">
|
||||
<button type="button" class="ghostLink" id="desc-back"><%= t('listEditor.descBack') %></button>
|
||||
<h3 id="desc-modal-title"></h3>
|
||||
<span></span>
|
||||
</header>
|
||||
<div class="modalBody">
|
||||
<p class="muted" style="margin:0;font-size:12px;"><%= t('listEditor.descHint') %></p>
|
||||
<textarea id="desc-textarea" class="textInput descTextarea" placeholder="<%= t('listEditor.descPlaceholder') %>" rows="6"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit modal (image) -->
|
||||
<div class="modalOverlay" id="editImageModal" hidden>
|
||||
<div class="modalCard">
|
||||
|
||||
154
views/op/terms-pack.ejs
Normal file
154
views/op/terms-pack.ejs
Normal file
@@ -0,0 +1,154 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><%= t('terms.packBrowserTitle', { name: pack.name }) %></title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<style>
|
||||
/* 약관 목록 — 카드 한 줄(가로 풀폭) 씩 세로로 쌓이도록. */
|
||||
.termsList { display: flex; flex-direction: column; gap: 10px; margin-top: 16px; }
|
||||
.termsRow {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
.termsRow .termsRowMain { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||
.termsRow .termsRowLabel { display: flex; align-items: center; gap: 8px; }
|
||||
.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; }
|
||||
.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;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.termsSideBySide { grid-template-columns: 1fr; }
|
||||
}
|
||||
.termsSection {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.termsSection h2 { margin: 0 0 12px; font-size: 15px; }
|
||||
.termsAddForm { display: grid; grid-template-columns: 1fr 2fr; gap: 10px; align-items: end; }
|
||||
.termsAddForm .field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.termsAddForm label { font-size: 12px; color: var(--text-muted); }
|
||||
.termsAddForm input, .termsImportForm select {
|
||||
background: var(--bg-alt); color: var(--text);
|
||||
border: 1px solid var(--border, #30363d); border-radius: 6px;
|
||||
padding: 8px 10px; font-size: 13px;
|
||||
}
|
||||
.termsAddForm .hint { color: var(--text-muted); font-size: 11px; }
|
||||
.termsAddForm .formActions { grid-column: 1 / -1; display: flex; justify-content: flex-end; }
|
||||
.termsImportForm { display: grid; grid-template-columns: 1fr; gap: 10px; }
|
||||
.termsImportForm .field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.termsImportForm label { font-size: 12px; color: var(--text-muted); }
|
||||
.termsImportForm .formActions { display: flex; justify-content: flex-end; }
|
||||
.termsImportForm .hint { color: var(--text-muted); font-size: 11px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
<%- include('../partials/navbar', { userId }) %>
|
||||
|
||||
<main class="pageWrap">
|
||||
<section class="dashboardHeader">
|
||||
<div>
|
||||
<a class="ghostLink" href="/op/agreement"><%= t('common.back') %></a>
|
||||
<h1 style="margin-top:20px;"><%= t('terms.packTitle', { name: pack.name }) %></h1>
|
||||
<p class="muted"><%= packKey %>.json</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="muted"><%= t('terms.hint') %></p>
|
||||
|
||||
<section class="termsList">
|
||||
<% items.forEach(function (item) { %>
|
||||
<article class="termsRow">
|
||||
<a class="termsRowMain" href="/op/agreement/<%= packKey %>/<%= item.kind %>" style="text-decoration:none; color:inherit;">
|
||||
<div class="termsRowLabel">
|
||||
<h2><%= item.label %></h2>
|
||||
<span class="visibilityBadges">
|
||||
<span class="visibilityBadge <%= item.showInInstaller ? '' : 'off' %>"><%= t('terms.visibilityInstallerShort') %></span>
|
||||
<span class="visibilityBadge <%= item.showInInstallerRp ? '' : 'off' %>"><%= t('terms.visibilityInstallerRpShort') %></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="termsRowSub"><%= item.kind %>.md</div>
|
||||
</a>
|
||||
<div class="termsRowActions">
|
||||
<a class="secondaryButton" href="/op/agreement/<%= packKey %>/<%= item.kind %>"><%= t('terms.edit') %></a>
|
||||
<form method="post" action="/op/agreement/<%= packKey %>/<%= item.kind %>/delete"
|
||||
onsubmit="return confirm('<%= t('terms.deleteConfirm', { label: item.label }).replace(/'/g, "\\'") %>');"
|
||||
style="margin:0;">
|
||||
<button type="submit" class="dangerButton"><%= t('terms.deleteButton') %></button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
<% }) %>
|
||||
</section>
|
||||
|
||||
<section class="termsSideBySide">
|
||||
<div class="termsSection">
|
||||
<h2><%= t('terms.addHeading') %></h2>
|
||||
<form method="post" action="/op/agreement/<%= packKey %>/create" class="termsAddForm">
|
||||
<div class="field">
|
||||
<label for="newKind"><%= t('terms.kindLabel') %></label>
|
||||
<input id="newKind" name="kind" type="text" required
|
||||
pattern="[a-z0-9][a-z0-9-]{0,31}"
|
||||
placeholder="<%= t('terms.kindPlaceholder') %>" />
|
||||
<span class="hint"><%= t('terms.kindHint') %></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="newLabel"><%= t('terms.labelLabel') %></label>
|
||||
<input id="newLabel" name="label" type="text" required maxlength="50"
|
||||
placeholder="<%= t('terms.labelPlaceholder') %>" />
|
||||
</div>
|
||||
<div class="formActions">
|
||||
<button type="submit" class="primaryButton"><%= t('terms.addButton') %></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="termsSection">
|
||||
<h2><%= t('terms.importHeading') %></h2>
|
||||
<% if (sourceCandidates.length === 0) { %>
|
||||
<p class="muted"><%= t('terms.importEmpty') %></p>
|
||||
<% } else { %>
|
||||
<form method="post" action="/op/agreement/<%= packKey %>/import" class="termsImportForm"
|
||||
onsubmit="return confirm('<%= t('terms.importConfirm').replace(/'/g, "\\'") %>');">
|
||||
<div class="field">
|
||||
<label for="importSource"><%= t('terms.importSourceLabel') %></label>
|
||||
<select id="importSource" name="source" required>
|
||||
<option value=""><%= t('terms.importSourcePlaceholder') %></option>
|
||||
<% sourceCandidates.forEach(function (cand) { %>
|
||||
<option value="<%= cand.key %>"><%= cand.definition ? cand.definition.name : cand.key %> (<%= cand.key %>)</option>
|
||||
<% }) %>
|
||||
</select>
|
||||
<span class="hint"><%= t('terms.importHint') %></span>
|
||||
</div>
|
||||
<div class="formActions">
|
||||
<button type="submit" class="primaryButton"><%= t('terms.importButton') %></button>
|
||||
</div>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -17,14 +17,24 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="muted"><%= t('terms.hint') %></p>
|
||||
<p class="muted"><%= t('terms.pickPackHint') %></p>
|
||||
|
||||
<section class="cardRow horizontalScroll">
|
||||
<% if (items.length === 0) { %>
|
||||
<p class="muted"><%= t('site.empty') %></p>
|
||||
<% } %>
|
||||
<% items.forEach(function (item) { %>
|
||||
<article class="packCard">
|
||||
<a class="cardLink" href="/op/agreement/<%= item.kind %>">
|
||||
<h2><%= item.label %></h2>
|
||||
<p class="muted"><%= item.kind %>.md</p>
|
||||
<a class="cardLink" href="/op/agreement/<%= item.key %>">
|
||||
<h2><%= item.definition ? item.definition.name : item.key %></h2>
|
||||
<p class="muted"><%= item.key %>.json</p>
|
||||
<% if (item.definition) { %>
|
||||
<ul class="metaList">
|
||||
<li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
|
||||
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
|
||||
<li><%= t('site.modsFolder') %> <%= item.definition.modsFolder || t('site.noneFallback') %></li>
|
||||
</ul>
|
||||
<% } %>
|
||||
</a>
|
||||
</article>
|
||||
<% }) %>
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
<main class="pageWrap">
|
||||
<section class="dashboardHeader">
|
||||
<div>
|
||||
<a class="ghostLink" href="/op/agreement"><%= t('common.back') %></a>
|
||||
<a class="ghostLink" href="/op/agreement/<%= packKey %>"><%= t('common.back') %></a>
|
||||
<h1 style="margin-top:20px;"><%= t('terms.editorTitle', { label: label }) %></h1>
|
||||
<p class="muted"><%= kind %>.md</p>
|
||||
<p class="muted"><%= pack.name %> · <%= kind %>.md</p>
|
||||
</div>
|
||||
<div class="dirtyMark" id="dirty-mark" hidden>*</div>
|
||||
</section>
|
||||
@@ -29,6 +29,19 @@
|
||||
<span class="statusText" id="status"></span>
|
||||
</div>
|
||||
|
||||
<!-- 표시 대상 토글: 어느 인스톨러에서 이 약관을 보여줄지 (중복 선택 가능). -->
|
||||
<fieldset class="termsVisibility" style="margin-top:16px; padding:10px 14px; border:1px solid var(--border, #30363d); border-radius:8px;">
|
||||
<legend style="padding:0 6px; font-size:12px; color:var(--text-muted);"><%= t('terms.visibilityHeading') %></legend>
|
||||
<label style="display:inline-flex; align-items:center; gap:6px; margin-right:18px;">
|
||||
<input type="checkbox" id="visInstaller" <%= showInInstaller ? 'checked' : '' %> />
|
||||
<span><%= t('terms.visibilityInstaller') %></span>
|
||||
</label>
|
||||
<label style="display:inline-flex; align-items:center; gap:6px;">
|
||||
<input type="checkbox" id="visInstallerRp" <%= showInInstallerRp ? 'checked' : '' %> />
|
||||
<span><%= t('terms.visibilityInstallerRp') %></span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<p class="muted" style="font-size:12px;"><%= t('terms.slashHint') %></p>
|
||||
|
||||
<div id="editorWrap" class="termsEditorWrap">
|
||||
@@ -39,6 +52,7 @@
|
||||
</main>
|
||||
|
||||
<script>
|
||||
var PACK_KEY = <%- JSON.stringify(packKey) %>;
|
||||
var TERM_KIND = <%- JSON.stringify(kind) %>;
|
||||
var INITIAL = <%- JSON.stringify(content) %>;
|
||||
var I18N = <%- JSON.stringify(localeDict.terms) %>;
|
||||
|
||||
Reference in New Issue
Block a user