nodebb-plugin-facebook-post 1.0.30 → 1.0.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/library.js CHANGED
@@ -14,6 +14,7 @@ const DEFAULT_SETTINGS = {
14
14
  categoriesWhitelist: '',
15
15
  minimumReputation: 0,
16
16
  maxImages: 4,
17
+ enablePlaceTagging: false,
17
18
  };
18
19
 
19
20
  function bool(v) {
@@ -51,6 +52,7 @@ async function loadSettings() {
51
52
  settings.minimumReputation = int(settings.minimumReputation, 0);
52
53
  settings.maxImages = int(settings.maxImages, DEFAULT_SETTINGS.maxImages);
53
54
  settings.categoriesWhitelist = trimStr(settings.categoriesWhitelist);
55
+ settings.enablePlaceTagging = bool(settings.enablePlaceTagging);
54
56
 
55
57
  const env = readEnv();
56
58
  settings.fbGraphVersion = env.fbGraphVersion;
@@ -73,16 +75,33 @@ function absolutizeUrl(url) {
73
75
  return `${base}/${url}`;
74
76
  }
75
77
 
78
+ function getApexDomain(hostname) {
79
+ const parts = hostname.split('.');
80
+ return parts.length <= 2 ? hostname : parts.slice(-2).join('.');
81
+ }
82
+
76
83
  function isForumHosted(urlAbs) {
77
84
  const base = getForumBaseUrl();
78
85
  if (!base) return false;
79
86
  try {
80
- return new URL(urlAbs).host === new URL(base).host;
87
+ const imgHost = new URL(urlAbs).host;
88
+ const forumHost = new URL(base).host;
89
+ return imgHost === forumHost || getApexDomain(imgHost) === getApexDomain(forumHost);
81
90
  } catch {
82
91
  return false;
83
92
  }
84
93
  }
85
94
 
95
+ function decodeHtmlEntities(str) {
96
+ return str
97
+ .replace(/&amp;/g, '&')
98
+ .replace(/&lt;/g, '<')
99
+ .replace(/&gt;/g, '>')
100
+ .replace(/&quot;/g, '"')
101
+ .replace(/&#x([0-9a-fA-F]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
102
+ .replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(Number(dec)));
103
+ }
104
+
86
105
  function extractImageUrlsFromContent(rawContent) {
87
106
  const urls = new Set();
88
107
  if (!rawContent) return [];
@@ -190,13 +209,14 @@ async function uploadPhotoToFacebook(urlAbs) {
190
209
  return resp.data && resp.data.id;
191
210
  }
192
211
 
193
- async function publishFeedPost({ message, link, photoIds }) {
212
+ async function publishFeedPost({ message, link, photoIds, placeId }) {
194
213
  const endpoint = `https://graph.facebook.com/${settings.fbGraphVersion}/${settings.fbPageId}/feed`;
195
214
 
196
215
  const form = new URLSearchParams();
197
216
  form.append('message', String(message || ''));
198
217
  form.append('access_token', String(settings.fbPageAccessToken));
199
218
  if (link) form.append('link', String(link));
219
+ if (placeId) form.append('place', String(placeId));
200
220
  if (Array.isArray(photoIds) && photoIds.length) {
201
221
  photoIds.forEach((id, idx) => {
202
222
  form.append(`attached_media[${idx}]`, JSON.stringify({ media_fbid: id }));
@@ -212,7 +232,7 @@ async function publishFeedPost({ message, link, photoIds }) {
212
232
 
213
233
  async function postToFacebook(ctx) {
214
234
  const rawContent = ctx.post.content || '';
215
- const topicTitle = ctx.topic.title || 'Nouveau sujet';
235
+ const topicTitle = decodeHtmlEntities(ctx.topic.title || 'Nouveau sujet');
216
236
  const author = ctx.user.username || 'Quelqu\u2019un';
217
237
  const link = ctx.topicUrlAbs;
218
238
 
@@ -235,7 +255,7 @@ async function postToFacebook(ctx) {
235
255
  if (id) photoIds.push(id);
236
256
  }
237
257
 
238
- return publishFeedPost({ message, link, photoIds });
258
+ return publishFeedPost({ message, link, photoIds, placeId: trimStr(ctx.post.fbPlaceId) });
239
259
  }
240
260
 
241
261
  const Plugin = {};
@@ -283,12 +303,36 @@ Plugin.init = async function (params) {
283
303
  const allowedGroups = parseAllowedGroupsList();
284
304
  if (!allowedGroups.length) return res.json({ allowed: false, reason: 'no_groups_configured' });
285
305
  const ok = await userIsAllowed(uid);
286
- return res.json({ allowed: ok, reason: ok ? 'ok' : 'not_in_group' });
306
+ return res.json({ allowed: ok, reason: ok ? 'ok' : 'not_in_group', enablePlaceTagging: settings.enablePlaceTagging });
287
307
  } catch {
288
308
  return res.json({ allowed: false, reason: 'error' });
289
309
  }
290
310
  });
291
311
 
312
+ router.get('/api/facebook-post/search-place', middleware.ensureLoggedIn, async (req, res) => {
313
+ res.set('Cache-Control', 'no-store');
314
+ try {
315
+ await loadSettings();
316
+ if (!settings.enabled || !settings.enablePlaceTagging || !settings.fbPageAccessToken) {
317
+ return res.json({ results: [] });
318
+ }
319
+ const q = trimStr(req.query.q);
320
+ if (q.length < 2) return res.json({ results: [] });
321
+ const resp = await axios.get(`https://graph.facebook.com/${settings.fbGraphVersion}/search`, {
322
+ params: {
323
+ type: 'place',
324
+ q,
325
+ fields: 'name,location,id',
326
+ access_token: settings.fbPageAccessToken,
327
+ limit: 8,
328
+ },
329
+ timeout: 8000,
330
+ });
331
+ return res.json({ results: (resp.data && resp.data.data) || [] });
332
+ } catch {
333
+ return res.json({ results: [] });
334
+ }
335
+ });
292
336
 
293
337
  };
294
338
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-facebook-post",
3
- "version": "1.0.30",
3
+ "version": "1.0.32",
4
4
  "description": "Auto-post new NodeBB topics to a fixed Facebook Page (text + NodeBB uploads + place id).",
5
5
  "main": "library.js",
6
6
  "dependencies": {
@@ -2,6 +2,8 @@
2
2
  'use strict';
3
3
 
4
4
  (function () {
5
+ const DEBOUNCE_MS = 400;
6
+
5
7
  async function canPost() {
6
8
  try {
7
9
  const res = await fetch('/api/facebook-post/can-post?_=' + Date.now(), {
@@ -10,51 +12,164 @@
10
12
  });
11
13
  if (!res.ok) return { allowed: false };
12
14
  return await res.json();
13
- } catch (err) {
15
+ } catch {
14
16
  return { allowed: false };
15
17
  }
16
18
  }
17
19
 
18
- function injectUI($composer) {
20
+ async function searchPlace(q) {
21
+ try {
22
+ const res = await fetch('/api/facebook-post/search-place?q=' + encodeURIComponent(q), {
23
+ credentials: 'same-origin',
24
+ cache: 'no-store',
25
+ });
26
+ if (!res.ok) return [];
27
+ const data = await res.json();
28
+ return data.results || [];
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ function resetPlace($panel) {
35
+ $panel.find('.fb-place-search').removeClass('d-none').val('');
36
+ $panel.find('.fb-place-id').val('');
37
+ $panel.find('.fb-place-selected').removeClass('d-inline-flex').addClass('d-none');
38
+ $panel.find('.fb-place-results').hide().empty();
39
+ }
40
+
41
+ function injectUI($composer, perm) {
19
42
  if ($composer.find('[data-fbpost-btn]').length) return;
20
43
 
21
- const $btn = $(`
22
- <button type="button" class="btn btn-sm btn-link" data-fbpost-btn
23
- title="Publier ce topic sur Facebook (cliquer pour activer)"
24
- style="opacity:0.4; color:#1877F2; font-size:1.2em; padding:2px 6px;">
25
- <i class="fab fa-facebook-f"></i>
26
- </button>
44
+ const showPlace = !!perm.enablePlaceTagging;
45
+
46
+ // ── Bouton dans la toolbar même structure que les boutons natifs ──
47
+ const $li = $(`
48
+ <li aria-label="Publier sur Facebook" data-bs-original-title="Publier sur Facebook" data-fbpost-btn>
49
+ <button type="button" class="btn btn-sm btn-link text-reset position-relative"
50
+ aria-label="Publier sur Facebook" style="opacity:0.45;">
51
+ <i class="fab fa-facebook-f"></i>
52
+ </button>
53
+ </li>
27
54
  `);
55
+ const $btn = $li.find('button');
28
56
 
57
+ // ── Panneau lieu (affiché sous la toolbar quand FB est actif) ──
58
+ const $placePanel = $(`
59
+ <div class="fb-place-panel d-none align-items-center gap-2 px-2 py-1"
60
+ style="font-size:0.85em; border-top:1px solid rgba(128,128,128,.2);">
61
+ <i class="fab fa-facebook-f" style="color:#1877F2; flex-shrink:0;"></i>
62
+ <span class="text-muted">Publier sur Facebook</span>
63
+ ${showPlace ? `
64
+ <div class="position-relative flex-grow-1 ms-2">
65
+ <input type="text" class="form-control form-control-sm fb-place-search"
66
+ placeholder="Lieu (optionnel)…" autocomplete="off">
67
+ <ul class="fb-place-results dropdown-menu p-1"
68
+ style="display:none; position:absolute; z-index:9999; top:100%; left:0;
69
+ min-width:220px; max-height:200px; overflow-y:auto;"></ul>
70
+ </div>
71
+ <span class="fb-place-selected d-none align-items-center gap-1 text-nowrap">
72
+ <i class="fa fa-map-marker" style="color:#1877F2;"></i>
73
+ <span class="fb-place-name fw-semibold"></span>
74
+ <a href="#" class="fb-place-clear ms-1 text-danger" title="Effacer">
75
+ <i class="fa fa-times"></i>
76
+ </a>
77
+ </span>
78
+ <input type="hidden" class="fb-place-id" value="">
79
+ ` : ''}
80
+ </div>
81
+ `);
82
+
83
+ // ── Toggle actif/inactif ──
29
84
  $btn.on('click', function () {
30
- const active = $(this).data('fbpost-active');
31
- $(this).data('fbpost-active', !active)
32
- .attr('title', !active
33
- ? 'Publier sur Facebook (actif — cliquer pour désactiver)'
34
- : 'Publier ce topic sur Facebook (cliquer pour activer)')
35
- .css('opacity', !active ? '1' : '0.4');
85
+ const active = !$(this).data('fbpost-active');
86
+ $(this).data('fbpost-active', active);
87
+ if (active) {
88
+ $(this).css({ color: '#1877F2', opacity: '1' });
89
+ $placePanel.removeClass('d-none').addClass('d-flex');
90
+ } else {
91
+ $(this).css({ color: '', opacity: '0.45' });
92
+ $placePanel.removeClass('d-flex').addClass('d-none');
93
+ if (showPlace) resetPlace($placePanel);
94
+ }
36
95
  });
37
96
 
38
- // Injecter dans la barre d'outils du composeur
39
- const $toolbar = $composer.find('.toolbar, .formatting-bar, [data-toolbar]').first();
97
+ // ── Recherche de lieu avec debounce ──
98
+ if (showPlace) {
99
+ let debounceTimer;
100
+
101
+ $placePanel.find('.fb-place-search').on('input', function () {
102
+ const q = $(this).val().trim();
103
+ const $results = $placePanel.find('.fb-place-results');
104
+ clearTimeout(debounceTimer);
105
+ if (q.length < 2) { $results.hide().empty(); return; }
106
+
107
+ debounceTimer = setTimeout(async () => {
108
+ const places = await searchPlace(q);
109
+ $results.empty();
110
+ if (!places.length) { $results.hide(); return; }
111
+
112
+ places.forEach(p => {
113
+ const loc = p.location
114
+ ? [p.location.city, p.location.country].filter(Boolean).join(', ')
115
+ : '';
116
+ $('<li>').append(
117
+ $('<a href="#" class="dropdown-item rounded-1 py-1">')
118
+ .attr({ 'data-place-id': p.id, 'data-place-name': p.name })
119
+ .append($('<div class="fw-semibold lh-sm">').text(p.name))
120
+ .append(loc ? $('<div class="text-muted small lh-sm">').text(loc) : null)
121
+ .on('click', function (e) {
122
+ e.preventDefault();
123
+ $placePanel.find('.fb-place-id').val($(this).data('place-id'));
124
+ $placePanel.find('.fb-place-name').text($(this).data('place-name'));
125
+ $placePanel.find('.fb-place-selected').removeClass('d-none').addClass('d-inline-flex');
126
+ $placePanel.find('.fb-place-search').addClass('d-none');
127
+ $results.hide().empty();
128
+ })
129
+ ).appendTo($results);
130
+ });
131
+ $results.show();
132
+ }, DEBOUNCE_MS);
133
+ });
134
+
135
+ $placePanel.find('.fb-place-clear').on('click', function (e) {
136
+ e.preventDefault();
137
+ $placePanel.find('.fb-place-id').val('');
138
+ $placePanel.find('.fb-place-selected').removeClass('d-inline-flex').addClass('d-none');
139
+ $placePanel.find('.fb-place-search').removeClass('d-none').val('').trigger('focus');
140
+ });
141
+
142
+ // Fermer le dropdown si clic ailleurs
143
+ $(document).off('click.fbpost').on('click.fbpost', function (e) {
144
+ if (!$(e.target).closest('.fb-place-panel').length) {
145
+ $placePanel.find('.fb-place-results').hide().empty();
146
+ }
147
+ });
148
+ }
149
+
150
+ // ── Injection dans la toolbar ──
151
+ const $toolbar = $composer.find('ul.formatting-group').first();
40
152
  if ($toolbar.length) {
41
- // Si la toolbar contient des <li>, on enveloppe
42
- if ($toolbar.find('li').length) {
43
- $toolbar.append($('<li>').append($btn));
44
- } else {
45
- $toolbar.append($btn);
46
- }
153
+ $toolbar.append($li);
154
+ $toolbar.after($placePanel);
47
155
  } else {
48
156
  // Fallback : avant la zone de texte
49
- $composer.find('.write').first().before($btn);
157
+ const $write = $composer.find('.write').first();
158
+ $write.before($placePanel);
159
+ $placePanel.before($li);
50
160
  }
51
161
 
162
+ // ── Hook submit ──
52
163
  $(window).off('action:composer.submit.fbpost')
53
164
  .on('action:composer.submit.fbpost', function (ev2, data) {
54
- const enabled = !!$composer.find('[data-fbpost-btn]').data('fbpost-active');
165
+ const enabled = !!$btn.data('fbpost-active');
55
166
  const postData = (data && data.composerData) || (data && data.postData) || data;
56
167
  if (!postData || typeof postData !== 'object') return;
57
168
  postData.fbPostEnabled = enabled;
169
+ if (enabled && showPlace) {
170
+ const placeId = $placePanel.find('.fb-place-id').val();
171
+ if (placeId) postData.fbPlaceId = placeId;
172
+ }
58
173
  });
59
174
  }
60
175
 
@@ -62,12 +177,10 @@
62
177
  try {
63
178
  const $composer = data && data.composer ? $(data.composer) : $('.composer');
64
179
  if (!$composer || !$composer.length) return;
65
-
66
180
  const perm = await canPost();
67
181
  if (!perm.allowed) return;
68
-
69
- injectUI($composer);
70
- } catch (err) {
182
+ injectUI($composer, perm);
183
+ } catch {
71
184
  // ignore
72
185
  }
73
186
  });