// ===================================================================== // 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(''); lines.push(''); lines.push('