Comparison

This commit is contained in:
dyedfire 2024-05-04 16:55:35 -06:00
parent d102578b54
commit 30da0803c3
87 changed files with 3878 additions and 294 deletions

4
.gitignore vendored
View file

@ -13,3 +13,7 @@ server/**/lib/
server/**/bin/
server/**/pyvenv.cfg
__pycache__/
# IDE files
.idea
*.iml

View file

@ -1,29 +1,28 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
rev: v3.2.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: mixed-line-ending
- repo: https://github.com/Lucas-C/pre-commit-hooks
rev: v1.4.2
rev: v1.1.9
hooks:
- id: remove-tabs
- repo: https://github.com/psf/black
rev: '23.1.0'
rev: 20.8b1
hooks:
- id: black
files: 'server/'
types: [python]
language_version: python3.9
language_version: python3.8
- repo: https://github.com/PyCQA/isort
rev: '5.12.0'
- repo: https://github.com/timothycrosley/isort
rev: '5.4.2'
hooks:
- id: isort
files: 'server/'
@ -32,8 +31,8 @@ repos:
additional_dependencies:
- toml
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.7.1
- repo: https://github.com/prettier/prettier
rev: '2.1.1'
hooks:
- id: prettier
files: client/js/
@ -41,7 +40,7 @@ repos:
args: ['--config', 'client/.prettierrc.yml']
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.33.0
rev: v7.8.0
hooks:
- id: eslint
files: client/js/
@ -49,8 +48,8 @@ repos:
additional_dependencies:
- eslint-config-prettier
- repo: https://github.com/PyCQA/flake8
rev: '6.0.0'
- repo: https://gitlab.com/pycqa/flake8
rev: '3.8.3'
hooks:
- id: flake8
files: server/szurubooru/
@ -58,5 +57,44 @@ repos:
- flake8-print
args: ['--config=server/.flake8']
- repo: local
hooks:
- id: docker-build-client
name: Docker - build client
entry: bash -c 'docker build client/'
language: system
types: [file]
files: client/
pass_filenames: false
- id: docker-build-server
name: Docker - build server
entry: bash -c 'docker build server/'
language: system
types: [file]
files: server/
pass_filenames: false
- id: pytest
name: pytest
entry: bash -c 'docker run --rm -t $(docker build --target testing -q server/) szurubooru/'
language: system
types: [python]
files: server/szurubooru/
exclude: server/szurubooru/migrations/
pass_filenames: false
stages: [push]
- id: pytest-cov
name: pytest
entry: bash -c 'docker run --rm -t $(docker build --target testing -q server/) --cov-report=term-missing:skip-covered --cov=szurubooru szurubooru/'
language: system
types: [python]
files: server/szurubooru/
exclude: server/szurubooru/migrations/
pass_filenames: false
verbose: true
stages: [manual]
fail_fast: true
exclude: LICENSE.md

View file

@ -3,12 +3,12 @@
Szurubooru is an image board engine inspired by services such as Danbooru,
Gelbooru and Moebooru dedicated for small and medium communities. Its name [has
its roots in Polish language and has onomatopeic meaning of scraping or
scrubbing](https://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
## Features
- Post content: images (JPG, PNG, GIF, animated GIF), videos (MP4, WEBM), Flash animations
- Ability to retrieve web video content using [yt-dlp](https://github.com/yt-dlp/yt-dlp)
- Ability to retrieve web video content using [youtube-dl](https://github.com/ytdl-org/youtube-dl)
- Post comments
- Post notes / annotations, including arbitrary polygons
- Rich JSON REST API ([see documentation](doc/API.md))

View file

@ -27,6 +27,8 @@ FROM nginx:alpine as release
RUN apk --no-cache add dumb-init
COPY --from=approot / /
RUN chmod +x /docker-start.sh
CMD ["/docker-start.sh"]
VOLUME ["/data"]

45
client/Dockerfile - Copy Normal file
View file

@ -0,0 +1,45 @@
FROM node:lts as builder
WORKDIR /opt/app
COPY package.json package-lock.json ./
RUN npm install
COPY . ./
ARG BUILD_INFO="docker-latest"
ARG CLIENT_BUILD_ARGS="--debug"
RUN BASE_URL="__BASEURL__" node build.js --gzip ${CLIENT_BUILD_ARGS}
FROM scratch as approot
COPY docker-start.sh /
WORKDIR /etc/nginx
COPY nginx.conf.docker ./nginx.conf
WORKDIR /var/www
COPY --from=builder /opt/app/public/ .
FROM nginx:alpine as release
RUN apk --no-cache add dumb-init
COPY --from=approot / /
RUN chmod +x /docker-start.sh
CMD ["/docker-start.sh"]
VOLUME ["/data"]
ARG DOCKER_REPO
ARG BUILD_DATE
ARG SOURCE_COMMIT
LABEL \
maintainer="" \
org.opencontainers.image.title="${DOCKER_REPO}" \
org.opencontainers.image.url="https://github.com/rr-/szurubooru" \
org.opencontainers.image.documentation="https://github.com/rr-/szurubooru/blob/${SOURCE_COMMIT}/doc/INSTALL.md" \
org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.source="https://github.com/rr-/szurubooru" \
org.opencontainers.image.revision="${SOURCE_COMMIT}" \
org.opencontainers.image.licenses="GPL-3.0"

424
client/build - Copy.js Normal file
View file

@ -0,0 +1,424 @@
#!/usr/bin/env node
'use strict';
// -------------------------------------------------
const webapp_icons = [
{ name: 'android-chrome-192x192.png', size: 192 },
{ name: 'android-chrome-512x512.png', size: 512 },
{ name: 'apple-touch-icon.png', size: 180 },
{ name: 'mstile-150x150.png', size: 150 }
];
const webapp_splash_screens = [
{ w: 640, h: 1136, center: 320 },
{ w: 750, h: 1294, center: 375 },
{ w: 1125, h: 2436, center: 565 },
{ w: 1242, h: 2148, center: 625 },
{ w: 1536, h: 2048, center: 770 },
{ w: 1668, h: 2224, center: 820 },
{ w: 2048, h: 2732, center: 1024 }
];
const external_js = [
'dompurify',
'js-cookie',
'marked',
'mousetrap',
'nprogress',
'superagent',
'underscore',
];
const app_manifest = {
name: 'szurubooru',
icons: [
{
src: baseUrl() + 'img/android-chrome-192x192.png',
type: 'image/png',
sizes: '192x192'
},
{
src: baseUrl() + 'img/android-chrome-512x512.png',
type: 'image/png',
sizes: '512x512'
}
],
start_url: baseUrl(),
theme_color: '#24aadd',
background_color: '#ffffff',
display: 'standalone'
}
// -------------------------------------------------
const fs = require('fs');
const glob = require('glob');
const path = require('path');
const util = require('util');
const execSync = require('child_process').execSync;
const browserify = require('browserify');
const chokidar = require('chokidar');
const WebSocket = require('ws');
var PrettyError = require('pretty-error');
var pe = new PrettyError();
function readTextFile(path) {
return fs.readFileSync(path, 'utf-8');
}
function gzipFile(file) {
file = path.normalize(file);
execSync('gzip -6 -k ' + file);
}
function baseUrl() {
return process.env.BASE_URL ? process.env.BASE_URL : '/';
}
// -------------------------------------------------
function bundleHtml() {
const underscore = require('underscore');
const babelify = require('babelify');
function minifyHtml(html) {
return require('html-minifier').minify(html, {
removeComments: true,
collapseWhitespace: true,
conservativeCollapse: true,
}).trim();
}
const baseHtml = readTextFile('./html/index.htm')
.replace('<!-- Base HTML Placeholder -->', `<base href="${baseUrl()}"/>`);
fs.writeFileSync('./public/index.htm', minifyHtml(baseHtml));
let compiledTemplateJs = [
`'use strict';`,
`let _ = require('underscore');`,
`let templates = {};`
];
for (const file of glob.sync('./html/**/*.tpl')) {
const name = path.basename(file, '.tpl').replace(/_/g, '-');
const placeholders = [];
let templateText = readTextFile(file);
templateText = templateText.replace(
/<%.*?%>/ig,
(match) => {
const ret = '%%%TEMPLATE' + placeholders.length;
placeholders.push(match);
return ret;
});
templateText = minifyHtml(templateText);
templateText = templateText.replace(
/%%%TEMPLATE(\d+)/g,
(match, number) => { return placeholders[number]; });
const functionText = underscore.template(
templateText, { variable: 'ctx' }).source;
compiledTemplateJs.push(`templates['${name}'] = ${functionText};`);
}
compiledTemplateJs.push('module.exports = templates;');
fs.writeFileSync('./js/.templates.autogen.js', compiledTemplateJs.join('\n'));
console.info('Bundled HTML');
}
function bundleCss() {
const stylus = require('stylus');
function minifyCss(css) {
return require('csso').minify(css).css;
}
let css = '';
for (const file of glob.sync('./css/**/*.styl')) {
css += stylus.render(readTextFile(file), { filename: file });
}
fs.writeFileSync('./public/css/app.min.css', minifyCss(css));
if (process.argv.includes('--gzip')) {
gzipFile('./public/css/app.min.css');
}
fs.copyFileSync(
'./node_modules/@fortawesome/fontawesome-free/css/all.min.css',
'./public/css/vendor.min.css');
if (process.argv.includes('--gzip')) {
gzipFile('./public/css/vendor.min.css');
}
console.info('Bundled CSS');
}
function minifyJs(path) {
return require('terser').minify(
fs.readFileSync(path, 'utf-8'), { compress: { unused: false } }).code;
}
function writeJsBundle(b, path, compress, callback) {
let outputFile = fs.createWriteStream(path);
b.bundle().on('error', (e) => console.error(pe.render(e))).pipe(outputFile);
outputFile.on('finish', () => {
if (compress) {
fs.writeFileSync(path, minifyJs(path));
}
callback();
});
}
function bundleVendorJs(compress) {
let b = browserify();
for (let lib of external_js) {
b.require(lib);
}
if (!process.argv.includes('--no-transpile')) {
b.add(require.resolve('babel-polyfill'));
}
const file = './public/js/vendor.min.js';
writeJsBundle(b, file, compress, () => {
if (process.argv.includes('--gzip')) {
gzipFile(file);
}
console.info('Bundled vendor JS');
});
}
function bundleAppJs(b, compress, callback) {
const file = './public/js/app.min.js';
writeJsBundle(b, file, compress, () => {
if (process.argv.includes('--gzip')) {
gzipFile(file);
}
console.info('Bundled app JS');
callback();
});
}
function bundleJs() {
if (!process.argv.includes('--no-vendor-js')) {
bundleVendorJs(true);
}
if (!process.argv.includes('--no-app-js')) {
let watchify = require('watchify');
let b = browserify({ debug: process.argv.includes('--debug') });
if (!process.argv.includes('--no-transpile')) {
b = b.transform('babelify');
}
b = b.external(external_js).add(glob.sync('./js/**/*.js'));
const compress = !process.argv.includes('--debug');
bundleAppJs(b, compress, () => { });
}
}
const environment = process.argv.includes('--watch') ? "development" : "production";
function bundleConfig() {
function getVersion() {
let build_info = process.env.BUILD_INFO;
if (!build_info) {
try {
build_info = execSync('git describe --always --dirty --long --tags').toString();
} catch (e) {
console.warn('Cannot find build version');
build_info = 'unknown';
}
}
return build_info.trim();
}
const config = {
meta: {
version: getVersion(),
buildDate: new Date().toUTCString()
},
environment: environment
};
fs.writeFileSync('./js/.config.autogen.json', JSON.stringify(config));
console.info('Generated config file');
}
function bundleBinaryAssets() {
fs.copyFileSync('./img/favicon.png', './public/img/favicon.png');
console.info('Copied images');
fs.copyFileSync('./fonts/open_sans.woff2', './public/webfonts/open_sans.woff2')
for (let file of glob.sync('./node_modules/@fortawesome/fontawesome-free/webfonts/*.*')) {
if (fs.lstatSync(file).isDirectory()) {
continue;
}
fs.copyFileSync(file, path.join('./public/webfonts/', path.basename(file)));
}
if (process.argv.includes('--gzip')) {
for (let file of glob.sync('./public/webfonts/*.*')) {
if (file.endsWith('woff2')) {
continue;
}
gzipFile(file);
}
}
console.info('Copied fonts')
}
function bundleWebAppFiles() {
const Jimp = require('jimp');
fs.writeFileSync('./public/manifest.json', JSON.stringify(app_manifest));
console.info('Generated app manifest');
Promise.all(webapp_icons.map(icon => {
return Jimp.read('./img/app.png')
.then(file => {
file.resize(icon.size, Jimp.AUTO, Jimp.RESIZE_BEZIER)
.write(path.join('./public/img/', icon.name));
});
}))
.then(() => {
console.info('Generated webapp icons');
});
Promise.all(webapp_splash_screens.map(dim => {
return Jimp.read('./img/splash.png')
.then(file => {
file.resize(dim.center, Jimp.AUTO, Jimp.RESIZE_BEZIER)
.background(0xFFFFFFFF)
.contain(dim.w, dim.center,
Jimp.HORIZONTAL_ALIGN_CENTER | Jimp.VERTICAL_ALIGN_MIDDLE)
.contain(dim.w, dim.h,
Jimp.HORIZONTAL_ALIGN_CENTER | Jimp.VERTICAL_ALIGN_MIDDLE)
.write(path.join('./public/img/',
'apple-touch-startup-image-' + dim.w + 'x' + dim.h + '.png'));
});
}))
.then(() => {
console.info('Generated splash screens');
});
}
function makeOutputDirs() {
const dirs = [
'./public',
'./public/css',
'./public/webfonts',
'./public/img',
'./public/js'
];
for (let dir of dirs) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, 0o755);
console.info('Created directory: ' + dir);
}
}
}
function watch() {
let wss = new WebSocket.Server({ port: 8080 });
const liveReload = !process.argv.includes('--no-live-reload');
function emitReload() {
if (liveReload) {
console.log("Requesting live reload.")
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send("reload");
}
});
}
}
chokidar.watch('./fonts/**/*').on('change', () => {
try {
bundleBinaryAssets();
emitReload();
} catch (e) {
console.error(pe.render(e));
}
});
chokidar.watch('./img/**/*').on('change', () => {
try {
bundleWebAppFiles();
emitReload();
} catch (e) {
console.error(pe.render(e));
}
});
chokidar.watch('./html/**/*.tpl').on('change', () => {
try {
bundleHtml();
} catch (e) {
console.error(pe.render(e));
}
});
chokidar.watch('./css/**/*.styl').on('change', () => {
try {
bundleCss()
emitReload();
} catch (e) {
console.error(pe.render(e));
}
});
bundleBinaryAssets();
bundleWebAppFiles();
bundleCss();
bundleHtml();
bundleVendorJs(true);
let watchify = require('watchify');
let b = browserify({
debug: process.argv.includes('--debug'),
entries: ['js/main.js'],
cache: {},
packageCache: {},
});
b.plugin(watchify);
if (!process.argv.includes('--no-transpile')) {
b = b.transform('babelify');
}
b = b.external(external_js).add(glob.sync('./js/**/*.js'));
const compress = false;
function bundle(id) {
console.info("Rebundling app JS...");
let start = new Date();
bundleAppJs(b, compress, () => {
let end = new Date() - start;
console.info('Rebundled in %ds.', end / 1000)
emitReload();
});
}
b.on('update', bundle);
bundle();
}
// -------------------------------------------------
console.log("Building for '" + environment + "' environment.");
makeOutputDirs();
bundleConfig();
if (process.argv.includes('--watch')) {
watch();
} else {
if (!process.argv.includes('--no-binary-assets')) {
bundleBinaryAssets();
}
if (!process.argv.includes('--no-web-app-files')) {
bundleWebAppFiles();
}
if (!process.argv.includes('--no-html')) {
bundleHtml();
}
if (!process.argv.includes('--no-css')) {
bundleCss();
}
if (!process.argv.includes('--no-js')) {
bundleJs();
}
}

View file

@ -127,10 +127,6 @@ $comment-border-color = #DDD
color: mix($main-color, $inactive-link-color-darktheme)
.comment-content
p
word-wrap: normal
word-break: break-all
ul, ol
list-style-position: inside
margin: 1em 0

View file

@ -300,10 +300,10 @@ a .access-key
background-size: 20px 20px
img
opacity: 0
width: 100%
width: auto
height: 100%
video
width: 100%
width: auto
height: 100%
.flexbox-dummy

View file

@ -0,0 +1,56 @@
@import colors
#metric-sorter
width: 100%
@media (max-width: 1000px)
padding: 0 !important
background-color: white !important
h2
display: none
.messages .message
margin-top: 0.5em
margin-bottom: 0
form
width: 100%
.posts-container
display: flex
flex-wrap: wrap
margin-bottom: 1em
@media (max-width: 1000px)
margin-left: -1em
margin-right: -1em
margin-top: -1em
.left-post-container, .right-post-container
flex: 1
width: 100%
.append
color: $inactive-link-color
.sorting-buttons
display: flex
@media (min-width: 1000px)
padding: 0 0.5em
@media (max-width: 1000px)
padding: 0.5em 1em
width: 100%
.compare-block
margin: auto
button
width: 1.3em
height: 1.3em
font-size: 200%
@media (min-width: 1000px)
padding: 0
padding-top: 0.05em
@media (max-width: 1000px)
padding: 0
i
transform: rotate(90deg)
@media (max-width: 1000px)
display: flex
.save-btn
margin: auto
margin-right: 1em
.skip-btn
margin: auto
margin-left: 1em

View file

@ -56,8 +56,8 @@
.edit-overlay
position: absolute
top: 0.5em
left: 0.5em
top: 0em
left: 0em
.tag-flipper
display: inline-block
@ -116,7 +116,7 @@
.delete-flipper
display: inline-block
padding: 0.5em
padding: 10em
box-sizing: border-box
border: 0
&:after
@ -133,7 +133,7 @@
font-family: FontAwesome;
content: "\f1f8"; // fa-trash
&:not(.delete)
background: rgba(200, 200, 200, 0.7)
background: rgba(200, 200, 200, 0.2)
&:after
color: white
content: '-'
@ -187,9 +187,6 @@
vertical-align: top
@media (max-width: 1000px)
display: block
&.bulk-edit-tags:not(.opened), &.bulk-edit-safety:not(.opened)
float: left
margin-right: 1em
input
margin-bottom: 0.25em
margin-right: 0.25em

