6 Commits
Author SHA1 Message Date
Olaf Kolkman a372b9dcc4 signed update - part 2
Build LinkLog Development Image / development-image (push) Successful in 10s
Release LinkLog / release (push) Successful in 9s
2026-09-06 11:02:48 +02:00
Olaf Kolkman 77dcf5c9bd Signed 0.3.0.xpi 2026-09-06 11:02:24 +02:00
olaf a6db9f1566 toolbar order
Build LinkLog Development Image / development-image (push) Successful in 10s
2026-09-06 10:58:55 +02:00
olaf 0700967c30 Re-ran make xpi
Build LinkLog Development Image / development-image (push) Successful in 8s
2026-09-06 10:50:26 +02:00
olaf 0e04c583a6 Character count in firefox plugin
Build LinkLog Development Image / development-image (push) Successful in 8s
2026-09-06 10:48:07 +02:00
olaf 443aff8323 Unenforced character count in frontend
Build LinkLog Development Image / development-image (push) Successful in 11s
2026-09-06 10:20:25 +02:00
23 changed files with 270 additions and 24 deletions
+2
View File
@@ -23,6 +23,8 @@ LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS=24
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS=1
LINKLOG_MASTODON_CLIENT_NAME=LinkLog
LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES=10
# Warn on the New Entry page when the assembled post would exceed this many characters (Mastodon's default limit is 500).
LINKLOG_MAX_POST_CHARACTERS=500
LINKLOG_LOG_LEVEL=INFO
# Optional comma-separated override. Leave empty to use the built-in list.
LINKLOG_TRACKING_PARAMS=
+2
View File
@@ -4,6 +4,8 @@
* Implemented toolbar search functionality, using Google-like syntax (implicit AND, quoted phrases, `OR`, `-` exclusions, and the `site:` operator)
* The feed now loads a user-selectable number of entries per page (default 25, configurable via `LINKLOG_FRONTEND_LOADPOSTS`) with Previous/Next and numbered page navigation
* Moved tag/user filtering, search, and sorting to the backend, so the feed API returns only the matching, paginated results
* The New Entry page shows a live character count and warns when the assembled post would exceed the configurable `LINKLOG_MAX_POST_CHARACTERS` limit (default 500), to help avoid failed Mastodon posts
* The Firefox extension popup now shows the same live character count and over-limit warning as the New Entry page (version 0.3.0)
### Fixed
* Restored tag and user filtering on the feed: the decorative header arc no longer intercepts clicks on the filter dropdowns
+24
View File
@@ -1592,4 +1592,28 @@ Make it so that <nav id=pagination> at the bottom of the page doesn't exceed the
### Assistant outcome
Made the pagination bar wrap within the page width (`max-width: 100%`, `overflow-x: auto`) and toned down its styling to ghost-style buttons with smaller padding/font and muted colors instead of solid backgrounds. Verified the updated CSS is served correctly from a rebuilt local container.
### User
Make sure the CHANGELOG reflects all changes since commit 74b2c400c6
### Assistant outcome
Reviewed the commit log and working-tree diff since `74b2c400c6` and updated the `v0.3.0` section of `CHANGELOG.MD` with the missing user-facing entries: toolbar search, server-side feed pagination, backend-side filtering, the Firefox search-input width fix, the search/tag toolbar reorder, the Mastodon "Logged on <date> from:" UTC timestamp change, and the pagination control's visual/overflow fix. Also fixed an unrelated pre-existing bug in `frontend/version.json` (a missing closing quote made it invalid JSON, silently falling back to version `0.0.0`).
### User
Add a skill to maintain the changelog.md
### Assistant outcome
Added `.github/skills/changelog-maintenance/SKILL.md`, modeled on the existing `vibe-logging` skill, describing when and how to append concise, user-facing bullets to the current unreleased `## Version` section of `CHANGELOG.MD` (Features/Fixed/Modification), when to bump `frontend/version.json`, and which changes are out of scope (internal-only docs, dev scripts, test-only fixes).
### User
In the new-entry form, provide a warning when the total ammount of characters is over LINKLOG_MAX_POST_CHARACTERS=500 (also indicat the number of characters used/500 count). This is to prevent Mastodon posts from failing - so all characters should be counted.
### Assistant outcome
Added a `LINKLOG_MAX_POST_CHARACTERS` setting (default 500) exposed via `GET /api/public/config`. The New Entry page now shows a live `used/limit characters` counter that recalculates on every relevant input (title, URL, comment, tags, checkbox toggles) by assembling the estimated Mastodon post text the same way the backend's Mastodon plugin does, and switches to a warning style when the limit is exceeded. Verified with the full backend test suite (56 passed) and a live smoke test of `/api/public/config` and the rendered New Entry page.
### User
Implement the same functionality in the firefox plugin
### Assistant outcome
Added the same live character-count warning to the Firefox extension popup: it fetches `/api/public/config` for the limit and the user's `/api/user/plugins/mastodon` post prefix, estimates the assembled Mastodon post length the same way as the New Entry page and backend plugin, and shows a localized `used/limit characters` counter (with an over-limit warning message) that updates on every relevant input and tag toggle. Added the `characterCount`/`characterCountOverLimit` translations to all five locale files (en-US, de, es, fr, nl) and verified all locale JSON files parse and `popup.js` passes a Node syntax check.
# Chat Log
+4
View File
@@ -311,6 +311,10 @@ The default selection is 25 with additional choices of 100 and 250 (to be config
If the user uses the filters and/or search in the toolbox then those should limit the entries the server presents, so the filters and search are applied on the server side.
258. Make it so that <nav id=pagination> at the bottom of the page doesn't exceed the page width and is less visually dominant
259. Make sure the CHANGELOG reflects all changes since commit 74b2c400c6
260. Add a skill to maintain the changelog.md
261. In the new-entry form, provide a warning when the total ammount of characters is over LINKLOG_MAX_POST_CHARACTERS=500 (also indicat the number of characters used/500 count). This is to prevent Mastodon posts from failing - so all characters should be counted.
262. Implement the same functionality in the firefox plugin
## Future entries
Binary file not shown.
Binary file not shown.
+5 -1
View File
@@ -28,7 +28,11 @@ def public_themes():
@router.get('/config')
def public_config():
return {'feed_page_sizes': settings.feed_page_sizes, 'default_page_size': settings.feed_page_sizes[0]}
return {
'feed_page_sizes': settings.feed_page_sizes,
'default_page_size': settings.feed_page_sizes[0],
'max_post_characters': settings.max_post_characters,
}
@router.get('/feed')
+1
View File
@@ -51,6 +51,7 @@ class Settings:
password_reset_expiry_hours: int = int(os.getenv('LINKLOG_PASSWORD_RESET_EXPIRY_HOURS', '1'))
mastodon_client_name: str = os.getenv('LINKLOG_MASTODON_CLIENT_NAME', 'LinkLog')
mastodon_oauth_expiry_minutes: int = int(os.getenv('LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES', '10'))
max_post_characters: int = int(os.getenv('LINKLOG_MAX_POST_CHARACTERS', '500'))
log_level: str = os.getenv('LINKLOG_LOG_LEVEL', 'INFO').upper()
tracking_params: list[str] = None
feed_page_sizes: list[int] = None
+69 -11
View File
@@ -16,11 +16,14 @@
const refetchTitleButton = document.getElementById('refetch-title-button');
const submitButton = document.getElementById('submit-button');
const submitStatus = document.getElementById('submit-status');
const characterCount = document.getElementById('character-count');
const token = localStorage.getItem('linklogAccessToken');
let availableTags = [];
let selectedTags = new Set();
let currentUser = null;
let maxPostCharacters = 500;
let mastodonPostPrefix = 'From my #LinkLog: ';
// Utility function to add status messages; splits on \n into real <br> line breaks without using innerHTML.
function setStatus(statusEl, message, isError = false) {
@@ -53,7 +56,7 @@
.then((user) => {
currentUser = user;
entryForm.classList.remove('hidden');
Promise.all([loadTags(), loadMastodonPublishing()]);
Promise.all([loadTags(), loadMastodonPublishing(), loadConfig()]).then(updateCharacterCount);
})
.catch(() => {
localStorage.removeItem('linklogAccessToken');
@@ -61,6 +64,17 @@
entryForm.classList.add('hidden');
});
async function loadConfig() {
try {
const response = await fetch('/api/public/config');
if (!response.ok) throw new Error('Could not load config');
const config = await response.json();
if (config.max_post_characters) maxPostCharacters = config.max_post_characters;
} catch (error) {
console.error('Could not load config:', error);
}
}
// Load available tags
async function loadTags() {
try {
@@ -82,11 +96,63 @@
const config = await response.json();
mastodonEnabledCheckbox.checked = Boolean(config.configured);
mastodonPublishing.classList.toggle('hidden', !config.configured);
if (config.post_prefix) {
mastodonPostPrefix = config.post_prefix;
} else if (config.hashtag) {
mastodonPostPrefix = `#${String(config.hashtag).trim().replace(/^#/, '')} `;
}
} catch (error) {
console.error('Could not load Mastodon configuration:', error);
}
}
// Collect the selected checkbox tags plus any typed new tags, mirroring the submit-time logic.
function collectTags() {
const tags = Array.from(selectedTags);
const newTags = newTagsInput.value.trim();
if (newTags) {
tags.push(...newTags.split(',').map((t) => t.trim()).filter(Boolean));
}
return tags;
}
// Format a UTC timestamp the same way the Mastodon plugin does, so the estimated post length matches the server.
function formatMastodonTimestamp(date) {
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const pad = (value) => String(value).padStart(2, '0');
return `${date.getUTCFullYear()} ${months[date.getUTCMonth()]} ${pad(date.getUTCDate())} - ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`;
}
// Estimate the assembled Mastodon post length, mirroring backend/app/services/plugin_manager.py.
function estimatePostLength() {
const title = titleInput.value.trim();
const comment = commentInput.value.trim();
const url = removeKnownTrackingParams(urlInput.value.trim());
const parts = [mastodonPostPrefix.trim()];
if (title) parts.push(title);
if (comment) parts.push(comment);
if (title) parts.push(`Logged on ${formatMastodonTimestamp(new Date())} UTC from: ${url}`);
const tags = collectTags();
if (tags.length) parts.push(tags.join(' '));
return parts.join('\n\n').length;
}
function updateCharacterCount() {
const length = estimatePostLength();
const overLimit = length > maxPostCharacters;
characterCount.textContent = overLimit
? `${length}/${maxPostCharacters} characters - exceeds the Mastodon post limit by ${length - maxPostCharacters}`
: `${length}/${maxPostCharacters} characters`;
characterCount.classList.toggle('over-limit', overLimit);
}
[urlInput, titleInput, commentInput, newTagsInput].forEach((input) => {
input.addEventListener('input', updateCharacterCount);
});
// Render tag checkboxes
function renderTags() {
existingTagsEl.innerHTML = '';
@@ -104,6 +170,7 @@
} else {
selectedTags.delete(tag);
}
updateCharacterCount();
});
label.appendChild(checkbox);
@@ -224,22 +291,13 @@
const url = removeKnownTrackingParams(urlInput.value.trim());
const title = titleInput.value.trim();
const comment = commentInput.value.trim();
const newTags = newTagsInput.value.trim();
if (!url || !title) {
setStatus(submitStatus, 'URL and title are required', true);
return;
}
// Combine selected tags and new tags
const tags = Array.from(selectedTags);
if (newTags) {
const newTagsList = newTags
.split(',')
.map((t) => t.trim())
.filter((t) => t);
tags.push(...newTagsList);
}
const tags = collectTags();
submitButton.disabled = true;
setStatus(submitStatus, 'Saving...', false);
+12
View File
@@ -1242,6 +1242,18 @@ button:disabled {
display: none;
}
.char-count {
margin: -8px 0 0;
color: var(--muted);
font-size: 0.82rem;
text-align: right;
}
.char-count.over-limit {
color: var(--red);
font-weight: 600;
}
@media (max-width: 600px) {
.container {
padding: 0 14px;
+1 -1
View File
@@ -60,7 +60,7 @@
<section class="link-item">
<h2>Plugin</h2>
<p>Install the Firefox plugin to save links directly from your browser. <a
href="https://git.kolkman.org/olaf/Link-Log/raw/branch/main/XPI/signed/LinkLog-0.2.0.xpi"
href="https://git.kolkman.org/olaf/Link-Log/raw/branch/main/XPI/signed/LinkLog-0.3.0.xpi"
download>Download and install the Plugin</a>.</p>
</section>
</main>
+4 -4
View File
@@ -57,14 +57,14 @@
<option value="">All users</option>
</select>
</label>
<label class="search-control">
Search
<input id="search-input" type="search" placeholder="Search links" autocomplete="off" aria-label="Search links" />
</label>
<label>
Per page
<select id="page-size-select"></select>
</label>
<label class="search-control">
Search
<input id="search-input" type="search" placeholder="Search links" autocomplete="off" aria-label="Search links" />
</label>
</section>
</div>
</div>
+3 -1
View File
@@ -71,6 +71,7 @@
<textarea id="comment-input" name="comment" rows="4" placeholder="Optional comment about this link"></textarea>
</label>
</div>
<div id="character-count" class="char-count" aria-live="polite">0/500 characters</div>
<fieldset class="form-section">
<legend>Tags</legend>
@@ -89,6 +90,7 @@
</label>
</fieldset>
<div class="form-actions">
<button id="submit-button" type="submit">Save Entry</button>
<a href="/" class="secondary button">Cancel</a>
@@ -103,6 +105,6 @@
</footer>
<script src="/static/auth-header.js"></script>
<script src="/static/new-entry.js?v=8"></script>
<script src="/static/new-entry.js?v=9"></script>
</body>
</html>
+3 -1
View File
@@ -53,5 +53,7 @@
"loginFailed": {"message": "Anmeldung fehlgeschlagen"},
"loggedInSuccessfully": {"message": "Erfolgreich angemeldet"},
"unableToLogIn": {"message": "Anmeldung nicht möglich. Überprüfe Backend-URL und Zugangsdaten."},
"signedOut": {"message": "Abgemeldet"}
"signedOut": {"message": "Abgemeldet"},
"characterCount": {"message": "$USED$/$LIMIT$ Zeichen", "placeholders": {"used": {"content": "$1"}, "limit": {"content": "$2"}}},
"characterCountOverLimit": {"message": "$USED$/$LIMIT$ Zeichen - überschreitet das Mastodon-Beitragslimit um $OVER$", "placeholders": {"used": {"content": "$1"}, "limit": {"content": "$2"}, "over": {"content": "$3"}}}
}
+25
View File
@@ -181,5 +181,30 @@
},
"signedOut": {
"message": "Signed out"
},
"characterCount": {
"message": "$USED$/$LIMIT$ characters",
"placeholders": {
"used": {
"content": "$1"
},
"limit": {
"content": "$2"
}
}
},
"characterCountOverLimit": {
"message": "$USED$/$LIMIT$ characters - exceeds the Mastodon post limit by $OVER$",
"placeholders": {
"used": {
"content": "$1"
},
"limit": {
"content": "$2"
},
"over": {
"content": "$3"
}
}
}
}
+3 -1
View File
@@ -53,5 +53,7 @@
"loginFailed": {"message": "Error de inicio de sesión"},
"loggedInSuccessfully": {"message": "Sesión iniciada correctamente"},
"unableToLogIn": {"message": "No se pudo iniciar sesión. Comprueba la URL y las credenciales."},
"signedOut": {"message": "Sesión cerrada"}
"signedOut": {"message": "Sesión cerrada"},
"characterCount": {"message": "$USED$/$LIMIT$ caracteres", "placeholders": {"used": {"content": "$1"}, "limit": {"content": "$2"}}},
"characterCountOverLimit": {"message": "$USED$/$LIMIT$ caracteres: supera el límite de publicación de Mastodon en $OVER$", "placeholders": {"used": {"content": "$1"}, "limit": {"content": "$2"}, "over": {"content": "$3"}}}
}
+3 -1
View File
@@ -53,5 +53,7 @@
"loginFailed": {"message": "Échec de la connexion"},
"loggedInSuccessfully": {"message": "Connexion réussie"},
"unableToLogIn": {"message": "Connexion impossible. Vérifiez l’URL du serveur et vos identifiants."},
"signedOut": {"message": "Déconnecté"}
"signedOut": {"message": "Déconnecté"},
"characterCount": {"message": "$USED$/$LIMIT$ caractères", "placeholders": {"used": {"content": "$1"}, "limit": {"content": "$2"}}},
"characterCountOverLimit": {"message": "$USED$/$LIMIT$ caractères : dépasse la limite de publication Mastodon de $OVER$", "placeholders": {"used": {"content": "$1"}, "limit": {"content": "$2"}, "over": {"content": "$3"}}}
}
+3 -1
View File
@@ -53,5 +53,7 @@
"loginFailed": {"message": "Inloggen mislukt"},
"loggedInSuccessfully": {"message": "Succesvol ingelogd"},
"unableToLogIn": {"message": "Inloggen mislukt. Controleer de backend-URL en inloggegevens."},
"signedOut": {"message": "Uitgelogd"}
"signedOut": {"message": "Uitgelogd"},
"characterCount": {"message": "$USED$/$LIMIT$ tekens", "placeholders": {"used": {"content": "$1"}, "limit": {"content": "$2"}}},
"characterCountOverLimit": {"message": "$USED$/$LIMIT$ tekens - overschrijdt de Mastodon-postlimiet met $OVER$", "placeholders": {"used": {"content": "$1"}, "limit": {"content": "$2"}, "over": {"content": "$3"}}}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "__MSG_extensionName__",
"version": "0.2.0",
"version": "0.3.0",
"description": "__MSG_extensionDescription__",
"default_locale": "en-US",
"permissions": [
+12
View File
@@ -149,6 +149,18 @@ button {
color: #f38ba8;
}
.char-count {
margin: -6px 0 10px;
color: #a6adc8;
font-size: 0.75rem;
text-align: right;
}
.char-count.over-limit {
color: #f38ba8;
font-weight: 600;
}
.auth-session {
margin-bottom: 10px;
border: 1px solid #585b70;
+1
View File
@@ -37,6 +37,7 @@
<span data-i18n="commentLabel">Comment</span>
<textarea id="comment" name="comment" rows="3" data-i18n-placeholder="commentPlaceholder" placeholder="Your comment"></textarea>
</label>
<div id="character-count" class="char-count" aria-live="polite"></div>
<fieldset>
<legend data-i18n="tagsLabel">Tags</legend>
+82 -1
View File
@@ -14,10 +14,14 @@ const authWarning = document.getElementById('auth-warning');
const warningSettingsButton = document.getElementById('warning-settings');
const authSession = document.getElementById('auth-session');
const refetchTitleButton = document.getElementById('refetch-title');
const characterCountEl = document.getElementById('character-count');
const sessionStore = browser.storage.session;
const t = window.linklogI18n;
let maxPostCharacters = 500;
let mastodonPostPrefix = 'From my #LinkLog: ';
function normalizeBackendOrigin(backendUrl) {
try {
const url = new URL(backendUrl);
@@ -192,9 +196,11 @@ async function loadExistingTags() {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = tag;
checkbox.addEventListener('change', updateCharacterCount);
label.append(checkbox, document.createTextNode(` ${tag}`));
return label;
}));
updateCharacterCount();
}
function getTags() {
@@ -203,6 +209,79 @@ function getTags() {
return [...new Set([...selected, ...newTags])].slice(0, 10);
}
// Load public app settings (e.g. the Mastodon post length limit) once a backend is configured.
async function loadConfig() {
const settings = await getSettings();
if (!settings.backendUrl || !(await hasBackendPermission(settings.backendUrl))) return;
try {
const response = await fetch(`${settings.backendUrl}/api/public/config`);
if (!response.ok) return;
const config = await response.json();
if (config.max_post_characters) maxPostCharacters = config.max_post_characters;
} catch (error) {
// Character-count warning is advisory; ignore config load failures.
}
updateCharacterCount();
}
// Load the user's configured Mastodon post prefix, so the estimated post length matches the server.
async function loadMastodonPrefix() {
const settings = await getSettings();
if (!settings.backendUrl || !settings.accessToken || !(await hasBackendPermission(settings.backendUrl))) return;
try {
const response = await fetch(`${settings.backendUrl}/api/user/plugins/mastodon`, {
headers: {'Authorization': `Bearer ${settings.accessToken}`},
});
if (!response.ok) return;
const config = await response.json();
if (config.post_prefix) {
mastodonPostPrefix = config.post_prefix;
} else if (config.hashtag) {
mastodonPostPrefix = `#${String(config.hashtag).trim().replace(/^#/, '')} `;
}
} catch (error) {
// Character-count warning is advisory; ignore config load failures.
}
updateCharacterCount();
}
// Format a UTC timestamp the same way the Mastodon plugin does, so the estimated post length matches the server.
function formatMastodonTimestamp(date) {
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const pad = (value) => String(value).padStart(2, '0');
return `${date.getUTCFullYear()} ${months[date.getUTCMonth()]} ${pad(date.getUTCDate())} - ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`;
}
// Estimate the assembled Mastodon post length, mirroring backend/app/services/plugin_manager.py.
function estimatePostLength() {
const title = titleInput.value.trim();
const comment = commentInput.value.trim();
const url = removeKnownTrackingParams(urlInput.value.trim());
const parts = [mastodonPostPrefix.trim()];
if (title) parts.push(title);
if (comment) parts.push(comment);
if (title) parts.push(`Logged on ${formatMastodonTimestamp(new Date())} UTC from: ${url}`);
const tags = getTags();
if (tags.length) parts.push(tags.join(' '));
return parts.join('\n\n').length;
}
function updateCharacterCount() {
const length = estimatePostLength();
const overLimit = length > maxPostCharacters;
characterCountEl.textContent = overLimit
? t('characterCountOverLimit', [String(length), String(maxPostCharacters), String(length - maxPostCharacters)])
: t('characterCount', [String(length), String(maxPostCharacters)]);
characterCountEl.classList.toggle('over-limit', overLimit);
}
[titleInput, urlInput, commentInput, newTagsInput].forEach((input) => {
input.addEventListener('input', updateCharacterCount);
});
function removeKnownTrackingParams(urlString) {
try {
const url = new URL(urlString);
@@ -361,6 +440,8 @@ refetchTitleButton.addEventListener('click', (e) => {
titleInput.value = '';
fetchTitle(url, { force: true });
});
populateCurrentTab().then(checkExistingLink);
populateCurrentTab().then(checkExistingLink).then(updateCharacterCount);
loadExistingTags();
updateFeedLink();
loadConfig();
loadMastodonPrefix();
+10
View File
@@ -2,6 +2,16 @@
"addons": {
"linklog@kolkman.org": {
"updates": [
{
"version": "0.3.0",
"update_link": "https://git.kolkman.org/olaf/Link-Log/raw/branch/main/XPI/signed/LinkLog-0.3.0.xpi",
"update_hash": "sha256:c4cc16a7e96885caa759c803dca90dd43fdf0befc43457655f6eeb6fd51a4e7f",
"applications": {
"gecko": {
"strict_min_version": "142.0"
}
}
},
{
"version": "0.2.0",
"update_link": "https://git.kolkman.org/olaf/Link-Log/raw/branch/main/XPI/signed/LinkLog-0.2.0.xpi",