remove clientside support code

This commit is contained in:
byte[] 2020-08-16 05:51:14 -04:00
parent 5dc4181014
commit fdda89500b
17 changed files with 9 additions and 1536 deletions

View file

@ -1,90 +1,10 @@
import { $ } from './utils/dom';
import parseSearch from './match_query';
import store from './utils/store';
/* Store a tag locally, marking the retrieval time */
function persistTag(tagData) {
tagData.fetchedAt = new Date().getTime() / 1000;
store.set(`bor_tags_${tagData.id}`, tagData);
}
function isStale(tag) {
const now = new Date().getTime() / 1000;
return tag.fetchedAt === null || tag.fetchedAt < (now - 604800);
}
function clearTags() {
Object.keys(localStorage).forEach(key => {
if (key.substring(0, 9) === 'bor_tags_') {
store.remove(key);
}
});
}
/* Returns a single tag, or a dummy tag object if we don't know about it yet */
function getTag(tagId) {
const stored = store.get(`bor_tags_${tagId}`);
if (stored) {
return stored;
}
return {
id: tagId,
name: '(unknown tag)',
images: 0,
spoiler_image_uri: null,
};
}
/* Fetches lots of tags in batches and stores them locally */
function fetchAndPersistTags(tagIds) {
if (!tagIds.length) return;
const ids = tagIds.slice(0, 40);
const remaining = tagIds.slice(41);
fetch(`/tags/fetch?ids[]=${ids.join('&ids[]=')}`)
.then(response => response.json())
.then(data => data.tags.forEach(tag => persistTag(tag)))
.then(() => fetchAndPersistTags(remaining));
}
/* Figure out which tags in the list we don't know about */
function fetchNewOrStaleTags(tagIds) {
const fetchIds = [];
tagIds.forEach(t => {
const stored = store.get(`bor_tags_${t}`);
if (!stored || isStale(stored)) {
fetchIds.push(t);
}
});
fetchAndPersistTags(fetchIds);
}
function verifyTagsVersion(latest) {
if (store.get('bor_tags_version') !== latest) {
clearTags();
store.set('bor_tags_version', latest);
}
}
function initializeFilters() {
const tags = window.booru.spoileredTagList
.concat(window.booru.hiddenTagList)
.filter((a, b, c) => c.indexOf(a) === b);
verifyTagsVersion(window.booru.tagsVersion);
fetchNewOrStaleTags(tags);
}
function unmarshal(data) {
try { return JSON.parse(data); } catch (_) { return data; }
}
function loadBooruData() {
export function loadBooruData() {
const booruData = document.querySelector('.js-datastore').dataset;
// Assign all elements to booru because lazy
@ -92,22 +12,8 @@ function loadBooruData() {
window.booru[prop] = unmarshal(booruData[prop]);
}
window.booru.hiddenFilter = parseSearch(window.booru.hiddenFilter);
window.booru.spoileredFilter = parseSearch(window.booru.spoileredFilter);
// Fetch tag metadata and set up filtering
initializeFilters();
// CSRF
window.booru.csrfToken = $('meta[name="csrf-token"]').content;
}
function BooruOnRails() {
this.apiEndpoint = '/api/v2/';
this.hiddenTag = '/images/tagblocked.svg';
this.tagsVersion = 5;
}
window.booru = new BooruOnRails();
export { getTag, loadBooruData };
window.booru = {};

View file

@ -6,7 +6,6 @@
import { $, $$ } from './utils/dom';
import { fetchHtml, handleError } from './utils/requests';
import { showBlock } from './utils/image';
import { addTag } from './tagsinput';
// Event types and any qualifying conditions - return true to not run action
@ -72,10 +71,7 @@ const actions = {
.catch(() => newTab.textContent = 'Error!');
}
},
unfilter(data) { showBlock(data.el.closest('.image-show-container')); },
}
};
// Use this function to apply a callback to elements matching the selectors

View file

@ -4,7 +4,6 @@
import { $ } from './utils/dom';
import { showOwnedComments } from './communications/comment';
import { filterNode } from './imagesclientside';
import { fetchHtml } from './utils/requests';
function handleError(response) {
@ -94,9 +93,6 @@ function insertParentPost(data, clickedLink, fullComment) {
// Add class active_reply_link to the clicked link
clickedLink.classList.add('active_reply_link');
// Filter images (if any) in the loaded comment
filterNode(fullComment.previousSibling);
}
function clearParentPost(clickedLink, fullComment) {
@ -125,9 +121,6 @@ function displayComments(container, commentsHtml) {
// Execute timeago on comments
window.booru.timeAgo(document.getElementsByTagName('time'));
// Filter images in the comments
filterNode(container);
// Show options on own comments
showOwnedComments();
@ -171,7 +164,6 @@ function setupComments() {
loadComments(false);
}
else {
filterNode(comments);
showOwnedComments();
}
}

View file

@ -1,76 +0,0 @@
/**
* Client-side image filtering/spoilering.
*/
import { $$, escapeHtml } from './utils/dom';
import { setupInteractions } from './interactions';
import { showThumb, showBlock, spoilerThumb, spoilerBlock, hideThumb } from './utils/image';
import { getHiddenTags, getSpoileredTags, imageHitsTags, imageHitsComplex, displayTags } from './utils/tag';
function runFilter(img, test, runCallback) {
if (!test || test.length === 0) return false;
runCallback(img, test);
// I don't like this.
window.booru.imagesWithDownvotingDisabled.push(img.dataset.imageId);
return true;
}
// ---
function filterThumbSimple(img, tagsHit) { hideThumb(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `[HIDDEN] ${displayTags(tagsHit)}`); }
function spoilerThumbSimple(img, tagsHit) { spoilerThumb(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, displayTags(tagsHit)); }
function filterThumbComplex(img) { hideThumb(img, window.booru.hiddenTag, '[HIDDEN] <i>(Complex Filter)</i>'); }
function spoilerThumbComplex(img) { spoilerThumb(img, window.booru.hiddenTag, '<i>(Complex Filter)</i>'); }
function filterBlockSimple(img, tagsHit) { spoilerBlock(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `This image is tagged <code>${escapeHtml(tagsHit[0].name)}</code>, which is hidden by `); }
function spoilerBlockSimple(img, tagsHit) { spoilerBlock(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `This image is tagged <code>${escapeHtml(tagsHit[0].name)}</code>, which is spoilered by `); }
function filterBlockComplex(img) { spoilerBlock(img, window.booru.hiddenTag, 'This image was hidden by a complex tag expression in '); }
function spoilerBlockComplex(img) { spoilerBlock(img, window.booru.hiddenTag, 'This image was spoilered by a complex tag expression in '); }
// ---
function thumbTagFilter(tags, img) { return runFilter(img, imageHitsTags(img, tags), filterThumbSimple); }
function thumbComplexFilter(complex, img) { return runFilter(img, imageHitsComplex(img, complex), filterThumbComplex); }
function thumbTagSpoiler(tags, img) { return runFilter(img, imageHitsTags(img, tags), spoilerThumbSimple); }
function thumbComplexSpoiler(complex, img) { return runFilter(img, imageHitsComplex(img, complex), spoilerThumbComplex); }
function blockTagFilter(tags, img) { return runFilter(img, imageHitsTags(img, tags), filterBlockSimple); }
function blockComplexFilter(complex, img) { return runFilter(img, imageHitsComplex(img, complex), filterBlockComplex); }
function blockTagSpoiler(tags, img) { return runFilter(img, imageHitsTags(img, tags), spoilerBlockSimple); }
function blockComplexSpoiler(complex, img) { return runFilter(img, imageHitsComplex(img, complex), spoilerBlockComplex); }
// ---
function filterNode(node = document) {
const hiddenTags = getHiddenTags(), spoileredTags = getSpoileredTags();
const { hiddenFilter, spoileredFilter } = window.booru;
// Image thumb boxes with vote and fave buttons on them
$$('.image-container', node)
.filter(img => !thumbTagFilter(hiddenTags, img))
.filter(img => !thumbComplexFilter(hiddenFilter, img))
.filter(img => !thumbTagSpoiler(spoileredTags, img))
.filter(img => !thumbComplexSpoiler(spoileredFilter, img))
.forEach(img => showThumb(img));
// Individual image pages and images in posts/comments
$$('.image-show-container', node)
.filter(img => !blockTagFilter(hiddenTags, img))
.filter(img => !blockComplexFilter(hiddenFilter, img))
.filter(img => !blockTagSpoiler(spoileredTags, img))
.filter(img => !blockComplexSpoiler(spoileredFilter, img))
.forEach(img => showBlock(img));
}
function initImagesClientside() {
window.booru.imagesWithDownvotingDisabled = [];
// This fills the imagesWithDownvotingDisabled array
filterNode(document);
// Once the array is populated, we can initialize interactions
setupInteractions();
}
export { initImagesClientside, filterNode };

View file

@ -1,871 +0,0 @@
/**
* booru.match_query: A port and modification of the search_parser library for
* performing client-side filtering.
*/
const tokenList = [
['fuzz', /^~(?:\d+(\.\d+)?|\.\d+)/],
['boost', /^\^[\-\+]?\d+(\.\d+)?/],
['quoted_lit', /^\s*"(?:(?:[^"]|\\")+)"/],
['lparen', /^\s*\(\s*/],
['rparen', /^\s*\)\s*/],
['and_op', /^\s*(?:\&\&|AND)\s+/],
['and_op', /^\s*,\s*/],
['or_op', /^\s*(?:\|\||OR)\s+/],
['not_op', /^\s*NOT(?:\s+|(?=\())/],
['not_op', /^\s*[\!\-]\s*/],
['space', /^\s+/],
['word', /^(?:[^\s,\(\)\^~]|\\[\s,\(\)\^~])+/],
['word', /^(?:[^\s,\(\)]|\\[\s,\(\)])+/]
],
numberFields = ['id', 'width', 'height', 'aspect_ratio',
'comment_count', 'score', 'upvotes', 'downvotes',
'faves'],
dateFields = ['created_at'],
literalFields = ['tags', 'orig_sha512_hash', 'sha512_hash',
'score', 'uploader', 'source_url', 'description'],
termSpaceToImageField = {
tags: 'data-image-tag-aliases',
score: 'data-score',
upvotes: 'data-upvotes',
downvotes: 'data-downvotes',
uploader: 'data-uploader',
// Yeah, I don't think this is reasonably supportable.
// faved_by: 'data-faved-by',
id: 'data-image-id',
width: 'data-width',
height: 'data-height',
aspect_ratio: 'data-aspect-ratio',
comment_count: 'data-comment-count',
source_url: 'data-source-url',
faves: 'data-faves',
sha512_hash: 'data-sha512',
orig_sha512_hash: 'data-orig-sha512',
created_at: 'data-created-at'
};
function SearchTerm(termStr, options) {
this.term = termStr.trim();
this.parsed = false;
}
SearchTerm.prototype.append = function(substr) {
this.term += substr;
this.parsed = false;
};
SearchTerm.prototype.parseRangeField = function(field) {
let qual;
if (numberFields.indexOf(field) !== -1) {
return [field, 'eq', 'number'];
}
if (dateFields.indexOf(field) !== -1) {
return [field, 'eq', 'date'];
}
qual = /^(\w+)\.([lg]te?|eq)$/.exec(field);
if (qual) {
if (numberFields.indexOf(qual[1]) !== -1) {
return [qual[1], qual[2], 'number'];
}
if (dateFields.indexOf(qual[1]) !== -1) {
return [qual[1], qual[2], 'date'];
}
}
return null;
};
SearchTerm.prototype.parseRelativeDate = function(dateVal, qual) {
const match = /(\d+) (second|minute|hour|day|week|month|year)s? ago/.exec(dateVal);
const bounds = {
second: 1000,
minute: 60000,
hour: 3600000,
day: 86400000,
week: 604800000,
month: 2592000000,
year: 31536000000
};
if (match) {
const amount = parseInt(match[1], 10);
const scale = bounds[match[2]];
const now = new Date().getTime();
const bottomDate = new Date(now - (amount * scale));
const topDate = new Date(now - ((amount - 1) * scale));
switch (qual) {
case 'lte':
return [bottomDate, 'lt'];
case 'gte':
return [bottomDate, 'gte'];
case 'lt':
return [bottomDate, 'lt'];
case 'gt':
return [bottomDate, 'gte'];
default:
return [[bottomDate, topDate], 'eq'];
}
}
else {
throw `Cannot parse date string: ${dateVal}`;
}
};
SearchTerm.prototype.parseAbsoluteDate = function(dateVal, qual) {
let parseRes = [
/^(\d{4})/,
/^\-(\d{2})/,
/^\-(\d{2})/,
/^(?:\s+|T|t)(\d{2})/,
/^:(\d{2})/,
/^:(\d{2})/
],
timeZoneOffset = [0, 0],
timeData = [0, 0, 1, 0, 0, 0],
bottomDate = null,
topDate = null,
i,
match,
origDateVal = dateVal;
match = /([\+\-])(\d{2}):(\d{2})$/.exec(dateVal);
if (match) {
timeZoneOffset[0] = parseInt(match[2], 10);
timeZoneOffset[1] = parseInt(match[3], 10);
if (match[1] === '-') {
timeZoneOffset[0] *= -1;
timeZoneOffset[1] *= -1;
}
dateVal = dateVal.substr(0, dateVal.length - 6);
}
else {
dateVal = dateVal.replace(/[Zz]$/, '');
}
for (i = 0; i < parseRes.length; i += 1) {
if (dateVal.length === 0) {
break;
}
match = parseRes[i].exec(dateVal);
if (match) {
if (i === 1) {
timeData[i] = parseInt(match[1], 10) - 1;
}
else {
timeData[i] = parseInt(match[1], 10);
}
dateVal = dateVal.substr(
match[0].length, dateVal.length - match[0].length
);
}
else {
throw `Cannot parse date string: ${origDateVal}`;
}
}
if (dateVal.length > 0) {
throw `Cannot parse date string: ${origDateVal}`;
}
// Apply the user-specified time zone offset. The JS Date constructor
// is very flexible here.
timeData[3] -= timeZoneOffset[0];
timeData[4] -= timeZoneOffset[1];
switch (qual) {
case 'lte':
timeData[i - 1] += 1;
return [Date.UTC.apply(Date, timeData), 'lt'];
case 'gte':
return [Date.UTC.apply(Date, timeData), 'gte'];
case 'lt':
return [Date.UTC.apply(Date, timeData), 'lt'];
case 'gt':
timeData[i - 1] += 1;
return [Date.UTC.apply(Date, timeData), 'gte'];
default:
bottomDate = Date.UTC.apply(Date, timeData);
timeData[i - 1] += 1;
topDate = Date.UTC.apply(Date, timeData);
return [[bottomDate, topDate], 'eq'];
}
};
SearchTerm.prototype.parseDate = function(dateVal, qual) {
try {
return this.parseAbsoluteDate(dateVal, qual);
}
catch (_) {
return this.parseRelativeDate(dateVal, qual);
}
};
SearchTerm.prototype.parse = function(substr) {
let matchArr,
rangeParsing,
candidateTermSpace,
termCandidate;
this.wildcardable = !this.fuzz && !/^"([^"]|\\")+"$/.test(this.term);
if (!this.wildcardable && !this.fuzz) {
this.term = this.term.substr(1, this.term.length - 2);
}
this.term = this._normalizeTerm();
// N.B.: For the purposes of this parser, boosting effects are ignored.
// Default.
this.termSpace = 'tags';
this.termType = 'literal';
matchArr = this.term.split(':');
if (matchArr.length > 1) {
candidateTermSpace = matchArr[0];
termCandidate = matchArr.slice(1).join(':');
rangeParsing = this.parseRangeField(candidateTermSpace);
if (rangeParsing) {
this.termSpace = rangeParsing[0];
this.termType = rangeParsing[2];
if (this.termType === 'date') {
rangeParsing = this.parseDate(termCandidate, rangeParsing[1]);
this.term = rangeParsing[0];
this.compare = rangeParsing[1];
}
else {
this.term = parseFloat(termCandidate);
this.compare = rangeParsing[1];
}
this.wildcardable = false;
}
else if (literalFields.indexOf(candidateTermSpace) !== -1) {
this.termType = 'literal';
this.term = termCandidate;
this.termSpace = candidateTermSpace;
}
else if (candidateTermSpace == 'my') {
this.termType = 'my';
this.termSpace = termCandidate;
}
}
if (this.wildcardable) {
// Transforms wildcard match into regular expression.
// A custom NFA with caching may be more sophisticated but not
// likely to be faster.
this.term = new RegExp(
`^${
this.term.replace(/([.+^$[\]\\(){}|-])/g, '\\$1')
.replace(/([^\\]|[^\\](?:\\\\)+)\*/g, '$1.*')
.replace(/^(?:\\\\)*\*/g, '.*')
.replace(/([^\\]|[^\\](?:\\\\)+)\?/g, '$1.?')
.replace(/^(?:\\\\)*\?/g, '.?')
}$`, 'i'
);
}
// Update parse status flag to indicate the new properties are ready.
this.parsed = true;
};
SearchTerm.prototype._normalizeTerm = function() {
if (!this.wildcardable) {
return this.term.replace('\"', '"');
}
return this.term.replace(/\\([^\*\?])/g, '$1');
};
SearchTerm.prototype.fuzzyMatch = function(targetStr) {
let targetDistance,
i,
j,
// Work vectors, representing the last three populated
// rows of the dynamic programming matrix of the iterative
// optimal string alignment calculation.
v0 = [],
v1 = [],
v2 = [],
temp;
if (this.fuzz < 1.0) {
targetDistance = targetStr.length * (1.0 - this.fuzz);
}
else {
targetDistance = this.fuzz;
}
targetStr = targetStr.toLowerCase();
for (i = 0; i <= targetStr.length; i += 1) {
v1.push(i);
}
for (i = 0; i < this.term.length; i += 1) {
v2[0] = i;
for (j = 0; j < targetStr.length; j += 1) {
const cost = this.term[i] === targetStr[j] ? 0 : 1;
v2[j + 1] = Math.min(
// Deletion.
v1[j + 1] + 1,
// Insertion.
v2[j] + 1,
// Substitution or No Change.
v1[j] + cost
);
if (i > 1 && j > 1 && this.term[i] === targetStr[j - 1] &&
targetStr[i - 1] === targetStr[j]) {
v2[j + 1] = Math.min(v2[j], v0[j - 1] + cost);
}
}
// Rotate dem vec pointers bra.
temp = v0;
v0 = v1;
v1 = v2;
v2 = temp;
}
return v1[targetStr.length] <= targetDistance;
};
SearchTerm.prototype.exactMatch = function(targetStr) {
return this.term.toLowerCase() === targetStr.toLowerCase();
};
SearchTerm.prototype.wildcardMatch = function(targetStr) {
return this.term.test(targetStr);
};
SearchTerm.prototype.interactionMatch = function(imageID, type, interaction, interactions) {
let ret = false;
interactions.forEach(v => {
if (v.image_id == imageID && v.interaction_type == type && (interaction == null || v.value == interaction)) {
ret = true;
return;
}
});
return ret;
};
SearchTerm.prototype.match = function(target) {
let ret = false,
ohffs = this,
compFunc,
numbuh,
date;
if (!this.parsed) {
this.parse();
}
if (this.termType === 'literal') {
// Literal matching.
if (this.fuzz) {
compFunc = this.fuzzyMatch;
}
else if (this.wildcardable) {
compFunc = this.wildcardMatch;
}
else {
compFunc = this.exactMatch;
}
if (this.termSpace === 'tags') {
target.getAttribute('data-image-tag-aliases').split(', ').every(
str => {
if (compFunc.call(ohffs, str)) {
ret = true;
return false;
}
return true;
}
);
}
else {
ret = compFunc.call(
this, target.getAttribute(termSpaceToImageField[this.termSpace])
);
}
}
else if (this.termType === 'my' && window.booru.interactions.length > 0) {
// Should work with most my:conditions except watched.
switch (this.termSpace) {
case 'faves':
ret = this.interactionMatch(target.getAttribute('data-image-id'), 'faved', null, window.booru.interactions);
break;
case 'upvotes':
ret = this.interactionMatch(target.getAttribute('data-image-id'), 'voted', 'up', window.booru.interactions);
break;
case 'downvotes':
ret = this.interactionMatch(target.getAttribute('data-image-id'), 'voted', 'down', window.booru.interactions);
break;
default:
ret = false; // Other my: interactions aren't supported, return false to prevent them from triggering spoiler.
break;
}
}
else if (this.termType === 'date') {
// Date matching.
date = (new Date(
target.getAttribute(termSpaceToImageField[this.termSpace])
)).getTime();
switch (this.compare) {
// The open-left, closed-right date range specified by the
// date/time format limits the types of comparisons that are
// done compared to numeric ranges.
case 'lt':
ret = this.term > date;
break;
case 'gte':
ret = this.term <= date;
break;
default:
ret = this.term[0] <= date && this.term[1] > date;
}
}
else {
// Range matching.
numbuh = parseFloat(
target.getAttribute(termSpaceToImageField[this.termSpace])
);
if (isNaN(this.term)) {
ret = false;
}
else if (this.fuzz) {
ret = this.term <= numbuh + this.fuzz &&
this.term + this.fuzz >= numbuh;
}
else {
switch (this.compare) {
case 'lt':
ret = this.term > numbuh;
break;
case 'gt':
ret = this.term < numbuh;
break;
case 'lte':
ret = this.term >= numbuh;
break;
case 'gte':
ret = this.term <= numbuh;
break;
default:
ret = this.term === numbuh;
}
}
}
return ret;
};
function generateLexArray(searchStr, options) {
let opQueue = [],
searchTerm = null,
boost = null,
fuzz = null,
lparenCtr = 0,
negate = false,
groupNegate = false,
tokenStack = [],
boostFuzzStr = '';
while (searchStr.length > 0) {
tokenList.every(tokenArr => {
let tokenName = tokenArr[0],
tokenRE = tokenArr[1],
match = tokenRE.exec(searchStr),
balanced, op;
if (match) {
match = match[0];
if (Boolean(searchTerm) && (
['and_op', 'or_op'].indexOf(tokenName) !== -1 ||
tokenName === 'rparen' && lparenCtr === 0)) {
// Set options.
searchTerm.boost = boost;
searchTerm.fuzz = fuzz;
// Push to stack.
tokenStack.push(searchTerm);
// Reset term and options data.
searchTerm = fuzz = boost = null;
boostFuzzStr = '';
lparenCtr = 0;
if (negate) {
tokenStack.push('not_op');
negate = false;
}
}
switch (tokenName) {
case 'and_op':
while (opQueue[0] === 'and_op') {
tokenStack.push(opQueue.shift());
}
opQueue.unshift('and_op');
break;
case 'or_op':
while (opQueue[0] === 'and_op' || opQueue[0] === 'or_op') {
tokenStack.push(opQueue.shift());
}
opQueue.unshift('or_op');
break;
case 'not_op':
if (searchTerm) {
// We're already inside a search term, so it does
// not apply, obv.
searchTerm.append(match);
}
else {
negate = !negate;
}
break;
case 'lparen':
if (searchTerm) {
// If we are inside the search term, do not error
// out just yet; instead, consider it as part of
// the search term, as a user convenience.
searchTerm.append(match);
lparenCtr += 1;
}
else {
opQueue.unshift('lparen');
groupNegate = negate;
negate = false;
}
break;
case 'rparen':
if (lparenCtr > 0) {
searchTerm.append(match);
lparenCtr -= 1;
}
else {
balanced = false;
while (opQueue.length) {
op = opQueue.shift();
if (op === 'lparen') {
balanced = true;
break;
}
tokenStack.push(op);
}
}
if (groupNegate) {
tokenStack.push('not_op');
groupNegate = false;
}
break;
case 'fuzz':
if (searchTerm) {
// For this and boost operations, we store the
// current match so far to a temporary string in
// case this is actually inside the term.
fuzz = parseFloat(match.substr(1));
boostFuzzStr += match;
}
else {
searchTerm = new SearchTerm(match, options);
}
break;
case 'boost':
if (searchTerm) {
boost = match.substr(1);
boostFuzzStr += match;
}
else {
searchTerm = new SearchTerm(match, options);
}
break;
case 'quoted_lit':
if (searchTerm) {
searchTerm.append(match);
}
else {
searchTerm = new SearchTerm(match, options);
}
break;
case 'word':
if (searchTerm) {
if (fuzz || boost) {
boost = fuzz = null;
searchTerm.append(boostFuzzStr);
boostFuzzStr = '';
}
searchTerm.append(match);
}
else {
searchTerm = new SearchTerm(match, options);
}
break;
default:
// Append extra spaces within search terms.
if (searchTerm) {
searchTerm.append(match);
}
}
// Truncate string and restart the token tests.
searchStr = searchStr.substr(
match.length, searchStr.length - match.length
);
// Break since we have found a match.
return false;
}
return true;
});
}
// Append final tokens to the stack, starting with the search term.
if (searchTerm) {
searchTerm.boost = boost;
searchTerm.fuzz = fuzz;
tokenStack.push(searchTerm);
}
if (negate) {
tokenStack.push('not_op');
}
if (opQueue.indexOf('rparen') !== -1 ||
opQueue.indexOf('lparen') !== -1) {
throw 'Mismatched parentheses.';
}
// Memory-efficient concatenation of remaining operators queue to the
// token stack.
tokenStack.push.apply(tokenStack, opQueue);
return tokenStack;
}
function parseTokens(lexicalArray) {
let operandStack = [],
negate, op1, op2, parsed;
lexicalArray.forEach((token, i) => {
if (token !== 'not_op') {
negate = lexicalArray[i + 1] === 'not_op';
if (typeof token === 'string') {
op2 = operandStack.pop();
op1 = operandStack.pop();
if (typeof op1 === 'undefined' || typeof op2 === 'undefined') {
throw 'Missing operand.';
}
operandStack.push(new SearchAST(token, negate, op1, op2));
}
else {
if (negate) {
operandStack.push(new SearchAST(null, true, token));
}
else {
operandStack.push(token);
}
}
}
});
if (operandStack.length > 1) {
throw 'Missing operator.';
}
op1 = operandStack.pop();
if (typeof op1 === 'undefined') {
return new SearchAST();
}
if (isTerminal(op1)) {
return new SearchAST(null, false, op1);
}
return op1;
}
function parseSearch(searchStr, options) {
return parseTokens(generateLexArray(searchStr, options));
}
function isTerminal(operand) {
// Whether operand is a terminal SearchTerm.
return typeof operand.term !== 'undefined';
}
function SearchAST(op, negate, leftOperand, rightOperand) {
this.negate = Boolean(negate);
this.leftOperand = leftOperand || null;
this.op = op || null;
this.rightOperand = rightOperand || null;
}
function combineOperands(ast1, ast2, parentAST) {
if (parentAST.op === 'and_op') {
ast1 = ast1 && ast2;
}
else {
ast1 = ast1 || ast2;
}
if (parentAST.negate) {
return !ast1;
}
return ast1;
}
// Evaluation of the AST in regard to a target image
SearchAST.prototype.hitsImage = function(image) {
let treeStack = [],
// Left side node.
ast1 = this,
// Right side node.
ast2,
// Parent node of the current subtree.
parentAST;
// Build the initial tree node traversal stack, of the "far left" side.
// The general idea is to accumulate from the bottom and make stacks
// of right-hand subtrees that themselves accumulate upward. The left
// side node, ast1, will always be a Boolean representing the left-side
// evaluated value, up to the current subtree (parentAST).
while (!isTerminal(ast1)) {
treeStack.push(ast1);
ast1 = ast1.leftOperand;
if (!ast1) {
// Empty tree.
return false;
}
}
ast1 = ast1.match(image);
treeStack.push(ast1);
while (treeStack.length > 0) {
parentAST = treeStack.pop();
if (parentAST === null) {
// We are at the end of a virtual stack for a right node
// subtree. We switch the result of this stack from left
// (ast1) to right (ast2), pop the original left node,
// and finally pop the parent subtree itself. See near the
// end of this function to view how this is populated.
ast2 = ast1;
ast1 = treeStack.pop();
parentAST = treeStack.pop();
}
else {
// First, check to see if we can do a short-circuit
// evaluation to skip evaluating the right side entirely.
if (!ast1 && parentAST.op === 'and_op') {
ast1 = parentAST.negate;
continue;
}
if (ast1 && parentAST.op === 'or_op') {
ast1 = !parentAST.negate;
continue;
}
// If we are not at the end of a stack, grab the right
// node. The left node (ast1) is currently a terminal Boolean.
ast2 = parentAST.rightOperand;
}
if (typeof ast2 === 'boolean') {
ast1 = combineOperands(ast1, ast2, parentAST);
}
else if (!ast2) {
// A subtree with a single node. This is generally the case
// for negated tokens.
if (parentAST.negate) {
ast1 = !ast1;
}
}
else if (isTerminal(ast2)) {
// We are finally at a leaf and can evaluate.
ast2 = ast2.match(image);
ast1 = combineOperands(ast1, ast2, parentAST);
}
else {
// We are at a node whose right side is a new subtree.
// We will build a new "virtual" stack, but instead of
// building a new Array, we can insert a null object as a
// marker.
treeStack.push(parentAST, ast1, null);
do {
treeStack.push(ast2);
ast2 = ast2.leftOperand;
} while (!isTerminal(ast2));
ast1 = ast2.match(image);
}
}
return ast1;
};
SearchAST.prototype.dumpTree = function() {
// Dumps to string a simple diagram of the syntax tree structure
// (starting with this object as the root) for debugging purposes.
let retStrArr = [],
treeQueue = [['', this]],
treeArr,
prefix,
tree;
while (treeQueue.length > 0) {
treeArr = treeQueue.shift();
prefix = treeArr[0];
tree = treeArr[1];
if (isTerminal(tree)) {
retStrArr.push(`${prefix}-> ${tree.term}`);
}
else {
if (tree.negate) {
retStrArr.push(`${prefix}+ NOT_OP`);
prefix += '\t';
}
if (tree.op) {
retStrArr.push(`${prefix}+ ${tree.op.toUpperCase()}`);
prefix += '\t';
treeQueue.unshift([prefix, tree.rightOperand]);
treeQueue.unshift([prefix, tree.leftOperand]);
}
else {
treeQueue.unshift([prefix, tree.leftOperand]);
}
}
}
return retStrArr.join('\n');
};
export default parseSearch;

View file

@ -3,7 +3,6 @@
*/
import { fetchJson } from './utils/requests';
import { filterNode } from './imagesclientside';
function handleError(response) {
const errorMessage = '<div>Preview failed to load!</div>';
@ -42,7 +41,6 @@ function getPreview(body, anonymous, previewTab, isImage = false) {
.then(handleError)
.then(data => {
previewTab.innerHTML = data;
filterNode(previewTab);
});
}
@ -83,4 +81,4 @@ function setupPreviews() {
});
}
export { setupPreviews };
export { setupPreviews };

View file

@ -1,128 +0,0 @@
import { clearEl } from './dom';
import store from './store';
function showVideoThumb(img) {
const size = img.dataset.size;
const uris = JSON.parse(img.dataset.uris);
const thumbUri = uris[size];
const vidEl = img.querySelector('video');
if (!vidEl) return false;
const imgEl = img.querySelector('img');
if (!imgEl || imgEl.classList.contains('hidden')) return false;
imgEl.classList.add('hidden');
vidEl.innerHTML = `
<source src="${thumbUri}" type="video/webm"/>
<source src="${thumbUri.replace(/webm$/, 'mp4')}" type="video/mp4"/>
`;
vidEl.classList.remove('hidden');
vidEl.play();
img.querySelector('.js-spoiler-info-overlay').classList.add('hidden');
return true;
}
function showThumb(img) {
const size = img.dataset.size;
const uris = JSON.parse(img.dataset.uris);
const thumbUri = uris[size].replace(/webm$/, 'gif');
const picEl = img.querySelector('picture');
if (!picEl) return showVideoThumb(img);
const imgEl = picEl.querySelector('img');
if (!imgEl || imgEl.src.indexOf(thumbUri) !== -1) return false;
if (store.get('serve_hidpi') && !thumbUri.endsWith('.gif')) {
// Check whether the HiDPI option is enabled, and make an exception for GIFs due to their size
imgEl.srcset = `${thumbUri} 1x, ${uris.medium} 2x`;
}
imgEl.src = thumbUri;
if (uris[size].indexOf('.webm') !== -1) {
const overlay = img.querySelector('.js-spoiler-info-overlay');
overlay.classList.remove('hidden');
overlay.innerHTML = 'WebM';
}
else {
img.querySelector('.js-spoiler-info-overlay').classList.add('hidden');
}
return true;
}
function showBlock(img) {
img.querySelector('.image-filtered').classList.add('hidden');
const imageShowClasses = img.querySelector('.image-show').classList;
imageShowClasses.remove('hidden');
imageShowClasses.add('spoiler-pending');
}
function hideVideoThumb(img, spoilerUri, reason) {
const vidEl = img.querySelector('video');
if (!vidEl) return;
const imgEl = img.querySelector('img');
const imgOverlay = img.querySelector('.js-spoiler-info-overlay');
if (!imgEl) return;
imgEl.classList.remove('hidden');
imgEl.src = spoilerUri;
imgOverlay.innerHTML = reason;
imgOverlay.classList.remove('hidden');
clearEl(vidEl);
vidEl.classList.add('hidden');
vidEl.pause();
}
function hideThumb(img, spoilerUri, reason) {
const picEl = img.querySelector('picture');
if (!picEl) return hideVideoThumb(img, spoilerUri, reason);
const imgEl = picEl.querySelector('img');
const imgOverlay = img.querySelector('.js-spoiler-info-overlay');
if (!imgEl || imgEl.src.indexOf(spoilerUri) !== -1) return;
imgEl.srcset = '';
imgEl.src = spoilerUri;
imgOverlay.innerHTML = reason;
imgOverlay.classList.remove('hidden');
}
function spoilerThumb(img, spoilerUri, reason) {
hideThumb(img, spoilerUri, reason);
switch (window.booru.spoilerType) {
case 'click':
img.addEventListener('click', event => { if (showThumb(img)) event.preventDefault(); });
img.addEventListener('mouseleave', () => hideThumb(img, spoilerUri, reason));
break;
case 'hover':
img.addEventListener('mouseenter', () => showThumb(img));
img.addEventListener('mouseleave', () => hideThumb(img, spoilerUri, reason));
break;
default:
break;
}
}
function spoilerBlock(img, spoilerUri, reason) {
const imgEl = img.querySelector('.image-filtered img');
const imgReason = img.querySelector('.filter-explanation');
if (!imgEl) return;
imgEl.src = spoilerUri;
imgReason.innerHTML = reason;
img.querySelector('.image-show').classList.add('hidden');
img.querySelector('.image-filtered').classList.remove('hidden');
}
export { showThumb, showBlock, spoilerThumb, spoilerBlock, hideThumb };