View file

@ -15,42 +15,38 @@
border: 0
outline: 0
>.sidebar>nav.buttons, >.content nav.buttons
margin-top: 0
display: flex
flex-wrap: wrap
article
flex: 1 0 33%
a
display: inline-block
width: 100%
padding: 0.3em 0
nav.buttons
margin-top: 0
display: flex
flex-wrap: wrap
article
flex: 1 0 25%
a
display: inline-block
width: 100%
padding: 0.3em 0
text-align: center
vertical-align: middle
transition: background 0.2s linear, box-shadow 0.2s linear
&:not(.inactive):hover
background: lighten($main-color, 90%)
i
font-size: 140%
text-align: center
vertical-align: middle
transition: background 0.2s linear, box-shadow 0.2s linear
&:not(.inactive):hover
background: lighten($main-color, 90%)
i
font-size: 140%
text-align: center
@media (max-width: 800px)
margin-top: 0.6em
margin-bottom: 0.6em
@media (max-width: 800px)
margin-top: 2em
>.content
width: 100%
.post-container
margin-bottom: 0.6em
margin-bottom: 2em
.post-content
margin: 0
.after-mobile-controls
width: 100%
.darktheme .post-view
>.sidebar, >.content
>.sidebar
nav.buttons
article
a:not(.inactive):hover
@ -60,8 +56,6 @@
@media (max-width: 800px)
.post-view
flex-wrap: wrap
>.after-mobile-controls
order: 3
>.sidebar
order: 2
min-width: 100%
@ -119,6 +113,7 @@
h1
margin-bottom: 0.5em
.thumbnail
background-position: 50% 30%
width: 4em
height: 3em
li

View file

@ -0,0 +1,49 @@
@import colors
.metric-controls
margin-left: 1.4em
display: inline-block
color: $inactive-link-color
hr.separator
display: none
ul.compact-unset-metrics, ul.compact-post-metrics
width: 100%
margin: 0.5em 0 0 0
padding: 0
li
margin: 0
width: 100%
line-height: 140%
overflow: hidden
text-overflow: ellipsis
transition: background-color 0.5s linear
a
display: inline
a:focus
outline: 0
box-shadow: inset 0 0 0 2px $main-color
.remove-metric
color: $inactive-link-color
unselectable()
margin-right: 0.5em
i
padding-right: 0.4em
.metric-bounds
color: $inactive-link-color
unselectable()
font-size: 90%
margin-left: 0.7em
ul.compact-post-metrics
input[type=number]
margin-left: 0.5em
margin-bottom: 0.5em
width: 4em
min-width: 60px
label
display: none
.range-delimiter
margin-left: 0.5em
color: $inactive-link-color

10
client/css/settings.styl Normal file
View file

@ -0,0 +1,10 @@
#settings
.uploadSafety
&>label
width: 100%
.radio-wrapper
display: flex
flex-wrap: wrap
.radio-wrapper label
flex-grow: 1
display: inline-block

View file

@ -21,11 +21,10 @@
.details
font-size: 90%
line-height: 130%
.image
margin: 0.25em 0.6em 0.25em 0
.thumbnail
width: 3em
height: 3em
margin: 0.25em 0.6em 0 0
.darktheme .user-list
ul li

16
client/hooks/build Normal file
View file

@ -0,0 +1,16 @@
#!/bin/sh
CLOSEST_VER=$(git describe --tags --abbrev=0 ${SOURCE_COMMIT})
if git describe --exact-match --abbrev=0 ${SOURCE_COMMIT} 2> /dev/null; then
BUILD_INFO="v${CLOSEST_VER}"
else
BUILD_INFO="v${CLOSEST_VER}-edge-$(git rev-parse --short ${SOURCE_COMMIT})"
fi
echo "Using BUILD_INFO=${BUILD_INFO}"
docker build \
--build-arg BUILD_INFO=${BUILD_INFO} \
--build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \
--build-arg SOURCE_COMMIT \
--build-arg DOCKER_REPO \
-f $DOCKERFILE_PATH -t $IMAGE_NAME .

19
client/hooks/post_push Normal file
View file

@ -0,0 +1,19 @@
#!/bin/sh
add_tag() {
echo "Also tagging image as ${DOCKER_REPO}:${1}"
docker tag $IMAGE_NAME $DOCKER_REPO:$1
docker push $DOCKER_REPO:$1
}
CLOSEST_VER=$(git describe --tags --abbrev=0)
CLOSEST_MAJOR_VER=$(echo ${CLOSEST_VER} | cut -d'.' -f1)
CLOSEST_MINOR_VER=$(echo ${CLOSEST_VER} | cut -d'.' -f2)
add_tag "${CLOSEST_MAJOR_VER}-edge"
add_tag "${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}-edge"
if git describe --exact-match --abbrev=0 2> /dev/null; then
add_tag "${CLOSEST_MAJOR_VER}"
add_tag "${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}"
fi

View file

@ -0,0 +1,26 @@
<li><!--
--><% if (ctx.editMode) { %><!--
--><a href="<%- ctx.formatClientLink('tag', ctx.tag.names[0]) %>"
class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
--><i class='fa fa-sliders-h tag-icon'></i><!--
--></a><!--
--><a href="<%- ctx.formatClientLink('posts', {
query: 'metric-' + ctx.escapeTagName(ctx.tag.names[0]) +
':' + ctx.tag.metric.min + '..' + ctx.tag.metric.max +
' sort:metric-' + ctx.escapeTagName(ctx.tag.names[0])
}) %>"
class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
--><%- ctx.tag.names[0] %>&#32;<!--
--></a><!--
--><span class='metric-bounds' data-pseudo-content=
'<%- ctx.tag.metric.min %> &mdash; <%- ctx.tag.metric.max %>'></span><!--
--><span class='metric-controls'>Set<!--
--><a href class='create-exact'> exact</a><!--
--><a href class='create-range'> range</a><!--
--><a href='<%= ctx.getMetricSorterUrl(ctx.post.id, {
metrics: ctx.tag.names[0],
query: ctx.query}) %>'
class='sort'> sort</a><!--
--></span><!--
--><% } %><!--
--></li>

View file

@ -0,0 +1,32 @@
<li><!--
--><% if (ctx.editMode) { %><!--
--><a href class='remove-metric' data-pseudo-content='×'/><!--
--><a href="<%- ctx.formatClientLink('posts', {
query: 'metric-' + ctx.escapeTagName(ctx.tag.names[0]) +
':' + ctx.tag.metric.min + '..' + ctx.tag.metric.max +
' sort:metric-' + ctx.escapeTagName(ctx.tag.names[0])
}) %>"
class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
--><i class='fas fa-angle-right tag-icon'></i><!--
--><%- ctx.postMetric.tagName %>:</a><!--
--><%= ctx.makeNumericInput({
name: 'value',
value: ctx.postMetric.value,
step: 'any',
min: ctx.tag.metric.min,
max: ctx.tag.metric.max,
}) %><!--
--><% } else { %><!--
--><a href="<%- ctx.formatClientLink('tag', ctx.tag.names[0]) %>"
class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
--><i class='fas fa-angle-right tag-icon'></i><!--
--></a><!--
--><a href="<%- ctx.formatClientLink('posts', {
query: 'metric-' + ctx.escapeTagName(ctx.tag.names[0]) +
':' + ctx.tag.metric.min + '..' + ctx.tag.metric.max +
' sort:metric-' + ctx.escapeTagName(ctx.tag.names[0])
}) %>"
class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
--><%- ctx.postMetric.tagName %>: <%- ctx.postMetric.value || 0 %></a><!--
--><% } %><!--
--></li>

View file

@ -0,0 +1,42 @@
<li><!--
--><% if (ctx.editMode) { %><!--
--><a class='remove-metric' data-pseudo-content='×'/><!--
--><a href="<%- ctx.formatClientLink('posts', {
query: 'metric-' + ctx.escapeTagName(ctx.tag.names[0]) +
':' + ctx.tag.metric.min + '..' + ctx.tag.metric.max +
' sort:metric-' + ctx.escapeTagName(ctx.tag.names[0])
}) %>"
class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
--><i class='fas fa-arrows-alt-h tag-icon'></i><!--
--><%- ctx.postMetricRange.tagName %>:</a><!--
--><%= ctx.makeNumericInput({
name: 'low',
value: ctx.postMetricRange.low,
step: 'any',
min: ctx.tag.metric.min,
max: ctx.tag.metric.max,
}) %><!--
--><span class='range-delimiter'>&mdash;</span><!--
--><%= ctx.makeNumericInput({
name: 'high',
value: ctx.postMetricRange.high,
step: 'any',
min: ctx.tag.metric.min,
max: ctx.tag.metric.max,
}) %><!--
--><% } else { %><!--
--><a href="<%- ctx.formatClientLink('tag', ctx.tag.names[0]) %>"
class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
--><i class='fas fa-arrows-alt-h tag-icon'></i><!--
--></a><!--
--><a href="<%- ctx.formatClientLink('posts', {
query: 'metric-' + ctx.escapeTagName(ctx.tag.names[0]) +
':' + ctx.tag.metric.min + '..' + ctx.tag.metric.max +
' sort:metric-' + ctx.escapeTagName(ctx.tag.names[0])
}) %>"
class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
--><%- ctx.postMetricRange.tagName %>:
<%- ctx.postMetricRange.low || 0 %> &mdash; <%- ctx.postMetricRange.high || 0 %><!--
--></a><!--
--><% } %><!--
--></li>

View file

@ -19,6 +19,11 @@ shortcuts:</p>
<td>Go to newer/older page or post</td>
</tr>
<tr>
<td><kbd>R</kbd></td>
<td>Go to random post</td>
</tr>
<tr>
<td><kbd>F</kbd></td>
<td>Cycle post fit mode</td>
@ -36,7 +41,7 @@ shortcuts:</p>
<tr>
<td><kbd>Delete</kbd></td>
<td>Delete post (while in edit mode)</td>
<td>(In edit mode) delete post</td>
</tr>
</tbody>
</table>

View file

@ -2,7 +2,7 @@
<html>
<head>
<meta charset='utf-8'/>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1'/>
<meta name='theme-color' content='#24aadd'/>
<meta name='apple-mobile-web-app-capable' content='yes'/>
<meta name='apple-mobile-web-app-status-bar-style' content='black'/>

View file

@ -0,0 +1,11 @@
<form class='horizontal'>
<ul class='metric-list'></ul>
<wbr>
<%= ctx.makeCheckbox({
text: 'Show values on posts',
name: 'show-values-on-posts',
checked: ctx.showValuesOnPost,
class: 'append'}) %>
<a class='mousetrap button append close sorting'
href="<%= ctx.getMetricSorterUrl('random', ctx.parameters) %>">Start sorting</a>
</form>

View file

@ -0,0 +1,6 @@
<li class="<%= ctx.makeCssName(ctx.metric.tag.category, 'tag') %><%
if (ctx.selected) { %> selected<% } %>">
<a href class="<%= ctx.makeCssName(ctx.metric.tag.category, 'tag') %><%
if (ctx.selected) { %> selected<% } %>"><%
%><%- ctx.metric.tag.names[0] %></a>
</li>

View file

@ -0,0 +1,39 @@
<div class='content-wrapper' id='metric-sorter'>
<h2>Sorting metric "<%- ctx.primaryMetric %>"</h2>
<form>
<div class='posts-container'>
<div class='left-post-container'></div>
<% if (window.innerWidth <= 1000) { %>
<div class='messages'></div>
<% } %>
<div class='sorting-buttons'>
<div class='compare-block'>
<% if (window.innerWidth <= 1000) { %>
<input class='mousetrap save-btn' type='submit' value='Save'>
<% } %>
<button class='compare left-lt-right'>
<i class='fa fa-less-than'></i>
</button>
<button class='compare left-gt-right'>
<i class='fa fa-greater-than'></i>
</button>
<% if (window.innerWidth <= 1000) { %>
<a href class='mousetrap append skip-btn'>Skip</a>
<% } %>
</div>
</div>
<div class='right-post-container'></div>
</div>
<% if (window.innerWidth > 1000) { %>
<div class='messages'></div>
<% } %>
<div class='buttons'>
<% if (window.innerWidth > 1000) { %>
<input class='mousetrap save-btn' type='submit' value='Save'>
<a href class='mousetrap append skip-btn'>Skip</a>
<% } %>
</div>
</form>
</div>

View file

@ -0,0 +1,5 @@
<% if (ctx.post) { %>
<a href='<%= ctx.getPostUrl(ctx.post.id, ctx.parameters) %>'>
<div class='post-container'></div>
</a>
<% } %>

View file

@ -29,7 +29,20 @@
<span class='vim-nav-hint'>Next post &gt;</span>
</a>
</article>
<% if (ctx.canEditPosts || ctx.canDeletePosts || ctx.canFeaturePosts) { %>
<article class='random-post'>
<% if (ctx.randomPostId) { %>
<% if (ctx.editMode) { %>
<a rel='next' href='<%= ctx.getPostEditUrl(ctx.randomPostId, ctx.parameters) %>'>
<% } else { %>
<a rel='next' href='<%= ctx.getPostUrl(ctx.randomPostId, ctx.parameters) %>'>
<% } %>
<% } else { %>
<a rel='next' class='inactive'>
<% } %>
<i class='fa fa-random'></i>
<span class='vim-nav-hint'>Random post</span>
</a>
</article>
<article class='edit-post'>
<% if (ctx.editMode) { %>
<a href='<%= ctx.getPostUrl(ctx.post.id, ctx.parameters) %>'>
@ -37,13 +50,16 @@
<span class='vim-nav-hint'>Back to view mode</span>
</a>
<% } else { %>
<a href='<%= ctx.getPostEditUrl(ctx.post.id, ctx.parameters) %>'>
<i class='fa fa-pencil'></i>
<span class='vim-nav-hint'>Edit post</span>
<% if (ctx.canEditPosts || ctx.canDeletePosts || ctx.canFeaturePosts) { %>
<a href='<%= ctx.getPostEditUrl(ctx.post.id, ctx.parameters) %>'>
<% } else { %>
<a class='inactive'>
<% } %>
<i class='fa fa-pencil'></i>
<span class='vim-nav-hint'>Edit post</span>
</a>
<% } %>
</article>
<% } %>
</nav>
<div class='sidebar-container'></div>
@ -52,15 +68,13 @@
<div class='content'>
<div class='post-container'></div>
<div class='after-mobile-controls'>
<% if (ctx.canCreateComments) { %>
<h2>Add comment</h2>
<div class='comment-form-container'></div>
<% } %>
<% if (ctx.canListComments) { %>
<div class='comments-container'></div>
<% } %>
<% if (ctx.canListComments) { %>
<div class='comments-container'></div>
<% } %>
</div>
<% if (ctx.canCreateComments) { %>
<h2>Add comment</h2>
<div class='comment-form-container'></div>
<% } %>
</div>
</div>

View file

@ -0,0 +1,5 @@
<div class='metric-input'>
<ul class='compact-unset-metrics'></ul>
<hr class='separator'>
<ul class='compact-post-metrics'></ul>
</div>

View file

@ -17,8 +17,8 @@
'video/mp4': 'MPEG-4',
'video/quicktime': 'MOV',
'application/x-shockwave-flash': 'SWF',
}[ctx.post.mimeType] %><!--
--></a>
}[ctx.post.mimeType] %>
</a>
(<%- ctx.post.canvasWidth %>x<%- ctx.post.canvasHeight %>)
<% if (ctx.post.flags.length) { %><!--
--><% if (ctx.post.flags.includes('loop')) { %><i class='fa fa-repeat'></i><% } %><!--
@ -99,10 +99,10 @@
--><% if (ctx.canListPosts) { %><!--
--><a href='<%- ctx.formatClientLink('posts', {query: ctx.escapeTagName(tag.names[0])}) %>' class='<%= ctx.makeCssName(tag.category, 'tag') %>'><!--
--><% } %><!--
--><%- ctx.getPrettyName(tag.names[0]) %><!--
--><%- ctx.getPrettyName(tag.names[0]) %>&#32;<!--
--><% if (ctx.canListPosts) { %><!--
--></a><!--
--><% } %>&#32;<!--
--><% } %><!--
--><span class='tag-usages' data-pseudo-content='<%- tag.postCount %>'></span><!--
--></li><!--
--><% } %><!--

View file

