From a8e1e2ae82b66301458adc1065a4cb562ce2cc89 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 21:46:09 +0000 Subject: [PATCH 1/5] chore(poc): add throwaway paste-image rename prototype Throwaway scratch app for issue 1703. Ports paste-image-rename 1.6.1 name generation and collision suffix so we can pick an interaction before touching QuickAdd source. Co-authored-by: Christian Bager Bach Houmann --- scratch/paste-rename-poc/README.md | 20 ++ scratch/paste-rename-poc/app.js | 258 +++++++++++++++++++++ scratch/paste-rename-poc/engine.js | 236 +++++++++++++++++++ scratch/paste-rename-poc/index.html | 163 +++++++++++++ scratch/paste-rename-poc/package.json | 1 + scratch/paste-rename-poc/poc.css | 301 +++++++++++++++++++++++++ scratch/paste-rename-poc/scenarios.mjs | 127 +++++++++++ 7 files changed, 1106 insertions(+) create mode 100644 scratch/paste-rename-poc/README.md create mode 100644 scratch/paste-rename-poc/app.js create mode 100644 scratch/paste-rename-poc/engine.js create mode 100644 scratch/paste-rename-poc/index.html create mode 100644 scratch/paste-rename-poc/package.json create mode 100644 scratch/paste-rename-poc/poc.css create mode 100644 scratch/paste-rename-poc/scenarios.mjs diff --git a/scratch/paste-rename-poc/README.md b/scratch/paste-rename-poc/README.md new file mode 100644 index 00000000..46e10ff1 --- /dev/null +++ b/scratch/paste-rename-poc/README.md @@ -0,0 +1,20 @@ +# Throwaway POC for issue 1703 + +This folder is not production code. It exists so you can feel four paste-rename interactions and pick one before anyone touches `src/`. + +Open `index.html` in a browser, or run `python3 -m http.server 8765 --bind 127.0.0.1` from this folder and visit `http://127.0.0.1:8765`. + +Run the algorithm against the plugin's own README examples: + +``` +node scenarios.mjs +``` + +The engine is a port of [obsidian-paste-image-rename 1.6.1](https://github.com/reorx/obsidian-paste-image-rename) (`generateNewName`, `deduplicateNewName`, `renderTemplate`). `{{VALUE}}` is the only QuickAdd-only token. + +## Variants + +- **Today.** What QuickAdd already writes: `Clipboard image YYYY-MM-DD HH.MM.SS.png`. +- **Silent title.** Name the file as the capture destination stem at write time. Collision suffix `-1`, `-2`. +- **Confirm modal.** The plugin's default. Save as `Pasted image …`, then a rename dialog. +- **Pattern.** Plugin auto-rename, but at write time, with QuickAdd's `{{VALUE}}` added. diff --git a/scratch/paste-rename-poc/app.js b/scratch/paste-rename-poc/app.js new file mode 100644 index 00000000..aaeaddaf --- /dev/null +++ b/scratch/paste-rename-poc/app.js @@ -0,0 +1,258 @@ +import { deduplicateNewName, planPaste, sanitizerFilename } from "./engine.js"; + +const BLURBS = { + today: + "Today. QuickAdd writes Clipboard image plus a timestamp. paste-image-rename never sees it, because it only auto-hooks files named Pasted image …", + silent: + "Silent title. The file is created as the destination note stem. No second modal. Collision adds -1. This is the issue request, done at write time instead of as a rename.", + confirm: + "Confirm modal. The plugin default. A second dialog stacks on the capture prompt. Enter is already Capture. This is the hostile one.", + pattern: + "Pattern at write. Same tokens as the plugin, plus {{VALUE}} from the prompt. No modal. Empty stems fall back to the timestamp name.", +}; + +const state = { + variant: "today", + files: [], + pending: null, + previewUrl: "", + seq: 0, +}; + +const els = { + tabs: [...document.querySelectorAll("[data-variant]")], + blurb: document.getElementById("variant-blurb"), + fileName: document.getElementById("file-name"), + dirName: document.getElementById("dir-name"), + value: document.getElementById("value"), + imageNameKey: document.getElementById("image-name-key"), + pattern: document.getElementById("pattern"), + patternField: document.getElementById("pattern-field"), + dupAlways: document.getElementById("dup-always"), + field: document.getElementById("capture-field"), + destLabel: document.getElementById("dest-label"), + fileList: document.getElementById("file-list"), + pluginCatch: document.getElementById("plugin-catch"), + pasteSample: document.getElementById("paste-sample"), + reset: document.getElementById("reset"), + overlay: document.getElementById("rename-modal"), + originPath: document.getElementById("origin-path"), + newPath: document.getElementById("new-path"), + renameStem: document.getElementById("rename-stem"), + renameError: document.getElementById("rename-error"), + confirmRename: document.getElementById("confirm-rename"), + cancelRename: document.getElementById("cancel-rename"), + previewImg: document.getElementById("preview-img"), +}; + +function ctx() { + const pattern = + state.variant === "pattern" ? els.pattern.value : "{{fileName}}"; + return { + fileName: els.fileName.value || "Untitled", + dirName: els.dirName.value, + value: els.value.value, + imageNameKey: els.imageNameKey.value, + extension: "png", + existing: state.files.map((f) => f.name), + now: new Date(), + settings: { + imageNamePattern: pattern, + dupNumberAtStart: false, + dupNumberDelimiter: "-", + dupNumberAlways: els.dupAlways.checked, + }, + }; +} + +function setVariant(variant) { + state.variant = variant; + for (const tab of els.tabs) { + tab.setAttribute("aria-selected", String(tab.dataset.variant === variant)); + } + els.blurb.textContent = BLURBS[variant]; + els.patternField.hidden = variant !== "pattern"; + els.destLabel.textContent = `${els.dirName.value}/${els.fileName.value}.md`; +} + +function renderFiles(highlight) { + els.fileList.replaceChildren(); + if (state.files.length === 0) { + const empty = document.createElement("li"); + empty.textContent = "(empty)"; + els.fileList.append(empty); + } + for (const file of state.files) { + const li = document.createElement("li"); + li.textContent = file.name; + if (file.name === highlight) li.classList.add("new"); + els.fileList.append(li); + } +} + +function insertEmbed(path) { + const embed = `![[${path}]]`; + const start = els.field.selectionStart ?? els.field.value.length; + const end = els.field.selectionEnd ?? start; + els.field.setRangeText(embed, start, end, "end"); + els.field.dispatchEvent(new Event("input")); +} + +function rewriteLastEmbed(fromPath, toPath) { + const from = `![[${fromPath}]]`; + const to = `![[${toPath}]]`; + if (els.field.value.includes(from)) { + els.field.value = els.field.value.replace(from, to); + } else { + insertEmbed(toPath); + } +} + +function showCatch(plan) { + const el = els.pluginCatch; + if (plan.pluginWouldCatch.catch) { + el.className = "catch hit"; + el.textContent = + plan.pluginWouldCatch.reason === "prefix" + ? "paste-image-rename would catch this (Pasted image prefix)." + : "paste-image-rename would catch this only because Handle all attachments is on."; + return; + } + el.className = "catch miss"; + el.textContent = + "paste-image-rename would ignore this. QuickAdd's Clipboard image prefix is not Pasted image …"; +} + +function makePreview() { + const canvas = document.createElement("canvas"); + canvas.width = 640; + canvas.height = 360; + const g = canvas.getContext("2d"); + g.fillStyle = "#1b2430"; + g.fillRect(0, 0, 640, 360); + g.fillStyle = "#7f6df2"; + g.fillRect(0, 0, 640, 8); + g.fillStyle = "#e8e6ff"; + g.font = "28px sans-serif"; + g.fillText("Screenshot " + ++state.seq, 32, 80); + g.fillStyle = "#9aa4b5"; + g.font = "16px sans-serif"; + g.fillText("Destination: " + els.fileName.value, 32, 130); + g.fillText("VALUE: " + els.value.value, 32, 160); + g.fillText("Variant: " + state.variant, 32, 190); + return canvas.toDataURL("image/png"); +} + +function applyPlan(plan, previewUrl) { + if (plan.needsModal) { + state.pending = { plan, previewUrl }; + els.originPath.textContent = `attachments/${plan.originName}`; + els.renameStem.value = plan.stem || ""; + els.newPath.textContent = `attachments/${plan.finalName}`; + els.previewImg.src = previewUrl; + els.renameError.hidden = true; + els.overlay.hidden = false; + els.renameStem.focus(); + els.renameStem.select(); + state.files.push({ name: plan.originName }); + insertEmbed(`attachments/${plan.originName}`); + renderFiles(plan.originName); + showCatch(plan); + return; + } + state.files.push({ name: plan.finalName }); + insertEmbed(plan.path); + renderFiles(plan.finalName); + showCatch(plan); +} + +function pasteOnce() { + const previewUrl = makePreview(); + state.previewUrl = previewUrl; + applyPlan(planPaste(state.variant, ctx()), previewUrl); +} + +function confirmRename() { + const pending = state.pending; + if (!pending) return; + const stem = sanitizerFilename(els.renameStem.value); + if (!stem) { + els.renameError.hidden = false; + return; + } + const siblings = state.files + .map((f) => f.name) + .filter((n) => n !== pending.plan.originName); + const { name } = deduplicateNewName( + `${stem}.png`, + siblings, + ctx().settings, + ); + const idx = state.files.findIndex((f) => f.name === pending.plan.originName); + if (idx !== -1) state.files[idx] = { name }; + rewriteLastEmbed( + `attachments/${pending.plan.originName}`, + `attachments/${name}`, + ); + renderFiles(name); + els.overlay.hidden = true; + state.pending = null; +} + +els.tabs.forEach((tab) => { + tab.addEventListener("click", () => setVariant(tab.dataset.variant)); +}); +window.addEventListener("keydown", (e) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + if (!["1", "2", "3", "4"].includes(e.key) || e.metaKey || e.ctrlKey) return; + if (e.target === els.field && !e.altKey) return; + } + const map = { 1: "today", 2: "silent", 3: "confirm", 4: "pattern" }; + if (map[e.key]) { + e.preventDefault(); + setVariant(map[e.key]); + } +}); + +els.fileName.addEventListener("input", () => { + els.destLabel.textContent = `${els.dirName.value}/${els.fileName.value}.md`; +}); +els.dirName.addEventListener("input", () => { + els.destLabel.textContent = `${els.dirName.value}/${els.fileName.value}.md`; +}); +els.renameStem.addEventListener("input", () => { + const stem = sanitizerFilename(els.renameStem.value); + els.newPath.textContent = `attachments/${stem || "?"}.png`; +}); +els.renameStem.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + confirmRename(); + } +}); + +els.pasteSample.addEventListener("click", pasteOnce); +els.reset.addEventListener("click", () => { + state.files = []; + els.field.value = ""; + els.pluginCatch.textContent = ""; + els.overlay.hidden = true; + state.pending = null; + renderFiles(); +}); +els.confirmRename.addEventListener("click", confirmRename); +els.cancelRename.addEventListener("click", () => { + els.overlay.hidden = true; + state.pending = null; +}); + +els.field.addEventListener("paste", (event) => { + const items = [...(event.clipboardData?.items ?? [])]; + const hasImage = items.some((i) => i.type.startsWith("image/")); + if (!hasImage) return; + event.preventDefault(); + pasteOnce(); +}); + +setVariant("today"); +renderFiles(); diff --git a/scratch/paste-rename-poc/engine.js b/scratch/paste-rename-poc/engine.js new file mode 100644 index 00000000..87a1939e --- /dev/null +++ b/scratch/paste-rename-poc/engine.js @@ -0,0 +1,236 @@ +/** + * Throwaway port of obsidian-paste-image-rename 1.6.1 + * (src/template.ts + generateNewName + deduplicateNewName). + * {{VALUE}} is a QuickAdd-only extra for the Pattern variant. + */ + +const DATE_TMPL = /{{DATE:([^}]+)}}/g; +const FRONTMATTER_TMPL = /{{frontmatter:([^}]+)}}/g; +const FILENAME_NOT_ALLOWED = /[^\p{L}0-9~`!@$&*()\-_=+{};'",<.>? ]/gu; + +export const PASTED_IMAGE_PREFIX = "Pasted image "; +export const QUICKADD_CLIPBOARD_PREFIX = "Clipboard image "; + +export const DEFAULT_SETTINGS = { + imageNamePattern: "{{fileName}}", + dupNumberAtStart: false, + dupNumberDelimiter: "-", + dupNumberAlways: false, +}; + +export function sanitizerFilename(s) { + return s.replace(FILENAME_NOT_ALLOWED, "").trim(); +} + +export function pad2(n) { + return String(n).padStart(2, "0"); +} + +export function formatClipboardAttachmentTimestamp(date) { + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2( + date.getDate(), + )} ${pad2(date.getHours())}.${pad2(date.getMinutes())}.${pad2( + date.getSeconds(), + )}`; +} + +function formatMomentish(date, format) { + const tokens = { + YYYY: String(date.getFullYear()), + MM: pad2(date.getMonth() + 1), + DD: pad2(date.getDate()), + HH: pad2(date.getHours()), + mm: pad2(date.getMinutes()), + ss: pad2(date.getSeconds()), + }; + return format.replace(/YYYY|MM|DD|HH|mm|ss/g, (t) => tokens[t] ?? t); +} + +function replaceOnce(regex, text, replacer) { + regex.lastIndex = 0; + const m = regex.exec(text); + if (!m) return text; + return text.replace(m[0], replacer(m)); +} + +export function renderTemplate(tmpl, data, frontmatter, now) { + let text = tmpl; + let next; + while ((next = replaceOnce(DATE_TMPL, text, (m) => formatMomentish(now, m[1]))) !== text) { + text = next; + } + while ( + (next = replaceOnce(FRONTMATTER_TMPL, text, (m) => { + if (!frontmatter) return ""; + return frontmatter[m[1]] ?? ""; + })) !== text + ) { + text = next; + } + return text + .replace(/{{imageNameKey}}/g, data.imageNameKey ?? "") + .replace(/{{fileName}}/g, data.fileName ?? "") + .replace(/{{dirName}}/g, data.dirName ?? "") + .replace(/{{firstHeading}}/g, data.firstHeading ?? "") + .replace(/{{VALUE}}/g, data.value ?? ""); +} + +export function generateNewName(fileExt, activeFile, settings, extras, now) { + const stem = sanitizerFilename( + renderTemplate( + settings.imageNamePattern, + { + imageNameKey: extras.imageNameKey ?? "", + fileName: activeFile.basename, + dirName: extras.dirName ?? "", + firstHeading: extras.firstHeading ?? "", + value: extras.value ?? "", + }, + extras.frontmatter, + now, + ), + ); + const delim = settings.dupNumberDelimiter || "-"; + const meaningless = new RegExp(`[${delim}\\s]`, "gm"); + return { + stem, + newName: `${stem}.${fileExt}`, + isMeaningful: stem.replace(meaningless, "") !== "", + }; +} + +function escapeRegExp(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function extensionOf(name) { + const i = name.lastIndexOf("."); + return i === -1 ? "" : name.slice(i + 1); +} + +/** + * `existing` is sibling basenames in the attachment folder. + */ +export function deduplicateNewName(newName, existing, settings) { + const newNameExt = extensionOf(newName); + const newNameStem = newName.slice(0, newName.length - newNameExt.length - 1); + const delim = settings.dupNumberDelimiter; + const stemEsc = escapeRegExp(newNameStem); + const delimEsc = escapeRegExp(delim); + const dupNameRegex = settings.dupNumberAtStart + ? new RegExp(`^(?\\d+)${delimEsc}(?${stemEsc})\\.${newNameExt}$`) + : new RegExp( + `^(?${stemEsc})${delimEsc}(?\\d+)\\.${newNameExt}$`, + ); + + const dupNameNumbers = []; + let isNewNameExist = false; + for (const sibling of existing) { + const base = sibling.split("/").pop(); + if (base === newName) { + isNewNameExist = true; + continue; + } + const m = dupNameRegex.exec(base); + if (!m) continue; + dupNameNumbers.push(parseInt(m.groups.number, 10)); + } + + let name = newName; + if (isNewNameExist || settings.dupNumberAlways) { + const newNumber = dupNameNumbers.length > 0 ? Math.max(...dupNameNumbers) + 1 : 1; + name = settings.dupNumberAtStart + ? `${newNumber}${delim}${newNameStem}.${newNameExt}` + : `${newNameStem}${delim}${newNumber}.${newNameExt}`; + } + + return { + name, + stem: name.slice(0, name.length - newNameExt.length - 1), + extension: newNameExt, + }; +} + +export function wouldPasteImageRenameCatch(filename, handleAllAttachments) { + if (filename.startsWith(PASTED_IMAGE_PREFIX)) return { catch: true, reason: "prefix" }; + if (handleAllAttachments) return { catch: true, reason: "handleAllAttachments" }; + return { catch: false, reason: "quickadd-prefix" }; +} + +export function planPaste(variant, ctx) { + const ext = ctx.extension || "png"; + const folder = ctx.attachmentFolder || "attachments"; + const existing = ctx.existing.slice(); + const now = ctx.now ?? new Date(); + const settings = { ...DEFAULT_SETTINGS, ...ctx.settings }; + + if (variant === "today") { + const name = `${QUICKADD_CLIPBOARD_PREFIX}${formatClipboardAttachmentTimestamp(now)}.${ext}`; + return { + originName: name, + finalName: name, + path: `${folder}/${name}`, + namedAt: "write", + needsModal: false, + pluginWouldCatch: wouldPasteImageRenameCatch(name, false), + }; + } + + if (variant === "silent" || variant === "pattern") { + const { stem, newName, isMeaningful } = generateNewName( + ext, + { basename: ctx.fileName }, + settings, + ctx, + now, + ); + if (!isMeaningful) { + const fallback = `${QUICKADD_CLIPBOARD_PREFIX}${formatClipboardAttachmentTimestamp(now)}.${ext}`; + return { + originName: fallback, + finalName: fallback, + finalStem: fallback.slice(0, -ext.length - 1), + path: `${folder}/${fallback}`, + namedAt: "write", + needsModal: false, + isMeaningful: false, + pluginWouldCatch: wouldPasteImageRenameCatch(fallback, false), + }; + } + const { name } = deduplicateNewName(newName, existing, settings); + return { + originName: name, + finalName: name, + finalStem: name.slice(0, -ext.length - 1), + stem, + path: `${folder}/${name}`, + namedAt: "write", + needsModal: false, + isMeaningful: true, + pluginWouldCatch: wouldPasteImageRenameCatch(name, false), + }; + } + + const originName = `${PASTED_IMAGE_PREFIX}${formatClipboardAttachmentTimestamp(now).replace(/[-:. ]/g, "")}.${ext}`; + const { stem, newName, isMeaningful } = generateNewName( + ext, + { basename: ctx.fileName }, + settings, + ctx, + now, + ); + const suggested = isMeaningful + ? deduplicateNewName(newName, existing, settings).name + : ""; + return { + originName, + finalName: suggested || originName, + finalStem: isMeaningful ? suggested.slice(0, -ext.length - 1) : "", + stem: isMeaningful ? stem : "", + path: `${folder}/${suggested || originName}`, + namedAt: "after-create", + needsModal: true, + isMeaningful, + pluginWouldCatch: wouldPasteImageRenameCatch(originName, false), + }; +} diff --git a/scratch/paste-rename-poc/index.html b/scratch/paste-rename-poc/index.html new file mode 100644 index 00000000..e7265db8 --- /dev/null +++ b/scratch/paste-rename-poc/index.html @@ -0,0 +1,163 @@ + + + + + + Paste rename POC · issue 1703 + + + +
+
+

Throwaway · issue 1703

+

How should QuickAdd name a pasted image?

+
+

+ Paste-image-rename watches the vault after Obsidian already wrote + Pasted image …. QuickAdd already owns the write, and the + paste often happens in a prompt before the note exists. Switch variants + and paste. Keys 1–4 also switch. +

+
+ + + +

+ +
+ + + + + + +
+ +
+ + + +
+ +
+

How paste-image-rename actually works

+
    +
  1. + Obsidian writes first. + A native paste in a markdown note creates + Pasted image YYYYMMDDHHMMSS.png via the attachment + folder setting. QuickAdd never uses that prefix. It writes + Clipboard image YYYY-MM-DD HH.MM.SS.png itself. +
  2. +
  3. + A vault.on('create') listener fires. + If the file is older than 1 second it is ignored (startup flood). + Markdown is ignored. Anything whose name starts with + Pasted image is in. Anything else is in only when + Handle all attachments is on. +
  4. +
  5. + The active markdown view supplies the name. + generateNewName reads that file's basename, folder, + first H1, and imageNameKey frontmatter, then renders + the Image name pattern. There is no capture destination. The + active file is the only source. +
  6. +
  7. + Auto rename is off by default. + If the stem is empty, or auto rename is off, a modal opens with + a preview and a New name field. Enter confirms. IME composition is + locked so Enter during pinyin does not submit. +
  8. +
  9. + Rename is a second filesystem op. + fileManager.renameFile then the plugin rewrites the + current editor line, because rename does not always update the + just-inserted link in time. Dedup lists the attachment folder and + adds -N (or a prefix) from the largest existing number. +
  10. +
+

+ Windows paste-from-Explorer keeps the original filename, so the + Pasted image prefix never appears. That is why Handle + all attachments exists. QuickAdd hits the same kind of miss today. + paste-image-rename will not see a QuickAdd paste unless Handle all + attachments is on, and even then it will rename against the + active note, not the capture target. +

