DRYing the project
This commit is contained in:
@@ -2,7 +2,9 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python:*)",
|
||||
"Bash(git mv:*)"
|
||||
"Bash(git mv:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(wc:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Use shared TagUtils module
|
||||
const { getTagIcon, getTagDisplayName, getTagDisplayNameForOverlay, escapeHtml, createTagChipHtml } = window.TagUtils || {};
|
||||
|
||||
const state = {
|
||||
isSelectMode: false,
|
||||
selectedIds: new Set(),
|
||||
@@ -263,7 +266,7 @@
|
||||
|
||||
dom.commonTags.innerHTML = tags.map(t => {
|
||||
const icon = getTagIcon(t.kind);
|
||||
const display = t.name.includes(':') ? t.name.split(':')[1] : t.name;
|
||||
const display = getTagDisplayName(t.name, t.kind);
|
||||
const escapedName = escapeHtml(t.name);
|
||||
const escapedDisplay = escapeHtml(display);
|
||||
return `<span class="tag-chip removable" data-name="${escapedName}">
|
||||
@@ -471,7 +474,7 @@
|
||||
|
||||
dom.autocomplete.innerHTML = tags.map((t, i) => {
|
||||
const icon = getTagIcon(t.kind);
|
||||
const displayName = t.name.includes(':') ? t.name.split(':')[1] : t.name;
|
||||
const displayName = getTagDisplayName(t.name, t.kind);
|
||||
return `<div class="tag-autocomplete-item" data-index="${i}" data-name="${escapeHtml(t.name)}">
|
||||
<span>${icon} ${escapeHtml(displayName)}</span>
|
||||
<span class="tag-kind">${t.kind || 'user'}</span>
|
||||
@@ -491,41 +494,9 @@
|
||||
autocompleteSelectedIndex = index;
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Helpers
|
||||
// ---------------------------
|
||||
function getTagIcon(kind) {
|
||||
const icons = {
|
||||
artist: '🎨',
|
||||
archive: '🗜️',
|
||||
character: '👤',
|
||||
series: '📺',
|
||||
fandom: '🎭',
|
||||
rating: '⚠️',
|
||||
source: '🌐',
|
||||
post: '📌'
|
||||
};
|
||||
return icons[kind] || '#';
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Tag Refresh for Gallery Items
|
||||
// ---------------------------
|
||||
function getTagDisplayName(name, kind) {
|
||||
// Strip prefix for known prefixed kinds
|
||||
if (['artist', 'character', 'series', 'fandom', 'rating'].includes(kind)) {
|
||||
return name.includes(':') ? name.split(':', 2)[1] : name;
|
||||
}
|
||||
// Regular tags get a # prefix
|
||||
return '#' + name;
|
||||
}
|
||||
|
||||
async function refreshItemTags(imageId) {
|
||||
// Find the gallery item
|
||||
const item = document.querySelector(`.img-clickable[data-id="${imageId}"]`);
|
||||
@@ -555,14 +526,8 @@
|
||||
item.appendChild(overlay);
|
||||
}
|
||||
|
||||
// Render tags with icons
|
||||
overlay.innerHTML = visibleTags.map(t => {
|
||||
const displayName = getTagDisplayName(t.name, t.kind);
|
||||
const icon = getTagIcon(t.kind);
|
||||
const encodedName = encodeURIComponent(t.name);
|
||||
const prefix = icon !== '#' ? icon + ' ' : '#';
|
||||
return `<a class="tag-chip" href="/gallery?tag=${encodedName}" title="Filter by ${escapeHtml(t.name)}" onclick="event.stopPropagation()">${prefix}${escapeHtml(displayName)}</a>`;
|
||||
}).join('');
|
||||
// Render tags with icons using shared utility
|
||||
overlay.innerHTML = visibleTags.map(t => createTagChipHtml(t)).join('');
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh tags for image', imageId, e);
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Use shared TagUtils module
|
||||
const { getTagIcon, getTagDisplayName, escapeHtml, createTagChipHtml } = window.TagUtils || {};
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
SCROLL_THRESHOLD: 1200, // px from bottom to trigger load
|
||||
@@ -187,21 +190,6 @@
|
||||
return section;
|
||||
}
|
||||
|
||||
// Tag icon mapping (matches showcase.js and bulk-select.js)
|
||||
function getTagIcon(kind) {
|
||||
const icons = {
|
||||
artist: '🎨',
|
||||
archive: '🗜️',
|
||||
character: '👤',
|
||||
series: '📺',
|
||||
fandom: '🎭',
|
||||
rating: '⚠️',
|
||||
source: '🌐',
|
||||
post: '📌'
|
||||
};
|
||||
return icons[kind] || '#';
|
||||
}
|
||||
|
||||
// Create a single image element
|
||||
function createImageElement(img) {
|
||||
const item = document.createElement('div');
|
||||
@@ -211,15 +199,10 @@
|
||||
item.dataset.type = img.is_video ? 'video' : 'image';
|
||||
item.dataset.filename = img.filename;
|
||||
|
||||
// Build tag overlay HTML with icons
|
||||
// Build tag overlay HTML with icons using shared utility
|
||||
let tagsHtml = '';
|
||||
if (img.tags && img.tags.length > 0) {
|
||||
const tagChips = img.tags.map(t => {
|
||||
const displayName = t.name.includes(':') ? t.name.split(':')[1] : t.name;
|
||||
const icon = getTagIcon(t.kind);
|
||||
const prefix = icon !== '#' ? icon + ' ' : '#';
|
||||
return `<a class="tag-chip" href="/gallery?tag=${encodeURIComponent(t.name)}" title="Filter by ${t.name}" onclick="event.stopPropagation()">${prefix}${displayName}</a>`;
|
||||
}).join('');
|
||||
const tagChips = img.tags.map(t => createTagChipHtml(t)).join('');
|
||||
tagsHtml = `<div class="tag-overlay">${tagChips}</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
// JS-managed masonry with column distribution for infinite scroll
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Use shared TagUtils module
|
||||
const { formatTagHtml, createTagChipHtml } = window.TagUtils || {};
|
||||
|
||||
const container = document.getElementById('showcaseGrid');
|
||||
if (!container) return;
|
||||
|
||||
@@ -156,15 +161,8 @@
|
||||
if (visibleTags.length > 0) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'tag-overlay';
|
||||
visibleTags.forEach(t => {
|
||||
const chip = document.createElement('a');
|
||||
chip.className = 'tag-chip';
|
||||
chip.href = `/gallery?tag=${encodeURIComponent(t.name)}`;
|
||||
chip.title = `Filter by ${t.name}`;
|
||||
chip.onclick = (e) => e.stopPropagation();
|
||||
chip.innerHTML = formatTag(t);
|
||||
overlay.appendChild(chip);
|
||||
});
|
||||
// Use shared utility to create tag chips
|
||||
overlay.innerHTML = visibleTags.map(t => createTagChipHtml(t)).join('');
|
||||
item.appendChild(overlay);
|
||||
}
|
||||
|
||||
@@ -274,33 +272,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatTag(tag) {
|
||||
const escape = (s) => {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = s;
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
const icons = {
|
||||
artist: '🎨',
|
||||
archive: '🗜️',
|
||||
character: '👤',
|
||||
series: '📺',
|
||||
fandom: '🎭',
|
||||
rating: '⚠️',
|
||||
source: '🌐',
|
||||
post: '📌'
|
||||
};
|
||||
|
||||
const icon = icons[tag.kind];
|
||||
if (icon) {
|
||||
const label = tag.name.includes(':') ? tag.name.split(':', 2)[1] : tag.name;
|
||||
return `${icon} ${escape(label)}`;
|
||||
} else {
|
||||
return `#${escape(tag.name)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Start
|
||||
init();
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// /app/static/js/tag-utils.js
|
||||
// Shared tag utility functions used across gallery, modal, and bulk editor
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Get emoji icon for a tag kind
|
||||
* @param {string} kind - Tag kind (artist, archive, character, etc.)
|
||||
* @returns {string} Emoji icon or '#' for user tags
|
||||
*/
|
||||
function getTagIcon(kind) {
|
||||
const icons = {
|
||||
artist: '🎨',
|
||||
archive: '🗜️',
|
||||
character: '👤',
|
||||
series: '📺',
|
||||
fandom: '🎭',
|
||||
rating: '⚠️',
|
||||
source: '🌐',
|
||||
post: '📌'
|
||||
};
|
||||
return icons[kind] || '#';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a tag, stripping the prefix if present
|
||||
* @param {string} name - Full tag name (e.g., "artist:Name")
|
||||
* @param {string} [kind] - Optional tag kind for context
|
||||
* @returns {string} Display name without prefix
|
||||
*/
|
||||
function getTagDisplayName(name, kind) {
|
||||
// For prefixed kinds, strip the prefix
|
||||
if (kind && ['artist', 'character', 'series', 'fandom', 'rating'].includes(kind)) {
|
||||
return name.includes(':') ? name.split(':', 2)[1] : name;
|
||||
}
|
||||
// If no kind specified but has colon, assume it's prefixed
|
||||
if (!kind && name.includes(':')) {
|
||||
return name.split(':', 2)[1];
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for tag overlay (used in gallery items)
|
||||
* Returns name with # prefix for unprefixed user tags
|
||||
* @param {string} name - Full tag name
|
||||
* @param {string} kind - Tag kind
|
||||
* @returns {string} Display name for overlay
|
||||
*/
|
||||
function getTagDisplayNameForOverlay(name, kind) {
|
||||
if (['artist', 'character', 'series', 'fandom', 'rating'].includes(kind)) {
|
||||
return name.includes(':') ? name.split(':', 2)[1] : name;
|
||||
}
|
||||
return '#' + name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters
|
||||
* @param {string} str - String to escape
|
||||
* @returns {string} HTML-escaped string
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a tag for display with icon and name
|
||||
* @param {Object} tag - Tag object with name and kind
|
||||
* @returns {string} Formatted HTML string
|
||||
*/
|
||||
function formatTagHtml(tag) {
|
||||
const icon = getTagIcon(tag.kind);
|
||||
const displayName = getTagDisplayName(tag.name, tag.kind);
|
||||
const escaped = escapeHtml(displayName);
|
||||
|
||||
if (icon !== '#') {
|
||||
return `${icon} ${escaped}`;
|
||||
}
|
||||
return `#${escaped}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tag chip HTML for gallery overlays
|
||||
* @param {Object} tag - Tag object with name and kind
|
||||
* @param {boolean} [stopPropagation=true] - Add onclick to stop propagation
|
||||
* @returns {string} HTML string for tag chip link
|
||||
*/
|
||||
function createTagChipHtml(tag, stopPropagation = true) {
|
||||
const icon = getTagIcon(tag.kind);
|
||||
const displayName = getTagDisplayNameForOverlay(tag.name, tag.kind);
|
||||
const encodedName = encodeURIComponent(tag.name);
|
||||
const escapedTitle = escapeHtml(tag.name);
|
||||
const escapedDisplay = escapeHtml(displayName);
|
||||
const prefix = icon !== '#' ? icon + ' ' : '#';
|
||||
const onclick = stopPropagation ? ' onclick="event.stopPropagation()"' : '';
|
||||
|
||||
return `<a class="tag-chip" href="/gallery?tag=${encodedName}" title="Filter by ${escapedTitle}"${onclick}>${prefix}${escapedDisplay}</a>`;
|
||||
}
|
||||
|
||||
// Export to window.TagUtils
|
||||
window.TagUtils = {
|
||||
getTagIcon,
|
||||
getTagDisplayName,
|
||||
getTagDisplayNameForOverlay,
|
||||
escapeHtml,
|
||||
formatTagHtml,
|
||||
createTagChipHtml
|
||||
};
|
||||
})();
|
||||
@@ -57,30 +57,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// ---------------------------
|
||||
// Tag editor helpers
|
||||
// ---------------------------
|
||||
// Use shared TagUtils module
|
||||
const { getTagIcon, getTagDisplayName, getTagDisplayNameForOverlay, escapeHtml, createTagChipHtml } = window.TagUtils || {};
|
||||
|
||||
function setEditorImageId(id) {
|
||||
if (tagEditor) tagEditor.dataset.imageId = id || '';
|
||||
}
|
||||
function getEditorImageId() {
|
||||
return tagEditor ? tagEditor.dataset.imageId : '';
|
||||
}
|
||||
function getTagIcon(kind) {
|
||||
const icons = {
|
||||
artist: '🎨',
|
||||
archive: '🗜️',
|
||||
character: '👤',
|
||||
series: '📺',
|
||||
fandom: '🎭',
|
||||
rating: '⚠️',
|
||||
source: '🌐',
|
||||
post: '📌'
|
||||
};
|
||||
return icons[kind] || '#';
|
||||
}
|
||||
|
||||
function getTagDisplayName(name) {
|
||||
// Remove prefix if present (e.g., "artist:Name" -> "Name")
|
||||
return name.includes(':') ? name.split(':', 2)[1] : name;
|
||||
}
|
||||
|
||||
function renderTags(tags) {
|
||||
if (!tagList) return;
|
||||
@@ -108,13 +93,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// ---------------------------
|
||||
// Gallery item tag refresh (fallback if bulk-select.js not loaded)
|
||||
// ---------------------------
|
||||
function getTagDisplayNameForOverlay(name, kind) {
|
||||
if (['artist', 'character', 'series', 'fandom', 'rating'].includes(kind)) {
|
||||
return name.includes(':') ? name.split(':', 2)[1] : name;
|
||||
}
|
||||
return '#' + name;
|
||||
}
|
||||
|
||||
async function refreshItemTagsFallback(imageId) {
|
||||
const item = document.querySelector(`.img-clickable[data-id="${imageId}"]`);
|
||||
if (!item) return;
|
||||
@@ -138,15 +116,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
item.appendChild(overlay);
|
||||
}
|
||||
|
||||
overlay.innerHTML = visibleTags.map(t => {
|
||||
const displayName = getTagDisplayNameForOverlay(t.name, t.kind);
|
||||
const icon = getTagIcon(t.kind);
|
||||
const encodedName = encodeURIComponent(t.name);
|
||||
const escapedName = t.name.replace(/"/g, '"');
|
||||
const escapedDisplay = displayName.replace(/</g, '<').replace(/>/g, '>');
|
||||
const prefix = icon !== '#' ? icon + ' ' : '#';
|
||||
return `<a class="tag-chip" href="/gallery?tag=${encodedName}" title="Filter by ${escapedName}" onclick="event.stopPropagation()">${prefix}${escapedDisplay}</a>`;
|
||||
}).join('');
|
||||
overlay.innerHTML = visibleTags.map(t => createTagChipHtml(t)).join('');
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh tags for image', imageId, e);
|
||||
}
|
||||
|
||||
@@ -174,7 +174,6 @@ header {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
|
||||
z-index: 1001;
|
||||
padding: 0.5rem 0;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.nav-dropdown-menu a {
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/tag-utils.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/gallery-infinite.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/view-modal.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/bulk-select.js') }}"></script>
|
||||
|
||||
@@ -47,35 +47,12 @@
|
||||
{% endfor %}]
|
||||
</script>
|
||||
|
||||
<!-- Modal viewer -->
|
||||
<div id="imageModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<button id="modalClose" class="modal-close-btn" title="Close (ESC)">×</button>
|
||||
<button id="modalPrev" class="modal-button modal-prev">‹</button>
|
||||
<button id="modalNext" class="modal-button modal-next">›</button>
|
||||
<span class="modal-close-hint">ESC to close</span>
|
||||
|
||||
<div class="modal-body">
|
||||
<div id="modalImageWrapper" class="modal-image-wrapper">
|
||||
<img id="modalImage" src="" alt="Full View">
|
||||
</div>
|
||||
|
||||
<div id="modalTagEditor" class="tag-editor" data-image-id="">
|
||||
<div id="modalTagList" class="tags"></div>
|
||||
<form id="modalTagForm" class="tag-form" autocomplete="off">
|
||||
<div class="tag-form-wrapper">
|
||||
<input type="text" id="tagInput" name="name" placeholder="Add tag..." autocomplete="off">
|
||||
<div id="tagAutocomplete" class="tag-autocomplete"></div>
|
||||
</div>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Modal viewer (shared with gallery) -->
|
||||
{% include '_gallery_modal.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/tag-utils.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/view-modal.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/showcase.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
# Dockerfile
|
||||
FROM python:slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# System deps for archives and video thumbs (all FOSS)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
p7zip-full \
|
||||
unar \
|
||||
file \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
# Install deps first for better layer caching
|
||||
COPY requirements.txt .
|
||||
RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy the rest of the app
|
||||
COPY . .
|
||||
|
||||
# Entrypoint controls migrations, background worker, and Gunicorn
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 5000
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
+26
-11
@@ -4,7 +4,7 @@ A Flask-based image repository with Celery task processing for importing, organi
|
||||
|
||||
> **IMPORTANT**: This summary must be kept up to date with any code changes. Update the timestamp below when making modifications.
|
||||
>
|
||||
> **Last Updated**: 2026-02-04 (Pixiv sidecar support, Tags navbar dropdown, tag search)
|
||||
> **Last Updated**: 2026-02-05 (Consolidated JS utilities, shared modal template, Tags dropdown gap fix)
|
||||
|
||||
---
|
||||
|
||||
@@ -346,25 +346,40 @@ Import tasks support two modes based on `context.deep_scan`:
|
||||
|
||||
| Template | Purpose |
|
||||
|----------|---------|
|
||||
| `layout.html` | Base template with navbar |
|
||||
| `showcase.html` | Random image masonry view |
|
||||
| `gallery.html` | Main gallery with infinite scroll, timeline sidebar, bulk editor, series editor |
|
||||
| `layout.html` | Base template with navbar (includes Tags dropdown) |
|
||||
| `showcase.html` | Random image masonry view (extends layout, includes `_gallery_modal.html`) |
|
||||
| `gallery.html` | Main gallery with infinite scroll, timeline sidebar, bulk editor (includes `_gallery_modal.html`) |
|
||||
| `reader.html` | Series reader with vertical scroll, page jump, thumbnail navigation |
|
||||
| `tags_list.html` | Tag browser with preview images |
|
||||
| `tags_list.html` | Tag browser with preview images and infinite scroll |
|
||||
| `settings.html` | Import settings, deletion tools, duplicate detection, maintenance tools |
|
||||
| `_gallery_item.html` | Single gallery item partial (with tag overlay icons) |
|
||||
| `_gallery_modal.html` | Image modal with tag editor and series management |
|
||||
| `_pagination.html` | Pagination controls |
|
||||
| `_gallery_modal.html` | **Shared** image modal with tag editor and series management (used by gallery & showcase) |
|
||||
| `_tag_cards.html` | Tag card grid partial for tag list |
|
||||
| `_pagination.html` | Legacy pagination controls |
|
||||
| `_pagination_floating.html` | Floating pagination indicator |
|
||||
|
||||
**Template Inheritance**:
|
||||
```
|
||||
layout.html (base)
|
||||
├── gallery.html (includes: _gallery_item.html, _gallery_modal.html)
|
||||
├── showcase.html (includes: _gallery_modal.html)
|
||||
├── reader.html
|
||||
├── tags_list.html (includes: _tag_cards.html)
|
||||
└── settings.html
|
||||
```
|
||||
|
||||
### JavaScript Files (`app/static/js/`)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `gallery-infinite.js` | Infinite scroll, timeline navigation |
|
||||
| `view-modal.js` | Modal image navigation, tag editing, series management (add/update/move) |
|
||||
| `bulk-select.js` | Multi-select with ordered selection, bulk tag editing, tag refresh, series bulk-add |
|
||||
| `tag-utils.js` | **Shared utility module** - `getTagIcon()`, `getTagDisplayName()`, `escapeHtml()`, `createTagChipHtml()` used by all tag-related JS |
|
||||
| `gallery-infinite.js` | Infinite scroll, timeline navigation (uses TagUtils) |
|
||||
| `view-modal.js` | Modal image navigation, tag editing, series management (uses TagUtils) |
|
||||
| `bulk-select.js` | Multi-select with ordered selection, bulk tag editing, tag refresh (uses TagUtils) |
|
||||
| `tag-editor.js` | Tag autocomplete and editing |
|
||||
| `showcase.js` | Showcase shuffle functionality |
|
||||
| `showcase.js` | Showcase masonry layout and shuffle functionality (uses TagUtils) |
|
||||
|
||||
**Script Loading Order**: `tag-utils.js` must be loaded before other JS files that depend on `window.TagUtils`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user