1(function () { 2 'use strict'; 3 4 window.ModAiImageStorageLoaded = true; 5 6 const PENDING_FLICKR_KEY = 'fa_pending_photos_v2'; 7 const BUTTON_SELECTOR = '.js-aiStorageUpload, .js-faFlickrUpload'; 8 const FILE_SELECTOR = '.js-aiStorageFile, .js-faFlickrFile'; 9 const WRAP_SELECTOR = '.js-aiStorageWrap, .js-faFlickrWrap'; 10 11 function canonicalUrl(path) { 12 return window.XF && XF.canonicalizeUrl ? XF.canonicalizeUrl(path) : path; 13 } 14 15 function getPendingFlickr() { 16 try { 17 const raw = JSON.parse(localStorage.getItem(PENDING_FLICKR_KEY) || '[]'); 18 const now = Date.now(); 19 return Array.isArray(raw) 20 ? raw.filter(item => item && /^\d+$/.test(String(item.photoId || '')) && (!item.t || now - Number(item.t) < 86400000)) 21 : []; 22 } catch (e) { 23 return []; 24 } 25 } 26 27 function setPendingFlickr(items) { 28 try { 29 localStorage.setItem(PENDING_FLICKR_KEY, JSON.stringify(items || [])); 30 } catch (e) {} 31 } 32 33 function addPendingFlickr(photoId, title) { 34 if (!/^\d+$/.test(String(photoId || ''))) return; 35 36 const current = getPendingFlickr(); 37 if (!current.find(item => String(item.photoId) === String(photoId))) { 38 current.push({ photoId: String(photoId), title: title || '', t: Date.now() }); 39 setPendingFlickr(current); 40 } 41 } 42 43 async function flushPendingFlickr() { 44 const items = getPendingFlickr(); 45 if (!items.length) return; 46 47 const photoIds = items.map(item => item.photoId).filter(Boolean); 48 if (!photoIds.length) return; 49 50 const sourceUrl = canonicalUrl(window.location.href); 51 const meta = getSourceMeta(document.querySelector(BUTTON_SELECTOR)); 52 const body = new URLSearchParams(); 53 body.set('_xfToken', window.XF && XF.config ? XF.config.csrf || '' : ''); 54 body.set('source_url', sourceUrl); 55 body.set('source_title', meta.sourceTitle || ''); 56 body.set('photo_ids', photoIds.join(',')); 57 58 try { 59 const response = await fetch(canonicalUrl('index.php?ai-image-storage/set-meta'), { 60 method: 'POST', 61 headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, 62 body, 63 credentials: 'same-origin' 64 }); 65 const json = await response.json().catch(() => ({})); 66 if (!response.ok || !json) return; 67 68 const retryIds = Array.isArray(json.retry_ids) ? json.retry_ids.map(String) : []; 69 if (retryIds.length) { 70 const retrySet = new Set(retryIds); 71 setPendingFlickr(items.filter(item => retrySet.has(String(item.photoId)))); 72 } else { 73 localStorage.removeItem(PENDING_FLICKR_KEY); 74 } 75 } catch (e) {} 76 } 77 78 if (document.readyState === 'loading') { 79 document.addEventListener('DOMContentLoaded', flushPendingFlickr); 80 } else { 81 flushPendingFlickr(); 82 } 83 84 let lastSignature = null; 85 let lastUploadTime = 0; 86 let lastResponse = null; 87 88 function fileSignature(file) { 89 return [file && file.name, file && file.size, file && file.lastModified].join(':'); 90 } 91 92 function getNearestTextArea(element) { 93 try { 94 const form = element && element.closest ? element.closest('form') : null; 95 if (form) { 96 const textarea = form.querySelector('textarea[name="message"], textarea.js-editor'); 97 if (textarea) return textarea; 98 } 99 100 const editorRoot = element && element.closest ? element.closest('.messageEditor, .js-fullEditor') : null; 101 if (editorRoot) { 102 const textarea = editorRoot.querySelector('textarea[name="message"], textarea.js-editor'); 103 if (textarea) return textarea; 104 } 105 } catch (e) {} 106 107 return document.querySelector('textarea[name="message"], textarea.js-editor') || null; 108 } 109 110 function insertAtCursor(textarea, text) { 111 if (!textarea) return false; 112 113 const start = textarea.selectionStart || 0; 114 const end = textarea.selectionEnd || 0; 115 const value = textarea.value || ''; 116 textarea.value = value.slice(0, start) + text + value.slice(end); 117 const position = start + text.length; 118 textarea.selectionStart = textarea.selectionEnd = position; 119 textarea.dispatchEvent(new Event('input', { bubbles: true })); 120 textarea.focus(); 121 return true; 122 } 123 124 function getNearestFroalaElement(button) { 125 try { 126 const root = button.closest('.messageEditor, .js-fullEditor, form') || document; 127 const element = root.querySelector('.fr-element[contenteditable="true"], .fr-element[contenteditable=true], .fr-element'); 128 if (element && element.offsetParent !== null) return element; 129 } catch (e) {} 130 131 return null; 132 } 133 134 function notifyFroalaChanged(element) { 135 if (!element) return; 136 try { element.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) {} 137 try { element.dispatchEvent(new Event('change', { bubbles: true })); } catch (e) {} 138 try { element.dispatchEvent(new CustomEvent('froalaEditor.contentChanged', { bubbles: true })); } catch (e) {} 139 } 140 141 function insertImageWysiwyg(button, url) { 142 const element = getNearestFroalaElement(button); 143 if (!element) return false;
144 145 try { element.focus(); } catch (e) {} 146 147 const html = `<p><img src="${escapeHtmlAttribute(url)}" alt=""></p><p><br></p>`; 148 let inserted = false; 149 150 try { inserted = document.execCommand('insertHTML', false, html); } catch (e) {} 151 if (!inserted) { 152 try { inserted = document.execCommand('insertImage', false, url); } catch (e) {} 153 } 154 155 if (!inserted) { 156 try { 157 const selection = window.getSelection && window.getSelection(); 158 if (selection && selection.rangeCount) { 159 const range = selection.getRangeAt(0); 160 range.deleteContents(); 161 const container = document.createElement('div'); 162 container.innerHTML = html; 163 const fragment = document.createDocumentFragment(); 164 while (container.firstChild) fragment.appendChild(container.firstChild); 165 const lastNode = fragment.lastChild; 166 range.insertNode(fragment); 167 if (lastNode) { 168 range.setStartAfter(lastNode); 169 range.setEndAfter(lastNode); 170 } 171 selection.removeAllRanges(); 172 selection.addRange(range); 173 inserted = true; 174 } 175 } catch (e) {} 176 } 177 178 if (inserted) notifyFroalaChanged(element); 179 return inserted; 180 } 181 182 function escapeHtmlAttribute(value) { 183 return String(value || '') 184 .replace(/&/g, '&') 185 .replace(/"/g, '"') 186 .replace(/</g, '<') 187 .replace(/>/g, '>'); 188 } 189 190 function insertIntoEditor(button, url) { 191 if (insertImageWysiwyg(button, url)) return true; 192 return insertAtCursor(getNearestTextArea(button), `[IMG]${url}[/IMG]\n\n`); 193 } 194 195 function ensureFileInput(button) { 196 const wrapper = button.closest(WRAP_SELECTOR) || button.parentElement; 197 if (!wrapper) return null; 198 199 const input = wrapper.querySelector(FILE_SELECTOR); 200 if (!input) return null; 201 202 const computed = getComputedStyle(input); 203 if (computed.display === 'none') input.style.display = ''; 204 input.style.position = 'absolute'; 205 input.style.left = '-9999px'; 206 input.style.width = '1px'; 207 input.style.height = '1px'; 208 input.style.opacity = '0'; 209 return input; 210 } 211 212 function getSourceMeta(button) { 213 let sourceTitle = ''; 214 let sourceUrl = window.location.href; 215 216 try { 217 const form = button && button.closest ? button.closest('form') : null; 218 const titleInput = form ? form.querySelector('input[name="title"], textarea[name="title"]') : null; 219 if (titleInput && String(titleInput.value || '').trim()) { 220 sourceTitle = String(titleInput.value || '').trim(); 221 } 222 223 if (!sourceTitle) { 224 const pageTitle = document.querySelector('.p-title-value'); 225 if (pageTitle) { 226 const clone = pageTitle.cloneNode(true); 227 clone.querySelectorAll('.label, .labelLink, .p-title-pageAction').forEach(element => element.remove()); 228 sourceTitle = String(clone.textContent || '').replace(/\s+/g, ' ').trim(); 229 } 230 } 231 232 if (!sourceTitle) { 233 const openGraphTitle = document.querySelector('meta[property="og:title"]'); 234 if (openGraphTitle && openGraphTitle.content) sourceTitle = String(openGraphTitle.content).trim(); 235 } 236 237 sourceUrl = canonicalUrl(window.location.href); 238 } catch (e) {} 239 240 return { sourceTitle, sourceUrl }; 241 } 242 243 async function uploadToXenForo(file, button) { 244 if (!window.XF || !XF.config || !XF.config.csrf) { 245 throw new Error('XenForo chưa sẵn sà ng, thiếu mã CSRF.'); 246 } 247 248 const formData = new FormData(); 249 formData.append('upload', file); 250 formData.append('_xfToken', XF.config.csrf); 251 252 const meta = getSourceMeta(button); 253 formData.append('source_title', meta.sourceTitle || ''); 254 formData.append('source_url', meta.sourceUrl || ''); 255 256 const response = await fetch(canonicalUrl('index.php?ai-image-storage/upload'), { 257 method: 'POST', 258 body: formData, 259 credentials: 'same-origin' 260 }); 261 const json = await response.json().catch(() => ({})); 262 263 if (!response.ok || !json || !json.url) { 264 throw new Error(json && (json.error || json.message) ? json.error || json.message : 'Upload ảnh thất bại.'); 265 } 266 267 return json; 268 } 269 270 function processUploadResponse(response, file, button) { 271 const url = response.url; 272 const title = response.title || file.name || ''; 273 274 if (response.provider === 'flickr' && /^\d+$/.test(String(response.photo_id || ''))) { 275 addPendingFlickr(response.photo_id, title); 276 } 277 278 if (!insertIntoEditor(button, url)) { 279 alert('Không tìm thấy trình soạn thảo Äá» chèn ảnh. Link ảnh: ' + url); 280 } 281 } 282 283 async function handlePickedFile(button, input) { 284 const file = input.files && input.files[0]; 285 if (!file) return; 286 287 const oldDisabled = button.disabled; 288 const oldText = button.textContent; 289 290 try { 291 button.disabled = true; 292 button.textContent = 'Äang tải...'; 293 294 const signature = fileSignature(file); 295 const now = Date.now(); 296 if (signature && lastSignature === signature && now - lastUploadTime < 15000 && lastResponse && lastResponse.url) { 297 processUploadResponse(lastResponse, file, button); 298 return; 299 } 300 301 const response = await uploadToXenForo(file, button); 302 lastSignature = signature; 303 lastUploadTime = now; 304 lastResponse = response; 305 processUploadResponse(response, file, button); 306 } catch (error) { 307 alert(error && error.message ? error.message : 'Lá»i upload ảnh.'); 308 } finally { 309 button.disabled = oldDisabled; 310 button.textContent = oldText; 311 input.value = ''; 312 } 313 } 314 315 function openPicker(button) { 316 const input = ensureFileInput(button); 317 if (!input) { 318 alert('Không tìm thấy ô chá»n file cá»§a AI Image Storage.'); 319 return; 320 } 321 322 if (input.dataset.aiStorageBound !== '1') { 323 input.dataset.aiStorageBound = '1'; 324 input.addEventListener('change', () => handlePickedFile(button, input)); 325 } 326 327 try { 328 if (typeof input.showPicker === 'function') input.showPicker(); 329 else input.click(); 330 } catch (e) {
331 try { input.click(); } catch (ignored) {} 332 } 333 } 334 335 function bindGlobal() { 336 document.addEventListener('click', function (event) { 337 const button = event.target.closest && event.target.closest(BUTTON_SELECTOR); 338 if (!button) return; 339 event.preventDefault(); 340 event.stopPropagation(); 341 openPicker(button); 342 }, true); 343 } 344 345 if (!document.documentElement.dataset.modAiStorageBound) { 346 document.documentElement.dataset.modAiStorageBound = '1'; 347 bindGlobal(); 348 } 349})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.