feat(snippets): 이름 규칙(영문 대문자·숫자·_, 3~50자)·실시간 중복/저장될 이름 표시, 설명 필수, 검색에 본문 ★머리줄·주석 포함(한글 검색), 메타 일괄 반영 명령 apply_snippet_meta
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
aa7481ffe9
commit
7a1498a791
@@ -22,3 +22,60 @@ it("팔레트 재소환으로 편집기를 닫으면 삭제 확인도 사라짐"
|
|||||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument())
|
await waitFor(() => expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument())
|
||||||
expect(props.onDelete).not.toHaveBeenCalled()
|
expect(props.onDelete).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("생성: 타이핑하면 저장될 이름·중복을 바로 보여주고, 정규화된 이름으로 저장", async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onSave = vi.fn()
|
||||||
|
render(
|
||||||
|
<EditDialog
|
||||||
|
open
|
||||||
|
mode="create"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSave={onSave}
|
||||||
|
isSaving={false}
|
||||||
|
existingNames={["ALV_FILTER"]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const name = screen.getByLabelText(/이름/)
|
||||||
|
await user.type(name, "alv-filter")
|
||||||
|
expect(screen.getByText("이미 있는 이름임: ALV_FILTER")).toBeInTheDocument()
|
||||||
|
await user.clear(name)
|
||||||
|
await user.type(name, "alv filter rows")
|
||||||
|
expect(screen.getByText("저장될 이름: ALV_FILTER_ROWS")).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText(/내용/), "WRITE 1.")
|
||||||
|
await user.click(screen.getByRole("button", { name: "생성" }))
|
||||||
|
expect(await screen.findByText(/설명을 입력해야 함/)).toBeInTheDocument() // 설명 필수
|
||||||
|
expect(onSave).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText(/설명/), "ALV 행 필터")
|
||||||
|
await user.click(screen.getByRole("button", { name: "생성" }))
|
||||||
|
await waitFor(() => expect(onSave).toHaveBeenCalled())
|
||||||
|
expect(onSave.mock.calls[0][0]).toMatchObject({ name: "ALV_FILTER_ROWS", desc: "ALV 행 필터" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("편집: 옛 규칙 이름(IF_SUBRC-ABAP)도 이름 검사 없이 저장 가능", async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onSave = vi.fn()
|
||||||
|
render(
|
||||||
|
<EditDialog
|
||||||
|
open
|
||||||
|
mode="edit"
|
||||||
|
snippet={{
|
||||||
|
name: "IF_SUBRC-ABAP",
|
||||||
|
desc: "sy-subrc 분기",
|
||||||
|
body: "IF sy-subrc = 0.",
|
||||||
|
category: "코드",
|
||||||
|
usageCount: 0,
|
||||||
|
lastUsed: 0,
|
||||||
|
}}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSave={onSave}
|
||||||
|
isSaving={false}
|
||||||
|
existingNames={["IF_SUBRC-ABAP"]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
await user.click(screen.getByRole("button", { name: "저장" }))
|
||||||
|
await waitFor(() => expect(onSave).toHaveBeenCalled())
|
||||||
|
expect(onSave.mock.calls[0][0].name).toBe("IF_SUBRC-ABAP")
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// 생성/편집 공용 다이얼로그(T030). react-hook-form+zod를 사용하되
|
// 생성/편집 공용 다이얼로그(T030). react-hook-form+zod를 사용하되
|
||||||
// shadcn Dialog(중앙 모달)로 — 팔레트 위에 뜨는 Raycast 결 유지.
|
// shadcn Dialog(중앙 모달)로 — 팔레트 위에 뜨는 Raycast 결 유지.
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
@@ -27,17 +27,27 @@ import { Input } from "@/shared/ui/input"
|
|||||||
import { Textarea } from "@/shared/ui/textarea"
|
import { Textarea } from "@/shared/ui/textarea"
|
||||||
import { Label } from "@/shared/ui/label"
|
import { Label } from "@/shared/ui/label"
|
||||||
import type { Snippet, SnippetInput } from "../types"
|
import type { Snippet, SnippetInput } from "../types"
|
||||||
|
import { nameProblem, normalizeName } from "../core/name"
|
||||||
|
|
||||||
const DEFAULT_CATEGORY = "코드"
|
const DEFAULT_CATEGORY = "코드"
|
||||||
|
|
||||||
const schema = z.object({
|
// 이름 규칙·중복은 생성 때만(편집은 이름 읽기 전용 — 옛 이름도 그대로 편집 가능). 설명은 필수(한글 검색이 여기로 걸림).
|
||||||
name: z.string().min(1, "이름을 입력해야 함"),
|
function makeSchema(mode: "create" | "edit", existing: string[]) {
|
||||||
desc: z.string(),
|
return z
|
||||||
body: z.string().min(1, "내용을 입력해야 함"),
|
.object({
|
||||||
category: z.string(),
|
name: z.string(),
|
||||||
})
|
desc: z.string().trim().min(1, "설명을 입력해야 함 — 한글로 무엇을 하는 코드인지"),
|
||||||
|
body: z.string().min(1, "내용을 입력해야 함"),
|
||||||
|
category: z.string(),
|
||||||
|
})
|
||||||
|
.superRefine((v, ctx) => {
|
||||||
|
if (mode !== "create") return
|
||||||
|
const problem = nameProblem(v.name, existing)
|
||||||
|
if (problem) ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["name"], message: problem })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type FormValues = z.infer<typeof schema>
|
type FormValues = z.infer<ReturnType<typeof makeSchema>>
|
||||||
|
|
||||||
interface EditDialogProps {
|
interface EditDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -48,6 +58,8 @@ interface EditDialogProps {
|
|||||||
onDelete?: (name: string) => void
|
onDelete?: (name: string) => void
|
||||||
isSaving: boolean
|
isSaving: boolean
|
||||||
isDeleting?: boolean
|
isDeleting?: boolean
|
||||||
|
/** 이미 있는 이름들 — 생성 때 타이핑하는 즉시 중복 표시 */
|
||||||
|
existingNames?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditDialog({
|
export function EditDialog({
|
||||||
@@ -59,12 +71,15 @@ export function EditDialog({
|
|||||||
onDelete,
|
onDelete,
|
||||||
isSaving,
|
isSaving,
|
||||||
isDeleting,
|
isDeleting,
|
||||||
|
existingNames = [],
|
||||||
}: EditDialogProps) {
|
}: EditDialogProps) {
|
||||||
|
const schema = useMemo(() => makeSchema(mode, existingNames), [mode, existingNames])
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
|
watch,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<FormValues>({
|
} = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -95,9 +110,14 @@ export function EditDialog({
|
|||||||
})
|
})
|
||||||
}, [open, mode, snippet, reset, setValue])
|
}, [open, mode, snippet, reset, setValue])
|
||||||
|
|
||||||
|
// 생성 중 실시간 안내 — 저장될 이름 + 규칙/중복 문제(제출 전에 보여줌)
|
||||||
|
const typedName = watch("name")
|
||||||
|
const liveName = mode === "create" ? normalizeName(typedName ?? "") : ""
|
||||||
|
const liveProblem = mode === "create" && typedName ? nameProblem(typedName, existingNames) : null
|
||||||
|
|
||||||
function onSubmit(data: FormValues) {
|
function onSubmit(data: FormValues) {
|
||||||
onSave({
|
onSave({
|
||||||
name: data.name,
|
name: mode === "create" ? normalizeName(data.name) : data.name,
|
||||||
desc: data.desc,
|
desc: data.desc,
|
||||||
body: data.body,
|
body: data.body,
|
||||||
category: data.category || DEFAULT_CATEGORY,
|
category: data.category || DEFAULT_CATEGORY,
|
||||||
@@ -122,17 +142,33 @@ export function EditDialog({
|
|||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="snippet-name"
|
id="snippet-name"
|
||||||
placeholder="예: GIT_COMMIT"
|
placeholder="예: ALV_FILTER_ROWS (영역_대상_동작, 영문)"
|
||||||
readOnly={mode === "edit"}
|
readOnly={mode === "edit"}
|
||||||
className={mode === "edit" ? "bg-muted text-muted-foreground" : undefined}
|
className={mode === "edit" ? "bg-muted text-muted-foreground" : undefined}
|
||||||
{...register("name")}
|
{...register("name")}
|
||||||
/>
|
/>
|
||||||
{errors.name && <p className="text-destructive text-xs">{errors.name.message}</p>}
|
{mode === "create" && typedName && (
|
||||||
|
<p
|
||||||
|
className={`text-xs ${liveProblem ? "text-destructive" : "text-muted-foreground"}`}
|
||||||
|
>
|
||||||
|
{liveProblem ?? `저장될 이름: ${liveName}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{errors.name && !typedName && (
|
||||||
|
<p className="text-destructive text-xs">{errors.name.message}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="snippet-desc">설명</Label>
|
<Label htmlFor="snippet-desc">
|
||||||
<Input id="snippet-desc" placeholder="짧은 설명" {...register("desc")} />
|
설명 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="snippet-desc"
|
||||||
|
placeholder="예: ALV 에서 조건에 맞는 행만 필터링 (한글 검색이 여기로 걸림)"
|
||||||
|
{...register("desc")}
|
||||||
|
/>
|
||||||
|
{errors.desc && <p className="text-destructive text-xs">{errors.desc.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import { normalizeName, nameProblem } from "./name"
|
||||||
|
|
||||||
|
describe("normalizeName", () => {
|
||||||
|
it("소문자·공백·하이픈·점을 대문자 _ 로, 기호·한글은 버리고 _ 정리", () => {
|
||||||
|
expect(normalizeName(" alv filter-rows.v2 ")).toBe("ALV_FILTER_ROWS_V2")
|
||||||
|
expect(normalizeName("IF_SUBRC-ABAP")).toBe("IF_SUBRC_ABAP")
|
||||||
|
expect(normalizeName("__a__b__")).toBe("A_B")
|
||||||
|
expect(normalizeName("필터 ALV (신규)")).toBe("ALV")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("nameProblem", () => {
|
||||||
|
it("통과", () => {
|
||||||
|
expect(nameProblem("alv filter")).toBeNull()
|
||||||
|
expect(nameProblem("ITAB_COUNT_ROWS_LINES")).toBeNull()
|
||||||
|
})
|
||||||
|
it("한글만 쓰면 설명으로 안내, 빈 값·숫자 시작·길이", () => {
|
||||||
|
expect(nameProblem("필터")).toMatch(/설명/)
|
||||||
|
expect(nameProblem(" ")).toMatch(/입력/)
|
||||||
|
expect(nameProblem("1ALV")).toMatch(/영문자로 시작/)
|
||||||
|
expect(nameProblem("AB")).toMatch(/3자 이상/)
|
||||||
|
expect(nameProblem("A".repeat(51))).toMatch(/50자 이하/)
|
||||||
|
})
|
||||||
|
it("정규화 기준으로 중복 검사 — 하이픈/소문자로 바꿔 넣어도 막힘", () => {
|
||||||
|
expect(nameProblem("alv-filter", ["ALV_FILTER"])).toMatch(/이미 있는 이름임: ALV_FILTER/)
|
||||||
|
expect(nameProblem("alv filter2", ["ALV_FILTER"])).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// 스니펫 이름 규칙 — 서버 apps/snippets/views.py 의 normalize_name/NAME_RE 와 같은 규칙(바꾸면 양쪽 같이).
|
||||||
|
// 모양: 영문 대문자·숫자·_ 만, 문자로 시작, 3~50자, _ 연속·앞뒤 금지. 예: ALV_FILTER_ROWS
|
||||||
|
// 권장 형식은 "영역_대상_동작"(ALV·ITAB·OPEN_SQL·SELECTION_SCREEN·STRING·DYNPRO·DDIC·DATE·BDC·FILE …) — 안내만, 강제 안 함.
|
||||||
|
// 한글은 이름 말고 설명에.
|
||||||
|
|
||||||
|
export const NAME_MIN = 3
|
||||||
|
export const NAME_MAX = 50
|
||||||
|
export const NAME_RE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/
|
||||||
|
|
||||||
|
/** 입력값 → 저장될 이름. 소문자→대문자, 공백·하이픈·점→_, 그 외 기호·한글은 버림, _ 연속·앞뒤 정리. */
|
||||||
|
export function normalizeName(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[\s\-.]+/g, "_")
|
||||||
|
.replace(/[^A-Z0-9_]/g, "")
|
||||||
|
.replace(/_+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 규칙 위반이면 이유(한글), 통과면 null. 입력 원문을 받아 정규화 결과로 판단. */
|
||||||
|
export function nameProblem(raw: string, existing: Iterable<string> = []): string | null {
|
||||||
|
const name = normalizeName(raw)
|
||||||
|
if (!name)
|
||||||
|
return /[가-힣]/.test(raw) ? "이름은 영문으로 — 한글은 설명에 적어" : "이름을 입력해야 함"
|
||||||
|
if (!/^[A-Z]/.test(name)) return "이름은 영문자로 시작해야 함"
|
||||||
|
if (name.length < NAME_MIN) return `이름은 ${NAME_MIN}자 이상`
|
||||||
|
if (name.length > NAME_MAX) return `이름은 ${NAME_MAX}자 이하`
|
||||||
|
if (!NAME_RE.test(name)) return "영문 대문자·숫자·_ 만 쓸 수 있음"
|
||||||
|
for (const e of existing) if (e === name) return `이미 있는 이름임: ${name}`
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -42,11 +42,24 @@ describe("searchSnippets", () => {
|
|||||||
expect(result.map((i) => i.name)).toEqual(["DOCKER_RUN"])
|
expect(result.map((i) => i.name)).toEqual(["DOCKER_RUN"])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("body는 검색 대상이 아님 — body에만 있는 키워드는 매칭 안 됨", () => {
|
it("body 코드 줄은 검색 대상 아님 — 코드에만 있는 키워드는 매칭 안 됨", () => {
|
||||||
const result = searchSnippets(items, "XYZKEYWORD")
|
const result = searchSnippets(items, "XYZKEYWORD")
|
||||||
expect(result).toHaveLength(0)
|
expect(result).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('body 의 ★ 머리줄·주석줄(* / ")은 검색됨 — 한글 키워드가 여기 있음', () => {
|
||||||
|
const withHeader = [
|
||||||
|
s({
|
||||||
|
name: "ALV_FILTER_ROWS",
|
||||||
|
body: '"★★ALV_FILTER-FILTER 필터\nDATA lt_x TYPE TABLE OF mara.',
|
||||||
|
}),
|
||||||
|
s({ name: "POPUP_CONFIRM", body: "* 확인 팝업 띄우기\nCALL FUNCTION 'POPUP_TO_CONFIRM'." }),
|
||||||
|
s({ name: "PLAIN", body: "DATA 필터 TYPE c." }), // 코드 줄의 한글은 안 걸림
|
||||||
|
]
|
||||||
|
expect(searchSnippets(withHeader, "필터").map((i) => i.name)).toEqual(["ALV_FILTER_ROWS"])
|
||||||
|
expect(searchSnippets(withHeader, "팝업").map((i) => i.name)).toEqual(["POPUP_CONFIRM"])
|
||||||
|
})
|
||||||
|
|
||||||
it("category 지정 시 그 분류로 먼저 제한", () => {
|
it("category 지정 시 그 분류로 먼저 제한", () => {
|
||||||
const result = searchSnippets(items, "", "기타")
|
const result = searchSnippets(items, "", "기타")
|
||||||
expect(result.map((i) => i.name)).toEqual(["MEMO"])
|
expect(result.map((i) => i.name)).toEqual(["MEMO"])
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
// 검색 순수함수 — data-model.md §검색. FR-005/006 그대로.
|
// 검색 순수함수 — data-model.md §검색. FR-005/006.
|
||||||
import type { Snippet } from "../types"
|
import type { Snippet } from "../types"
|
||||||
|
|
||||||
|
/** 본문 중 검색에 넣을 줄 — 원작성자가 달아둔 ★ 머리줄과 ABAP 주석(* 로 시작, " 로 시작).
|
||||||
|
* 본문 전체는 안 넣음: 코드에 TABLE·DATA 같은 흔한 말이 많아 결과가 흐려짐.
|
||||||
|
* 2026-09-23 실측: 143개 중 한글이 이름 0·설명 0, 본문 72 — 한글 키워드가 여기에만 있었음. */
|
||||||
|
export function keywordLines(body: string): string {
|
||||||
|
return body
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l.startsWith("*") || l.startsWith('"') || l.includes("★"))
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* category 로 먼저 제한(없거나 "전체"면 무제한) 후, 쿼리를 공백으로 나눈 키워드 전부(AND)가
|
* category 로 먼저 제한(없거나 "전체"면 무제한) 후, 쿼리를 공백으로 나눈 키워드 전부(AND)가
|
||||||
* name+desc 합친 문자열(대소문자 무시)에 포함되는 것만 남긴다. body 는 검색 대상 아님. 퍼지 없음.
|
* 이름 + 설명 + 본문 키워드줄(keywordLines) 합친 문자열(대소문자 무시)에 포함되는 것만 남긴다. 퍼지 없음.
|
||||||
* 빈 쿼리 → (category 제한된) 전체.
|
* 빈 쿼리 → (category 제한된) 전체.
|
||||||
*/
|
*/
|
||||||
export function searchSnippets(snippets: Snippet[], query: string, category?: string): Snippet[] {
|
export function searchSnippets(snippets: Snippet[], query: string, category?: string): Snippet[] {
|
||||||
@@ -14,7 +25,7 @@ export function searchSnippets(snippets: Snippet[], query: string, category?: st
|
|||||||
if (keywords.length === 0) return inCategory
|
if (keywords.length === 0) return inCategory
|
||||||
|
|
||||||
return inCategory.filter((s) => {
|
return inCategory.filter((s) => {
|
||||||
const haystack = `${s.name} ${s.desc}`.toUpperCase()
|
const haystack = `${s.name} ${s.desc} ${keywordLines(s.body)}`.toUpperCase()
|
||||||
return keywords.every((k) => haystack.includes(k))
|
return keywords.every((k) => haystack.includes(k))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export default function SnippetPalettePage() {
|
|||||||
return [ALL_CATEGORY, ...found]
|
return [ALL_CATEGORY, ...found]
|
||||||
}, [snippets])
|
}, [snippets])
|
||||||
const [category, setCategory] = useState(ALL_CATEGORY)
|
const [category, setCategory] = useState(ALL_CATEGORY)
|
||||||
|
const existingNames = useMemo(() => snippets.map((s) => s.name), [snippets])
|
||||||
// 선택 중이던 category의 스니펫이 다 사라지면(삭제 등) "전체"로 복귀.
|
// 선택 중이던 category의 스니펫이 다 사라지면(삭제 등) "전체"로 복귀.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!categories.includes(category)) setCategory(ALL_CATEGORY)
|
if (!categories.includes(category)) setCategory(ALL_CATEGORY)
|
||||||
@@ -413,6 +414,7 @@ export default function SnippetPalettePage() {
|
|||||||
onDelete={removeSnippet}
|
onDelete={removeSnippet}
|
||||||
isSaving={createSnippet.isPending || updateSnippet.isPending}
|
isSaving={createSnippet.isPending || updateSnippet.isPending}
|
||||||
isDeleting={deleteSnippet.isPending}
|
isDeleting={deleteSnippet.isPending}
|
||||||
|
existingNames={existingNames}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""검토한 스니펫 메타(한글 설명·새 이름)를 DB 에 반영. CSV 는 docs 의 검토 파일(UTF-8, 헤더 name,new_name,desc).
|
||||||
|
|
||||||
|
python manage.py apply_snippet_meta snippet_meta_review.csv --dry-run # 뭐가 바뀌는지만
|
||||||
|
python manage.py apply_snippet_meta snippet_meta_review.csv # 반영
|
||||||
|
|
||||||
|
- desc: 비어 있거나 한글이 없는(옛 이름 복사 등) 설명만 채움. --overwrite 면 한글 설명도 덮어씀.
|
||||||
|
- new_name: 옛 규칙 이름을 새 이름으로. 사람별 사용 기록(SnippetUsage)도 같이 옮김. 새 이름이 이미 있으면 건너뜀.
|
||||||
|
- DB 에 없는 name 은 건너뜀(사용자가 지웠을 수 있음). 여러 번 돌려도 결과 같음.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import re
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from apps.snippets.models import Snippet, SnippetUsage
|
||||||
|
from apps.snippets.views import name_problem, normalize_name
|
||||||
|
|
||||||
|
KO = re.compile(r"[가-힣]")
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "검토한 스니펫 한글 설명·새 이름을 반영"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("path")
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
parser.add_argument("--overwrite", action="store_true", help="이미 한글 설명이 있어도 덮어씀")
|
||||||
|
|
||||||
|
def handle(self, path, dry_run, overwrite, **_):
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8-sig", newline="") as f:
|
||||||
|
rows = list(csv.DictReader(f))
|
||||||
|
except OSError as e:
|
||||||
|
raise CommandError(f"CSV 못 읽음: {e}") from e
|
||||||
|
if rows and not {"name", "desc"} <= set(rows[0]):
|
||||||
|
raise CommandError("CSV 헤더는 name,new_name,desc 여야 함")
|
||||||
|
|
||||||
|
stat = {"desc": 0, "rename": 0, "missing": 0, "skip": 0}
|
||||||
|
with transaction.atomic():
|
||||||
|
for r in rows:
|
||||||
|
old = (r.get("name") or "").strip()
|
||||||
|
s = Snippet.objects.filter(name=old).first()
|
||||||
|
if not s:
|
||||||
|
stat["missing"] += 1
|
||||||
|
continue
|
||||||
|
desc = (r.get("desc") or "").strip()
|
||||||
|
if desc and (overwrite or not KO.search(s.desc or "")) and desc != s.desc:
|
||||||
|
self.stdout.write(f"설명 {old}: {s.desc!r} → {desc!r}")
|
||||||
|
s.desc = desc
|
||||||
|
stat["desc"] += 1
|
||||||
|
if not dry_run:
|
||||||
|
s.save(update_fields=["desc", "updated_at"])
|
||||||
|
new = normalize_name(r.get("new_name") or "")
|
||||||
|
if new and new != old:
|
||||||
|
if name_problem(new) or Snippet.objects.filter(name=new).exists():
|
||||||
|
self.stdout.write(self.style.WARNING(f"이름 {old} → {new}: 규칙 위반이거나 이미 있음 — 건너뜀"))
|
||||||
|
stat["skip"] += 1
|
||||||
|
continue
|
||||||
|
self.stdout.write(f"이름 {old} → {new}")
|
||||||
|
stat["rename"] += 1
|
||||||
|
if not dry_run:
|
||||||
|
# PK 가 이름이라 새로 만들고 사용 기록을 옮긴 뒤 옛 것을 지움
|
||||||
|
Snippet.objects.create(name=new, desc=s.desc, body=s.body, category=s.category, created_by=s.created_by)
|
||||||
|
SnippetUsage.objects.filter(snippet_id=old).update(snippet_id=new)
|
||||||
|
s.delete()
|
||||||
|
if dry_run:
|
||||||
|
transaction.set_rollback(True)
|
||||||
|
self.stdout.write(
|
||||||
|
f"{'(미리보기) ' if dry_run else ''}설명 {stat['desc']} / 이름 변경 {stat['rename']} / "
|
||||||
|
f"건너뜀 {stat['skip']} / DB 에 없음 {stat['missing']} (총 {len(rows)}줄)"
|
||||||
|
)
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
POST /api/v1/snippets/{name}/use → {name, usageCount, lastUsed}
|
POST /api/v1/snippets/{name}/use → {name, usageCount, lastUsed}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -18,19 +20,48 @@ from common.envelope import CodedError
|
|||||||
from .models import Snippet, SnippetUsage
|
from .models import Snippet, SnippetUsage
|
||||||
|
|
||||||
|
|
||||||
|
# 이름 규칙 — 프론트 features/snippets/core/name.ts 와 같음(바꾸면 양쪽 같이).
|
||||||
|
# 영문 대문자·숫자·_, 문자로 시작, 3~50자, _ 연속·앞뒤 금지. 권장 "영역_대상_동작". 한글은 설명에.
|
||||||
|
NAME_RE = re.compile(r"^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$")
|
||||||
|
NAME_MIN, NAME_MAX = 3, 50
|
||||||
|
|
||||||
|
|
||||||
def normalize_name(name: str) -> str:
|
def normalize_name(name: str) -> str:
|
||||||
|
"""입력 → 저장될 이름. 대문자, 공백·하이픈·점→_, 그 외 기호·한글 버림, _ 정리."""
|
||||||
|
s = re.sub(r"[\s\-.]+", "_", (name or "").upper())
|
||||||
|
s = re.sub(r"[^A-Z0-9_]", "", s)
|
||||||
|
return re.sub(r"_+", "_", s).strip("_")
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_name(name: str) -> str:
|
||||||
|
"""URL 의 이름 → DB 키. 옛 규칙 이름(IF_SUBRC-ABAP 등)이 남아 있으면 그대로 찾아야 해서 정규화 안 함(대문자·공백만)."""
|
||||||
return (name or "").strip().upper().replace(" ", "_")
|
return (name or "").strip().upper().replace(" ", "_")
|
||||||
|
|
||||||
|
|
||||||
|
def name_problem(raw: str) -> str | None:
|
||||||
|
name = normalize_name(raw)
|
||||||
|
if not name:
|
||||||
|
return "이름은 영문으로 — 한글은 설명에 적어" if re.search(r"[가-힣]", raw or "") else "이름을 입력해야 함"
|
||||||
|
if not name[0].isalpha():
|
||||||
|
return "이름은 영문자로 시작해야 함"
|
||||||
|
if not NAME_MIN <= len(name) <= NAME_MAX:
|
||||||
|
return f"이름은 {NAME_MIN}~{NAME_MAX}자"
|
||||||
|
if not NAME_RE.match(name):
|
||||||
|
return "영문 대문자·숫자·_ 만 쓸 수 있음"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _validate(body: dict, *, need_name: bool) -> dict:
|
def _validate(body: dict, *, need_name: bool) -> dict:
|
||||||
name = normalize_name(body.get("name", ""))
|
name = normalize_name(body.get("name", ""))
|
||||||
if need_name and not name:
|
if need_name and (problem := name_problem(body.get("name", ""))):
|
||||||
raise CodedError(400, "VALIDATION_ERROR", "이름을 입력해야 함")
|
raise CodedError(400, "VALIDATION_ERROR", problem)
|
||||||
|
if not (body.get("desc") or "").strip():
|
||||||
|
raise CodedError(400, "VALIDATION_ERROR", "설명을 입력해야 함 — 한글로 무엇을 하는 코드인지")
|
||||||
if not (body.get("body") or "").strip():
|
if not (body.get("body") or "").strip():
|
||||||
raise CodedError(400, "VALIDATION_ERROR", "내용을 입력해야 함")
|
raise CodedError(400, "VALIDATION_ERROR", "내용을 입력해야 함")
|
||||||
return {
|
return {
|
||||||
"name": name,
|
"name": name,
|
||||||
"desc": body.get("desc") or "",
|
"desc": body["desc"].strip(),
|
||||||
"body": body["body"],
|
"body": body["body"],
|
||||||
"category": (body.get("category") or "").strip() or "코드",
|
"category": (body.get("category") or "").strip() or "코드",
|
||||||
}
|
}
|
||||||
@@ -53,7 +84,7 @@ class SnippetListView(APIView):
|
|||||||
|
|
||||||
class SnippetDetailView(APIView):
|
class SnippetDetailView(APIView):
|
||||||
def put(self, request, name: str):
|
def put(self, request, name: str):
|
||||||
s = get_object_or_404(Snippet, name=normalize_name(name))
|
s = get_object_or_404(Snippet, name=lookup_name(name))
|
||||||
d = _validate({**request.data, "name": s.name}, need_name=False)
|
d = _validate({**request.data, "name": s.name}, need_name=False)
|
||||||
s.desc, s.body, s.category = d["desc"], d["body"], d["category"]
|
s.desc, s.body, s.category = d["desc"], d["body"], d["category"]
|
||||||
s.save(update_fields=["desc", "body", "category", "updated_at"])
|
s.save(update_fields=["desc", "body", "category", "updated_at"])
|
||||||
@@ -61,7 +92,7 @@ class SnippetDetailView(APIView):
|
|||||||
return Response(s.as_dto(usage))
|
return Response(s.as_dto(usage))
|
||||||
|
|
||||||
def delete(self, request, name: str):
|
def delete(self, request, name: str):
|
||||||
s = get_object_or_404(Snippet, name=normalize_name(name))
|
s = get_object_or_404(Snippet, name=lookup_name(name))
|
||||||
deleted = s.name # delete() 가 pk 를 None 으로 지움
|
deleted = s.name # delete() 가 pk 를 None 으로 지움
|
||||||
s.delete()
|
s.delete()
|
||||||
return Response({"name": deleted})
|
return Response({"name": deleted})
|
||||||
@@ -69,7 +100,7 @@ class SnippetDetailView(APIView):
|
|||||||
|
|
||||||
class SnippetUseView(APIView):
|
class SnippetUseView(APIView):
|
||||||
def post(self, request, name: str):
|
def post(self, request, name: str):
|
||||||
s = get_object_or_404(Snippet, name=normalize_name(name))
|
s = get_object_or_404(Snippet, name=lookup_name(name))
|
||||||
u, _ = SnippetUsage.objects.get_or_create(snippet=s, user=request.user)
|
u, _ = SnippetUsage.objects.get_or_create(snippet=s, user=request.user)
|
||||||
u.count += 1
|
u.count += 1
|
||||||
u.last_used = timezone.now()
|
u.last_used = timezone.now()
|
||||||
|
|||||||
@@ -8,12 +8,17 @@ from apps.snippets.models import Snippet
|
|||||||
|
|
||||||
|
|
||||||
def test_crud_and_usage(auth_api, user):
|
def test_crud_and_usage(auth_api, user):
|
||||||
r = auth_api.post("/api/v1/snippets", {"name": " alv filter ", "body": "x", "category": ""}, format="json")
|
r = auth_api.post("/api/v1/snippets", {"name": " alv filter ", "desc": "ALV 필터", "body": "x", "category": ""}, format="json")
|
||||||
assert r.status_code == 201 and r.json()["data"]["name"] == "ALV_FILTER" and r.json()["data"]["category"] == "코드"
|
assert r.status_code == 201 and r.json()["data"]["name"] == "ALV_FILTER" and r.json()["data"]["category"] == "코드"
|
||||||
assert auth_api.post("/api/v1/snippets", {"name": "ALV_FILTER", "body": "y"}, format="json").status_code == 409
|
assert auth_api.post("/api/v1/snippets", {"name": "alv-filter", "desc": "d", "body": "y"}, format="json").status_code == 409 # 하이픈도 같은 이름
|
||||||
assert auth_api.post("/api/v1/snippets", {"name": "", "body": "y"}, format="json").status_code == 400
|
assert auth_api.post("/api/v1/snippets", {"name": "", "desc": "d", "body": "y"}, format="json").status_code == 400
|
||||||
assert auth_api.post("/api/v1/snippets", {"name": "B", "body": " "}, format="json").status_code == 400
|
assert auth_api.post("/api/v1/snippets", {"name": "BBB", "desc": "d", "body": " "}, format="json").status_code == 400
|
||||||
|
assert auth_api.post("/api/v1/snippets", {"name": "CCC_OK", "body": "y"}, format="json").status_code == 400 # 설명 필수
|
||||||
|
r = auth_api.post("/api/v1/snippets", {"name": "필터", "desc": "d", "body": "y"}, format="json")
|
||||||
|
assert r.status_code == 400 and "설명" in r.json()["message"] # 한글 이름 → 설명으로 안내
|
||||||
|
assert auth_api.post("/api/v1/snippets", {"name": "1ABC", "desc": "d", "body": "y"}, format="json").status_code == 400
|
||||||
|
|
||||||
|
assert auth_api.put("/api/v1/snippets/alv_filter", {"desc": " ", "body": "z"}, format="json").status_code == 400
|
||||||
r = auth_api.put("/api/v1/snippets/alv_filter", {"desc": "d", "body": "z", "category": "ALV"}, format="json")
|
r = auth_api.put("/api/v1/snippets/alv_filter", {"desc": "d", "body": "z", "category": "ALV"}, format="json")
|
||||||
assert r.status_code == 200 and r.json()["data"]["body"] == "z" and r.json()["data"]["category"] == "ALV"
|
assert r.status_code == 200 and r.json()["data"]["body"] == "z" and r.json()["data"]["category"] == "ALV"
|
||||||
|
|
||||||
@@ -46,3 +51,43 @@ def test_import_from_sqlite(db, tmp_path):
|
|||||||
assert set(names) == {"A_B", "C"} and names["C"].category == "코드"
|
assert set(names) == {"A_B", "C"} and names["C"].category == "코드"
|
||||||
call_command("import_snippets", str(p)) # 재실행해도 중복 없음
|
call_command("import_snippets", str(p)) # 재실행해도 중복 없음
|
||||||
assert Snippet.objects.count() == 2
|
assert Snippet.objects.count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_name_still_editable(auth_api, user):
|
||||||
|
Snippet.objects.create(name="IF_SUBRC-ABAP", desc="", body="IF sy-subrc = 0.")
|
||||||
|
r = auth_api.put("/api/v1/snippets/IF_SUBRC-ABAP", {"desc": "sy-subrc 분기", "body": "IF sy-subrc = 0."}, format="json")
|
||||||
|
assert r.status_code == 200 and r.json()["data"]["name"] == "IF_SUBRC-ABAP"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_snippet_meta(db, user, tmp_path):
|
||||||
|
from apps.snippets.models import SnippetUsage
|
||||||
|
|
||||||
|
Snippet.objects.create(name="IF_SUBRC-ABAP", desc="", body="IF sy-subrc = 0.")
|
||||||
|
Snippet.objects.create(name="ALV_FILTER_ROWS", desc="ALV_FILTER", body="x") # 한글 없는 옛 설명 → 채움
|
||||||
|
Snippet.objects.create(name="KEEP_KO", desc="이미 한글", body="x") # 한글 설명 → 안 덮음
|
||||||
|
Snippet.objects.create(name="TAKEN", desc="d", body="x")
|
||||||
|
Snippet.objects.create(name="OLD_ONE", desc="", body="x")
|
||||||
|
SnippetUsage.objects.create(snippet_id="IF_SUBRC-ABAP", user=user, count=3)
|
||||||
|
p = tmp_path / "meta.csv"
|
||||||
|
p.write_text(
|
||||||
|
"\n".join([
|
||||||
|
"name,new_name,desc",
|
||||||
|
"IF_SUBRC-ABAP,CONTROL_IF_SUBRC_CHECK,sy-subrc 로 성공/실패 분기",
|
||||||
|
"ALV_FILTER_ROWS,,ALV 에서 조건에 맞는 행만 필터",
|
||||||
|
"KEEP_KO,,새 설명",
|
||||||
|
"OLD_ONE,TAKEN,x",
|
||||||
|
"GONE,,없는 것",
|
||||||
|
]),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
call_command("apply_snippet_meta", str(p), "--dry-run")
|
||||||
|
assert Snippet.objects.filter(name="IF_SUBRC-ABAP").exists() # 미리보기는 안 바꿈
|
||||||
|
call_command("apply_snippet_meta", str(p))
|
||||||
|
new = Snippet.objects.get(name="CONTROL_IF_SUBRC_CHECK")
|
||||||
|
assert new.desc == "sy-subrc 로 성공/실패 분기" and not Snippet.objects.filter(name="IF_SUBRC-ABAP").exists()
|
||||||
|
assert SnippetUsage.objects.get(snippet_id="CONTROL_IF_SUBRC_CHECK").count == 3 # 사용 기록 같이 옮김
|
||||||
|
assert Snippet.objects.get(name="ALV_FILTER_ROWS").desc == "ALV 에서 조건에 맞는 행만 필터"
|
||||||
|
assert Snippet.objects.get(name="KEEP_KO").desc == "이미 한글"
|
||||||
|
assert Snippet.objects.filter(name="OLD_ONE").exists() # 새 이름이 이미 있으면 건너뜀
|
||||||
|
call_command("apply_snippet_meta", str(p)) # 다시 돌려도 결과 같음
|
||||||
|
assert Snippet.objects.filter(name="CONTROL_IF_SUBRC_CHECK").count() == 1
|
||||||
|
|||||||
Reference in New Issue
Block a user