@ -4,6 +4,10 @@
%><wbr/><%
%><input class='mousetrap' type='submit' value='Search'/><%
%><wbr/><%
%><button id='randomize-button' class='icon-button'><%
%><i class="fa fa-random"><%
%></button><%
%><wbr/><%
%><% if (ctx.enableSafety) { %><%
%><input data-safety=safe type='button' class='mousetrap safety safety-safe <%- ctx.settings.listPosts.safe ? '' : 'disabled' %>'/><%
%><input data-safety=sketchy type='button' class='mousetrap safety safety-sketchy <%- ctx.settings.listPosts.sketchy ? '' : 'disabled' %>'/><%
@ -16,6 +20,7 @@
%><form class='horizontal bulk-edit bulk-edit-tags'><%
%><span class='append hint'>Tagging with:</span><%
%><a href class='mousetrap button append open'>Mass tag</a><%
%><wbr/><%
%><%= ctx.makeTextInput({name: 'tag', value: ctx.parameters.tag}) %><%
%><input class='mousetrap start' type='submit' value='Start tagging'/><%
%><a href class='mousetrap button append close'>Stop tagging</a><%

View file

@ -0,0 +1,5 @@
<li>
<a href='<%= ctx.getPostUrl(ctx.id, ctx.parameters) %>'>
<%= ctx.makeThumbnail(ctx.thumbnailUrl) %>
</a>
</li>

View file

@ -0,0 +1,40 @@
<div class='tag-metric'>
<form class='horizontal edit-metric'>
<div class='metric-bounds-edit'>
<%= ctx.makeNumericInput({
text: 'Minimum',
name: 'metric-min',
value: ctx.metricMin,
step: 'any',
readonly: !ctx.canEditMetricBounds,
}) %>
<%= ctx.makeNumericInput({
text: 'Maximum',
name: 'metric-max',
value: ctx.metricMax,
step: 'any',
readonly: !ctx.canEditMetricBounds,
}) %>
</div>
<% if (ctx.tag.metric && ctx.canDeleteMetric) { %>
<div class='confirmation'>
<%= ctx.makeCheckbox({name: 'confirm-delete',
text: 'I confirm that I want to delete this metric.'}) %>
</div>
<% } %>
<div class='messages'></div>
<div class='buttons'><!--
--><% if (!ctx.tag.metric && ctx.canCreateMetric) { %><!--
--><input type='submit' value='Create metric'/><!--
--><% } else if (ctx.tag.metric && ctx.canEditMetricBounds) { %><!--
--><input type='submit' value='Update metric'/><!--
--><% } %><!--
--><% if (ctx.tag.metric && ctx.canDeleteMetric) { %><!--
--><input type='button' name='delete' class='delete' value='Delete metric'/><!--
--><% } %>
</div>
</form>
</div>

View file

@ -23,6 +23,15 @@
pattern: ctx.passwordPattern,
}) %>
</li>
<li>
<%= ctx.makeCodeInput({
text: 'Entry code',
name: 'code',
placeholder: 'Secret code',
required: true,
pattern: ctx.codePattern,
}) %>
</li>
<li>
<%= ctx.makeEmailInput({
text: 'Email',

View file

@ -15,6 +15,7 @@ class Api extends events.EventTarget {
this.user = null;
this.userName = null;
this.userPassword = null;
this.userCode = null;
this.token = null;
this.cache = {};
this.allRanks = [
@ -92,6 +93,10 @@ class Api extends events.EventTarget {
return remoteConfig.passwordRegex;
}
getCodeRegex() {
return remoteConfig.codeRegex;
}
getUserNameRegex() {
return remoteConfig.userNameRegex;
}

View file

@ -0,0 +1,187 @@
'use strict';
const api = require('../api.js');
const router = require('../router.js');
const views = require('../util/views.js');
const topNavigation = require('../models/top_navigation.js');
const Post = require('../models/post.js');
const PostMetric = require('../models/post_metric.js');
const PostMetricRange = require('../models/post_metric_range.js');
const PostList = require('../models/post_list.js');
const MetricSorterView = require('../views/metric_sorter_view.js');
const EmptyView = require('../views/empty_view.js');
const LEFT = 'left';
const RIGHT = 'right';
class MetricSorterController {
constructor(ctx) {
if (!api.hasPrivilege('posts:view') ||
!api.hasPrivilege('metrics:edit:posts')) {
this._view = new EmptyView();
this._view.showError('You don\'t have privileges to edit post metric values.');
return;
}
topNavigation.activate('posts');
topNavigation.setTitle('Sorting metrics');
this._ctx = ctx;
this._metricNames = (ctx.parameters.metrics || '')
.split(' ')
.filter(m => m);
if (!this._metricNames.length) {
this._view = new EmptyView();
this._view.showError('No metrics selected');
return;
}
this._primaryMetricName = this._metricNames[0];
this._view = new MetricSorterView({
primaryMetric: this._primaryMetricName,
greaterPost: RIGHT,
});
this._view.addEventListener('submit', e => this._evtSubmit(e));
this._view.addEventListener('skip', e => this._evtSkip(e));
this._view.addEventListener('changeMetric', e => this._evtChangeMetric(e));
if (ctx.parameters.id === 'random') {
this.startSortingRandomPost();
} else {
this.startSortingPost(ctx.parameters.id);
}
}
startSortingPost(id) {
this._view.clearMessages();
this._foundExactValue = false;
Post.get(id).then(post => {
this._unsortedPost = post;
this._view.installLeftPost(post);
this.reloadMedianPost();
}).catch(error => {
this._view.showError(error.message)
});
}
startSortingRandomPost() {
this._view.clearMessages();
this._getRandomUnsortedPostId().then(id => {
this._ctx.parameters.id = id;
router.replace(views.getMetricSorterUrl(id, this._ctx.parameters));
this.startSortingPost(id);
}).catch(error => {
this._view.showError(error.message)
});
}
reloadMedianPost() {
const metricName = this._primaryMetricName;
let range = this._getOrCreateRange(this._unsortedPost, metricName);
this._tryGetMedianPost(metricName, range).then(medianResponse => {
if (medianResponse.post) {
this._sortedPost = medianResponse.post;
this._view.installRightPost(this._sortedPost);
} else {
// No existing metrics, apply the median value
this._foundExactValue = true;
let exactValue = (medianResponse.range.low + medianResponse.range.high) / 2;
this._view.showSuccess(`Found exact value: ${exactValue}`);
this._setExactMetric(this._unsortedPost, metricName, exactValue);
//TODO: maybe allow to set exact value?
}
}).catch(error => {
this._view.showError(error.message)
});
}
_getRandomUnsortedPostId() {
let unsetMetricsQuery = this._metricNames
.map(m => `${m} -metric:${m}`)
.join(' ');
let filterQuery = this._ctx.parameters.query || '';
let unsetFullQuery = `${filterQuery} ${unsetMetricsQuery} sort:random`;
return PostList.search(unsetFullQuery,
this._ctx.parameters.skips || 0, 1, ['id']).then(response => {
if (!response.results.length) {
return Promise.reject(new Error('No posts found'));
} else {
return Promise.resolve(response.results.at(0).id);
}
});
}
_tryGetMedianPost(metric, range) {
let low = range.low + 0.000000001;
let high = range.high - 0.000000001;
let median_query = `metric-${metric}:${low}..${high} sort:metric-${metric}`;
return PostList.getMedian(median_query, []).then(response => {
return Promise.resolve({
range: range,
post: response.results.at(0)
});
});
}
_getOrCreateRange(post, metricName) {
let range = post.metricRanges.findByTagName(metricName);
if (!range) {
let tag = post.tags.findByName(metricName);
range = PostMetricRange.create(post.id, tag);
post.metricRanges.add(range);
}
return range;
}
_setExactMetric(post, metricName, value) {
let range = post.metricRanges.findByTagName(metricName);
if (!range) {
post.metricRanges.remove(range);
}
let tag = post.tags.findByName(metricName);
let exactMetric = PostMetric.create(post.id, tag);
exactMetric.value = value;
post.metrics.add(exactMetric);
}
_evtSubmit(e) {
let range = this._getOrCreateRange(this._unsortedPost, this._primaryMetricName);
if (this._foundExactValue) {
this._unsortedPost.metricRanges.remove(range);
} else {
let medianValue = this._sortedPost.metrics.findByTagName(this._primaryMetricName).value;
if (e.detail.greaterPost === LEFT) {
range.low = medianValue;
} else {
range.high = medianValue;
}
}
this._unsortedPost.save().then(() => {
if (this._foundExactValue) {
this.startSortingRandomPost();
} else {
this.reloadMedianPost();
}
}, error => {
this._view.showError(error.message)
});
}
_evtSkip(e) {
this._ctx.parameters.skips = (this._ctx.parameters.skips || 0) + 1;
this.startSortingRandomPost();
}
_evtChangeMetric(e) {
// this._primaryMetricName = e.detail.metricName;
}
}
module.exports = router => {
router.enter(
['post', ':id', 'metric-sorter'],
(ctx, next) => {
ctx.controller = new MetricSorterController(ctx);
});
};

View file

@ -91,16 +91,16 @@ class PoolController {
_evtUpdate(e) {
this._view.clearMessages();
this._view.disableForm();
if (e.detail.names !== undefined && e.detail.names !== null) {
if (e.detail.names !== undefined) {
e.detail.pool.names = e.detail.names;
}
if (e.detail.category !== undefined && e.detail.category !== null) {
if (e.detail.category !== undefined) {
e.detail.pool.category = e.detail.category;
}
if (e.detail.description !== undefined && e.detail.description !== null) {
if (e.detail.description !== undefined) {
e.detail.pool.description = e.detail.description;
}
if (e.detail.posts !== undefined && e.detail.posts !== null) {
if (e.detail.posts !== undefined) {
e.detail.pool.posts.clear();
for (let postId of e.detail.posts) {
e.detail.pool.posts.add(

View file

@ -43,8 +43,6 @@ class PoolListController {
this._headerView.addEventListener(
"submit",
(e) => this._evtSubmit(e),
);
this._headerView.addEventListener(
"navigate",
(e) => this._evtNavigate(e)
);

View file

@ -147,10 +147,11 @@ class PostListController {
},
requestPage: (offset, limit) => {
return PostList.search(
this._ctx.parameters.query,
offset,
limit,
fields
this._ctx.parameters.query,
offset,
limit,
fields,
this._ctx.parameters.cachenumber
);
},
pageRenderer: (pageCtx) => {

View file

@ -51,6 +51,9 @@ class PostMainController extends BasePostController {
nextPostId: aroundResponse.next
? aroundResponse.next.id
: null,
randomPostId: aroundResponse.random
? aroundResponse.random.id
: null,
canEditPosts: api.hasPrivilege("posts:edit"),
canDeletePosts: api.hasPrivilege("posts:delete"),
canFeaturePosts: api.hasPrivilege("posts:feature"),
@ -169,22 +172,22 @@ class PostMainController extends BasePostController {
this._view.sidebarControl.disableForm();
this._view.sidebarControl.clearMessages();
const post = e.detail.post;
if (e.detail.safety !== undefined && e.detail.safety !== null) {
if (e.detail.safety !== undefined) {
post.safety = e.detail.safety;
}
if (e.detail.flags !== undefined && e.detail.flags !== null) {
if (e.detail.flags !== undefined) {
post.flags = e.detail.flags;
}
if (e.detail.relations !== undefined && e.detail.relations !== null) {
if (e.detail.relations !== undefined) {
post.relations = e.detail.relations;
}
if (e.detail.content !== undefined && e.detail.content !== null) {
if (e.detail.content !== undefined) {
post.newContent = e.detail.content;
}
if (e.detail.thumbnail !== undefined && e.detail.thumbnail !== null) {
if (e.detail.thumbnail !== undefined) {
post.newThumbnail = e.detail.thumbnail;
}
if (e.detail.source !== undefined && e.detail.source !== null) {
if (e.detail.source !== undefined) {
post.source = e.detail.source;
}
post.save().then(

View file

@ -95,13 +95,13 @@ class TagController {
_evtUpdate(e) {
this._view.clearMessages();
this._view.disableForm();
if (e.detail.names !== undefined && e.detail.names !== null) {
if (e.detail.names !== undefined) {
e.detail.tag.names = e.detail.names;
}
if (e.detail.category !== undefined && e.detail.category !== null) {
if (e.detail.category !== undefined) {
e.detail.tag.category = e.detail.category;
}
if (e.detail.description !== undefined && e.detail.description !== null) {
if (e.detail.description !== undefined) {
e.detail.tag.description = e.detail.description;
}
e.detail.tag.save().then(

View file

@ -175,21 +175,21 @@ class UserController {
const isLoggedIn = api.isLoggedIn(e.detail.user);
const infix = isLoggedIn ? "self" : "any";
if (e.detail.name !== undefined && e.detail.name !== null) {
if (e.detail.name !== undefined) {
e.detail.user.name = e.detail.name;
}
if (e.detail.email !== undefined && e.detail.email !== null) {
if (e.detail.email !== undefined) {
e.detail.user.email = e.detail.email;
}
if (e.detail.rank !== undefined && e.detail.rank !== null) {
if (e.detail.rank !== undefined) {
e.detail.user.rank = e.detail.rank;
}
if (e.detail.password !== undefined && e.detail.password !== null) {
if (e.detail.password !== undefined) {
e.detail.user.password = e.detail.password;
}
if (e.detail.avatarStyle !== undefined && e.detail.avatarStyle !== null) {
if (e.detail.avatarStyle !== undefined) {
e.detail.user.avatarStyle = e.detail.avatarStyle;
if (e.detail.avatarContent) {
e.detail.user.avatarContent = e.detail.avatarContent;
@ -302,7 +302,7 @@ class UserController {
this._view.clearMessages();
this._view.disableForm();
if (e.detail.note !== undefined && e.detail.note !== null) {
if (e.detail.note !== undefined) {
e.detail.userToken.note = e.detail.note;
}

View file

@ -29,6 +29,7 @@ class UserRegistrationController {
user.name = e.detail.name;
user.email = e.detail.email;
user.password = e.detail.password;
user.code = e.detail.code;
const isLoggedIn = api.isLoggedIn();
user.save()
.then(() => {

View file

@ -0,0 +1,85 @@
'use strict';
const events = require('../events.js');
const misc = require('../util/misc.js');
const views = require('../util/views.js');
const MetricList = require('../models/metric_list.js');
const mainTemplate = views.getTemplate('metric-header');
const metricItemTemplate = views.getTemplate('metric-header-item');
class MetricHeaderControl extends events.EventTarget {
constructor(hostNode, ctx) {
super();
this._ctx = ctx;
this._hostNode = hostNode;
this._selectedMetrics = new MetricList();
this._headerNode = mainTemplate(ctx);
this._metricListNode = this._headerNode.querySelector('ul.metric-list');
this._hostNode.insertBefore(
this._headerNode, this._hostNode.nextSibling);
MetricList.loadAll().then(response => {
this._ctx.allMetrics = response.results;
this._addSelectedMetrics(ctx.parameters.metrics);
this._installMetrics(response.results);
this._refreshStartSortingButton();
});
}
_addSelectedMetrics(metricsStr) {
let selectedNames = (metricsStr || '').split(' ');
for (let metric of [...this._ctx.allMetrics]) {
if (selectedNames.includes(metric.tag.names[0])) {
this._selectedMetrics.add(metric);
}
}
}
_installMetrics(metrics) {
for (let metric of metrics) {
const node = metricItemTemplate(Object.assign({},
{
metric: metric,
selected: this._selectedMetrics.includes(metric),
},
this._ctx));
node.addEventListener('click', e =>
this._evtMetricClicked(e, node, metric));
this._metricListNode.appendChild(node);
}
}
_evtMetricClicked(e, node, metric) {
e.preventDefault();
node.classList.toggle('selected');
node.querySelector('a').classList.toggle('selected');
if (this._selectedMetrics.includes(metric)) {
this._selectedMetrics.remove(metric);
} else {
this._selectedMetrics.add(metric);
}
this._ctx.parameters = Object.assign({},
this._ctx.parameters, {
metrics: this._selectedMetrics
.map(m => m.tag.names[0]).join(' '),
});
this._refreshStartSortingButton();
this.dispatchEvent(new CustomEvent('submit'));
}
_refreshStartSortingButton() {
let btn = this._hostNode.querySelector('a.sorting');
btn.hidden = !this._selectedMetrics.length;
btn.setAttribute('href', views.getMetricSorterUrl('random', this._ctx.parameters));
}
refreshQuery(query) {
this._ctx.parameters.query = query;
this._refreshStartSortingButton();
}
}
module.exports = MetricHeaderControl;

View file

@ -103,30 +103,6 @@ class PostContentControl {
}
_refreshSize() {
if (window.innerWidth <= 800) {
const buttons = document.querySelector(".sidebar > .buttons");
if (buttons) {
const content = document.querySelector(".content");
content.insertBefore(buttons, content.querySelector(".post-container + *"));
const afterControls = document.querySelector(".content > .after-mobile-controls");
if (afterControls) {
afterControls.parentElement.parentElement.appendChild(afterControls);
}
}
} else {
const buttons = document.querySelector(".content > .buttons");
if (buttons) {
const sidebar = document.querySelector(".sidebar");
sidebar.insertBefore(buttons, sidebar.firstElementChild);
}
const afterControls = document.querySelector(".content + .after-mobile-controls");
if (afterControls) {
document.querySelector(".content").appendChild(afterControls);
}
}
this._currentFitFunction();
}

View file

@ -427,7 +427,7 @@ class PostEditSidebarControl extends events.EventTarget {
: undefined,
thumbnail:
this._newPostThumbnail !== undefined && this._newPostThumbnail !== null
this._newPostThumbnail !== undefined
? this._newPostThumbnail
: undefined,

View file

@ -0,0 +1,164 @@
'use strict';
const uri = require('../util/uri.js');
const PostMetric = require('../models/post_metric.js');
const PostMetricRange = require('../models/post_metric_range.js');
const events = require('../events.js');
const views = require('../util/views.js');
const mainTemplate = views.getTemplate('post-metric-input');
const metricNodeTemplate = views.getTemplate('compact-metric-list-item');
const postMetricNodeTemplate = views.getTemplate('compact-post-metric-list-item');
const postMetricRangeNodeTemplate = views.getTemplate('compact-post-metric-range-list-item');
class PostMetricInputControl extends events.EventTarget {
constructor(hostNode, ctx) {
super();
this._ctx = ctx;
this._post = ctx.post;
this._hostNode = hostNode;
// dom
const editAreaNode = mainTemplate({
tags: this._post.tags,
postMetrics: this._post.metrics,
});
this._editAreaNode = editAreaNode;
this._metricListNode = editAreaNode.querySelector('ul.compact-unset-metrics');
this._separatorNode = editAreaNode.querySelector('hr.separator');
this._postMetricListNode = editAreaNode.querySelector('ul.compact-post-metrics');
// show
this._hostNode.style.display = 'none';
this._hostNode.parentNode.insertBefore(
this._editAreaNode, hostNode.nextSibling);
// add existing metrics and post metrics:
this.refreshContent();
}
refreshContent() {
this._metricListNode.innerHTML = '';
for (let tag of this._post.tags.filterMetrics()) {
const metricNode = this._createMetricNode(tag);
this._metricListNode.appendChild(metricNode);
}
this._postMetricListNode.innerHTML = '';
for (let pm of this._post.metrics) {
const postMetricNode = this._createPostMetricNode(pm);
this._postMetricListNode.appendChild(postMetricNode);
}
for (let pmr of this._post.metricRanges) {
const postMetricRangeNode = this._createPostMetricRangeNode(pmr);
this._postMetricListNode.appendChild(postMetricRangeNode);
}
this._separatorNode.style.display =
this._postMetricListNode.innerHTML ? 'block' : 'none';
}
_createMetricNode(tag) {
const node = metricNodeTemplate({
editMode: true,
tag: tag,
post: this._post,
query: this._ctx.parameters.query,
});
const createExactNode = node.querySelector('a.create-exact');
if (this._post.metrics.hasTagName(tag.names[0])) {
createExactNode.style.display = 'none';
} else {
createExactNode.addEventListener('click', e => {
e.preventDefault();
this.createPostMetric(tag);
});
}
const createRangeNode = node.querySelector('a.create-range');
if (this._post.metricRanges.hasTagName(tag.names[0])) {
createRangeNode.style.display = 'none';
} else {
createRangeNode.addEventListener('click', e => {
e.preventDefault();
this.createPostMetricRange(tag);
});
}
const sortNode = node.querySelector('a.sort');
if (this._post.metrics.hasTagName(tag.names[0])) {
sortNode.style.display = 'none';
}
return node;
}
_createPostMetricNode(pm) {
const tag = this._post.tags.findByName(pm.tagName);
const node = postMetricNodeTemplate({
editMode: true,
postMetric: pm,
tag: tag,
});
node.querySelector('input[name=value]').addEventListener('change', e => {
pm.value = e.target.value;
this.dispatchEvent(new CustomEvent('change'));
});
node.querySelector('.remove-metric').addEventListener('click', e => {
e.preventDefault();
this.deletePostMetric(pm);
});
return node;
}
_createPostMetricRangeNode(pmr) {
const tag = this._post.tags.findByName(pmr.tagName);
const node = postMetricRangeNodeTemplate({
editMode: true,
postMetricRange: pmr,
tag: tag,
});
node.querySelector('input[name=low]').addEventListener('change', e => {
pmr.low = e.target.value;
this.dispatchEvent(new CustomEvent('change'));
});
node.querySelector('input[name=high]').addEventListener('change', e => {
pmr.high = e.target.value;
this.dispatchEvent(new CustomEvent('change'));
});
node.querySelector('.remove-metric').addEventListener('click', e => {
e.preventDefault();
this.deletePostMetricRange(pmr);
});
return node;
}
createPostMetric(tag) {
let postMetricRange = this._post.metricRanges.findByTagName(tag.names[0]);
if (postMetricRange) {
this._post.metricRanges.remove(postMetricRange);
}
this._post.metrics.add(PostMetric.create(this._post.id, tag));
this.refreshContent();
this.dispatchEvent(new CustomEvent('change'));
}
createPostMetricRange(tag) {
let postMetric = this._post.metrics.findByTagName(tag.names[0]);
if (postMetric) {
this._post.metrics.remove(postMetric);
}
this._post.metricRanges.add(PostMetricRange.create(this._post.id, tag));
this.refreshContent();
this.dispatchEvent(new CustomEvent('change'));
}
deletePostMetric(pm) {
this._post.metrics.remove(pm);
this.refreshContent();
this.dispatchEvent(new CustomEvent('change'));
}
deletePostMetricRange(pmr) {
this._post.metricRanges.remove(pmr);
this.refreshContent();
this.dispatchEvent(new CustomEvent('change'));
}
}
module.exports = PostMetricInputControl;

View file

@ -0,0 +1,51 @@
'use strict';
const events = require('../events.js');
const views = require('../util/views.js');
const postMetricNodeTemplate = views.getTemplate('compact-post-metric-list-item');
const postMetricRangeNodeTemplate = views.getTemplate('compact-post-metric-range-list-item');
class PostMetricListControl extends events.EventTarget {
constructor(listNode, post) {
super();
this._post = post;
this._listNode = listNode;
this._refreshContent();
}
_refreshContent() {
this._listNode.innerHTML = '';
for (let pm of this._post.metrics) {
const postMetricNode = this._createPostMetricNode(pm);
this._listNode.appendChild(postMetricNode);
}
for (let pmr of this._post.metricRanges) {
const postMetricRangeNode = this._createPostMetricRangeNode(pmr);
this._listNode.appendChild(postMetricRangeNode);
}
}
_createPostMetricNode(pm) {
const tag = this._post.tags.findByName(pm.tagName);
const node = postMetricNodeTemplate({
editMode: false,
postMetric: pm,
tag: tag,
});
return node;
}
_createPostMetricRangeNode(pmr) {
const tag = this._post.tags.findByName(pmr.tagName);
const node = postMetricRangeNodeTemplate({
editMode: false,
postMetricRange: pmr,
tag: tag,
});
return node;
}
}
module.exports = PostMetricListControl;

View file

@ -0,0 +1,88 @@
'use strict';
const api = require('../api.js');
const uri = require('../util/uri.js');
const events = require('../events.js');
const misc = require('../util/misc.js');
const Tag = require('./tag.js');
class Metric extends events.EventTarget {
constructor() {
super();
this._orig = {};
this._updateFromResponse({});
}
get version() { return this._version; }
get min() { return this._min; }
get max() { return this._max; }
get tag() { return this._tag; }
set min(value) { this._min = value; }
set max(value) { this._max = value; }
static fromResponse(response) {
const ret = new Metric();
ret._updateFromResponse(response);
return ret;
}
static get(name) {
//TODO get metric. Or only via tag?
return api.get(uri.formatApiLink('metric', name))
.then(response => {
return Promise.resolve(Metric.fromResponse(response));
});
}
save() {
const detail = {version: this._version};
if (this._min !== this._orig._min) {
detail.min = this._min;
}
if (this._max !== this._orig._max) {
detail.max = this._max;
}
return api.post(uri.formatApiLink('metrics'), detail)
.then(response => {
this._updateFromResponse(response);
this.dispatchEvent(new CustomEvent('change', {
detail: {
metric: this,
},
}));
return Promise.resolve();
});
}
delete() {
return api.delete(
uri.formatApiLink('metric', this._orig),
{version: this._version})
.then(response => {
this.dispatchEvent(new CustomEvent('delete', {
detail: {
metric: this,
},
}));
return Promise.resolve();
});
}
_updateFromResponse(response) {
const map = {
_version: response.version,
_min: response.min,
_max: response.max,
_tag: Tag.fromResponse(response.tag || {}),
};
Object.assign(this, map);
Object.assign(this._orig, map);
}
}
module.exports = Metric;

View file

@ -0,0 +1,24 @@
'use strict';
const api = require('../api.js');
const uri = require('../util/uri.js');
const AbstractList = require('./abstract_list.js');
const Metric = require('./metric.js');
class MetricList extends AbstractList {
static loadAll() {
return api.get(
uri.formatApiLink('metrics'))
.then(response => {
return Promise.resolve(Object.assign(
{},
response,
{results: MetricList.fromResponse(response.results)}));
});
}
}
MetricList._itemClass = Metric;
MetricList._itemName = 'metric';
module.exports = MetricList;

View file

@ -271,7 +271,7 @@ class Post extends events.EventTarget {
if (this._newContent) {
files.content = this._newContent;
}
if (this._newThumbnail !== undefined && this._newThumbnail !== null) {
if (this._newThumbnail !== undefined) {
files.thumbnail = this._newThumbnail;
}
if (this._source !== this._orig._source) {

View file

@ -16,7 +16,7 @@ class PostList extends AbstractList {
);
}
static search(text, offset, limit, fields) {
static search(text, offset, limit, fields, cachenumber) {
return api
.get(
uri.formatApiLink("posts", {
@ -24,6 +24,7 @@ class PostList extends AbstractList {
offset: offset,
limit: limit,
fields: fields.join(","),
cachenumber: cachenumber,
})
)
.then((response) => {

View file

@ -0,0 +1,39 @@
'use strict';
const events = require('../events.js');
class PostMetric extends events.EventTarget {
constructor() {
super();
this._updateFromResponse({});
}
static create(postId, tag) {
const metric = new PostMetric();
metric._postId = postId;
metric._tagName = tag.names[0];
metric._value = tag.metric.min;
return metric;
}
static fromResponse(response) {
const metric = new PostMetric();
metric._updateFromResponse(response);
return metric;
}
get tagName() { return this._tagName; }
get postId() { return this._postId; }
get value() { return this._value; }
set value(value) { this._value = value; }
_updateFromResponse(response) {
this._version = response.version;
this._postId = response.post_id;
this._tagName = response.tag_name;
this._value = response.value;
}
}
module.exports = PostMetric;

View file

@ -0,0 +1,24 @@
'use strict';
const AbstractList = require('./abstract_list.js');
const PostMetric = require('./post_metric.js');
class PostMetricList extends AbstractList {
findByTagName(testName) {
for (let postMetric of this._list) {
if (postMetric.tagName.toLowerCase() === testName.toLowerCase()) {
return postMetric;
}
}
return null;
}
hasTagName(testName) {
return !!this.findByTagName(testName);
}
}
PostMetricList._itemClass = PostMetric;
PostMetricList._itemName = 'postMetric';
module.exports = PostMetricList;

View file

@ -0,0 +1,43 @@
'use strict';
const events = require('../events.js');
class PostMetricRange extends events.EventTarget {
constructor() {
super();
this._updateFromResponse({});
}
static create(postId, tag) {
const metric = new PostMetricRange();
metric._postId = postId;
metric._tagName = tag.names[0];
metric._low = tag.metric.min;
metric._high = tag.metric.max;
return metric;
}
static fromResponse(response) {
const metric = new PostMetricRange();
metric._updateFromResponse(response);
return metric;
}
get tagName() { return this._tagName; }
get postId() { return this._postId; }
get low() { return this._low; }
get high() { return this._high; }
set low(value) { this._low = value; }
set high(value) { this._high = value; }
_updateFromResponse(response) {
this._version = response.version;
this._postId = response.post_id;
this._tagName = response.tag_name;
this._low = response.low;
this._high = response.high;
}
}
module.exports = PostMetricRange;

View file

@ -0,0 +1,24 @@
'use strict';
const AbstractList = require('./abstract_list.js');
const PostMetricRange = require('./post_metric_range.js');
class PostMetricRangeList extends AbstractList {
findByTagName(testName) {
for (let pmr of this._list) {
if (pmr.tagName.toLowerCase() === testName.toLowerCase()) {
return pmr;
}
}
return null;
}
hasTagName(testName) {
return !!this.findByTagName(testName);
}
}
PostMetricRangeList._itemClass = PostMetricRange;
PostMetricRangeList._itemName = 'postMetricRange';
module.exports = PostMetricRangeList;

View file

@ -12,6 +12,7 @@ function handleTouchStart(handler, evt) {
const touchEvent = evt.touches[0];
handler._xStart = touchEvent.clientX;
handler._yStart = touchEvent.clientY;
handler._startScrollY = window.scrollY;
}
function handleTouchMove(handler, evt) {
@ -35,22 +36,22 @@ function handleTouchMove(handler, evt) {
}
}
function handleTouchEnd(handler) {
function handleTouchEnd(handler, evt) {
evt.startScrollY = handler._startScrollY;
switch (handler._direction) {
case direction.NONE:
return;
case direction.LEFT:
handler._swipeLeftTask();
handler._swipeLeftTask(evt);
break;
case direction.RIGHT:
handler._swipeRightTask();
handler._swipeRightTask(evt);
break;
case direction.DOWN:
handler._swipeDownTask();
handler._swipeDownTask(evt);
break;
case direction.UP:
handler._swipeUpTask();
// no default
handler._swipeUpTask(evt);
}
handler._xStart = null;
@ -76,15 +77,15 @@ class Touch {
this._yStart = null;
this._direction = direction.NONE;
this._target.addEventListener("touchstart", (evt) => {
handleTouchStart(this, evt);
});
this._target.addEventListener("touchmove", (evt) => {
handleTouchMove(this, evt);
});
this._target.addEventListener("touchend", () => {
handleTouchEnd(this);
});
this._target.addEventListener('touchstart', (evt) =>
{ handleTouchStart(this, evt); }
);
this._target.addEventListener('touchmove', (evt) =>
{ handleTouchMove(this, evt); }
);
this._target.addEventListener('touchend', (evt) =>
{ handleTouchEnd(this, evt); }
);
}
}

View file

@ -136,6 +136,11 @@ function makePasswordInput(options) {
return makeInput(options);
}
function makeCodeInput(options) {
options.type = "code";
return makeInput(options);
}
function makeEmailInput(options) {
options.type = "email";
return makeInput(options);
@ -209,13 +214,13 @@ function makePostLink(id, includeHash) {
}
function makeTagLink(name, includeHash, includeCount, tag) {
const category = tag && tag.category ? tag.category : "unknown";
const category = tag ? tag.category : "unknown";
let text = misc.getPrettyName(name);
if (includeHash === true) {
text = "#" + text;
}
if (includeCount === true) {
text += " (" + (tag && tag.postCount ? tag.postCount : 0) + ")";
text += " (" + (tag ? tag.postCount : 0) + ")";
}
return api.hasPrivilege("tags:view")
? makeElement(
@ -234,15 +239,15 @@ function makeTagLink(name, includeHash, includeCount, tag) {
}
function makePoolLink(id, includeHash, includeCount, pool, name) {
const category = pool && pool.category ? pool.category : "unknown";
const category = pool ? pool.category : "unknown";
let text = misc.getPrettyName(
name ? name : pool && pool.names ? pool.names[0] : "pool " + id
name ? name : pool ? pool.names[0] : "unknown"
);
if (includeHash === true) {
text = "#" + text;
}
if (includeCount === true) {
text += " (" + (pool && pool.postCount ? pool.postCount : 0) + ")";
text += " (" + (pool ? pool.postCount : 0) + ")";
}
return api.hasPrivilege("pools:view")
? makeElement(
@ -264,7 +269,7 @@ function makeUserLink(user) {
let text = makeThumbnail(user ? user.avatarUrl : null);
text += user && user.name ? misc.escapeHtml(user.name) : "Anonymous";
const link =
user && user.name && api.hasPrivilege("users:view")
user && api.hasPrivilege("users:view")
? makeElement(
"a",
{ href: uri.formatClientLink("user", user.name) },
@ -444,6 +449,7 @@ function getTemplate(templatePath) {
makeTextarea: makeTextarea,
makeTextInput: makeTextInput,
makePasswordInput: makePasswordInput,
makeCodeInput: makeCodeInput,
makeEmailInput: makeEmailInput,
makeColorInput: makeColorInput,
makeDateInput: makeDateInput,

View file

@ -0,0 +1,138 @@
'use strict';
const events = require('../events.js');
const views = require('../util/views.js');
const iosCorrectedInnerHeight = require('ios-inner-height');
const PostContentControl = require('../controls/post_content_control.js');
const template = views.getTemplate('metric-sorter');
const sideTemplate = views.getTemplate('metric-sorter-side');
//TODO: find a way to export these constants once
const LEFT = 'left';
const RIGHT = 'right';
class MetricSorterView extends events.EventTarget {
constructor(ctx) {
super();
this._ctx = ctx;
this._hostNode = document.getElementById('content-holder');
views.replaceContent(this._hostNode, template(ctx));
this._formNode.addEventListener('submit', e => this._evtFormSubmit(e));
this._skipButtonNode.addEventListener('click', e => this._evtSkipClick(e));
this._compareLessBtnNode.addEventListener('click', e => this._evtCompareClick(e));
this._compareGreaterBtnNode.addEventListener('click', e => this._evtCompareClick(e));
this._refreshCompareButton();
}
installLeftPost(post) {
this._leftPostControl = this._installPostControl(post, this._leftSideNode);
}
installRightPost(post) {
this._rightPostControl = this._installPostControl(post, this._rightSideNode);
}
_installPostControl(post, sideNode) {
views.replaceContent(
sideNode,
sideTemplate(Object.assign({}, this._ctx, {
post: post,
})));
let containerNode = this._getSidePostContainerNode(sideNode);
return new PostContentControl(
containerNode,
post,
() => {
// TODO: come up with a more reliable resizing mechanism
return window.innerWidth < 1000 ?
[
window.innerWidth,
iosCorrectedInnerHeight() / 2
] : [
containerNode.getBoundingClientRect().width,
window.innerHeight - containerNode.getBoundingClientRect().top -
this._buttonsNode.getBoundingClientRect().height * 2
];
});
}
clearMessages() {
views.clearMessages(this._hostNode);
}
enableForm() {
views.enableForm(this._formNode);
}
disableForm() {
views.disableForm(this._formNode);
}
showSuccess(message) {
views.showSuccess(this._hostNode, message);
}
showError(message) {
views.showError(this._hostNode, message);
}
get _formNode() {
return this._hostNode.querySelector('form');
}
get _leftSideNode() {
return this._hostNode.querySelector('.left-post-container');
}
get _rightSideNode() {
return this._hostNode.querySelector('.right-post-container');
}
get _compareGreaterBtnNode() {
return this._hostNode.querySelector('.left-gt-right')
}
get _compareLessBtnNode() {
return this._hostNode.querySelector('.left-lt-right')
}
get _buttonsNode() {
return this._hostNode.querySelector('.buttons');
}
get _skipButtonNode() {
return this._hostNode.querySelector('.skip-btn');
}
_getSidePostContainerNode(sideNode) {
return sideNode.querySelector('.post-container');
}
_evtSkipClick(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('skip'));
}
_evtFormSubmit(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', {
detail: {
greaterPost: this._ctx.greaterPost,
}}));
}
_evtCompareClick(e) {
e.preventDefault();
this._ctx.greaterPost = this._ctx.greaterPost === LEFT ? RIGHT : LEFT;
this._refreshCompareButton();
}
_refreshCompareButton() {
this._compareGreaterBtnNode.hidden = this._ctx.greaterPost === RIGHT;
this._compareLessBtnNode.hidden = this._ctx.greaterPost === LEFT;
}
}
module.exports = MetricSorterView;

View file

@ -86,6 +86,12 @@ class PostMainView {
}
};
const showRandomImage = () => {
if (ctx.randomPostId) {
router.show(ctx.getPostUrl(ctx.randomPostId, ctx.parameters));
}
};
keyboard.bind("e", () => {
if (ctx.editMode) {
router.show(uri.formatClientLink("post", ctx.post.id));
@ -95,6 +101,7 @@ class PostMainView {
});
keyboard.bind(["a", "left"], showPreviousImage);
keyboard.bind(["d", "right"], showNextImage);
keyboard.bind("r", showRandomImage);
keyboard.bind("del", (e) => {
if (ctx.editMode) {
this.sidebarControl._evtDeleteClick(e);
@ -105,15 +112,21 @@ class PostMainView {
postContainerNode,
() => {
if (!ctx.editMode) {
showPreviousImage();
showNextImage()
}
},
() => {
if (!ctx.editMode) {
showNextImage();
showPreviousImage()
}
},
() => {},
(e) => {
if (!ctx.editMode && e.startScrollY === 0) {
showRandomImage()
}
}
);
)
}
_installSidebar(ctx) {

View file

@ -197,8 +197,11 @@ class PostsHeaderView extends events.EventTarget {
this._evtSafetyButtonClick(e)
);
}
this._formNode.addEventListener("submit", (e) =>
this._evtFormSubmit(e)
this._formNode.addEventListener('submit', e =>
this._evtFormSubmit(e));
this._randomizeButtonNode.addEventListener('click', e =>
this._evtRandomizeButtonClick(e)
);
this._bulkEditors = [];
@ -256,6 +259,10 @@ class PostsHeaderView extends events.EventTarget {
return this._hostNode.querySelector("form [name=search-text]");
}
get _randomizeButtonNode() {
return this._hostNode.querySelector('#randomize-button');
}
get _bulkEditTagsNode() {
return this._hostNode.querySelector(".bulk-edit-tags");
}
@ -314,9 +321,21 @@ class PostsHeaderView extends events.EventTarget {
this._navigate();
}
_evtRandomizeButtonClick(e) {
e.preventDefault();
if (!this._queryInputNode.value.includes('sort:random')) {
this._queryInputNode.value += ' sort:random';
}
this._ctx.parameters.cachenumber = Math.round(Math.random() * 1000);
this._navigate();
}
_navigate() {
this._autoCompleteControl.hide();
let parameters = { query: this._queryInputNode.value };
let parameters = {
query: this._queryInputNode.value,
cachenumber: this._ctx.parameters.cachenumber,
};
// convert falsy values to an empty string "" so that we can correctly compare with the current query
const prevQuery = this._ctx.parameters.query

View file

@ -15,6 +15,7 @@ class RegistrationView extends events.EventTarget {
template({
userNamePattern: api.getUserNameRegex(),
passwordPattern: api.getPasswordRegex(),
codePattern: api.getCodeRegex(),
})
);
views.syncScrollPosition();
@ -45,6 +46,7 @@ class RegistrationView extends events.EventTarget {
detail: {
name: this._userNameFieldNode.value,
password: this._passwordFieldNode.value,
code: this._codeFieldNode.value,
email: this._emailFieldNode.value,
},
})
@ -63,6 +65,10 @@ class RegistrationView extends events.EventTarget {
return this._formNode.querySelector("[name=password]");
}
get _codeFieldNode() {
return this._formNode.querySelector("[name=code]");
}
get _emailFieldNode() {
return this._formNode.querySelector("[name=email]");
}

View file

@ -0,0 +1,97 @@
'use strict';
const events = require('../events.js');
const api = require('../api.js');
const views = require('../util/views.js');
const Metric = require('../models/metric.js');
const template = views.getTemplate('tag-metric');
class TagMetricView extends events.EventTarget {
constructor(ctx) {
super();
this._tag = ctx.tag;
this._hostNode = ctx.hostNode;
if (ctx.tag.metric) {
ctx.metricMin = ctx.tag.metric.min;
ctx.metricMax = ctx.tag.metric.max;
} else {
// default new values
ctx.metricMin = 0;
ctx.metricMax = 10;
}
views.replaceContent(this._hostNode, template(ctx));
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
if (this._deleteButtonNode) {
this._deleteButtonNode.addEventListener('click', e => this._evtDelete(e));
}
}
_evtSubmit(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', {
detail: {
tag: this._tag,
metricMin: this._minFieldNode.value,
metricMax: this._maxFieldNode.value,
},
}));
}
_evtDelete(e) {
e.preventDefault();
if (!this._deleteConfirmationNode.checked) {
this.showError('Please confirm deletion.')
} else {
this.dispatchEvent(new CustomEvent('delete', {
detail: {tag: this._tag},
}));
}
}
clearMessages() {
views.clearMessages(this._hostNode);
}
enableForm() {
views.enableForm(this._formNode);
}
disableForm() {
views.disableForm(this._formNode);
}
showSuccess(message) {
views.showSuccess(this._hostNode, message);
}
showError(message) {
views.showError(this._hostNode, message);
}
get _formNode() {
return this._hostNode.querySelector('form');
}
get _minFieldNode() {
return this._formNode.querySelector('input[name=metric-min]');
}
get _maxFieldNode() {
return this._formNode.querySelector('input[name=metric-max]');
}
get _deleteConfirmationNode() {
return this._formNode.querySelector('input[name=confirm-delete]');
}
get _deleteButtonNode() {
return this._formNode.querySelector('input[name=delete]');
}
}
module.exports = TagMetricView;

BIN
customdocker2 master.7z Normal file

Binary file not shown.

Binary file not shown.

View file

@ -37,7 +37,6 @@
- [Creating post](#creating-post)
- [Updating post](#updating-post)
- [Getting post](#getting-post)
- [Getting around post](#getting-around-post)
- [Deleting post](#deleting-post)
- [Merging posts](#merging-posts)
- [Rating post](#rating-post)
@ -54,7 +53,7 @@
- [Deleting pool category](#deleting-pool-category)
- [Setting default pool category](#setting-default-pool-category)
- Pools
- [Listing pools](#listing-pools)
- [Listing pools](#listing-pool)
- [Creating pool](#creating-pool)
- [Updating pool](#updating-pool)
- [Getting pool](#getting-pool)
@ -165,9 +164,9 @@ way. The files, however, should be passed as regular fields appended with a
accepts a file named `content`, the client should pass
`{"contentUrl":"http://example.com/file.jpg"}` as a part of the JSON message
body. When creating or updating post content using this method, the server can
also be configured to employ [yt-dlp](https://github.com/yt-dlp/yt-dlp) to
download content from popular sites such as youtube, gfycat, etc. Access to
yt-dlp can be configured with the `'uploads:use_downloader'` permission
also be configured to employ [youtube-dl](https://github.com/ytdl-org/youtube-dl)
to download content from popular sites such as youtube, gfycat, etc. Access to
youtube-dl can be configured with the `'uploads:use_downloader'` permission
Finally, in some cases the user might want to reuse one file between the
requests to save the bandwidth (for example, reverse search + consecutive
@ -323,7 +322,7 @@ data.
{
"name": <name>,
"color": <color>,
"order": <order>
"order": <order> // optional
}
```
@ -952,29 +951,6 @@ data.
Retrieves information about an existing post.
## Getting around post
- **Request**
`GET /post/<id>/around`
- **Output**
```json5
{
"prev": <post-resource>,
"next": <post-resource>
}
```
- **Errors**
- the post does not exist
- privileges are too low
- **Description**
Retrieves information about posts that are before or after an existing post.
## Deleting post
- **Request**
@ -1389,7 +1365,7 @@ data.
## Creating pool
- **Request**
`POST /pool`
`POST /pools/create`
- **Input**
@ -2491,7 +2467,7 @@ One file together with its metadata posted to the site.
## Micro post
**Description**
A [post resource](#post) stripped down to `id` and `thumbnailUrl` fields.
A [post resource](#post) stripped down to `name` and `thumbnailUrl` fields.
## Note
**Description**

View file

@ -34,79 +34,33 @@ and Docker Compose (version 1.6.0 or greater) already installed.
Read the comments to guide you. Note that `.env` should be in the root
directory of this repository.
4. Pull the containers:
### Running the Application
This pulls the latest containers from docker.io:
```console
user@host:szuru$ docker-compose pull
```
Download containers:
```console
user@host:szuru$ docker-compose pull
```
If you have modified the application's source and would like to manually
build it, follow the instructions in [**Building**](#Building) instead,
then read here once you're done.
For first run, it is recommended to start the database separately:
```console
user@host:szuru$ docker-compose up -d sql
```
5. Run it!
To start all containers:
```console
user@host:szuru$ docker-compose up -d
```
For first run, it is recommended to start the database separately:
```console
user@host:szuru$ docker-compose up -d sql
```
To start all containers:
```console
user@host:szuru$ docker-compose up -d
```
To view/monitor the application logs:
```console
user@host:szuru$ docker-compose logs -f
# (CTRL+C to exit)
```
### Building
1. Edit `docker-compose.yml` to tell Docker to build instead of pull containers:
```diff yaml
...
server:
- image: szurubooru/server:latest
+ build: server
...
client:
- image: szurubooru/client:latest
+ build: client
...
```
You can choose to build either one from source.
2. Build the containers:
```console
user@host:szuru$ docker-compose build
```
That will attempt to build both containers, but you can specify `client`
or `server` to make it build only one.
If `docker-compose build` spits out:
```
ERROR: Service 'server' failed to build: failed to parse platform : "" is an invalid component of "": platform specifier component must match "^[A-Za-z0-9_-]+$": invalid argument
```
...you will need to export Docker BuildKit flags:
```console
user@host:szuru$ export DOCKER_BUILDKIT=1; export COMPOSE_DOCKER_CLI_BUILD=1
```
...and run `docker-compose build` again.
*Note: If your changes are not taking effect in your builds, consider building
with `--no-cache`.*
To view/monitor the application logs:
```console
user@host:szuru$ docker-compose logs -f
# (CTRL+C to exit)
```
To stop all containers:
```console
user@host:szuru$ docker-compose down
```
### Additional Features

View file

@ -10,12 +10,6 @@ BUILD_INFO=latest
# otherwise the port specified here will be publicly accessible
PORT=8080
# How many waitress threads to start
# 4 is the default amount of threads. If you experience performance
# degradation with a large number of posts, increasing this may
# improve performance, since waitress is most likely clogging up with Tasks.
THREADS=4
# URL base to run szurubooru under
# See "Additional Features" section in INSTALL.md
BASE_URL=/

View file

@ -2,32 +2,39 @@
##
## Use this as a template to set up docker-compose, or as guide to set up other
## orchestration services
version: '2'
version: '3'
services:
server:
image: szurubooru/server:latest
depends_on:
- sql
# image: szurubooru/server:latest
build:
context: ./server
# depends_on:
# - sql
environment:
## These should be the names of the dependent containers listed below,
## or FQDNs/IP addresses if these services are running outside of Docker
POSTGRES_HOST: sql
POSTGRES_HOST: 192.168.1.17
## Credentials for database:
POSTGRES_USER:
POSTGRES_PASSWORD:
POSTGRES_USER: stash_u
POSTGRES_PASSWORD: tGmwRgEHbziNNEdsvcXynudFayVayzBybFvmMw
## Commented Values are Default:
#POSTGRES_DB: defaults to same as POSTGRES_USER
#POSTGRES_PORT: 5432
POSTGRES_DB: stash_db
POSTGRES_PORT: 5432
#LOG_SQL: 0 (1 for verbose SQL logs)
THREADS:
volumes:
- "${MOUNT_DATA}:/data"
- "./server/config.yaml:/opt/app/config.yaml"
- "${MOUNT_SQL}:/var/lib/postgresql/data"
networks:
- br0
client:
image: szurubooru/client:latest
# image: szurubooru/client:latest
build:
context: ./client
depends_on:
- server
environment:
@ -36,13 +43,22 @@ services:
volumes:
- "${MOUNT_DATA}:/data:ro"
ports:
- "${PORT}:80"
- "${PORT}:8069"
networks:
- br0
sql:
image: postgres:11-alpine
restart: unless-stopped
environment:
POSTGRES_USER:
POSTGRES_PASSWORD:
volumes:
- "${MOUNT_SQL}:/var/lib/postgresql/data"
#sql:
# image: postgres:11-alpine
#restart: unless-stopped
#environment:
# POSTGRES_USER: stash_u
#POSTGRES_PASSWORD: TeKeuubttpyLtYHtkdvixgvyKqvfmeWuSkXcKp
#volumes:
# - "${MOUNT_SQL}:/var/lib/postgresql/data"
#networks:
# - temposvision
networks:
br0:
external: true
name: br0

View file

@ -24,14 +24,21 @@ RUN apk --no-cache add \
py3-pynacl \
py3-tz \
py3-pyrfc3339
RUN pip3 install --no-cache-dir --disable-pip-version-check \
# Upgrade pip and setuptools
RUN pip3 install --no-cache-dir --disable-pip-version-check --upgrade pip setuptools wheel
# Install required Python packages with PEP 517 disabled
RUN pip3 install --no-cache-dir --disable-pip-version-check --no-build-isolation \
"alembic>=0.8.5" \
"coloredlogs==5.0" \
"pyheif==0.6.1" \
"heif-image-plugin>=0.3.2" \
yt-dlp \
"pillow-avif-plugin~=1.1.0"
RUN apk --no-cache del py3-pip
youtube_dl \
"pillow-avif-plugin>=1.1.0"
# Debugging: Print the installed packages
RUN apk list --installed
COPY ./ /opt/app/
RUN rm -rf /opt/app/szurubooru/tests
@ -75,6 +82,8 @@ RUN apk --no-cache add \
&& addgroup -g ${PGID} app \
&& adduser -SDH -h /opt/app -g '' -G app -u ${PUID} app \
&& chown -R app:app /opt/app /data
RUN chmod +x /opt/app/docker-start.sh
USER app
CMD ["/opt/app/docker-start.sh"]

115
server/Dockerfile - Copy Normal file
View file

@ -0,0 +1,115 @@
ARG ALPINE_VERSION=3.14
FROM alpine:$ALPINE_VERSION as prereqs
WORKDIR /opt/app
RUN apk --no-cache add py3-pip build-base python3-dev libffi-dev libheif-dev \
&& pip3 install --no-cache-dir --disable-pip-version-check --upgrade cffi \
&& pip3 install --no-cache-dir --disable-pip-version-check --upgrade pyheif-pillow-opener \
&& apk --no-cache del py3-pip build-base python3-dev libffi-dev libheif-dev \
&& apk --no-cache add python3 python3-dev ffmpeg openssl-dev py3-pip py3-yaml py3-psycopg2 py3-sqlalchemy py3-certifi py3-numpy py3-pillow py3-pynacl py3-tz py3-pyrfc3339 build-base \
&& apk --no-cache add libheif libavif libheif-dev libavif-dev \
&& pip3 install --upgrade --disable-pip-version-check wheel alembic "coloredlogs==5.0" youtube_dl pillow-avif-plugin pyheif-pillow-opener \
&& apk --no-cache del py3-pip
RUN apk --no-cache add \
python3 \
python3-dev \
ffmpeg \
openssl-dev \
py3-pip \
# from requirements.txt:
py3-yaml \
py3-psycopg2 \
py3-sqlalchemy \
py3-certifi \
py3-numpy \
py3-pillow \
py3-pynacl \
py3-tz \
py3-pyrfc3339 \
build-base \
&& apk --no-cache add \
libheif \
libavif \
libheif-dev \
libavif-dev \
&& pip3 install --upgrade --disable-pip-version-check \
wheel \
alembic \
"coloredlogs==5.0" \
youtube_dl \
pillow-avif-plugin \
pyheif-pillow-opener \
&& apk --no-cache del py3-pip
COPY ./ /opt/app/
RUN rm -rf /opt/app/szurubooru/tests
FROM prereqs as testing
WORKDIR /opt/app
RUN apk --no-cache add \
py3-pip \
py3-pytest \
py3-pytest-cov \
postgresql \
&& pip3 install --no-cache-dir --disable-pip-version-check \
pytest-pgsql \
freezegun \
&& apk --no-cache del py3-pip \
&& addgroup app \
&& adduser -SDH -h /opt/app -g '' -G app app \
&& chown app:app /opt/app
COPY --chown=app:app ./szurubooru/tests /opt/app/szurubooru/tests/
ENV TEST_ENVIRONMENT="true"
USER app
ENTRYPOINT ["pytest", "--tb=short"]
CMD ["szurubooru/"]
FROM prereqs as release
WORKDIR /opt/app
ARG PUID=1000
ARG PGID=1000
RUN apk --no-cache add \
dumb-init \
py3-setuptools \
py3-waitress \
&& mkdir -p /opt/app /data \
&& addgroup -g ${PGID} app \
&& adduser -SDH -h /opt/app -g '' -G app -u ${PUID} app \
&& chown -R app:app /opt/app /data
RUN chmod +x /opt/app/docker-start.sh
USER app
CMD ["/opt/app/docker-start.sh"]
ARG PORT=6666
ENV PORT=${PORT}
EXPOSE ${PORT}
VOLUME ["/data/"]
ARG DOCKER_REPO
ARG BUILD_DATE
ARG SOURCE_COMMIT
LABEL \
maintainer="" \
org.opencontainers.image.title="${DOCKER_REPO}" \
org.opencontainers.image.url="https://github.com/rr-/szurubooru" \
org.opencontainers.image.documentation="https://github.com/rr-/szurubooru/blob/${SOURCE_COMMIT}/doc/INSTALL.md" \
org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.source="https://github.com/rr-/szurubooru" \
org.opencontainers.image.revision="${SOURCE_COMMIT}" \
org.opencontainers.image.licenses="GPL-3.0"

View file

@ -0,0 +1,104 @@
ARG ALPINE_VERSION=3.13
FROM alpine:$ALPINE_VERSION as prereqs
WORKDIR /opt/app
RUN apk --no-cache add \
python3 \
python3-dev \
py3-pip \
build-base \
libheif \
libheif-dev \
libavif \
libavif-dev \
ffmpeg \
# from requirements.txt:
py3-yaml \
py3-psycopg2 \
py3-sqlalchemy \
py3-certifi \
py3-numpy \
py3-pillow \
py3-pynacl \
py3-tz \
py3-pyrfc3339 \
&& pip3 install --no-cache-dir --disable-pip-version-check \
"alembic>=0.8.5" \
"coloredlogs==5.0" \
"pyheif==0.6.1" \
"heif-image-plugin>=0.3.2" \
youtube_dl \
"pillow-avif-plugin>=1.1.0" \
&& apk --no-cache del py3-pip
COPY ./ /opt/app/
RUN rm -rf /opt/app/szurubooru/tests
FROM --platform=$BUILDPLATFORM prereqs as testing
WORKDIR /opt/app
RUN apk --no-cache add \
py3-pip \
py3-pytest \
py3-pytest-cov \
postgresql \
&& pip3 install --no-cache-dir --disable-pip-version-check \
pytest-pgsql \
freezegun \
&& apk --no-cache del py3-pip \
&& addgroup app \
&& adduser -SDH -h /opt/app -g '' -G app app \
&& chown app:app /opt/app
COPY --chown=app:app ./szurubooru/tests /opt/app/szurubooru/tests/
ENV TEST_ENVIRONMENT="true"
USER app
ENTRYPOINT ["pytest", "--tb=short"]
CMD ["szurubooru/"]
FROM prereqs as release
WORKDIR /opt/app
ARG PUID=1000
ARG PGID=1000
RUN apk --no-cache add \
dumb-init \
py3-setuptools \
py3-waitress \
&& mkdir -p /opt/app /data \
&& addgroup -g ${PGID} app \
&& adduser -SDH -h /opt/app -g '' -G app -u ${PUID} app \
&& chown -R app:app /opt/app /data
RUN chmod +x /opt/app/docker-start.sh
USER app
CMD ["/opt/app/docker-start.sh"]
ARG PORT=6666
ENV PORT=${PORT}
EXPOSE ${PORT}
ARG THREADS=4
ENV THREADS=${THREADS}
VOLUME ["/data/"]
ARG DOCKER_REPO
ARG BUILD_DATE
ARG SOURCE_COMMIT
LABEL \
maintainer="" \
org.opencontainers.image.title="${DOCKER_REPO}" \
org.opencontainers.image.url="https://github.com/rr-/szurubooru" \
org.opencontainers.image.documentation="https://github.com/rr-/szurubooru/blob/${SOURCE_COMMIT}/doc/INSTALL.md" \
org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.source="https://github.com/rr-/szurubooru" \
org.opencontainers.image.revision="${SOURCE_COMMIT}" \
org.opencontainers.image.licenses="GPL-3.0"

View file

@ -2,7 +2,7 @@
set -e
cd /opt/app
alembic upgrade head
alembic upgrade heads
echo "Starting szurubooru API on port ${PORT} - Running on ${THREADS} threads"
exec waitress-serve-3 --port ${PORT} --threads ${THREADS} szurubooru.facade:app
echo "Starting szurubooru API on port ${PORT}"
exec waitress-serve-3 --port ${PORT} szurubooru.facade:app

7
server/hooks/build Normal file
View file

@ -0,0 +1,7 @@
#!/bin/sh
docker build \
--build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \
--build-arg SOURCE_COMMIT \
--build-arg DOCKER_REPO \
-f $DOCKERFILE_PATH -t $IMAGE_NAME .

19
server/hooks/post_push Normal file
View file

@ -0,0 +1,19 @@
#!/bin/sh
add_tag() {
echo "Also tagging image as ${DOCKER_REPO}:${1}"
docker tag $IMAGE_NAME $DOCKER_REPO:$1
docker push $DOCKER_REPO:$1
}
CLOSEST_VER=$(git describe --tags --abbrev=0)
CLOSEST_MAJOR_VER=$(echo ${CLOSEST_VER} | cut -d'.' -f1)
CLOSEST_MINOR_VER=$(echo ${CLOSEST_VER} | cut -d'.' -f2)
add_tag "${CLOSEST_MAJOR_VER}-edge"
add_tag "${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}-edge"
if git describe --exact-match --abbrev=0 2> /dev/null; then
add_tag "${CLOSEST_MAJOR_VER}"
add_tag "${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}"
fi

8
server/hooks/test Normal file
View file

@ -0,0 +1,8 @@
#!/bin/sh
set -e
docker run --rm \
-t $(docker build --target testing -q .) \
--color=no szurubooru/
exit $?

View file

@ -3,7 +3,7 @@ certifi>=2017.11.5
coloredlogs==5.0
heif-image-plugin==0.3.2
numpy>=1.8.2
pillow-avif-plugin~=1.1.0
pillow-avif-plugin>=1.1.0
pillow>=4.3.0
psycopg2-binary>=2.6.1
pyheif==0.6.1
@ -12,4 +12,4 @@ pyRFC3339>=1.0
pytz>=2018.3
pyyaml>=3.11
SQLAlchemy>=1.0.12, <1.4
yt-dlp
youtube_dl

View file

@ -0,0 +1,94 @@
from math import ceil
from typing import Optional, List, Dict
from szurubooru import db, model, search, rest
from szurubooru.func import (
auth, metrics, snapshots, serialization, tags, versions
)
_search_executor_config = search.configs.PostMetricSearchConfig()
_search_executor = search.Executor(_search_executor_config)
def _serialize_metric(
ctx: rest.Context, metric: model.Metric) -> rest.Response:
return metrics.serialize_metric(
metric, options=serialization.get_serialization_options(ctx)
)
def _serialize_post_metric(
ctx: rest.Context, post_metric: model.PostMetric) -> rest.Response:
return metrics.serialize_post_metric(
post_metric, options=serialization.get_serialization_options(ctx)
)
def _get_metric(params: Dict[str, str]) -> model.Metric:
return metrics.get_metric_by_tag_name(params["tag_name"])
@rest.routes.get("/metrics/?")
def get_metrics(
ctx: rest.Context, params: Dict[str, str] = {}) -> rest.Response:
auth.verify_privilege(ctx.user, "metrics:list")
all_metrics = metrics.get_all_metrics()
return {
"results": [_serialize_metric(ctx, metric) for metric in all_metrics]
}
@rest.routes.post("/metrics/?")
def create_metric(
ctx: rest.Context, params: Dict[str, str] = {}) -> rest.Response:
auth.verify_privilege(ctx.user, "metrics:create")
tag_name = ctx.get_param_as_string("tag_name")
tag = tags.get_tag_by_name(tag_name)
min = ctx.get_param_as_float("min")
max = ctx.get_param_as_float("max")
metric = metrics.create_metric(tag, min, max)
ctx.session.flush()
# snapshots.create(metric, ctx.user)
ctx.session.commit()
return _serialize_metric(ctx, metric)
@rest.routes.delete("/metric/(?P<tag_name>.+)")
def delete_metric(ctx: rest.Context, params: Dict[str, str]) -> rest.Response:
metric = _get_metric(params)
versions.verify_version(metric, ctx)
auth.verify_privilege(ctx.user, "metrics:delete")
# snapshots.delete(metric, ctx.user)
metrics.delete_metric(metric)
ctx.session.commit()
return {}
@rest.routes.get("/post-metrics/?")
def get_post_metrics(
ctx: rest.Context, params: Dict[str, str] = {}) -> rest.Response:
auth.verify_privilege(ctx.user, "metrics:list")
return _search_executor.execute_and_serialize(
ctx, lambda post_metric: _serialize_post_metric(ctx, post_metric))
@rest.routes.get("/post-metrics/median/(?P<tag_name>.+)")
def get_post_metrics_median(
ctx: rest.Context, params: Dict[str, str] = {}) -> rest.Response:
auth.verify_privilege(ctx.user, "metrics:list")
metric = _get_metric(params)
tag_name = params["tag_name"]
query_text = ctx.get_param_as_string(
"query",
default="%s:%f..%f" % (tag_name, metric.min, metric.max))
total_count = _search_executor.count(query_text)
offset = ceil(total_count/2) - 1
_, results = _search_executor.execute(query_text, offset, 1)
return {
"query": query_text,
"offset": offset,
"limit": 1,
"total": len(results),
"results": list([_serialize_post_metric(ctx, pm) for pm in results])
}

View file

@ -0,0 +1,273 @@
import sqlalchemy as sa
from typing import Any, Optional, List, Dict, Callable
from szurubooru import db, model, errors, rest
from szurubooru.func import serialization, tags, util, versions
class MetricDoesNotExistsError(errors.ValidationError):
pass
class MetricAlreadyExistsError(errors.ValidationError):
pass
class InvalidMetricError(errors.ValidationError):
pass
class PostMissingTagError(errors.ValidationError):
pass
class MetricValueOutOfRangeError(errors.ValidationError):
pass
class MetricSerializer(serialization.BaseSerializer):
def __init__(self, metric: model.Metric):
self.metric = metric
def _serializers(self) -> Dict[str, Callable[[], Any]]:
return {
"version": lambda: self.metric.version,
"min": lambda: self.metric.min,
"max": lambda: self.metric.max,
"exact_count": lambda: self.metric.post_metric_count,
"range_count": lambda: self.metric.post_metric_range_count,
"tag": lambda: tags.serialize_tag(self.metric.tag, [
"names", "category", "description", "usages"])
}
class PostMetricSerializer(serialization.BaseSerializer):
def __init__(self, post_metric: model.PostMetric):
self.post_metric = post_metric
def _serializers(self) -> Dict[str, Callable[[], Any]]:
return {
"tag_name": lambda: self.post_metric.metric.tag_name,
"post_id": lambda: self.post_metric.post_id,
"value": lambda: self.post_metric.value,
}
class PostMetricRangeSerializer(serialization.BaseSerializer):
def __init__(self, post_metric_range: model.PostMetricRange):
self.post_metric_range = post_metric_range
def _serializers(self) -> Dict[str, Callable[[], Any]]:
return {
"tag_name": lambda: self.post_metric_range.metric.tag_name,
"post_id": lambda: self.post_metric_range.post_id,
"low": lambda: self.post_metric_range.low,
"high": lambda: self.post_metric_range.high,
}
def serialize_metric(
metric: model.Metric,
options: List[str] = []) -> Optional[rest.Response]:
if not metric:
return None
return MetricSerializer(metric).serialize(options)
def serialize_post_metric(
post_metric: model.PostMetric,
options: List[str] = []) -> Optional[rest.Response]:
if not post_metric:
return None
return PostMetricSerializer(post_metric).serialize(options)
def serialize_post_metric_range(
post_metric_range: model.PostMetricRange,
options: List[str] = []) -> Optional[rest.Response]:
if not post_metric_range:
return None
return PostMetricRangeSerializer(post_metric_range).serialize(options)
def try_get_metric_by_tag_name(tag_name: str) -> Optional[model.Metric]:
return (
db.session
.query(model.Metric)
.filter(sa.func.lower(model.Metric.tag_name) == tag_name.lower())
.one_or_none())
def get_metric_by_tag_name(tag_name: str) -> model.Metric:
metric = try_get_metric_by_tag_name(tag_name)
if not metric:
raise MetricDoesNotExistsError("Metric %r not found." % tag_name)
return metric
def get_all_metrics() -> List[model.Metric]:
return db.session.query(model.Metric).all()
def get_all_metric_tag_names() -> List[str]:
return [
tag_name.name for tag_name in util.flatten_list(
[metric.tag.names for metric in get_all_metrics()]
)
]
def try_get_post_metric(
post: model.Post,
metric: model.Metric) -> Optional[model.PostMetric]:
return (
db.session
.query(model.PostMetric)
.filter(model.PostMetric.metric == metric)
.filter(model.PostMetric.post == post)
.one_or_none())
def try_get_post_metric_range(
post: model.Post,
metric: model.Metric) -> Optional[model.PostMetricRange]:
return (
db.session
.query(model.PostMetricRange)
.filter(model.PostMetricRange.metric == metric)
.filter(model.PostMetricRange.post == post)
.one_or_none())
def create_metric(
tag: model.Tag,
min: float,
max: float) -> model.Metric:
assert tag
if tag.metric:
raise MetricAlreadyExistsError("Tag already has a metric.")
if min >= max:
raise InvalidMetricError("Metric min(%r) >= max(%r)" % (min, max))
metric = model.Metric(tag=tag, min=min, max=max)
db.session.add(metric)
return metric
def update_or_create_metric(
tag: model.Tag,
metric_data: Any) -> Optional[model.Metric]:
assert tag
for field in ("min", "max"):
if field not in metric_data:
raise InvalidMetricError("Metric is missing %r field." % field)
min, max = metric_data["min"], metric_data["max"]
if min >= max:
raise InvalidMetricError("Metric min(%r) >= max(%r)" % (min, max))
if tag.metric:
tag.metric.min = min
tag.metric.max = max
versions.bump_version(tag.metric)
return None
else:
return create_metric(tag=tag, min=min, max=max)
def update_or_create_post_metric(
post: model.Post,
metric: model.Metric,
value: float) -> model.PostMetric:
assert post
assert metric
if metric.tag not in post.tags:
raise PostMissingTagError(
"Post doesn\"t have this tag.")
if value < metric.min or value > metric.max:
raise MetricValueOutOfRangeError(
"Metric value %r out of range." % value)
post_metric = try_get_post_metric(post, metric)
if not post_metric:
post_metric = model.PostMetric(post=post, metric=metric, value=value)
db.session.add(post_metric)
else:
post_metric.value = value
versions.bump_version(post_metric)
return post_metric
def update_or_create_post_metrics(post: model.Post, metrics_data: Any) -> None:
"""
Overwrites any existing post metrics, deletes other existing post metrics.
"""
assert post
post.metrics = []
for metric_data in metrics_data:
for field in ("tag_name", "value"):
if field not in metric_data:
raise InvalidMetricError("Metric is missing %r field." % field)
value = float(metric_data["value"])
tag_name = metric_data["tag_name"]
tag = tags.get_tag_by_name(tag_name)
if not tag.metric:
raise MetricDoesNotExistsError(
"Tag %r has no metric." % tag_name)
post_metric = update_or_create_post_metric(post, tag.metric, value)
post.metrics.append(post_metric)
def update_or_create_post_metric_range(
post: model.Post,
metric: model.Metric,
low: float,
high: float) -> model.PostMetricRange:
assert post
assert metric
if metric.tag not in post.tags:
raise PostMissingTagError(
"Post doesn\"t have this tag.")
for value in (low, high):
if value < metric.min or value > metric.max:
raise MetricValueOutOfRangeError(
"Metric value %r out of range." % value)
if low >= high:
raise InvalidMetricError(
"Metric range low(%r) >= high(%r)" % (low, high))
post_metric_range = try_get_post_metric_range(post, metric)
if not post_metric_range:
post_metric_range = model.PostMetricRange(
post=post, metric=metric, low=low, high=high)
db.session.add(post_metric_range)
else:
post_metric_range.low = low
post_metric_range.high = high
versions.bump_version(post_metric_range)
return post_metric_range
def update_or_create_post_metric_ranges(
post: model.Post,
metric_ranges_data: Any) -> None:
"""
Overwrites any existing post metrics, deletes other existing post metrics.
"""
assert post
post.metric_ranges = []
for metric_data in metric_ranges_data:
for field in ("tag_name", "low", "high"):
if field not in metric_data:
raise InvalidMetricError(
"Metric range is missing %r field." % field)
low = float(metric_data["low"])
high = float(metric_data["high"])
tag_name = metric_data["tag_name"]
tag = tags.get_tag_by_name(tag_name)
if not tag.metric:
raise MetricDoesNotExistsError(
"Tag %r has no metric." % tag_name)
post_metric_range = update_or_create_post_metric_range(
post, tag.metric, low, high)
post.metric_ranges.append(post_metric_range)
def delete_metric(metric: model.Metric) -> None:
assert metric
db.session.delete(metric)

View file

@ -64,7 +64,7 @@ def download(url: str, use_video_downloader: bool = False) -> bytes:
def _get_youtube_dl_content_url(url: str) -> str:
cmd = ["yt-dlp", "--format", "best", "--no-playlist"]
cmd = ["youtube-dl", "--format", "best", "--no-playlist"]
if config.config["user_agent"]:
cmd.extend(["--user-agent", config.config["user_agent"]])
cmd.extend(["--get-url", url])

View file

@ -0,0 +1,32 @@
from typing import List
import sqlalchemy as sa
from szurubooru import db, model, search
_search_executor_config = search.configs.PostSearchConfig()
_search_executor = search.Executor(_search_executor_config)
# TODO(hunternif): this ignores the query, e.g. rating.
# (But we're actually using a "similar" search query on the client anyway.)
def find_similar_posts(
source_post: model.Post, limit: int, query_text: str = ''
) -> List[model.Post]:
post_alias = sa.orm.aliased(model.Post)
pt_alias = sa.orm.aliased(model.PostTag)
result = (
db.session.query(post_alias)
.join(pt_alias, pt_alias.post_id == post_alias.post_id)
.filter(
sa.sql.or_(
pt_alias.tag_id == tag.tag_id for tag in source_post.tags
)
)
.filter(pt_alias.post_id != source_post.post_id)
.group_by(post_alias.post_id)
.order_by(sa.func.count(pt_alias.tag_id).desc())
.order_by(post_alias.post_id.desc())
.limit(limit)
)
return result

View file

@ -0,0 +1,141 @@
import sqlalchemy as sa
from szurubooru.model.base import Base
from szurubooru.model.post import PostTag
from szurubooru.model.tag import TagName
class PostMetric(Base):
__tablename__ = 'post_metric'
post_id = sa.Column(
'post_id',
sa.Integer,
sa.ForeignKey('post.id'),
primary_key=True,
nullable=False,
index=True)
tag_id = sa.Column(
'tag_id',
sa.Integer,
sa.ForeignKey('metric.tag_id'),
primary_key=True,
nullable=False,
index=True)
version = sa.Column('version', sa.Integer, default=1, nullable=False)
value = sa.Column('value', sa.Float, nullable=False, index=True)
post = sa.orm.relationship('Post')
metric = sa.orm.relationship('Metric', back_populates='post_metrics')
__table_args__ = (sa.ForeignKeyConstraint(
(post_id, tag_id),
(PostTag.post_id, PostTag.tag_id),
ondelete='cascade'),
)
__mapper_args__ = {
'version_id_col': version,
'version_id_generator': False,
# when deleting tag or post, cascade will ensure this post metric is
# also deleted, but sqlalchemy will try to delete it twice because of
# the cascade on foreign key into PostTag. This silences the error:
'confirm_deleted_rows': False,
}
class PostMetricRange(Base):
"""
Could be a metric in the process of finding its exact value, e.g. by sorting.
It has upper and lower boundaries that will converge at the final value.
"""
__tablename__ = 'post_metric_range'
post_id = sa.Column(
'post_id',
sa.Integer,
sa.ForeignKey('post.id'),
primary_key=True,
nullable=False,
index=True)
tag_id = sa.Column(
'tag_id',
sa.Integer,
sa.ForeignKey('metric.tag_id'),
primary_key=True,
nullable=False,
index=True)
version = sa.Column('version', sa.Integer, default=1, nullable=False)
low = sa.Column('low', sa.Float, nullable=False)
high = sa.Column('high', sa.Float, nullable=False)
post = sa.orm.relationship('Post')
metric = sa.orm.relationship('Metric', back_populates='post_metric_ranges')
__table_args__ = (sa.ForeignKeyConstraint(
(post_id, tag_id),
(PostTag.post_id, PostTag.tag_id),
ondelete='cascade'),
)
__mapper_args__ = {
'version_id_col': version,
'version_id_generator': False,
# when deleting tag or post, cascade will ensure this post metric is
# also deleted, but sqlalchemy will try to delete it twice because of
# the cascade on foreign key into PostTag. This silences the error:
'confirm_deleted_rows': False,
}
class Metric(Base):
"""
Must be attached to a tag, tag_id is primary key.
"""
__tablename__ = 'metric'
tag_id = sa.Column(
'tag_id',
sa.Integer,
sa.ForeignKey('tag.id'),
primary_key=True,
nullable=False,
index=True)
version = sa.Column('version', sa.Integer, default=1, nullable=False)
min = sa.Column('min', sa.Float, nullable=False)
max = sa.Column('max', sa.Float, nullable=False)
tag = sa.orm.relationship('Tag')
post_metrics = sa.orm.relationship(
'PostMetric', back_populates='metric', cascade='all, delete-orphan')
post_metric_ranges = sa.orm.relationship(
'PostMetricRange', back_populates='metric', cascade='all, delete-orphan')
tag_name = sa.orm.column_property(
(
sa.sql.expression.select([TagName.name])
.where(TagName.tag_id == tag_id)
.order_by(TagName.order)
.limit(1)
.as_scalar()
))
post_metric_count = sa.orm.column_property(
(
sa.sql.expression.select(
[sa.sql.expression.func.count(PostMetric.post_id)])
.where(PostMetric.tag_id == tag_id)
.correlate_except(PostMetric)
),
deferred=True)
post_metric_range_count = sa.orm.column_property(
(
sa.sql.expression.select(
[sa.sql.expression.func.count(PostMetricRange.post_id)])
.where(PostMetricRange.tag_id == tag_id)
.correlate_except(PostMetricRange)
),
deferred=True)
__mapper_args__ = {
'version_id_col': version,
'version_id_generator': False,
}

View file

@ -0,0 +1,44 @@
from typing import Dict
import sqlalchemy as sa
from szurubooru import db, model
from szurubooru.func import metrics, util
from szurubooru.search.configs import util as search_util
from szurubooru.search.configs.base_search_config import (
BaseSearchConfig, Filter)
from szurubooru.search.typing import SaQuery
class PostMetricSearchConfig(BaseSearchConfig):
def __init__(self) -> None:
self.all_metric_names = []
def refresh_metrics(self) -> None:
self.all_metric_names = metrics.get_all_metric_tag_names()
def create_filter_query(self, _disable_eager_loads: bool) -> SaQuery:
self.refresh_metrics()
return db.session.query(model.PostMetric).options(sa.orm.lazyload('*'))
def create_count_query(self, disable_eager_loads: bool) -> SaQuery:
return self.create_filter_query(disable_eager_loads)
def create_around_query(self) -> SaQuery:
return self.create_filter_query()
def finalize_query(self, query: SaQuery) -> SaQuery:
return query.order_by(model.PostMetric.value.asc())
@property
def anonymous_filter(self) -> Filter:
return search_util.create_subquery_filter(
model.PostMetric.tag_id,
model.TagName.tag_id,
model.TagName.name,
search_util.create_str_filter)
@property
def named_filters(self) -> Dict[str, Filter]:
num_filter = search_util.create_float_filter(model.PostMetric.value)
return {tag_name: num_filter for tag_name in self.all_metric_names}

View file

@ -37,8 +37,7 @@ class Executor:
self.parser = parser.Parser()
def get_around(
self, query_text: str, entity_id: int
) -> Tuple[model.Base, model.Base]:
self, query_text: str, entity_id: int) -> Tuple[model.Base, model.Base, model.Base]:
search_query = self.parser.parse(query_text)
self.config.on_search_query_parsed(search_query)
filter_query = self.config.create_around_query().options(
@ -57,11 +56,15 @@ class Executor:
filter_query.filter(self.config.id_column < entity_id)
.order_by(None)
.order_by(sa.func.abs(self.config.id_column - entity_id).asc())
.limit(1)
)
.limit(1))
# random post
if 'sort:random' not in query_text:
query_text = query_text + ' sort:random'
count, random_entities = self.execute(query_text, 0, 1)
return (
prev_filter_query.one_or_none(),
next_filter_query.one_or_none(),
random_entities[0] if random_entities else None
)
def get_around_and_serialize(
@ -76,6 +79,7 @@ class Executor:
return {
"prev": serializer(entities[0]),
"next": serializer(entities[1]),
'random': serializer(entities[2]),
}
def execute(
@ -94,7 +98,7 @@ class Executor:
disable_eager_loads = True
key = (id(self.config), hash(search_query), offset, limit)
if cache.has(key):
if not disable_eager_loads and cache.has(key):
return cache.get(key)
filter_query = self.config.create_filter_query(disable_eager_loads)

View file

@ -0,0 +1,63 @@
from szurubooru import api, db, model
import pytest
@pytest.fixture(autouse=True)
def inject_config(config_injector):
config_injector(
{
"privileges": {
"metrics:list": model.User.RANK_REGULAR,
},
}
)
@pytest.mark.parametrize('query,expected_value', [
('', 5),
('mytag:0..', 5),
('mytag:..10', 5),
('mytag:0..10', 5),
('mytag:2..8', 5),
('mytag:0..8', 4),
('mytag:0..6', 4),
('mytag:0..5.5', 4),
('mytag:0..4', 1),
('mytag:1..4', 1),
('mytag:2..3', None),
])
def test_median(
query,
expected_value,
tag_factory,
post_factory,
metric_factory,
post_metric_factory,
context_factory,
user_factory):
tag = tag_factory(names=['mytag'])
post1 = post_factory(tags=[tag])
post4 = post_factory(tags=[tag])
post5 = post_factory(tags=[tag])
post6 = post_factory(tags=[tag])
post10 = post_factory(tags=[tag])
metric = metric_factory(tag=tag, min=0, max=10)
pm1 = post_metric_factory(metric=metric, post=post1, value=1)
pm4 = post_metric_factory(metric=metric, post=post4, value=4)
pm5 = post_metric_factory(metric=metric, post=post5, value=5)
pm6 = post_metric_factory(metric=metric, post=post6, value=6)
pm10 = post_metric_factory(metric=metric, post=post10, value=10)
db.session.add_all([tag, metric, pm1, pm4, pm5, pm6, pm10,
post1, post4, post5, post6, post10])
db.session.flush()
response = api.metric_api.get_post_metrics_median(
context_factory(
params={'query': query},
user=user_factory(rank=model.User.RANK_REGULAR)),
{'tag_name': 'mytag'})
if not expected_value:
assert response['total'] == 0
assert len(response['results']) == 0
else:
assert response['total'] == 1
assert response['results'][0]['value'] == expected_value

View file

@ -0,0 +1,459 @@
import pytest
from szurubooru import db, model
from szurubooru.func import metrics
def test_serialize_metric(tag_category_factory, tag_factory):
cat = tag_category_factory(name="cat")
tag = tag_factory(names=["tag1"], category=cat)
metric = model.Metric(tag=tag, min=1, max=2)
db.session.add(metric)
db.session.flush()
result = metrics.serialize_metric(metric)
assert result == {
"version": 1,
"min": 1,
"max": 2,
"exact_count": 0,
"range_count": 0,
"tag": {
"names": ["tag1"],
"category": "cat",
"description": None,
"usages": 0,
},
}
def test_serialize_post_metric(post_factory, tag_factory, metric_factory):
tag = tag_factory(names=["mytag"])
post = post_factory(id=456, tags=[tag])
metric = metric_factory(tag)
post_metric = model.PostMetric(post=post, metric=metric, value=-12.3)
db.session.add_all([post, tag, metric, post_metric])
db.session.flush()
result = metrics.serialize_post_metric(post_metric)
assert result == {
"tag_name": "mytag",
"post_id": 456,
"value": -12.3,
}
def test_serialize_post_metric_range(post_factory, tag_factory, metric_factory):
tag = tag_factory(names=["mytag"])
post = post_factory(id=456, tags=[tag])
metric = metric_factory(tag)
post_metric_range = model.PostMetricRange(
post=post, metric=metric, low=-1.2, high=3.4)
db.session.add_all([post, tag, metric, post_metric_range])
db.session.flush()
result = metrics.serialize_post_metric_range(post_metric_range)
assert result == {
"tag_name": "mytag",
"post_id": 456,
"low": -1.2,
"high": 3.4
}
def test_try_get_metric_by_tag_name(tag_factory, metric_factory):
tag = tag_factory(names=["mytag"])
metric = metric_factory(tag)
db.session.add_all([tag, metric])
db.session.flush()
assert metrics.try_get_metric_by_tag_name("unknown") is None
assert metrics.try_get_metric_by_tag_name("mytag") is metric
def test_try_get_post_metric(
post_factory, metric_factory, post_metric_factory):
metric1 = metric_factory()
metric2 = metric_factory()
post = post_factory(tags=[metric1.tag, metric2.tag])
post_metric = post_metric_factory(post=post, metric=metric1)
db.session.add_all([post, metric1, metric2, post_metric])
db.session.flush()
assert metrics.try_get_post_metric(post, metric2) is None
assert metrics.try_get_post_metric(post, metric1) is post_metric
def test_try_get_post_metric_range(
post_factory, metric_factory, post_metric_range_factory):
metric1 = metric_factory()
metric2 = metric_factory()
post = post_factory(tags=[metric1.tag, metric2.tag])
post_metric_range = post_metric_range_factory(post=post, metric=metric1)
db.session.add_all([post, metric1, metric2, post_metric_range])
db.session.flush()
assert metrics.try_get_post_metric_range(post, metric2) is None
assert metrics.try_get_post_metric_range(post, metric1) is post_metric_range
def test_get_all_metrics(metric_factory):
metric1 = metric_factory()
metric2 = metric_factory()
metric3 = metric_factory()
db.session.add_all([metric1, metric2, metric3])
db.session.flush()
all_metrics = metrics.get_all_metrics()
assert len(all_metrics) == 3
assert metric1 in all_metrics
assert metric2 in all_metrics
assert metric3 in all_metrics
def test_get_all_metric_tag_names(tag_factory, metric_factory):
tag1 = tag_factory(names=["abc", "def"])
tag2 = tag_factory(names=["ghi"])
metric1 = metric_factory(tag=tag1)
metric2 = metric_factory(tag=tag2)
db.session.add_all([metric1, metric2])
db.session.flush()
assert metrics.get_all_metric_tag_names() == ["abc", "def", "ghi"]
def test_create_metric(tag_factory):
tag = tag_factory()
db.session.add(tag)
new_metric = metrics.create_metric(tag, 1, 2)
assert new_metric is not None
db.session.flush()
assert tag.metric is not None
assert tag.metric.min == 1
assert tag.metric.max == 2
def test_create_metric_with_existing_metric(tag_factory):
tag = tag_factory()
tag.metric = model.Metric()
with pytest.raises(metrics.MetricAlreadyExistsError):
metrics.create_metric(tag, 1, 2)
def test_create_metric_with_invalid_params(tag_factory):
tag = tag_factory()
with pytest.raises(metrics.InvalidMetricError):
metrics.create_metric(tag, 2, 1)
def test_update_or_create_metric(tag_factory):
tag = tag_factory()
db.session.add(tag)
new_metric = metrics.update_or_create_metric(tag, {"min": 1, "max": 2})
assert new_metric is not None
db.session.flush()
assert tag.metric is not None
assert tag.metric.min == 1
assert tag.metric.max == 2
assert tag.metric.version == 1
new_metric = metrics.update_or_create_metric(tag, {"min": 3, "max": 4})
assert new_metric is None
db.session.flush()
assert tag.metric.min == 3
assert tag.metric.max == 4
assert tag.metric.version == 2
@pytest.mark.parametrize("params", [
{"min": 1}, {"max": 2}, {"min": 2, "max": 1}
])
def test_update_or_create_metric_with_invalid_params(tag_factory, params):
tag = tag_factory()
with pytest.raises(metrics.InvalidMetricError):
metrics.update_or_create_metric(tag, params)
# Post metrics
def test_update_or_create_post_metric_without_tag(post_factory, metric_factory):
post = post_factory()
metric = metric_factory()
with pytest.raises(metrics.PostMissingTagError):
metrics.update_or_create_post_metric(post, metric, 1.5)
def test_update_or_create_post_metric_with_value_out_of_range(
post_factory, metric_factory):
metric = metric_factory()
post = post_factory(tags=[metric.tag])
with pytest.raises(metrics.MetricValueOutOfRangeError):
metrics.update_or_create_post_metric(post, metric, -99)
def test_update_or_create_post_metric_create(post_factory, metric_factory):
metric = metric_factory()
post = post_factory(tags=[metric.tag])
db.session.add(metric)
db.session.flush()
post_metric = metrics.update_or_create_post_metric(post, metric, 1.5)
assert post_metric.value == 1.5
def test_update_or_create_post_metric_update(post_factory, metric_factory):
metric = metric_factory()
post1 = post_factory(tags=[metric.tag])
post2 = post_factory(tags=[metric.tag])
post_metric1 = model.PostMetric(post=post1, metric=metric, value=1.2)
post_metric2 = model.PostMetric(post=post2, metric=metric, value=5.6)
db.session.add_all([post1, post2, post_metric1, post_metric2])
db.session.flush()
assert post_metric1.version == 1
assert post_metric2.version == 1
metrics.update_or_create_post_metric(post1, metric, 3.4)
db.session.flush()
assert db.session.query(model.PostMetric).count() == 2
assert post_metric1.value == 3.4
assert post_metric1.version == 2
assert post_metric2.value == 5.6
assert post_metric2.version == 1
def test_update_or_create_post_metrics_missing_tag(
post_factory, tag_factory, metric_factory):
post = post_factory()
tag = tag_factory(names=["tag1"])
metric = metric_factory(tag)
db.session.add(metric)
db.session.flush()
data = [{"tag_name": "tag1", "value": 1.5}]
with pytest.raises(metrics.PostMissingTagError):
metrics.update_or_create_post_metrics(post, data)
@pytest.mark.parametrize("params", [
[{}],
[{"tag_name": "tag"}],
[{"value": 1.5}]
])
def test_update_or_create_post_metrics_with_missing_fields(
params, post_factory):
post = post_factory()
with pytest.raises(metrics.InvalidMetricError):
metrics.update_or_create_post_metrics(post, params)
def test_update_or_create_post_metrics_with_invalid_tag(
post_factory, tag_factory):
tag = tag_factory(names=["tag1"])
post = post_factory(tags=[tag])
db.session.add(tag)
db.session.flush()
data = [{"tag_name": "tag1", "value": 2}]
with pytest.raises(metrics.MetricDoesNotExistsError):
metrics.update_or_create_post_metrics(post, data)
def test_update_or_create_post_metrics(
post_factory, tag_factory, metric_factory):
tag1 = tag_factory(names=["tag1"])
tag2 = tag_factory(names=["tag2"])
post = post_factory(tags=[tag1, tag2])
metric1 = metric_factory(tag1)
metric2 = metric_factory(tag2)
db.session.add_all([metric1, metric2])
db.session.flush()
data = [
{"tag_name": "tag1", "value": 1.2},
{"tag_name": "tag2", "value": 3.4},
]
metrics.update_or_create_post_metrics(post, data)
db.session.flush()
assert len(post.metrics) == 2
assert post.metrics[0].value == 1.2
assert post.metrics[1].value == 3.4
def test_update_or_create_post_metrics_with_trim(
post_factory, tag_factory, metric_factory, post_metric_factory):
tag1 = tag_factory(names=["tag1"])
tag2 = tag_factory(names=["tag2"])
post = post_factory(tags=[tag1, tag2])
metric1 = metric_factory(tag1)
metric2 = metric_factory(tag2)
post_metric = post_metric_factory(post=post, metric=metric1, value=1.2)
db.session.add_all([post, tag1, tag2, metric1, metric2, post_metric])
db.session.flush()
assert len(post.metrics) == 1
assert post.metrics[0].metric == metric1
assert post.metrics[0].value == 1.2
data = [
{"tag_name": "tag2", "value": 3.4},
]
metrics.update_or_create_post_metrics(post, data)
db.session.flush()
assert len(post.metrics) == 1
assert post.metrics[0].metric == metric2
assert post.metrics[0].value == 3.4
# Post metric ranges
def test_update_or_create_post_metric_range_without_tag(
post_factory, metric_factory):
post = post_factory()
metric = metric_factory()
with pytest.raises(metrics.PostMissingTagError):
metrics.update_or_create_post_metric_range(post, metric, 2, 3)
@pytest.mark.parametrize("low, high", [
(-99, 1), (1, 99),
])
def test_update_or_create_post_metric_range_with_values_out_of_range(
low, high, post_factory, metric_factory):
metric = metric_factory()
post = post_factory(tags=[metric.tag])
with pytest.raises(metrics.MetricValueOutOfRangeError):
metrics.update_or_create_post_metric_range(post, metric, low, high)
def test_update_or_create_post_metric_range_create(
post_factory, metric_factory):
metric = metric_factory()
post = post_factory(tags=[metric.tag])
db.session.add(metric)
db.session.flush()
post_metric_range = metrics.update_or_create_post_metric_range(
post, metric, 2, 3)
assert post_metric_range.low == 2
assert post_metric_range.high == 3
def test_update_or_create_post_metric_range_update(
post_factory, metric_factory):
metric = metric_factory()
post = post_factory(tags=[metric.tag])
post_metric_range = model.PostMetricRange(
post=post, metric=metric, low=2, high=3)
db.session.add(post_metric_range)
db.session.flush()
assert post_metric_range.version == 1
metrics.update_or_create_post_metric_range(post, metric, 4, 5)
db.session.flush()
assert post_metric_range.low == 4
assert post_metric_range.high == 5
assert post_metric_range.version == 2
def test_update_or_create_post_metric_ranges_missing_tag(
post_factory, tag_factory, metric_factory):
post = post_factory()
tag = tag_factory(names=["tag1"])
metric = metric_factory(tag)
db.session.add(metric)
db.session.flush()
data = [{"tag_name": "tag1", "low": 2, "high": 3}]
with pytest.raises(metrics.PostMissingTagError):
metrics.update_or_create_post_metric_ranges(post, data)
@pytest.mark.parametrize("params", [
[{}],
[{"tag_name": "tag"}],
[{"tag_name": "tag", "low": 2}],
[{"low": 2, "high": 3}],
])
def test_update_or_create_post_metric_ranges_with_missing_fields(
params, post_factory, tag_factory):
tag = tag_factory(names=["tag"])
post = post_factory(tags=[tag])
with pytest.raises(metrics.InvalidMetricError):
metrics.update_or_create_post_metric_ranges(post, params)
def test_update_or_create_post_metric_ranges_with_invalid_tag(
post_factory, tag_factory):
tag = tag_factory(names=["tag1"])
post = post_factory(tags=[tag])
db.session.add(tag)
db.session.flush()
data = [{"tag_name": "tag1", "low": 2, "high": 3}]
with pytest.raises(metrics.MetricDoesNotExistsError):
metrics.update_or_create_post_metric_ranges(post, data)
def test_update_or_create_post_metric_ranges_with_invalid_values(
post_factory, tag_factory, metric_factory):
tag = tag_factory(names=["tag1"])
post = post_factory(tags=[tag])
metric = metric_factory(tag=tag)
db.session.add_all([metric, tag])
db.session.flush()
data = [
{"tag_name": "tag1", "low": 4, "high": 2},
]
with pytest.raises(metrics.InvalidMetricError):
metrics.update_or_create_post_metric_ranges(post, data)
def test_update_or_create_post_metric_ranges(
post_factory, tag_factory, metric_factory):
tag1 = tag_factory(names=["tag1"])
tag2 = tag_factory(names=["tag2"])
post = post_factory(tags=[tag1, tag2])
metric1 = metric_factory(tag1)
metric2 = metric_factory(tag2)
db.session.add_all([metric1, metric2])
db.session.flush()
data = [
{"tag_name": "tag1", "low": 2, "high": 3},
{"tag_name": "tag2", "low": 4, "high": 5},
]
metrics.update_or_create_post_metric_ranges(post, data)
db.session.flush()
assert len(post.metric_ranges) == 2
assert post.metric_ranges[0].low == 2
assert post.metric_ranges[0].high == 3
assert post.metric_ranges[1].low == 4
assert post.metric_ranges[1].high == 5
def test_update_or_create_post_metric_ranges_with_trim(
post_factory, tag_factory, metric_factory, post_metric_range_factory):
tag1 = tag_factory(names=["tag1"])
tag2 = tag_factory(names=["tag2"])
post = post_factory(tags=[tag1, tag2])
metric1 = metric_factory(tag1)
metric2 = metric_factory(tag2)
post_metric_range = post_metric_range_factory(
post=post, metric=metric1, low=1, high=2)
db.session.add_all([post, tag1, tag2, metric1, metric2, post_metric_range])
db.session.flush()
assert len(post.metric_ranges) == 1
assert post.metric_ranges[0].metric == metric1
assert post.metric_ranges[0].low == 1
assert post.metric_ranges[0].high == 2
data = [
{"tag_name": "tag2", "low": 3, "high": 4},
]
metrics.update_or_create_post_metric_ranges(post, data)
db.session.flush()
assert len(post.metric_ranges) == 1
assert post.metric_ranges[0].metric == metric2
assert post.metric_ranges[0].low == 3
assert post.metric_ranges[0].high == 4
def test_delete_metric(metric_factory):
metric1 = metric_factory()
metric2 = metric_factory()
db.session.add_all([metric1, metric2])
db.session.flush()
assert db.session.query(model.Metric).count() == 2
metrics.delete_metric(metric2)
db.session.flush()
assert db.session.query(model.Metric).count() == 1

View file

@ -0,0 +1,60 @@
import pytest
from szurubooru import db
from szurubooru.func import similar
@pytest.fixture
def verify_posts():
def verify(actual_posts, expected_posts):
actual_post_ids = list([p.post_id for p in actual_posts])
expected_post_ids = list([p.post_id for p in expected_posts])
assert actual_post_ids == expected_post_ids
return verify
def test_find_similar_posts(post_factory, tag_factory, verify_posts):
tagA = tag_factory(names=["a"])
tagB = tag_factory(names=["b"])
tagC = tag_factory(names=["c"])
postA = post_factory(id=1, tags=[tagA])
postAB = post_factory(id=2, tags=[tagA, tagB])
postAC = post_factory(id=3, tags=[tagA, tagC])
postABC = post_factory(id=4, tags=[tagA, tagB, tagC])
postBC = post_factory(id=5, tags=[tagB, tagC])
db.session.add_all([tagA, tagB, tagC, postA, postAB, postAC, postABC, postBC])
db.session.flush()
results = similar.find_similar_posts(postBC, 10)
verify_posts(results, [postABC, postAC, postAB])
results = similar.find_similar_posts(postBC, 2)
verify_posts(results, [postABC, postAC])
results = similar.find_similar_posts(postABC, 10)
verify_posts(results, [postBC, postAC, postAB, postA])
results = similar.find_similar_posts(postA, 10)
verify_posts(results, [postABC, postAC, postAB]) # sorted by id
results = similar.find_similar_posts(postAB, 10)
verify_posts(results, [postABC, postBC, postAC, postA])
results = similar.find_similar_posts(postAC, 10)
verify_posts(results, [postABC, postBC, postAB, postA])
def test_find_similar_posts_with_limit(post_factory, tag_factory, verify_posts):
tagA = tag_factory(names=["a"])
tagB = tag_factory(names=["b"])
tagC = tag_factory(names=["c"])
tagD = tag_factory(names=["d"])
tagE = tag_factory(names=["e"])
postA = post_factory(id=111, tags=[tagA])
postAB = post_factory(id=112, tags=[tagA, tagB])
postABCDE = post_factory(id=113, tags=[tagA, tagB, tagC, tagD, tagE])
db.session.add_all([tagA, tagB, tagC, tagD, tagE, postA, postAB, postABCDE])
db.session.flush()
results = similar.find_similar_posts(postABCDE, 10)
verify_posts(results, [postAB, postA])

View file

@ -0,0 +1,235 @@
from szurubooru import db, model
import pytest
@pytest.fixture(autouse=True)
def inject_config(config_injector):
config_injector(
{"secret": "secret", "data_dir": "", "delete_source_files": False}
)
def test_saving_metric(post_factory, tag_factory):
tag = tag_factory()
post = post_factory(tags=[tag])
metric = model.Metric(tag=tag, min=1., max=10.)
post_metric = model.PostMetric(metric=metric, post=post, value=5.5)
post_metric_range = model.PostMetricRange(metric=metric, post=post,
low=2., high=8.)
db.session.add_all([post, tag, metric, post_metric, post_metric_range])
db.session.commit()
assert metric.tag_id is not None
assert post_metric.tag_id is not None
assert post_metric.post_id is not None
assert post_metric_range.tag_id is not None
assert post_metric_range.post_id is not None
assert tag.metric.tag_id == tag.tag_id
assert tag.metric.min == 1.
assert tag.metric.max == 10.
metric = (
db.session
.query(model.Metric)
.filter(model.Metric.tag_id == tag.tag_id)
.one())
assert metric.min == 1.
assert metric.max == 10.
post_metric = (
db.session
.query(model.PostMetric)
.filter(model.PostMetric.tag_id == tag.tag_id and
model.PostMetric.post_id == post.post_id)
.one())
assert post_metric.value == 5.5
post_metric_range = (
db.session
.query(model.PostMetricRange)
.filter(model.PostMetricRange.tag_id == tag.tag_id and
model.PostMetricRange.post_id == post.post_id)
.one())
assert post_metric_range.low == 2.
assert post_metric_range.high == 8.
tag = (
db.session
.query(model.Tag)
.filter(model.Tag.tag_id == metric.tag_id)
.one())
assert tag.metric == metric
def test_cascade_delete_metric(post_factory, tag_factory):
tag = tag_factory()
post1 = post_factory(tags=[tag])
post2 = post_factory(tags=[tag])
metric = model.Metric(tag=tag, min=1., max=10.)
post_metric1 = model.PostMetric(metric=metric, post=post1, value=2.3)
post_metric2 = model.PostMetric(metric=metric, post=post2, value=4.5)
post_metric_range = model.PostMetricRange(
metric=metric, post=post2, low=2, high=8)
db.session.add_all([post1, post2, tag, metric, post_metric1, post_metric2,
post_metric_range])
db.session.flush()
assert not db.session.dirty
assert db.session.query(model.Post).count() == 2
assert db.session.query(model.Tag).count() == 1
assert db.session.query(model.Metric).count() == 1
assert db.session.query(model.PostMetric).count() == 2
assert db.session.query(model.PostMetricRange).count() == 1
db.session.delete(metric)
db.session.commit()
assert not db.session.dirty
assert db.session.query(model.Post).count() == 2
assert db.session.query(model.Tag).count() == 1
assert db.session.query(model.Metric).count() == 0
assert db.session.query(model.PostMetric).count() == 0
assert db.session.query(model.PostMetricRange).count() == 0
def test_cascade_delete_tag(post_factory, tag_factory):
tag1 = tag_factory()
tag2 = tag_factory()
post = post_factory(tags=[tag1, tag2])
metric1 = model.Metric(tag=tag1, min=1., max=10.)
metric2 = model.Metric(tag=tag2, min=2., max=20.)
post_metric1 = model.PostMetric(metric=metric1, post=post, value=2.3)
post_metric2 = model.PostMetric(metric=metric2, post=post, value=4.5)
post_metric_range1 = model.PostMetricRange(
metric=metric1, post=post, low=2, high=8)
post_metric_range2 = model.PostMetricRange(
metric=metric2, post=post, low=2, high=8)
db.session.add_all([post, tag1, tag2, metric1, metric2, post_metric1,
post_metric2, post_metric_range1, post_metric_range2])
db.session.commit()
assert not db.session.dirty
assert db.session.query(model.Post).count() == 1
assert db.session.query(model.Tag).count() == 2
assert db.session.query(model.Metric).count() == 2
assert db.session.query(model.PostMetric).count() == 2
assert db.session.query(model.PostMetricRange).count() == 2
db.session.delete(tag2)
db.session.commit()
assert not db.session.dirty
assert db.session.query(model.Post).count() == 1
assert db.session.query(model.Tag).count() == 1
assert db.session.query(model.Metric).count() == 1
assert db.session.query(model.PostMetric).count() == 1
assert db.session.query(model.PostMetricRange).count() == 1
def test_cascade_delete_post(post_factory, tag_factory):
tag = tag_factory()
post1 = post_factory(tags=[tag])
post2 = post_factory(tags=[tag])
metric = model.Metric(tag=tag, min=1., max=10.)
post_metric1 = model.PostMetric(metric=metric, post=post1, value=2.3)
post_metric2 = model.PostMetric(metric=metric, post=post2, value=4.5)
post_metric_range1 = model.PostMetricRange(
metric=metric, post=post1, low=2, high=8)
post_metric_range2 = model.PostMetricRange(
metric=metric, post=post2, low=2, high=8)
db.session.add_all([post1, post2, tag, metric, post_metric1, post_metric2,
post_metric_range1, post_metric_range2])
db.session.commit()
assert not db.session.dirty
assert db.session.query(model.Post).count() == 2
assert db.session.query(model.Tag).count() == 1
assert db.session.query(model.Metric).count() == 1
assert db.session.query(model.PostMetric).count() == 2
assert db.session.query(model.PostMetricRange).count() == 2
db.session.delete(post2)
db.session.commit()
assert not db.session.dirty
assert db.session.query(model.Post).count() == 1
assert db.session.query(model.Tag).count() == 1
assert db.session.query(model.Metric).count() == 1
assert db.session.query(model.PostMetric).count() == 1
assert db.session.query(model.PostMetricRange).count() == 1
def test_delete_post_metric_no_cascade(
post_factory, tag_factory, metric_factory,
post_metric_factory, post_metric_range_factory):
tag = tag_factory()
post = post_factory(tags=[tag])
metric = metric_factory(tag=tag)
post_metric = post_metric_factory(post=post, metric=metric)
post_metric_range = post_metric_range_factory(post=post, metric=metric)
db.session.add(metric)
db.session.commit()
assert len(metric.post_metrics) == 1
db.session.delete(post_metric)
db.session.delete(post_metric_range)
db.session.commit()
assert len(metric.post_metrics) == 0
assert len(metric.post_metric_ranges) == 0
def test_tag_without_metric(tag_factory):
tag = tag_factory(names=['mytag'])
assert tag.metric is None
db.session.add(tag)
db.session.commit()
tag = (
db.session
.query(model.Tag)
.join(model.TagName)
.filter(model.TagName.name == 'mytag')
.one())
assert tag.metric is None
def test_metric_counts(post_factory, metric_factory):
metric = metric_factory()
post1 = post_factory(tags=[metric.tag])
post2 = post_factory(tags=[metric.tag])
post_metric1 = model.PostMetric(post=post1, metric=metric, value=1.2)
post_metric2 = model.PostMetric(post=post2, metric=metric, value=3.4)
post_metric_range = model.PostMetricRange(post=post1, metric=metric, low=5.6, high=7.8)
db.session.add_all([metric, post_metric1, post_metric2, post_metric_range])
db.session.flush()
assert metric.post_metric_count == 2
assert metric.post_metric_range_count == 1
def test_cascade_on_remove_tag_from_post(
post_factory, tag_factory, metric_factory,
post_metric_factory, post_metric_range_factory):
tag = tag_factory()
post = post_factory(tags=[tag])
metric = metric_factory(tag=tag)
post_metric = post_metric_factory(post=post, metric=metric)
post_metric_range = post_metric_range_factory(post=post, metric=metric)
db.session.add_all([post, tag, metric, post_metric, post_metric_range])
db.session.commit()
assert not db.session.dirty
assert db.session.query(model.Post).count() == 1
assert db.session.query(model.Tag).count() == 1
assert db.session.query(model.PostTag).count() == 1
assert db.session.query(model.Metric).count() == 1
assert db.session.query(model.PostMetric).count() == 1
assert db.session.query(model.PostMetricRange).count() == 1
post.tags.clear()
db.session.commit()
assert not db.session.dirty
assert db.session.query(model.Post).count() == 1
assert db.session.query(model.Tag).count() == 1
assert db.session.query(model.PostTag).count() == 0
assert db.session.query(model.Metric).count() == 1
assert db.session.query(model.PostMetric).count() == 0
assert db.session.query(model.PostMetricRange).count() == 0

View file

@ -0,0 +1,89 @@
import pytest
from szurubooru import db, model, errors, search
@pytest.fixture
def executor():
return search.Executor(search.configs.PostMetricSearchConfig())
@pytest.fixture
def verify_unpaged(executor):
def verify(input, expected_values):
actual_count, actual_post_metrics = executor.execute(
input, offset=0, limit=100)
actual_values = ['%s:%r' % (u.metric.tag_name, u.value)
for u in actual_post_metrics]
assert actual_count == len(expected_values)
assert actual_values == expected_values
return verify
def test_refresh_metrics(tag_factory, metric_factory):
tag1 = tag_factory(names=['tag1'])
tag2 = tag_factory(names=['tag2'])
metric1 = metric_factory(tag1)
metric2 = metric_factory(tag2)
db.session.add_all([tag1, tag2, metric1, metric2])
db.session.flush()
config = search.configs.PostMetricSearchConfig()
config.refresh_metrics()
assert config.all_metric_names == ['tag1', 'tag2']
@pytest.mark.parametrize('input,expected_tag_names', [
('', ['t1:10', 't2:20.5', 't1:30', 't2:40']),
('*', ['t1:10', 't2:20.5', 't1:30', 't2:40']),
('t1', ['t1:10', 't1:30']),
('t2', ['t2:20.5', 't2:40']),
('t*', ['t1:10', 't2:20.5', 't1:30', 't2:40']),
('t1,t2', ['t1:10', 't2:20.5', 't1:30', 't2:40']),
('T1,T2', ['t1:10', 't2:20.5', 't1:30', 't2:40']),
])
def test_filter_anonymous(
verify_unpaged, input, expected_tag_names,
post_factory, tag_factory, metric_factory, post_metric_factory):
tag1 = tag_factory(names=['t1'])
tag2 = tag_factory(names=['t2'])
post1 = post_factory(tags=[tag1, tag2])
post2 = post_factory(tags=[tag1, tag2])
metric1 = metric_factory(tag1)
metric2 = metric_factory(tag2)
t1_10 = post_metric_factory(post=post1, metric=metric1, value=10)
t1_30 = post_metric_factory(post=post2, metric=metric1, value=30)
t2_20 = post_metric_factory(post=post1, metric=metric2, value=20.5)
t2_40 = post_metric_factory(post=post2, metric=metric2, value=40)
db.session.add_all([tag1, tag2, metric1, metric2,
t1_10, t1_30, t2_20, t2_40])
db.session.flush()
verify_unpaged(input, expected_tag_names)
@pytest.mark.parametrize('input,expected_tag_names', [
('t:13', []),
('t:10', ['t:10']),
('t:20.5', ['t:20.5']),
('t:18.6..', ['t:20.5', 't:30', 't:40']),
('t-min:18.6', ['t:20.5', 't:30', 't:40']),
('t:..21.4', ['t:10', 't:20.5']),
('t-max:21.4', ['t:10', 't:20.5']),
('t:17..33', ['t:20.5', 't:30']),
])
def test_filter_by_value(
verify_unpaged, input, expected_tag_names,
post_factory, tag_factory, metric_factory, post_metric_factory):
tag = tag_factory(names=['t'])
post1 = post_factory(tags=[tag])
post2 = post_factory(tags=[tag])
post3 = post_factory(tags=[tag])
post4 = post_factory(tags=[tag])
metric = metric_factory(tag)
t1 = post_metric_factory(post=post1, metric=metric, value=10)
t2 = post_metric_factory(post=post2, metric=metric, value=30)
t3 = post_metric_factory(post=post3, metric=metric, value=20.5)
t4 = post_metric_factory(post=post4, metric=metric, value=40)
db.session.add_all([tag, metric, t1, t2, t3, t4])
db.session.flush()
verify_unpaged(input, expected_tag_names)