PageSourceSearch

https://vnbit.org/js/mod/ai_image_storage/editor.js?_v=83ed1fb9

js vnbit.org collected 2026-10-01 10:36:18 UTC 11,705 bytes, 349 lines download raw bytes

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, '&amp;')
185      .replace(/"/g, '&quot;')
186      .replace(/</g, '&lt;')
187      .replace(/>/g, '&gt;');
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.