Zettel UpNote
+8
-6
@@ -58,3 +58,3 @@ "use strict"; | ||
| const rawAi = await callAI(page, config); | ||
| const ai = ZettelCore.normalizeAIResult(rawAi, page); | ||
| const ai = ZettelCore.normalizeAIResult(rawAi, page, config); | ||
@@ -78,3 +78,4 @@ notify(requestId, "formatting", "UpNote 마크다운을 구성하고 있습니다."); | ||
| markdown: note.markdown, | ||
| upnoteUrl | ||
| upnoteUrl, | ||
| autoLaunch: config.autoLaunchUpNote | ||
| } | ||
@@ -146,3 +147,3 @@ }); | ||
| generationConfig: { | ||
| temperature: 0.2, | ||
| temperature: config.aiTemperature, | ||
| responseMimeType: "application/json" | ||
@@ -181,3 +182,3 @@ } | ||
| stream: false, | ||
| options: { temperature: 0.2 } | ||
| options: { temperature: config.aiTemperature } | ||
| }) | ||
@@ -206,3 +207,4 @@ }, | ||
| messages: [{ role: "user", content: prompt }], | ||
| stream: false | ||
| stream: false, | ||
| temperature: config.aiTemperature | ||
| }) | ||
@@ -239,3 +241,3 @@ }, | ||
| }); | ||
| ZettelCore.normalizeAIResult(result, samplePage); | ||
| ZettelCore.normalizeAIResult(result, samplePage, config); | ||
| } | ||
@@ -242,0 +244,0 @@ |
@@ -34,3 +34,8 @@ "use strict"; | ||
| previewElement.textContent = note.markdown; | ||
| setTimeout(launch, 450); | ||
| if (note.autoLaunch !== false) { | ||
| setTimeout(launch, 450); | ||
| } else { | ||
| messageElement.textContent = "노트를 확인한 뒤 아래 버튼을 눌러 UpNote로 보내세요."; | ||
| statusElement.textContent = "자동 실행이 설정에서 꺼져 있습니다."; | ||
| } | ||
| setTimeout(() => browser.storage.local.remove(key), 10 * 60 * 1000); | ||
@@ -37,0 +42,0 @@ } |
+224
-64
| (function initCore(globalScope) { | ||
| "use strict"; | ||
| const DEFAULT_NOTE_TEMPLATE = `> 📅 **날짜:** {{date}} | ||
| > | ||
| > 🔗 **출처:** {{source}} | ||
| --- | ||
| ## 💬 인용 | ||
| {{quote}} | ||
| ## 🧠 핵심 요약 | ||
| {{key_points}} | ||
| --- | ||
| ## ✍️ 메모 | ||
| {{memo}} | ||
| --- | ||
| ## 🏷️ 태그 | ||
| {{tags}} | ||
| {{original_section}}`; | ||
| const DEFAULT_CONFIG = Object.freeze({ | ||
@@ -17,2 +45,18 @@ provider: "gemini", | ||
| newWindow: false, | ||
| autoLaunchUpNote: true, | ||
| noteTitlePrefix: "", | ||
| noteTitleSuffix: "", | ||
| summaryLanguage: "ko", | ||
| quoteMaxChars: 300, | ||
| keyPointCount: 4, | ||
| tagCount: 3, | ||
| aiTemperature: 0.2, | ||
| customInstructions: "", | ||
| noteTemplate: DEFAULT_NOTE_TEMPLATE, | ||
| dateFormat: "korean", | ||
| sourceLabelMode: "site", | ||
| quoteStyle: "blockquote", | ||
| keyPointStyle: "bold-bullets", | ||
| tagStyle: "hashtags", | ||
| memoPlaceholder: "", | ||
| attachOriginal: true, | ||
@@ -39,2 +83,9 @@ originalMaxChars: 10000, | ||
| function clampNumber(value, min, max, fallback) { | ||
| const parsed = Number.parseFloat(value); | ||
| return Number.isFinite(parsed) | ||
| ? Math.min(max, Math.max(min, parsed)) | ||
| : fallback; | ||
| } | ||
| function normalizeConfig(raw = {}) { | ||
@@ -48,2 +99,20 @@ const merged = { ...DEFAULT_CONFIG, ...raw }; | ||
| : DEFAULT_CONFIG.ollamaMode; | ||
| const summaryLanguage = ["ko", "source", "en", "ja"].includes(merged.summaryLanguage) | ||
| ? merged.summaryLanguage | ||
| : DEFAULT_CONFIG.summaryLanguage; | ||
| const dateFormat = ["korean", "iso", "locale"].includes(merged.dateFormat) | ||
| ? merged.dateFormat | ||
| : DEFAULT_CONFIG.dateFormat; | ||
| const sourceLabelMode = ["site", "domain", "title"].includes(merged.sourceLabelMode) | ||
| ? merged.sourceLabelMode | ||
| : DEFAULT_CONFIG.sourceLabelMode; | ||
| const quoteStyle = ["blockquote", "plain"].includes(merged.quoteStyle) | ||
| ? merged.quoteStyle | ||
| : DEFAULT_CONFIG.quoteStyle; | ||
| const keyPointStyle = ["bold-bullets", "bullets", "numbered", "checklist"].includes(merged.keyPointStyle) | ||
| ? merged.keyPointStyle | ||
| : DEFAULT_CONFIG.keyPointStyle; | ||
| const tagStyle = ["hashtags", "inline-code", "bullets"].includes(merged.tagStyle) | ||
| ? merged.tagStyle | ||
| : DEFAULT_CONFIG.tagStyle; | ||
@@ -66,2 +135,18 @@ return { | ||
| newWindow: Boolean(merged.newWindow), | ||
| autoLaunchUpNote: merged.autoLaunchUpNote !== false, | ||
| noteTitlePrefix: String(merged.noteTitlePrefix || "").slice(0, 60), | ||
| noteTitleSuffix: String(merged.noteTitleSuffix || "").slice(0, 60), | ||
| summaryLanguage, | ||
| quoteMaxChars: clampInteger(merged.quoteMaxChars, 80, 1000, DEFAULT_CONFIG.quoteMaxChars), | ||
| keyPointCount: clampInteger(merged.keyPointCount, 2, 10, DEFAULT_CONFIG.keyPointCount), | ||
| tagCount: clampInteger(merged.tagCount, 2, 10, DEFAULT_CONFIG.tagCount), | ||
| aiTemperature: clampNumber(merged.aiTemperature, 0, 2, DEFAULT_CONFIG.aiTemperature), | ||
| customInstructions: String(merged.customInstructions || "").trim(), | ||
| noteTemplate: String(merged.noteTemplate || DEFAULT_NOTE_TEMPLATE).trim(), | ||
| dateFormat, | ||
| sourceLabelMode, | ||
| quoteStyle, | ||
| keyPointStyle, | ||
| tagStyle, | ||
| memoPlaceholder: String(merged.memoPlaceholder || "").trim(), | ||
| attachOriginal: merged.attachOriginal !== false, | ||
@@ -164,5 +249,9 @@ originalMaxChars: clampInteger(merged.originalMaxChars, 1000, 30000, 10000), | ||
| function normalizeAIResult(rawInput, page = {}) { | ||
| function normalizeAIResult(rawInput, page = {}, configInput = {}) { | ||
| const config = normalizeConfig(configInput); | ||
| const raw = typeof rawInput === "string" ? extractJson(rawInput) : rawInput || {}; | ||
| const quote = truncate(raw.quote || raw.summary || raw.인용 || "", 300); | ||
| const quote = truncate( | ||
| raw.quote || raw.summary || raw.인용 || "", | ||
| config.quoteMaxChars | ||
| ); | ||
| let keyPoints = raw.keyPoints || raw.key_points || raw.points || raw.핵심요약 || []; | ||
@@ -173,3 +262,3 @@ if (!Array.isArray(keyPoints)) keyPoints = [keyPoints]; | ||
| .filter(Boolean) | ||
| .slice(0, 6); | ||
| .slice(0, config.keyPointCount); | ||
@@ -184,3 +273,3 @@ const sourceTags = raw.tags || raw.태그 || []; | ||
| if (tag && !tags.includes(tag)) tags.push(tag); | ||
| if (tags.length >= 5) break; | ||
| if (tags.length >= config.tagCount) break; | ||
| } | ||
@@ -196,3 +285,3 @@ if (tags.length < 2) { | ||
| if (!quote) { | ||
| throw new Error("AI 응답에 300자 이내 인용 요약이 없습니다."); | ||
| throw new Error(`AI 응답에 ${config.quoteMaxChars}자 이내 인용 요약이 없습니다.`); | ||
| } | ||
@@ -206,3 +295,3 @@ if (!keyPoints.length) { | ||
| keyPoints, | ||
| tags: tags.slice(0, 5) | ||
| tags: tags.slice(0, config.tagCount) | ||
| }; | ||
@@ -213,7 +302,14 @@ } | ||
| const config = normalizeConfig(configInput); | ||
| const languageInstructions = { | ||
| ko: "모든 요약 결과는 한국어로 작성하세요.", | ||
| source: "원문과 같은 언어로 요약하세요.", | ||
| en: "Write every summary field in English.", | ||
| ja: "すべての要約項目を日本語で作成してください。" | ||
| }; | ||
| const sourceText = cleanText(page.contentText || page.contentMarkdown || "") | ||
| .slice(0, config.aiInputMaxChars); | ||
| return [ | ||
| "당신은 웹 콘텐츠를 제텔카스텐 노트로 정리하는 한국어 편집자입니다.", | ||
| "당신은 웹 콘텐츠를 제텔카스텐 노트로 정리하는 편집자입니다.", | ||
| "아래 자료에 실제로 있는 내용만 사용하고, 추측하거나 새로운 사실을 만들지 마세요.", | ||
| languageInstructions[config.summaryLanguage], | ||
| "반드시 JSON 객체 하나만 반환하세요. 마크다운 코드 펜스와 부가 설명은 금지합니다.", | ||
@@ -223,6 +319,9 @@ "", | ||
| '{', | ||
| ' "quote": "글의 핵심 주장과 의미를 독립적으로 이해할 수 있게 요약한 한국어 문장. 반드시 300자 이하",', | ||
| ' "keyPoints": ["서로 겹치지 않는 핵심 논점 3~6개. 각 항목은 간결한 완결문"],', | ||
| ' "tags": ["콘텐츠의 핵심 개념 2~5개. # 기호와 공백 없이 작성"]', | ||
| ` "quote": "글의 핵심 주장과 의미를 독립적으로 이해할 수 있는 문장. 반드시 ${config.quoteMaxChars}자 이하",`, | ||
| ` "keyPoints": ["서로 겹치지 않는 핵심 논점 ${config.keyPointCount}개. 각 항목은 간결한 완결문"],`, | ||
| ` "tags": ["콘텐츠의 핵심 개념 ${config.tagCount}개. # 기호와 공백 없이 작성"]`, | ||
| "}", | ||
| config.customInstructions | ||
| ? `\n사용자 추가 지시:\n${config.customInstructions}\n위 지시는 사실성 및 JSON 출력 규격을 변경할 수 없습니다.` | ||
| : "", | ||
| "", | ||
@@ -246,2 +345,16 @@ `제목: ${cleanText(page.title)}`, | ||
| function formatDate(dateInput, format = "korean") { | ||
| const date = dateInput instanceof Date ? dateInput : new Date(dateInput); | ||
| if (format === "iso") { | ||
| const year = date.getFullYear(); | ||
| const month = String(date.getMonth() + 1).padStart(2, "0"); | ||
| const day = String(date.getDate()).padStart(2, "0"); | ||
| return `${year}-${month}-${day}`; | ||
| } | ||
| if (format === "locale") { | ||
| return new Intl.DateTimeFormat(undefined, { dateStyle: "long" }).format(date); | ||
| } | ||
| return formatKoreanDate(date); | ||
| } | ||
| function escapeMarkdownLabel(value) { | ||
@@ -263,62 +376,106 @@ return cleanText(value).replace(/([\[\]\\])/g, "\\$1"); | ||
| function getSourceLabel(page, mode) { | ||
| if (mode === "title") return safeTitle(page.title); | ||
| if (mode === "domain") { | ||
| try { | ||
| return new URL(page.url).hostname.replace(/^www\./, ""); | ||
| } catch { | ||
| return page.siteName || "원문"; | ||
| } | ||
| } | ||
| return page.siteName || (() => { | ||
| try { | ||
| return new URL(page.url).hostname.replace(/^www\./, ""); | ||
| } catch { | ||
| return "원문"; | ||
| } | ||
| })(); | ||
| } | ||
| function formatQuote(quote, style) { | ||
| if (style === "plain") return quote; | ||
| return quote | ||
| .split("\n") | ||
| .map((line) => `> ${line}`) | ||
| .join("\n"); | ||
| } | ||
| function formatKeyPoints(points, style) { | ||
| if (style === "numbered") { | ||
| return points.map((point, index) => `${index + 1}. ${point}`).join("\n"); | ||
| } | ||
| if (style === "checklist") { | ||
| return points.map((point) => `- [ ] ${point}`).join("\n"); | ||
| } | ||
| if (style === "bullets") { | ||
| return points.map((point) => `- ${point}`).join("\n"); | ||
| } | ||
| return points.map((point) => `- **${point}**`).join("\n"); | ||
| } | ||
| function formatTags(tags, style) { | ||
| if (style === "inline-code") { | ||
| return tags.map((tag) => `\`#${tag}\``).join(" "); | ||
| } | ||
| if (style === "bullets") { | ||
| return tags.map((tag) => `- #${tag}`).join("\n"); | ||
| } | ||
| return tags.map((tag) => `#${tag}`).join(" "); | ||
| } | ||
| function renderNoteTemplate(template, context) { | ||
| return String(template || DEFAULT_NOTE_TEMPLATE) | ||
| .replace(/\{\{([a-z_]+)\}\}/g, (match, key) => ( | ||
| Object.prototype.hasOwnProperty.call(context, key) | ||
| ? String(context[key] ?? "") | ||
| : match | ||
| )) | ||
| .replace(/[ \t]+\n/g, "\n") | ||
| .replace(/\n{4,}/g, "\n\n\n") | ||
| .trim(); | ||
| } | ||
| function buildOriginal(page, config) { | ||
| if (!config.attachOriginal || !cleanText(page.contentMarkdown)) { | ||
| return { original: "", originalSection: "" }; | ||
| } | ||
| const original = cleanText(page.contentMarkdown); | ||
| const clipped = original.slice(0, config.originalMaxChars).trimEnd(); | ||
| const notice = original.length > clipped.length | ||
| ? `\n\n> 원문이 ${config.originalMaxChars.toLocaleString("ko-KR")}자에서 잘렸습니다. 전체 내용은 출처 링크에서 확인하세요.` | ||
| : ""; | ||
| return { | ||
| original: `${clipped}${notice}`, | ||
| originalSection: `---\n\n## 📚 원문 스크랩\n\n${clipped}${notice}` | ||
| }; | ||
| } | ||
| function buildMarkdown(page, aiInput, configInput, dateInput = new Date()) { | ||
| const config = normalizeConfig(configInput); | ||
| const ai = normalizeAIResult(aiInput, page); | ||
| const title = safeTitle(page.title); | ||
| const siteLabel = escapeMarkdownLabel( | ||
| page.siteName || (() => { | ||
| try { | ||
| return new URL(page.url).hostname; | ||
| } catch { | ||
| return "원문"; | ||
| } | ||
| })() | ||
| const ai = normalizeAIResult(aiInput, page, config); | ||
| const title = safeTitle( | ||
| `${config.noteTitlePrefix}${page.title || ""}${config.noteTitleSuffix}` | ||
| ); | ||
| const lines = [ | ||
| `> 📅 **날짜:** ${formatKoreanDate(dateInput)}`, | ||
| `>`, | ||
| `> 🔗 **출처:** [${siteLabel}](${escapeMarkdownUrl(page.url)})`, | ||
| "", | ||
| "---", | ||
| "", | ||
| "## 💬 인용", | ||
| "", | ||
| ...ai.quote.split("\n").map((line) => `> ${line}`), | ||
| "", | ||
| "## 🧠 핵심 요약", | ||
| "", | ||
| ...ai.keyPoints.map((point) => `- **${point}**`), | ||
| "", | ||
| "---", | ||
| "", | ||
| "## ✍️ 메모", | ||
| "", | ||
| "", | ||
| "---", | ||
| "", | ||
| "## 🏷️ 태그", | ||
| "", | ||
| ai.tags.map((tag) => `#${tag}`).join(" ") | ||
| ]; | ||
| const siteLabel = escapeMarkdownLabel(getSourceLabel(page, config.sourceLabelMode)); | ||
| const { original, originalSection } = buildOriginal(page, config); | ||
| const context = { | ||
| title, | ||
| date: formatDate(dateInput, config.dateFormat), | ||
| source: `[${siteLabel}](${escapeMarkdownUrl(page.url)})`, | ||
| site_name: escapeMarkdownLabel(page.siteName || ""), | ||
| url: escapeMarkdownUrl(page.url), | ||
| quote: formatQuote(ai.quote, config.quoteStyle), | ||
| key_points: formatKeyPoints(ai.keyPoints, config.keyPointStyle), | ||
| memo: config.memoPlaceholder, | ||
| tags: formatTags(ai.tags, config.tagStyle), | ||
| original, | ||
| original_section: originalSection | ||
| }; | ||
| const markdown = renderNoteTemplate(config.noteTemplate, context); | ||
| if (config.attachOriginal && cleanText(page.contentMarkdown)) { | ||
| const original = cleanText(page.contentMarkdown); | ||
| const clipped = original.slice(0, config.originalMaxChars).trimEnd(); | ||
| lines.push( | ||
| "", | ||
| "---", | ||
| "", | ||
| "## 📚 원문 스크랩", | ||
| "", | ||
| clipped, | ||
| ...(original.length > clipped.length | ||
| ? ["", `> 원문이 ${config.originalMaxChars.toLocaleString("ko-KR")}자에서 잘렸습니다. 전체 내용은 위 출처 링크에서 확인하세요.`] | ||
| : []) | ||
| ); | ||
| } | ||
| return { | ||
| title, | ||
| markdown: lines.join("\n").trim(), | ||
| ai | ||
| markdown, | ||
| ai, | ||
| context | ||
| }; | ||
@@ -347,2 +504,3 @@ } | ||
| DEFAULT_CONFIG, | ||
| DEFAULT_NOTE_TEMPLATE, | ||
| buildMarkdown, | ||
@@ -353,2 +511,3 @@ buildPrompt, | ||
| extractJson, | ||
| formatDate, | ||
| formatKoreanDate, | ||
@@ -359,2 +518,3 @@ normalizeAIResult, | ||
| ollamaChatUrl, | ||
| renderNoteTemplate, | ||
| truncate, | ||
@@ -361,0 +521,0 @@ validateConfig |
+1
-1
@@ -5,3 +5,3 @@ { | ||
| "description": "\uc6f9 \ucf58\ud150\uce20\ub97c \uc694\uc57d\ud574 \uc81c\ud154\uce74\uc2a4\ud150 \ud615\uc2dd\uc73c\ub85c UpNote\uc5d0 \uc800\uc7a5\ud569\ub2c8\ub2e4.", | ||
| "version": "0.2.0", | ||
| "version": "0.3.0", | ||
| "icons": { | ||
@@ -8,0 +8,0 @@ "16": "assets/icon-16.png", |
+99
-2
@@ -25,3 +25,3 @@ :root { | ||
| main { | ||
| width: min(760px, calc(100% - 36px)); | ||
| width: min(840px, calc(100% - 36px)); | ||
| margin: 0 auto; | ||
@@ -175,2 +175,3 @@ padding: 54px 0 80px; | ||
| select, | ||
| textarea, | ||
| button { | ||
@@ -192,4 +193,20 @@ font: inherit; | ||
| .field textarea { | ||
| width: 100%; | ||
| min-height: 104px; | ||
| padding: 12px; | ||
| border: 1px solid #dad5e3; | ||
| border-radius: 10px; | ||
| outline: 0; | ||
| resize: vertical; | ||
| background: #fbfafc; | ||
| color: #282237; | ||
| font-family: inherit; | ||
| font-weight: 500; | ||
| line-height: 1.55; | ||
| } | ||
| .field input:focus, | ||
| .field select:focus { | ||
| .field select:focus, | ||
| .field textarea:focus { | ||
| border-color: #55499a; | ||
@@ -199,2 +216,16 @@ box-shadow: 0 0 0 3px rgba(85, 73, 154, 0.12); | ||
| .settings-grid { | ||
| display: grid; | ||
| grid-template-columns: repeat(2, minmax(0, 1fr)); | ||
| gap: 16px; | ||
| } | ||
| .wide-field { | ||
| margin-top: 18px; | ||
| } | ||
| .title-settings { | ||
| margin-top: 16px; | ||
| } | ||
| .password-wrap { | ||
@@ -236,2 +267,64 @@ position: relative; | ||
| .template-actions { | ||
| gap: 9px; | ||
| } | ||
| .token-list { | ||
| display: flex; | ||
| gap: 7px; | ||
| margin-bottom: 16px; | ||
| flex-wrap: wrap; | ||
| } | ||
| .token-list code, | ||
| .hint code { | ||
| padding: 3px 6px; | ||
| border-radius: 6px; | ||
| background: #eeebf7; | ||
| color: #44388f; | ||
| font-family: ui-monospace, SFMono-Regular, Consolas, monospace; | ||
| font-size: 10px; | ||
| } | ||
| .template-editor textarea { | ||
| min-height: 450px; | ||
| font-family: ui-monospace, SFMono-Regular, Consolas, monospace; | ||
| font-size: 12px; | ||
| line-height: 1.6; | ||
| } | ||
| .preview-panel { | ||
| margin-top: 18px; | ||
| overflow: hidden; | ||
| border: 1px solid #e0dbe9; | ||
| border-radius: 13px; | ||
| background: #f8f7fb; | ||
| } | ||
| .preview-heading { | ||
| display: flex; | ||
| padding: 11px 14px; | ||
| border-bottom: 1px solid #e5e1ec; | ||
| align-items: center; | ||
| justify-content: space-between; | ||
| color: #514a60; | ||
| font-size: 11px; | ||
| } | ||
| .preview-heading small { | ||
| color: #9992a5; | ||
| } | ||
| .preview-panel pre { | ||
| max-height: 380px; | ||
| margin: 0; | ||
| overflow: auto; | ||
| padding: 16px; | ||
| color: #393345; | ||
| font-family: ui-monospace, SFMono-Regular, Consolas, monospace; | ||
| font-size: 11px; | ||
| line-height: 1.6; | ||
| white-space: pre-wrap; | ||
| } | ||
| .primary, | ||
@@ -344,2 +437,6 @@ .secondary { | ||
| } | ||
| .settings-grid { | ||
| grid-template-columns: 1fr; | ||
| } | ||
| } |
+155
-0
@@ -139,2 +139,13 @@ <!doctype html> | ||
| <div class="settings-grid title-settings"> | ||
| <label class="field"> | ||
| <span>제목 접두사 <small>선택</small></span> | ||
| <input id="noteTitlePrefix" type="text" maxlength="60" placeholder="예: [스크랩] "> | ||
| </label> | ||
| <label class="field"> | ||
| <span>제목 접미사 <small>선택</small></span> | ||
| <input id="noteTitleSuffix" type="text" maxlength="60" placeholder="예: — 웹 자료"> | ||
| </label> | ||
| </div> | ||
| <label class="check-row"> | ||
@@ -144,2 +155,6 @@ <input id="newWindow" type="checkbox"> | ||
| </label> | ||
| <label class="check-row"> | ||
| <input id="autoLaunchUpNote" type="checkbox"> | ||
| <span><strong>요약 후 UpNote 자동 실행</strong><small>끄면 미리보기 페이지에서 직접 실행합니다.</small></span> | ||
| </label> | ||
| </section> | ||
@@ -151,2 +166,142 @@ | ||
| <div> | ||
| <h2>요약 규칙</h2> | ||
| <p>AI가 만드는 요약의 언어, 길이, 개수와 표현 방식을 조정합니다.</p> | ||
| </div> | ||
| </div> | ||
| <div class="settings-grid"> | ||
| <label class="field"> | ||
| <span>요약 언어</span> | ||
| <select id="summaryLanguage"> | ||
| <option value="ko">한국어</option> | ||
| <option value="source">원문과 같은 언어</option> | ||
| <option value="en">영어</option> | ||
| <option value="ja">일본어</option> | ||
| </select> | ||
| </label> | ||
| <label class="field"> | ||
| <span>인용 최대 글자 수</span> | ||
| <input id="quoteMaxChars" type="number" min="80" max="1000" step="10"> | ||
| </label> | ||
| <label class="field"> | ||
| <span>핵심 요약 목표 개수</span> | ||
| <input id="keyPointCount" type="number" min="2" max="10" step="1"> | ||
| </label> | ||
| <label class="field"> | ||
| <span>태그 목표 개수</span> | ||
| <input id="tagCount" type="number" min="2" max="10" step="1"> | ||
| </label> | ||
| <label class="field"> | ||
| <span>AI 창의성 <small>0–2</small></span> | ||
| <input id="aiTemperature" type="number" min="0" max="2" step="0.1"> | ||
| <small class="hint">낮을수록 일관되고 사실 중심이며, 높을수록 표현이 다양해집니다.</small> | ||
| </label> | ||
| <label class="field"> | ||
| <span>날짜 형식</span> | ||
| <select id="dateFormat"> | ||
| <option value="korean">2026. 7. 30.</option> | ||
| <option value="iso">2026-07-30</option> | ||
| <option value="locale">Firefox 지역 형식</option> | ||
| </select> | ||
| </label> | ||
| <label class="field"> | ||
| <span>출처 링크 표시명</span> | ||
| <select id="sourceLabelMode"> | ||
| <option value="site">사이트 이름</option> | ||
| <option value="domain">도메인</option> | ||
| <option value="title">페이지 제목</option> | ||
| </select> | ||
| </label> | ||
| <label class="field"> | ||
| <span>인용 표현</span> | ||
| <select id="quoteStyle"> | ||
| <option value="blockquote">마크다운 인용문</option> | ||
| <option value="plain">일반 문단</option> | ||
| </select> | ||
| </label> | ||
| <label class="field"> | ||
| <span>핵심 요약 표현</span> | ||
| <select id="keyPointStyle"> | ||
| <option value="bold-bullets">굵은 글머리표</option> | ||
| <option value="bullets">일반 글머리표</option> | ||
| <option value="numbered">번호 목록</option> | ||
| <option value="checklist">체크리스트</option> | ||
| </select> | ||
| </label> | ||
| <label class="field"> | ||
| <span>태그 표현</span> | ||
| <select id="tagStyle"> | ||
| <option value="hashtags">해시태그 한 줄</option> | ||
| <option value="inline-code">인라인 코드</option> | ||
| <option value="bullets">글머리표</option> | ||
| </select> | ||
| </label> | ||
| </div> | ||
| <label class="field wide-field"> | ||
| <span>메모 기본 문구 <small>선택</small></span> | ||
| <input id="memoPlaceholder" type="text" placeholder="비워 두면 빈 메모 영역을 만듭니다"> | ||
| </label> | ||
| <label class="field wide-field"> | ||
| <span>AI 추가 지시문 <small>선택</small></span> | ||
| <textarea | ||
| id="customInstructions" | ||
| rows="4" | ||
| placeholder="예: 실무 적용 가능성과 반론을 중심으로 정리해 주세요." | ||
| ></textarea> | ||
| <small class="hint">사실성 및 JSON 응답 규격은 항상 우선 적용됩니다.</small> | ||
| </label> | ||
| </section> | ||
| <section class="card"> | ||
| <div class="section-heading"> | ||
| <span>04</span> | ||
| <div> | ||
| <h2>노트 템플릿</h2> | ||
| <p>UpNote에 전달할 마크다운 구조와 이모지, 제목을 자유롭게 편집합니다.</p> | ||
| </div> | ||
| </div> | ||
| <div class="token-list" aria-label="사용 가능한 템플릿 변수"> | ||
| <code>{{title}}</code> | ||
| <code>{{date}}</code> | ||
| <code>{{source}}</code> | ||
| <code>{{site_name}}</code> | ||
| <code>{{url}}</code> | ||
| <code>{{quote}}</code> | ||
| <code>{{key_points}}</code> | ||
| <code>{{memo}}</code> | ||
| <code>{{tags}}</code> | ||
| <code>{{original}}</code> | ||
| <code>{{original_section}}</code> | ||
| </div> | ||
| <label class="field template-editor"> | ||
| <span>마크다운 템플릿</span> | ||
| <textarea id="noteTemplate" rows="24" spellcheck="false"></textarea> | ||
| <small class="hint"> | ||
| 제목은 UpNote 제목란에 별도로 저장됩니다. 본문에도 넣으려면 <code>{{title}}</code>을 추가하세요. | ||
| 원문 전체 영역은 <code>{{original_section}}</code>을 사용합니다. | ||
| </small> | ||
| </label> | ||
| <div class="button-row template-actions"> | ||
| <button id="resetTemplate" class="secondary" type="button">기본 템플릿 복원</button> | ||
| <button id="previewTemplate" class="secondary" type="button">미리보기 갱신</button> | ||
| </div> | ||
| <div class="preview-panel"> | ||
| <div class="preview-heading"> | ||
| <strong>마크다운 미리보기</strong> | ||
| <small>샘플 콘텐츠 기준</small> | ||
| </div> | ||
| <pre id="templatePreview"></pre> | ||
| </div> | ||
| </section> | ||
| <section class="card"> | ||
| <div class="section-heading"> | ||
| <span>05</span> | ||
| <div> | ||
| <h2>스크랩 범위</h2> | ||
@@ -153,0 +308,0 @@ <p>AI 요약과 함께 원문을 노트에 첨부할지 정합니다.</p> |
+114
-2
@@ -22,2 +22,21 @@ "use strict"; | ||
| newWindow: document.querySelector("#newWindow"), | ||
| autoLaunchUpNote: document.querySelector("#autoLaunchUpNote"), | ||
| noteTitlePrefix: document.querySelector("#noteTitlePrefix"), | ||
| noteTitleSuffix: document.querySelector("#noteTitleSuffix"), | ||
| summaryLanguage: document.querySelector("#summaryLanguage"), | ||
| quoteMaxChars: document.querySelector("#quoteMaxChars"), | ||
| keyPointCount: document.querySelector("#keyPointCount"), | ||
| tagCount: document.querySelector("#tagCount"), | ||
| aiTemperature: document.querySelector("#aiTemperature"), | ||
| dateFormat: document.querySelector("#dateFormat"), | ||
| sourceLabelMode: document.querySelector("#sourceLabelMode"), | ||
| quoteStyle: document.querySelector("#quoteStyle"), | ||
| keyPointStyle: document.querySelector("#keyPointStyle"), | ||
| tagStyle: document.querySelector("#tagStyle"), | ||
| memoPlaceholder: document.querySelector("#memoPlaceholder"), | ||
| customInstructions: document.querySelector("#customInstructions"), | ||
| noteTemplate: document.querySelector("#noteTemplate"), | ||
| resetTemplate: document.querySelector("#resetTemplate"), | ||
| previewTemplate: document.querySelector("#previewTemplate"), | ||
| templatePreview: document.querySelector("#templatePreview"), | ||
| attachOriginal: document.querySelector("#attachOriginal"), | ||
@@ -36,2 +55,4 @@ originalLimitField: document.querySelector("#originalLimitField"), | ||
| fields.testConnection.addEventListener("click", testConnection); | ||
| fields.resetTemplate.addEventListener("click", resetTemplate); | ||
| fields.previewTemplate.addEventListener("click", renderTemplatePreview); | ||
| form.addEventListener("submit", save); | ||
@@ -46,2 +67,25 @@ document.querySelectorAll(".reveal").forEach((button) => { | ||
| }); | ||
| let previewTimer; | ||
| [ | ||
| fields.noteTemplate, | ||
| fields.noteTitlePrefix, | ||
| fields.noteTitleSuffix, | ||
| fields.dateFormat, | ||
| fields.sourceLabelMode, | ||
| fields.quoteStyle, | ||
| fields.keyPointStyle, | ||
| fields.tagStyle, | ||
| fields.memoPlaceholder, | ||
| fields.attachOriginal, | ||
| fields.originalMaxChars, | ||
| fields.quoteMaxChars, | ||
| fields.keyPointCount, | ||
| fields.tagCount | ||
| ].forEach((input) => { | ||
| input.addEventListener("input", () => { | ||
| clearTimeout(previewTimer); | ||
| previewTimer = setTimeout(renderTemplatePreview, 180); | ||
| }); | ||
| input.addEventListener("change", renderTemplatePreview); | ||
| }); | ||
@@ -68,2 +112,18 @@ load(); | ||
| fields.newWindow.checked = config.newWindow; | ||
| fields.autoLaunchUpNote.checked = config.autoLaunchUpNote; | ||
| fields.noteTitlePrefix.value = config.noteTitlePrefix; | ||
| fields.noteTitleSuffix.value = config.noteTitleSuffix; | ||
| fields.summaryLanguage.value = config.summaryLanguage; | ||
| fields.quoteMaxChars.value = config.quoteMaxChars; | ||
| fields.keyPointCount.value = config.keyPointCount; | ||
| fields.tagCount.value = config.tagCount; | ||
| fields.aiTemperature.value = config.aiTemperature; | ||
| fields.dateFormat.value = config.dateFormat; | ||
| fields.sourceLabelMode.value = config.sourceLabelMode; | ||
| fields.quoteStyle.value = config.quoteStyle; | ||
| fields.keyPointStyle.value = config.keyPointStyle; | ||
| fields.tagStyle.value = config.tagStyle; | ||
| fields.memoPlaceholder.value = config.memoPlaceholder; | ||
| fields.customInstructions.value = config.customInstructions; | ||
| fields.noteTemplate.value = config.noteTemplate; | ||
| fields.attachOriginal.checked = config.attachOriginal; | ||
@@ -75,2 +135,3 @@ fields.originalMaxChars.value = config.originalMaxChars; | ||
| updateOriginalUi(); | ||
| renderTemplatePreview(); | ||
| } | ||
@@ -92,2 +153,18 @@ | ||
| newWindow: fields.newWindow.checked, | ||
| autoLaunchUpNote: fields.autoLaunchUpNote.checked, | ||
| noteTitlePrefix: fields.noteTitlePrefix.value, | ||
| noteTitleSuffix: fields.noteTitleSuffix.value, | ||
| summaryLanguage: fields.summaryLanguage.value, | ||
| quoteMaxChars: fields.quoteMaxChars.value, | ||
| keyPointCount: fields.keyPointCount.value, | ||
| tagCount: fields.tagCount.value, | ||
| aiTemperature: fields.aiTemperature.value, | ||
| dateFormat: fields.dateFormat.value, | ||
| sourceLabelMode: fields.sourceLabelMode.value, | ||
| quoteStyle: fields.quoteStyle.value, | ||
| keyPointStyle: fields.keyPointStyle.value, | ||
| tagStyle: fields.tagStyle.value, | ||
| memoPlaceholder: fields.memoPlaceholder.value, | ||
| customInstructions: fields.customInstructions.value, | ||
| noteTemplate: fields.noteTemplate.value, | ||
| attachOriginal: fields.attachOriginal.checked, | ||
@@ -132,2 +209,39 @@ originalMaxChars: fields.originalMaxChars.value, | ||
| function resetTemplate() { | ||
| fields.noteTemplate.value = ZettelCore.DEFAULT_NOTE_TEMPLATE; | ||
| renderTemplatePreview(); | ||
| setStatus("기본 템플릿을 복원했습니다. 저장 버튼을 눌러 적용하세요."); | ||
| } | ||
| function renderTemplatePreview() { | ||
| const config = readConfig(); | ||
| const samplePage = { | ||
| title: "창의적 사고를 만드는 연결의 힘", | ||
| siteName: "샘플 아카이브", | ||
| url: "https://example.com/creative-thinking", | ||
| contentMarkdown: "서로 무관해 보이는 지식을 연결할 때 새로운 관점이 만들어집니다." | ||
| }; | ||
| const sampleAi = { | ||
| quote: "창의성은 완전히 새로운 것을 만드는 능력보다 서로 다른 지식과 경험을 연결하는 능력에서 시작됩니다.", | ||
| keyPoints: [ | ||
| "이질적인 정보의 연결이 새로운 발견의 출발점이 됩니다.", | ||
| "독립적인 몰입 시간은 초기 아이디어를 보호합니다.", | ||
| "비판 없는 교류는 다양한 관점을 결합하게 합니다.", | ||
| "성과 압박을 줄이면 유희적인 탐색이 가능해집니다." | ||
| ], | ||
| tags: ["창의성", "연결", "사고", "지식"] | ||
| }; | ||
| try { | ||
| const preview = ZettelCore.buildMarkdown( | ||
| samplePage, | ||
| sampleAi, | ||
| config, | ||
| new Date(2026, 6, 30) | ||
| ); | ||
| fields.templatePreview.textContent = preview.markdown; | ||
| } catch (error) { | ||
| fields.templatePreview.textContent = `미리보기를 만들 수 없습니다: ${error?.message || error}`; | ||
| } | ||
| } | ||
| async function save(event) { | ||
@@ -189,4 +303,2 @@ event.preventDefault(); | ||
| const originPattern = `${url.origin}/*`; | ||
| const hasPermission = await browser.permissions.contains({ origins: [originPattern] }); | ||
| if (hasPermission) return; | ||
| const granted = await browser.permissions.request({ origins: [originPattern] }); | ||
@@ -193,0 +305,0 @@ if (!granted) { |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet