New Entry page improvements on link detection
This commit is contained in:
@@ -15,6 +15,13 @@
|
|||||||
* Brightened the Red theme surfaces and deepened its crimson accents
|
* Brightened the Red theme surfaces and deepened its crimson accents
|
||||||
* Allow every user to filter the feed using every available tag or label
|
* Allow every user to filter the feed using every available tag or label
|
||||||
* Bundle required web fonts during Docker image builds and serve them from LinkLog
|
* Bundle required web fonts during Docker image builds and serve them from LinkLog
|
||||||
|
* New Entry page warns when the title/URL combination already exists, matching the browser extension's duplicate warning
|
||||||
|
* New Entry page warns when an existing link with the same title has a different or missing URL (after tracking parameters are stripped)
|
||||||
|
* New Entry page auto-fills the title when the URL field loses focus, instead of requiring a manual button press
|
||||||
|
* Added a "Re-fetch Title" button to manually re-scrape the title after editing the URL
|
||||||
|
* New Entry duplicate detection and submission strip known tracking parameters (utm_*, gclid, fbclid, etc.) from URLs, mirroring the browser extension
|
||||||
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
* Fixed element ID conflict on the Admin page so Available Themes load correctly
|
* Fixed element ID conflict on the Admin page so Available Themes load correctly
|
||||||
* Positioned new-entry tag checkboxes after their label text
|
* Positioned new-entry tag checkboxes after their label text
|
||||||
|
|||||||
@@ -1424,3 +1424,39 @@ Don't let the frontend and plugin pull from the google foundry but make sure any
|
|||||||
|
|
||||||
### Assistant outcome
|
### Assistant outcome
|
||||||
Removed the web frontend's Google Fonts import and added a Docker build-time font bundling script. Image builds now download pinned Asset, DM Sans, and Space Grotesk files into `frontend/static/fonts`, generate a local `fonts.css`, and serve them through LinkLog at `/static/fonts`. The Firefox extension did not make Google Font requests and continues to use bundled assets with local fallback fonts. A clean image build confirmed all nine local font assets are present.
|
Removed the web frontend's Google Fonts import and added a Docker build-time font bundling script. Image builds now download pinned Asset, DM Sans, and Space Grotesk files into `frontend/static/fonts`, generate a local `fonts.css`, and serve them through LinkLog at `/static/fonts`. The Firefox extension did not make Google Font requests and continues to use bundled assets with local fallback fonts. A clean image build confirmed all nine local font assets are present.
|
||||||
|
|
||||||
|
### User
|
||||||
|
On the new-entry page follow the logic for existing pages that already exists in the plugin and display "This link already exists. Comment and tags can be updated, and by submitting publishing will be retriggered." when the link exists. Also assess if the Auto-fill title button is necessary, or if the functionality can be automated while the URL is entered.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Added a duplicate-check request (`GET /api/links/check`) to `new-entry.js`, triggered on URL/title blur, that shows the requested warning in a new `#duplicate-status` element and clears it as the fields are edited. Replaced the manual "Auto-fill Title" button with automatic scraping on URL blur (only filling the title when it is still empty and the URL actually changed), removing the now-unused button and CSS.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Also: detect as duplicate when the URL has arguments (not already stripped). And: add a button to re-fetch the title for if the URL has been fetched but the user still edits the field.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Added client-side stripping of known tracking parameters (`utm_*`, `gclid`, `fbclid`, etc.) mirroring the browser extension, applied consistently to the scrape lookup, duplicate check, and submission. Added a "Re-fetch Title" button next to the title field that force-refreshes the scraped title regardless of the auto-fill guard.
|
||||||
|
|
||||||
|
### User
|
||||||
|
If the link already exist with URL arguments then also display the warning, but add that the link has been stored additional parameters.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Extended the duplicate warning to note when the stored link had additional URL parameters stripped (e.g. tracking parameters) compared to the entered URL.
|
||||||
|
|
||||||
|
### User
|
||||||
|
I want this last change to be more precise: I do not want to be warned when parameters are stripped, but when the URL entered has arguments/parameters and the URL in the database has none or different ones (even after tracking parameters are stripped).
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Added `find_owned_link_by_title` in `link_service.py` and extended `GET /api/links/check` to return `{exists, url_matches, stored_url}`, detecting a title match with a differing URL in addition to an exact match. `new-entry.js` now only warns about a differing stored URL when `url_matches` is false, no longer warning solely because tracking parameters were stripped from an otherwise matching URL.
|
||||||
|
|
||||||
|
### User
|
||||||
|
In the check duplicate I have a `<br>` tag, but that shows as formatted text on the page, I want a real break to occur at that position.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Reworked `setStatus` in `new-entry.js` to split messages on `\n` and build real `<br>` elements via DOM APIs instead of relying on `textContent` (which escaped the literal `<br>`) or unsafe `innerHTML`.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Update VIBE and Changelog.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Appended the new-entry duplicate-detection and title auto-fill work to `VIBE/PROMPTS.md` and `VIBE/CHAT_LOG.md`, and added a Version v0.1.3 section to `CHANGELOG.MD`.
|
||||||
|
|||||||
@@ -254,6 +254,15 @@
|
|||||||
235. I believe tag selection was still working for v0.1.0 - it is still broken
|
235. I believe tag selection was still working for v0.1.0 - it is still broken
|
||||||
236. Don't let the frontend and plugin pull from the google foundry but make sure any necessary fonts are served from the linklog server. I would prefer the fonts to be updated at docker image building time.
|
236. Don't let the frontend and plugin pull from the google foundry but make sure any necessary fonts are served from the linklog server. I would prefer the fonts to be updated at docker image building time.
|
||||||
|
|
||||||
|
## 2026-08-28
|
||||||
|
|
||||||
|
237. On the new-entry page follow the logic for existing pages that already exists in the plugin and display "This link already exists. Comment and tags can be updated, and by submitting publishing will be retriggered." when the link exists. Also assess if the Auto-fill title button is necessary, or if the functionality can be automated while the URL is entered.
|
||||||
|
238. Also: detect as duplicate when the URL has arguments (not already stripped). And: add a button to re-fetch the title for if the URL has been fetched but the user still edits the field.
|
||||||
|
239. If the link already exist with URL arguments then also display the warning, but add that the link has been stored additional parameters.
|
||||||
|
240. I want this last change to be more precise: I do not want to be warned when parameters are stripped, but when the URL entered has arguments/parameters and the URL in the database has none or different ones (even after tracking parameters are stripped).
|
||||||
|
241. In the check duplicate I have a `<br>` tag, but that shows as formatted text on the page, I want a real break to occur at that position.
|
||||||
|
242. Update VIBE and Changelog.
|
||||||
|
|
||||||
## Future entries
|
## Future entries
|
||||||
|
|
||||||
Append each new user prompt here with its date and preserve the chronological order.
|
Append each new user prompt here with its date and preserve the chronological order.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Response, status
|
|||||||
import logging
|
import logging
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from backend.app.services.link_service import create_link, delete_link, find_owned_link_by_title_url, get_link_tags, get_owned_link, list_public_links, list_tags, mark_mastodon_posted, update_link
|
from backend.app.services.link_service import create_link, delete_link, find_owned_link_by_title, find_owned_link_by_title_url, get_link_tags, get_owned_link, list_public_links, list_tags, mark_mastodon_posted, update_link
|
||||||
from backend.app.database import get_connection
|
from backend.app.database import get_connection
|
||||||
from backend.app.services.plugin_manager import plugin_manager
|
from backend.app.services.plugin_manager import plugin_manager
|
||||||
from backend.app.services.token_service import validate_token
|
from backend.app.services.token_service import validate_token
|
||||||
@@ -65,7 +65,12 @@ def check_existing_link(
|
|||||||
if info is None:
|
if info is None:
|
||||||
raise HTTPException(status_code=401, detail='Token expired or invalid')
|
raise HTTPException(status_code=401, detail='Token expired or invalid')
|
||||||
record = find_owned_link_by_title_url(info['user_id'], title, url)
|
record = find_owned_link_by_title_url(info['user_id'], title, url)
|
||||||
return {'exists': record is not None}
|
if record is not None:
|
||||||
|
return {'exists': True, 'url_matches': True, 'stored_url': record['url']}
|
||||||
|
record = find_owned_link_by_title(info['user_id'], title)
|
||||||
|
if record is not None:
|
||||||
|
return {'exists': True, 'url_matches': False, 'stored_url': record['url']}
|
||||||
|
return {'exists': False, 'url_matches': None, 'stored_url': None}
|
||||||
|
|
||||||
|
|
||||||
@router.post('/links', status_code=status.HTTP_201_CREATED)
|
@router.post('/links', status_code=status.HTTP_201_CREATED)
|
||||||
|
|||||||
@@ -123,6 +123,19 @@ def find_owned_link_by_title_url(user_id: str, title: str, url: str) -> dict | N
|
|||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def find_owned_link_by_title(user_id: str, title: str) -> dict | None:
|
||||||
|
with get_connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
'SELECT * FROM links WHERE user_id = ? AND title = ? ORDER BY created_at DESC LIMIT 1',
|
||||||
|
(user_id, title),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
record = dict(row)
|
||||||
|
record['tags'] = get_link_tags(conn, record['id'])
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
def list_public_links(username: str | None = None):
|
def list_public_links(username: str | None = None):
|
||||||
with get_connection() as conn:
|
with get_connection() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
|
|||||||
@@ -11,8 +11,9 @@
|
|||||||
const existingTagsEl = document.getElementById('existing-tags');
|
const existingTagsEl = document.getElementById('existing-tags');
|
||||||
const mastodonPublishing = document.getElementById('mastodon-publishing');
|
const mastodonPublishing = document.getElementById('mastodon-publishing');
|
||||||
const mastodonEnabledCheckbox = document.getElementById('mastodon-enabled');
|
const mastodonEnabledCheckbox = document.getElementById('mastodon-enabled');
|
||||||
const scrapeButton = document.getElementById('scrape-button');
|
|
||||||
const scrapeStatus = document.getElementById('scrape-status');
|
const scrapeStatus = document.getElementById('scrape-status');
|
||||||
|
const duplicateStatus = document.getElementById('duplicate-status');
|
||||||
|
const refetchTitleButton = document.getElementById('refetch-title-button');
|
||||||
const submitButton = document.getElementById('submit-button');
|
const submitButton = document.getElementById('submit-button');
|
||||||
const submitStatus = document.getElementById('submit-status');
|
const submitStatus = document.getElementById('submit-status');
|
||||||
|
|
||||||
@@ -21,9 +22,14 @@
|
|||||||
let selectedTags = new Set();
|
let selectedTags = new Set();
|
||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
|
|
||||||
// Utility function to add status messages
|
// Utility function to add status messages; splits on \n into real <br> line breaks without using innerHTML.
|
||||||
function setStatus(statusEl, message, isError = false) {
|
function setStatus(statusEl, message, isError = false) {
|
||||||
statusEl.textContent = message;
|
const lines = message.split('\n');
|
||||||
|
statusEl.replaceChildren(
|
||||||
|
...lines.flatMap((line, index) => (
|
||||||
|
index === 0 ? [document.createTextNode(line)] : [document.createElement('br'), document.createTextNode(line)]
|
||||||
|
)),
|
||||||
|
);
|
||||||
statusEl.className = `status ${isError ? 'error' : 'success'}`;
|
statusEl.className = `status ${isError ? 'error' : 'success'}`;
|
||||||
statusEl.classList.remove('hidden');
|
statusEl.classList.remove('hidden');
|
||||||
if (!isError) {
|
if (!isError) {
|
||||||
@@ -106,17 +112,29 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scrape URL for title
|
// Strip known tracking parameters so duplicate detection and scraping ignore them, mirroring the browser extension.
|
||||||
scrapeButton.addEventListener('click', async (e) => {
|
function removeKnownTrackingParams(urlString) {
|
||||||
e.preventDefault();
|
try {
|
||||||
const url = urlInput.value.trim();
|
const url = new URL(urlString);
|
||||||
if (!url) {
|
const known = new Set([
|
||||||
setStatus(scrapeStatus, 'Please enter a URL', true);
|
'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
|
||||||
return;
|
'utm_id', 'utm_name', 'gclid', 'fbclid', 'dclid', 'msclkid',
|
||||||
|
]);
|
||||||
|
for (const key of known) {
|
||||||
|
url.searchParams.delete(key);
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
} catch (error) {
|
||||||
|
return urlString;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
scrapeButton.disabled = true;
|
// Fetch the page title for the given URL and fill it in, unless the user already typed one.
|
||||||
setStatus(scrapeStatus, 'Scraping...', false);
|
let lastScrapedUrl = null;
|
||||||
|
async function fetchTitle(url, { force = false } = {}) {
|
||||||
|
if (!url || (!force && (titleInput.value.trim() || url === lastScrapedUrl))) return;
|
||||||
|
|
||||||
|
setStatus(scrapeStatus, 'Looking up title...', false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/scrape?url=${encodeURIComponent(url)}`, {
|
const response = await fetch(`/api/scrape?url=${encodeURIComponent(url)}`, {
|
||||||
@@ -132,6 +150,7 @@
|
|||||||
throw new Error(`HTTP ${response.status}`);
|
throw new Error(`HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastScrapedUrl = url;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.title) {
|
if (data.title) {
|
||||||
titleInput.value = data.title;
|
titleInput.value = data.title;
|
||||||
@@ -142,16 +161,64 @@
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Scrape error:', error);
|
console.error('Scrape error:', error);
|
||||||
setStatus(scrapeStatus, `Error: ${error.message}`, true);
|
setStatus(scrapeStatus, `Error: ${error.message}`, true);
|
||||||
} finally {
|
|
||||||
scrapeButton.disabled = false;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
urlInput.addEventListener('blur', async () => {
|
||||||
|
await fetchTitle(removeKnownTrackingParams(urlInput.value.trim()));
|
||||||
|
checkDuplicate();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
refetchTitleButton.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const url = removeKnownTrackingParams(urlInput.value.trim());
|
||||||
|
if (!url) {
|
||||||
|
setStatus(scrapeStatus, 'Please enter a URL', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
titleInput.value = '';
|
||||||
|
fetchTitle(url, { force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Warn when the URL/title combination already exists for this user, mirroring the browser extension.
|
||||||
|
titleInput.addEventListener('blur', checkDuplicate);
|
||||||
|
|
||||||
|
urlInput.addEventListener('input', () => duplicateStatus.classList.add('hidden'));
|
||||||
|
titleInput.addEventListener('input', () => duplicateStatus.classList.add('hidden'));
|
||||||
|
|
||||||
|
async function checkDuplicate() {
|
||||||
|
const url = removeKnownTrackingParams(urlInput.value.trim());
|
||||||
|
const title = titleInput.value.trim();
|
||||||
|
if (!url || !title) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/links/check?${new URLSearchParams({ title, url })}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.exists) {
|
||||||
|
let message = 'This link already exists. ';
|
||||||
|
if (!data.url_matches) {
|
||||||
|
message += 'But, the stored link has a different URL (missing or different parameters).';
|
||||||
|
message += '\nWhen you save the entry you risk a duplicate entry.';
|
||||||
|
} else {
|
||||||
|
message += '\nYou can still save the entry, which will update the existing entry\'s comment and or tags.';
|
||||||
|
}
|
||||||
|
setStatus(duplicateStatus, message, true);
|
||||||
|
} else {
|
||||||
|
duplicateStatus.classList.add('hidden');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Duplicate checking is advisory; submission remains available.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
entryForm.addEventListener('submit', async (e) => {
|
entryForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const url = urlInput.value.trim();
|
const url = removeKnownTrackingParams(urlInput.value.trim());
|
||||||
const title = titleInput.value.trim();
|
const title = titleInput.value.trim();
|
||||||
const comment = commentInput.value.trim();
|
const comment = commentInput.value.trim();
|
||||||
const newTags = newTagsInput.value.trim();
|
const newTags = newTagsInput.value.trim();
|
||||||
|
|||||||
@@ -1133,8 +1133,7 @@ button:disabled {
|
|||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-actions button[type='submit'],
|
.form-actions button[type='submit'] {
|
||||||
#scrape-button:not(:disabled) {
|
|
||||||
background: var(--mauve);
|
background: var(--mauve);
|
||||||
border: 1px solid var(--mauve);
|
border: 1px solid var(--mauve);
|
||||||
color: var(--crust);
|
color: var(--crust);
|
||||||
@@ -1150,22 +1149,6 @@ button:disabled {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
#scrape-button {
|
|
||||||
background: var(--surface-1);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
#scrape-button:not(:disabled):hover {
|
|
||||||
background: var(--surface-2);
|
|
||||||
border-color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
#scrape-button:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions a {
|
.form-actions a {
|
||||||
background: var(--surface-1);
|
background: var(--surface-1);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|||||||
@@ -52,7 +52,6 @@
|
|||||||
<span>URL</span>
|
<span>URL</span>
|
||||||
<input id="url-input" name="url" type="url" required placeholder="https://example.com" />
|
<input id="url-input" name="url" type="url" required placeholder="https://example.com" />
|
||||||
</label>
|
</label>
|
||||||
<button id="scrape-button" class="secondary" type="button">Auto-fill Title</button>
|
|
||||||
<div id="scrape-status" class="status hidden" aria-live="polite"></div>
|
<div id="scrape-status" class="status hidden" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,8 +60,11 @@
|
|||||||
<span>Title</span>
|
<span>Title</span>
|
||||||
<input id="title-input" name="title" type="text" required />
|
<input id="title-input" name="title" type="text" required />
|
||||||
</label>
|
</label>
|
||||||
|
<button id="refetch-title-button" class="secondary" type="button">Re-fetch Title</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="duplicate-status" class="status hidden" aria-live="polite"></div>
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<label>
|
<label>
|
||||||
<span>Comment</span>
|
<span>Comment</span>
|
||||||
@@ -101,6 +103,6 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="/static/auth-header.js"></script>
|
<script src="/static/auth-header.js"></script>
|
||||||
<script src="/static/new-entry.js?v=2"></script>
|
<script src="/static/new-entry.js?v=7"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user