From 54a416c63ce0b9524b4043e8da43e19d136bda2a Mon Sep 17 00:00:00 2001 From: OpenClaw Sub-agent Date: Sun, 2 Aug 2026 17:28:25 +1000 Subject: [PATCH] feat(template links): Sketch-style visual parameter builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - link_form: template builder under the URL field — preset param chips (q/id/page/category/keyword/lang, CN labels) click to insert ?q={q} with smart ?/& separators; auto-parsed parameter rows (name + default input + remove); custom params (name + default -> append); live dark preview filling defaults (green) and flagging required (red); chips grey out when used; hidden on Custom/Action tabs; edits existing templates - redirect_to_original: map GET query params (?q=weather) by name into template placeholders, keep path-param backward compat (go/alias/x fills every unfilled param), defaults otherwise; query wins over path - tests: +5 template redirect cases (query map, multi-param, defaults, path compat, query-over-path) --- links/templates/links/link_form.html | 299 +++++++++++++++++++++++++++ links/views.py | 19 +- tests/test_template_redirect.py | 52 +++++ 3 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 tests/test_template_redirect.py diff --git a/links/templates/links/link_form.html b/links/templates/links/link_form.html index 9f75741..a4d6999 100644 --- a/links/templates/links/link_form.html +++ b/links/templates/links/link_form.html @@ -71,6 +71,74 @@ padding: 0 .3rem; font-weight: 600; } .msg-preview-hint { font-size: .72rem; color: #9ca3af; margin-top: .45rem; } + /* ── Sketch-style template parameter builder ── */ + .template-builder { + margin-top: .8rem; + border: 1px solid #e9e5f5; border-radius: 14px; + background: linear-gradient(180deg, #faf9ff 0%, #f7f5fc 100%); + padding: .9rem 1rem; + } + .tpl-label { font-size: .7rem; font-weight: 600; color: #7c6ba6; text-transform: uppercase; letter-spacing: .06em; } + .tpl-hint { font-size: .72rem; color: #9ca3af; margin-top: .2rem; line-height: 1.4; } + .tpl-chip { + display: inline-flex; align-items: center; gap: .35rem; + border: 1px solid #ddd6fe; background: #fff; + color: #6d28d9; border-radius: 980px; + padding: .38rem 1rem; font-size: .88rem; font-weight: 600; + cursor: pointer; transition: all .15s; + } + .tpl-chip:hover { background: #ede9fe; border-color: #a78bfa; box-shadow: 0 1px 4px rgba(109,40,217,.15); } + .tpl-chip:active { transform: scale(.96); } + .tpl-chip .tpl-chip-sub { font-weight: 400; color: #a78bfa; font-size: .78rem; } + .tpl-chip .tpl-chip-name { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } + .tpl-chip.used { opacity: .45; pointer-events: none; } + .tpl-param-list { margin-top: .7rem; display: flex; flex-direction: column; gap: .4rem; } + .tpl-param-row { display: flex; align-items: center; gap: .55rem; } + .tpl-param-name { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + background: #ede9fe; color: #6d28d9; border-radius: 7px; + padding: .2rem .55rem; font-size: .82rem; font-weight: 600; + min-width: 4.5rem; text-align: center; flex-shrink: 0; + } + .tpl-param-default { + flex: 1; max-width: 320px; + border: 1px solid #d1d5db; border-radius: 8px; + padding: .35rem .6rem; font-size: .85rem; color: #374151; + background: #fff; outline: none; + } + .tpl-param-default:focus { border-color: #8b5cf6; box-shadow: 0 0 0 3px rgba(139,92,246,.12); } + .tpl-param-label { font-size: .72rem; color: #9ca3af; width: 3.2rem; flex-shrink: 0; } + .tpl-param-del { + background: none; border: none; cursor: pointer; + color: #c4b5fd; font-size: .8rem; padding: .3rem .45rem; border-radius: 6px; + } + .tpl-param-del:hover { color: #ef4444; background: #fef2f2; } + .tpl-add-row { display: flex; align-items: center; gap: .5rem; margin-top: .75rem; flex-wrap: wrap; } + .tpl-add-row input { + border: 1px solid #d1d5db; border-radius: 8px; + padding: .38rem .65rem; font-size: .85rem; outline: none; background: #fff; + } + .tpl-add-row input:focus { border-color: #8b5cf6; box-shadow: 0 0 0 3px rgba(139,92,246,.12); } + .tpl-add-name { width: 8rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } + .tpl-add-def { width: 10rem; } + .tpl-add-btn { + border: none; background: #7c3aed; color: #fff; + border-radius: 8px; padding: .4rem .9rem; font-size: .82rem; font-weight: 600; + cursor: pointer; transition: background .15s; + } + .tpl-add-btn:hover { background: #6d28d9; } + .tpl-preview { + margin-top: .75rem; border-radius: 10px; + background: #23252b; padding: .6rem .85rem; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: .8rem; color: #d1d5db; word-break: break-all; line-height: 1.5; + } + .tpl-preview-label { font-size: .7rem; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: .06em; margin-bottom: .35rem; } + .tpl-preview-url { white-space: pre-wrap; } + .tpl-preview-val { color: #34d399; font-weight: 600; } + .tpl-preview-req { color: #f87171; font-weight: 600; } + .tpl-preview-note { font-size: .7rem; color: #9ca3af; margin-top: .3rem; } + .tpl-empty { font-size: .78rem; color: #9ca3af; padding: .3rem 0; } {% endblock %} @@ -156,6 +224,29 @@ value="{{ field.value|default_if_none:'' }}" class="shadow appearance-none rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" {% if field.field.required %}required{% endif %}> + +
+
🔧 {% trans "Template parameters" %}
+
{% trans "Click a parameter to insert it into the URL. The text after go/alias/… fills the parameter when the link is opened." %}
+
+ + + + + + +
+
+
+ + + +
+
+
{% trans "Live preview" %}
+
+
+
{% endif %} {% elif field.name == 'action_type' %} @@ -308,6 +399,8 @@ actionDescription.classList.add('hidden'); linkTypeInput.value = 'LINK'; textField.classList.add('hidden'); + const tplBuilder = document.getElementById('template-builder'); + if (tplBuilder) tplBuilder.classList.remove('hidden'); } function showCustomFields() { @@ -338,6 +431,8 @@ actionDescription.classList.add('hidden'); linkTypeInput.value = 'CUSTOM'; textField.classList.remove('hidden'); + const tplBuilder = document.getElementById('template-builder'); + if (tplBuilder) tplBuilder.classList.add('hidden'); initializeSimpleMDE(); } @@ -369,6 +464,8 @@ customDescription.classList.add('hidden'); linkTypeInput.value = 'ACTION'; textField.classList.add('hidden'); + const tplBuilder = document.getElementById('template-builder'); + if (tplBuilder) tplBuilder.classList.add('hidden'); } function initializeSimpleMDE() { @@ -460,6 +557,208 @@ if (msgInput) msgInput.addEventListener('input', renderPreview); renderPreview(); + /* ── Sketch-style template builder ── */ + const urlInput = document.getElementById('id_original_url'); + const builder = document.getElementById('template-builder'); + const paramListEl = document.getElementById('tpl-param-list'); + const previewEl = document.getElementById('tpl-preview-url'); + const newNameInput = document.getElementById('tpl-new-name'); + const newDefInput = document.getElementById('tpl-new-default'); + const addParamBtn = document.getElementById('tpl-add-param'); + + function parseTemplateParams(url) { + const result = []; + const re = /\{([^{}]*)\}/g; + let m; + while ((m = re.exec(url)) !== null) { + const inner = m[1].trim(); + const parts = inner.split(','); + const name = parts[0].trim(); + let def = null; + if (parts.length > 1) { + const dp = parts.slice(1).join(',').trim(); + if (dp.startsWith('default=')) { + def = dp.slice(8).trim().replace(/^["']|["']$/g, ''); + } else { + def = dp; + } + } + result.push({ name: name, def: def, match: m[0] }); + } + return result; + } + function buildPlaceholderToken(name, def) { + // Pure placeholder: {name} or {name,default=def} + return '{' + name + ((def !== null && def !== '') ? ',default=' + def : '') + '}'; + } + function buildQueryToken(name, def) { + // Query-style full token: name={name,default=def} — matches the + // common ?q={q} usage; pure path params can be typed manually. + return name + '=' + buildPlaceholderToken(name, def); + } + function escapeHtmlTpl(s) { + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + function insertAtCursor(input, text) { + const start = input.selectionStart != null ? input.selectionStart : input.value.length; + const end = input.selectionEnd != null ? input.selectionEnd : input.value.length; + input.value = input.value.slice(0, start) + text + input.value.slice(end); + const pos = start + text.length; + input.setSelectionRange(pos, pos); + input.focus(); + } + function appendToken(input, token) { + const v = input.value; + if (!v) { input.value = token; return; } + // Cursor in the middle of the URL: insert raw (user controls position) + if (document.activeElement === input && input.selectionStart != null && + input.selectionStart === input.selectionEnd && input.selectionEnd < v.length) { + insertAtCursor(input, token); + return; + } + // Append at the end with a smart separator + const hasQuery = v.indexOf('?') > -1; + const sep = hasQuery ? '&' : '?'; + const endsSep = /[?&]$/.test(v); + input.value = v + (endsSep ? '' : sep) + token; + const pos = input.value.length; + input.setSelectionRange(pos, pos); + input.focus(); + } + function removeTokenFromUrl(url, token) { + // token is {name} or {name,default=...}; also drop the `name=` key + const m = token.match(/^\{(.+?)(?:,default=.*)?\}$/); + const name = m ? m[1] : null; + let u = url; + if (name) { + u = u.replace(new RegExp('(^|[?&])' + name + '=\\{' + name + '(?:,default=[^}]*)?\\}(?=&|$)', 'g'), '$1'); + } + u = u.split(token).join(''); + u = u.replace(/[?&]{2,}/g, '&').replace(/^&/, '').replace(/[?&]$/, ''); + return u; + } + + function renderTemplateBuilder() { + if (!urlInput || !paramListEl) return; + const url = urlInput.value || ''; + const params = parseTemplateParams(url); + + paramListEl.innerHTML = ''; + if (!params.length) { + const empty = document.createElement('div'); + empty.className = 'tpl-empty'; + empty.textContent = '{% trans "No parameters yet — click one above or type {name,default=value} in the URL." %}'; + paramListEl.appendChild(empty); + } + params.forEach(function (p) { + const row = document.createElement('div'); + row.className = 'tpl-param-row'; + + const nameEl = document.createElement('code'); + nameEl.className = 'tpl-param-name'; + nameEl.textContent = p.name; + + const labelEl = document.createElement('span'); + labelEl.className = 'tpl-param-label'; + labelEl.textContent = '{% trans "default" %}'; + + const defInput = document.createElement('input'); + defInput.className = 'tpl-param-default'; + defInput.placeholder = '{% trans "default value" %}'; + defInput.value = p.def || ''; + defInput.addEventListener('input', function () { + const v = defInput.value.trim(); + const token = buildPlaceholderToken(p.name, v); + urlInput.value = urlInput.value.split(p.match).join(token); + p.match = token; + renderTemplateBuilder(); + }); + + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'tpl-param-del'; + delBtn.textContent = '✕'; + delBtn.title = '{% trans "Remove parameter" %}'; + delBtn.setAttribute('aria-label', '{% trans "Remove parameter" %} ' + p.name); + delBtn.addEventListener('click', function () { + urlInput.value = removeTokenFromUrl(urlInput.value, p.match); + renderTemplateBuilder(); + urlInput.dispatchEvent(new Event('input', { bubbles: true })); + }); + + row.appendChild(nameEl); + row.appendChild(labelEl); + row.appendChild(defInput); + row.appendChild(delBtn); + paramListEl.appendChild(row); + }); + + // grey out preset chips already in use + document.querySelectorAll('#tpl-chips .tpl-chip').forEach(function (chip) { + const used = params.some(function (p) { return p.name === chip.dataset.param; }); + chip.classList.toggle('used', used); + }); + + // live preview: fill defaults, flag missing ones + if (previewEl) { + if (!url) { + previewEl.innerHTML = '{% trans "Enter an original URL to see the live preview here." %}'; + } else { + let html = escapeHtmlTpl(url); + params.forEach(function (p) { + const escMatch = escapeHtmlTpl(p.match); + const valHtml = (p.def !== null && p.def !== '') + ? '' + escapeHtmlTpl(p.def) + '' + : '' + escapeHtmlTpl(p.name) + ''; + html = html.split(escMatch).join(valHtml); + }); + previewEl.innerHTML = html; + } + } + } + + // preset chips: click to insert + document.querySelectorAll('#tpl-chips .tpl-chip').forEach(function (chip) { + chip.addEventListener('click', function () { + const name = chip.dataset.param; + const def = chip.dataset.def || ''; + const token = buildQueryToken(name, def); + appendToken(urlInput, token); + renderTemplateBuilder(); + urlInput.dispatchEvent(new Event('input', { bubbles: true })); + }); + }); + + // custom param: name + default -> append + if (addParamBtn) { + addParamBtn.addEventListener('click', function () { + const name = (newNameInput.value || '').trim(); + if (!name || !/^[A-Za-z0-9_]+$/.test(name)) { + newNameInput.focus(); + newNameInput.setAttribute('style', 'border-color:#ef4444'); + setTimeout(function () { newNameInput.removeAttribute('style'); }, 1200); + return; + } + const def = (newDefInput.value || '').trim(); + const token = buildQueryToken(name, def); + appendToken(urlInput, token); + newNameInput.value = ''; + newDefInput.value = ''; + renderTemplateBuilder(); + urlInput.dispatchEvent(new Event('input', { bubbles: true })); + }); + newNameInput.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { e.preventDefault(); addParamBtn.click(); } + }); + newDefInput.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { e.preventDefault(); addParamBtn.click(); } + }); + } + + // sync on manual URL editing + if (urlInput) urlInput.addEventListener('input', renderTemplateBuilder); + renderTemplateBuilder(); + // Initialize form state if (linkTypeInput.value === 'CUSTOM') { showCustomFields(); diff --git a/links/views.py b/links/views.py index 17901a0..f230850 100644 --- a/links/views.py +++ b/links/views.py @@ -361,7 +361,24 @@ def redirect_to_original(request, alias, param=None): 'link': link, }) - # 如果是模板 URL 并且提供了参数 + # 模板 URL:query 参数按名映射(?q=xxx&page=2),路径参数兼容旧行为 + # (go/alias/xxx → 所有未提供值的参数都用 xxx),都没有则用默认值 + template_params = link.get_template_parameters() + if template_params: + url_kwargs = {k: v for k, v in request.GET.items() if k in template_params} + if param: + for name in template_params: + if name not in url_kwargs: + url_kwargs[name] = param + if url_kwargs: + url = link.get_processed_url(**url_kwargs) + else: + url = link.get_processed_url() + if url is None: + url = link.original_url + return redirect(url) + + # 非模板链接:仅兼容旧版路径参数行为 if param: # 从 URL 中提取参数名 pattern = r'\{([^{}]*)\}' diff --git a/tests/test_template_redirect.py b/tests/test_template_redirect.py new file mode 100644 index 0000000..314e90d --- /dev/null +++ b/tests/test_template_redirect.py @@ -0,0 +1,52 @@ +"""Tests for template-link redirects: query-param mapping, path-param +compat, and default-value fallback (the Sketch-style template builder +produces ?q={q,default=…} style URLs, so the redirect must honor GET).""" +import pytest +from django.test import Client +from django.urls import reverse +from links.models import Link + + +@pytest.fixture +def tpl_link(db): + return Link.objects.create( + alias="tpltest", + original_url="https://www.google.com/search?q={q,default=hello}&page={page,default=1}", + link_type=Link.LinkType.LINK, + ) + + +@pytest.mark.django_db +def test_template_query_param_mapping(client, tpl_link): + resp = client.get(reverse("redirect_to_original", args=["tpltest"]), {"q": "weather"}) + assert resp.status_code == 302 + assert resp["Location"] == "https://www.google.com/search?q=weather&page=1" + + +@pytest.mark.django_db +def test_template_multiple_query_params(client, tpl_link): + resp = client.get(reverse("redirect_to_original", args=["tpltest"]), {"q": "stocks", "page": "3"}) + assert resp.status_code == 302 + assert resp["Location"] == "https://www.google.com/search?q=stocks&page=3" + + +@pytest.mark.django_db +def test_template_defaults_when_no_params(client, tpl_link): + resp = client.get(reverse("redirect_to_original", args=["tpltest"])) + assert resp.status_code == 302 + assert resp["Location"] == "https://www.google.com/search?q=hello&page=1" + + +@pytest.mark.django_db +def test_template_path_param_backward_compat(client, tpl_link): + """Old style go/alias/foo fills every unfilled param with foo.""" + resp = client.get(reverse("redirect_to_original_with_param", args=["tpltest", "weather"])) + assert resp.status_code == 302 + assert resp["Location"] == "https://www.google.com/search?q=weather&page=weather" + + +@pytest.mark.django_db +def test_template_path_plus_query_query_wins(client, tpl_link): + resp = client.get(reverse("redirect_to_original_with_param", args=["tpltest", "foo"]), {"q": "weather"}) + assert resp.status_code == 302 + assert resp["Location"] == "https://www.google.com/search?q=weather&page=foo"