widgets.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. __package__ = 'archivebox.core'
  2. import json
  3. from django import forms
  4. from django.utils.html import escape
  5. class TagEditorWidget(forms.Widget):
  6. """
  7. A widget that renders tags as clickable pills with inline editing.
  8. - Displays existing tags alphabetically as styled pills with X remove button
  9. - Text input with HTML5 datalist for autocomplete suggestions
  10. - Press Enter or Space to create new tags (auto-creates if doesn't exist)
  11. - Uses AJAX for autocomplete and tag creation
  12. """
  13. template_name = None # We render manually
  14. class Media:
  15. css = {'all': []}
  16. js = []
  17. def __init__(self, attrs=None, snapshot_id=None):
  18. self.snapshot_id = snapshot_id
  19. super().__init__(attrs)
  20. def _escape(self, value):
  21. """Escape HTML entities in value."""
  22. return escape(str(value)) if value else ''
  23. def render(self, name, value, attrs=None, renderer=None):
  24. """
  25. Render the tag editor widget.
  26. Args:
  27. name: Field name
  28. value: Can be:
  29. - QuerySet of Tag objects (from M2M field)
  30. - List of tag names
  31. - Comma-separated string of tag names
  32. - None
  33. attrs: HTML attributes
  34. renderer: Not used
  35. """
  36. # Parse value to get list of tag names
  37. tags = []
  38. if value:
  39. if hasattr(value, 'all'): # QuerySet
  40. tags = sorted([tag.name for tag in value.all()])
  41. elif isinstance(value, (list, tuple)):
  42. if value and hasattr(value[0], 'name'): # List of Tag objects
  43. tags = sorted([tag.name for tag in value])
  44. else: # List of strings or IDs
  45. # Could be tag IDs from form submission
  46. from archivebox.core.models import Tag
  47. tag_names = []
  48. for v in value:
  49. if isinstance(v, str) and not v.isdigit():
  50. tag_names.append(v)
  51. else:
  52. try:
  53. tag = Tag.objects.get(pk=v)
  54. tag_names.append(tag.name)
  55. except (Tag.DoesNotExist, ValueError):
  56. if isinstance(v, str):
  57. tag_names.append(v)
  58. tags = sorted(tag_names)
  59. elif isinstance(value, str):
  60. tags = sorted([t.strip() for t in value.split(',') if t.strip()])
  61. widget_id = attrs.get('id', name) if attrs else name
  62. # Build pills HTML
  63. pills_html = ''
  64. for tag in tags:
  65. pills_html += f'''
  66. <span class="tag-pill" data-tag="{self._escape(tag)}">
  67. {self._escape(tag)}
  68. <button type="button" class="tag-remove-btn" data-tag-name="{self._escape(tag)}">&times;</button>
  69. </span>
  70. '''
  71. # Build the widget HTML
  72. html = f'''
  73. <div id="{widget_id}_container" class="tag-editor-container" onclick="focusTagInput_{widget_id}(event)">
  74. <div id="{widget_id}_pills" class="tag-pills">
  75. {pills_html}
  76. </div>
  77. <input type="text"
  78. id="{widget_id}_input"
  79. class="tag-inline-input"
  80. list="{widget_id}_datalist"
  81. placeholder="Add tag..."
  82. autocomplete="off"
  83. onkeydown="handleTagKeydown_{widget_id}(event)"
  84. oninput="fetchTagAutocomplete_{widget_id}(this.value)"
  85. >
  86. <datalist id="{widget_id}_datalist"></datalist>
  87. <input type="hidden" name="{name}" id="{widget_id}" value="{self._escape(','.join(tags))}">
  88. </div>
  89. <script>
  90. (function() {{
  91. var currentTags_{widget_id} = {json.dumps(tags)};
  92. var autocompleteTimeout_{widget_id} = null;
  93. window.focusTagInput_{widget_id} = function(event) {{
  94. if (event.target.classList.contains('tag-remove-btn')) return;
  95. document.getElementById('{widget_id}_input').focus();
  96. }};
  97. window.updateHiddenInput_{widget_id} = function() {{
  98. document.getElementById('{widget_id}').value = currentTags_{widget_id}.join(',');
  99. }};
  100. window.addTag_{widget_id} = function(tagName) {{
  101. tagName = tagName.trim();
  102. if (!tagName) return;
  103. // Check if tag already exists (case-insensitive)
  104. var exists = currentTags_{widget_id}.some(function(t) {{
  105. return t.toLowerCase() === tagName.toLowerCase();
  106. }});
  107. if (exists) {{
  108. document.getElementById('{widget_id}_input').value = '';
  109. return;
  110. }}
  111. // Add to current tags
  112. currentTags_{widget_id}.push(tagName);
  113. currentTags_{widget_id}.sort(function(a, b) {{
  114. return a.toLowerCase().localeCompare(b.toLowerCase());
  115. }});
  116. // Rebuild pills
  117. rebuildPills_{widget_id}();
  118. updateHiddenInput_{widget_id}();
  119. // Clear input
  120. document.getElementById('{widget_id}_input').value = '';
  121. // Create tag via API if it doesn't exist (fire and forget)
  122. fetch('/api/v1/core/tags/create/', {{
  123. method: 'POST',
  124. headers: {{
  125. 'Content-Type': 'application/json',
  126. 'X-CSRFToken': getCSRFToken()
  127. }},
  128. body: JSON.stringify({{ name: tagName }})
  129. }}).catch(function(err) {{
  130. console.log('Tag creation note:', err);
  131. }});
  132. }};
  133. window.removeTag_{widget_id} = function(tagName) {{
  134. currentTags_{widget_id} = currentTags_{widget_id}.filter(function(t) {{
  135. return t.toLowerCase() !== tagName.toLowerCase();
  136. }});
  137. rebuildPills_{widget_id}();
  138. updateHiddenInput_{widget_id}();
  139. }};
  140. window.rebuildPills_{widget_id} = function() {{
  141. var container = document.getElementById('{widget_id}_pills');
  142. container.innerHTML = '';
  143. currentTags_{widget_id}.forEach(function(tag) {{
  144. var pill = document.createElement('span');
  145. pill.className = 'tag-pill';
  146. pill.setAttribute('data-tag', tag);
  147. var tagText = document.createTextNode(tag);
  148. pill.appendChild(tagText);
  149. var removeBtn = document.createElement('button');
  150. removeBtn.type = 'button';
  151. removeBtn.className = 'tag-remove-btn';
  152. removeBtn.setAttribute('data-tag-name', tag);
  153. removeBtn.innerHTML = '&times;';
  154. pill.appendChild(removeBtn);
  155. container.appendChild(pill);
  156. }});
  157. }};
  158. // Add event delegation for remove buttons
  159. document.getElementById('{widget_id}_pills').addEventListener('click', function(event) {{
  160. if (event.target.classList.contains('tag-remove-btn')) {{
  161. var tagName = event.target.getAttribute('data-tag-name');
  162. if (tagName) {{
  163. removeTag_{widget_id}(tagName);
  164. }}
  165. }}
  166. }});
  167. window.handleTagKeydown_{widget_id} = function(event) {{
  168. var input = event.target;
  169. var value = input.value.trim();
  170. if (event.key === 'Enter' || event.key === ' ' || event.key === ',') {{
  171. event.preventDefault();
  172. if (value) {{
  173. // Handle comma-separated values
  174. value.split(',').forEach(function(tag) {{
  175. addTag_{widget_id}(tag.trim());
  176. }});
  177. }}
  178. }} else if (event.key === 'Backspace' && !value && currentTags_{widget_id}.length > 0) {{
  179. // Remove last tag on backspace when input is empty
  180. var lastTag = currentTags_{widget_id}.pop();
  181. rebuildPills_{widget_id}();
  182. updateHiddenInput_{widget_id}();
  183. }}
  184. }};
  185. window.fetchTagAutocomplete_{widget_id} = function(query) {{
  186. if (autocompleteTimeout_{widget_id}) {{
  187. clearTimeout(autocompleteTimeout_{widget_id});
  188. }}
  189. autocompleteTimeout_{widget_id} = setTimeout(function() {{
  190. if (!query || query.length < 1) {{
  191. document.getElementById('{widget_id}_datalist').innerHTML = '';
  192. return;
  193. }}
  194. fetch('/api/v1/core/tags/autocomplete/?q=' + encodeURIComponent(query))
  195. .then(function(response) {{ return response.json(); }})
  196. .then(function(data) {{
  197. var datalist = document.getElementById('{widget_id}_datalist');
  198. datalist.innerHTML = '';
  199. (data.tags || []).forEach(function(tag) {{
  200. var option = document.createElement('option');
  201. option.value = tag.name;
  202. datalist.appendChild(option);
  203. }});
  204. }})
  205. .catch(function(err) {{
  206. console.log('Autocomplete error:', err);
  207. }});
  208. }}, 150);
  209. }};
  210. function escapeHtml(text) {{
  211. var div = document.createElement('div');
  212. div.textContent = text;
  213. return div.innerHTML;
  214. }}
  215. function getCSRFToken() {{
  216. var cookies = document.cookie.split(';');
  217. for (var i = 0; i < cookies.length; i++) {{
  218. var cookie = cookies[i].trim();
  219. if (cookie.startsWith('csrftoken=')) {{
  220. return cookie.substring('csrftoken='.length);
  221. }}
  222. }}
  223. // Fallback to hidden input
  224. var input = document.querySelector('input[name="csrfmiddlewaretoken"]');
  225. return input ? input.value : '';
  226. }}
  227. }})();
  228. </script>
  229. '''
  230. return html
  231. class InlineTagEditorWidget(TagEditorWidget):
  232. """
  233. Inline version of TagEditorWidget for use in list views.
  234. Includes AJAX save functionality for immediate persistence.
  235. """
  236. def __init__(self, attrs=None, snapshot_id=None):
  237. super().__init__(attrs, snapshot_id)
  238. self.snapshot_id = snapshot_id
  239. def render(self, name, value, attrs=None, renderer=None, snapshot_id=None):
  240. """Render inline tag editor with AJAX save."""
  241. # Use snapshot_id from __init__ or from render call
  242. snapshot_id = snapshot_id or self.snapshot_id
  243. # Parse value to get list of tag dicts with id and name
  244. tags = []
  245. tag_data = []
  246. if value:
  247. if hasattr(value, 'all'): # QuerySet
  248. for tag in value.all():
  249. tag_data.append({'id': tag.pk, 'name': tag.name})
  250. tag_data.sort(key=lambda x: x['name'].lower())
  251. tags = [t['name'] for t in tag_data]
  252. elif isinstance(value, (list, tuple)):
  253. if value and hasattr(value[0], 'name'):
  254. for tag in value:
  255. tag_data.append({'id': tag.pk, 'name': tag.name})
  256. tag_data.sort(key=lambda x: x['name'].lower())
  257. tags = [t['name'] for t in tag_data]
  258. widget_id = f"inline_tags_{snapshot_id}" if snapshot_id else (attrs.get('id', name) if attrs else name)
  259. # Build pills HTML with filter links
  260. pills_html = ''
  261. for td in tag_data:
  262. pills_html += f'''
  263. <span class="tag-pill" data-tag="{self._escape(td['name'])}" data-tag-id="{td['id']}">
  264. <a href="/admin/core/snapshot/?tags__id__exact={td['id']}" class="tag-link">{self._escape(td['name'])}</a>
  265. <button type="button" class="tag-remove-btn" data-tag-id="{td['id']}" data-tag-name="{self._escape(td['name'])}">&times;</button>
  266. </span>
  267. '''
  268. html = f'''
  269. <span id="{widget_id}_container" class="tag-editor-inline" onclick="focusInlineTagInput_{widget_id}(event)">
  270. <span id="{widget_id}_pills" class="tag-pills-inline">
  271. {pills_html}
  272. </span>
  273. <input type="text"
  274. id="{widget_id}_input"
  275. class="tag-inline-input-sm"
  276. list="{widget_id}_datalist"
  277. placeholder="+"
  278. autocomplete="off"
  279. onkeydown="handleInlineTagKeydown_{widget_id}(event)"
  280. oninput="fetchInlineTagAutocomplete_{widget_id}(this.value)"
  281. onfocus="this.placeholder='add tag...'"
  282. onblur="this.placeholder='+'"
  283. >
  284. <datalist id="{widget_id}_datalist"></datalist>
  285. </span>
  286. <script>
  287. (function() {{
  288. var snapshotId_{widget_id} = '{snapshot_id}';
  289. var currentTagData_{widget_id} = {json.dumps(tag_data)};
  290. var autocompleteTimeout_{widget_id} = null;
  291. window.focusInlineTagInput_{widget_id} = function(event) {{
  292. event.stopPropagation();
  293. if (event.target.classList.contains('tag-remove-btn') || event.target.classList.contains('tag-link')) return;
  294. document.getElementById('{widget_id}_input').focus();
  295. }};
  296. window.addInlineTag_{widget_id} = function(tagName) {{
  297. tagName = tagName.trim();
  298. if (!tagName) return;
  299. // Check if tag already exists
  300. var exists = currentTagData_{widget_id}.some(function(t) {{
  301. return t.name.toLowerCase() === tagName.toLowerCase();
  302. }});
  303. if (exists) {{
  304. document.getElementById('{widget_id}_input').value = '';
  305. return;
  306. }}
  307. // Add via API
  308. fetch('/api/v1/core/tags/add-to-snapshot/', {{
  309. method: 'POST',
  310. headers: {{
  311. 'Content-Type': 'application/json',
  312. 'X-CSRFToken': getCSRFToken()
  313. }},
  314. body: JSON.stringify({{
  315. snapshot_id: snapshotId_{widget_id},
  316. tag_name: tagName
  317. }})
  318. }})
  319. .then(function(response) {{ return response.json(); }})
  320. .then(function(data) {{
  321. if (data.success) {{
  322. currentTagData_{widget_id}.push({{ id: data.tag_id, name: data.tag_name }});
  323. currentTagData_{widget_id}.sort(function(a, b) {{
  324. return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
  325. }});
  326. rebuildInlinePills_{widget_id}();
  327. }}
  328. }})
  329. .catch(function(err) {{
  330. console.error('Error adding tag:', err);
  331. }});
  332. document.getElementById('{widget_id}_input').value = '';
  333. }};
  334. window.removeInlineTag_{widget_id} = function(tagId) {{
  335. fetch('/api/v1/core/tags/remove-from-snapshot/', {{
  336. method: 'POST',
  337. headers: {{
  338. 'Content-Type': 'application/json',
  339. 'X-CSRFToken': getCSRFToken()
  340. }},
  341. body: JSON.stringify({{
  342. snapshot_id: snapshotId_{widget_id},
  343. tag_id: tagId
  344. }})
  345. }})
  346. .then(function(response) {{ return response.json(); }})
  347. .then(function(data) {{
  348. if (data.success) {{
  349. currentTagData_{widget_id} = currentTagData_{widget_id}.filter(function(t) {{
  350. return t.id !== tagId;
  351. }});
  352. rebuildInlinePills_{widget_id}();
  353. }}
  354. }})
  355. .catch(function(err) {{
  356. console.error('Error removing tag:', err);
  357. }});
  358. }};
  359. window.rebuildInlinePills_{widget_id} = function() {{
  360. var container = document.getElementById('{widget_id}_pills');
  361. container.innerHTML = '';
  362. currentTagData_{widget_id}.forEach(function(td) {{
  363. var pill = document.createElement('span');
  364. pill.className = 'tag-pill';
  365. pill.setAttribute('data-tag', td.name);
  366. pill.setAttribute('data-tag-id', td.id);
  367. var link = document.createElement('a');
  368. link.href = '/admin/core/snapshot/?tags__id__exact=' + td.id;
  369. link.className = 'tag-link';
  370. link.textContent = td.name;
  371. pill.appendChild(link);
  372. var removeBtn = document.createElement('button');
  373. removeBtn.type = 'button';
  374. removeBtn.className = 'tag-remove-btn';
  375. removeBtn.setAttribute('data-tag-id', td.id);
  376. removeBtn.setAttribute('data-tag-name', td.name);
  377. removeBtn.innerHTML = '&times;';
  378. pill.appendChild(removeBtn);
  379. container.appendChild(pill);
  380. }});
  381. }};
  382. // Add event delegation for remove buttons
  383. document.getElementById('{widget_id}_pills').addEventListener('click', function(event) {{
  384. if (event.target.classList.contains('tag-remove-btn')) {{
  385. event.stopPropagation();
  386. event.preventDefault();
  387. var tagId = parseInt(event.target.getAttribute('data-tag-id'), 10);
  388. if (tagId) {{
  389. removeInlineTag_{widget_id}(tagId);
  390. }}
  391. }}
  392. }});
  393. window.handleInlineTagKeydown_{widget_id} = function(event) {{
  394. event.stopPropagation();
  395. var input = event.target;
  396. var value = input.value.trim();
  397. if (event.key === 'Enter' || event.key === ',') {{
  398. event.preventDefault();
  399. if (value) {{
  400. value.split(',').forEach(function(tag) {{
  401. addInlineTag_{widget_id}(tag.trim());
  402. }});
  403. }}
  404. }}
  405. }};
  406. window.fetchInlineTagAutocomplete_{widget_id} = function(query) {{
  407. if (autocompleteTimeout_{widget_id}) {{
  408. clearTimeout(autocompleteTimeout_{widget_id});
  409. }}
  410. autocompleteTimeout_{widget_id} = setTimeout(function() {{
  411. if (!query || query.length < 1) {{
  412. document.getElementById('{widget_id}_datalist').innerHTML = '';
  413. return;
  414. }}
  415. fetch('/api/v1/core/tags/autocomplete/?q=' + encodeURIComponent(query))
  416. .then(function(response) {{ return response.json(); }})
  417. .then(function(data) {{
  418. var datalist = document.getElementById('{widget_id}_datalist');
  419. datalist.innerHTML = '';
  420. (data.tags || []).forEach(function(tag) {{
  421. var option = document.createElement('option');
  422. option.value = tag.name;
  423. datalist.appendChild(option);
  424. }});
  425. }})
  426. .catch(function(err) {{
  427. console.log('Autocomplete error:', err);
  428. }});
  429. }}, 150);
  430. }};
  431. function escapeHtml(text) {{
  432. var div = document.createElement('div');
  433. div.textContent = text;
  434. return div.innerHTML;
  435. }}
  436. function getCSRFToken() {{
  437. var cookies = document.cookie.split(';');
  438. for (var i = 0; i < cookies.length; i++) {{
  439. var cookie = cookies[i].trim();
  440. if (cookie.startsWith('csrftoken=')) {{
  441. return cookie.substring('csrftoken='.length);
  442. }}
  443. }}
  444. var input = document.querySelector('input[name="csrfmiddlewaretoken"]');
  445. return input ? input.value : '';
  446. }}
  447. }})();
  448. </script>
  449. '''
  450. return html