View file

@ -1,58 +0,0 @@
import { escapeHtml } from './dom';
import { getTag } from '../booru';
function unique(array) {
return array.filter((a, b, c) => c.indexOf(a) === b);
}
function sortTags(hidden, a, b) {
// If both tags have a spoiler image, sort by images count desc (hidden) or asc (spoilered)
if (a.spoiler_image_uri && b.spoiler_image_uri) {
return hidden ? b.images - a.images : a.images - b.images;
}
// If neither has a spoiler image, sort by images count desc
else if (!a.spoiler_image_uri && !b.spoiler_image_uri) {
return b.images - a.images;
}
// Tag with spoiler image takes precedence
return a.spoiler_image_uri ? -1 : 1;
}
function getHiddenTags() {
return unique(window.booru.hiddenTagList)
.map(tagId => getTag(tagId))
.sort(sortTags.bind(null, true));
}
function getSpoileredTags() {
if (window.booru.spoilerType === 'off') return [];
return unique(window.booru.spoileredTagList)
.filter(tagId => window.booru.ignoredTagList.indexOf(tagId) === -1)
.map(tagId => getTag(tagId))
.sort(sortTags.bind(null, false));
}
function imageHitsTags(img, matchTags) {
const imageTags = JSON.parse(img.dataset.imageTags);
return matchTags.filter(t => imageTags.indexOf(t.id) !== -1);
}
function imageHitsComplex(img, matchComplex) {
return matchComplex.hitsImage(img);
}
function displayTags(tags) {
const mainTag = tags[0], otherTags = tags.slice(1);
let list = escapeHtml(mainTag.name), extras;
if (otherTags.length > 0) {
extras = otherTags.map(tag => escapeHtml(tag.name)).join(', ');
list += `<span title="${extras}">, ${extras}</span>`;
}
return list;
}
export { getHiddenTags, getSpoileredTags, imageHitsTags, imageHitsComplex, displayTags };

