widgets.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. __package__ = 'archivebox.core'
  2. import json
  3. import re
  4. import hashlib
  5. from django import forms
  6. from django.utils.html import escape
  7. from django.utils.safestring import mark_safe
  8. class TagEditorWidget(forms.Widget):
  9. """
  10. A widget that renders tags as clickable pills with inline editing.
  11. - Displays existing tags alphabetically as styled pills with X remove button
  12. - Text input with HTML5 datalist for autocomplete suggestions
  13. - Press Enter or Space to create new tags (auto-creates if doesn't exist)
  14. - Uses AJAX for autocomplete and tag creation
  15. """
  16. template_name = None # We render manually
  17. class Media:
  18. css = {'all': []}
  19. js = []
  20. def __init__(self, attrs=None, snapshot_id=None):
  21. self.snapshot_id = snapshot_id
  22. super().__init__(attrs)
  23. def _escape(self, value):
  24. """Escape HTML entities in value."""
  25. return escape(str(value)) if value else ''
  26. def _normalize_id(self, value):
  27. """Normalize IDs for HTML + JS usage (letters, digits, underscore; JS-safe start)."""
  28. normalized = re.sub(r'[^A-Za-z0-9_]', '_', str(value))
  29. if not normalized or not re.match(r'[A-Za-z_]', normalized):
  30. normalized = f't_{normalized}'
  31. return normalized
  32. def _tag_style(self, value):
  33. """Compute a stable pastel color style for a tag value."""
  34. tag = (value or '').strip().lower()
  35. digest = hashlib.md5(tag.encode('utf-8')).hexdigest()
  36. hue = int(digest[:4], 16) % 360
  37. bg = f'hsl({hue}, 70%, 92%)'
  38. border = f'hsl({hue}, 60%, 82%)'
  39. fg = f'hsl({hue}, 35%, 28%)'
  40. return f'--tag-bg: {bg}; --tag-border: {border}; --tag-fg: {fg};'
  41. def render(self, name, value, attrs=None, renderer=None):
  42. """
  43. Render the tag editor widget.
  44. Args:
  45. name: Field name
  46. value: Can be:
  47. - QuerySet of Tag objects (from M2M field)
  48. - List of tag names
  49. - Comma-separated string of tag names
  50. - None
  51. attrs: HTML attributes
  52. renderer: Not used
  53. """
  54. # Parse value to get list of tag names
  55. tags = []
  56. if value:
  57. if hasattr(value, 'all'): # QuerySet
  58. tags = sorted([tag.name for tag in value.all()])
  59. elif isinstance(value, (list, tuple)):
  60. if value and hasattr(value[0], 'name'): # List of Tag objects
  61. tags = sorted([tag.name for tag in value])
  62. else: # List of strings or IDs
  63. # Could be tag IDs from form submission
  64. from archivebox.core.models import Tag
  65. tag_names = []
  66. for v in value:
  67. if isinstance(v, str) and not v.isdigit():
  68. tag_names.append(v)
  69. else:
  70. try:
  71. tag = Tag.objects.get(pk=v)
  72. tag_names.append(tag.name)
  73. except (Tag.DoesNotExist, ValueError):
  74. if isinstance(v, str):
  75. tag_names.append(v)
  76. tags = sorted(tag_names)
  77. elif isinstance(value, str):
  78. tags = sorted([t.strip() for t in value.split(',') if t.strip()])
  79. widget_id_raw = attrs.get('id', name) if attrs else name
  80. widget_id = self._normalize_id(widget_id_raw)
  81. # Build pills HTML
  82. pills_html = ''
  83. for tag in tags:
  84. pills_html += f'''
  85. <span class="tag-pill" data-tag="{self._escape(tag)}" style="{self._tag_style(tag)}">
  86. {self._escape(tag)}
  87. <button type="button" class="tag-remove-btn" data-tag-name="{self._escape(tag)}">&times;</button>
  88. </span>
  89. '''
  90. # Build the widget HTML
  91. html = f'''
  92. <div id="{widget_id}_container" class="tag-editor-container" onclick="focusTagInput_{widget_id}(event)">
  93. <div id="{widget_id}_pills" class="tag-pills">
  94. {pills_html}
  95. </div>
  96. <input type="text"
  97. id="{widget_id}_input"
  98. class="tag-inline-input"
  99. list="{widget_id}_datalist"
  100. placeholder="Add tag..."
  101. autocomplete="off"
  102. onkeydown="handleTagKeydown_{widget_id}(event)"
  103. onkeypress="if(event.key==='Enter' || event.keyCode===13){{event.preventDefault(); event.stopPropagation();}}"
  104. oninput="fetchTagAutocomplete_{widget_id}(this.value)"
  105. >
  106. <datalist id="{widget_id}_datalist"></datalist>
  107. <input type="hidden" name="{name}" id="{widget_id}" value="{self._escape(','.join(tags))}">
  108. </div>
  109. <script>
  110. (function() {{
  111. var currentTags_{widget_id} = {json.dumps(tags)};
  112. var autocompleteTimeout_{widget_id} = null;
  113. window.focusTagInput_{widget_id} = function(event) {{
  114. if (event.target.classList.contains('tag-remove-btn')) return;
  115. document.getElementById('{widget_id}_input').focus();
  116. }};
  117. window.updateHiddenInput_{widget_id} = function() {{
  118. document.getElementById('{widget_id}').value = currentTags_{widget_id}.join(',');
  119. }};
  120. function computeTagStyle_{widget_id}(tagName) {{
  121. var hash = 0;
  122. var name = String(tagName || '').toLowerCase();
  123. for (var i = 0; i < name.length; i++) {{
  124. hash = (hash * 31 + name.charCodeAt(i)) % 360;
  125. }}
  126. var bg = 'hsl(' + hash + ', 70%, 92%)';
  127. var border = 'hsl(' + hash + ', 60%, 82%)';
  128. var fg = 'hsl(' + hash + ', 35%, 28%)';
  129. return {{ bg: bg, border: border, fg: fg }};
  130. }}
  131. function applyTagStyle_{widget_id}(el, tagName) {{
  132. var colors = computeTagStyle_{widget_id}(tagName);
  133. el.style.setProperty('--tag-bg', colors.bg);
  134. el.style.setProperty('--tag-border', colors.border);
  135. el.style.setProperty('--tag-fg', colors.fg);
  136. }}
  137. function getApiKey() {{
  138. return (window.ARCHIVEBOX_API_KEY || '').trim();
  139. }}
  140. function buildApiUrl(path) {{
  141. var apiKey = getApiKey();
  142. if (!apiKey) return path;
  143. var sep = path.indexOf('?') !== -1 ? '&' : '?';
  144. return path + sep + 'api_key=' + encodeURIComponent(apiKey);
  145. }}
  146. function buildApiHeaders() {{
  147. var headers = {{
  148. 'Content-Type': 'application/json',
  149. }};
  150. var apiKey = getApiKey();
  151. if (apiKey) headers['X-ArchiveBox-API-Key'] = apiKey;
  152. var csrfToken = getCSRFToken();
  153. if (csrfToken) headers['X-CSRFToken'] = csrfToken;
  154. return headers;
  155. }}
  156. window.addTag_{widget_id} = function(tagName) {{
  157. tagName = tagName.trim();
  158. if (!tagName) return;
  159. // Check if tag already exists (case-insensitive)
  160. var exists = currentTags_{widget_id}.some(function(t) {{
  161. return t.toLowerCase() === tagName.toLowerCase();
  162. }});
  163. if (exists) {{
  164. document.getElementById('{widget_id}_input').value = '';
  165. return;
  166. }}
  167. // Add to current tags
  168. currentTags_{widget_id}.push(tagName);
  169. currentTags_{widget_id}.sort(function(a, b) {{
  170. return a.toLowerCase().localeCompare(b.toLowerCase());
  171. }});
  172. // Rebuild pills
  173. rebuildPills_{widget_id}();
  174. updateHiddenInput_{widget_id}();
  175. // Clear input
  176. document.getElementById('{widget_id}_input').value = '';
  177. // Create tag via API if it doesn't exist (fire and forget)
  178. fetch(buildApiUrl('/api/v1/core/tags/create/'), {{
  179. method: 'POST',
  180. headers: buildApiHeaders(),
  181. body: JSON.stringify({{ name: tagName }})
  182. }}).catch(function(err) {{
  183. console.log('Tag creation note:', err);
  184. }});
  185. }};
  186. window.removeTag_{widget_id} = function(tagName) {{
  187. currentTags_{widget_id} = currentTags_{widget_id}.filter(function(t) {{
  188. return t.toLowerCase() !== tagName.toLowerCase();
  189. }});
  190. rebuildPills_{widget_id}();
  191. updateHiddenInput_{widget_id}();
  192. }};
  193. window.rebuildPills_{widget_id} = function() {{
  194. var container = document.getElementById('{widget_id}_pills');
  195. container.innerHTML = '';
  196. currentTags_{widget_id}.forEach(function(tag) {{
  197. var pill = document.createElement('span');
  198. pill.className = 'tag-pill';
  199. pill.setAttribute('data-tag', tag);
  200. applyTagStyle_{widget_id}(pill, tag);
  201. var tagText = document.createTextNode(tag);
  202. pill.appendChild(tagText);
  203. var removeBtn = document.createElement('button');
  204. removeBtn.type = 'button';
  205. removeBtn.className = 'tag-remove-btn';
  206. removeBtn.setAttribute('data-tag-name', tag);
  207. removeBtn.innerHTML = '&times;';
  208. pill.appendChild(removeBtn);
  209. container.appendChild(pill);
  210. }});
  211. }};
  212. // Add event delegation for remove buttons
  213. document.getElementById('{widget_id}_pills').addEventListener('click', function(event) {{
  214. if (event.target.classList.contains('tag-remove-btn')) {{
  215. var tagName = event.target.getAttribute('data-tag-name');
  216. if (tagName) {{
  217. removeTag_{widget_id}(tagName);
  218. }}
  219. }}
  220. }});
  221. window.handleTagKeydown_{widget_id} = function(event) {{
  222. var input = event.target;
  223. var value = input.value.trim();
  224. if (event.key === 'Enter' || event.keyCode === 13 || event.key === ' ' || event.key === ',') {{
  225. event.preventDefault();
  226. event.stopPropagation();
  227. if (value) {{
  228. // Handle comma-separated values
  229. value.split(',').forEach(function(tag) {{
  230. addTag_{widget_id}(tag.trim());
  231. }});
  232. }}
  233. return false;
  234. }} else if (event.key === 'Backspace' && !value && currentTags_{widget_id}.length > 0) {{
  235. // Remove last tag on backspace when input is empty
  236. var lastTag = currentTags_{widget_id}.pop();
  237. rebuildPills_{widget_id}();
  238. updateHiddenInput_{widget_id}();
  239. }}
  240. }};
  241. window.fetchTagAutocomplete_{widget_id} = function(query) {{
  242. if (autocompleteTimeout_{widget_id}) {{
  243. clearTimeout(autocompleteTimeout_{widget_id});
  244. }}
  245. autocompleteTimeout_{widget_id} = setTimeout(function() {{
  246. if (!query || query.length < 1) {{
  247. document.getElementById('{widget_id}_datalist').innerHTML = '';
  248. return;
  249. }}
  250. fetch(buildApiUrl('/api/v1/core/tags/autocomplete/?q=' + encodeURIComponent(query)))
  251. .then(function(response) {{ return response.json(); }})
  252. .then(function(data) {{
  253. var datalist = document.getElementById('{widget_id}_datalist');
  254. datalist.innerHTML = '';
  255. (data.tags || []).forEach(function(tag) {{
  256. var option = document.createElement('option');
  257. option.value = tag.name;
  258. datalist.appendChild(option);
  259. }});
  260. }})
  261. .catch(function(err) {{
  262. console.log('Autocomplete error:', err);
  263. }});
  264. }}, 150);
  265. }};
  266. function escapeHtml(text) {{
  267. var div = document.createElement('div');
  268. div.textContent = text;
  269. return div.innerHTML;
  270. }}
  271. function getCSRFToken() {{
  272. var cookies = document.cookie.split(';');
  273. for (var i = 0; i < cookies.length; i++) {{
  274. var cookie = cookies[i].trim();
  275. if (cookie.startsWith('csrftoken=')) {{
  276. return cookie.substring('csrftoken='.length);
  277. }}
  278. }}
  279. // Fallback to hidden input
  280. var input = document.querySelector('input[name="csrfmiddlewaretoken"]');
  281. return input ? input.value : '';
  282. }}
  283. }})();
  284. </script>
  285. '''
  286. return mark_safe(html)
  287. class InlineTagEditorWidget(TagEditorWidget):
  288. """
  289. Inline version of TagEditorWidget for use in list views.
  290. Includes AJAX save functionality for immediate persistence.
  291. """
  292. def __init__(self, attrs=None, snapshot_id=None):
  293. super().__init__(attrs, snapshot_id)
  294. self.snapshot_id = snapshot_id
  295. def render(self, name, value, attrs=None, renderer=None, snapshot_id=None):
  296. """Render inline tag editor with AJAX save."""
  297. # Use snapshot_id from __init__ or from render call
  298. snapshot_id = snapshot_id or self.snapshot_id
  299. # Parse value to get list of tag dicts with id and name
  300. tags = []
  301. tag_data = []
  302. if value:
  303. if hasattr(value, 'all'): # QuerySet
  304. for tag in value.all():
  305. tag_data.append({'id': tag.pk, 'name': tag.name})
  306. tag_data.sort(key=lambda x: x['name'].lower())
  307. tags = [t['name'] for t in tag_data]
  308. elif isinstance(value, (list, tuple)):
  309. if value and hasattr(value[0], 'name'):
  310. for tag in value:
  311. tag_data.append({'id': tag.pk, 'name': tag.name})
  312. tag_data.sort(key=lambda x: x['name'].lower())
  313. tags = [t['name'] for t in tag_data]
  314. widget_id_raw = f"inline_tags_{snapshot_id}" if snapshot_id else (attrs.get('id', name) if attrs else name)
  315. widget_id = self._normalize_id(widget_id_raw)
  316. # Build pills HTML with filter links
  317. pills_html = ''
  318. for td in tag_data:
  319. pills_html += f'''
  320. <span class="tag-pill" data-tag="{self._escape(td['name'])}" data-tag-id="{td['id']}" style="{self._tag_style(td['name'])}">
  321. <a href="/admin/core/snapshot/?tags__id__exact={td['id']}" class="tag-link">{self._escape(td['name'])}</a>
  322. <button type="button" class="tag-remove-btn" data-tag-id="{td['id']}" data-tag-name="{self._escape(td['name'])}">&times;</button>
  323. </span>
  324. '''
  325. tags_json = escape(json.dumps(tag_data))
  326. html = f'''
  327. <span id="{widget_id}_container" class="tag-editor-inline" data-snapshot-id="{snapshot_id}" data-tags="{tags_json}">
  328. <span id="{widget_id}_pills" class="tag-pills-inline">
  329. {pills_html}
  330. </span>
  331. <input type="text"
  332. id="{widget_id}_input"
  333. class="tag-inline-input-sm"
  334. list="{widget_id}_datalist"
  335. placeholder="+"
  336. autocomplete="off"
  337. data-inline-tag-input="1"
  338. >
  339. <datalist id="{widget_id}_datalist"></datalist>
  340. </span>
  341. '''
  342. return mark_safe(html)