// =====================================================================
// Core page interactions for Shaarli Pro.
// Theme handling lives in js/modules/theme.js.
// =====================================================================
document.addEventListener('DOMContentLoaded', () => {
const i18n = window.ShaarliProI18n || { t: function (k) { return k; } };
// ===== Hidden Tags Management =====
const HIDDEN_TAGS_STORAGE_KEY = 'shaarli_hidden_tags';
// Default system tags that are hidden by default
// Harmonisé avec ShaarIt Android (PRESET_SYSTEM_TAGS).
const DEFAULT_HIDDEN_TAGS = [
'note',
'shaarli-note',
'todo',
'shaarli-todo',
'shaarli-pin',
'note-color-*',
'notebg-*',
'notefilter-*',
'font-*',
'readitlater',
'brain-dump',
'shaarli-archive'
];
// Get hidden tags from localStorage
function getHiddenTags() {
try {
const stored = localStorage.getItem(HIDDEN_TAGS_STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.error('Error loading hidden tags:', e);
}
return [...DEFAULT_HIDDEN_TAGS];
}
// Check if a tag matches a wildcard pattern
function matchesWildcard(pattern, tag) {
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$', 'i');
return regex.test(tag);
}
// Check if a tag should be hidden
function isTagHidden(tag) {
if (!tag) return false;
const normalizedTag = tag.toLowerCase().trim();
const hiddenTags = getHiddenTags();
return hiddenTags.some(hiddenTag => {
const normalizedHidden = hiddenTag.toLowerCase().trim();
if (normalizedHidden.includes('*')) {
return matchesWildcard(normalizedHidden, normalizedTag);
}
return normalizedHidden === normalizedTag;
});
}
// Filter out hidden tags from a list
function filterHiddenTags(tags) {
if (!Array.isArray(tags)) return [];
return tags.filter(tag => !isTagHidden(tag));
}
// Hide tag elements in the DOM
function hideHiddenTagElements() {
// Hide tag chips in bookmark cards
document.querySelectorAll('.link-tag[data-tag]').forEach(el => {
const tag = el.getAttribute('data-tag');
if (isTagHidden(tag)) {
el.style.display = 'none';
}
});
// Hide tags in tag list
document.querySelectorAll('#tagListContainer .list-group-item[data-tag-name]').forEach(el => {
const tagName = el.getAttribute('data-tag-name');
if (isTagHidden(tagName)) {
el.style.display = 'none';
}
});
// Hide tags in tag cloud
document.querySelectorAll('.tag-item[data-tag]').forEach(el => {
const tag = el.getAttribute('data-tag');
if (isTagHidden(tag)) {
el.style.display = 'none';
}
});
// Update visible tag count in tag list
const visibleTagCountEl = document.getElementById('visibleTagCount');
if (visibleTagCountEl) {
const visibleTags = document.querySelectorAll('#tagListContainer .list-group-item:not([style*="display: none"])');
visibleTagCountEl.textContent = visibleTags.length;
}
}
// Run on page load
hideHiddenTagElements();
// Set up MutationObserver to handle dynamically added tags
const tagObserver = new MutationObserver((mutations) => {
let shouldFilter = false;
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if added node contains tags
if (node.matches?.('.link-tag[data-tag], .tag-item[data-tag], [data-tag-name], .list-group-item[data-tag-name]') ||
node.querySelector?.('.link-tag[data-tag], .tag-item[data-tag], [data-tag-name], .list-group-item[data-tag-name]')) {
shouldFilter = true;
}
}
});
}
});
if (shouldFilter) {
hideHiddenTagElements();
}
});
// Start observing once DOM is ready
tagObserver.observe(document.body, {
childList: true,
subtree: true
});
// Expose utilities used by extracted modules
window.ShaarliProUtils = {
escapeHtml: function (text) {
if (text === null || text === undefined) return '';
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
},
isTagHidden: isTagHidden,
filterHiddenTags: filterHiddenTags
};
// ===== View Toggle (Grid/List/Compact) =====
const linksList = document.getElementById('links-list');
const viewGridBtn = document.getElementById('view-grid-btn');
const viewListBtn = document.getElementById('view-list-btn');
const viewCompactBtn = document.getElementById('view-compact-btn');
function setView(view) {
if (!linksList) return;
// Remove all view classes
linksList.classList.remove('view-grid', 'view-list', 'view-compact');
// Remove active state from all buttons
viewGridBtn?.classList.remove('active');
viewListBtn?.classList.remove('active');
viewCompactBtn?.classList.remove('active');
// Apply selected view
if (view === 'list') {
linksList.classList.add('view-list');
viewListBtn?.classList.add('active');
} else if (view === 'compact') {
linksList.classList.add('view-compact');
viewCompactBtn?.classList.add('active');
} else {
// Default to grid
linksList.classList.add('view-grid');
viewGridBtn?.classList.add('active');
}
localStorage.setItem('linksView', view);
}
// Init view from localStorage
const savedView = localStorage.getItem('linksView') || 'grid';
setView(savedView);
viewGridBtn?.addEventListener('click', () => setView('grid'));
viewListBtn?.addEventListener('click', () => setView('list'));
viewCompactBtn?.addEventListener('click', () => setView('compact'));
// ===== Thumbnail Update =====
const thumbnailsPage = document.querySelector('.page-thumbnails');
if (thumbnailsPage) {
const thumbnailPlaceholder = document.querySelector('.thumbnail-placeholder');
const thumbnailTitle = document.querySelector('.thumbnail-link-title');
const progressCurrent = document.querySelector('.progress-current');
const progressBarActual = document.querySelector('.progress-actual');
const idsInput = document.querySelector('input[name="ids"]');
if (idsInput && idsInput.value) {
const thumbnailsIdList = idsInput.value.split(',');
const total = thumbnailsIdList.length;
let i = 0;
const updateThumbnail = function (id) {
fetch(shaarli.basePath + '/admin/shaare/' + id + '/update-thumbnail', {
method: 'PATCH',
headers: {
'Accept': 'application/json',
}
})
.then(response => response.json())
.then(data => {
i++;
if (thumbnailTitle) {
thumbnailTitle.textContent = data.title || i18n.t('Processing...');
}
if (thumbnailPlaceholder) {
thumbnailPlaceholder.innerHTML = '';
if (data.thumbnail) {
const img = document.createElement('img');
img.title = i18n.t('Current thumbnail');
img.src = data.thumbnail;
img.style.maxWidth = '100%';
img.style.height = 'auto';
thumbnailPlaceholder.appendChild(img);
}
}
if (progressCurrent) {
progressCurrent.textContent = i;
}
if (progressBarActual) {
progressBarActual.style.width = ((i * 100) / total) + '%';
}
if (i < total) {
updateThumbnail(thumbnailsIdList[i]);
} else {
if (thumbnailTitle) {
thumbnailTitle.textContent = i18n.t('Thumbnail update done!');
}
}
})
.catch(error => {
console.error('Failed to update thumbnail:', error);
if (thumbnailTitle) {
thumbnailTitle.textContent = i18n.t('Error updating thumbnail #{index}', { index: i });
}
// Continue with next thumbnail even if one fails
i++;
if (i < total) {
updateThumbnail(thumbnailsIdList[i]);
}
});
};
// Start the update process
updateThumbnail(thumbnailsIdList[0]);
}
}
// ===== Fullscreen Description Modal =====
const descModal = document.getElementById('desc-modal');
const descModalBody = document.getElementById('desc-modal-body');
const descModalClose = document.getElementById('desc-modal-close');
function openDescModal(contentNode) {
if (descModal && descModalBody) {
descModalBody.innerHTML = '';
if (contentNode) {
descModalBody.appendChild(contentNode);
}
descModal.classList.add('show');
document.body.style.overflow = 'hidden'; // Prevent background scrolling
}
}
function closeDescModal() {
if (descModal) {
descModal.classList.remove('show');
document.body.style.overflow = '';
}
}
descModalClose?.addEventListener('click', closeDescModal);
descModal?.addEventListener('click', (e) => {
if (e.target === descModal) {
closeDescModal();
}
});
// Press ESC to close description modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && descModal?.classList.contains('show')) {
closeDescModal();
}
});
// Delegate click event for dynamic buttons
document.addEventListener('click', (e) => {
const btn = e.target.closest('.view-desc-btn');
if (btn) {
e.preventDefault();
const id = btn.dataset.id;
const card = document.getElementById(id);
if (card) {
// Try to find description element (even if hidden in compact view)
const descEl = card.querySelector('.link-description');
if (descEl) {
openDescModal(descEl.cloneNode(true));
} else {
const emptyMsg = document.createElement('p');
emptyMsg.className = 'text-muted';
emptyMsg.textContent = i18n.t('No description available.');
openDescModal(emptyMsg);
}
}
}
});
// ===== QR Code Plugin Modal =====
const qrcodeModal = document.getElementById('qrcode-modal');
const qrcodeModalBody = document.getElementById('qrcode-modal-body');
const qrcodeModalClose = document.getElementById('qrcode-modal-close');
function openQrcodeModal(permalink, title) {
if (!qrcodeModal || !qrcodeModalBody) return;
function setStatusMessage(text) {
qrcodeModalBody.innerHTML = '';
const div = document.createElement('div');
div.style.padding = '2rem';
div.style.color = 'var(--text-muted)';
div.textContent = text;
qrcodeModalBody.appendChild(div);
}
// Show loading state
setStatusMessage(i18n.t('Generating QR Code...'));
qrcodeModal.classList.add('show');
document.body.style.overflow = 'hidden';
// Generate QR code using the qr.js library (loaded by the Shaarli qrcode plugin)
function renderQR() {
if (typeof qr !== 'undefined') {
const image = qr.image({ size: 8, value: permalink });
if (image) {
qrcodeModalBody.innerHTML = '';
image.style.maxWidth = '100%';
image.style.borderRadius = '0.5rem';
image.style.background = 'white';
image.style.padding = '0.75rem';
qrcodeModalBody.appendChild(image);
if (title) {
const titleDiv = document.createElement('div');
titleDiv.className = 'qrcode-modal-title';
titleDiv.textContent = title;
qrcodeModalBody.appendChild(titleDiv);
}
} else {
setStatusMessage(i18n.t('Failed to generate QR Code'));
}
} else {
// qr.js library not yet loaded — load it dynamically
const basePath = document.querySelector('input[name="js_base_path"]')?.value || '';
const script = document.createElement('script');
script.src = basePath + '/plugins/qrcode/qr-1.1.3.min.js';
document.body.appendChild(script);
setTimeout(() => renderQR(), 300);
}
}
renderQR();
}
function closeQrcodeModal() {
if (qrcodeModal) {
qrcodeModal.classList.remove('show');
document.body.style.overflow = '';
}
}
qrcodeModalClose?.addEventListener('click', closeQrcodeModal);
qrcodeModal?.addEventListener('click', (e) => {
if (e.target === qrcodeModal) closeQrcodeModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && qrcodeModal?.classList.contains('show')) closeQrcodeModal();
});
// ===== Transform QR code plugin icons =====
document.querySelectorAll('.link-plugin .linkqrcode, .link-plugin img.qrcode').forEach(el => {
// The QR code plugin injects:
![]()
const img = el.tagName === 'IMG' ? el : el.querySelector('img.qrcode');
if (!img) return;
const permalink = img.dataset.permalink || '';
const parentLink = img.closest('a') || img.parentElement;
// Replace img with MDI icon
const icon = document.createElement('i');
icon.className = 'mdi mdi-qrcode';
if (parentLink.tagName === 'A' || parentLink.classList.contains('linkqrcode')) {
// Wrap in a clickable element if not already
const btn = document.createElement('a');
btn.href = '#';
btn.className = 'qrcode-trigger';
btn.title = i18n.t('QR Code');
btn.dataset.permalink = permalink;
btn.appendChild(icon);
// Replace the whole linkqrcode div or img with our button
const wrapper = el.classList.contains('linkqrcode') ? el : el.closest('.linkqrcode') || el;
wrapper.replaceWith(btn);
}
});
// Click handler for QR code icons
document.addEventListener('click', (e) => {
const trigger = e.target.closest('.qrcode-trigger, .link-plugin img.qrcode');
if (!trigger) return;
e.preventDefault();
e.stopPropagation();
const permalink = trigger.dataset?.permalink ||
trigger.querySelector('img')?.dataset?.permalink || '';
const card = trigger.closest('.link-outer');
const title = card?.querySelector('.link-title')?.textContent?.trim() || permalink;
if (permalink) {
openQrcodeModal(permalink, title);
}
});
// ===== Read It Later (tag-based, no plugin dependency) =====
const READ_IT_LATER_TAG = 'readitlater';
const READ_IT_LATER_ALIASES = ['readitlater', 'readlater', 'toread'];
const normalizeTagValue = (tagValue) => (tagValue || '').trim().toLowerCase();
const isReadItLaterTag = (tagValue) => READ_IT_LATER_ALIASES.includes(normalizeTagValue(tagValue));
function getReadItLaterEditUrl(card) {
if (!card) return '';
const id = card.dataset.id;
if (id) {
return `${shaarli.basePath}/admin/shaare/${encodeURIComponent(id)}`;
}
const editLink = card.querySelector('.link-actions a[href*="/admin/shaare/"]:not([href*="/pin"]):not([href*="/delete"])');
return editLink ? editLink.href : '';
}
function collectBookmarkFormData(form) {
const formData = new URLSearchParams();
const inputs = form.querySelectorAll('input, textarea, select');
inputs.forEach((input) => {
if (!input.name || input.disabled) return;
if (input.type === 'checkbox') {
if (input.checked) {
formData.append(input.name, input.value || 'on');
}
return;
}
if (input.type === 'radio' && !input.checked) {
return;
}
formData.append(input.name, input.value || '');
});
return formData;
}
async function updateReadItLaterTag(editUrl, enableTag) {
const editResponse = await fetch(editUrl, {
method: 'GET',
credentials: 'same-origin',
});
if (!editResponse.ok) {
throw new Error(i18n.t('Unable to load bookmark edit form.'));
}
const html = await editResponse.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const form = doc.querySelector('form[name="linkform"]');
if (!form) {
throw new Error(i18n.t('Bookmark edit form not found.'));
}
const formData = collectBookmarkFormData(form);
const existingTags = (formData.get('lf_tags') || '')
.split(/[\s,]+/)
.map((tag) => tag.trim())
.filter(Boolean)
.filter((tag) => !isReadItLaterTag(tag));
if (enableTag) {
existingTags.push(READ_IT_LATER_TAG);
}
formData.set('lf_tags', existingTags.join(' '));
formData.set('save_edit', '1');
const submitResponse = await fetch(form.action, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
credentials: 'same-origin',
});
if (!submitResponse.ok) {
throw new Error(i18n.t('Unable to save bookmark tags.'));
}
}
function syncReadItLaterTagPill(card, isActive) {
const tagList = card.querySelector('.link-tag-list');
if (!tagList) return;
const existingPill = Array.from(tagList.querySelectorAll('.link-tag')).find((tagEl) => {
const rawTag = tagEl.dataset.tag || tagEl.querySelector('.link-tag-link')?.textContent || tagEl.textContent || '';
return normalizeTagValue(rawTag) === READ_IT_LATER_TAG;
});
if (!isActive) {
existingPill?.remove();
return;
}
if (existingPill) return;
const pill = document.createElement('span');
pill.className = 'link-tag';
pill.dataset.tag = READ_IT_LATER_TAG;
const link = document.createElement('a');
link.className = 'link-tag-link';
link.href = `${shaarli.basePath}/add-tag/${encodeURIComponent(READ_IT_LATER_TAG)}`;
link.textContent = READ_IT_LATER_TAG;
pill.appendChild(link);
if (shaarli.isAuth) {
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'tag-remove-btn';
removeBtn.dataset.tag = READ_IT_LATER_TAG;
removeBtn.setAttribute('aria-label', i18n.t('Delete tag {tag}', { tag: READ_IT_LATER_TAG }));
removeBtn.title = i18n.t('Delete');
removeBtn.textContent = '×';
pill.appendChild(removeBtn);
}
tagList.appendChild(pill);
}
function syncReadItLaterCardUI(card, isActive) {
if (!card) return;
card.classList.toggle('readitlater-active', isActive);
let badge = card.querySelector('.link-readlater-badge');
if (isActive && !badge) {
badge = document.createElement('div');
badge.className = 'link-readlater-badge';
badge.textContent = i18n.t('Read Later');
card.appendChild(badge);
} else if (!isActive && badge) {
badge.remove();
}
const toggleBtn = card.querySelector('.readitlater-toggle-btn');
if (toggleBtn) {
const titleText = isActive ? i18n.t('Remove from Read It Later') : i18n.t('Add to Read It Later');
toggleBtn.classList.toggle('active', isActive);
toggleBtn.dataset.active = isActive ? '1' : '0';
toggleBtn.setAttribute('title', titleText);
toggleBtn.setAttribute('aria-label', titleText);
const icon = toggleBtn.querySelector('i');
if (icon) {
icon.className = `mdi ${isActive ? 'mdi-bookmark-clock' : 'mdi-bookmark-clock-outline'}`;
}
}
syncReadItLaterTagPill(card, isActive);
}
document.querySelectorAll('.link-outer').forEach((card) => {
const cardTags = Array.from(card.querySelectorAll('.link-tag')).map((tagEl) => {
const rawTag = tagEl.dataset.tag || tagEl.querySelector('.link-tag-link')?.textContent || tagEl.textContent || '';
return normalizeTagValue(rawTag);
});
const isActive = cardTags.some(isReadItLaterTag) || card.classList.contains('readitlater-active') || card.classList.contains('readitlater-unread');
syncReadItLaterCardUI(card, isActive);
});
document.addEventListener('click', async (event) => {
const toggleBtn = event.target.closest('.readitlater-toggle-btn');
if (!toggleBtn) return;
event.preventDefault();
event.stopPropagation();
const card = toggleBtn.closest('.link-outer');
if (!card) return;
const editUrl = getReadItLaterEditUrl(card);
if (!editUrl) return;
const isActive = card.classList.contains('readitlater-active') || toggleBtn.dataset.active === '1';
const nextState = !isActive;
if (toggleBtn.disabled) return;
toggleBtn.disabled = true;
toggleBtn.classList.add('is-loading');
try {
await updateReadItLaterTag(editUrl, nextState);
syncReadItLaterCardUI(card, nextState);
} catch (error) {
console.error('Failed to toggle readitlater tag:', error);
alert(i18n.t('Failed to toggle readitlater tag.'));
} finally {
toggleBtn.disabled = false;
toggleBtn.classList.remove('is-loading');
}
});
// ===== Bookmark Editor Form (Markdown + Tags UI) =====
function initBookmarkEditorForms() {
const forms = document.querySelectorAll('.bookmark-editor-form[data-batch-mode="0"]');
if (!forms.length) return;
const normalizeTag = (value) => value.trim().replace(/\s+/g, '-');
forms.forEach((form) => {
form.classList.add('is-enhanced');
const descriptionSource = form.querySelector('.bookmark-editor-source');
const editorMount = form.querySelector('.bookmark-markdown-editor');
const hiddenTagsInput = form.querySelector('.bookmark-tags-hidden-input');
const tagsInputContainer = form.querySelector('.bookmark-tags-input');
const tagsList = form.querySelector('.bookmark-tags-list');
const tagsTextInput = form.querySelector('.bookmark-tags-text-input');
const readLaterCheckbox = form.querySelector('.bookmark-toggle-readlater');
const noteCheckbox = form.querySelector('.bookmark-toggle-note');
const todoCheckbox = form.querySelector('.bookmark-toggle-todo');
const titleInput = form.querySelector('input[name="lf_title"]');
const isDarkTheme = document.documentElement.getAttribute('data-theme') === 'dark';
// Markdown editor
if (descriptionSource && editorMount && window.toastui && window.toastui.Editor) {
const previewStyle = window.innerWidth < 992 ? 'tab' : 'vertical';
let sourceSyncTimer = null;
const markdownEditor = new window.toastui.Editor({
el: editorMount,
height: '320px',
initialEditType: 'wysiwyg',
previewStyle,
initialValue: descriptionSource.value || '',
placeholder: descriptionSource.getAttribute('placeholder') || '',
theme: isDarkTheme ? 'dark' : undefined,
usageStatistics: false,
});
// Shaarli metadata script updates the textarea value asynchronously.
// Mirror that value into Toast UI only if the editor is still empty.
sourceSyncTimer = window.setInterval(() => {
const sourceValue = (descriptionSource.value || '').trim();
if (!sourceValue) return;
const editorValue = (markdownEditor.getMarkdown() || '').trim();
if (!editorValue) {
markdownEditor.setMarkdown(descriptionSource.value || '', false);
}
window.clearInterval(sourceSyncTimer);
sourceSyncTimer = null;
}, 250);
form.addEventListener('submit', () => {
if (sourceSyncTimer) {
window.clearInterval(sourceSyncTimer);
sourceSyncTimer = null;
}
descriptionSource.value = markdownEditor.getMarkdown();
});
} else if (editorMount) {
editorMount.style.display = 'none';
if (descriptionSource) {
descriptionSource.style.display = 'block';
}
}
// Pill tags
if (!hiddenTagsInput || !tagsList || !tagsTextInput) return;
const availableTags = (hiddenTagsInput.getAttribute('data-tag-options') || '')
.split(',')
.map((tag) => normalizeTag(tag))
.filter(Boolean);
const getSuggestionList = () => availableTags
.filter((tag) => !tags.some((existing) => existing.toLowerCase() === tag.toLowerCase()));
let tagsAutocomplete = null;
const refreshAutocomplete = () => {
if (!tagsAutocomplete) return;
tagsAutocomplete.list = getSuggestionList();
};
if (tagsInputContainer) {
tagsInputContainer.addEventListener('click', () => tagsTextInput.focus());
}
let tags = hiddenTagsInput.value
.split(/[\s,]+/)
.map((tag) => normalizeTag(tag))
.filter(Boolean);
// Remove duplicate tags (case-insensitive)
tags = tags.filter((tag, index, self) =>
index === self.findIndex((t) => t.toLowerCase() === tag.toLowerCase())
);
// Update return URL based on bookmark type (note vs regular)
const returnUrlInput = form.querySelector('input[name="returnurl"]');
if (returnUrlInput) {
form.addEventListener('submit', () => {
const isNote = noteCheckbox?.checked || tags.some((tag) => /^note$/i.test(tag));
if (isNote) {
returnUrlInput.value = `${shaarli.basePath}/?searchtags=note`;
}
});
}
const updateHiddenTags = () => {
hiddenTagsInput.value = tags.join(' ');
};
const syncReadLaterCheckbox = () => {
if (!readLaterCheckbox) return;
readLaterCheckbox.checked = tags.some((tag) => /^(readitlater|readlater|toread)$/i.test(tag));
};
const syncNoteCheckbox = () => {
if (!noteCheckbox) return;
// Reconnaît les conventions Android (`note`, `#note`) et legacy (`shaarli-note`).
noteCheckbox.checked = tags.some((tag) => /^(note|#note|shaarli-note)$/i.test(tag));
};
const syncTodoCheckbox = () => {
if (!todoCheckbox) return;
// Reconnaît les conventions Android (`todo`, `#todo`) et legacy (`shaarli-todo`).
todoCheckbox.checked = tags.some((tag) => /^(todo|#todo|shaarli-todo)$/i.test(tag));
};
// Helper functions to manage note emoji in title
const NOTE_EMOJI = '📝';
const TODO_EMOJI = '✅';
const hasNoteEmoji = (title) => {
return title && title.startsWith(NOTE_EMOJI);
};
const hasTodoEmoji = (title) => {
return title && title.startsWith(TODO_EMOJI);
};
const addNoteEmoji = (title) => {
if (!title) return NOTE_EMOJI + ' ';
if (hasNoteEmoji(title)) return title;
return NOTE_EMOJI + ' ' + title;
};
const addTodoEmoji = (title) => {
if (!title) return TODO_EMOJI + ' ';
if (hasTodoEmoji(title)) return title;
return TODO_EMOJI + ' ' + title;
};
const removeNoteEmoji = (title) => {
if (!title) return '';
if (title.startsWith(NOTE_EMOJI + ' ')) {
return title.substring(NOTE_EMOJI.length + 1);
}
if (title.startsWith(NOTE_EMOJI)) {
return title.substring(NOTE_EMOJI.length);
}
return title;
};
const removeTodoEmoji = (title) => {
if (!title) return '';
if (title.startsWith(TODO_EMOJI + ' ')) {
return title.substring(TODO_EMOJI.length + 1);
}
if (title.startsWith(TODO_EMOJI)) {
return title.substring(TODO_EMOJI.length);
}
return title;
};
const updateNoteTitle = (isNote) => {
if (!titleInput) return;
const currentTitle = titleInput.value || '';
if (isNote) {
if (!hasNoteEmoji(currentTitle)) {
titleInput.value = addNoteEmoji(currentTitle);
}
} else {
if (hasNoteEmoji(currentTitle)) {
titleInput.value = removeNoteEmoji(currentTitle);
}
}
};
const updateTodoTitle = (isTodo) => {
if (!titleInput) return;
const currentTitle = titleInput.value || '';
if (isTodo) {
if (!hasTodoEmoji(currentTitle)) {
titleInput.value = addTodoEmoji(currentTitle);
}
} else {
if (hasTodoEmoji(currentTitle)) {
titleInput.value = removeTodoEmoji(currentTitle);
}
}
};
const addTag = (rawTag) => {
const tag = normalizeTag(rawTag);
if (!tag) return;
const exists = tags.some((existing) => existing.toLowerCase() === tag.toLowerCase());
if (!exists) {
tags.push(tag);
updateHiddenTags();
syncReadLaterCheckbox();
syncNoteCheckbox();
syncTodoCheckbox();
renderTags();
refreshAutocomplete();
}
};
const removeTag = (tagToRemove) => {
tags = tags.filter((tag) => tag.toLowerCase() !== tagToRemove.toLowerCase());
updateHiddenTags();
syncReadLaterCheckbox();
syncNoteCheckbox();
syncTodoCheckbox();
renderTags();
refreshAutocomplete();
};
const commitInputValue = () => {
const value = tagsTextInput.value;
if (!value.trim()) return;
value
.split(/[\s,]+/)
.map((tag) => tag.trim())
.filter(Boolean)
.forEach(addTag);
tagsTextInput.value = '';
refreshAutocomplete();
};
function renderTags() {
tagsList.innerHTML = '';
tags.forEach((tag) => {
const pill = document.createElement('span');
pill.className = 'bookmark-tag-pill';
pill.setAttribute('data-tag', tag);
const label = document.createElement('span');
label.className = 'bookmark-tag-label';
label.textContent = tag;
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'bookmark-tag-remove';
removeBtn.setAttribute('aria-label', `Remove tag ${tag}`);
removeBtn.innerHTML = '';
removeBtn.addEventListener('click', () => removeTag(tag));
pill.appendChild(label);
pill.appendChild(removeBtn);
tagsList.appendChild(pill);
});
}
tagsTextInput.addEventListener('keydown', (event) => {
const hasOpenAutocomplete = Boolean(tagsAutocomplete && tagsAutocomplete.ul && !tagsAutocomplete.ul.hasAttribute('hidden'));
const shouldCommit = event.key === ' ' || event.key === ',' || (event.key === 'Enter' && !hasOpenAutocomplete);
if (shouldCommit) {
event.preventDefault();
commitInputValue();
return;
}
if (event.key === 'Backspace' && !tagsTextInput.value && tags.length) {
removeTag(tags[tags.length - 1]);
}
});
tagsTextInput.addEventListener('blur', commitInputValue);
tagsTextInput.addEventListener('paste', (event) => {
const pasted = event.clipboardData ? event.clipboardData.getData('text') : '';
if (!pasted) return;
event.preventDefault();
pasted
.split(/[\s,]+/)
.map((tag) => tag.trim())
.filter(Boolean)
.forEach(addTag);
tagsTextInput.value = '';
});
if (readLaterCheckbox) {
readLaterCheckbox.addEventListener('change', () => {
tags = tags.filter((tag) => !/^(readitlater|readlater|toread)$/i.test(tag));
if (readLaterCheckbox.checked) {
tags.push('readitlater');
}
updateHiddenTags();
syncNoteCheckbox();
renderTags();
refreshAutocomplete();
});
}
if (noteCheckbox) {
noteCheckbox.addEventListener('change', () => {
tags = tags.filter((tag) => !/^note$/i.test(tag));
if (noteCheckbox.checked) {
tags.push('note');
updateNoteTitle(true);
} else {
updateNoteTitle(false);
}
updateHiddenTags();
syncReadLaterCheckbox();
syncTodoCheckbox();
renderTags();
refreshAutocomplete();
});
}
if (todoCheckbox) {
todoCheckbox.addEventListener('change', () => {
tags = tags.filter((tag) => !/^shaarli-todo$/i.test(tag));
if (todoCheckbox.checked) {
tags.push('shaarli-todo');
updateTodoTitle(true);
} else {
updateTodoTitle(false);
}
updateHiddenTags();
syncReadLaterCheckbox();
syncNoteCheckbox();
renderTags();
refreshAutocomplete();
});
}
if (window.Awesomplete && availableTags.length) {
tagsAutocomplete = new window.Awesomplete(tagsTextInput, {
list: getSuggestionList(),
minChars: 1,
maxItems: 8,
autoFirst: true,
sort: false,
filter: window.Awesomplete.FILTER_CONTAINS,
});
tagsTextInput.addEventListener('input', () => {
refreshAutocomplete();
tagsAutocomplete.evaluate();
});
tagsTextInput.addEventListener('awesomplete-selectcomplete', commitInputValue);
}
form.addEventListener('submit', commitInputValue);
// === Todo migration: shaarli-todo → todo + Android URL ===
// Lors de la sauvegarde d'un todo, remplacer le tag legacy par la norme Android
// et générer une URL Android si vide ou legacy.
form.addEventListener('submit', () => {
try {
if (!window.ShaarItRules) return;
const urlField = form.querySelector('input[name="lf_url"]');
const url = urlField ? (urlField.value || '').trim() : '';
// Vérifier si c'est un todo (tag shaarli-todo ou todo)
const hasTodoTag = tags.some((t) => /^(todo|shaarli-todo)$/i.test(t));
if (!hasTodoTag) return;
// 1. Remplacer shaarli-todo par todo
const hadLegacyTag = tags.some((t) => /^shaarli-todo$/i.test(t));
if (hadLegacyTag) {
tags = tags.filter((t) => !/^shaarli-todo$/i.test(t));
if (!tags.some((t) => /^todo$/i.test(t))) {
tags.push('todo');
}
updateHiddenTags();
}
// 2. Générer URL Android si vide ou legacy
if (!url || /^https?:\/\/shaarli-todo/.test(url) || url === 'http://shaarli-todo') {
const newUrl = window.ShaarItRules.generateTodoUrl();
urlField.value = newUrl;
}
} catch (e) {
console.warn('[shaarit] todo migration failed:', e);
}
});
// === Content-type auto-tagging (harmonisation ShaarIt Android) ===
// À la soumission, détecte le type de contenu depuis l'URL et injecte
// les tags automatiques (video, podcast, radio, music, article, news,
// social, repository+dev, shopping, image, pdf...).
// Les tags déjà présents ne sont jamais dupliqués ; aucun tag n'est
// supprimé. Désactivé pour les notes/todos (URLs internes).
form.addEventListener('submit', () => {
try {
if (!window.ShaarItRules) return;
const urlField = form.querySelector('input[name="lf_url"]');
const url = urlField ? (urlField.value || '').trim() : '';
if (!url) return;
// Ne pas auto-tagger les notes/todos (URLs internes reconnues par ShaarItRules).
const fakeLink = { url: url, tags: tags };
if (window.ShaarItRules.isNote(fakeLink) || window.ShaarItRules.isTodo(fakeLink)) return;
const detection = window.ShaarItRules.detectContentType(url);
if (!detection || !detection.tags || detection.tags.length === 0) return;
tags = window.ShaarItRules.mergeAutoTags(tags, detection.tags);
updateHiddenTags();
} catch (e) {
console.warn('[shaarit] content-type auto-tagging failed:', e);
}
});
updateHiddenTags();
syncReadLaterCheckbox();
syncNoteCheckbox();
syncTodoCheckbox();
renderTags();
refreshAutocomplete();
// Initial title update if note or todo tag is present
if (todoCheckbox && todoCheckbox.checked && titleInput) {
const currentTitle = titleInput.value || '';
if (!hasTodoEmoji(currentTitle) && !currentTitle.trim()) {
titleInput.value = TODO_EMOJI + ' ';
} else if (!hasTodoEmoji(currentTitle)) {
titleInput.value = addTodoEmoji(currentTitle);
}
} else if (noteCheckbox && noteCheckbox.checked && titleInput) {
const currentTitle = titleInput.value || '';
// Replace default "Note:" title with just the emoji
if (currentTitle === 'Note:' || currentTitle === 'Note') {
titleInput.value = NOTE_EMOJI + ' ';
} else if (!hasNoteEmoji(currentTitle) && !currentTitle.trim()) {
titleInput.value = NOTE_EMOJI + ' ';
} else if (!hasNoteEmoji(currentTitle)) {
titleInput.value = addNoteEmoji(currentTitle);
}
}
});
}
function initBatchCreationFlow() {
const batchToggleBtn = document.querySelector('.button-batch-addform');
const batchForm = document.querySelector('form.batch-addform');
if (batchToggleBtn && batchForm) {
batchToggleBtn.addEventListener('click', () => {
const isHidden = batchForm.classList.contains('hidden');
batchForm.classList.toggle('hidden');
batchToggleBtn.setAttribute('aria-expanded', isHidden ? 'true' : 'false');
if (isHidden) {
const urlsField = batchForm.querySelector('#urls');
urlsField?.focus();
}
});
}
const batchPage = document.querySelector('.page-edit-link-batch');
if (!batchPage) return;
const progressOverlay = document.getElementById('progress-overlay');
const progressCurrentEl = batchPage.querySelector('.progress-current');
const progressTotalEl = batchPage.querySelector('.progress-total');
const progressActualEl = batchPage.querySelector('.progress-actual');
const saveAllButtons = batchPage.querySelectorAll('button[name="save_edit_batch"]');
const getActiveBatchForms = () => Array.from(
batchPage.querySelectorAll('.bookmark-editor-form[data-batch-mode="1"]')
);
const updateProgress = (current, total) => {
if (progressCurrentEl) {
progressCurrentEl.textContent = String(current);
}
if (progressTotalEl) {
progressTotalEl.textContent = String(total);
}
if (progressActualEl) {
const percentage = total > 0 ? (current * 100) / total : 0;
progressActualEl.style.width = `${percentage}%`;
}
};
const initializeProgressTotal = () => {
const total = getActiveBatchForms().length;
updateProgress(0, total);
};
initializeProgressTotal();
batchPage.addEventListener('click', (event) => {
const cancelBtn = event.target.closest('button[name="cancel-batch-link"]');
if (!cancelBtn) return;
event.preventDefault();
const editForm = cancelBtn.closest('.editlinkform');
editForm?.remove();
initializeProgressTotal();
});
async function saveFormsSequentially(forms) {
const total = forms.length;
let current = 0;
updateProgress(0, total);
progressOverlay?.classList.remove('hidden');
for (const form of forms) {
const formData = new FormData(form);
try {
const response = await fetch(form.action || `${shaarli.basePath}/admin/shaare`, {
method: 'POST',
body: formData,
credentials: 'same-origin',
});
if (!response.ok) {
console.error('Batch save failed with status', response.status);
}
} catch (error) {
console.error('Batch save request failed', error);
}
current += 1;
updateProgress(current, total);
}
window.location.href = `${shaarli.basePath}/`;
}
saveAllButtons.forEach((button) => {
button.addEventListener('click', async (event) => {
event.preventDefault();
const forms = getActiveBatchForms();
if (!forms.length) return;
saveAllButtons.forEach((btn) => {
btn.disabled = true;
});
await saveFormsSequentially(forms);
});
});
}
initBookmarkEditorForms();
initBatchCreationFlow();
// ===== Persistent Media Player (Popup via Blob URL) =====
// Audio plays in a separate popup window that survives page navigation.
// The popup HTML is generated as a Blob URL (no server file needed).
// Communication via BroadcastChannel API.
// The inline bar serves as a "Now Playing" indicator and control relay.
const MEDIA_EXTENSIONS = [
'.mp3', '.mp4', '.ogg', '.webm', '.m3u8', '.flac', '.wav', '.aac',
'.m4a', '.opus', '.wma', '.oga', '.m3u', '.pls'
];
function isMediaUrl(url) {
if (!url) return false;
var lower = url.toLowerCase();
var pathname = lower.split('?')[0].split('#')[0];
for (var i = 0; i < MEDIA_EXTENSIONS.length; i++) {
if (pathname.endsWith(MEDIA_EXTENSIONS[i])) return true;
}
if (/\/(stream|listen|live|icecast|shoutcast)(\/|$|\?)/i.test(url)) {
return true;
}
return false;
}
var playerBar = document.getElementById('media-player-bar');
var playerPlayBtn = document.getElementById('media-player-play');
var playerPlayIcon = document.getElementById('media-player-play-icon');
var playerTitle = document.getElementById('media-player-title');
var playerProgress = document.getElementById('media-player-progress');
var playerTime = document.getElementById('media-player-time');
var playerVolume = document.getElementById('media-player-volume');
var playerVolBtn = document.getElementById('media-player-vol-btn');
var playerVolIcon = document.getElementById('media-player-vol-icon');
var playerCloseBtn = document.getElementById('media-player-close');
// Reference to the popup window
var playerPopup = null;
// BroadcastChannel for reliable cross-window communication
var playerChannel = null;
try {
playerChannel = new BroadcastChannel('shaarli-media-player');
} catch (e) {
// BroadcastChannel not supported
}
function buildPlayerHTML() {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const theme = isDark ? 'dark' : 'light';
var lines = [];
lines.push('');
lines.push('');
lines.push('');
lines.push('');
lines.push('');
lines.push('♪ Shaarli Player');
lines.push('');
lines.push('');
lines.push('');
lines.push('');
lines.push('');
lines.push('');
lines.push('
');
lines.push(' ');
lines.push(' ' + i18n.t('LIVE') + '');
lines.push('
');
lines.push('
' + i18n.t('Loading...') + '
');
lines.push('
');
lines.push('
');
lines.push('
');
lines.push(' 0:00');
lines.push(' 0:00');
lines.push('
');
lines.push('
');
lines.push('
');
lines.push(' ');
lines.push(' ');
lines.push(' ');
lines.push('
');
lines.push('
');
lines.push(' ');
lines.push(' ');
lines.push(' ');
lines.push('
');
lines.push('
');
lines.push('');
lines.push('');
lines.push(' ');
lines.push(' No media selected. Click play on a bookmark to start.');
lines.push('
');
lines.push('');
lines.push('');
lines.push('');
lines.push('