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/**/bin/
server/**/pyvenv.cfg server/**/pyvenv.cfg
__pycache__/ __pycache__/
# IDE files
.idea
*.iml

View file

@ -1,29 +1,28 @@
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0 rev: v3.2.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
- id: end-of-file-fixer - id: end-of-file-fixer
- id: check-yaml - id: check-yaml
- id: mixed-line-ending - id: mixed-line-ending
- repo: https://github.com/Lucas-C/pre-commit-hooks - repo: https://github.com/Lucas-C/pre-commit-hooks
rev: v1.4.2 rev: v1.1.9
hooks: hooks:
- id: remove-tabs - id: remove-tabs
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: '23.1.0' rev: 20.8b1
hooks: hooks:
- id: black - id: black
files: 'server/' files: 'server/'
types: [python] types: [python]
language_version: python3.9 language_version: python3.8
- repo: https://github.com/PyCQA/isort - repo: https://github.com/timothycrosley/isort
rev: '5.12.0' rev: '5.4.2'
hooks: hooks:
- id: isort - id: isort
files: 'server/' files: 'server/'
@ -32,8 +31,8 @@ repos:
additional_dependencies: additional_dependencies:
- toml - toml
- repo: https://github.com/pre-commit/mirrors-prettier - repo: https://github.com/prettier/prettier
rev: v2.7.1 rev: '2.1.1'
hooks: hooks:
- id: prettier - id: prettier
files: client/js/ files: client/js/
@ -41,7 +40,7 @@ repos:
args: ['--config', 'client/.prettierrc.yml'] args: ['--config', 'client/.prettierrc.yml']
- repo: https://github.com/pre-commit/mirrors-eslint - repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.33.0 rev: v7.8.0
hooks: hooks:
- id: eslint - id: eslint
files: client/js/ files: client/js/
@ -49,8 +48,8 @@ repos:
additional_dependencies: additional_dependencies:
- eslint-config-prettier - eslint-config-prettier
- repo: https://github.com/PyCQA/flake8 - repo: https://gitlab.com/pycqa/flake8
rev: '6.0.0' rev: '3.8.3'
hooks: hooks:
- id: flake8 - id: flake8
files: server/szurubooru/ files: server/szurubooru/
@ -58,5 +57,44 @@ repos:
- flake8-print - flake8-print
args: ['--config=server/.flake8'] 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 fail_fast: true
exclude: LICENSE.md exclude: LICENSE.md

View file

