Enhance: Non-destructive metadata system, Korean support, and UI readability

This commit is contained in:
leeyj
2026-04-18 03:39:14 +09:00
parent c0dbeb8f18
commit 828ec07b94
5 changed files with 55 additions and 50 deletions
+8 -28
View File
@@ -27,48 +27,28 @@ def parse_metadata(text, default_group=GROUP_DEFAULT):
def parse_and_clean_metadata(content, ui_group=GROUP_DEFAULT, ui_tags=None):
"""
본문에서 메타데이터($ , #)를 추출하고 삭제한 뒤, UI 입력값과 합쳐 최하단에 재배치합니다.
[비파괴적 버전] 본문에서 메타데이터를 추출하되, 본문의 내용은 훼손하지 않습니다.
단, 이전에 자동 생성되었던 푸터 블록만 식별하여 제거합니다.
"""
if ui_tags is None: ui_tags = []
if not content:
return content, ui_group, ui_tags
# 1. 기존에 생성된 푸터 블록(수평선 + 메타데이터)을 제거
# 파일의 가장 마지막에 위치한 수평선(---, ***, ___)과 그 뒤에 따라오는 메타데이터($ , #) 줄들만 식별하여 제거합니다.
# 1. 기존에 자동 생성되었던 푸터 블록만 제거 (원본 본문 보호를 위함)
content = content.strip()
# 패턴 설명: 줄바꿈 + 수평선 + 줄바꿈 + (줄 시작이 $ 또는 #이며 뒤에 공백이 없는 줄들의 반복) + 끝
footer_regex = r'\n+[\*\-\_]{3,}\s*\n(?:^[\$\#][^\s\#].*$(?:\n|$))*$'
content = re.sub(footer_regex, '', content, flags=re.MULTILINE).strip()
# 2. 본문에서 기호 정보 추출
# 2. 본문에서 기호 정보 추출 (추출만 수행하고 본문에서 삭제는 하지 않음)
content_group, content_tags = parse_metadata(content)
# 3. 본문에서 기호 패턴 삭제
# $그룹 삭제
content = re.sub(r'\$\w+', '', content)
# #태그 삭제 (헤더 및 내부 링크 제외, 태그는 # 뒤에 바로 문자가 와야 함)
content = re.sub(r'(?<!#)(?<!\[\[)(?<!\w)#([^\s\#\d\W][^\s\#]*)', '', content)
content = content.strip()
# 4. 데이터 통합
# 본문에 적힌 그룹이 있다면 UI 선택값보다 우선함
# 3. 데이터 통합
# 본문에 적힌 태그와 UI에서 선택된 태그를 합칩니다.
final_group = content_group if content_group != GROUP_DEFAULT else ui_group
# 태그는 모두 합침
final_tags = list(set(ui_tags + content_tags))
# 5. 푸터 재생성
footer_parts = []
if final_group and final_group != GROUP_DEFAULT:
footer_parts.append(f"${final_group}")
if final_tags:
footer_tags = " ".join([f"#{t}" for t in sorted(final_tags)])
footer_parts.append(footer_tags)
final_content = content
if footer_parts:
final_content += "\n\n---\n" + "\n".join(footer_parts)
return final_content, final_group, final_tags
# 이제 본문에서 태그를 삭제(re.sub)하거나 최하단에 수평선 + 태그를 붙이지 않습니다.
return content, final_group, final_tags
def generate_auto_title(content):
"""
+12 -2
View File
@@ -65,8 +65,18 @@
/* Links & Actions */
.memo-backlinks { margin-top: 12px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.05); font-size: 0.8rem; color: var(--muted); }
.link-item { color: var(--accent); cursor: pointer; text-decoration: none; font-weight: 600; }
.link-item:hover { text-decoration: underline; }
.link-item, .internal-link, .memo-content a {
color: #fbbf24; /* 고대비 Amber 색상 */
cursor: pointer;
text-decoration: none;
font-weight: 600;
transition: color 0.2s, opacity 0.2s;
}
.link-item:hover, .internal-link:hover, .memo-content a:hover {
color: #f59e0b;
text-decoration: underline;
}
.memo-actions { position: absolute; bottom: 10px; right: 10px; opacity: 0; transition: opacity 0.2s; display: flex; gap: 5px; }
.memo-card:hover .memo-actions { opacity: 1; }
+3 -5
View File
@@ -1,7 +1,7 @@
/**
* 메모 카드 컴포넌트
*/
import { escapeHTML, parseInternalLinks, fixImagePaths } from '../utils.js';
import { escapeHTML, parseInternalLinks, fixImagePaths, stripMetadata } from '../utils.js';
import { renderAttachmentBox } from './AttachmentBox.js';
import { Constants } from '../utils/Constants.js';
import { I18nManager } from '../utils/I18nManager.js';
@@ -36,10 +36,8 @@ export function createMemoCardHtml(memo, isDone) {
</div>
`;
} else {
// 본문에서 하단 메타데이터 블록(--- 이후)을 제외하고 렌더링 (중복 표시 방지)
let content = memo.content || '';
const footerIndex = content.lastIndexOf('\n\n---\n');
const displayContent = footerIndex !== -1 ? content.substring(0, footerIndex) : content;
// 렌더링 시에는 태그와 그룹명을 시각적으로 가립니다 (원본 보존 정책)
const displayContent = stripMetadata(memo.content || '');
// marked로 파싱한 후 DOMPurify로 살균하여 XSS 방지
htmlContent = DOMPurify.sanitize(marked.parse(displayContent));
+4 -15
View File
@@ -164,21 +164,10 @@ export const ModalManager = {
const memo = memos.find(m => m.id == id);
if (!memo) return;
import('../utils.js').then(({ parseInternalLinks, fixImagePaths }) => {
// 메모 본문과 메타데이터 푸터 분리 렌더링
let content = memo.content || '';
const footerIndex = content.lastIndexOf('\n\n---\n');
let html;
if (footerIndex !== -1) {
const mainBody = content.substring(0, footerIndex);
const footerPart = content.substring(footerIndex + 5).trim(); // '---' 이후
html = DOMPurify.sanitize(marked.parse(mainBody));
html += `<div class="memo-metadata-footer"><hr style="border:none; border-top:1px dashed rgba(255,255,255,0.1); margin-bottom:15px;">${DOMPurify.sanitize(marked.parse(footerPart))}</div>`;
} else {
html = DOMPurify.sanitize(marked.parse(content));
}
import('../utils.js').then(({ parseInternalLinks, fixImagePaths, stripMetadata }) => {
// 렌더링 시에는 태그와 그룹명을 시각적으로 가립니다 (원본 보존 정책)
const displayContent = stripMetadata(memo.content || '');
let html = DOMPurify.sanitize(marked.parse(displayContent));
html = parseInternalLinks(html);
html = fixImagePaths(html);
+28
View File
@@ -41,3 +41,31 @@ export function fixImagePaths(html) {
return `<img src="/api/download/${filename}"`;
});
}
/**
* 본문에서 메타데이터 기능어($그룹, #태그)를 시각적으로 가립니다.
* 원본 보존 정책에 따라 렌더링 시에만 필터링 용도로 사용됩니다.
*/
export function stripMetadata(text) {
if (!text) return '';
let processed = text;
// 1. 기존 자동생성 푸터 블록(--- 및 하단 메타데이터) 시각적 제거
const footerRegex = /\n+[\*\-\_]{3,}\s*\n(?:^[\$\#][^\s\#].*$(?:\n|$))*/gm;
processed = processed.replace(footerRegex, '');
// 태그/그룹 구성에서 제외할 특수문자들 (한글 및 유니코드 지원을 위해 제외 문자 방식 사용)
const excludeChars = "\\s\\#\\!\\@\\%\\^\\&\\*\\(\\)\\=\\+\\[\\]\\{\\}\\;\\:\\'\\\"\\,\\<\\.\\>\\/\\?\\-";
// 2. 본문 내 $그룹 제거
const groupRegex = new RegExp("\\$[^" + excludeChars + "]+", "g");
processed = processed.replace(groupRegex, '');
// 3. 본문 내 #태그 제거 (마크다운 헤더 및 내부 링크 제외)
// 패턴 설명: 공백 또는 줄 시작 뒤의 #로 시작하고, 뒤에 숫자가 아닌 문자가 오는 태그 매칭
const tagRegex = new RegExp("(^|\\s)#[^" + excludeChars + "0-9]+[^" + excludeChars + "]*", "g");
processed = processed.replace(tagRegex, '$1');
return processed.trim();
}