feat: 템플릿 태그와 병합 가져오기 및 에디터 제거 추가

This commit is contained in:
2026-04-06 11:48:22 +09:00
parent 2d5506e35a
commit fe79c91e82
9 changed files with 482 additions and 60 deletions

View File

@@ -32,6 +32,21 @@ function serializeJson(value) {
return JSON.stringify(value || [])
}
function normalizeTags(tags) {
const values = Array.isArray(tags)
? tags
: typeof tags === 'string'
? tags.split(',')
: []
return Array.from(
new Set(
values
.map((tag) => String(tag || '').trim().replace(/^#/, '').slice(0, 40))
.filter(Boolean)
)
).slice(0, 30)
}
function normalizeTopicSlug(value) {
return String(value || '').trim().toLowerCase()
}
@@ -100,6 +115,7 @@ function mapTopicRow(row) {
isPublic: row.is_public == null ? true : !!row.is_public,
displayRank: row.display_rank == null ? null : Number(row.display_rank),
createdAt: Number(row.created_at),
tags: normalizeTags(parseJson(row.tags_json, [])),
}
}
@@ -112,6 +128,7 @@ function mapTopicItemRow(row) {
label: row.label,
displayOrder: row.display_order == null ? null : Number(row.display_order),
createdAt: Number(row.created_at),
tags: normalizeTags(parseJson(row.tags_json, [])),
}
}
@@ -127,6 +144,7 @@ function mapCustomItemRow(row) {
replacedBySrc: row.replaced_by_src || '',
replacedByLabel: row.replaced_by_label || '',
replacedAt: Number(row.replaced_at || 0),
tags: normalizeTags(parseJson(row.tags_json, [])),
}
}
@@ -349,6 +367,7 @@ async function ensureSchema() {
slug VARCHAR(120) NOT NULL,
name VARCHAR(120) NOT NULL,
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
tags_json LONGTEXT NULL,
is_public TINYINT(1) NOT NULL DEFAULT 1,
display_rank INT NULL DEFAULT NULL,
created_at BIGINT NOT NULL,
@@ -362,6 +381,7 @@ async function ensureSchema() {
topic_id VARCHAR(120) NOT NULL,
src VARCHAR(255) NOT NULL,
label VARCHAR(120) NOT NULL,
tags_json LONGTEXT NULL,
display_order INT NULL DEFAULT NULL,
created_at BIGINT NOT NULL,
INDEX idx_topic_items_topic_id (topic_id),
@@ -375,6 +395,7 @@ async function ensureSchema() {
owner_id VARCHAR(64) NOT NULL,
src VARCHAR(255) NOT NULL,
label VARCHAR(120) NOT NULL,
tags_json LONGTEXT NULL,
replaced_by_item_id VARCHAR(64) NOT NULL DEFAULT '',
replaced_by_src VARCHAR(255) NOT NULL DEFAULT '',
replaced_by_label VARCHAR(120) NOT NULL DEFAULT '',
@@ -385,6 +406,9 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`ALTER TABLE topics ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER thumbnail_src`)
await query(`ALTER TABLE topic_items ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER label`)
await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER label`)
await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_by_item_id VARCHAR(64) NOT NULL DEFAULT '' AFTER label`)
await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_by_src VARCHAR(255) NOT NULL DEFAULT '' AFTER replaced_by_item_id`)
await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_by_label VARCHAR(120) NOT NULL DEFAULT '' AFTER replaced_by_src`)
@@ -517,11 +541,11 @@ async function ensureSchema() {
await query(
`
INSERT INTO topics (id, slug, name, thumbnail_src, is_public, created_at)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE slug = VALUES(slug), name = VALUES(name), is_public = VALUES(is_public)
INSERT INTO topics (id, slug, name, thumbnail_src, tags_json, is_public, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE slug = VALUES(slug), name = VALUES(name), tags_json = VALUES(tags_json), is_public = VALUES(is_public)
`,
[FREEFORM_TOPIC_ID, FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', 1, now()]
[FREEFORM_TOPIC_ID, FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', '[]', 1, now()]
)
})()
@@ -834,7 +858,7 @@ async function listTopics(currentUserId = '', options = {}) {
const includePrivate = !!options.includePrivate
const rows = await query(
`
SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at
SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at
FROM topics
WHERE id <> ?
${includePrivate ? '' : 'AND is_public = 1'}
@@ -859,7 +883,7 @@ async function listTopics(currentUserId = '', options = {}) {
async function findTopicById(id) {
const rows = await query(
'SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1',
'SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1',
[id]
)
return mapTopicRow(rows[0])
@@ -869,7 +893,7 @@ async function findTopicBySlug(slug) {
const normalizedSlug = normalizeTopicSlug(slug)
if (!normalizedSlug) return null
const rows = await query(
'SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE slug = ? LIMIT 1',
'SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at FROM topics WHERE slug = ? LIMIT 1',
[normalizedSlug]
)
return mapTopicRow(rows[0])
@@ -882,7 +906,7 @@ async function findTopicByIdentifier(topicRef) {
const rows = await query(
`
SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at
SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at
FROM topics
WHERE id = ? OR slug = ?
ORDER BY
@@ -897,7 +921,7 @@ async function findTopicByIdentifier(topicRef) {
async function listTopicItems(topicId) {
const rows = await query(
`
SELECT id, topic_id, src, label, display_order, created_at
SELECT id, topic_id, src, label, tags_json, display_order, created_at
FROM topic_items
WHERE topic_id = ?
ORDER BY
@@ -912,7 +936,7 @@ async function listTopicItems(topicId) {
}
async function findTopicItemById(itemId) {
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
return mapTopicItemRow(rows[0])
}
@@ -923,14 +947,15 @@ async function getTopicDetail(topicRef) {
return { topic, template: topic, items }
}
async function createTopic({ slug, name, isPublic = true }) {
async function createTopic({ slug, name, tags = [], isPublic = true }) {
const topicId = nanoid()
const topicSlug = assertTopicSlug(slug)
await query('INSERT INTO topics (id, slug, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [
await query('INSERT INTO topics (id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [
topicId,
topicSlug,
name,
'',
serializeJson(normalizeTags(tags)),
isPublic ? 1 : 0,
null,
now(),
@@ -938,11 +963,12 @@ async function createTopic({ slug, name, isPublic = true }) {
return findTopicById(topicId)
}
async function updateTopicMeta(topicId, { slug, name, isPublic }) {
async function updateTopicMeta(topicId, { slug, name, tags = [], isPublic }) {
const topicSlug = assertTopicSlug(slug)
await query('UPDATE topics SET slug = ?, name = ?, is_public = ? WHERE id = ?', [
await query('UPDATE topics SET slug = ?, name = ?, tags_json = ?, is_public = ? WHERE id = ?', [
topicSlug,
name,
serializeJson(normalizeTags(tags)),
isPublic ? 1 : 0,
topicId,
])
@@ -1552,26 +1578,37 @@ async function clearImageOptimizationJobs({ month } = {}) {
const result = await query('DELETE FROM image_optimization_jobs')
return Number(result.affectedRows || 0)
}
async function createTopicItem({ id, topicId, src, label }) {
async function createTopicItem({ id, topicId, src, label, tags = [] }) {
const createdAt = now()
const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM topic_items WHERE topic_id = ?', [topicId])
const nextDisplayOrder =
minOrderRows[0]?.min_display_order == null ? 0 : Number(minOrderRows[0].min_display_order) - 1
await query('INSERT INTO topic_items (id, topic_id, src, label, display_order, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
await query('INSERT INTO topic_items (id, topic_id, src, label, tags_json, display_order, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [
id,
topicId,
src,
label,
serializeJson(normalizeTags(tags)),
nextDisplayOrder,
createdAt,
])
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [id])
const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [id])
return mapTopicItemRow(rows[0])
}
async function updateTopicItemLabel(itemId, label) {
await query('UPDATE topic_items SET label = ? WHERE id = ?', [label, itemId])
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
return mapTopicItemRow(rows[0])
}
async function updateTopicItemMeta(itemId, { label, tags = [] }) {
await query('UPDATE topic_items SET label = ?, tags_json = ? WHERE id = ?', [
label,
serializeJson(normalizeTags(tags)),
itemId,
])
const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
return mapTopicItemRow(rows[0])
}
@@ -1593,7 +1630,29 @@ async function updateTopicItemDisplayOrder(topicId, itemIds) {
async function updateCustomItemLabel(itemId, label) {
await query('UPDATE custom_items SET label = ? WHERE id = ?', [label, itemId])
const rows = await query(`
SELECT c.id, c.owner_id, c.src, c.label, c.replaced_by_item_id, c.replaced_by_src, c.replaced_by_label, c.replaced_at, c.created_at, u.nickname, u.email
SELECT c.id, c.owner_id, c.src, c.label, c.tags_json, c.replaced_by_item_id, c.replaced_by_src, c.replaced_by_label, c.replaced_at, c.created_at, u.nickname, u.email
FROM custom_items c
INNER JOIN users u ON u.id = c.owner_id
WHERE c.id = ?
LIMIT 1
`, [itemId])
const row = rows[0]
if (!row) return null
return {
...mapCustomItemRow(row),
ownerName: row.nickname || row.email,
ownerEmail: row.email,
}
}
async function updateCustomItemMeta(itemId, { label, tags = [] }) {
await query('UPDATE custom_items SET label = ?, tags_json = ? WHERE id = ?', [
label,
serializeJson(normalizeTags(tags)),
itemId,
])
const rows = await query(`
SELECT c.id, c.owner_id, c.src, c.label, c.tags_json, c.replaced_by_item_id, c.replaced_by_src, c.replaced_by_label, c.replaced_at, c.created_at, u.nickname, u.email
FROM custom_items c
INNER JOIN users u ON u.id = c.owner_id
WHERE c.id = ?
@@ -1680,16 +1739,17 @@ async function updateTopicDisplayOrder(topicIds) {
return listTopics()
}
async function createCustomItem({ id, ownerId, src, label }) {
async function createCustomItem({ id, ownerId, src, label, tags = [] }) {
const createdAt = now()
await query('INSERT INTO custom_items (id, owner_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
await query('INSERT INTO custom_items (id, owner_id, src, label, tags_json, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
id,
ownerId,
src,
label,
serializeJson(normalizeTags(tags)),
createdAt,
])
return { id, ownerId, src, label, origin: 'custom', createdAt }
return { id, ownerId, src, label, tags: normalizeTags(tags), origin: 'custom', createdAt }
}
async function syncOwnedCustomItemLabels({ ownerId, items }) {
@@ -1713,7 +1773,7 @@ async function syncOwnedCustomItemLabels({ ownerId, items }) {
async function findCustomItemById(id) {
const rows = await query(
`
SELECT id, owner_id, src, label, replaced_by_item_id, replaced_by_src, replaced_by_label, replaced_at, created_at
SELECT id, owner_id, src, label, tags_json, replaced_by_item_id, replaced_by_src, replaced_by_label, replaced_at, created_at
FROM custom_items
WHERE id = ?
LIMIT 1
@@ -1787,6 +1847,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
c.owner_id,
c.src,
c.label,
c.tags_json,
c.replaced_by_item_id,
c.replaced_by_src,
c.replaced_by_label,
@@ -1796,10 +1857,10 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
u.email
FROM custom_items c
INNER JOIN users u ON u.id = c.owner_id
${hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''}
${hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR c.tags_json LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''}
ORDER BY c.created_at DESC
`,
hasQuery ? [search, search, search, search] : []
hasQuery ? [search, search, search, search, search] : []
),
query(
`
@@ -1808,14 +1869,15 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
gi.topic_id,
gi.src,
gi.label,
gi.tags_json,
gi.created_at,
tp.name AS topic_name
FROM topic_items gi
INNER JOIN topics tp ON tp.id = gi.topic_id
${hasQuery ? 'WHERE gi.label LIKE ? OR gi.src LIKE ? OR gi.topic_id LIKE ? OR tp.name LIKE ?' : ''}
${hasQuery ? 'WHERE gi.label LIKE ? OR gi.src LIKE ? OR gi.tags_json LIKE ? OR gi.topic_id LIKE ? OR tp.name LIKE ?' : ''}
ORDER BY gi.created_at DESC
`,
hasQuery ? [search, search, search, search] : []
hasQuery ? [search, search, search, search, search] : []
),
query(
`
@@ -1896,6 +1958,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
ownerId: '',
src: row.src,
label: row.label_override || (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음',
tags: [],
createdAt: Number(row.created_at || 0),
ownerName: '관리자 미사용 이미지',
ownerEmail: '',
@@ -1915,6 +1978,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
ownerId: '',
src: row.src,
label: row.label,
tags: normalizeTags(parseJson(row.tags_json, [])),
createdAt: Number(row.created_at),
ownerName: row.topic_name || row.topic_id,
ownerEmail: '',
@@ -1976,6 +2040,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
usageCount: entry.usageCount || 0,
linkedTemplates: entry.linkedTemplates || [],
isAssetLibraryItem: !!entry.isAssetLibraryItem,
tags: entry.tags || [],
replacedByItemId: entry.replacedByItemId || '',
replacedBySrc: entry.replacedBySrc || '',
replacedByLabel: entry.replacedByLabel || '',
@@ -2026,8 +2091,6 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
async function findUnusedCustomItems({ queryText = '' } = {}) {
const hasQuery = !!(queryText || '').trim()
const search = `%${(queryText || '').trim()}%`
const whereClause = hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''
const params = hasQuery ? [search, search, search, search] : []
const [rows, topicItemRows, usageMeta] = await Promise.all([
query(
@@ -2037,6 +2100,7 @@ async function findUnusedCustomItems({ queryText = '' } = {}) {
c.owner_id,
c.src,
c.label,
c.tags_json,
c.replaced_by_item_id,
c.replaced_by_src,
c.replaced_by_label,
@@ -2046,10 +2110,10 @@ async function findUnusedCustomItems({ queryText = '' } = {}) {
u.email
FROM custom_items c
INNER JOIN users u ON u.id = c.owner_id
${whereClause}
${hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR c.tags_json LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''}
ORDER BY c.created_at DESC
`,
params
hasQuery ? [search, search, search, search, search] : []
),
query(
`
@@ -3004,7 +3068,7 @@ async function findCustomItemsByIds(ids) {
const placeholders = ids.map(() => '?').join(', ')
const rows = await query(
`
SELECT id, owner_id, src, label, replaced_by_item_id, replaced_by_src, replaced_by_label, replaced_at, created_at
SELECT id, owner_id, src, label, tags_json, replaced_by_item_id, replaced_by_src, replaced_by_label, replaced_at, created_at
FROM custom_items
WHERE id IN (${placeholders})
`,
@@ -3162,12 +3226,14 @@ module.exports = {
cleanupMissingUploadReferences,
createTopicItem,
updateTopicItemLabel,
updateTopicItemMeta,
updateTopicItemDisplayOrder,
countTierListsUsingTopicItem,
deleteTopicItem,
deleteTopic,
updateTopicDisplayOrder,
updateCustomItemLabel,
updateCustomItemMeta,
clearCustomItemReplacement,
markCustomItemReplaced,
updateImageAssetLabel,