Files
Momento/memento-note/extension/sidepanel.js
Antigravity a623454347
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m32s
CI / Deploy production (on server) (push) Has been skipped
perf: memo GridCard, fuse save fns, fix slash tab active color
2026-06-14 14:06:05 +00:00

721 lines
23 KiB
JavaScript

/** Mettre à false pour le build Chrome Web Store (URL production en dur). */
const ALLOW_INSTANCE_CONFIG = true
const DEFAULT_BASE = 'https://memento-note.com'
const STORAGE_KEYS = { baseUrl: 'memento_clipper_base_url', notebookId: 'memento_clipper_notebook_id' }
let state = 'idle'
let notebooks = []
let selectedNotebookId = ''
let pageUrl = ''
let pageTitle = ''
let pageDomain = ''
let pageFavicon = ''
let pageHtml = ''
let pageDir = 'ltr'
let pageLang = ''
let selectionText = ''
let pageRestricted = false
let lastNoteId = ''
let lastNoteUrl = ''
let successTitle = ''
let successTags = []
let errorMessage = ''
let activeTabId = null
let pendingClipType = 'page'
let analyzeResult = null
let editableTitle = ''
let connected = false
const els = {
screen: document.getElementById('screen'),
baseUrl: document.getElementById('baseUrl'),
settingsPanel: document.getElementById('settingsPanel'),
settingsBtn: document.getElementById('settingsBtn'),
connBadge: document.getElementById('connBadge'),
connLabel: document.getElementById('connLabel'),
settingsStatus: document.getElementById('settingsStatus'),
applyInstanceBtn: document.getElementById('applyInstanceBtn'),
openLoginBtn: document.getElementById('openLoginBtn'),
}
const ICON_SELECT =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>'
const ICON_CLIP =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>'
const ICON_LINK =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>'
function apiBase() {
if (!ALLOW_INSTANCE_CONFIG) return DEFAULT_BASE
return (els.baseUrl?.value || DEFAULT_BASE).replace(/\/$/, '')
}
function isRestrictedUrl(url) {
return !url || /^(chrome|chrome-extension|edge|about|moz-extension|devtools):/i.test(url)
}
async function ensureApiPermission() {
const origin = `${apiBase()}/*`
const has = await chrome.permissions.contains({ origins: [origin] })
if (!has) {
const granted = await chrome.permissions.request({ origins: [origin] })
if (!granted) throw new Error(t('errPermissionDenied'))
}
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
const RTL_CHAR = /[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/
const LTR_CHAR = /[A-Za-z0-9]/
function detectTextDirection(text) {
const sample = String(text || '').replace(/\s+/g, '').slice(0, 3000)
if (!sample) return 'ltr'
let rtl = 0
let ltr = 0
for (const ch of sample) {
if (RTL_CHAR.test(ch)) rtl++
else if (LTR_CHAR.test(ch)) ltr++
}
if (rtl === 0) return 'ltr'
return rtl >= ltr ? 'rtl' : 'ltr'
}
function resolveUiDirection(text) {
if (pageDir === 'rtl') return 'rtl'
if (pageLang === 'fa' || pageLang === 'ar' || pageLang === 'he') return 'rtl'
if (/\/persian\b|\/fa\b|bbc\.com\/persian/i.test(pageUrl)) return 'rtl'
return detectTextDirection(text)
}
function rtlAttrs(text) {
if (resolveUiDirection(text) !== 'rtl') return ''
const lang = pageLang && ['fa', 'ar', 'he'].includes(pageLang) ? ` lang="${pageLang}"` : ''
return ` class="text-rtl" dir="rtl"${lang}`
}
function sortNotebooksHierarchy(list) {
const byParent = new Map()
for (const n of list) {
const pid = n.parentId || '__root__'
if (!byParent.has(pid)) byParent.set(pid, [])
byParent.get(pid).push(n)
}
for (const items of byParent.values()) {
items.sort((a, b) => (a.name || '').localeCompare(b.name || '', uiLocaleTag()))
}
const out = []
const seen = new Set()
function walk(parentKey, depth) {
for (const n of byParent.get(parentKey) || []) {
if (seen.has(n.id)) continue
seen.add(n.id)
out.push({ ...n, depth })
walk(n.id, depth + 1)
}
}
walk('__root__', 0)
for (const n of list) {
if (!seen.has(n.id)) out.push({ ...n, depth: 0 })
}
return out
}
function notebookSelectHtml() {
const sorted = sortNotebooksHierarchy(notebooks)
const opts = sorted
.map((n) => {
const indent = n.depth > 0 ? '\u00A0\u00A0'.repeat(n.depth) + '↳ ' : ''
const sel = n.id === selectedNotebookId ? ' selected' : ''
return `<option value="${escapeHtml(n.id)}"${sel}>${escapeHtml(indent + (n.name || t('notebookUnnamed')))}</option>`
})
.join('')
return `<select id="notebookSelect" class="notebook-select" aria-label="${escapeHtml(t('destinationNotebook'))}">
${notebooks.length ? opts : `<option value="">${escapeHtml(t('noNotebooks'))}</option>`}
</select>`
}
function formatReadingTime(minutes) {
const m = Number(minutes) || 0
if (m <= 0) return ''
if (m === 1) return t('readingTimeOne')
return t('readingTimeOther', String(m))
}
async function getActiveTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
return tab
}
async function ensureContentScript(tabId) {
try {
const resp = await chrome.tabs.sendMessage(tabId, { type: 'PING' })
if (resp?.ok) return true
} catch {
/* inject */
}
try {
await chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] })
return true
} catch {
return false
}
}
async function setPickModeOnTab(enabled) {
if (!activeTabId || pageRestricted) return
const ok = await ensureContentScript(activeTabId)
if (!ok) return
try {
await chrome.tabs.sendMessage(activeTabId, { type: 'SET_PICK_MODE', enabled })
} catch {
/* ignore */
}
}
async function syncPickMode() {
await setPickModeOnTab(state === 'idle' && !pageRestricted)
}
function updateConnBadge() {
if (!els.connBadge) return
els.connBadge.hidden = !connected
if (els.connLabel) els.connLabel.textContent = connected ? t('connected') : t('disconnected')
}
function setSettingsStatus(msg, isError) {
if (!els.settingsStatus) return
els.settingsStatus.hidden = !msg
els.settingsStatus.textContent = msg || ''
els.settingsStatus.className = `settings-status${isError ? ' is-error' : ' is-ok'}`
}
function applyInstanceConfigVisibility() {
if (ALLOW_INSTANCE_CONFIG) return
els.settingsPanel?.setAttribute('hidden', '')
els.settingsBtn?.setAttribute('hidden', '')
if (els.baseUrl) els.baseUrl.value = DEFAULT_BASE
}
function selectionBlockHtml() {
if (selectionText) {
return `<div class="selection-panel has-text" id="selectionSlot">
<div class="selection-head">
<span class="status live"><span class="pulse-dot sky"></span> ${escapeHtml(t('selectionDetected'))}</span>
<button type="button" class="clear-btn" id="clearSel">${escapeHtml(t('ignore'))}</button>
</div>
<div class="selection-body"${rtlAttrs(selectionText)}>「 ${escapeHtml(selectionText)} 」</div>
</div>`
}
return `<div class="selection-hint" id="selectionSlot">
<p>${escapeHtml(t('selectionHint'))}</p>
</div>`
}
function actionsBlockHtml() {
const hasSel = Boolean(selectionText)
return `<div class="actions" id="actionsSlot">
${
hasSel
? `<button type="button" class="btn btn-sky" id="clipSelBtn">
${ICON_SELECT} ${escapeHtml(t('clipSelection'))}
</button>`
: ''
}
<button type="button" class="btn ${hasSel ? 'btn-secondary' : 'btn-primary'}" id="clipPageBtn" ${pageRestricted ? 'disabled' : ''}>
${ICON_CLIP} ${escapeHtml(t('clipPage'))}
</button>
<button type="button" class="btn-link link-only" id="clipLinkBtn" ${pageRestricted ? 'disabled' : ''}>
${ICON_LINK} ${escapeHtml(t('saveLinkOnly'))}
</button>
</div>`
}
function bindIdleHandlers() {
document.getElementById('notebookSelect')?.addEventListener('change', async (e) => {
selectedNotebookId = e.target.value || ''
await chrome.storage.sync.set({ [STORAGE_KEYS.notebookId]: selectedNotebookId })
})
document.getElementById('clearSel')?.addEventListener('click', () => void clearSelection())
document.getElementById('clipSelBtn')?.addEventListener('click', () => void runAnalyze('selection'))
document.getElementById('clipPageBtn')?.addEventListener('click', () => void runAnalyze('page'))
document.getElementById('clipLinkBtn')?.addEventListener('click', () => void runAnalyze('link'))
// Gérer l'erreur de chargement du favicon
document.querySelector('.page-favicon')?.addEventListener('error', function() {
const fallback = this.getAttribute('data-fallback')
if (fallback && this.src !== fallback) {
this.src = fallback
}
})
}
async function clearSelection() {
selectionText = ''
if (activeTabId) {
try {
await chrome.scripting.executeScript({
target: { tabId: activeTabId },
func: () => window.getSelection()?.removeAllRanges(),
})
} catch {
/* ignore */
}
}
updateSelectionUI()
}
function updateSelectionUI() {
const slot = document.getElementById('selectionSlot')
const actions = document.getElementById('actionsSlot')
if (!slot || !actions || state !== 'idle') {
render()
return
}
slot.outerHTML = selectionBlockHtml()
actions.outerHTML = actionsBlockHtml()
bindIdleHandlers()
}
function applySelectionFromMessage(msg) {
if (!msg || msg.url !== pageUrl) return
selectionText = msg.text || ''
if (msg.dir?.toLowerCase() === 'rtl') pageDir = 'rtl'
if (msg.lang) pageLang = msg.lang
if (state === 'idle') updateSelectionUI()
}
async function refreshPageContext() {
const tab = await getActiveTab()
activeTabId = tab?.id ?? null
pageRestricted = isRestrictedUrl(tab?.url)
if (!tab?.id || pageRestricted) {
pageUrl = tab?.url || ''
pageTitle = tab?.title || t('pageNotAccessible')
selectionText = ''
return
}
pageUrl = tab.url
pageTitle = tab.title || ''
try {
const u = new URL(pageUrl)
pageDomain = u.hostname
pageFavicon = `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=32`
} catch {
pageDomain = pageUrl
pageFavicon = 'https://www.google.com/s2/favicons?domain=google.com&sz=32'
}
const ok = await ensureContentScript(tab.id)
if (!ok) return
try {
const ctx = await chrome.tabs.sendMessage(tab.id, { type: 'GET_CONTEXT' })
pageHtml = ctx?.html || ''
selectionText = ctx?.text || ''
pageDir = ctx?.dir?.toLowerCase() === 'rtl' ? 'rtl' : 'ltr'
pageLang = ctx?.lang || ''
} catch {
try {
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => ({
html: document.documentElement.outerHTML,
text: window.getSelection()?.toString().trim() || '',
dir: document.documentElement.getAttribute('dir') || '',
lang: (document.documentElement.getAttribute('lang') || '').split('-')[0],
}),
})
pageHtml = result?.html || ''
selectionText = result?.text || ''
pageDir = result?.dir?.toLowerCase() === 'rtl' ? 'rtl' : 'ltr'
pageLang = result?.lang || ''
} catch {
/* ignore */
}
}
}
async function loadSettings() {
const stored = await chrome.storage.sync.get([STORAGE_KEYS.baseUrl, STORAGE_KEYS.notebookId])
if (els.baseUrl) {
els.baseUrl.value = ALLOW_INSTANCE_CONFIG
? stored[STORAGE_KEYS.baseUrl] || DEFAULT_BASE
: DEFAULT_BASE
}
await loadNotebooks(stored[STORAGE_KEYS.notebookId])
}
async function loadNotebooks(preferredId) {
try {
await ensureApiPermission()
const res = await fetch(`${apiBase()}/api/clip/notebooks`, { credentials: 'include' })
if (!res.ok) {
connected = false
updateConnBadge()
if (res.status === 401) {
throw new Error(t('errLoginRequired'))
}
throw new Error(t('errLoadNotebooks'))
}
const data = await res.json()
notebooks = data.notebooks || []
selectedNotebookId =
(preferredId && notebooks.some((n) => n.id === preferredId) ? preferredId : '') ||
notebooks[0]?.id ||
''
connected = true
updateConnBadge()
errorMessage = ''
setSettingsStatus(t('notebooksLoaded'), false)
} catch (e) {
notebooks = []
connected = false
updateConnBadge()
errorMessage = e.message
setSettingsStatus(e.message, true)
}
}
async function applyInstance() {
const url = (els.baseUrl?.value || DEFAULT_BASE).replace(/\/$/, '')
if (els.baseUrl) els.baseUrl.value = url
await chrome.storage.sync.set({ [STORAGE_KEYS.baseUrl]: url })
setSettingsStatus(t('connecting'), false)
await loadNotebooks(selectedNotebookId)
if (connected) {
setSettingsStatus(t('connectedToUrl', url), false)
}
}
function renderIdle() {
const restrictedBlock = pageRestricted
? `<div class="restricted-note">${escapeHtml(t('restrictedPage'))}</div>`
: ''
const authHint =
!connected && errorMessage
? `<div class="auth-hint">${escapeHtml(errorMessage)}</div>`
: ''
els.screen.innerHTML = `
${restrictedBlock}
${authHint}
<div>
<span class="label">${escapeHtml(t('destinationNotebook'))}</span>
${notebookSelectHtml()}
</div>
<div class="page-card">
<span class="sub">${escapeHtml(t('activePage'))}</span>
<div class="page-row">
<img src="${escapeHtml(pageFavicon)}" alt="" class="page-favicon" data-fallback="https://www.google.com/s2/favicons?domain=google.com&sz=32" />
<div class="page-text">
<div class="title"${rtlAttrs(pageTitle)}>${escapeHtml(pageTitle || '—')}</div>
<div class="url">${escapeHtml(pageUrl || '—')}</div>
</div>
</div>
</div>
${selectionBlockHtml()}
${actionsBlockHtml()}
`
bindIdleHandlers()
}
function renderLoading(label) {
els.screen.innerHTML = `
<div class="center-state">
<div class="spinner-wrap">
<div class="spinner-ring"></div>
<div class="spinner"></div>
</div>
<div>
<div class="state-title">${escapeHtml(t('analyzingSource'))}</div>
<div class="state-sub">${escapeHtml(label || t('statusAnalyzing'))}</div>
<div class="state-detail">${escapeHtml(t('processingDetail'))}</div>
</div>
</div>
`
}
function renderConfirm() {
const excerpt = analyzeResult?.excerpt || ''
const tags = analyzeResult?.tags || []
const reading = formatReadingTime(analyzeResult?.readingTime)
const tagsHtml = tags.map((t) => `<span class="tag-chip">${escapeHtml(t)}</span>`).join('')
els.screen.innerHTML = `
<div class="confirm-panel">
<span class="label">${escapeHtml(t('previewBeforeSave'))}</span>
<label class="field">
<span>${escapeHtml(t('noteTitleLabel'))}</span>
<input id="titleInput" type="text" value="${escapeHtml(editableTitle)}" maxlength="300" />
</label>
${
reading
? `<div class="meta-row"><span class="reading-time">${escapeHtml(reading)}</span></div>`
: ''
}
${
analyzeResult?.summary
? `<p class="summary-preview"${rtlAttrs(analyzeResult.summary)}>${escapeHtml(analyzeResult.summary)}</p>`
: ''
}
${
excerpt && pendingClipType !== 'link'
? `<div class="excerpt-preview"${rtlAttrs(excerpt)}>
<span class="excerpt-label">${escapeHtml(t('excerptLabel'))}</span>
${escapeHtml(excerpt)}
</div>`
: ''
}
${tagsHtml ? `<div class="tags preview-tags">${tagsHtml}</div>` : ''}
</div>
<div class="actions">
<button type="button" class="btn btn-primary" id="saveBtn">${escapeHtml(t('saveToMomento'))}</button>
<button type="button" class="btn-link" id="cancelConfirmBtn">${escapeHtml(t('back'))}</button>
</div>
`
document.getElementById('titleInput')?.addEventListener('input', (e) => {
editableTitle = e.target.value
})
document.getElementById('saveBtn')?.addEventListener('click', () => void runSave())
document.getElementById('cancelConfirmBtn')?.addEventListener('click', async () => {
state = 'idle'
analyzeResult = null
await syncPickMode()
render()
})
}
function renderSuccess() {
const nb = notebooks.find((n) => n.id === selectedNotebookId)
const tagsHtml = successTags.map((t) => `<span class="tag-chip">${escapeHtml(t)}</span>`).join('')
const reading = formatReadingTime(analyzeResult?.readingTime)
els.screen.innerHTML = `
<div class="center-state" style="justify-content:flex-start;padding-top:12px">
<div class="success-icon">✓</div>
<div>
<span class="badge-ok">${escapeHtml(t('noteSaved'))}</span>
<div class="note-title"${rtlAttrs(successTitle)}>${escapeHtml(successTitle)}</div>
<div class="state-detail">${escapeHtml(t('sentToNotebook', nb?.name || ''))}</div>
${reading ? `<div class="state-detail">${escapeHtml(reading)}</div>` : ''}
</div>
${tagsHtml ? `<div class="tags">${tagsHtml}</div>` : ''}
</div>
<div class="actions">
<button type="button" class="btn btn-primary" id="viewBtn">${escapeHtml(t('viewInMomento'))} ↗</button>
<button type="button" class="btn-link" id="againBtn">${escapeHtml(t('clipAnother'))}</button>
</div>
`
document.getElementById('viewBtn')?.addEventListener('click', () => {
if (lastNoteUrl) chrome.tabs.create({ url: `${apiBase()}${lastNoteUrl}` })
})
document.getElementById('againBtn')?.addEventListener('click', async () => {
state = 'idle'
analyzeResult = null
await refreshPageContext()
await syncPickMode()
render()
})
}
function renderError() {
els.screen.innerHTML = `
<div class="center-state">
<div class="error-icon">!</div>
<div>
<div class="state-title" style="color:#ef4444">${escapeHtml(t('failure'))}</div>
<div class="state-detail">${escapeHtml(errorMessage || t('genericError'))}</div>
</div>
</div>
<div class="actions">
<button type="button" class="btn btn-danger" id="retryBtn">${escapeHtml(t('retry'))}</button>
<button type="button" class="btn-link" id="backIdleBtn">${escapeHtml(t('back'))}</button>
</div>
`
document.getElementById('retryBtn')?.addEventListener('click', () => {
if (analyzeResult) void runSave()
else void runAnalyze(pendingClipType)
})
document.getElementById('backIdleBtn')?.addEventListener('click', async () => {
state = 'idle'
errorMessage = ''
analyzeResult = null
await refreshPageContext()
await syncPickMode()
render()
})
}
function render() {
if (state === 'loading' || state === 'saving') {
return renderLoading(state === 'saving' ? t('statusSaving') : t('statusAnalyzing'))
}
if (state === 'confirm') return renderConfirm()
if (state === 'success') return renderSuccess()
if (state === 'error') return renderError()
renderIdle()
}
async function runAnalyze(type) {
pendingClipType = type
state = 'loading'
await setPickModeOnTab(false)
render()
try {
await ensureApiPermission()
await chrome.storage.sync.set({
[STORAGE_KEYS.baseUrl]: apiBase(),
[STORAGE_KEYS.notebookId]: selectedNotebookId,
})
if (type === 'selection') {
if (!selectionText) throw new Error(t('errNoSelection'))
await refreshPageContext()
}
let analyzeBody
if (type === 'link') {
analyzeBody = { url: pageUrl, title: pageTitle, mode: 'link' }
} else if (type === 'selection' && selectionText) {
analyzeBody = { url: pageUrl, title: pageTitle, mode: 'selection', selection: selectionText }
} else {
analyzeBody = { url: pageUrl, html: pageHtml, title: pageTitle, mode: 'article' }
}
const analyzeRes = await fetch(`${apiBase()}/api/clip/analyze`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(analyzeBody),
})
const analysis = await analyzeRes.json()
if (!analyzeRes.ok) throw new Error(analysis.error || t('errAnalyzeFailed'))
analyzeResult = analysis
editableTitle = analysis.title || pageTitle || pageDomain
state = 'confirm'
render()
} catch (e) {
errorMessage = e.message || t('errNetwork')
state = 'error'
render()
}
}
async function runSave() {
if (!analyzeResult) return
state = 'saving'
render()
try {
const title = (editableTitle || analyzeResult.title || pageTitle || pageDomain).trim()
const saveRes = await fetch(`${apiBase()}/api/clip/save`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: pageUrl,
title,
content: analyzeResult.content,
summary: analyzeResult.summary,
tags: analyzeResult.tags || [],
notebookId: selectedNotebookId || undefined,
}),
})
const saved = await saveRes.json()
if (!saveRes.ok) throw new Error(saved.error || t('errSaveFailed'))
successTitle = title
successTags = analyzeResult.tags || []
lastNoteId = saved.noteId
lastNoteUrl = saved.noteUrl
state = 'success'
render()
} catch (e) {
errorMessage = e.message || t('errNetwork')
state = 'error'
render()
}
}
chrome.runtime.onMessage.addListener((msg) => {
if (msg?.type === 'SELECTION_CHANGED') applySelectionFromMessage(msg)
})
chrome.tabs.onActivated.addListener(async () => {
if (state !== 'idle') return
await refreshPageContext()
await syncPickMode()
render()
})
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
if (info.status !== 'complete' || state !== 'idle') return
const tab = await getActiveTab()
if (tab?.id === tabId) {
await refreshPageContext()
await syncPickMode()
render()
}
})
els.settingsBtn?.addEventListener('click', () => {
if (!ALLOW_INSTANCE_CONFIG) return
els.settingsPanel.hidden = !els.settingsPanel.hidden
})
document.querySelectorAll('.preset-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const url = btn.getAttribute('data-url')
if (url && els.baseUrl) els.baseUrl.value = url
})
})
els.applyInstanceBtn?.addEventListener('click', () => void applyInstance())
els.openLoginBtn?.addEventListener('click', () => {
chrome.tabs.create({ url: apiBase() })
})
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible') {
if (state === 'idle') {
await refreshPageContext()
await syncPickMode()
render()
}
} else if (document.visibilityState === 'hidden') {
// Désactiver le pick mode quand le sidepanel est fermé
await setPickModeOnTab(false)
}
})
document.addEventListener('DOMContentLoaded', async () => {
applyDocumentLocale()
applyInstanceConfigVisibility()
applyShellI18n()
await loadSettings()
try {
await ensureApiPermission()
} catch (e) {
errorMessage = e.message
connected = false
updateConnBadge()
}
await refreshPageContext()
await syncPickMode()
render()
})