View file

@ -16,8 +16,8 @@ import { setupComments } from './comment';
import { setupDupeReports } from './duplicate_reports.js';
import { setFingerprintCookie } from './fingerprint';
import { setupGalleryEditing } from './galleries';
import { initImagesClientside } from './imagesclientside';
import { bindImageTarget } from './image_expansion';
import { setupInteractions } from './interactions';
import { setupEvents } from './misc';
import { setupNotifications } from './notifications';
import { setupPreviews } from './preview';
@ -44,7 +44,7 @@ whenReady(() => {
registerEvents();
setupBurgerMenu();
bindCaptchaLinks();
initImagesClientside();
setupInteractions();
setupComments();
setupDupeReports();
setFingerprintCookie();

View file

@ -1,169 +0,0 @@
defmodule Philomena.Search.Evaluator do
# TODO: rethink the necessity of this module.
# Can we do this in elasticsearch instead?
def hits?(doc, %{bool: bool_query}) do
must(doc, bool_query[:must]) and
must(doc, bool_query[:filter]) and
should(doc, bool_query[:should]) and
not should(doc, bool_query[:must_not])
end
def hits?(doc, %{range: range_query}) do
[term] = Map.keys(range_query)
doc_values = wrap(doc[atomify(term)])
range_query[term]
|> Enum.all?(fn
{:gt, query_val} ->
Enum.any?(doc_values, &(&1 > query_val))
{:gte, query_val} ->
Enum.any?(doc_values, &(&1 >= query_val))
{:lt, query_val} ->
Enum.any?(doc_values, &(&1 < query_val))
{:lte, query_val} ->
Enum.any?(doc_values, &(&1 <= query_val))
end)
end
def hits?(doc, %{fuzzy: fuzzy_query}) do
[{term, %{value: query_val, fuzziness: fuzziness}}] = Enum.to_list(fuzzy_query)
wrap(doc[atomify(term)])
|> Enum.any?(fn doc_val ->
cond do
fuzziness >= 1 ->
levenshtein(query_val, doc_val) <= fuzziness
fuzziness >= 0 ->
levenshtein(query_val, doc_val) <= trunc((1 - fuzziness) * byte_size(query_val))
true ->
false
end
end)
end
def hits?(doc, %{wildcard: wildcard_query}) do
[{term, query_val}] = Enum.to_list(wildcard_query)
query_re = wildcard_to_regex(query_val)
wrap(doc[atomify(term)])
|> Enum.any?(&Regex.match?(query_re, &1 || ""))
end
def hits?(doc, %{match_phrase: phrase_query}) do
# This is wildly inaccurate but practically unavoidable as
# there is no good reason to import a term stemmer
[{term, query_val}] = Enum.to_list(phrase_query)
wrap(doc[atomify(term)])
|> Enum.any?(&String.contains?(&1, query_val))
end
def hits?(_doc, %{nested: _}) do
# No way to tell without a wildly expensive database query
false
end
def hits?(doc, %{term: term_query}) do
[{term, query_val}] = Enum.to_list(term_query)
wrap(doc[atomify(term)])
|> Enum.member?(query_val)
end
def hits?(doc, %{terms: terms_query}) do
[{term, query_vals}] = Enum.to_list(terms_query)
wrap(doc[atomify(term)])
|> Enum.any?(&Enum.member?(query_vals, &1))
end
def hits?(_doc, %{match_all: %{}}), do: true
def hits?(_doc, %{match_none: %{}}), do: false
def hits?(doc, %{function_score: %{query: query}}), do: hits?(doc, query)
defp must(_doc, nil), do: true
defp must(doc, queries) when is_list(queries), do: Enum.all?(queries, &hits?(doc, &1))
defp must(doc, query), do: hits?(doc, query)
defp should(_doc, nil), do: false
defp should(doc, queries) when is_list(queries), do: Enum.any?(queries, &hits?(doc, &1))
defp should(doc, query), do: hits?(doc, query)
defp wrap(list) when is_list(list), do: list
defp wrap(object), do: [object]
defp atomify(atom) when is_atom(atom), do: atom
defp atomify(string) when is_binary(string), do: String.to_existing_atom(string)
def levenshtein(s1, s2) do
{dist, _lookup} = levenshtein_lookup(s1, s2, %{}, 0)
dist
end
defp levenshtein_lookup(s1, s2, lookup, times) do
case lookup[{s1, s2}] do
nil ->
levenshtein_execute(s1, s2, lookup, times)
val ->
{val, lookup}
end
end
# Avoid pursuing excessively time-consuming substrings
defp levenshtein_execute(s1, s2, lookup, times) when times > 2,
do: {max(byte_size(s1), byte_size(s2)), lookup}
defp levenshtein_execute("", s2, lookup, _times), do: {byte_size(s2), lookup}
defp levenshtein_execute(s1, "", lookup, _times), do: {byte_size(s1), lookup}
defp levenshtein_execute(s1, s1, lookup, _times), do: {0, lookup}
defp levenshtein_execute(s1, s2, lookup, times) do
{deletion, lookup} = levenshtein_lookup(chop(s1), s2, lookup, times + 1)
{insertion, lookup} = levenshtein_lookup(s1, chop(s2), lookup, times + 1)
{substitution, lookup} = levenshtein_lookup(chop(s1), chop(s2), lookup, times + 1)
min =
Enum.min([
deletion + 1,
insertion + 1,
substitution + last_bytes_different?(s1, s2)
])
lookup = Map.put(lookup, {s1, s2}, min)
{min, lookup}
end
defp chop(str) when is_binary(str), do: binary_part(str, 0, byte_size(str) - 1)
defp last_bytes_different?(s1, s2)
when binary_part(s1, byte_size(s1) - 1, 1) == binary_part(s2, byte_size(s2) - 1, 1),
do: 0
defp last_bytes_different?(_s1, _s2), do: 1
defp wildcard_to_regex(input) do
re =
input
# escape regex metacharacters
|> String.replace(~r/([.+^$\[\]\\\(\){}|-])/, "\\\\\\1")
# * -> .* (kleene star)
|> String.replace(~r/([^\\]|[^\\](?:\\\\)+)\*/, "\\1.*")
# * -> .* (kleene star)
|> String.replace(~r/\A(?:\\\\)*\*/, ".*")
# ? -> .? (concatenation/alternation)
|> String.replace(~r/([^\\]|[^\\](?:\\\\)+)\?/, "\\1.?")
# ? -> .? (concatenation/alternation)
|> String.replace(~r/\A(?:\\\\)*\?/, ".?")
Regex.compile!("\\A#{re}\\z", "im")
end
end

View file

@ -27,7 +27,6 @@ defmodule PhilomenaWeb.Image.CommentController do
preload: [:tags]
plug :verify_authorized when action in [:show]
plug PhilomenaWeb.FilterForcedUsersPlug when action in [:create, :edit, :update]
# Undo the previous private parameter screwery
plug PhilomenaWeb.LoadCommentPlug, [param: "id", show_hidden: true] when action in [:show]

View file

@ -15,8 +15,6 @@ defmodule PhilomenaWeb.Image.FaveController do
persisted: true,
preload: [:tags]
plug PhilomenaWeb.FilterForcedUsersPlug
def create(conn, _params) do
user = conn.assigns.current_user
image = conn.assigns.image

View file

@ -15,8 +15,6 @@ defmodule PhilomenaWeb.Image.VoteController do
persisted: true,
preload: [:tags]
plug PhilomenaWeb.FilterForcedUsersPlug
def create(conn, params) do
user = conn.assigns.current_user
image = conn.assigns.image

View file

@ -1,61 +0,0 @@
defmodule PhilomenaWeb.FilterForcedUsersPlug do
@moduledoc """
Halts the request pipeline if the current image belongs to the conn's
"forced filter".
"""
import Phoenix.Controller
import Plug.Conn
alias Philomena.Search.String, as: SearchString
alias Philomena.Search.Evaluator
alias Philomena.Images.Query
alias PhilomenaWeb.ImageView
def init(_opts) do
[]
end
def call(conn, _opts) do
maybe_fetch_forced(conn, conn.assigns.forced_filter)
end
defp maybe_fetch_forced(conn, nil), do: conn
defp maybe_fetch_forced(conn, forced) do
maybe_halt(conn, matches_filter?(conn.assigns.current_user, conn.assigns.image, forced))
end
defp maybe_halt(conn, false), do: conn
defp maybe_halt(conn, true) do
conn
|> put_flash(:error, "You have been blocked from performing this action on this image.")
|> redirect(external: conn.assigns.referrer)
|> halt()
end
defp matches_filter?(user, image, filter) do
matches_tag_filter?(image, filter.hidden_tag_ids) or
matches_complex_filter?(user, image, filter.hidden_complex_str)
end
defp matches_tag_filter?(image, tag_ids) do
image.tags
|> MapSet.new(& &1.id)
|> MapSet.intersection(MapSet.new(tag_ids))
|> Enum.any?()
end
defp matches_complex_filter?(user, image, search_string) do
image
|> ImageView.image_filter_data()
|> Evaluator.hits?(compile_filter(user, search_string))
end
defp compile_filter(user, search_string) do
case Query.compile(user, SearchString.normalize(search_string)) do
{:ok, query} -> query
_error -> %{match_all: %{}}
end
end
end

View file

@ -1,6 +1,6 @@
- size = assigns[:size] || :full
= content_tag :div, [data: image_container_data(@conn, @image, size), class: "image-show-container"] do
= content_tag :div, [class: "image-show-container"] do
.block.block--fixed.block--warning.block--no-margin.image-filtered.hidden
strong
= link("This image is blocked by your current filter - click here to display it anyway", to: "#", data: [click_unfilter: @image.id])

View file

@ -123,31 +123,8 @@ defmodule PhilomenaWeb.ImageView do
Application.get_env(:philomena, :image_url_root)
end
def image_container_data(conn, image, size) do
[
image_id: image.id,
image_tags: Jason.encode!(Enum.map(image.tags, & &1.id)),
image_tag_aliases: image.tag_list_plus_alias_cache,
score: image.score,
faves: image.faves_count,
upvotes: image.upvotes_count,
downvotes: image.downvotes_count,
comment_count: image.comments_count,
created_at: NaiveDateTime.to_iso8601(image.created_at),
source_url: image.source_url,
uris: Jason.encode!(thumb_urls(image, can?(conn, :show, image))),
width: image.image_width,
height: image.image_height,
aspect_ratio: image.image_aspect_ratio,
size: size
]
end
def image_container(conn, image, size, block) do
content_tag(:div, block.(),
class: "image-container #{size}",
data: image_container_data(conn, image, size)
)
def image_container(_conn, _image, size, block) do
content_tag(:div, block.(), class: "image-container #{size}")
end
def display_order(tags) do
@ -216,26 +193,6 @@ defmodule PhilomenaWeb.ImageView do
defp thumb_format(_, :rendered, _download), do: "png"
defp thumb_format(format, _name, _download), do: format
def image_filter_data(image) do
%{
id: image.id,
"namespaced_tags.name": String.split(image.tag_list_plus_alias_cache || "", ", "),
score: image.score,
faves: image.faves_count,
upvotes: image.upvotes_count,
downvotes: image.downvotes_count,
comment_count: image.comments_count,
created_at: image.created_at,
first_seen_at: image.first_seen_at,
source_url: image.source_url,
width: image.image_width,
height: image.image_height,
aspect_ratio: image.image_aspect_ratio,
sha512_hash: image.image_sha512_hash,
orig_sha512_hash: image.image_orig_sha512_hash
}
end
def filter_or_spoiler_value(conn, image) do
spoilered(conn)[image.id]
end

View file

@ -24,11 +24,6 @@ defmodule PhilomenaWeb.LayoutView do
host |> to_string
end
defp ignored_tag_list(nil), do: []
defp ignored_tag_list([]), do: []
defp ignored_tag_list([{tag, _body, _dnp_entries}]), do: [tag.id]
defp ignored_tag_list(tags), do: Enum.map(tags, & &1.id)
def clientside_data(conn) do
conn = Conn.fetch_cookies(conn)
@ -40,9 +35,7 @@ defmodule PhilomenaWeb.LayoutView do
data = [
filter_id: filter.id,
hidden_tag_list: Jason.encode!(filter.hidden_tag_ids),
hidden_filter: Philomena.Search.String.normalize(filter.hidden_complex_str || ""),
spoilered_tag_list: Jason.encode!(filter.spoilered_tag_ids),
spoilered_filter: Philomena.Search.String.normalize(filter.spoilered_complex_str || ""),
user_id: if(user, do: user.id, else: nil),
user_name: if(user, do: user.name, else: nil),
user_slug: if(user, do: user.slug, else: nil),
@ -53,7 +46,6 @@ defmodule PhilomenaWeb.LayoutView do
fancy_tag_edit: if(user, do: user.fancy_tag_field_on_edit, else: true),
fancy_tag_upload: if(user, do: user.fancy_tag_field_on_upload, else: true),
interactions: Jason.encode!(interactions),
ignored_tag_list: Jason.encode!(ignored_tag_list(conn.assigns[:tags])),
hide_staff_tools: conn.cookies["hide_staff_tools"]
]