Plugin follows duplicate behavior behavior of new-entry

This commit is contained in:
2026-08-28 09:37:14 +02:00
parent 50d61371e1
commit 5696c89dee
14 changed files with 105 additions and 16 deletions
+3 -2
View File
@@ -1,6 +1,6 @@
# Changelog # Changelog
## Version v0.1.2 ## Version v0.2.0
### Features ### Features
* Users can edit or delete labels created by themselves on the labels page * Users can edit or delete labels created by themselves on the labels page
* Administrators can edit labels from the admin interface * Administrators can edit labels from the admin interface
@@ -15,12 +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
* 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 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 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 * 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 * 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 * New Entry duplicate detection and submission strip known tracking parameters (utm_*, gclid, fbclid, etc.) from URLs, mirroring the browser extension
* Browser extension popup now matches the New Entry page: it warns on duplicate title/URL, notes when the stored link has a different URL, and gained a "Re-fetch title" button; bumped extension version to 0.2.0
### 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
+6
View File
@@ -1460,3 +1460,9 @@ Update VIBE and Changelog.
### Assistant outcome ### 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`. 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`.
### User
Make sure the webplugin has the same functionality as just implemented in the new-entry page. Increase its version number to 0.2.0.
### Assistant outcome
Brought `webextension/popup.js` to parity with `new-entry.js`: the duplicate check now distinguishes an exact title/URL match from a title match with a differing stored URL (using the same `/api/links/check` `url_matches`/`stored_url` fields) and shows an additional localized note in the latter case; added a "Re-fetch title" button that calls the backend `/api/scrape` endpoint (guarded by existing backend-permission checks) and auto-fills the title on URL blur when empty; reworked `setStatus` to render real `<br>` line breaks via DOM APIs instead of `textContent`. Added `duplicateLinkDifferentUrl`, `refetchTitle`, and `titleFetchFailed` strings to all five locale files, updated `popup.html` with the new button, and bumped `manifest.json` to version `0.2.0`. Full backend test suite (36 tests) passed.
+1
View File
@@ -262,6 +262,7 @@
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). 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. 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. 242. Update VIBE and Changelog.
243. Make sure the webplugin has the same functionality as just implemented in the new-entry page. Increase its version number to 0.2.0.
## Future entries ## Future entries
Binary file not shown.
+4 -1
View File
@@ -198,8 +198,11 @@
if (!response.ok) return; if (!response.ok) return;
const data = await response.json(); const data = await response.json();
if (data.exists) { if (data.exists) {
// Re-derive the match locally: browser URL normalization (e.g. trailing slashes) can
// make the server's raw string comparison disagree even when the URLs are equivalent.
const urlMatches = data.url_matches || (data.stored_url && removeKnownTrackingParams(data.stored_url) === url);
let message = 'This link already exists. '; let message = 'This link already exists. ';
if (!data.url_matches) { if (!urlMatches) {
message += 'But, the stored link has a different URL (missing or different parameters).'; message += 'But, the stored link has a different URL (missing or different parameters).';
message += '\nWhen you save the entry you risk a duplicate entry.'; message += '\nWhen you save the entry you risk a duplicate entry.';
} else { } else {
+1 -1
View File
@@ -103,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=7"></script> <script src="/static/new-entry.js?v=8"></script>
</body> </body>
</html> </html>
+4 -1
View File
@@ -42,7 +42,10 @@
"submissionFailed": {"message": "Senden fehlgeschlagen"}, "submissionFailed": {"message": "Senden fehlgeschlagen"},
"linkSaved": {"message": "Link auf $URL$ gespeichert.", "placeholders": {"url": {"content": "$1"}}}, "linkSaved": {"message": "Link auf $URL$ gespeichert.", "placeholders": {"url": {"content": "$1"}}},
"linkAlreadyExists": {"message": "Dieser Link existiert bereits. Kommentar und Tags wurden aktualisiert und die Veröffentlichung erneut ausgelöst."}, "linkAlreadyExists": {"message": "Dieser Link existiert bereits. Kommentar und Tags wurden aktualisiert und die Veröffentlichung erneut ausgelöst."},
"duplicateLinkWarning": {"message": "Dieser Link existiert bereits. Kommentar und Tags können aktualisiert werden; beim Absenden wird die Veröffentlichung erneut ausgelöst."}, "duplicateLinkSameUrl": {"message": "Dieser Link existiert bereits.\nDu kannst den Eintrag trotzdem speichern; dabei werden Kommentar und Tags des bestehenden Eintrags aktualisiert."},
"duplicateLinkDifferentUrl": {"message": "Dieser Link existiert bereits. Der gespeicherte Link hat jedoch eine andere URL (fehlende oder andere Parameter).\nWenn du den Eintrag speicherst, riskierst du einen doppelten Eintrag."},
"refetchTitle": {"message": "Titel erneut abrufen"},
"titleFetchFailed": {"message": "Titel konnte nicht abgerufen werden"},
"publishingErrors": {"message": "Fehler bei der Veröffentlichung: $ERRORS$", "placeholders": {"errors": {"content": "$1"}}}, "publishingErrors": {"message": "Fehler bei der Veröffentlichung: $ERRORS$", "placeholders": {"errors": {"content": "$1"}}},
"submissionFailedConnection": {"message": "Senden fehlgeschlagen. Überprüfe die Verbindung zum Backend."}, "submissionFailedConnection": {"message": "Senden fehlgeschlagen. Überprüfe die Verbindung zum Backend."},
"loggedInAt": {"message": "$USERNAME$ ist bei $BACKEND$ angemeldet", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}}, "loggedInAt": {"message": "$USERNAME$ ist bei $BACKEND$ angemeldet", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}},
+11 -2
View File
@@ -133,8 +133,17 @@
"linkAlreadyExists": { "linkAlreadyExists": {
"message": "This link already exists. Comment and tags were updated, and publishing was retriggered." "message": "This link already exists. Comment and tags were updated, and publishing was retriggered."
}, },
"duplicateLinkWarning": { "duplicateLinkSameUrl": {
"message": "This link already exists. Comment and tags can be updated, and by submitting publishing will be retriggered." "message": "This link already exists.\nYou can still save the entry, which will update the existing entry's comment and or tags."
},
"duplicateLinkDifferentUrl": {
"message": "This link already exists. But, the stored link has a different URL (missing or different parameters).\nWhen you save the entry you risk a duplicate entry."
},
"refetchTitle": {
"message": "Re-fetch title"
},
"titleFetchFailed": {
"message": "Could not fetch title"
}, },
"publishingErrors": { "publishingErrors": {
"message": "Publishing errors: $ERRORS$", "message": "Publishing errors: $ERRORS$",
+4 -1
View File
@@ -42,7 +42,10 @@
"submissionFailed": {"message": "Error al enviar"}, "submissionFailed": {"message": "Error al enviar"},
"linkSaved": {"message": "Enlace guardado en $URL$.", "placeholders": {"url": {"content": "$1"}}}, "linkSaved": {"message": "Enlace guardado en $URL$.", "placeholders": {"url": {"content": "$1"}}},
"linkAlreadyExists": {"message": "Este enlace ya existe. Se actualizaron el comentario y las etiquetas, y se volvió a activar la publicación."}, "linkAlreadyExists": {"message": "Este enlace ya existe. Se actualizaron el comentario y las etiquetas, y se volvió a activar la publicación."},
"duplicateLinkWarning": {"message": "Este enlace ya existe. Puedes actualizar el comentario y las etiquetas; al enviarlo se volverá a activar la publicación."}, "duplicateLinkSameUrl": {"message": "Este enlace ya existe.\nAún puedes guardar la entrada, lo que actualizará el comentario y las etiquetas de la entrada existente."},
"duplicateLinkDifferentUrl": {"message": "Este enlace ya existe. Pero el enlace guardado tiene una URL diferente (parámetros ausentes o distintos).\nSi guardas la entrada, corres el riesgo de crear una entrada duplicada."},
"refetchTitle": {"message": "Volver a obtener el título"},
"titleFetchFailed": {"message": "No se pudo obtener el título"},
"publishingErrors": {"message": "Errores de publicación: $ERRORS$", "placeholders": {"errors": {"content": "$1"}}}, "publishingErrors": {"message": "Errores de publicación: $ERRORS$", "placeholders": {"errors": {"content": "$1"}}},
"submissionFailedConnection": {"message": "Error al enviar. Comprueba la conexión con el servidor."}, "submissionFailedConnection": {"message": "Error al enviar. Comprueba la conexión con el servidor."},
"loggedInAt": {"message": "$USERNAME$ ha iniciado sesión en $BACKEND$", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}}, "loggedInAt": {"message": "$USERNAME$ ha iniciado sesión en $BACKEND$", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}},
+4 -1
View File
@@ -42,7 +42,10 @@
"submissionFailed": {"message": "Échec de lenvoi"}, "submissionFailed": {"message": "Échec de lenvoi"},
"linkSaved": {"message": "Lien enregistré sur $URL$.", "placeholders": {"url": {"content": "$1"}}}, "linkSaved": {"message": "Lien enregistré sur $URL$.", "placeholders": {"url": {"content": "$1"}}},
"linkAlreadyExists": {"message": "Ce lien existe déjà. Le commentaire et les étiquettes ont été mis à jour et la publication a été relancée."}, "linkAlreadyExists": {"message": "Ce lien existe déjà. Le commentaire et les étiquettes ont été mis à jour et la publication a été relancée."},
"duplicateLinkWarning": {"message": "Ce lien existe déjà. Le commentaire et les étiquettes peuvent être mis à jour ; lenvoi relancera la publication."}, "duplicateLinkSameUrl": {"message": "Ce lien existe déjà.\nVous pouvez tout de même enregistrer lentrée ; le commentaire et les étiquettes de lentrée existante seront mis à jour."},
"duplicateLinkDifferentUrl": {"message": "Ce lien existe déjà. Mais le lien enregistré a une URL différente (paramètres manquants ou différents).\nSi vous enregistrez lentrée, vous risquez de créer un doublon."},
"refetchTitle": {"message": "Récupérer à nouveau le titre"},
"titleFetchFailed": {"message": "Impossible de récupérer le titre"},
"publishingErrors": {"message": "Erreurs de publication : $ERRORS$", "placeholders": {"errors": {"content": "$1"}}}, "publishingErrors": {"message": "Erreurs de publication : $ERRORS$", "placeholders": {"errors": {"content": "$1"}}},
"submissionFailedConnection": {"message": "Échec de lenvoi. Vérifiez la connexion au serveur."}, "submissionFailedConnection": {"message": "Échec de lenvoi. Vérifiez la connexion au serveur."},
"loggedInAt": {"message": "$USERNAME$ est connecté à $BACKEND$", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}}, "loggedInAt": {"message": "$USERNAME$ est connecté à $BACKEND$", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}},
+4 -1
View File
@@ -42,7 +42,10 @@
"submissionFailed": {"message": "Verzenden mislukt"}, "submissionFailed": {"message": "Verzenden mislukt"},
"linkSaved": {"message": "Koppeling opgeslagen op $URL$.", "placeholders": {"url": {"content": "$1"}}}, "linkSaved": {"message": "Koppeling opgeslagen op $URL$.", "placeholders": {"url": {"content": "$1"}}},
"linkAlreadyExists": {"message": "Deze koppeling bestaat al. De opmerking en tags zijn bijgewerkt en publiceren is opnieuw gestart."}, "linkAlreadyExists": {"message": "Deze koppeling bestaat al. De opmerking en tags zijn bijgewerkt en publiceren is opnieuw gestart."},
"duplicateLinkWarning": {"message": "Deze koppeling bestaat al. De opmerking en tags kunnen worden bijgewerkt; na verzenden wordt publiceren opnieuw gestart."}, "duplicateLinkSameUrl": {"message": "Deze koppeling bestaat al.\nJe kunt de invoer nog steeds opslaan; de opmerking en tags van de bestaande koppeling worden dan bijgewerkt."},
"duplicateLinkDifferentUrl": {"message": "Deze koppeling bestaat al. De opgeslagen koppeling heeft echter een andere URL (ontbrekende of andere parameters).\nAls je de invoer opslaat, loop je het risico op een dubbele koppeling."},
"refetchTitle": {"message": "Titel opnieuw ophalen"},
"titleFetchFailed": {"message": "Titel kon niet worden opgehaald"},
"publishingErrors": {"message": "Publicatiefouten: $ERRORS$", "placeholders": {"errors": {"content": "$1"}}}, "publishingErrors": {"message": "Publicatiefouten: $ERRORS$", "placeholders": {"errors": {"content": "$1"}}},
"submissionFailedConnection": {"message": "Verzenden mislukt. Controleer de verbinding met de backend."}, "submissionFailedConnection": {"message": "Verzenden mislukt. Controleer de verbinding met de backend."},
"loggedInAt": {"message": "$USERNAME$ is ingelogd op $BACKEND$", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}}, "loggedInAt": {"message": "$USERNAME$ is ingelogd op $BACKEND$", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}},
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "__MSG_extensionName__", "name": "__MSG_extensionName__",
"version": "0.1.0", "version": "0.2.0",
"description": "__MSG_extensionDescription__", "description": "__MSG_extensionDescription__",
"default_locale": "en-US", "default_locale": "en-US",
"permissions": [ "permissions": [
+1
View File
@@ -26,6 +26,7 @@
<span data-i18n="titleLabel">Title</span> <span data-i18n="titleLabel">Title</span>
<input id="title" name="title" type="text" /> <input id="title" name="title" type="text" />
</label> </label>
<button type="button" id="refetch-title" class="secondary" data-i18n="refetchTitle">Re-fetch title</button>
<label> <label>
<span data-i18n="urlLabel">URL</span> <span data-i18n="urlLabel">URL</span>
+60 -4
View File
@@ -13,6 +13,7 @@ const feedLink = document.getElementById('feed-link');
const authWarning = document.getElementById('auth-warning'); const authWarning = document.getElementById('auth-warning');
const warningSettingsButton = document.getElementById('warning-settings'); const warningSettingsButton = document.getElementById('warning-settings');
const authSession = document.getElementById('auth-session'); const authSession = document.getElementById('auth-session');
const refetchTitleButton = document.getElementById('refetch-title');
const sessionStore = browser.storage.session; const sessionStore = browser.storage.session;
const t = window.linklogI18n; const t = window.linklogI18n;
@@ -35,8 +36,14 @@ async function hasBackendPermission(backendUrl) {
return browser.permissions.contains({origins: [`${origin}/*`]}); return browser.permissions.contains({origins: [`${origin}/*`]});
} }
// Splits on \n into real <br> line breaks without using innerHTML.
function setStatus(message, isError = false) { function setStatus(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.classList.remove('hidden'); statusEl.classList.remove('hidden');
statusEl.classList.toggle('error', isError); statusEl.classList.toggle('error', isError);
statusEl.classList.toggle('success', !isError); statusEl.classList.toggle('success', !isError);
@@ -225,20 +232,54 @@ async function checkExistingLink() {
const settings = await getSettings(); const settings = await getSettings();
if (!settings.backendUrl || !settings.accessToken || !(await hasBackendPermission(settings.backendUrl)) || !titleInput.value || !urlInput.value) return; if (!settings.backendUrl || !settings.accessToken || !(await hasBackendPermission(settings.backendUrl)) || !titleInput.value || !urlInput.value) return;
try { try {
const url = removeKnownTrackingParams(urlInput.value);
const response = await fetch(`${settings.backendUrl}/api/links/check?${new URLSearchParams({ const response = await fetch(`${settings.backendUrl}/api/links/check?${new URLSearchParams({
title: titleInput.value, title: titleInput.value,
url: removeKnownTrackingParams(urlInput.value), url,
})}`, { })}`, {
headers: {'Authorization': `Bearer ${settings.accessToken}`}, headers: {'Authorization': `Bearer ${settings.accessToken}`},
}); });
if (response.ok && (await response.json()).exists) { if (!response.ok) return;
setStatus(t('duplicateLinkWarning'), true); const data = await response.json();
if (data.exists) {
// Re-derive the match locally: browser URL normalization (e.g. trailing slashes) can
// make the server's raw string comparison disagree even when the URLs are equivalent.
const urlMatches = data.url_matches || (data.stored_url && removeKnownTrackingParams(data.stored_url) === url);
const message = urlMatches ? t('duplicateLinkSameUrl') : t('duplicateLinkDifferentUrl');
setStatus(message, true);
} }
} catch (error) { } catch (error) {
// Duplicate checking is advisory; submission remains available. // Duplicate checking is advisory; submission remains available.
} }
} }
// Re-fetch the title from the backend scraper, for when the URL was edited after the initial tab-based fill.
let lastScrapedUrl = null;
async function fetchTitle(url, { force = false } = {}) {
const settings = await getSettings();
if (!settings.backendUrl || !settings.accessToken || !(await hasBackendPermission(settings.backendUrl))) return;
if (!url || (!force && (titleInput.value.trim() || url === lastScrapedUrl))) return;
try {
const response = await fetch(`${settings.backendUrl}/api/scrape?url=${encodeURIComponent(url)}`, {
headers: {'Authorization': `Bearer ${settings.accessToken}`},
});
if (!response.ok) {
if (force) setStatus(t('titleFetchFailed'), true);
return;
}
lastScrapedUrl = url;
const data = await response.json();
if (data.title) {
titleInput.value = data.title;
} else if (force) {
setStatus(t('titleFetchFailed'), true);
}
} catch (error) {
if (force) setStatus(t('titleFetchFailed'), true);
}
}
async function handleSubmit(event) { async function handleSubmit(event) {
event.preventDefault(); event.preventDefault();
setStatus(t('submitting'), false); setStatus(t('submitting'), false);
@@ -305,6 +346,21 @@ async function handleSubmit(event) {
openSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage()); openSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage());
warningSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage()); warningSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage());
form.addEventListener('submit', handleSubmit); form.addEventListener('submit', handleSubmit);
urlInput.addEventListener('blur', async () => {
await fetchTitle(removeKnownTrackingParams(urlInput.value.trim()));
checkExistingLink();
});
titleInput.addEventListener('blur', checkExistingLink);
refetchTitleButton.addEventListener('click', (e) => {
e.preventDefault();
const url = removeKnownTrackingParams(urlInput.value.trim());
if (!url) {
setStatus(t('titleFetchFailed'), true);
return;
}
titleInput.value = '';
fetchTitle(url, { force: true });
});
populateCurrentTab().then(checkExistingLink); populateCurrentTab().then(checkExistingLink);
loadExistingTags(); loadExistingTags();
updateFeedLink(); updateFeedLink();