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_PASSWORD_RESET_EXPIRY_HOURS=1
LINKLOG_MASTODON_CLIENT_NAME=LinkLog LINKLOG_MASTODON_CLIENT_NAME=LinkLog
LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES=10 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 LINKLOG_LOG_LEVEL=INFO
# Optional comma-separated override. Leave empty to use the built-in list. # Optional comma-separated override. Leave empty to use the built-in list.
LINKLOG_TRACKING_PARAMS= 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) * 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 * 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 * 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 ### Fixed
* Restored tag and user filtering on the feed: the decorative header arc no longer intercepts clicks on the filter dropdowns * 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 ### 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. 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 # 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. 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 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 ## Future entries
Binary file not shown.
Binary file not shown.
+5 -1
View File
@@ -28,7 +28,11 @@ def public_themes():
@router.get('/config') @router.get('/config')
def public_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') @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')) 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_client_name: str = os.getenv('LINKLOG_MASTODON_CLIENT_NAME', 'LinkLog')
mastodon_oauth_expiry_minutes: int = int(os.getenv('LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES', '10')) 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() log_level: str = os.getenv('LINKLOG_LOG_LEVEL', 'INFO').upper()
tracking_params: list[str] = None tracking_params: list[str] = None
feed_page_sizes: list[int] = None feed_page_sizes: list[int] = None
+69 -11
View File
@@ -16,11 +16,14 @@
const refetchTitleButton = document.getElementById('refetch-title-button'); 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');
const characterCount = document.getElementById('character-count');
const token = localStorage.getItem('linklogAccessToken'); const token = localStorage.getItem('linklogAccessToken');
let availableTags = []; let availableTags = [];
let selectedTags = new Set(); let selectedTags = new Set();
let currentUser = null; 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. // 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) {
@@ -53,7 +56,7 @@
.then((user) => { .then((user) => {
currentUser = user; currentUser = user;
entryForm.classList.remove('hidden'); entryForm.classList.remove('hidden');
Promise.all([loadTags(), loadMastodonPublishing()]); Promise.all([loadTags(), loadMastodonPublishing(), loadConfig()]).then(updateCharacterCount);
}) })
.catch(() => { .catch(() => {
localStorage.removeItem('linklogAccessToken'); localStorage.removeItem('linklogAccessToken');
@@ -61,6 +64,17 @@
entryForm.classList.add('hidden'); 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 // Load available tags
async function loadTags() { async function loadTags() {
try { try {
@@ -82,11 +96,63 @@
const config = await response.json(); const config = await response.json();
mastodonEnabledCheckbox.checked = Boolean(config.configured); mastodonEnabledCheckbox.checked = Boolean(config.configured);
mastodonPublishing.classList.toggle('hidden', !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) { } catch (error) {
console.error('Could not load Mastodon configuration:', 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 // Render tag checkboxes
function renderTags() { function renderTags() {
existingTagsEl.innerHTML = ''; existingTagsEl.innerHTML = '';
@@ -104,6 +170,7 @@
} else { } else {
selectedTags.delete(tag); selectedTags.delete(tag);
} }
updateCharacterCount();
}); });
label.appendChild(checkbox); label.appendChild(checkbox);
@@ -224,22 +291,13 @@
const url = removeKnownTrackingParams(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();
if (!url || !title) { if (!url || !title) {
setStatus(submitStatus, 'URL and title are required', true); setStatus(submitStatus, 'URL and title are required', true);
return; return;
} }
// Combine selected tags and new tags const tags = collectTags();
const tags = Array.from(selectedTags);
if (newTags) {
const newTagsList = newTags
.split(',')
.map((t) => t.trim())
.filter((t) => t);
tags.push(...newTagsList);
}
submitButton.disabled = true; submitButton.disabled = true;
setStatus(submitStatus, 'Saving...', false); setStatus(submitStatus, 'Saving...', false);
+12
View File
@@ -1242,6 +1242,18 @@ button:disabled {
display: none; 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) { @media (max-width: 600px) {
.container { .container {
padding: 0 14px; padding: 0 14px;
+1 -1
View File
@@ -60,7 +60,7 @@
<section class="link-item"> <section class="link-item">
<h2>Plugin</h2> <h2>Plugin</h2>
<p>Install the Firefox plugin to save links directly from your browser. <a <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> download>Download and install the Plugin</a>.</p>
</section> </section>
</main> </main>
+4 -4
View File
@@ -57,14 +57,14 @@
<option value="">All users</option> <option value="">All users</option>
</select> </select>
</label> </label>
<label class="search-control">
Search
<input id="search-input" type="search" placeholder="Search links" autocomplete="off" aria-label="Search links" />
</label>
<label> <label>
Per page Per page
<select id="page-size-select"></select> <select id="page-size-select"></select>
</label> </label>
<label class="search-control">
Search
<input id="search-input" type="search" placeholder="Search links" autocomplete="off" aria-label="Search links" />
</label>
</section> </section>
</div> </div>
</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> <textarea id="comment-input" name="comment" rows="4" placeholder="Optional comment about this link"></textarea>
</label> </label>
</div> </div>
<div id="character-count" class="char-count" aria-live="polite">0/500 characters</div>
<fieldset class="form-section"> <fieldset class="form-section">
<legend>Tags</legend> <legend>Tags</legend>
@@ -89,6 +90,7 @@
</label> </label>
</fieldset> </fieldset>
<div class="form-actions"> <div class="form-actions">
<button id="submit-button" type="submit">Save Entry</button> <button id="submit-button" type="submit">Save Entry</button>
<a href="/" class="secondary button">Cancel</a> <a href="/" class="secondary button">Cancel</a>
@@ -103,6 +105,6 @@
</footer> </footer>
<script src="/static/auth-header.js"></script> <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> </body>
</html> </html>
+3 -1
View File
@@ -53,5 +53,7 @@
"loginFailed": {"message": "Anmeldung fehlgeschlagen"}, "loginFailed": {"message": "Anmeldung fehlgeschlagen"},
"loggedInSuccessfully": {"message": "Erfolgreich angemeldet"}, "loggedInSuccessfully": {"message": "Erfolgreich angemeldet"},
"unableToLogIn": {"message": "Anmeldung nicht möglich. Überprüfe Backend-URL und Zugangsdaten."}, "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": { "signedOut": {
"message": "Signed out" "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"}, "loginFailed": {"message": "Error de inicio de sesión"},
"loggedInSuccessfully": {"message": "Sesión iniciada correctamente"}, "loggedInSuccessfully": {"message": "Sesión iniciada correctamente"},
"unableToLogIn": {"message": "No se pudo iniciar sesión. Comprueba la URL y las credenciales."}, "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"}, "loginFailed": {"message": "Échec de la connexion"},
"loggedInSuccessfully": {"message": "Connexion réussie"}, "loggedInSuccessfully": {"message": "Connexion réussie"},
"unableToLogIn": {"message": "Connexion impossible. Vérifiez l’URL du serveur et vos identifiants."}, "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"}, "loginFailed": {"message": "Inloggen mislukt"},
"loggedInSuccessfully": {"message": "Succesvol ingelogd"}, "loggedInSuccessfully": {"message": "Succesvol ingelogd"},
"unableToLogIn": {"message": "Inloggen mislukt. Controleer de backend-URL en inloggegevens."}, "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, "manifest_version": 3,
"name": "__MSG_extensionName__", "name": "__MSG_extensionName__",
"version": "0.2.0", "version": "0.3.0",
"description": "__MSG_extensionDescription__", "description": "__MSG_extensionDescription__",
"default_locale": "en-US", "default_locale": "en-US",
"permissions": [ "permissions": [
+12
View File
@@ -149,6 +149,18 @@ button {
color: #f38ba8; 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 { .auth-session {
margin-bottom: 10px; margin-bottom: 10px;
border: 1px solid #585b70; border: 1px solid #585b70;
+1
View File
@@ -37,6 +37,7 @@
<span data-i18n="commentLabel">Comment</span> <span data-i18n="commentLabel">Comment</span>
<textarea id="comment" name="comment" rows="3" data-i18n-placeholder="commentPlaceholder" placeholder="Your comment"></textarea> <textarea id="comment" name="comment" rows="3" data-i18n-placeholder="commentPlaceholder" placeholder="Your comment"></textarea>
</label> </label>
<div id="character-count" class="char-count" aria-live="polite"></div>
<fieldset> <fieldset>
<legend data-i18n="tagsLabel">Tags</legend> <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 warningSettingsButton = document.getElementById('warning-settings');
const authSession = document.getElementById('auth-session'); const authSession = document.getElementById('auth-session');
const refetchTitleButton = document.getElementById('refetch-title'); const refetchTitleButton = document.getElementById('refetch-title');
const characterCountEl = document.getElementById('character-count');
const sessionStore = browser.storage.session; const sessionStore = browser.storage.session;
const t = window.linklogI18n; const t = window.linklogI18n;
let maxPostCharacters = 500;
let mastodonPostPrefix = 'From my #LinkLog: ';
function normalizeBackendOrigin(backendUrl) { function normalizeBackendOrigin(backendUrl) {
try { try {
const url = new URL(backendUrl); const url = new URL(backendUrl);
@@ -192,9 +196,11 @@ async function loadExistingTags() {
const checkbox = document.createElement('input'); const checkbox = document.createElement('input');
checkbox.type = 'checkbox'; checkbox.type = 'checkbox';
checkbox.value = tag; checkbox.value = tag;
checkbox.addEventListener('change', updateCharacterCount);
label.append(checkbox, document.createTextNode(` ${tag}`)); label.append(checkbox, document.createTextNode(` ${tag}`));
return label; return label;
})); }));
updateCharacterCount();
} }
function getTags() { function getTags() {
@@ -203,6 +209,79 @@ function getTags() {
return [...new Set([...selected, ...newTags])].slice(0, 10); 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) { function removeKnownTrackingParams(urlString) {
try { try {
const url = new URL(urlString); const url = new URL(urlString);
@@ -361,6 +440,8 @@ refetchTitleButton.addEventListener('click', (e) => {
titleInput.value = ''; titleInput.value = '';
fetchTitle(url, { force: true }); fetchTitle(url, { force: true });
}); });
populateCurrentTab().then(checkExistingLink); populateCurrentTab().then(checkExistingLink).then(updateCharacterCount);
loadExistingTags(); loadExistingTags();
updateFeedLink(); updateFeedLink();
loadConfig();
loadMastodonPrefix();
+10
View File
@@ -2,6 +2,16 @@
"addons": { "addons": {
"linklog@kolkman.org": { "linklog@kolkman.org": {
"updates": [ "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", "version": "0.2.0",
"update_link": "https://git.kolkman.org/olaf/Link-Log/raw/branch/main/XPI/signed/LinkLog-0.2.0.xpi", "update_link": "https://git.kolkman.org/olaf/Link-Log/raw/branch/main/XPI/signed/LinkLog-0.2.0.xpi",