+
+ + + + + + diff --git a/scratch/paste-rename-poc/package.json b/scratch/paste-rename-poc/package.json new file mode 100644 index 00000000..8898b262 --- /dev/null +++ b/scratch/paste-rename-poc/package.json @@ -0,0 +1 @@ +{ "type": "module", "private": true } diff --git a/scratch/paste-rename-poc/poc.css b/scratch/paste-rename-poc/poc.css new file mode 100644 index 00000000..3991014c --- /dev/null +++ b/scratch/paste-rename-poc/poc.css @@ -0,0 +1,301 @@ +:root { + --bg: #1e1e1e; + --bg-2: #252525; + --bg-3: #2b2b2b; + --border: #3f3f3f; + --text: #dadada; + --muted: #9b9b9b; + --faint: #6b6b6b; + --accent: #7f6df2; + --accent-hover: #8b7cf5; + --danger: #e46c6c; + --shadow: 0 12px 40px rgb(0 0 0 / 0.45); + font-family: "Inter", "Segoe UI", system-ui, sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + padding: 28px 32px 64px; + max-width: 1180px; +} + +.app-header h1 { + margin: 4px 0 8px; + font-size: 1.55rem; + font-weight: 650; +} + +.eyebrow { + margin: 0; + color: var(--accent); + font-size: 0.75rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.lede, +.variant-blurb, +.how p { + color: var(--muted); + line-height: 1.5; + max-width: 72ch; +} + +code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.88em; + background: var(--bg-3); + padding: 0.05em 0.35em; + border-radius: 4px; +} + +.variants { + display: flex; + gap: 8px; + margin: 22px 0 12px; + flex-wrap: wrap; +} + +.variants button { + background: var(--bg-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 14px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; +} + +.variants button[aria-selected="true"] { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent) inset; +} + +.key { + display: inline-grid; + place-items: center; + width: 1.3em; + height: 1.3em; + border-radius: 4px; + background: var(--bg-3); + font-size: 0.75rem; + color: var(--muted); +} + +.context { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; + margin: 18px 0 22px; +} + +label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.78rem; + color: var(--muted); +} + +input[type="text"], +input:not([type]), +textarea, +#rename-stem { + background: var(--bg-3); + border: 1px solid var(--border); + color: var(--text); + border-radius: 6px; + padding: 8px 10px; + font: inherit; +} + +.check { + flex-direction: row; + align-items: center; + gap: 8px; + padding-top: 18px; +} + +.stage { + display: grid; + grid-template-columns: 1.4fr 0.9fr; + gap: 20px; +} + +.modal-window, +.vault, +.how, +.rename-dialog { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: var(--shadow); +} + +.modal-window, +.vault { + padding: 16px 16px 14px; +} + +.modal-window h2, +.vault h2, +.how h2 { + margin: 0 0 6px; + font-size: 1rem; +} + +.modal-window p { + margin: 0 0 12px; + color: var(--muted); + font-size: 0.85rem; +} + +textarea { + width: 100%; + min-height: 180px; + resize: vertical; + line-height: 1.45; +} + +.modal-actions { + display: flex; + gap: 8px; + margin-top: 12px; + justify-content: flex-end; +} + +button.ghost, +button.cta { + border-radius: 6px; + padding: 7px 12px; + border: 1px solid var(--border); + cursor: pointer; + font: inherit; +} + +button.ghost { + background: var(--bg-3); + color: var(--text); +} + +button.cta { + background: var(--accent); + border-color: var(--accent); + color: white; +} + +button.cta:disabled { + opacity: 0.55; + cursor: default; +} + +.vault ul { + list-style: none; + padding: 0; + margin: 8px 0; +} + +.vault li { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85rem; + padding: 6px 0; + border-bottom: 1px solid var(--border); +} + +.vault li.new { + color: #c4b5fd; +} + +.catch { + font-size: 0.8rem; + color: var(--muted); + line-height: 1.4; +} + +.catch.miss { + color: #e8b86d; +} + +.catch.hit { + color: #8bd49c; +} + +.how { + margin-top: 28px; + padding: 18px 20px 8px; +} + +.flow { + color: var(--text); + line-height: 1.5; + padding-left: 1.2em; +} + +.flow li { + margin-bottom: 10px; +} + +.overlay { + position: fixed; + inset: 0; + background: rgb(0 0 0 / 0.55); + display: grid; + place-items: center; + z-index: 20; +} + +.rename-dialog { + width: min(560px, 92vw); + padding: 18px 18px 14px; +} + +.preview { + display: grid; + place-items: center; + background: #111; + border-radius: 8px; + margin: 10px 0 12px; + min-height: 140px; +} + +.preview img { + max-height: 180px; + max-width: 100%; +} + +dl { + display: grid; + grid-template-columns: 7em 1fr; + gap: 6px 10px; + color: var(--muted); + font-size: 0.85rem; +} + +dt { + font-weight: 600; +} + +dd { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + color: var(--text); +} + +.warn { + color: var(--danger); + font-size: 0.85rem; +} + +@media (max-width: 860px) { + .stage { + grid-template-columns: 1fr; + } +} diff --git a/scratch/paste-rename-poc/scenarios.mjs b/scratch/paste-rename-poc/scenarios.mjs new file mode 100644 index 00000000..87b192f7 --- /dev/null +++ b/scratch/paste-rename-poc/scenarios.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +import { + DEFAULT_SETTINGS, + deduplicateNewName, + generateNewName, + planPaste, + renderTemplate, + wouldPasteImageRenameCatch, +} from "./engine.js"; + +const now = new Date("2026-08-29T21:40:00"); + +function heading(title) { + console.log(""); + console.log(`== ${title}`); +} + +heading("README examples (fileName=My note, imageNameKey=foo)"); +const cases = [ + ["{{fileName}}", "My note"], + ["{{imageNameKey}}", "foo"], + ["{{imageNameKey}}-{{DATE:YYYYMMDD}}", "foo-20260829"], +]; +for (const [pattern, expectStem] of cases) { + const stem = renderTemplate( + pattern, + { fileName: "My note", imageNameKey: "foo", dirName: "", firstHeading: "", value: "" }, + {}, + now, + ); + console.log(` ${pattern} -> ${stem} (readme wants ${expectStem})`); +} + +heading("Collision suffix (plugin default: delimiter -, number at end)"); +let existing = []; +for (let i = 0; i < 3; i++) { + const { newName } = generateNewName( + "png", + { basename: "Meeting notes" }, + DEFAULT_SETTINGS, + { imageNameKey: "", dirName: "Meetings", value: "standup" }, + now, + ); + const { name } = deduplicateNewName(newName, existing, DEFAULT_SETTINGS); + console.log(` paste ${i + 1}: ${name}`); + existing.push(name); +} + +heading("Always-add duplicate number"); +existing = []; +const always = { ...DEFAULT_SETTINGS, dupNumberAlways: true }; +for (let i = 0; i < 2; i++) { + const { newName } = generateNewName( + "png", + { basename: "Meeting notes" }, + always, + { imageNameKey: "", value: "" }, + now, + ); + const { name } = deduplicateNewName(newName, existing, always); + console.log(` paste ${i + 1}: ${name}`); + existing.push(name); +} + +heading("Prefix duplicate numbers"); +existing = []; +const atStart = { ...DEFAULT_SETTINGS, dupNumberAtStart: true }; +for (let i = 0; i < 2; i++) { + const { newName } = generateNewName( + "png", + { basename: "Meeting notes" }, + atStart, + { imageNameKey: "", value: "" }, + now, + ); + const { name } = deduplicateNewName(newName, existing, atStart); + console.log(` paste ${i + 1}: ${name}`); + existing.push(name); +} + +heading("Would paste-image-rename catch QuickAdd's current filename?"); +const today = planPaste("today", { + fileName: "Meeting notes", + extension: "png", + existing: [], + now, + settings: DEFAULT_SETTINGS, + imageNameKey: "", + value: "", +}); +console.log(` QuickAdd writes: ${today.finalName}`); +console.log( + ` plugin catch: ${JSON.stringify(wouldPasteImageRenameCatch(today.finalName, false))}`, +); +console.log( + ` plugin catch if handleAllAttachments: ${JSON.stringify(wouldPasteImageRenameCatch(today.finalName, true))}`, +); + +heading("Four variants, one paste into Capture targeting Meetings/Meeting notes.md"); +for (const variant of ["today", "silent", "confirm", "pattern"]) { + const plan = planPaste(variant, { + fileName: "Meeting notes", + extension: "png", + existing: ["Meeting notes.png"], + now, + settings: { + ...DEFAULT_SETTINGS, + imageNamePattern: variant === "pattern" ? "{{VALUE}}-{{DATE:YYYY-MM-DD}}" : "{{fileName}}", + }, + imageNameKey: "my-blog", + value: "standup", + dirName: "Meetings", + }); + console.log( + ` ${variant}: ${plan.originName} -> ${plan.finalName} namedAt=${plan.namedAt} modal=${plan.needsModal}`, + ); +} + +heading("Meaningless pattern falls back ({{imageNameKey}} with empty key)"); +const empty = generateNewName( + "png", + { basename: "Meeting notes" }, + { ...DEFAULT_SETTINGS, imageNamePattern: "{{imageNameKey}}" }, + { imageNameKey: "", value: "" }, + now, +); +console.log(` stem=${JSON.stringify(empty.stem)} meaningful=${empty.isMeaningful}`); From 4fa7f1d28cfce363b46c06b8a0794cf75e907f58 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 22:08:52 +0000 Subject: [PATCH 2/5] fix(poc): honor hidden on the rename overlay Author CSS display:grid on .overlay beat the hidden attribute, so the confirm dialog stayed on screen and Cancel did nothing. Co-authored-by: Christian Bager Bach Houmann --- scratch/paste-rename-poc/app.js | 20 ++++++++++++++------ scratch/paste-rename-poc/poc.css | 5 ++++- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/scratch/paste-rename-poc/app.js b/scratch/paste-rename-poc/app.js index aaeaddaf..2573df88 100644 --- a/scratch/paste-rename-poc/app.js +++ b/scratch/paste-rename-poc/app.js @@ -143,6 +143,12 @@ function makePreview() { return canvas.toDataURL("image/png"); } +function hideOverlay() { + els.overlay.hidden = true; + els.overlay.style.removeProperty("display"); + state.pending = null; +} + function applyPlan(plan, previewUrl) { if (plan.needsModal) { state.pending = { plan, previewUrl }; @@ -152,6 +158,7 @@ function applyPlan(plan, previewUrl) { els.previewImg.src = previewUrl; els.renameError.hidden = true; els.overlay.hidden = false; + els.overlay.style.removeProperty("display"); els.renameStem.focus(); els.renameStem.select(); state.files.push({ name: plan.originName }); @@ -195,8 +202,7 @@ function confirmRename() { `attachments/${name}`, ); renderFiles(name); - els.overlay.hidden = true; - state.pending = null; + hideOverlay(); } els.tabs.forEach((tab) => { @@ -229,6 +235,10 @@ els.renameStem.addEventListener("keydown", (e) => { e.preventDefault(); confirmRename(); } + if (e.key === "Escape") { + e.preventDefault(); + hideOverlay(); + } }); els.pasteSample.addEventListener("click", pasteOnce); @@ -236,14 +246,12 @@ els.reset.addEventListener("click", () => { state.files = []; els.field.value = ""; els.pluginCatch.textContent = ""; - els.overlay.hidden = true; - state.pending = null; + hideOverlay(); renderFiles(); }); els.confirmRename.addEventListener("click", confirmRename); els.cancelRename.addEventListener("click", () => { - els.overlay.hidden = true; - state.pending = null; + hideOverlay(); }); els.field.addEventListener("paste", (event) => { diff --git a/scratch/paste-rename-poc/poc.css b/scratch/paste-rename-poc/poc.css index 3991014c..36919f2f 100644 --- a/scratch/paste-rename-poc/poc.css +++ b/scratch/paste-rename-poc/poc.css @@ -247,11 +247,14 @@ button.cta:disabled { position: fixed; inset: 0; background: rgb(0 0 0 / 0.55); - display: grid; place-items: center; z-index: 20; } +.overlay:not([hidden]) { + display: grid; +} + .rename-dialog { width: min(560px, 92vw); padding: 18px 18px 14px; From f0543b1a6799daf848d5f7ebcc55cbe6c9d031a0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 22:32:20 +0000 Subject: [PATCH 3/5] fix(poc): insert a newline between successive image embeds Co-authored-by: Christian Bager Bach Houmann --- scratch/paste-rename-poc/app.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scratch/paste-rename-poc/app.js b/scratch/paste-rename-poc/app.js index 2573df88..584c332f 100644 --- a/scratch/paste-rename-poc/app.js +++ b/scratch/paste-rename-poc/app.js @@ -94,7 +94,8 @@ function insertEmbed(path) { const embed = `![[${path}]]`; const start = els.field.selectionStart ?? els.field.value.length; const end = els.field.selectionEnd ?? start; - els.field.setRangeText(embed, start, end, "end"); + const prefix = start > 0 && els.field.value[start - 1] !== "\n" ? "\n" : ""; + els.field.setRangeText(prefix + embed, start, end, "end"); els.field.dispatchEvent(new Event("input")); } From a2c4686c2325c12d4294d62e195a9da6c25d709b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 16:33:31 +0000 Subject: [PATCH 4/5] chore: remove the throwaway paste-rename HTML prototype The interaction belongs in QuickAdd and Obsidian, not a scratch app. Co-authored-by: Christian Bager Bach Houmann --- scratch/paste-rename-poc/README.md | 20 -- scratch/paste-rename-poc/app.js | 267 ---------------------- scratch/paste-rename-poc/engine.js | 236 ------------------- scratch/paste-rename-poc/index.html | 163 ------------- scratch/paste-rename-poc/package.json | 1 - scratch/paste-rename-poc/poc.css | 304 ------------------------- scratch/paste-rename-poc/scenarios.mjs | 127 ----------- 7 files changed, 1118 deletions(-) delete mode 100644 scratch/paste-rename-poc/README.md delete mode 100644 scratch/paste-rename-poc/app.js delete mode 100644 scratch/paste-rename-poc/engine.js delete mode 100644 scratch/paste-rename-poc/index.html delete mode 100644 scratch/paste-rename-poc/package.json delete mode 100644 scratch/paste-rename-poc/poc.css delete mode 100644 scratch/paste-rename-poc/scenarios.mjs diff --git a/scratch/paste-rename-poc/README.md b/scratch/paste-rename-poc/README.md deleted file mode 100644 index 46e10ff1..00000000 --- a/scratch/paste-rename-poc/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Throwaway POC for issue 1703 - -This folder is not production code. It exists so you can feel four paste-rename interactions and pick one before anyone touches `src/`. - -Open `index.html` in a browser, or run `python3 -m http.server 8765 --bind 127.0.0.1` from this folder and visit `http://127.0.0.1:8765`. - -Run the algorithm against the plugin's own README examples: - -``` -node scenarios.mjs -``` - -The engine is a port of [obsidian-paste-image-rename 1.6.1](https://github.com/reorx/obsidian-paste-image-rename) (`generateNewName`, `deduplicateNewName`, `renderTemplate`). `{{VALUE}}` is the only QuickAdd-only token. - -## Variants - -- **Today.** What QuickAdd already writes: `Clipboard image YYYY-MM-DD HH.MM.SS.png`. -- **Silent title.** Name the file as the capture destination stem at write time. Collision suffix `-1`, `-2`. -- **Confirm modal.** The plugin's default. Save as `Pasted image …`, then a rename dialog. -- **Pattern.** Plugin auto-rename, but at write time, with QuickAdd's `{{VALUE}}` added. diff --git a/scratch/paste-rename-poc/app.js b/scratch/paste-rename-poc/app.js deleted file mode 100644 index 584c332f..00000000 --- a/scratch/paste-rename-poc/app.js +++ /dev/null @@ -1,267 +0,0 @@ -import { deduplicateNewName, planPaste, sanitizerFilename } from "./engine.js"; - -const BLURBS = { - today: - "Today. QuickAdd writes Clipboard image plus a timestamp. paste-image-rename never sees it, because it only auto-hooks files named Pasted image …", - silent: - "Silent title. The file is created as the destination note stem. No second modal. Collision adds -1. This is the issue request, done at write time instead of as a rename.", - confirm: - "Confirm modal. The plugin default. A second dialog stacks on the capture prompt. Enter is already Capture. This is the hostile one.", - pattern: - "Pattern at write. Same tokens as the plugin, plus {{VALUE}} from the prompt. No modal. Empty stems fall back to the timestamp name.", -}; - -const state = { - variant: "today", - files: [], - pending: null, - previewUrl: "", - seq: 0, -}; - -const els = { - tabs: [...document.querySelectorAll("[data-variant]")], - blurb: document.getElementById("variant-blurb"), - fileName: document.getElementById("file-name"), - dirName: document.getElementById("dir-name"), - value: document.getElementById("value"), - imageNameKey: document.getElementById("image-name-key"), - pattern: document.getElementById("pattern"), - patternField: document.getElementById("pattern-field"), - dupAlways: document.getElementById("dup-always"), - field: document.getElementById("capture-field"), - destLabel: document.getElementById("dest-label"), - fileList: document.getElementById("file-list"), - pluginCatch: document.getElementById("plugin-catch"), - pasteSample: document.getElementById("paste-sample"), - reset: document.getElementById("reset"), - overlay: document.getElementById("rename-modal"), - originPath: document.getElementById("origin-path"), - newPath: document.getElementById("new-path"), - renameStem: document.getElementById("rename-stem"), - renameError: document.getElementById("rename-error"), - confirmRename: document.getElementById("confirm-rename"), - cancelRename: document.getElementById("cancel-rename"), - previewImg: document.getElementById("preview-img"), -}; - -function ctx() { - const pattern = - state.variant === "pattern" ? els.pattern.value : "{{fileName}}"; - return { - fileName: els.fileName.value || "Untitled", - dirName: els.dirName.value, - value: els.value.value, - imageNameKey: els.imageNameKey.value, - extension: "png", - existing: state.files.map((f) => f.name), - now: new Date(), - settings: { - imageNamePattern: pattern, - dupNumberAtStart: false, - dupNumberDelimiter: "-", - dupNumberAlways: els.dupAlways.checked, - }, - }; -} - -function setVariant(variant) { - state.variant = variant; - for (const tab of els.tabs) { - tab.setAttribute("aria-selected", String(tab.dataset.variant === variant)); - } - els.blurb.textContent = BLURBS[variant]; - els.patternField.hidden = variant !== "pattern"; - els.destLabel.textContent = `${els.dirName.value}/${els.fileName.value}.md`; -} - -function renderFiles(highlight) { - els.fileList.replaceChildren(); - if (state.files.length === 0) { - const empty = document.createElement("li"); - empty.textContent = "(empty)"; - els.fileList.append(empty); - } - for (const file of state.files) { - const li = document.createElement("li"); - li.textContent = file.name; - if (file.name === highlight) li.classList.add("new"); - els.fileList.append(li); - } -} - -function insertEmbed(path) { - const embed = `![[${path}]]`; - const start = els.field.selectionStart ?? els.field.value.length; - const end = els.field.selectionEnd ?? start; - const prefix = start > 0 && els.field.value[start - 1] !== "\n" ? "\n" : ""; - els.field.setRangeText(prefix + embed, start, end, "end"); - els.field.dispatchEvent(new Event("input")); -} - -function rewriteLastEmbed(fromPath, toPath) { - const from = `![[${fromPath}]]`; - const to = `![[${toPath}]]`; - if (els.field.value.includes(from)) { - els.field.value = els.field.value.replace(from, to); - } else { - insertEmbed(toPath); - } -} - -function showCatch(plan) { - const el = els.pluginCatch; - if (plan.pluginWouldCatch.catch) { - el.className = "catch hit"; - el.textContent = - plan.pluginWouldCatch.reason === "prefix" - ? "paste-image-rename would catch this (Pasted image prefix)." - : "paste-image-rename would catch this only because Handle all attachments is on."; - return; - } - el.className = "catch miss"; - el.textContent = - "paste-image-rename would ignore this. QuickAdd's Clipboard image prefix is not Pasted image …"; -} - -function makePreview() { - const canvas = document.createElement("canvas"); - canvas.width = 640; - canvas.height = 360; - const g = canvas.getContext("2d"); - g.fillStyle = "#1b2430"; - g.fillRect(0, 0, 640, 360); - g.fillStyle = "#7f6df2"; - g.fillRect(0, 0, 640, 8); - g.fillStyle = "#e8e6ff"; - g.font = "28px sans-serif"; - g.fillText("Screenshot " + ++state.seq, 32, 80); - g.fillStyle = "#9aa4b5"; - g.font = "16px sans-serif"; - g.fillText("Destination: " + els.fileName.value, 32, 130); - g.fillText("VALUE: " + els.value.value, 32, 160); - g.fillText("Variant: " + state.variant, 32, 190); - return canvas.toDataURL("image/png"); -} - -function hideOverlay() { - els.overlay.hidden = true; - els.overlay.style.removeProperty("display"); - state.pending = null; -} - -function applyPlan(plan, previewUrl) { - if (plan.needsModal) { - state.pending = { plan, previewUrl }; - els.originPath.textContent = `attachments/${plan.originName}`; - els.renameStem.value = plan.stem || ""; - els.newPath.textContent = `attachments/${plan.finalName}`; - els.previewImg.src = previewUrl; - els.renameError.hidden = true; - els.overlay.hidden = false; - els.overlay.style.removeProperty("display"); - els.renameStem.focus(); - els.renameStem.select(); - state.files.push({ name: plan.originName }); - insertEmbed(`attachments/${plan.originName}`); - renderFiles(plan.originName); - showCatch(plan); - return; - } - state.files.push({ name: plan.finalName }); - insertEmbed(plan.path); - renderFiles(plan.finalName); - showCatch(plan); -} - -function pasteOnce() { - const previewUrl = makePreview(); - state.previewUrl = previewUrl; - applyPlan(planPaste(state.variant, ctx()), previewUrl); -} - -function confirmRename() { - const pending = state.pending; - if (!pending) return; - const stem = sanitizerFilename(els.renameStem.value); - if (!stem) { - els.renameError.hidden = false; - return; - } - const siblings = state.files - .map((f) => f.name) - .filter((n) => n !== pending.plan.originName); - const { name } = deduplicateNewName( - `${stem}.png`, - siblings, - ctx().settings, - ); - const idx = state.files.findIndex((f) => f.name === pending.plan.originName); - if (idx !== -1) state.files[idx] = { name }; - rewriteLastEmbed( - `attachments/${pending.plan.originName}`, - `attachments/${name}`, - ); - renderFiles(name); - hideOverlay(); -} - -els.tabs.forEach((tab) => { - tab.addEventListener("click", () => setVariant(tab.dataset.variant)); -}); -window.addEventListener("keydown", (e) => { - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { - if (!["1", "2", "3", "4"].includes(e.key) || e.metaKey || e.ctrlKey) return; - if (e.target === els.field && !e.altKey) return; - } - const map = { 1: "today", 2: "silent", 3: "confirm", 4: "pattern" }; - if (map[e.key]) { - e.preventDefault(); - setVariant(map[e.key]); - } -}); - -els.fileName.addEventListener("input", () => { - els.destLabel.textContent = `${els.dirName.value}/${els.fileName.value}.md`; -}); -els.dirName.addEventListener("input", () => { - els.destLabel.textContent = `${els.dirName.value}/${els.fileName.value}.md`; -}); -els.renameStem.addEventListener("input", () => { - const stem = sanitizerFilename(els.renameStem.value); - els.newPath.textContent = `attachments/${stem || "?"}.png`; -}); -els.renameStem.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - confirmRename(); - } - if (e.key === "Escape") { - e.preventDefault(); - hideOverlay(); - } -}); - -els.pasteSample.addEventListener("click", pasteOnce); -els.reset.addEventListener("click", () => { - state.files = []; - els.field.value = ""; - els.pluginCatch.textContent = ""; - hideOverlay(); - renderFiles(); -}); -els.confirmRename.addEventListener("click", confirmRename); -els.cancelRename.addEventListener("click", () => { - hideOverlay(); -}); - -els.field.addEventListener("paste", (event) => { - const items = [...(event.clipboardData?.items ?? [])]; - const hasImage = items.some((i) => i.type.startsWith("image/")); - if (!hasImage) return; - event.preventDefault(); - pasteOnce(); -}); - -setVariant("today"); -renderFiles(); diff --git a/scratch/paste-rename-poc/engine.js b/scratch/paste-rename-poc/engine.js deleted file mode 100644 index 87a1939e..00000000 --- a/scratch/paste-rename-poc/engine.js +++ /dev/null @@ -1,236 +0,0 @@ -/** - * Throwaway port of obsidian-paste-image-rename 1.6.1 - * (src/template.ts + generateNewName + deduplicateNewName). - * {{VALUE}} is a QuickAdd-only extra for the Pattern variant. - */ - -const DATE_TMPL = /{{DATE:([^}]+)}}/g; -const FRONTMATTER_TMPL = /{{frontmatter:([^}]+)}}/g; -const FILENAME_NOT_ALLOWED = /[^\p{L}0-9~`!@$&*()\-_=+{};'",<.>? ]/gu; - -export const PASTED_IMAGE_PREFIX = "Pasted image "; -export const QUICKADD_CLIPBOARD_PREFIX = "Clipboard image "; - -export const DEFAULT_SETTINGS = { - imageNamePattern: "{{fileName}}", - dupNumberAtStart: false, - dupNumberDelimiter: "-", - dupNumberAlways: false, -}; - -export function sanitizerFilename(s) { - return s.replace(FILENAME_NOT_ALLOWED, "").trim(); -} - -export function pad2(n) { - return String(n).padStart(2, "0"); -} - -export function formatClipboardAttachmentTimestamp(date) { - return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2( - date.getDate(), - )} ${pad2(date.getHours())}.${pad2(date.getMinutes())}.${pad2( - date.getSeconds(), - )}`; -} - -function formatMomentish(date, format) { - const tokens = { - YYYY: String(date.getFullYear()), - MM: pad2(date.getMonth() + 1), - DD: pad2(date.getDate()), - HH: pad2(date.getHours()), - mm: pad2(date.getMinutes()), - ss: pad2(date.getSeconds()), - }; - return format.replace(/YYYY|MM|DD|HH|mm|ss/g, (t) => tokens[t] ?? t); -} - -function replaceOnce(regex, text, replacer) { - regex.lastIndex = 0; - const m = regex.exec(text); - if (!m) return text; - return text.replace(m[0], replacer(m)); -} - -export function renderTemplate(tmpl, data, frontmatter, now) { - let text = tmpl; - let next; - while ((next = replaceOnce(DATE_TMPL, text, (m) => formatMomentish(now, m[1]))) !== text) { - text = next; - } - while ( - (next = replaceOnce(FRONTMATTER_TMPL, text, (m) => { - if (!frontmatter) return ""; - return frontmatter[m[1]] ?? ""; - })) !== text - ) { - text = next; - } - return text - .replace(/{{imageNameKey}}/g, data.imageNameKey ?? "") - .replace(/{{fileName}}/g, data.fileName ?? "") - .replace(/{{dirName}}/g, data.dirName ?? "") - .replace(/{{firstHeading}}/g, data.firstHeading ?? "") - .replace(/{{VALUE}}/g, data.value ?? ""); -} - -export function generateNewName(fileExt, activeFile, settings, extras, now) { - const stem = sanitizerFilename( - renderTemplate( - settings.imageNamePattern, - { - imageNameKey: extras.imageNameKey ?? "", - fileName: activeFile.basename, - dirName: extras.dirName ?? "", - firstHeading: extras.firstHeading ?? "", - value: extras.value ?? "", - }, - extras.frontmatter, - now, - ), - ); - const delim = settings.dupNumberDelimiter || "-"; - const meaningless = new RegExp(`[${delim}\\s]`, "gm"); - return { - stem, - newName: `${stem}.${fileExt}`, - isMeaningful: stem.replace(meaningless, "") !== "", - }; -} - -function escapeRegExp(s) { - return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function extensionOf(name) { - const i = name.lastIndexOf("."); - return i === -1 ? "" : name.slice(i + 1); -} - -/** - * `existing` is sibling basenames in the attachment folder. - */ -export function deduplicateNewName(newName, existing, settings) { - const newNameExt = extensionOf(newName); - const newNameStem = newName.slice(0, newName.length - newNameExt.length - 1); - const delim = settings.dupNumberDelimiter; - const stemEsc = escapeRegExp(newNameStem); - const delimEsc = escapeRegExp(delim); - const dupNameRegex = settings.dupNumberAtStart - ? new RegExp(`^(?\\d+)${delimEsc}(?${stemEsc})\\.${newNameExt}$`) - : new RegExp( - `^(?${stemEsc})${delimEsc}(?\\d+)\\.${newNameExt}$`, - ); - - const dupNameNumbers = []; - let isNewNameExist = false; - for (const sibling of existing) { - const base = sibling.split("/").pop(); - if (base === newName) { - isNewNameExist = true; - continue; - } - const m = dupNameRegex.exec(base); - if (!m) continue; - dupNameNumbers.push(parseInt(m.groups.number, 10)); - } - - let name = newName; - if (isNewNameExist || settings.dupNumberAlways) { - const newNumber = dupNameNumbers.length > 0 ? Math.max(...dupNameNumbers) + 1 : 1; - name = settings.dupNumberAtStart - ? `${newNumber}${delim}${newNameStem}.${newNameExt}` - : `${newNameStem}${delim}${newNumber}.${newNameExt}`; - } - - return { - name, - stem: name.slice(0, name.length - newNameExt.length - 1), - extension: newNameExt, - }; -} - -export function wouldPasteImageRenameCatch(filename, handleAllAttachments) { - if (filename.startsWith(PASTED_IMAGE_PREFIX)) return { catch: true, reason: "prefix" }; - if (handleAllAttachments) return { catch: true, reason: "handleAllAttachments" }; - return { catch: false, reason: "quickadd-prefix" }; -} - -export function planPaste(variant, ctx) { - const ext = ctx.extension || "png"; - const folder = ctx.attachmentFolder || "attachments"; - const existing = ctx.existing.slice(); - const now = ctx.now ?? new Date(); - const settings = { ...DEFAULT_SETTINGS, ...ctx.settings }; - - if (variant === "today") { - const name = `${QUICKADD_CLIPBOARD_PREFIX}${formatClipboardAttachmentTimestamp(now)}.${ext}`; - return { - originName: name, - finalName: name, - path: `${folder}/${name}`, - namedAt: "write", - needsModal: false, - pluginWouldCatch: wouldPasteImageRenameCatch(name, false), - }; - } - - if (variant === "silent" || variant === "pattern") { - const { stem, newName, isMeaningful } = generateNewName( - ext, - { basename: ctx.fileName }, - settings, - ctx, - now, - ); - if (!isMeaningful) { - const fallback = `${QUICKADD_CLIPBOARD_PREFIX}${formatClipboardAttachmentTimestamp(now)}.${ext}`; - return { - originName: fallback, - finalName: fallback, - finalStem: fallback.slice(0, -ext.length - 1), - path: `${folder}/${fallback}`, - namedAt: "write", - needsModal: false, - isMeaningful: false, - pluginWouldCatch: wouldPasteImageRenameCatch(fallback, false), - }; - } - const { name } = deduplicateNewName(newName, existing, settings); - return { - originName: name, - finalName: name, - finalStem: name.slice(0, -ext.length - 1), - stem, - path: `${folder}/${name}`, - namedAt: "write", - needsModal: false, - isMeaningful: true, - pluginWouldCatch: wouldPasteImageRenameCatch(name, false), - }; - } - - const originName = `${PASTED_IMAGE_PREFIX}${formatClipboardAttachmentTimestamp(now).replace(/[-:. ]/g, "")}.${ext}`; - const { stem, newName, isMeaningful } = generateNewName( - ext, - { basename: ctx.fileName }, - settings, - ctx, - now, - ); - const suggested = isMeaningful - ? deduplicateNewName(newName, existing, settings).name - : ""; - return { - originName, - finalName: suggested || originName, - finalStem: isMeaningful ? suggested.slice(0, -ext.length - 1) : "", - stem: isMeaningful ? stem : "", - path: `${folder}/${suggested || originName}`, - namedAt: "after-create", - needsModal: true, - isMeaningful, - pluginWouldCatch: wouldPasteImageRenameCatch(originName, false), - }; -} diff --git a/scratch/paste-rename-poc/index.html b/scratch/paste-rename-poc/index.html deleted file mode 100644 index e7265db8..00000000 --- a/scratch/paste-rename-poc/index.html +++ /dev/null @@ -1,163 +0,0 @@ - - - - - - Paste rename POC · issue 1703 - - - -
-
-

Throwaway · issue 1703

-

How should QuickAdd name a pasted image?

-
-

- Paste-image-rename watches the vault after Obsidian already wrote - Pasted image …. QuickAdd already owns the write, and the - paste often happens in a prompt before the note exists. Switch variants - and paste. Keys 1–4 also switch. -

-
- - - -

- -
- - - - - - -
- -
- - - -
- -
-

How paste-image-rename actually works

-
    -
  1. - Obsidian writes first. - A native paste in a markdown note creates - Pasted image YYYYMMDDHHMMSS.png via the attachment - folder setting. QuickAdd never uses that prefix. It writes - Clipboard image YYYY-MM-DD HH.MM.SS.png itself. -
  2. -
  3. - A vault.on('create') listener fires. - If the file is older than 1 second it is ignored (startup flood). - Markdown is ignored. Anything whose name starts with - Pasted image is in. Anything else is in only when - Handle all attachments is on. -
  4. -
  5. - The active markdown view supplies the name. - generateNewName reads that file's basename, folder, - first H1, and imageNameKey frontmatter, then renders - the Image name pattern. There is no capture destination. The - active file is the only source. -
  6. -
  7. - Auto rename is off by default. - If the stem is empty, or auto rename is off, a modal opens with - a preview and a New name field. Enter confirms. IME composition is - locked so Enter during pinyin does not submit. -
  8. -
  9. - Rename is a second filesystem op. - fileManager.renameFile then the plugin rewrites the - current editor line, because rename does not always update the - just-inserted link in time. Dedup lists the attachment folder and - adds -N (or a prefix) from the largest existing number. -
  10. -
-

- Windows paste-from-Explorer keeps the original filename, so the - Pasted image prefix never appears. That is why Handle - all attachments exists. QuickAdd hits the same kind of miss today. - paste-image-rename will not see a QuickAdd paste unless Handle all - attachments is on, and even then it will rename against the - active note, not the capture target. -

-
- - - - - - diff --git a/scratch/paste-rename-poc/package.json b/scratch/paste-rename-poc/package.json deleted file mode 100644 index 8898b262..00000000 --- a/scratch/paste-rename-poc/package.json +++ /dev/null @@ -1 +0,0 @@ -{ "type": "module", "private": true } diff --git a/scratch/paste-rename-poc/poc.css b/scratch/paste-rename-poc/poc.css deleted file mode 100644 index 36919f2f..00000000 --- a/scratch/paste-rename-poc/poc.css +++ /dev/null @@ -1,304 +0,0 @@ -:root { - --bg: #1e1e1e; - --bg-2: #252525; - --bg-3: #2b2b2b; - --border: #3f3f3f; - --text: #dadada; - --muted: #9b9b9b; - --faint: #6b6b6b; - --accent: #7f6df2; - --accent-hover: #8b7cf5; - --danger: #e46c6c; - --shadow: 0 12px 40px rgb(0 0 0 / 0.45); - font-family: "Inter", "Segoe UI", system-ui, sans-serif; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - background: var(--bg); - color: var(--text); - padding: 28px 32px 64px; - max-width: 1180px; -} - -.app-header h1 { - margin: 4px 0 8px; - font-size: 1.55rem; - font-weight: 650; -} - -.eyebrow { - margin: 0; - color: var(--accent); - font-size: 0.75rem; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.lede, -.variant-blurb, -.how p { - color: var(--muted); - line-height: 1.5; - max-width: 72ch; -} - -code { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 0.88em; - background: var(--bg-3); - padding: 0.05em 0.35em; - border-radius: 4px; -} - -.variants { - display: flex; - gap: 8px; - margin: 22px 0 12px; - flex-wrap: wrap; -} - -.variants button { - background: var(--bg-2); - color: var(--text); - border: 1px solid var(--border); - border-radius: 8px; - padding: 8px 14px; - cursor: pointer; - display: flex; - align-items: center; - gap: 8px; -} - -.variants button[aria-selected="true"] { - border-color: var(--accent); - box-shadow: 0 0 0 1px var(--accent) inset; -} - -.key { - display: inline-grid; - place-items: center; - width: 1.3em; - height: 1.3em; - border-radius: 4px; - background: var(--bg-3); - font-size: 0.75rem; - color: var(--muted); -} - -.context { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); - gap: 12px; - margin: 18px 0 22px; -} - -label { - display: flex; - flex-direction: column; - gap: 6px; - font-size: 0.78rem; - color: var(--muted); -} - -input[type="text"], -input:not([type]), -textarea, -#rename-stem { - background: var(--bg-3); - border: 1px solid var(--border); - color: var(--text); - border-radius: 6px; - padding: 8px 10px; - font: inherit; -} - -.check { - flex-direction: row; - align-items: center; - gap: 8px; - padding-top: 18px; -} - -.stage { - display: grid; - grid-template-columns: 1.4fr 0.9fr; - gap: 20px; -} - -.modal-window, -.vault, -.how, -.rename-dialog { - background: var(--bg-2); - border: 1px solid var(--border); - border-radius: 12px; - box-shadow: var(--shadow); -} - -.modal-window, -.vault { - padding: 16px 16px 14px; -} - -.modal-window h2, -.vault h2, -.how h2 { - margin: 0 0 6px; - font-size: 1rem; -} - -.modal-window p { - margin: 0 0 12px; - color: var(--muted); - font-size: 0.85rem; -} - -textarea { - width: 100%; - min-height: 180px; - resize: vertical; - line-height: 1.45; -} - -.modal-actions { - display: flex; - gap: 8px; - margin-top: 12px; - justify-content: flex-end; -} - -button.ghost, -button.cta { - border-radius: 6px; - padding: 7px 12px; - border: 1px solid var(--border); - cursor: pointer; - font: inherit; -} - -button.ghost { - background: var(--bg-3); - color: var(--text); -} - -button.cta { - background: var(--accent); - border-color: var(--accent); - color: white; -} - -button.cta:disabled { - opacity: 0.55; - cursor: default; -} - -.vault ul { - list-style: none; - padding: 0; - margin: 8px 0; -} - -.vault li { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 0.85rem; - padding: 6px 0; - border-bottom: 1px solid var(--border); -} - -.vault li.new { - color: #c4b5fd; -} - -.catch { - font-size: 0.8rem; - color: var(--muted); - line-height: 1.4; -} - -.catch.miss { - color: #e8b86d; -} - -.catch.hit { - color: #8bd49c; -} - -.how { - margin-top: 28px; - padding: 18px 20px 8px; -} - -.flow { - color: var(--text); - line-height: 1.5; - padding-left: 1.2em; -} - -.flow li { - margin-bottom: 10px; -} - -.overlay { - position: fixed; - inset: 0; - background: rgb(0 0 0 / 0.55); - place-items: center; - z-index: 20; -} - -.overlay:not([hidden]) { - display: grid; -} - -.rename-dialog { - width: min(560px, 92vw); - padding: 18px 18px 14px; -} - -.preview { - display: grid; - place-items: center; - background: #111; - border-radius: 8px; - margin: 10px 0 12px; - min-height: 140px; -} - -.preview img { - max-height: 180px; - max-width: 100%; -} - -dl { - display: grid; - grid-template-columns: 7em 1fr; - gap: 6px 10px; - color: var(--muted); - font-size: 0.85rem; -} - -dt { - font-weight: 600; -} - -dd { - margin: 0; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - color: var(--text); -} - -.warn { - color: var(--danger); - font-size: 0.85rem; -} - -@media (max-width: 860px) { - .stage { - grid-template-columns: 1fr; - } -} diff --git a/scratch/paste-rename-poc/scenarios.mjs b/scratch/paste-rename-poc/scenarios.mjs deleted file mode 100644 index 87b192f7..00000000 --- a/scratch/paste-rename-poc/scenarios.mjs +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env node -import { - DEFAULT_SETTINGS, - deduplicateNewName, - generateNewName, - planPaste, - renderTemplate, - wouldPasteImageRenameCatch, -} from "./engine.js"; - -const now = new Date("2026-08-29T21:40:00"); - -function heading(title) { - console.log(""); - console.log(`== ${title}`); -} - -heading("README examples (fileName=My note, imageNameKey=foo)"); -const cases = [ - ["{{fileName}}", "My note"], - ["{{imageNameKey}}", "foo"], - ["{{imageNameKey}}-{{DATE:YYYYMMDD}}", "foo-20260829"], -]; -for (const [pattern, expectStem] of cases) { - const stem = renderTemplate( - pattern, - { fileName: "My note", imageNameKey: "foo", dirName: "", firstHeading: "", value: "" }, - {}, - now, - ); - console.log(` ${pattern} -> ${stem} (readme wants ${expectStem})`); -} - -heading("Collision suffix (plugin default: delimiter -, number at end)"); -let existing = []; -for (let i = 0; i < 3; i++) { - const { newName } = generateNewName( - "png", - { basename: "Meeting notes" }, - DEFAULT_SETTINGS, - { imageNameKey: "", dirName: "Meetings", value: "standup" }, - now, - ); - const { name } = deduplicateNewName(newName, existing, DEFAULT_SETTINGS); - console.log(` paste ${i + 1}: ${name}`); - existing.push(name); -} - -heading("Always-add duplicate number"); -existing = []; -const always = { ...DEFAULT_SETTINGS, dupNumberAlways: true }; -for (let i = 0; i < 2; i++) { - const { newName } = generateNewName( - "png", - { basename: "Meeting notes" }, - always, - { imageNameKey: "", value: "" }, - now, - ); - const { name } = deduplicateNewName(newName, existing, always); - console.log(` paste ${i + 1}: ${name}`); - existing.push(name); -} - -heading("Prefix duplicate numbers"); -existing = []; -const atStart = { ...DEFAULT_SETTINGS, dupNumberAtStart: true }; -for (let i = 0; i < 2; i++) { - const { newName } = generateNewName( - "png", - { basename: "Meeting notes" }, - atStart, - { imageNameKey: "", value: "" }, - now, - ); - const { name } = deduplicateNewName(newName, existing, atStart); - console.log(` paste ${i + 1}: ${name}`); - existing.push(name); -} - -heading("Would paste-image-rename catch QuickAdd's current filename?"); -const today = planPaste("today", { - fileName: "Meeting notes", - extension: "png", - existing: [], - now, - settings: DEFAULT_SETTINGS, - imageNameKey: "", - value: "", -}); -console.log(` QuickAdd writes: ${today.finalName}`); -console.log( - ` plugin catch: ${JSON.stringify(wouldPasteImageRenameCatch(today.finalName, false))}`, -); -console.log( - ` plugin catch if handleAllAttachments: ${JSON.stringify(wouldPasteImageRenameCatch(today.finalName, true))}`, -); - -heading("Four variants, one paste into Capture targeting Meetings/Meeting notes.md"); -for (const variant of ["today", "silent", "confirm", "pattern"]) { - const plan = planPaste(variant, { - fileName: "Meeting notes", - extension: "png", - existing: ["Meeting notes.png"], - now, - settings: { - ...DEFAULT_SETTINGS, - imageNamePattern: variant === "pattern" ? "{{VALUE}}-{{DATE:YYYY-MM-DD}}" : "{{fileName}}", - }, - imageNameKey: "my-blog", - value: "standup", - dirName: "Meetings", - }); - console.log( - ` ${variant}: ${plan.originName} -> ${plan.finalName} namedAt=${plan.namedAt} modal=${plan.needsModal}`, - ); -} - -heading("Meaningless pattern falls back ({{imageNameKey}} with empty key)"); -const empty = generateNewName( - "png", - { basename: "Meeting notes" }, - { ...DEFAULT_SETTINGS, imageNamePattern: "{{imageNameKey}}" }, - { imageNameKey: "", value: "" }, - now, -); -console.log(` stem=${JSON.stringify(empty.stem)} meaningful=${empty.isMeaningful}`); From b064927c598dd3056e03faadfd7fed6b20df1c61 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 16:33:31 +0000 Subject: [PATCH 5/5] feat: name pasted images after the destination note Add a setting that names clipboard images from the capture destination stem at write time. Unknown destinations keep the timestamp name. Collisions stay with getAvailablePathForAttachment. The CLI command quickadd:save-clipboard-image exercises the same path. Co-authored-by: Christian Bager Bach Houmann --- docs/src/content/docs/docs/Advanced/CLI.md | 13 +++ docs/src/content/docs/docs/FormatSyntax.md | 6 +- docs/src/content/docs/docs/Settings.md | 1 + src/cli/registerQuickAddCliHandlers.test.ts | 1 + src/cli/registerQuickAddCliHandlers.ts | 12 +++ src/cli/saveClipboardImageCli.test.ts | 88 ++++++++++++++++++ src/cli/saveClipboardImageCli.ts | 89 +++++++++++++++++++ .../captureChoiceFormatter-clipboard.test.ts | 21 +++++ src/gui/imagePasteHandler.test.ts | 23 +++++ src/quickAddSettingsTab.test.ts | 1 + src/quickAddSettingsTab.ts | 5 ++ src/settings.ts | 11 ++- src/utils/clipboardImageAttachments.test.ts | 76 ++++++++++++++++ src/utils/clipboardImageAttachments.ts | 58 +++++++++++- 14 files changed, 399 insertions(+), 6 deletions(-) create mode 100644 src/cli/saveClipboardImageCli.test.ts create mode 100644 src/cli/saveClipboardImageCli.ts diff --git a/docs/src/content/docs/docs/Advanced/CLI.md b/docs/src/content/docs/docs/Advanced/CLI.md index 8825fb40..7e3bfcc1 100644 --- a/docs/src/content/docs/docs/Advanced/CLI.md +++ b/docs/src/content/docs/docs/Advanced/CLI.md @@ -72,6 +72,19 @@ obsidian vault=dev quickadd:run-template \ - The picker (interactive command) only lists templates inside your configured template folder(s); `path=` here is explicit, so any vault file resolves. - Like `quickadd:run`, name collisions on the target note still prompt (the file-exists choice is not a pre-collected input). Under `quickadd:interactive` that prompt is forwarded to you like any other. +### Save a clipboard image: `quickadd:save-clipboard-image` {#quickaddsave-clipboard-image} + +Save a 1x1 PNG through the same path QuickAdd uses when you paste an image into a prompt or when `{{CLIPBOARD}}` falls back to an image. Useful for checking attachment naming without driving a modal. + +```bash +obsidian vault=dev quickadd:save-clipboard-image \ + sourcePath="Meetings/Meeting notes.md" \ + nameAfterNoteTitle=true +``` + +- `sourcePath=` is the note the attachment belongs to (the capture destination). Empty uses vault-root attachment placement and the timestamp name even when title-naming is on. +- `nameAfterNoteTitle=` overrides the **Name pasted images after the note title** setting for this save. Omit it to use the setting. + ## Pass variables to a choice {#passing-variables} QuickAdd's CLI accepts variables three ways: diff --git a/docs/src/content/docs/docs/FormatSyntax.md b/docs/src/content/docs/docs/FormatSyntax.md index 4ab19ba3..ec6d5d42 100644 --- a/docs/src/content/docs/docs/FormatSyntax.md +++ b/docs/src/content/docs/docs/FormatSyntax.md @@ -286,7 +286,9 @@ screenshot or copied image: QuickAdd saves it using Obsidian's attachment settings and inserts an embedded link at the cursor. You can mix typed text and images, and paste more than one. Clipboard text wins over an image when both are present (copying a file in a file manager usually pastes its path as -text). Prompts for file names, folders, capture targets, and +text). Turn on **Name pasted images after the note title** in QuickAdd +settings to name the file after the destination note when that path is known +(otherwise the file stays `Clipboard image YYYY-MM-DD HH.MM.SS`). Prompts for file names, folders, capture targets, and insert-after/before targets never accept image paste, since an embed link would break the path. Pasted attachments are ordinary vault files; cancelling the prompt afterwards does not delete them. @@ -905,6 +907,8 @@ In Capture content, if the clipboard has no text but holds a supported image, QuickAdd saves the image using Obsidian's attachment settings and inserts an embedded link. Text wins when both are present. You can also paste an image straight into a [value prompt](#value) while typing - no placeholder needed. +The **Name pasted images after the note title** setting names those files +after the destination note when QuickAdd already knows that path. ### A template file: `{{TEMPLATE:}}` {#template} diff --git a/docs/src/content/docs/docs/Settings.md b/docs/src/content/docs/docs/Settings.md index 023f69cb..853ab014 100644 --- a/docs/src/content/docs/docs/Settings.md +++ b/docs/src/content/docs/docs/Settings.md @@ -23,6 +23,7 @@ The choice picker is the list you see when you run **QuickAdd: Run**. - **Use multi-line input prompt** - get a large text box for text prompts instead of a single line, so you can write several lines at once. Multi-line prompts submit with Ctrl/Cmd+Enter, and plain Enter adds a newline. See [Controlling Prompts](/docs/ControllingPrompts/#submit-keys). - **Persist input prompt drafts** - don't lose what you typed if you close a prompt by accident. When on, a closed prompt keeps its draft and restores it when you reopen. Drafts last only for the current session. - **Use editor selection as default Capture value** - let a Capture reuse text you already have highlighted. When on, Capture uses the current editor selection as `{{VALUE}}` and may skip the prompt entirely. When off, Capture always asks for `{{VALUE}}`. Individual Capture choices can override this. +- **Name pasted images after the note title** - name clipboard images after the destination note instead of `Clipboard image YYYY-MM-DD HH.MM.SS`. Applies when you paste an image into a prompt whose answer lands in note content, and when Capture's `{{CLIPBOARD}}` falls back to an image. If the destination is not known yet, QuickAdd keeps the timestamp name. Duplicate names follow Obsidian's attachment folder setting. Off by default. - **One-page input for choices** - answer all of a choice's questions in one form up front, instead of one prompt after another. Works with Template and Capture choices, and with Macros whose scripts declare inputs. Template and Capture choices can [override this individually](/docs/Advanced/onePageInputs/#per-choice-override). See [One-page Inputs](/docs/Advanced/onePageInputs/) and [Controlling Prompts](/docs/ControllingPrompts/). - **Date aliases** - set your own shortcodes for natural-language dates, so typing `tm` in a date prompt means `tomorrow`. Write one per line as `alias = phrase`, for example `tm = tomorrow`. **Reset to defaults** restores the built-in aliases. diff --git a/src/cli/registerQuickAddCliHandlers.test.ts b/src/cli/registerQuickAddCliHandlers.test.ts index 03cc5896..d8b2ca35 100644 --- a/src/cli/registerQuickAddCliHandlers.test.ts +++ b/src/cli/registerQuickAddCliHandlers.test.ts @@ -197,6 +197,7 @@ describe("registerQuickAddCliHandlers", () => { "quickadd:check", "quickadd:package-preview", "quickadd:interactive", + "quickadd:save-clipboard-image", ]); }); diff --git a/src/cli/registerQuickAddCliHandlers.ts b/src/cli/registerQuickAddCliHandlers.ts index b1bfd4e4..def77c80 100644 --- a/src/cli/registerQuickAddCliHandlers.ts +++ b/src/cli/registerQuickAddCliHandlers.ts @@ -24,6 +24,11 @@ import type ITemplateChoice from "../types/choices/ITemplateChoice"; import type ICaptureChoice from "../types/choices/ICaptureChoice"; import type IMacroChoice from "../types/choices/IMacroChoice"; import { applyInvocationDate } from "../utils/resolveDateOrigin"; +import { + SAVE_CLIPBOARD_IMAGE_COMMAND, + SAVE_CLIPBOARD_IMAGE_FLAGS, + saveClipboardImageHandler, +} from "./saveClipboardImageCli"; import { analysePackagePreview, readQuickAddPackage, @@ -196,6 +201,7 @@ const CLI_COMMANDS = { check: "quickadd:check", preview: "quickadd:package-preview", interactive: "quickadd:interactive", + saveClipboardImage: SAVE_CLIPBOARD_IMAGE_COMMAND, } as const; const SUPPORTED_LIST_TYPES = new Set(["template", "capture", "macro", "multi"]); @@ -1005,6 +1011,12 @@ export function registerQuickAddCliHandlers(plugin: QuickAdd): boolean { INTERACTIVE_FLAGS, (params: CliData) => interactiveHandler(plugin, params), ); + register( + CLI_COMMANDS.saveClipboardImage, + "Save a 1x1 PNG as a vault attachment using QuickAdd clipboard-image naming", + SAVE_CLIPBOARD_IMAGE_FLAGS, + (params: CliData) => saveClipboardImageHandler(plugin, params), + ); log.logMessage("Registered QuickAdd CLI handlers."); return true; diff --git a/src/cli/saveClipboardImageCli.test.ts b/src/cli/saveClipboardImageCli.test.ts new file mode 100644 index 00000000..1a3d7387 --- /dev/null +++ b/src/cli/saveClipboardImageCli.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TFile } from "obsidian"; +import type QuickAdd from "../main"; +import { + SAVE_CLIPBOARD_IMAGE_COMMAND, + saveClipboardImageHandler, +} from "./saveClipboardImageCli"; + +vi.mock("../utils/clipboardImageAttachments", () => ({ + saveClipboardImageToVault: vi.fn(), +})); + +import { saveClipboardImageToVault } from "../utils/clipboardImageAttachments"; + +const saveMock = vi.mocked(saveClipboardImageToVault); + +function pluginWithSetting(nameAfterNoteTitle: boolean): QuickAdd { + return { + app: {}, + settings: { namePastedImagesAfterNoteTitle: nameAfterNoteTitle }, + } as unknown as QuickAdd; +} + +describe("saveClipboardImageHandler", () => { + it("saves a png named after the destination when the flag is true", async () => { + saveMock.mockResolvedValue({ + path: "attachments/Meeting notes.png", + name: "Meeting notes.png", + } as TFile); + + const payload = JSON.parse( + await saveClipboardImageHandler(pluginWithSetting(false), { + sourcePath: "Meetings/Meeting notes.md", + nameAfterNoteTitle: "true", + }), + ); + + expect(payload).toMatchObject({ + ok: true, + command: SAVE_CLIPBOARD_IMAGE_COMMAND, + path: "attachments/Meeting notes.png", + name: "Meeting notes.png", + sourcePath: "Meetings/Meeting notes.md", + nameAfterNoteTitle: true, + }); + expect(saveMock).toHaveBeenCalledWith( + expect.anything(), + expect.any(ArrayBuffer), + "image/png", + "Meetings/Meeting notes.md", + { nameAfterNoteTitle: true }, + ); + }); + + it("omits the override when the flag is absent so the setting applies", async () => { + saveMock.mockResolvedValue({ + path: "attachments/Clipboard image 2026-08-29 21.40.00.png", + name: "Clipboard image 2026-08-29 21.40.00.png", + } as TFile); + + const payload = JSON.parse( + await saveClipboardImageHandler(pluginWithSetting(false), { + sourcePath: "Meetings/Meeting notes.md", + }), + ); + + expect(payload.ok).toBe(true); + expect(payload.nameAfterNoteTitle).toBe(false); + expect(saveMock).toHaveBeenCalledWith( + expect.anything(), + expect.any(ArrayBuffer), + "image/png", + "Meetings/Meeting notes.md", + undefined, + ); + }); + + it("rejects an invalid nameAfterNoteTitle value", async () => { + const payload = JSON.parse( + await saveClipboardImageHandler(pluginWithSetting(false), { + nameAfterNoteTitle: "maybe", + }), + ); + + expect(payload.ok).toBe(false); + expect(payload.error).toMatch(/Invalid nameAfterNoteTitle/); + }); +}); diff --git a/src/cli/saveClipboardImageCli.ts b/src/cli/saveClipboardImageCli.ts new file mode 100644 index 00000000..13a839b9 --- /dev/null +++ b/src/cli/saveClipboardImageCli.ts @@ -0,0 +1,89 @@ +import type { CliData, CliFlags } from "obsidian"; +import type QuickAdd from "../main"; +import { saveClipboardImageToVault } from "../utils/clipboardImageAttachments"; + +export const SAVE_CLIPBOARD_IMAGE_COMMAND = "quickadd:save-clipboard-image"; + +export const SAVE_CLIPBOARD_IMAGE_FLAGS: CliFlags = { + sourcePath: { + value: "", + description: + "Note path the image will live in (capture destination). Empty keeps a vault-root attachment.", + }, + nameAfterNoteTitle: { + value: "", + description: + "Override the Name pasted images after the note title setting for this save", + }, +}; + +const ONE_PIXEL_PNG = Uint8Array.from( + atob( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + ), + (char) => char.charCodeAt(0), +); + +function parseOptionalBoolean(value: string | undefined): boolean | undefined { + if (value === undefined || value === "") return undefined; + const normalized = value.toLowerCase(); + if ( + normalized === "true" || + normalized === "1" || + normalized === "yes" || + normalized === "on" + ) { + return true; + } + if ( + normalized === "false" || + normalized === "0" || + normalized === "no" || + normalized === "off" + ) { + return false; + } + throw new Error( + `Invalid nameAfterNoteTitle: ${value}. Use true or false.`, + ); +} + +export async function saveClipboardImageHandler( + plugin: QuickAdd, + params: CliData, +): Promise { + try { + const sourcePath = + typeof params.sourcePath === "string" ? params.sourcePath : ""; + const nameAfterNoteTitle = parseOptionalBoolean( + typeof params.nameAfterNoteTitle === "string" + ? params.nameAfterNoteTitle + : undefined, + ); + const file = await saveClipboardImageToVault( + plugin.app, + ONE_PIXEL_PNG.buffer, + "image/png", + sourcePath, + nameAfterNoteTitle === undefined + ? undefined + : { nameAfterNoteTitle }, + ); + return JSON.stringify({ + ok: true, + command: SAVE_CLIPBOARD_IMAGE_COMMAND, + path: file.path, + name: file.name, + sourcePath: sourcePath || "", + nameAfterNoteTitle: + nameAfterNoteTitle ?? + plugin.settings.namePastedImagesAfterNoteTitle, + }); + } catch (error) { + return JSON.stringify({ + ok: false, + command: SAVE_CLIPBOARD_IMAGE_COMMAND, + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/src/formatters/captureChoiceFormatter-clipboard.test.ts b/src/formatters/captureChoiceFormatter-clipboard.test.ts index bbe8040e..4c6f184a 100644 --- a/src/formatters/captureChoiceFormatter-clipboard.test.ts +++ b/src/formatters/captureChoiceFormatter-clipboard.test.ts @@ -103,6 +103,7 @@ vi.mock("../logger/logManager", () => ({ })); import { CaptureChoiceFormatter } from "./captureChoiceFormatter"; +import { settingsStore } from "../settingsStore"; function createTFile(path: string): TFile { const name = path.split("/").pop() ?? path; @@ -240,6 +241,26 @@ describe("CaptureChoiceFormatter clipboard image support", () => { ); }); + it("asks for a destination-title attachment name when the setting is on", async () => { + settingsStore.setState({ namePastedImagesAfterNoteTitle: true }); + try { + const { app, getAvailablePathForAttachment } = createMockApp(); + const item = createClipboardItem("image/png", [1, 2, 3]); + setClipboard({ items: [item] }); + const formatter = createFormatter(app); + formatter.setDestinationSourcePath("Notes/Clip.md"); + + await formatter.formatContentOnly("{{clipboard}}"); + + expect(getAvailablePathForAttachment).toHaveBeenCalledWith( + "Clip.png", + "Notes/Clip.md", + ); + } finally { + settingsStore.setState({ namePastedImagesAfterNoteTitle: false }); + } + }); + it("inserts clipboard text literally when it contains the clipboard token", async () => { const { app } = createMockApp(); setClipboard({ text: "{{clipboard}}" }); diff --git a/src/gui/imagePasteHandler.test.ts b/src/gui/imagePasteHandler.test.ts index 557de8d5..1cc48748 100644 --- a/src/gui/imagePasteHandler.test.ts +++ b/src/gui/imagePasteHandler.test.ts @@ -11,6 +11,7 @@ vi.mock("../logger/logManager", () => ({ })); import { Notice } from "obsidian"; +import { settingsStore } from "../settingsStore"; function makeApp() { const created: string[] = []; @@ -112,6 +113,28 @@ describe("attachImagePasteHandler", () => { ); }); + it("names a pasted image after the destination note when the setting is on", async () => { + settingsStore.setState({ namePastedImagesAfterNoteTitle: true }); + try { + const { app, createBinary } = makeApp(); + const input = makeInput(); + const handle = attachImagePasteHandler(app, input, { + sourcePath: "Meetings/Meeting notes.md", + }); + + dispatchPaste(input, makeClipboardData([makeImageFile()])); + await flushSaves(handle); + + expect(createBinary).toHaveBeenCalledWith( + "attachments/Meeting notes.png", + expect.any(ArrayBuffer), + ); + expect(input.value).toBe("![[attachments/Meeting notes.png]]"); + } finally { + settingsStore.setState({ namePastedImagesAfterNoteTitle: false }); + } + }); + it("fires an input event so component onChange observers update", async () => { const { app } = makeApp(); const input = makeInput(); diff --git a/src/quickAddSettingsTab.test.ts b/src/quickAddSettingsTab.test.ts index a6fdc8ba..f311fe12 100644 --- a/src/quickAddSettingsTab.test.ts +++ b/src/quickAddSettingsTab.test.ts @@ -263,6 +263,7 @@ describe("QuickAddSettingsTab declarative bridge", () => { "inputPrompt", "persistInputPromptDrafts", "useSelectionAsCaptureValue", + "namePastedImagesAfterNoteTitle", "onePageInputEnabled", "enableTemplatePropertyTypes", "announceUpdates", diff --git a/src/quickAddSettingsTab.ts b/src/quickAddSettingsTab.ts index 88d4f554..086d38ff 100644 --- a/src/quickAddSettingsTab.ts +++ b/src/quickAddSettingsTab.ts @@ -235,6 +235,11 @@ export class QuickAddSettingsTab extends PluginSettingTab { desc: "When enabled, Capture uses the current editor selection as {{VALUE}} and may skip the prompt. When disabled, Capture always prompts for {{VALUE}}.", control: { type: "toggle", key: "useSelectionAsCaptureValue" }, }, + { + name: "Name pasted images after the note title", + desc: "When on, clipboard images saved by QuickAdd (pasting into a prompt, or {{CLIPBOARD}} with an image and no text) are named after the destination note. When the destination is not yet known, QuickAdd keeps the timestamp name. Duplicate names are handled by Obsidian's attachment folder setting.", + control: { type: "toggle", key: "namePastedImagesAfterNoteTitle" }, + }, { name: "One-page input for choices", // The trailing sentence used to read "See One-page Inputs in the diff --git a/src/settings.ts b/src/settings.ts index d69a83bf..7e33ecf9 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -11,9 +11,15 @@ export interface QuickAddSettings { inputPrompt: "multi-line" | "single-line"; persistInputPromptDrafts: boolean; /** - * When enabled, Capture uses the current editor selection as the default {{VALUE}}. - */ + * When enabled, Capture uses the current editor selection as the default {{VALUE}}. + */ useSelectionAsCaptureValue: boolean; + /** + * Name clipboard images (prompt paste and {{CLIPBOARD}} image fallback) + * after the destination note when that path is known. Unknown destination + * keeps the timestamp name. Collisions use Obsidian's attachment-folder API. + */ + namePastedImagesAfterNoteTitle: boolean; /** * When enabled, typing in the choice picker also searches choices nested * inside Multi choices and shows matches with their folder path. @@ -111,6 +117,7 @@ export const DEFAULT_SETTINGS: QuickAddSettings = { inputPrompt: "single-line", persistInputPromptDrafts: true, useSelectionAsCaptureValue: true, + namePastedImagesAfterNoteTitle: false, searchNestedChoices: true, templateFolderLauncherRow: "bottom", devMode: false, diff --git a/src/utils/clipboardImageAttachments.test.ts b/src/utils/clipboardImageAttachments.test.ts index e8fa96da..01410815 100644 --- a/src/utils/clipboardImageAttachments.test.ts +++ b/src/utils/clipboardImageAttachments.test.ts @@ -3,7 +3,9 @@ import type { App, TFile } from "obsidian"; import { IMAGE_CLIPBOARD_MIME_EXTENSIONS, buildImageEmbedLink, + clipboardImageAttachmentFileName, formatClipboardAttachmentTimestamp, + sanitizeClipboardImageStem, saveClipboardImageToVault, } from "./clipboardImageAttachments"; @@ -56,6 +58,37 @@ describe("saveClipboardImageToVault", () => { expect(file.path).toMatch(/^attachments\/Clipboard image .*\.png$/); }); + it("names the file after the destination note when asked", async () => { + const { app, getAvailablePathForAttachment } = makeApp(); + + await saveClipboardImageToVault( + app, + data, + "image/png", + "Meetings/Meeting notes.md", + { nameAfterNoteTitle: true }, + ); + + expect(getAvailablePathForAttachment).toHaveBeenCalledWith( + "Meeting notes.png", + "Meetings/Meeting notes.md", + ); + }); + + it("keeps the timestamp name when destination-title naming is on but the path is empty", async () => { + const { app, getAvailablePathForAttachment } = makeApp(); + + await saveClipboardImageToVault(app, data, "image/png", "", { + nameAfterNoteTitle: true, + now: new Date(2026, 7, 29, 21, 40, 0), + }); + + expect(getAvailablePathForAttachment).toHaveBeenCalledWith( + "Clipboard image 2026-08-29 21.40.00.png", + undefined, + ); + }); + it("passes undefined source context when the destination is unknown", async () => { const { app, getAvailablePathForAttachment } = makeApp(); @@ -125,3 +158,46 @@ describe("formatClipboardAttachmentTimestamp", () => { expect(stamp).toBe("2026-07-06 09.05.03"); }); }); + +describe("clipboardImageAttachmentFileName", () => { + const now = new Date(2026, 7, 29, 21, 40, 0); + + it("uses the timestamp name when destination-title naming is off", () => { + expect( + clipboardImageAttachmentFileName({ + extension: "png", + sourcePath: "Meetings/Meeting notes.md", + now, + nameAfterNoteTitle: false, + }), + ).toBe("Clipboard image 2026-08-29 21.40.00.png"); + }); + + it("uses the destination basename when destination-title naming is on", () => { + expect( + clipboardImageAttachmentFileName({ + extension: "png", + sourcePath: "Meetings/Meeting notes.md", + now, + nameAfterNoteTitle: true, + }), + ).toBe("Meeting notes.png"); + }); + + it("falls back to the timestamp when the stem sanitizes to empty", () => { + expect( + clipboardImageAttachmentFileName({ + extension: "png", + sourcePath: "???.md", + now, + nameAfterNoteTitle: true, + }), + ).toBe("Clipboard image 2026-08-29 21.40.00.png"); + }); +}); + +describe("sanitizeClipboardImageStem", () => { + it("strips path separators and Windows-illegal characters", () => { + expect(sanitizeClipboardImageStem('a/b:c*d?e"fh|i')).toBe("abcdefghi"); + }); +}); diff --git a/src/utils/clipboardImageAttachments.ts b/src/utils/clipboardImageAttachments.ts index 4161b769..f7d6c969 100644 --- a/src/utils/clipboardImageAttachments.ts +++ b/src/utils/clipboardImageAttachments.ts @@ -1,4 +1,6 @@ import type { App, TFile } from "obsidian"; +import { settingsStore } from "../settingsStore"; +import { fileBasenameFromPath } from "./fileSyntax"; import { escapesVaultBoundary } from "./vaultPathBoundary"; /** @@ -28,6 +30,49 @@ export function formatClipboardAttachmentTimestamp(date: Date): string { )}`; } +const CLIPBOARD_IMAGE_STEM_FORBIDDEN = /[/\\:*?"<>|#^[\]]/g; + +export function sanitizeClipboardImageStem(stem: string): string { + return stem + .replace(CLIPBOARD_IMAGE_STEM_FORBIDDEN, "") + .replace(/\s+/g, " ") + .replace(/[. ]+$/g, "") + .trim(); +} + +export interface ClipboardImageFileNameInput { + extension: string; + sourcePath: string; + now: Date; + nameAfterNoteTitle: boolean; +} + +/** + * Basename (with extension) passed to `getAvailablePathForAttachment`. + * Destination-title naming only applies when the note path is known and the + * sanitized stem is non-empty; otherwise this keeps the timestamp name. + */ +export function clipboardImageAttachmentFileName( + input: ClipboardImageFileNameInput, +): string { + if (input.nameAfterNoteTitle) { + const stem = sanitizeClipboardImageStem( + fileBasenameFromPath(input.sourcePath), + ); + if (stem.length > 0) { + return `${stem}.${input.extension}`; + } + } + return `Clipboard image ${formatClipboardAttachmentTimestamp(input.now)}.${ + input.extension + }`; +} + +export interface SaveClipboardImageOptions { + nameAfterNoteTitle?: boolean; + now?: Date; +} + /** * Saves clipboard image bytes as a vault attachment and returns the created * file. Link generation is a separate step ({@link buildImageEmbedLink}) so a @@ -47,15 +92,22 @@ export async function saveClipboardImageToVault( data: ArrayBuffer, mimeType: string, sourcePath: string, + options?: SaveClipboardImageOptions, ): Promise { const extension = IMAGE_CLIPBOARD_MIME_EXTENSIONS[mimeType]; if (!extension) { throw new Error(`Unsupported clipboard image type: ${mimeType}`); } - const filename = `Clipboard image ${formatClipboardAttachmentTimestamp( - new Date(), - )}.${extension}`; + const nameAfterNoteTitle = + options?.nameAfterNoteTitle ?? + settingsStore.getState().namePastedImagesAfterNoteTitle; + const filename = clipboardImageAttachmentFileName({ + extension, + sourcePath, + now: options?.now ?? new Date(), + nameAfterNoteTitle, + }); const attachmentPath = await app.fileManager.getAvailablePathForAttachment( filename, sourcePath || undefined,