@ -3,12 +3,12 @@
Szurubooru is an image board engine inspired by services such as Danbooru, Szurubooru is an image board engine inspired by services such as Danbooru,
Gelbooru and Moebooru dedicated for small and medium communities. Its name [has Gelbooru and Moebooru dedicated for small and medium communities. Its name [has
its roots in Polish language and has onomatopeic meaning of scraping or 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 ## Features
- Post content: images (JPG, PNG, GIF, animated GIF), videos (MP4, WEBM), Flash animations - 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 comments
- Post notes / annotations, including arbitrary polygons - Post notes / annotations, including arbitrary polygons
- Rich JSON REST API ([see documentation](doc/API.md)) - 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 RUN apk --no-cache add dumb-init
COPY --from=approot / / COPY --from=approot / /
RUN chmod +x /docker-start.sh
CMD ["/docker-start.sh"] CMD ["/docker-start.sh"]
VOLUME ["/data"] 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) color: mix($main-color, $inactive-link-color-darktheme)
.comment-content .comment-content
p
word-wrap: normal
word-break: break-all
ul, ol ul, ol
list-style-position: inside list-style-position: inside
margin: 1em 0 margin: 1em 0

View file

@ -300,10 +300,10 @@ a .access-key
background-size: 20px 20px background-size: 20px 20px
img img
opacity: 0 opacity: 0
width: 100% width: auto
height: 100% height: 100%
video video
width: 100% width: auto
height: 100% height: 100%
.flexbox-dummy .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 .edit-overlay
position: absolute position: absolute
top: 0.5em top: 0em
left: 0.5em left: 0em
.tag-flipper .tag-flipper
display: inline-block display: inline-block
@ -116,7 +116,7 @@
.delete-flipper .delete-flipper
display: inline-block display: inline-block
padding: 0.5em padding: 10em
box-sizing: border-box box-sizing: border-box
border: 0 border: 0
&:after &:after
@ -133,7 +133,7 @@
font-family: FontAwesome; font-family: FontAwesome;
content: "\f1f8"; // fa-trash content: "\f1f8"; // fa-trash
&:not(.delete) &:not(.delete)
background: rgba(200, 200, 200, 0.7) background: rgba(200, 200, 200, 0.2)
&:after &:after
color: white color: white
content: '-' content: '-'
@ -187,9 +187,6 @@
vertical-align: top vertical-align: top
@media (max-width: 1000px) @media (max-width: 1000px)
display: block display: block
&.bulk-edit-tags:not(.opened), &.bulk-edit-safety:not(.opened)
float: left
margin-right: 1em
input input
margin-bottom: 0.25em margin-bottom: 0.25em
margin-right: 0.25em margin-right: 0.25em

View file

@ -15,42 +15,38 @@
border: 0 border: 0
outline: 0 outline: 0
>.sidebar>nav.buttons, >.content nav.buttons nav.buttons
margin-top: 0 margin-top: 0
display: flex display: flex
flex-wrap: wrap flex-wrap: wrap
article article
flex: 1 0 33% flex: 1 0 25%
a a
display: inline-block display: inline-block
width: 100% width: 100%
padding: 0.3em 0 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 text-align: center
vertical-align: middle @media (max-width: 800px)
transition: background 0.2s linear, box-shadow 0.2s linear margin-top: 2em
&: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
>.content >.content
width: 100% width: 100%
.post-container .post-container
margin-bottom: 0.6em margin-bottom: 2em
.post-content .post-content
margin: 0 margin: 0
.after-mobile-controls
width: 100%
.darktheme .post-view .darktheme .post-view
>.sidebar, >.content >.sidebar
nav.buttons nav.buttons
article article
a:not(.inactive):hover a:not(.inactive):hover
@ -60,8 +56,6 @@
@media (max-width: 800px) @media (max-width: 800px)
.post-view .post-view
flex-wrap: wrap flex-wrap: wrap
>.after-mobile-controls
order: 3
>.sidebar >.sidebar
order: 2 order: 2
min-width: 100% min-width: 100%
@ -119,6 +113,7 @@
h1 h1
margin-bottom: 0.5em margin-bottom: 0.5em
.thumbnail .thumbnail
background-position: 50% 30%
width: 4em width: 4em
height: 3em height: 3em
li 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 .details
font-size: 90% font-size: 90%
line-height: 130% line-height: 130%
.image
margin: 0.25em 0.6em 0.25em 0
.thumbnail .thumbnail
width: 3em width: 3em
height: 3em height: 3em
margin: 0.25em 0.6em 0 0
.darktheme .user-list .darktheme .user-list
ul li 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> <td>Go to newer/older page or post</td>
</tr> </tr>
<tr>
<td><kbd>R</kbd></td>
<td>Go to random post</td>
</tr>
<tr> <tr>
<td><kbd>F</kbd></td> <td><kbd>F</kbd></td>
<td>Cycle post fit mode</td> <td>Cycle post fit mode</td>
@ -36,7 +41,7 @@ shortcuts:</p>
<tr> <tr>
<td><kbd>Delete</kbd></td> <td><kbd>Delete</kbd></td>
<td>Delete post (while in edit mode)</td> <td>(In edit mode) delete post</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View file

@ -2,7 +2,7 @@
<html> <html>
<head> <head>
<meta charset='utf-8'/> <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='theme-color' content='#24aadd'/>
<meta name='apple-mobile-web-app-capable' content='yes'/> <meta name='apple-mobile-web-app-capable' content='yes'/>
<meta name='apple-mobile-web-app-status-bar-style' content='black'/> <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> <span class='vim-nav-hint'>Next post &gt;</span>
</a> </a>
</article> </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'> <article class='edit-post'>
<% if (ctx.editMode) { %> <% if (ctx.editMode) { %>
<a href='<%= ctx.getPostUrl(ctx.post.id, ctx.parameters) %>'> <a href='<%= ctx.getPostUrl(ctx.post.id, ctx.parameters) %>'>
@ -37,13 +50,16 @@
<span class='vim-nav-hint'>Back to view mode</span> <span class='vim-nav-hint'>Back to view mode</span>
</a> </a>
<% } else { %> <% } else { %>
<a href='<%= ctx.getPostEditUrl(ctx.post.id, ctx.parameters) %>'> <% if (ctx.canEditPosts || ctx.canDeletePosts || ctx.canFeaturePosts) { %>
<i class='fa fa-pencil'></i> <a href='<%= ctx.getPostEditUrl(ctx.post.id, ctx.parameters) %>'>
<span class='vim-nav-hint'>Edit post</span> <% } else { %>
<a class='inactive'>
<% } %>
<i class='fa fa-pencil'></i>
<span class='vim-nav-hint'>Edit post</span>
</a> </a>
<% } %> <% } %>
</article> </article>
<% } %>
</nav> </nav>
<div class='sidebar-container'></div> <div class='sidebar-container'></div>
@ -52,15 +68,13 @@
<div class='content'> <div class='content'>
<div class='post-container'></div> <div class='post-container'></div>
<div class='after-mobile-controls'> <% if (ctx.canListComments) { %>
<% if (ctx.canCreateComments) { %> <div class='comments-container'></div>
<h2>Add comment</h2> <% } %>
<div class='comment-form-container'></div>
<% } %>
<% if (ctx.canListComments) { %> <% if (ctx.canCreateComments) { %>
<div class='comments-container'></div> <h2>Add comment</h2>
<% } %> <div class='comment-form-container'></div>
</div> <% } %>
</div> </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/mp4': 'MPEG-4',
'video/quicktime': 'MOV', 'video/quicktime': 'MOV',
'application/x-shockwave-flash': 'SWF', 'application/x-shockwave-flash': 'SWF',
}[ctx.post.mimeType] %><!-- }[ctx.post.mimeType] %>
--></a> </a>
(<%- ctx.post.canvasWidth %>x<%- ctx.post.canvasHeight %>) (<%- ctx.post.canvasWidth %>x<%- ctx.post.canvasHeight %>)
<% if (ctx.post.flags.length) { %><!-- <% if (ctx.post.flags.length) { %><!--
--><% if (ctx.post.flags.includes('loop')) { %><i class='fa fa-repeat'></i><% } %><!-- --><% if (ctx.post.flags.includes('loop')) { %><i class='fa fa-repeat'></i><% } %><!--
@ -99,10 +99,10 @@
--><% if (ctx.canListPosts) { %><!-- --><% if (ctx.canListPosts) { %><!--
--><a href='<%- ctx.formatClientLink('posts', {query: ctx.escapeTagName(tag.names[0])}) %>' class='<%= ctx.makeCssName(tag.category, 'tag') %>'><!-- --><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) { %><!-- --><% if (ctx.canListPosts) { %><!--
--></a><!-- --></a><!--
--><% } %>&#32;<!-- --><% } %><!--
--><span class='tag-usages' data-pseudo-content='<%- tag.postCount %>'></span><!-- --><span class='tag-usages' data-pseudo-content='<%- tag.postCount %>'></span><!--
--></li><!-- --></li><!--
--><% } %><!-- --><% } %><!--

View file

@ -4,6 +4,10 @@
%><wbr/><% %><wbr/><%
%><input class='mousetrap' type='submit' value='Search'/><% %><input class='mousetrap' type='submit' value='Search'/><%
%><wbr/><% %><wbr/><%
%><button id='randomize-button' class='icon-button'><%
%><i class="fa fa-random"><%
%></button><%
%><wbr/><%
%><% if (ctx.enableSafety) { %><% %><% if (ctx.enableSafety) { %><%
%><input data-safety=safe type='button' class='mousetrap safety safety-safe <%- ctx.settings.listPosts.safe ? '' : 'disabled' %>'/><% %><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' %>'/><% %><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'><% %><form class='horizontal bulk-edit bulk-edit-tags'><%
%><span class='append hint'>Tagging with:</span><% %><span class='append hint'>Tagging with:</span><%
%><a href class='mousetrap button append open'>Mass tag</a><% %><a href class='mousetrap button append open'>Mass tag</a><%
%><wbr/><%
%><%= ctx.makeTextInput({name: 'tag', value: ctx.parameters.tag}) %><% %><%= ctx.makeTextInput({name: 'tag', value: ctx.parameters.tag}) %><%
%><input class='mousetrap start' type='submit' value='Start tagging'/><% %><input class='mousetrap start' type='submit' value='Start tagging'/><%
%><a href class='mousetrap button append close'>Stop tagging</a><% %><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, pattern: ctx.passwordPattern,
}) %> }) %>
</li> </li>
<li>
<%= ctx.makeCodeInput({
text: 'Entry code',
name: 'code',
placeholder: 'Secret code',
required: true,
pattern: ctx.codePattern,
}) %>
</li>
<li> <li>
<%= ctx.makeEmailInput({ <%= ctx.makeEmailInput({
text: 'Email', text: 'Email',

View file

@ -15,6 +15,7 @@ class Api extends events.EventTarget {
this.user = null; this.user = null;
this.userName = null; this.userName = null;
this.userPassword = null; this.userPassword = null;
this.userCode = null;
this.token = null; this.token = null;
this.cache = {}; this.cache = {};
this.allRanks = [ this.allRanks = [
@ -92,6 +93,10 @@ class Api extends events.EventTarget {
return remoteConfig.passwordRegex; return remoteConfig.passwordRegex;
} }
getCodeRegex() {
return remoteConfig.codeRegex;
}
getUserNameRegex() { getUserNameRegex() {
return remoteConfig.userNameRegex; 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) { _evtUpdate(e) {
this._view.clearMessages(); this._view.clearMessages();
this._view.disableForm(); this._view.disableForm();
if (e.detail.names !== undefined && e.detail.names !== null) { if (e.detail.names !== undefined) {
e.detail.pool.names = e.detail.names; 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; 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; 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(); e.detail.pool.posts.clear();
for (let postId of e.detail.posts) { for (let postId of e.detail.posts) {
e.detail.pool.posts.add( e.detail.pool.posts.add(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -29,6 +29,7 @@ class UserRegistrationController {
user.name = e.detail.name; user.name = e.detail.name;
user.email = e.detail.email; user.email = e.detail.email;
user.password = e.detail.password; user.password = e.detail.password;
user.code = e.detail.code;
const isLoggedIn = api.isLoggedIn(); const isLoggedIn = api.isLoggedIn();
user.save() user.save()
.then(() => { .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() { _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(); this._currentFitFunction();
} }

View file

@ -427,7 +427,7 @@ class PostEditSidebarControl extends events.EventTarget {
: undefined, : undefined,
thumbnail: thumbnail:
this._newPostThumbnail !== undefined && this._newPostThumbnail !== null this._newPostThumbnail !== undefined
? this._newPostThumbnail ? this._newPostThumbnail
: undefined, : 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) { if (this._newContent) {
files.content = this._newContent; files.content = this._newContent;
} }
if (this._newThumbnail !== undefined && this._newThumbnail !== null) { if (this._newThumbnail !== undefined) {
files.thumbnail = this._newThumbnail; files.thumbnail = this._newThumbnail;
} }
if (this._source !== this._orig._source) { 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 return api
.get( .get(
uri.formatApiLink("posts", { uri.formatApiLink("posts", {
@ -24,6 +24,7 @@ class PostList extends AbstractList {
offset: offset, offset: offset,
limit: limit, limit: limit,
fields: fields.join(","), fields: fields.join(","),
cachenumber: cachenumber,
}) })
) )
.then((response) => { .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]; const touchEvent = evt.touches[0];
handler._xStart = touchEvent.clientX; handler._xStart = touchEvent.clientX;
handler._yStart = touchEvent.clientY; handler._yStart = touchEvent.clientY;
handler._startScrollY = window.scrollY;
} }
function handleTouchMove(handler, evt) { 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) { switch (handler._direction) {
case direction.NONE: case direction.NONE:
return; return;
case direction.LEFT: case direction.LEFT:
handler._swipeLeftTask(); handler._swipeLeftTask(evt);
break; break;
case direction.RIGHT: case direction.RIGHT:
handler._swipeRightTask(); handler._swipeRightTask(evt);
break; break;
case direction.DOWN: case direction.DOWN:
handler._swipeDownTask(); handler._swipeDownTask(evt);
break; break;
case direction.UP: case direction.UP:
handler._swipeUpTask(); handler._swipeUpTask(evt);
// no default
} }
handler._xStart = null; handler._xStart = null;
@ -76,15 +77,15 @@ class Touch {
this._yStart = null; this._yStart = null;
this._direction = direction.NONE; this._direction = direction.NONE;
this._target.addEventListener("touchstart", (evt) => { this._target.addEventListener('touchstart', (evt) =>
handleTouchStart(this, evt); { handleTouchStart(this, evt); }
}); );
this._target.addEventListener("touchmove", (evt) => { this._target.addEventListener('touchmove', (evt) =>
handleTouchMove(this, evt); { handleTouchMove(this, evt); }
}); );
this._target.addEventListener("touchend", () => { this._target.addEventListener('touchend', (evt) =>
handleTouchEnd(this); { handleTouchEnd(this, evt); }
}); );
} }
} }

View file

@ -136,6 +136,11 @@ function makePasswordInput(options) {
return makeInput(options); return makeInput(options);
} }
function makeCodeInput(options) {
options.type = "code";
return makeInput(options);
}
function makeEmailInput(options) { function makeEmailInput(options) {
options.type = "email"; options.type = "email";
return makeInput(options); return makeInput(options);
@ -209,13 +214,13 @@ function makePostLink(id, includeHash) {
} }
function makeTagLink(name, includeHash, includeCount, tag) { 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); let text = misc.getPrettyName(name);
if (includeHash === true) { if (includeHash === true) {
text = "#" + text; text = "#" + text;
} }
if (includeCount === true) { if (includeCount === true) {
text += " (" + (tag && tag.postCount ? tag.postCount : 0) + ")"; text += " (" + (tag ? tag.postCount : 0) + ")";
} }
return api.hasPrivilege("tags:view") return api.hasPrivilege("tags:view")
? makeElement( ? makeElement(
@ -234,15 +239,15 @@ function makeTagLink(name, includeHash, includeCount, tag) {
} }
function makePoolLink(id, includeHash, includeCount, pool, name) { 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( let text = misc.getPrettyName(
name ? name : pool && pool.names ? pool.names[0] : "pool " + id name ? name : pool ? pool.names[0] : "unknown"
); );
if (includeHash === true) { if (includeHash === true) {
text = "#" + text; text = "#" + text;
} }
if (includeCount === true) { if (includeCount === true) {
text += " (" + (pool && pool.postCount ? pool.postCount : 0) + ")"; text += " (" + (pool ? pool.postCount : 0) + ")";
} }
return api.hasPrivilege("pools:view") return api.hasPrivilege("pools:view")
? makeElement( ? makeElement(
@ -264,7 +269,7 @@ function makeUserLink(user) {
let text = makeThumbnail(user ? user.avatarUrl : null); let text = makeThumbnail(user ? user.avatarUrl : null);
text += user && user.name ? misc.escapeHtml(user.name) : "Anonymous"; text += user && user.name ? misc.escapeHtml(user.name) : "Anonymous";
const link = const link =
user && user.name && api.hasPrivilege("users:view") user && api.hasPrivilege("users:view")
? makeElement( ? makeElement(
"a", "a",
{ href: uri.formatClientLink("user", user.name) }, { href: uri.formatClientLink("user", user.name) },
@ -444,6 +449,7 @@ function getTemplate(templatePath) {
makeTextarea: makeTextarea, makeTextarea: makeTextarea,
makeTextInput: makeTextInput, makeTextInput: makeTextInput,
makePasswordInput: makePasswordInput, makePasswordInput: makePasswordInput,
makeCodeInput: makeCodeInput,
makeEmailInput: makeEmailInput, makeEmailInput: makeEmailInput,
makeColorInput: makeColorInput, makeColorInput: makeColorInput,
makeDateInput: makeDateInput, 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", () => { keyboard.bind("e", () => {
if (ctx.editMode) { if (ctx.editMode) {
router.show(uri.formatClientLink("post", ctx.post.id)); router.show(uri.formatClientLink("post", ctx.post.id));
@ -95,6 +101,7 @@ class PostMainView {
}); });
keyboard.bind(["a", "left"], showPreviousImage); keyboard.bind(["a", "left"], showPreviousImage);
keyboard.bind(["d", "right"], showNextImage); keyboard.bind(["d", "right"], showNextImage);
keyboard.bind("r", showRandomImage);
keyboard.bind("del", (e) => { keyboard.bind("del", (e) => {
if (ctx.editMode) { if (ctx.editMode) {
this.sidebarControl._evtDeleteClick(e); this.sidebarControl._evtDeleteClick(e);
@ -105,15 +112,21 @@ class PostMainView {
postContainerNode, postContainerNode,
() => { () => {
if (!ctx.editMode) { if (!ctx.editMode) {
showPreviousImage(); showNextImage()
} }
}, },
() => { () => {
if (!ctx.editMode) { if (!ctx.editMode) {
showNextImage(); showPreviousImage()
}
},
() => {},
(e) => {
if (!ctx.editMode && e.startScrollY === 0) {
showRandomImage()
} }
} }
); )
} }
_installSidebar(ctx) { _installSidebar(ctx) {

View file

@ -197,8 +197,11 @@ class PostsHeaderView extends events.EventTarget {
this._evtSafetyButtonClick(e) 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 = []; this._bulkEditors = [];
@ -256,6 +259,10 @@ class PostsHeaderView extends events.EventTarget {
return this._hostNode.querySelector("form [name=search-text]"); return this._hostNode.querySelector("form [name=search-text]");
} }
get _randomizeButtonNode() {
return this._hostNode.querySelector('#randomize-button');
}
get _bulkEditTagsNode() { get _bulkEditTagsNode() {
return this._hostNode.querySelector(".bulk-edit-tags"); return this._hostNode.querySelector(".bulk-edit-tags");
} }
@ -314,9 +321,21 @@ class PostsHeaderView extends events.EventTarget {
this._navigate(); 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() { _navigate() {
this._autoCompleteControl.hide(); 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 // convert falsy values to an empty string "" so that we can correctly compare with the current query
const prevQuery = this._ctx.parameters.query const prevQuery = this._ctx.parameters.query

View file

@ -15,6 +15,7 @@ class RegistrationView extends events.EventTarget {
template({ template({
userNamePattern: api.getUserNameRegex(), userNamePattern: api.getUserNameRegex(),
passwordPattern: api.getPasswordRegex(), passwordPattern: api.getPasswordRegex(),
codePattern: api.getCodeRegex(),
}) })
); );
views.syncScrollPosition(); views.syncScrollPosition();
@ -45,6 +46,7 @@ class RegistrationView extends events.EventTarget {
detail: { detail: {
name: this._userNameFieldNode.value, name: this._userNameFieldNode.value,
password: this._passwordFieldNode.value, password: this._passwordFieldNode.value,
code: this._codeFieldNode.value,
email: this._emailFieldNode.value, email: this._emailFieldNode.value,
}, },
}) })
@ -63,6 +65,10 @@ class RegistrationView extends events.EventTarget {
return this._formNode.querySelector("[name=password]"); return this._formNode.querySelector("[name=password]");
} }
get _codeFieldNode() {
return this._formNode.querySelector("[name=code]");
}
get _emailFieldNode() { get _emailFieldNode() {
return this._formNode.querySelector("[name=email]"); 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) - [Creating post](#creating-post)
- [Updating post](#updating-post) - [Updating post](#updating-post)
- [Getting post](#getting-post) - [Getting post](#getting-post)
- [Getting around post](#getting-around-post)
- [Deleting post](#deleting-post) - [Deleting post](#deleting-post)
- [Merging posts](#merging-posts) - [Merging posts](#merging-posts)
- [Rating post](#rating-post) - [Rating post](#rating-post)
@ -54,7 +53,7 @@
- [Deleting pool category](#deleting-pool-category) - [Deleting pool category](#deleting-pool-category)
- [Setting default pool category](#setting-default-pool-category) - [Setting default pool category](#setting-default-pool-category)
- Pools - Pools
- [Listing pools](#listing-pools) - [Listing pools](#listing-pool)
- [Creating pool](#creating-pool) - [Creating pool](#creating-pool)
- [Updating pool](#updating-pool) - [Updating pool](#updating-pool)
- [Getting pool](#getting-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 accepts a file named `content`, the client should pass
`{"contentUrl":"http://example.com/file.jpg"}` as a part of the JSON message `{"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 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 also be configured to employ [youtube-dl](https://github.com/ytdl-org/youtube-dl)
download content from popular sites such as youtube, gfycat, etc. Access to to download content from popular sites such as youtube, gfycat, etc. Access to
yt-dlp can be configured with the `'uploads:use_downloader'` permission 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 Finally, in some cases the user might want to reuse one file between the
requests to save the bandwidth (for example, reverse search + consecutive requests to save the bandwidth (for example, reverse search + consecutive
@ -323,7 +322,7 @@ data.
{ {
"name": <name>, "name": <name>,
"color": <color>, "color": <color>,
"order": <order> "order": <order> // optional
} }
``` ```
@ -952,29 +951,6 @@ data.
Retrieves information about an existing post. 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 ## Deleting post
- **Request** - **Request**
@ -1389,7 +1365,7 @@ data.
## Creating pool ## Creating pool
- **Request** - **Request**
`POST /pool` `POST /pools/create`
- **Input** - **Input**
@ -2491,7 +2467,7 @@ One file together with its metadata posted to the site.
## Micro post ## Micro post
**Description** **Description**
A [post resource](#post) stripped down to `id` and `thumbnailUrl` fields. A [post resource](#post) stripped down to `name` and `thumbnailUrl` fields.
## Note ## Note
**Description** **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 Read the comments to guide you. Note that `.env` should be in the root
directory of this repository. directory of this repository.
4. Pull the containers: ### Running the Application
This pulls the latest containers from docker.io: Download containers:
```console ```console
user@host:szuru$ docker-compose pull user@host:szuru$ docker-compose pull
``` ```
If you have modified the application's source and would like to manually For first run, it is recommended to start the database separately:
build it, follow the instructions in [**Building**](#Building) instead, ```console
then read here once you're done. 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: To view/monitor the application logs:
```console ```console
user@host:szuru$ docker-compose up -d sql user@host:szuru$ docker-compose logs -f
``` # (CTRL+C to exit)
```
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 stop all containers:
```console
user@host:szuru$ docker-compose down
```
### Additional Features ### Additional Features

View file

@ -10,12 +10,6 @@ BUILD_INFO=latest
# otherwise the port specified here will be publicly accessible # otherwise the port specified here will be publicly accessible
PORT=8080 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 # URL base to run szurubooru under
# See "Additional Features" section in INSTALL.md # See "Additional Features" section in INSTALL.md
BASE_URL=/ 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 ## Use this as a template to set up docker-compose, or as guide to set up other
## orchestration services ## orchestration services
version: '2' version: '3'
services: services:
server: server:
image: szurubooru/server:latest # image: szurubooru/server:latest
depends_on: build:
- sql context: ./server
# depends_on:
# - sql
environment: environment:
## These should be the names of the dependent containers listed below, ## These should be the names of the dependent containers listed below,
## or FQDNs/IP addresses if these services are running outside of Docker ## or FQDNs/IP addresses if these services are running outside of Docker
POSTGRES_HOST: sql POSTGRES_HOST: 192.168.1.17
## Credentials for database: ## Credentials for database:
POSTGRES_USER: POSTGRES_USER: stash_u
POSTGRES_PASSWORD: POSTGRES_PASSWORD: tGmwRgEHbziNNEdsvcXynudFayVayzBybFvmMw
## Commented Values are Default: ## Commented Values are Default:
#POSTGRES_DB: defaults to same as POSTGRES_USER POSTGRES_DB: stash_db
#POSTGRES_PORT: 5432 POSTGRES_PORT: 5432
#LOG_SQL: 0 (1 for verbose SQL logs) #LOG_SQL: 0 (1 for verbose SQL logs)
THREADS: THREADS:
volumes: volumes:
- "${MOUNT_DATA}:/data" - "${MOUNT_DATA}:/data"
- "./server/config.yaml:/opt/app/config.yaml" - "./server/config.yaml:/opt/app/config.yaml"
- "${MOUNT_SQL}:/var/lib/postgresql/data"
networks:
- br0
client: client:
image: szurubooru/client:latest # image: szurubooru/client:latest
build:
context: ./client
depends_on: depends_on:
- server - server
environment: environment:
@ -36,13 +43,22 @@ services:
volumes: volumes:
- "${MOUNT_DATA}:/data:ro" - "${MOUNT_DATA}:/data:ro"
ports: ports:
- "${PORT}:80" - "${PORT}:8069"
networks:
- br0
sql: #sql:
image: postgres:11-alpine # image: postgres:11-alpine
restart: unless-stopped #restart: unless-stopped
environment: #environment:
POSTGRES_USER: # POSTGRES_USER: stash_u
POSTGRES_PASSWORD: #POSTGRES_PASSWORD: TeKeuubttpyLtYHtkdvixgvyKqvfmeWuSkXcKp
volumes: #volumes:
- "${MOUNT_SQL}:/var/lib/postgresql/data" # - "${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-pynacl \
py3-tz \ py3-tz \
py3-pyrfc3339 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" \ "alembic>=0.8.5" \
"coloredlogs==5.0" \ "coloredlogs==5.0" \
"pyheif==0.6.1" \ "pyheif==0.6.1" \
"heif-image-plugin>=0.3.2" \ "heif-image-plugin>=0.3.2" \
yt-dlp \ youtube_dl \
"pillow-avif-plugin~=1.1.0" "pillow-avif-plugin>=1.1.0"
RUN apk --no-cache del py3-pip
# Debugging: Print the installed packages
RUN apk list --installed
COPY ./ /opt/app/ COPY ./ /opt/app/
RUN rm -rf /opt/app/szurubooru/tests RUN rm -rf /opt/app/szurubooru/tests
@ -75,6 +82,8 @@ RUN apk --no-cache add \
&& addgroup -g ${PGID} app \ && addgroup -g ${PGID} app \
&& adduser -SDH -h /opt/app -g '' -G app -u ${PUID} app \ && adduser -SDH -h /opt/app -g '' -G app -u ${PUID} app \
&& chown -R app:app /opt/app /data && chown -R app:app /opt/app /data
RUN chmod +x /opt/app/docker-start.sh
USER app USER app
CMD ["/opt/app/docker-start.sh"] 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 set -e
cd /opt/app cd /opt/app
alembic upgrade head alembic upgrade heads
echo "Starting szurubooru API on port ${PORT} - Running on ${THREADS} threads" echo "Starting szurubooru API on port ${PORT}"
exec waitress-serve-3 --port ${PORT} --threads ${THREADS} szurubooru.facade:app 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 coloredlogs==5.0
heif-image-plugin==0.3.2 heif-image-plugin==0.3.2
numpy>=1.8.2 numpy>=1.8.2
pillow-avif-plugin~=1.1.0 pillow-avif-plugin>=1.1.0
pillow>=4.3.0 pillow>=4.3.0
psycopg2-binary>=2.6.1 psycopg2-binary>=2.6.1
pyheif==0.6.1 pyheif==0.6.1
@ -12,4 +12,4 @@ pyRFC3339>=1.0
pytz>=2018.3 pytz>=2018.3
pyyaml>=3.11 pyyaml>=3.11
SQLAlchemy>=1.0.12, <1.4 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: 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"]: if config.config["user_agent"]:
cmd.extend(["--user-agent", config.config["user_agent"]]) cmd.extend(["--user-agent", config.config["user_agent"]])
cmd.extend(["--get-url", url]) 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() self.parser = parser.Parser()
def get_around( def get_around(
self, query_text: str, entity_id: int self, query_text: str, entity_id: int) -> Tuple[model.Base, model.Base, model.Base]:
) -> Tuple[model.Base, model.Base]:
search_query = self.parser.parse(query_text) search_query = self.parser.parse(query_text)
self.config.on_search_query_parsed(search_query) self.config.on_search_query_parsed(search_query)
filter_query = self.config.create_around_query().options( filter_query = self.config.create_around_query().options(
@ -57,11 +56,15 @@ class Executor:
filter_query.filter(self.config.id_column < entity_id) filter_query.filter(self.config.id_column < entity_id)
.order_by(None) .order_by(None)
.order_by(sa.func.abs(self.config.id_column - entity_id).asc()) .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 ( return (
prev_filter_query.one_or_none(), prev_filter_query.one_or_none(),
next_filter_query.one_or_none(), next_filter_query.one_or_none(),
random_entities[0] if random_entities else None
) )
def get_around_and_serialize( def get_around_and_serialize(
@ -76,6 +79,7 @@ class Executor:
return { return {
"prev": serializer(entities[0]), "prev": serializer(entities[0]),
"next": serializer(entities[1]), "next": serializer(entities[1]),
'random': serializer(entities[2]),
} }
def execute( def execute(
@ -94,7 +98,7 @@ class Executor:
disable_eager_loads = True disable_eager_loads = True
key = (id(self.config), hash(search_query), offset, limit) 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) return cache.get(key)
filter_query = self.config.create_filter_query(disable_eager_loads) 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)