fix: normalize bare URLs in capture flow
This commit is contained in:
parent
56ef211418
commit
6d15639b41
|
|
@ -56,6 +56,11 @@ func (a *App) CaptureURLWithContext(rawURL, title, source, contextJSON string) (
|
||||||
if rawURL == "" {
|
if rawURL == "" {
|
||||||
return nil, fmt.Errorf("url required")
|
return nil, fmt.Errorf("url required")
|
||||||
}
|
}
|
||||||
|
normalizedURL, ok := normalizeHTTPURL(rawURL)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("invalid url")
|
||||||
|
}
|
||||||
|
rawURL = normalizedURL
|
||||||
title = strings.TrimSpace(title)
|
title = strings.TrimSpace(title)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = linkTitle(rawURL, "")
|
title = linkTitle(rawURL, "")
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,8 @@ func classifyClipboardText(text string) (string, string) {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return "text", ""
|
return "text", ""
|
||||||
}
|
}
|
||||||
if isURLLike(value) {
|
if normalized, ok := normalizeHTTPURL(value); ok {
|
||||||
return "url", value
|
return "url", normalized
|
||||||
}
|
}
|
||||||
return "text", value
|
return "text", value
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,11 @@ func (a *App) createResolvedLink(nodeID, rawURL, title, note, source, capturedAt
|
||||||
if rawURL == "" {
|
if rawURL == "" {
|
||||||
return nil, fmt.Errorf("url required")
|
return nil, fmt.Errorf("url required")
|
||||||
}
|
}
|
||||||
|
normalizedURL, ok := normalizeHTTPURL(rawURL)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("invalid url")
|
||||||
|
}
|
||||||
|
rawURL = normalizedURL
|
||||||
title = linkTitle(rawURL, title)
|
title = linkTitle(rawURL, title)
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
id := util.UUID7()
|
id := util.UUID7()
|
||||||
|
|
@ -154,26 +159,50 @@ func linkTitle(rawURL, title string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func isURLLike(text string) bool {
|
func isURLLike(text string) bool {
|
||||||
|
_, ok := normalizeHTTPURL(text)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeHTTPURL(text string) (string, bool) {
|
||||||
text = strings.TrimSpace(text)
|
text = strings.TrimSpace(text)
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return false
|
return "", false
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(text, " \t\r\n") || strings.Contains(text, "@") {
|
||||||
|
return "", false
|
||||||
}
|
}
|
||||||
u, err := url.Parse(text)
|
u, err := url.Parse(text)
|
||||||
return err == nil && u.Scheme != "" && u.Host != ""
|
if err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" {
|
||||||
|
return text, true
|
||||||
|
}
|
||||||
|
if u != nil && u.Scheme != "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
withScheme := "https://" + text
|
||||||
|
u, err = url.Parse(withScheme)
|
||||||
|
if err != nil || u.Host == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
host := u.Hostname()
|
||||||
|
if host == "" || !strings.Contains(host, ".") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return withScheme, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func openExternalURL(rawURL string) error {
|
func openExternalURL(rawURL string) error {
|
||||||
if !isURLLike(rawURL) {
|
normalizedURL, ok := normalizeHTTPURL(rawURL)
|
||||||
|
if !ok {
|
||||||
return fmt.Errorf("invalid url")
|
return fmt.Errorf("invalid url")
|
||||||
}
|
}
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "darwin":
|
case "darwin":
|
||||||
cmd = exec.Command("open", rawURL)
|
cmd = exec.Command("open", normalizedURL)
|
||||||
case "windows":
|
case "windows":
|
||||||
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL)
|
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", normalizedURL)
|
||||||
default:
|
default:
|
||||||
cmd = exec.Command("xdg-open", rawURL)
|
cmd = exec.Command("xdg-open", normalizedURL)
|
||||||
}
|
}
|
||||||
return cmd.Start()
|
return cmd.Start()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -263,3 +263,41 @@ func TestClassifyClipboardTextRoutesURLBeforePlainText(t *testing.T) {
|
||||||
t.Fatalf("value = %q, want trimmed text", value)
|
t.Fatalf("value = %q, want trimmed text", value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClassifyClipboardTextTreatsBareDomainsAsURLs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "apex domain", input: " mirv.top ", want: "https://mirv.top"},
|
||||||
|
{name: "www domain", input: "www.example.com", want: "https://www.example.com"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
kind, value := classifyClipboardText(tt.input)
|
||||||
|
if kind != "url" {
|
||||||
|
t.Fatalf("kind = %q, want url", kind)
|
||||||
|
}
|
||||||
|
if value != tt.want {
|
||||||
|
t.Fatalf("value = %q, want %q", value, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureURLNormalizesBareDomain(t *testing.T) {
|
||||||
|
app, _ := setupTestApp(t)
|
||||||
|
|
||||||
|
dto, err := app.CaptureURL("mirv.top", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CaptureURL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dto.URL != "https://mirv.top" {
|
||||||
|
t.Fatalf("URL = %q, want https://mirv.top", dto.URL)
|
||||||
|
}
|
||||||
|
if dto.Hostname != "mirv.top" {
|
||||||
|
t.Fatalf("Hostname = %q, want mirv.top", dto.Hostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -19,8 +19,8 @@
|
||||||
background: #13131f;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/main-DFvUQBl-.js"></script>
|
<script type="module" crossorigin src="/assets/main-D6zAtuqe.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-DvvUc9rb.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-CtRnbH6M.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -1503,14 +1503,25 @@
|
||||||
localInboxNodes = [item, ...localInboxNodes.filter(existing => existing.id !== item.id)]
|
localInboxNodes = [item, ...localInboxNodes.filter(existing => existing.id !== item.id)]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function looksLikeURL(value) {
|
function normalizeURL(value) {
|
||||||
|
const raw = String(value || '').trim()
|
||||||
|
if (!raw || /[\s]/.test(raw) || raw.includes('@')) return ''
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(value)
|
const parsed = new URL(raw)
|
||||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.hostname ? raw : ''
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false
|
try {
|
||||||
|
const withScheme = `https://${raw}`
|
||||||
|
const parsed = new URL(withScheme)
|
||||||
|
return parsed.hostname && parsed.hostname.includes('.') ? withScheme : ''
|
||||||
|
} catch (err) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function looksLikeURL(value) {
|
||||||
|
return normalizeURL(value) !== ''
|
||||||
|
}
|
||||||
function extensionForMime(type) {
|
function extensionForMime(type) {
|
||||||
const map = {
|
const map = {
|
||||||
'image/png': 'png',
|
'image/png': 'png',
|
||||||
|
|
@ -1574,8 +1585,9 @@
|
||||||
async function captureTextPayload(text, source) {
|
async function captureTextPayload(text, source) {
|
||||||
const value = String(text || '').trim()
|
const value = String(text || '').trim()
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
const item = looksLikeURL(value)
|
const normalizedURL = normalizeURL(value)
|
||||||
? await wailsCall('CaptureURLWithContext', value, '', source, captureContextJSON())
|
const item = normalizedURL
|
||||||
|
? await wailsCall('CaptureURLWithContext', normalizedURL, '', source, captureContextJSON())
|
||||||
: await wailsCall('CaptureTextWithContext', value, source, captureContextJSON())
|
: await wailsCall('CaptureTextWithContext', value, source, captureContextJSON())
|
||||||
await refreshInboxAfterCapture(item)
|
await refreshInboxAfterCapture(item)
|
||||||
return item
|
return item
|
||||||
|
|
@ -1625,16 +1637,18 @@
|
||||||
const moz = dataTransfer.getData?.('text/x-moz-url')
|
const moz = dataTransfer.getData?.('text/x-moz-url')
|
||||||
if (moz) {
|
if (moz) {
|
||||||
const parsed = parseMozURL(moz)
|
const parsed = parseMozURL(moz)
|
||||||
if (parsed && looksLikeURL(parsed.url)) {
|
const normalizedURL = parsed ? normalizeURL(parsed.url) : ''
|
||||||
await captureUrlPayload(parsed.url, parsed.title, source)
|
if (normalizedURL) {
|
||||||
|
await captureUrlPayload(normalizedURL, parsed.title, source)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const uri = dataTransfer.getData?.('text/uri-list')
|
const uri = dataTransfer.getData?.('text/uri-list')
|
||||||
if (uri) {
|
if (uri) {
|
||||||
const url = parseURIList(uri)
|
const url = parseURIList(uri)
|
||||||
if (looksLikeURL(url)) {
|
const normalizedURL = normalizeURL(url)
|
||||||
await captureUrlPayload(url, '', source)
|
if (normalizedURL) {
|
||||||
|
await captureUrlPayload(normalizedURL, '', source)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2080,7 +2094,7 @@
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-brand">
|
<div class="sidebar-brand">
|
||||||
<span class="logo">⚒</span>
|
<img class="logo" src="/assets/app-icons/icon_32x32.png" width="20" height="20" alt="" />
|
||||||
<span class="brand-name">{t('nav.brand')}</span>
|
<span class="brand-name">{t('nav.brand')}</span>
|
||||||
</div>
|
</div>
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
|
|
@ -2263,6 +2277,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if activeTab === 'files'}
|
{:else if activeTab === 'files'}
|
||||||
|
<!-- External file drops are handled by the unified capture flow and land in the selected case inbox. -->
|
||||||
<div class="files-tab">
|
<div class="files-tab">
|
||||||
<div class="tab-toolbar">
|
<div class="tab-toolbar">
|
||||||
<button class="btn btn-primary" on:click={addFile} disabled={importing}>{t('file.addFile')}</button>
|
<button class="btn btn-primary" on:click={addFile} disabled={importing}>{t('file.addFile')}</button>
|
||||||
|
|
@ -3309,7 +3324,7 @@
|
||||||
/* Sidebar */
|
/* Sidebar */
|
||||||
.sidebar { width: 260px; min-width: 200px; height: 100vh; display: flex; flex-direction: column; background: #1a1a28; border-right: 1px solid #2a2a3c; flex-shrink: 0; overflow: hidden; }
|
.sidebar { width: 260px; min-width: 200px; height: 100vh; display: flex; flex-direction: column; background: #1a1a28; border-right: 1px solid #2a2a3c; flex-shrink: 0; overflow: hidden; }
|
||||||
.sidebar-brand { padding: 16px 20px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid #2a2a3c; flex-shrink: 0; }
|
.sidebar-brand { padding: 16px 20px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid #2a2a3c; flex-shrink: 0; }
|
||||||
.logo { font-size: 20px; line-height: 1; }
|
.logo { display: block; width: 20px; height: 20px; flex-shrink: 0; }
|
||||||
.brand-name { font-size: 16px; font-weight: 600; }
|
.brand-name { font-size: 16px; font-weight: 600; }
|
||||||
.sidebar-nav { flex: 1; overflow-y: auto; padding: 12px 0; }
|
.sidebar-nav { flex: 1; overflow-y: auto; padding: 12px 0; }
|
||||||
.nav-group { margin-bottom: 16px; }
|
.nav-group { margin-bottom: 16px; }
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,13 @@ async function runReadyScenario(cdp, url) {
|
||||||
await clickText(cdp, '.modal-actions .btn', 'Разложить')
|
await clickText(cdp, '.modal-actions .btn', 'Разложить')
|
||||||
await waitForGone(cdp, '.modal-overlay')
|
await waitForGone(cdp, '.modal-overlay')
|
||||||
await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('example.test')`, 'inbox: assigned link leaves inbox')
|
await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('example.test')`, 'inbox: assigned link leaves inbox')
|
||||||
|
await setClipboardText(cdp, 'mirv.top')
|
||||||
|
await clickText(cdp, '.inbox-header .btn', 'Вставить из буфера')
|
||||||
|
await assertText(cdp, 'mirv.top', 'inbox: bare domain captured as URL')
|
||||||
|
await clickInboxItemButton(cdp, 'mirv.top', 'Открыть')
|
||||||
|
await assertEval(cdp, `window.__VERSTAK_GUI_SMOKE__.state.openedUrls.includes('https://mirv.top')`, 'inbox: bare domain opens normalized URL')
|
||||||
|
await clickInboxItemButton(cdp, 'mirv.top', 'Удалить')
|
||||||
|
await clickText(cdp, '.overlay .btn', 'Удалить')
|
||||||
await emitDroppedFiles(cdp, ['/tmp/smoke-drop-folder'])
|
await emitDroppedFiles(cdp, ['/tmp/smoke-drop-folder'])
|
||||||
await assertText(cdp, 'smoke-drop-folder', 'inbox: dropped folder captured')
|
await assertText(cdp, 'smoke-drop-folder', 'inbox: dropped folder captured')
|
||||||
await assertText(cdp, 'Перетаскивание', 'inbox: dropped source visible')
|
await assertText(cdp, 'Перетаскивание', 'inbox: dropped source visible')
|
||||||
|
|
@ -833,6 +840,23 @@ function wailsMockSource() {
|
||||||
try { return new URL(value).hostname; } catch { return ''; }
|
try { return new URL(value).hostname; } catch { return ''; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeURL(value) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw || /[\s]/.test(raw) || raw.includes('@')) return '';
|
||||||
|
try {
|
||||||
|
const parsed = new URL(raw);
|
||||||
|
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.hostname ? raw : '';
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const withScheme = `https://${raw}`;
|
||||||
|
const parsed = new URL(withScheme);
|
||||||
|
return parsed.hostname && parsed.hostname.includes('.') ? withScheme : '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function inboxDTO(node) {
|
function inboxDTO(node) {
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
|
|
@ -960,7 +984,8 @@ function wailsMockSource() {
|
||||||
CaptureClipboardTextWithContext: async (contextJSON) => {
|
CaptureClipboardTextWithContext: async (contextJSON) => {
|
||||||
const text = String(window.__VERSTAK_GUI_SMOKE_CLIPBOARD__ || '').trim();
|
const text = String(window.__VERSTAK_GUI_SMOKE_CLIPBOARD__ || '').trim();
|
||||||
if (!text) throw new Error('clipboard is empty');
|
if (!text) throw new Error('clipboard is empty');
|
||||||
if (/^https?:\\/\\//.test(text)) return App.CaptureURLWithContext(text, '', 'clipboard_button', contextJSON);
|
const normalizedURL = normalizeURL(text);
|
||||||
|
if (normalizedURL) return App.CaptureURLWithContext(normalizedURL, '', 'clipboard_button', contextJSON);
|
||||||
return App.CaptureTextWithContext(text, 'clipboard_button', contextJSON);
|
return App.CaptureTextWithContext(text, 'clipboard_button', contextJSON);
|
||||||
},
|
},
|
||||||
ResolveInboxNode: async (nodeId, targetParentId) => {
|
ResolveInboxNode: async (nodeId, targetParentId) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue