diff --git a/.gitignore b/.gitignore
index b21e3adf..ec86ed25 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,7 @@ server/**/lib/
server/**/bin/
server/**/pyvenv.cfg
__pycache__/
+
+# IDE files
+.idea
+*.iml
\ No newline at end of file
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 75a55d62..5053b6e0 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,29 +1,28 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v4.4.0
+ rev: v3.2.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: mixed-line-ending
-
- repo: https://github.com/Lucas-C/pre-commit-hooks
- rev: v1.4.2
+ rev: v1.1.9
hooks:
- id: remove-tabs
- repo: https://github.com/psf/black
- rev: '23.1.0'
+ rev: 20.8b1
hooks:
- id: black
files: 'server/'
types: [python]
- language_version: python3.9
+ language_version: python3.8
-- repo: https://github.com/PyCQA/isort
- rev: '5.12.0'
+- repo: https://github.com/timothycrosley/isort
+ rev: '5.4.2'
hooks:
- id: isort
files: 'server/'
@@ -32,8 +31,8 @@ repos:
additional_dependencies:
- toml
-- repo: https://github.com/pre-commit/mirrors-prettier
- rev: v2.7.1
+- repo: https://github.com/prettier/prettier
+ rev: '2.1.1'
hooks:
- id: prettier
files: client/js/
@@ -41,7 +40,7 @@ repos:
args: ['--config', 'client/.prettierrc.yml']
- repo: https://github.com/pre-commit/mirrors-eslint
- rev: v8.33.0
+ rev: v7.8.0
hooks:
- id: eslint
files: client/js/
@@ -49,8 +48,8 @@ repos:
additional_dependencies:
- eslint-config-prettier
-- repo: https://github.com/PyCQA/flake8
- rev: '6.0.0'
+- repo: https://gitlab.com/pycqa/flake8
+ rev: '3.8.3'
hooks:
- id: flake8
files: server/szurubooru/
@@ -58,5 +57,44 @@ repos:
- flake8-print
args: ['--config=server/.flake8']
+- repo: local
+ hooks:
+ - id: docker-build-client
+ name: Docker - build client
+ entry: bash -c 'docker build client/'
+ language: system
+ types: [file]
+ files: client/
+ pass_filenames: false
+
+ - id: docker-build-server
+ name: Docker - build server
+ entry: bash -c 'docker build server/'
+ language: system
+ types: [file]
+ files: server/
+ pass_filenames: false
+
+ - id: pytest
+ name: pytest
+ entry: bash -c 'docker run --rm -t $(docker build --target testing -q server/) szurubooru/'
+ language: system
+ types: [python]
+ files: server/szurubooru/
+ exclude: server/szurubooru/migrations/
+ pass_filenames: false
+ stages: [push]
+
+ - id: pytest-cov
+ name: pytest
+ entry: bash -c 'docker run --rm -t $(docker build --target testing -q server/) --cov-report=term-missing:skip-covered --cov=szurubooru szurubooru/'
+ language: system
+ types: [python]
+ files: server/szurubooru/
+ exclude: server/szurubooru/migrations/
+ pass_filenames: false
+ verbose: true
+ stages: [manual]
+
fail_fast: true
exclude: LICENSE.md
diff --git a/README.md b/README.md
index 6a38501c..a86ef795 100644
--- a/README.md
+++ b/README.md
@@ -3,12 +3,12 @@
Szurubooru is an image board engine inspired by services such as Danbooru,
Gelbooru and Moebooru dedicated for small and medium communities. Its name [has
its roots in Polish language and has onomatopeic meaning of scraping or
-scrubbing](https://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
+scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
## Features
- Post content: images (JPG, PNG, GIF, animated GIF), videos (MP4, WEBM), Flash animations
-- Ability to retrieve web video content using [yt-dlp](https://github.com/yt-dlp/yt-dlp)
+- Ability to retrieve web video content using [youtube-dl](https://github.com/ytdl-org/youtube-dl)
- Post comments
- Post notes / annotations, including arbitrary polygons
- Rich JSON REST API ([see documentation](doc/API.md))
diff --git a/client/Dockerfile b/client/Dockerfile
index ea5151fa..3490e7ea 100644
--- a/client/Dockerfile
+++ b/client/Dockerfile
@@ -27,6 +27,8 @@ FROM nginx:alpine as release
RUN apk --no-cache add dumb-init
COPY --from=approot / /
+RUN chmod +x /docker-start.sh
+
CMD ["/docker-start.sh"]
VOLUME ["/data"]
diff --git a/client/Dockerfile - Copy b/client/Dockerfile - Copy
new file mode 100644
index 00000000..e51bf646
--- /dev/null
+++ b/client/Dockerfile - Copy
@@ -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"
diff --git a/client/build - Copy.js b/client/build - Copy.js
new file mode 100644
index 00000000..51ef643d
--- /dev/null
+++ b/client/build - Copy.js
@@ -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('', ` `);
+ 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();
+ }
+}
diff --git a/client/css/comment-control.styl b/client/css/comment-control.styl
index 21bbf728..0d5959bb 100644
--- a/client/css/comment-control.styl
+++ b/client/css/comment-control.styl
@@ -127,10 +127,6 @@ $comment-border-color = #DDD
color: mix($main-color, $inactive-link-color-darktheme)
.comment-content
- p
- word-wrap: normal
- word-break: break-all
-
ul, ol
list-style-position: inside
margin: 1em 0
diff --git a/client/css/core-general.styl b/client/css/core-general.styl
index d25c5f6e..7e5883aa 100644
--- a/client/css/core-general.styl
+++ b/client/css/core-general.styl
@@ -300,10 +300,10 @@ a .access-key
background-size: 20px 20px
img
opacity: 0
- width: 100%
+ width: auto
height: 100%
video
- width: 100%
+ width: auto
height: 100%
.flexbox-dummy
diff --git a/client/css/metric-sorter-view.styl b/client/css/metric-sorter-view.styl
new file mode 100644
index 00000000..5388c6e7
--- /dev/null
+++ b/client/css/metric-sorter-view.styl
@@ -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
diff --git a/client/css/post-list-view.styl b/client/css/post-list-view.styl
index e4f75d5d..ba3b0dfa 100644
--- a/client/css/post-list-view.styl
+++ b/client/css/post-list-view.styl
@@ -56,8 +56,8 @@
.edit-overlay
position: absolute
- top: 0.5em
- left: 0.5em
+ top: 0em
+ left: 0em
.tag-flipper
display: inline-block
@@ -116,7 +116,7 @@
.delete-flipper
display: inline-block
- padding: 0.5em
+ padding: 10em
box-sizing: border-box
border: 0
&:after
@@ -133,7 +133,7 @@
font-family: FontAwesome;
content: "\f1f8"; // fa-trash
&:not(.delete)
- background: rgba(200, 200, 200, 0.7)
+ background: rgba(200, 200, 200, 0.2)
&:after
color: white
content: '-'
@@ -187,9 +187,6 @@
vertical-align: top
@media (max-width: 1000px)
display: block
- &.bulk-edit-tags:not(.opened), &.bulk-edit-safety:not(.opened)
- float: left
- margin-right: 1em
input
margin-bottom: 0.25em
margin-right: 0.25em
diff --git a/client/css/post-main-view.styl b/client/css/post-main-view.styl
index 1183cce2..7b75606c 100644
--- a/client/css/post-main-view.styl
+++ b/client/css/post-main-view.styl
@@ -15,42 +15,38 @@
border: 0
outline: 0
- >.sidebar>nav.buttons, >.content nav.buttons
- margin-top: 0
- display: flex
- flex-wrap: wrap
- article
- flex: 1 0 33%
- a
- display: inline-block
- width: 100%
- padding: 0.3em 0
+ nav.buttons
+ margin-top: 0
+ display: flex
+ flex-wrap: wrap
+ article
+ flex: 1 0 25%
+ a
+ display: inline-block
+ width: 100%
+ padding: 0.3em 0
+ text-align: center
+ vertical-align: middle
+ transition: background 0.2s linear, box-shadow 0.2s linear
+ &:not(.inactive):hover
+ background: lighten($main-color, 90%)
+ i
+ font-size: 140%
text-align: center
- vertical-align: middle
- transition: background 0.2s linear, box-shadow 0.2s linear
- &:not(.inactive):hover
- background: lighten($main-color, 90%)
- i
- font-size: 140%
- text-align: center
- @media (max-width: 800px)
- margin-top: 0.6em
- margin-bottom: 0.6em
+ @media (max-width: 800px)
+ margin-top: 2em
>.content
width: 100%
.post-container
- margin-bottom: 0.6em
+ margin-bottom: 2em
.post-content
margin: 0
- .after-mobile-controls
- width: 100%
-
.darktheme .post-view
- >.sidebar, >.content
+ >.sidebar
nav.buttons
article
a:not(.inactive):hover
@@ -60,8 +56,6 @@
@media (max-width: 800px)
.post-view
flex-wrap: wrap
- >.after-mobile-controls
- order: 3
>.sidebar
order: 2
min-width: 100%
@@ -119,6 +113,7 @@
h1
margin-bottom: 0.5em
.thumbnail
+ background-position: 50% 30%
width: 4em
height: 3em
li
diff --git a/client/css/post-metric-input.styl b/client/css/post-metric-input.styl
new file mode 100644
index 00000000..2d4046ba
--- /dev/null
+++ b/client/css/post-metric-input.styl
@@ -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
diff --git a/client/css/settings.styl b/client/css/settings.styl
new file mode 100644
index 00000000..95e037ba
--- /dev/null
+++ b/client/css/settings.styl
@@ -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
\ No newline at end of file
diff --git a/client/css/user-list-view.styl b/client/css/user-list-view.styl
index 64915b4d..f1569f8f 100644
--- a/client/css/user-list-view.styl
+++ b/client/css/user-list-view.styl
@@ -21,11 +21,10 @@
.details
font-size: 90%
line-height: 130%
- .image
- margin: 0.25em 0.6em 0.25em 0
.thumbnail
width: 3em
height: 3em
+ margin: 0.25em 0.6em 0 0
.darktheme .user-list
ul li
diff --git a/client/hooks/build b/client/hooks/build
new file mode 100644
index 00000000..46443f40
--- /dev/null
+++ b/client/hooks/build
@@ -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 .
diff --git a/client/hooks/post_push b/client/hooks/post_push
new file mode 100644
index 00000000..1b1e0ad9
--- /dev/null
+++ b/client/hooks/post_push
@@ -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
diff --git a/client/html/compact_metric_list_item.tpl b/client/html/compact_metric_list_item.tpl
new file mode 100644
index 00000000..ddddc33b
--- /dev/null
+++ b/client/html/compact_metric_list_item.tpl
@@ -0,0 +1,26 @@
+
<% if (ctx.editMode) { %><%- ctx.tag.names[0] %> — <%- ctx.tag.metric.max %>'> Set exact range sort <% } %>
diff --git a/client/html/compact_post_metric_list_item.tpl b/client/html/compact_post_metric_list_item.tpl
new file mode 100644
index 00000000..aa6da3fe
--- /dev/null
+++ b/client/html/compact_post_metric_list_item.tpl
@@ -0,0 +1,32 @@
+<% if (ctx.editMode) { %> <%- ctx.postMetric.tagName %>: <%= ctx.makeNumericInput({
+ name: 'value',
+ value: ctx.postMetric.value,
+ step: 'any',
+ min: ctx.tag.metric.min,
+ max: ctx.tag.metric.max,
+ }) %><% } else { %><%- ctx.postMetric.tagName %>: <%- ctx.postMetric.value || 0 %> <% } %>
diff --git a/client/html/compact_post_metric_range_list_item.tpl b/client/html/compact_post_metric_range_list_item.tpl
new file mode 100644
index 00000000..fd4c40e5
--- /dev/null
+++ b/client/html/compact_post_metric_range_list_item.tpl
@@ -0,0 +1,42 @@
+<% if (ctx.editMode) { %> <%- ctx.postMetricRange.tagName %>: <%= ctx.makeNumericInput({
+ name: 'low',
+ value: ctx.postMetricRange.low,
+ step: 'any',
+ min: ctx.tag.metric.min,
+ max: ctx.tag.metric.max,
+ }) %>— <%= ctx.makeNumericInput({
+ name: 'high',
+ value: ctx.postMetricRange.high,
+ step: 'any',
+ min: ctx.tag.metric.min,
+ max: ctx.tag.metric.max,
+ }) %><% } else { %><%- ctx.postMetricRange.tagName %>:
+ <%- ctx.postMetricRange.low || 0 %> — <%- ctx.postMetricRange.high || 0 %> <% } %>
diff --git a/client/html/help_keyboard.tpl b/client/html/help_keyboard.tpl
index f200ce02..6d919bd2 100644
--- a/client/html/help_keyboard.tpl
+++ b/client/html/help_keyboard.tpl
@@ -19,6 +19,11 @@ shortcuts:
Go to newer/older page or post
+
+ R
+ Go to random post
+
+
F
Cycle post fit mode
@@ -36,7 +41,7 @@ shortcuts:
Delete
- Delete post (while in edit mode)
+ (In edit mode) delete post
diff --git a/client/html/index.htm b/client/html/index.htm
index 2f0f4e40..00728903 100644
--- a/client/html/index.htm
+++ b/client/html/index.htm
@@ -2,7 +2,7 @@
-
+
diff --git a/client/html/metric_header.tpl b/client/html/metric_header.tpl
new file mode 100644
index 00000000..a711575d
--- /dev/null
+++ b/client/html/metric_header.tpl
@@ -0,0 +1,11 @@
+
diff --git a/client/html/metric_header_item.tpl b/client/html/metric_header_item.tpl
new file mode 100644
index 00000000..880dfedb
--- /dev/null
+++ b/client/html/metric_header_item.tpl
@@ -0,0 +1,6 @@
+
+ <%
+ %><%- ctx.metric.tag.names[0] %>
+
\ No newline at end of file
diff --git a/client/html/metric_sorter.tpl b/client/html/metric_sorter.tpl
new file mode 100644
index 00000000..3023fbd4
--- /dev/null
+++ b/client/html/metric_sorter.tpl
@@ -0,0 +1,39 @@
+
+
Sorting metric "<%- ctx.primaryMetric %>"
+
+
diff --git a/client/html/metric_sorter_side.tpl b/client/html/metric_sorter_side.tpl
new file mode 100644
index 00000000..8d0b010f
--- /dev/null
+++ b/client/html/metric_sorter_side.tpl
@@ -0,0 +1,5 @@
+<% if (ctx.post) { %>
+
+
+
+<% } %>
diff --git a/client/html/post_main.tpl b/client/html/post_main.tpl
index 84e48b1c..7bf31cc4 100644
--- a/client/html/post_main.tpl
+++ b/client/html/post_main.tpl
@@ -29,7 +29,20 @@
Next post >
- <% if (ctx.canEditPosts || ctx.canDeletePosts || ctx.canFeaturePosts) { %>
+
+ <% if (ctx.randomPostId) { %>
+ <% if (ctx.editMode) { %>
+
+ <% } else { %>
+
+ <% } %>
+ <% } else { %>
+
+ <% } %>
+
+ Random post
+
+
<% if (ctx.editMode) { %>
@@ -37,13 +50,16 @@
Back to view mode
<% } else { %>
-
-
- Edit post
+ <% if (ctx.canEditPosts || ctx.canDeletePosts || ctx.canFeaturePosts) { %>
+
+ <% } else { %>
+
+ <% } %>
+
+ Edit post
<% } %>
- <% } %>
@@ -52,15 +68,13 @@
-
- <% if (ctx.canCreateComments) { %>
-
Add comment
-
- <% } %>
+ <% if (ctx.canListComments) { %>
+
+ <% } %>
- <% if (ctx.canListComments) { %>
-
- <% } %>
-
+ <% if (ctx.canCreateComments) { %>
+
Add comment
+
+ <% } %>
diff --git a/client/html/post_metric_input.tpl b/client/html/post_metric_input.tpl
new file mode 100644
index 00000000..b3240629
--- /dev/null
+++ b/client/html/post_metric_input.tpl
@@ -0,0 +1,5 @@
+
diff --git a/client/html/post_readonly_sidebar.tpl b/client/html/post_readonly_sidebar.tpl
index 60efc26a..0f93ae3b 100644
--- a/client/html/post_readonly_sidebar.tpl
+++ b/client/html/post_readonly_sidebar.tpl
@@ -17,8 +17,8 @@
'video/mp4': 'MPEG-4',
'video/quicktime': 'MOV',
'application/x-shockwave-flash': 'SWF',
- }[ctx.post.mimeType] %>
+ }[ctx.post.mimeType] %>
+
(<%- ctx.post.canvasWidth %>x<%- ctx.post.canvasHeight %>)
<% if (ctx.post.flags.length) { %><% if (ctx.post.flags.includes('loop')) { %> <% } %><% if (ctx.canListPosts) { %>' class='<%= ctx.makeCssName(tag.category, 'tag') %>'><% } %><%- ctx.getPrettyName(tag.names[0]) %><%- ctx.getPrettyName(tag.names[0]) %> <% if (ctx.canListPosts) { %> <% } %> <% } %> <% } %><% if (!ctx.tag.metric && ctx.canCreateMetric) { %> <% } else if (ctx.tag.metric && ctx.canEditMetricBounds) { %> <% } %><% if (ctx.tag.metric && ctx.canDeleteMetric) { %> <% } %>
+
+
+
diff --git a/client/html/user_registration.tpl b/client/html/user_registration.tpl
index a6d291f4..debaa57d 100644
--- a/client/html/user_registration.tpl
+++ b/client/html/user_registration.tpl
@@ -23,6 +23,15 @@
pattern: ctx.passwordPattern,
}) %>
+
+ <%= ctx.makeCodeInput({
+ text: 'Entry code',
+ name: 'code',
+ placeholder: 'Secret code',
+ required: true,
+ pattern: ctx.codePattern,
+ }) %>
+
<%= ctx.makeEmailInput({
text: 'Email',
diff --git a/client/js/api.js b/client/js/api.js
index 5bde6d81..6cc091ab 100644
--- a/client/js/api.js
+++ b/client/js/api.js
@@ -15,6 +15,7 @@ class Api extends events.EventTarget {
this.user = null;
this.userName = null;
this.userPassword = null;
+ this.userCode = null;
this.token = null;
this.cache = {};
this.allRanks = [
@@ -92,6 +93,10 @@ class Api extends events.EventTarget {
return remoteConfig.passwordRegex;
}
+ getCodeRegex() {
+ return remoteConfig.codeRegex;
+ }
+
getUserNameRegex() {
return remoteConfig.userNameRegex;
}
diff --git a/client/js/controllers/metric_sorter_contoller.js b/client/js/controllers/metric_sorter_contoller.js
new file mode 100644
index 00000000..e7a8d761
--- /dev/null
+++ b/client/js/controllers/metric_sorter_contoller.js
@@ -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);
+ });
+};
diff --git a/client/js/controllers/pool_controller.js b/client/js/controllers/pool_controller.js
index f49827fe..ad0b9561 100644
--- a/client/js/controllers/pool_controller.js
+++ b/client/js/controllers/pool_controller.js
@@ -91,16 +91,16 @@ class PoolController {
_evtUpdate(e) {
this._view.clearMessages();
this._view.disableForm();
- if (e.detail.names !== undefined && e.detail.names !== null) {
+ if (e.detail.names !== undefined) {
e.detail.pool.names = e.detail.names;
}
- if (e.detail.category !== undefined && e.detail.category !== null) {
+ if (e.detail.category !== undefined) {
e.detail.pool.category = e.detail.category;
}
- if (e.detail.description !== undefined && e.detail.description !== null) {
+ if (e.detail.description !== undefined) {
e.detail.pool.description = e.detail.description;
}
- if (e.detail.posts !== undefined && e.detail.posts !== null) {
+ if (e.detail.posts !== undefined) {
e.detail.pool.posts.clear();
for (let postId of e.detail.posts) {
e.detail.pool.posts.add(
diff --git a/client/js/controllers/pool_list_controller.js b/client/js/controllers/pool_list_controller.js
index a66f8163..91d655c5 100644
--- a/client/js/controllers/pool_list_controller.js
+++ b/client/js/controllers/pool_list_controller.js
@@ -43,8 +43,6 @@ class PoolListController {
this._headerView.addEventListener(
"submit",
(e) => this._evtSubmit(e),
- );
- this._headerView.addEventListener(
"navigate",
(e) => this._evtNavigate(e)
);
diff --git a/client/js/controllers/post_list_controller.js b/client/js/controllers/post_list_controller.js
index fdb7b844..80348619 100644
--- a/client/js/controllers/post_list_controller.js
+++ b/client/js/controllers/post_list_controller.js
@@ -147,10 +147,11 @@ class PostListController {
},
requestPage: (offset, limit) => {
return PostList.search(
- this._ctx.parameters.query,
- offset,
- limit,
- fields
+ this._ctx.parameters.query,
+ offset,
+ limit,
+ fields,
+ this._ctx.parameters.cachenumber
);
},
pageRenderer: (pageCtx) => {
diff --git a/client/js/controllers/post_main_controller.js b/client/js/controllers/post_main_controller.js
index bd338129..617418d5 100644
--- a/client/js/controllers/post_main_controller.js
+++ b/client/js/controllers/post_main_controller.js
@@ -51,6 +51,9 @@ class PostMainController extends BasePostController {
nextPostId: aroundResponse.next
? aroundResponse.next.id
: null,
+ randomPostId: aroundResponse.random
+ ? aroundResponse.random.id
+ : null,
canEditPosts: api.hasPrivilege("posts:edit"),
canDeletePosts: api.hasPrivilege("posts:delete"),
canFeaturePosts: api.hasPrivilege("posts:feature"),
@@ -169,22 +172,22 @@ class PostMainController extends BasePostController {
this._view.sidebarControl.disableForm();
this._view.sidebarControl.clearMessages();
const post = e.detail.post;
- if (e.detail.safety !== undefined && e.detail.safety !== null) {
+ if (e.detail.safety !== undefined) {
post.safety = e.detail.safety;
}
- if (e.detail.flags !== undefined && e.detail.flags !== null) {
+ if (e.detail.flags !== undefined) {
post.flags = e.detail.flags;
}
- if (e.detail.relations !== undefined && e.detail.relations !== null) {
+ if (e.detail.relations !== undefined) {
post.relations = e.detail.relations;
}
- if (e.detail.content !== undefined && e.detail.content !== null) {
+ if (e.detail.content !== undefined) {
post.newContent = e.detail.content;
}
- if (e.detail.thumbnail !== undefined && e.detail.thumbnail !== null) {
+ if (e.detail.thumbnail !== undefined) {
post.newThumbnail = e.detail.thumbnail;
}
- if (e.detail.source !== undefined && e.detail.source !== null) {
+ if (e.detail.source !== undefined) {
post.source = e.detail.source;
}
post.save().then(
diff --git a/client/js/controllers/tag_controller.js b/client/js/controllers/tag_controller.js
index 80e32c78..dfd80bc2 100644
--- a/client/js/controllers/tag_controller.js
+++ b/client/js/controllers/tag_controller.js
@@ -95,13 +95,13 @@ class TagController {
_evtUpdate(e) {
this._view.clearMessages();
this._view.disableForm();
- if (e.detail.names !== undefined && e.detail.names !== null) {
+ if (e.detail.names !== undefined) {
e.detail.tag.names = e.detail.names;
}
- if (e.detail.category !== undefined && e.detail.category !== null) {
+ if (e.detail.category !== undefined) {
e.detail.tag.category = e.detail.category;
}
- if (e.detail.description !== undefined && e.detail.description !== null) {
+ if (e.detail.description !== undefined) {
e.detail.tag.description = e.detail.description;
}
e.detail.tag.save().then(
diff --git a/client/js/controllers/user_controller.js b/client/js/controllers/user_controller.js
index 8cf46584..068d329e 100644
--- a/client/js/controllers/user_controller.js
+++ b/client/js/controllers/user_controller.js
@@ -175,21 +175,21 @@ class UserController {
const isLoggedIn = api.isLoggedIn(e.detail.user);
const infix = isLoggedIn ? "self" : "any";
- if (e.detail.name !== undefined && e.detail.name !== null) {
+ if (e.detail.name !== undefined) {
e.detail.user.name = e.detail.name;
}
- if (e.detail.email !== undefined && e.detail.email !== null) {
+ if (e.detail.email !== undefined) {
e.detail.user.email = e.detail.email;
}
- if (e.detail.rank !== undefined && e.detail.rank !== null) {
+ if (e.detail.rank !== undefined) {
e.detail.user.rank = e.detail.rank;
}
- if (e.detail.password !== undefined && e.detail.password !== null) {
+ if (e.detail.password !== undefined) {
e.detail.user.password = e.detail.password;
}
- if (e.detail.avatarStyle !== undefined && e.detail.avatarStyle !== null) {
+ if (e.detail.avatarStyle !== undefined) {
e.detail.user.avatarStyle = e.detail.avatarStyle;
if (e.detail.avatarContent) {
e.detail.user.avatarContent = e.detail.avatarContent;
@@ -302,7 +302,7 @@ class UserController {
this._view.clearMessages();
this._view.disableForm();
- if (e.detail.note !== undefined && e.detail.note !== null) {
+ if (e.detail.note !== undefined) {
e.detail.userToken.note = e.detail.note;
}
diff --git a/client/js/controllers/user_registration_controller.js b/client/js/controllers/user_registration_controller.js
index 89cfd8cd..8b44a2b7 100644
--- a/client/js/controllers/user_registration_controller.js
+++ b/client/js/controllers/user_registration_controller.js
@@ -29,6 +29,7 @@ class UserRegistrationController {
user.name = e.detail.name;
user.email = e.detail.email;
user.password = e.detail.password;
+ user.code = e.detail.code;
const isLoggedIn = api.isLoggedIn();
user.save()
.then(() => {
diff --git a/client/js/controls/metric_header_control.js b/client/js/controls/metric_header_control.js
new file mode 100644
index 00000000..6a70b5ba
--- /dev/null
+++ b/client/js/controls/metric_header_control.js
@@ -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;
diff --git a/client/js/controls/post_content_control.js b/client/js/controls/post_content_control.js
index 8cbd5d89..55daca76 100644
--- a/client/js/controls/post_content_control.js
+++ b/client/js/controls/post_content_control.js
@@ -103,30 +103,6 @@ class PostContentControl {
}
_refreshSize() {
- if (window.innerWidth <= 800) {
- const buttons = document.querySelector(".sidebar > .buttons");
- if (buttons) {
- const content = document.querySelector(".content");
- content.insertBefore(buttons, content.querySelector(".post-container + *"));
-
- const afterControls = document.querySelector(".content > .after-mobile-controls");
- if (afterControls) {
- afterControls.parentElement.parentElement.appendChild(afterControls);
- }
- }
- } else {
- const buttons = document.querySelector(".content > .buttons");
- if (buttons) {
- const sidebar = document.querySelector(".sidebar");
- sidebar.insertBefore(buttons, sidebar.firstElementChild);
- }
-
- const afterControls = document.querySelector(".content + .after-mobile-controls");
- if (afterControls) {
- document.querySelector(".content").appendChild(afterControls);
- }
- }
-
this._currentFitFunction();
}
diff --git a/client/js/controls/post_edit_sidebar_control.js b/client/js/controls/post_edit_sidebar_control.js
index 3b1c16e7..eabb98ae 100644
--- a/client/js/controls/post_edit_sidebar_control.js
+++ b/client/js/controls/post_edit_sidebar_control.js
@@ -427,7 +427,7 @@ class PostEditSidebarControl extends events.EventTarget {
: undefined,
thumbnail:
- this._newPostThumbnail !== undefined && this._newPostThumbnail !== null
+ this._newPostThumbnail !== undefined
? this._newPostThumbnail
: undefined,
diff --git a/client/js/controls/post_metric_input_control.js b/client/js/controls/post_metric_input_control.js
new file mode 100644
index 00000000..45b6b245
--- /dev/null
+++ b/client/js/controls/post_metric_input_control.js
@@ -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;
diff --git a/client/js/controls/post_metric_list_control.js b/client/js/controls/post_metric_list_control.js
new file mode 100644
index 00000000..b179b121
--- /dev/null
+++ b/client/js/controls/post_metric_list_control.js
@@ -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;
diff --git a/client/js/models/metric.js b/client/js/models/metric.js
new file mode 100644
index 00000000..bb9cf84a
--- /dev/null
+++ b/client/js/models/metric.js
@@ -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;
diff --git a/client/js/models/metric_list.js b/client/js/models/metric_list.js
new file mode 100644
index 00000000..83aba110
--- /dev/null
+++ b/client/js/models/metric_list.js
@@ -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;
diff --git a/client/js/models/post.js b/client/js/models/post.js
index 01f81bf1..2fb3d34c 100644
--- a/client/js/models/post.js
+++ b/client/js/models/post.js
@@ -271,7 +271,7 @@ class Post extends events.EventTarget {
if (this._newContent) {
files.content = this._newContent;
}
- if (this._newThumbnail !== undefined && this._newThumbnail !== null) {
+ if (this._newThumbnail !== undefined) {
files.thumbnail = this._newThumbnail;
}
if (this._source !== this._orig._source) {
diff --git a/client/js/models/post_list.js b/client/js/models/post_list.js
index 8c2c9d4e..6838a5cb 100644
--- a/client/js/models/post_list.js
+++ b/client/js/models/post_list.js
@@ -16,7 +16,7 @@ class PostList extends AbstractList {
);
}
- static search(text, offset, limit, fields) {
+ static search(text, offset, limit, fields, cachenumber) {
return api
.get(
uri.formatApiLink("posts", {
@@ -24,6 +24,7 @@ class PostList extends AbstractList {
offset: offset,
limit: limit,
fields: fields.join(","),
+ cachenumber: cachenumber,
})
)
.then((response) => {
diff --git a/client/js/models/post_metric.js b/client/js/models/post_metric.js
new file mode 100644
index 00000000..dd06957a
--- /dev/null
+++ b/client/js/models/post_metric.js
@@ -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;
\ No newline at end of file
diff --git a/client/js/models/post_metric_list.js b/client/js/models/post_metric_list.js
new file mode 100644
index 00000000..5e2c8386
--- /dev/null
+++ b/client/js/models/post_metric_list.js
@@ -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;
diff --git a/client/js/models/post_metric_range.js b/client/js/models/post_metric_range.js
new file mode 100644
index 00000000..ab7bddba
--- /dev/null
+++ b/client/js/models/post_metric_range.js
@@ -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;
\ No newline at end of file
diff --git a/client/js/models/post_metric_range_list.js b/client/js/models/post_metric_range_list.js
new file mode 100644
index 00000000..68e701b6
--- /dev/null
+++ b/client/js/models/post_metric_range_list.js
@@ -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;
diff --git a/client/js/util/touch.js b/client/js/util/touch.js
index 64bd00ac..85b66d84 100644
--- a/client/js/util/touch.js
+++ b/client/js/util/touch.js
@@ -12,6 +12,7 @@ function handleTouchStart(handler, evt) {
const touchEvent = evt.touches[0];
handler._xStart = touchEvent.clientX;
handler._yStart = touchEvent.clientY;
+ handler._startScrollY = window.scrollY;
}
function handleTouchMove(handler, evt) {
@@ -35,22 +36,22 @@ function handleTouchMove(handler, evt) {
}
}
-function handleTouchEnd(handler) {
+function handleTouchEnd(handler, evt) {
+ evt.startScrollY = handler._startScrollY;
switch (handler._direction) {
case direction.NONE:
return;
case direction.LEFT:
- handler._swipeLeftTask();
+ handler._swipeLeftTask(evt);
break;
case direction.RIGHT:
- handler._swipeRightTask();
+ handler._swipeRightTask(evt);
break;
case direction.DOWN:
- handler._swipeDownTask();
+ handler._swipeDownTask(evt);
break;
case direction.UP:
- handler._swipeUpTask();
- // no default
+ handler._swipeUpTask(evt);
}
handler._xStart = null;
@@ -76,15 +77,15 @@ class Touch {
this._yStart = null;
this._direction = direction.NONE;
- this._target.addEventListener("touchstart", (evt) => {
- handleTouchStart(this, evt);
- });
- this._target.addEventListener("touchmove", (evt) => {
- handleTouchMove(this, evt);
- });
- this._target.addEventListener("touchend", () => {
- handleTouchEnd(this);
- });
+ this._target.addEventListener('touchstart', (evt) =>
+ { handleTouchStart(this, evt); }
+ );
+ this._target.addEventListener('touchmove', (evt) =>
+ { handleTouchMove(this, evt); }
+ );
+ this._target.addEventListener('touchend', (evt) =>
+ { handleTouchEnd(this, evt); }
+ );
}
}
diff --git a/client/js/util/views.js b/client/js/util/views.js
index f6280a1c..878c92fb 100644
--- a/client/js/util/views.js
+++ b/client/js/util/views.js
@@ -136,6 +136,11 @@ function makePasswordInput(options) {
return makeInput(options);
}
+function makeCodeInput(options) {
+ options.type = "code";
+ return makeInput(options);
+}
+
function makeEmailInput(options) {
options.type = "email";
return makeInput(options);
@@ -209,13 +214,13 @@ function makePostLink(id, includeHash) {
}
function makeTagLink(name, includeHash, includeCount, tag) {
- const category = tag && tag.category ? tag.category : "unknown";
+ const category = tag ? tag.category : "unknown";
let text = misc.getPrettyName(name);
if (includeHash === true) {
text = "#" + text;
}
if (includeCount === true) {
- text += " (" + (tag && tag.postCount ? tag.postCount : 0) + ")";
+ text += " (" + (tag ? tag.postCount : 0) + ")";
}
return api.hasPrivilege("tags:view")
? makeElement(
@@ -234,15 +239,15 @@ function makeTagLink(name, includeHash, includeCount, tag) {
}
function makePoolLink(id, includeHash, includeCount, pool, name) {
- const category = pool && pool.category ? pool.category : "unknown";
+ const category = pool ? pool.category : "unknown";
let text = misc.getPrettyName(
- name ? name : pool && pool.names ? pool.names[0] : "pool " + id
+ name ? name : pool ? pool.names[0] : "unknown"
);
if (includeHash === true) {
text = "#" + text;
}
if (includeCount === true) {
- text += " (" + (pool && pool.postCount ? pool.postCount : 0) + ")";
+ text += " (" + (pool ? pool.postCount : 0) + ")";
}
return api.hasPrivilege("pools:view")
? makeElement(
@@ -264,7 +269,7 @@ function makeUserLink(user) {
let text = makeThumbnail(user ? user.avatarUrl : null);
text += user && user.name ? misc.escapeHtml(user.name) : "Anonymous";
const link =
- user && user.name && api.hasPrivilege("users:view")
+ user && api.hasPrivilege("users:view")
? makeElement(
"a",
{ href: uri.formatClientLink("user", user.name) },
@@ -444,6 +449,7 @@ function getTemplate(templatePath) {
makeTextarea: makeTextarea,
makeTextInput: makeTextInput,
makePasswordInput: makePasswordInput,
+ makeCodeInput: makeCodeInput,
makeEmailInput: makeEmailInput,
makeColorInput: makeColorInput,
makeDateInput: makeDateInput,
diff --git a/client/js/views/metric_sorter_view.js b/client/js/views/metric_sorter_view.js
new file mode 100644
index 00000000..763a1de8
--- /dev/null
+++ b/client/js/views/metric_sorter_view.js
@@ -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;
diff --git a/client/js/views/post_main_view.js b/client/js/views/post_main_view.js
index 5ef7f61e..f9713912 100644
--- a/client/js/views/post_main_view.js
+++ b/client/js/views/post_main_view.js
@@ -86,6 +86,12 @@ class PostMainView {
}
};
+ const showRandomImage = () => {
+ if (ctx.randomPostId) {
+ router.show(ctx.getPostUrl(ctx.randomPostId, ctx.parameters));
+ }
+ };
+
keyboard.bind("e", () => {
if (ctx.editMode) {
router.show(uri.formatClientLink("post", ctx.post.id));
@@ -95,6 +101,7 @@ class PostMainView {
});
keyboard.bind(["a", "left"], showPreviousImage);
keyboard.bind(["d", "right"], showNextImage);
+ keyboard.bind("r", showRandomImage);
keyboard.bind("del", (e) => {
if (ctx.editMode) {
this.sidebarControl._evtDeleteClick(e);
@@ -105,15 +112,21 @@ class PostMainView {
postContainerNode,
() => {
if (!ctx.editMode) {
- showPreviousImage();
+ showNextImage()
}
},
() => {
if (!ctx.editMode) {
- showNextImage();
+ showPreviousImage()
+ }
+ },
+ () => {},
+ (e) => {
+ if (!ctx.editMode && e.startScrollY === 0) {
+ showRandomImage()
}
}
- );
+ )
}
_installSidebar(ctx) {
diff --git a/client/js/views/posts_header_view.js b/client/js/views/posts_header_view.js
index 38a4aa98..4f04eb42 100644
--- a/client/js/views/posts_header_view.js
+++ b/client/js/views/posts_header_view.js
@@ -197,8 +197,11 @@ class PostsHeaderView extends events.EventTarget {
this._evtSafetyButtonClick(e)
);
}
- this._formNode.addEventListener("submit", (e) =>
- this._evtFormSubmit(e)
+
+ this._formNode.addEventListener('submit', e =>
+ this._evtFormSubmit(e));
+ this._randomizeButtonNode.addEventListener('click', e =>
+ this._evtRandomizeButtonClick(e)
);
this._bulkEditors = [];
@@ -256,6 +259,10 @@ class PostsHeaderView extends events.EventTarget {
return this._hostNode.querySelector("form [name=search-text]");
}
+ get _randomizeButtonNode() {
+ return this._hostNode.querySelector('#randomize-button');
+ }
+
get _bulkEditTagsNode() {
return this._hostNode.querySelector(".bulk-edit-tags");
}
@@ -314,9 +321,21 @@ class PostsHeaderView extends events.EventTarget {
this._navigate();
}
+ _evtRandomizeButtonClick(e) {
+ e.preventDefault();
+ if (!this._queryInputNode.value.includes('sort:random')) {
+ this._queryInputNode.value += ' sort:random';
+ }
+ this._ctx.parameters.cachenumber = Math.round(Math.random() * 1000);
+ this._navigate();
+ }
+
_navigate() {
this._autoCompleteControl.hide();
- let parameters = { query: this._queryInputNode.value };
+ let parameters = {
+ query: this._queryInputNode.value,
+ cachenumber: this._ctx.parameters.cachenumber,
+ };
// convert falsy values to an empty string "" so that we can correctly compare with the current query
const prevQuery = this._ctx.parameters.query
diff --git a/client/js/views/registration_view.js b/client/js/views/registration_view.js
index 0a08de23..31642227 100644
--- a/client/js/views/registration_view.js
+++ b/client/js/views/registration_view.js
@@ -15,6 +15,7 @@ class RegistrationView extends events.EventTarget {
template({
userNamePattern: api.getUserNameRegex(),
passwordPattern: api.getPasswordRegex(),
+ codePattern: api.getCodeRegex(),
})
);
views.syncScrollPosition();
@@ -45,6 +46,7 @@ class RegistrationView extends events.EventTarget {
detail: {
name: this._userNameFieldNode.value,
password: this._passwordFieldNode.value,
+ code: this._codeFieldNode.value,
email: this._emailFieldNode.value,
},
})
@@ -63,6 +65,10 @@ class RegistrationView extends events.EventTarget {
return this._formNode.querySelector("[name=password]");
}
+ get _codeFieldNode() {
+ return this._formNode.querySelector("[name=code]");
+ }
+
get _emailFieldNode() {
return this._formNode.querySelector("[name=email]");
}
diff --git a/client/js/views/tag_metric_view.js b/client/js/views/tag_metric_view.js
new file mode 100644
index 00000000..d7cb67f5
--- /dev/null
+++ b/client/js/views/tag_metric_view.js
@@ -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;
diff --git a/customdocker2 master.7z b/customdocker2 master.7z
new file mode 100644
index 00000000..d447093e
Binary files /dev/null and b/customdocker2 master.7z differ
diff --git a/customdocker2 stable master v2.7z b/customdocker2 stable master v2.7z
new file mode 100644
index 00000000..f7b5d3ba
Binary files /dev/null and b/customdocker2 stable master v2.7z differ
diff --git a/doc/API.md b/doc/API.md
index 00ee75a9..3d280fd1 100644
--- a/doc/API.md
+++ b/doc/API.md
@@ -37,7 +37,6 @@
- [Creating post](#creating-post)
- [Updating post](#updating-post)
- [Getting post](#getting-post)
- - [Getting around post](#getting-around-post)
- [Deleting post](#deleting-post)
- [Merging posts](#merging-posts)
- [Rating post](#rating-post)
@@ -54,7 +53,7 @@
- [Deleting pool category](#deleting-pool-category)
- [Setting default pool category](#setting-default-pool-category)
- Pools
- - [Listing pools](#listing-pools)
+ - [Listing pools](#listing-pool)
- [Creating pool](#creating-pool)
- [Updating pool](#updating-pool)
- [Getting pool](#getting-pool)
@@ -165,9 +164,9 @@ way. The files, however, should be passed as regular fields appended with a
accepts a file named `content`, the client should pass
`{"contentUrl":"http://example.com/file.jpg"}` as a part of the JSON message
body. When creating or updating post content using this method, the server can
-also be configured to employ [yt-dlp](https://github.com/yt-dlp/yt-dlp) to
-download content from popular sites such as youtube, gfycat, etc. Access to
-yt-dlp can be configured with the `'uploads:use_downloader'` permission
+also be configured to employ [youtube-dl](https://github.com/ytdl-org/youtube-dl)
+to download content from popular sites such as youtube, gfycat, etc. Access to
+youtube-dl can be configured with the `'uploads:use_downloader'` permission
Finally, in some cases the user might want to reuse one file between the
requests to save the bandwidth (for example, reverse search + consecutive
@@ -323,7 +322,7 @@ data.
{
"name": ,
"color": ,
- "order":
+ "order": // optional
}
```
@@ -952,29 +951,6 @@ data.
Retrieves information about an existing post.
-## Getting around post
-- **Request**
-
- `GET /post//around`
-
-- **Output**
-
- ```json5
- {
- "prev": ,
- "next":
- }
- ```
-
-- **Errors**
-
- - the post does not exist
- - privileges are too low
-
-- **Description**
-
- Retrieves information about posts that are before or after an existing post.
-
## Deleting post
- **Request**
@@ -1389,7 +1365,7 @@ data.
## Creating pool
- **Request**
- `POST /pool`
+ `POST /pools/create`
- **Input**
@@ -2491,7 +2467,7 @@ One file together with its metadata posted to the site.
## Micro post
**Description**
-A [post resource](#post) stripped down to `id` and `thumbnailUrl` fields.
+A [post resource](#post) stripped down to `name` and `thumbnailUrl` fields.
## Note
**Description**
diff --git a/doc/INSTALL.md b/doc/INSTALL.md
index ca0212bf..d978e4a8 100644
--- a/doc/INSTALL.md
+++ b/doc/INSTALL.md
@@ -34,79 +34,33 @@ and Docker Compose (version 1.6.0 or greater) already installed.
Read the comments to guide you. Note that `.env` should be in the root
directory of this repository.
-4. Pull the containers:
+### Running the Application
- This pulls the latest containers from docker.io:
- ```console
- user@host:szuru$ docker-compose pull
- ```
+Download containers:
+```console
+user@host:szuru$ docker-compose pull
+```
- If you have modified the application's source and would like to manually
- build it, follow the instructions in [**Building**](#Building) instead,
- then read here once you're done.
+For first run, it is recommended to start the database separately:
+```console
+user@host:szuru$ docker-compose up -d sql
+```
-5. Run it!
+To start all containers:
+```console
+user@host:szuru$ docker-compose up -d
+```
- For first run, it is recommended to start the database separately:
- ```console
- user@host:szuru$ docker-compose up -d sql
- ```
-
- To start all containers:
- ```console
- user@host:szuru$ docker-compose up -d
- ```
-
- To view/monitor the application logs:
- ```console
- user@host:szuru$ docker-compose logs -f
- # (CTRL+C to exit)
- ```
-
-### Building
-
-1. Edit `docker-compose.yml` to tell Docker to build instead of pull containers:
-
- ```diff yaml
- ...
- server:
- - image: szurubooru/server:latest
- + build: server
- ...
- client:
- - image: szurubooru/client:latest
- + build: client
- ...
- ```
-
- You can choose to build either one from source.
-
-2. Build the containers:
-
- ```console
- user@host:szuru$ docker-compose build
- ```
-
- That will attempt to build both containers, but you can specify `client`
- or `server` to make it build only one.
-
- If `docker-compose build` spits out:
-
- ```
- ERROR: Service 'server' failed to build: failed to parse platform : "" is an invalid component of "": platform specifier component must match "^[A-Za-z0-9_-]+$": invalid argument
- ```
-
- ...you will need to export Docker BuildKit flags:
-
- ```console
- user@host:szuru$ export DOCKER_BUILDKIT=1; export COMPOSE_DOCKER_CLI_BUILD=1
- ```
-
- ...and run `docker-compose build` again.
-
-*Note: If your changes are not taking effect in your builds, consider building
-with `--no-cache`.*
+To view/monitor the application logs:
+```console
+user@host:szuru$ docker-compose logs -f
+# (CTRL+C to exit)
+```
+To stop all containers:
+```console
+user@host:szuru$ docker-compose down
+```
### Additional Features
diff --git a/doc/example.env b/doc/example.env
index 303a25e6..59e1e859 100644
--- a/doc/example.env
+++ b/doc/example.env
@@ -10,12 +10,6 @@ BUILD_INFO=latest
# otherwise the port specified here will be publicly accessible
PORT=8080
-# How many waitress threads to start
-# 4 is the default amount of threads. If you experience performance
-# degradation with a large number of posts, increasing this may
-# improve performance, since waitress is most likely clogging up with Tasks.
-THREADS=4
-
# URL base to run szurubooru under
# See "Additional Features" section in INSTALL.md
BASE_URL=/
diff --git a/docker-compose.yml b/docker-compose.yml
index 38e08b97..b4f689c4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,32 +2,39 @@
##
## Use this as a template to set up docker-compose, or as guide to set up other
## orchestration services
-version: '2'
+version: '3'
services:
server:
- image: szurubooru/server:latest
- depends_on:
- - sql
+ # image: szurubooru/server:latest
+ build:
+ context: ./server
+# depends_on:
+# - sql
environment:
## These should be the names of the dependent containers listed below,
## or FQDNs/IP addresses if these services are running outside of Docker
- POSTGRES_HOST: sql
+ POSTGRES_HOST: 192.168.1.17
## Credentials for database:
- POSTGRES_USER:
- POSTGRES_PASSWORD:
+ POSTGRES_USER: stash_u
+ POSTGRES_PASSWORD: tGmwRgEHbziNNEdsvcXynudFayVayzBybFvmMw
## Commented Values are Default:
- #POSTGRES_DB: defaults to same as POSTGRES_USER
- #POSTGRES_PORT: 5432
+ POSTGRES_DB: stash_db
+ POSTGRES_PORT: 5432
#LOG_SQL: 0 (1 for verbose SQL logs)
THREADS:
volumes:
- "${MOUNT_DATA}:/data"
- "./server/config.yaml:/opt/app/config.yaml"
+ - "${MOUNT_SQL}:/var/lib/postgresql/data"
+ networks:
+ - br0
client:
- image: szurubooru/client:latest
+ # image: szurubooru/client:latest
+ build:
+ context: ./client
depends_on:
- server
environment:
@@ -36,13 +43,22 @@ services:
volumes:
- "${MOUNT_DATA}:/data:ro"
ports:
- - "${PORT}:80"
+ - "${PORT}:8069"
+ networks:
+ - br0
- sql:
- image: postgres:11-alpine
- restart: unless-stopped
- environment:
- POSTGRES_USER:
- POSTGRES_PASSWORD:
- volumes:
- - "${MOUNT_SQL}:/var/lib/postgresql/data"
+ #sql:
+ # image: postgres:11-alpine
+ #restart: unless-stopped
+ #environment:
+ # POSTGRES_USER: stash_u
+ #POSTGRES_PASSWORD: TeKeuubttpyLtYHtkdvixgvyKqvfmeWuSkXcKp
+ #volumes:
+ # - "${MOUNT_SQL}:/var/lib/postgresql/data"
+ #networks:
+ # - temposvision
+
+networks:
+ br0:
+ external: true
+ name: br0
\ No newline at end of file
diff --git a/server/Dockerfile b/server/Dockerfile
index 3e4dadfb..35281d5a 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -24,14 +24,21 @@ RUN apk --no-cache add \
py3-pynacl \
py3-tz \
py3-pyrfc3339
-RUN pip3 install --no-cache-dir --disable-pip-version-check \
+
+# Upgrade pip and setuptools
+RUN pip3 install --no-cache-dir --disable-pip-version-check --upgrade pip setuptools wheel
+
+# Install required Python packages with PEP 517 disabled
+RUN pip3 install --no-cache-dir --disable-pip-version-check --no-build-isolation \
"alembic>=0.8.5" \
"coloredlogs==5.0" \
"pyheif==0.6.1" \
"heif-image-plugin>=0.3.2" \
- yt-dlp \
- "pillow-avif-plugin~=1.1.0"
-RUN apk --no-cache del py3-pip
+ youtube_dl \
+ "pillow-avif-plugin>=1.1.0"
+
+# Debugging: Print the installed packages
+RUN apk list --installed
COPY ./ /opt/app/
RUN rm -rf /opt/app/szurubooru/tests
@@ -75,6 +82,8 @@ RUN apk --no-cache add \
&& addgroup -g ${PGID} app \
&& adduser -SDH -h /opt/app -g '' -G app -u ${PUID} app \
&& chown -R app:app /opt/app /data
+
+RUN chmod +x /opt/app/docker-start.sh
USER app
CMD ["/opt/app/docker-start.sh"]
diff --git a/server/Dockerfile - Copy b/server/Dockerfile - Copy
new file mode 100644
index 00000000..70d36dbb
--- /dev/null
+++ b/server/Dockerfile - Copy
@@ -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"
diff --git a/server/Dockerfile - Copy (2) b/server/Dockerfile - Copy (2)
new file mode 100644
index 00000000..8d535c8b
--- /dev/null
+++ b/server/Dockerfile - Copy (2)
@@ -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"
diff --git a/server/docker-start.sh b/server/docker-start.sh
index eebef1c7..314c355e 100755
--- a/server/docker-start.sh
+++ b/server/docker-start.sh
@@ -2,7 +2,7 @@
set -e
cd /opt/app
-alembic upgrade head
+alembic upgrade heads
-echo "Starting szurubooru API on port ${PORT} - Running on ${THREADS} threads"
-exec waitress-serve-3 --port ${PORT} --threads ${THREADS} szurubooru.facade:app
+echo "Starting szurubooru API on port ${PORT}"
+exec waitress-serve-3 --port ${PORT} szurubooru.facade:app
diff --git a/server/hooks/build b/server/hooks/build
new file mode 100644
index 00000000..b5e914b2
--- /dev/null
+++ b/server/hooks/build
@@ -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 .
diff --git a/server/hooks/post_push b/server/hooks/post_push
new file mode 100644
index 00000000..1b1e0ad9
--- /dev/null
+++ b/server/hooks/post_push
@@ -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
diff --git a/server/hooks/test b/server/hooks/test
new file mode 100644
index 00000000..b3251864
--- /dev/null
+++ b/server/hooks/test
@@ -0,0 +1,8 @@
+#!/bin/sh
+set -e
+
+docker run --rm \
+ -t $(docker build --target testing -q .) \
+ --color=no szurubooru/
+
+exit $?
diff --git a/server/requirements.txt b/server/requirements.txt
index ffe18f0c..16b29fff 100644
--- a/server/requirements.txt
+++ b/server/requirements.txt
@@ -3,7 +3,7 @@ certifi>=2017.11.5
coloredlogs==5.0
heif-image-plugin==0.3.2
numpy>=1.8.2
-pillow-avif-plugin~=1.1.0
+pillow-avif-plugin>=1.1.0
pillow>=4.3.0
psycopg2-binary>=2.6.1
pyheif==0.6.1
@@ -12,4 +12,4 @@ pyRFC3339>=1.0
pytz>=2018.3
pyyaml>=3.11
SQLAlchemy>=1.0.12, <1.4
-yt-dlp
+youtube_dl
diff --git a/server/szurubooru/api/metric_api.py b/server/szurubooru/api/metric_api.py
new file mode 100644
index 00000000..2bba66f6
--- /dev/null
+++ b/server/szurubooru/api/metric_api.py
@@ -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.+)")
+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.+)")
+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])
+ }
diff --git a/server/szurubooru/func/metrics.py b/server/szurubooru/func/metrics.py
new file mode 100644
index 00000000..944c4d58
--- /dev/null
+++ b/server/szurubooru/func/metrics.py
@@ -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)
diff --git a/server/szurubooru/func/net.py b/server/szurubooru/func/net.py
index d6aa95e9..c53a62eb 100644
--- a/server/szurubooru/func/net.py
+++ b/server/szurubooru/func/net.py
@@ -64,7 +64,7 @@ def download(url: str, use_video_downloader: bool = False) -> bytes:
def _get_youtube_dl_content_url(url: str) -> str:
- cmd = ["yt-dlp", "--format", "best", "--no-playlist"]
+ cmd = ["youtube-dl", "--format", "best", "--no-playlist"]
if config.config["user_agent"]:
cmd.extend(["--user-agent", config.config["user_agent"]])
cmd.extend(["--get-url", url])
diff --git a/server/szurubooru/func/similar.py b/server/szurubooru/func/similar.py
new file mode 100644
index 00000000..a19b7d17
--- /dev/null
+++ b/server/szurubooru/func/similar.py
@@ -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
diff --git a/server/szurubooru/model/metric.py b/server/szurubooru/model/metric.py
new file mode 100644
index 00000000..530165ad
--- /dev/null
+++ b/server/szurubooru/model/metric.py
@@ -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,
+ }
diff --git a/server/szurubooru/search/configs/post_metric_search_config.py b/server/szurubooru/search/configs/post_metric_search_config.py
new file mode 100644
index 00000000..0cdb1ea2
--- /dev/null
+++ b/server/szurubooru/search/configs/post_metric_search_config.py
@@ -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}
diff --git a/server/szurubooru/search/executor.py b/server/szurubooru/search/executor.py
index a5ef9625..7a64fc26 100644
--- a/server/szurubooru/search/executor.py
+++ b/server/szurubooru/search/executor.py
@@ -37,8 +37,7 @@ class Executor:
self.parser = parser.Parser()
def get_around(
- self, query_text: str, entity_id: int
- ) -> Tuple[model.Base, model.Base]:
+ self, query_text: str, entity_id: int) -> Tuple[model.Base, model.Base, model.Base]:
search_query = self.parser.parse(query_text)
self.config.on_search_query_parsed(search_query)
filter_query = self.config.create_around_query().options(
@@ -57,11 +56,15 @@ class Executor:
filter_query.filter(self.config.id_column < entity_id)
.order_by(None)
.order_by(sa.func.abs(self.config.id_column - entity_id).asc())
- .limit(1)
- )
+ .limit(1))
+ # random post
+ if 'sort:random' not in query_text:
+ query_text = query_text + ' sort:random'
+ count, random_entities = self.execute(query_text, 0, 1)
return (
prev_filter_query.one_or_none(),
next_filter_query.one_or_none(),
+ random_entities[0] if random_entities else None
)
def get_around_and_serialize(
@@ -76,6 +79,7 @@ class Executor:
return {
"prev": serializer(entities[0]),
"next": serializer(entities[1]),
+ 'random': serializer(entities[2]),
}
def execute(
@@ -94,7 +98,7 @@ class Executor:
disable_eager_loads = True
key = (id(self.config), hash(search_query), offset, limit)
- if cache.has(key):
+ if not disable_eager_loads and cache.has(key):
return cache.get(key)
filter_query = self.config.create_filter_query(disable_eager_loads)
diff --git a/server/szurubooru/tests/api/test_metric_retrieving.py b/server/szurubooru/tests/api/test_metric_retrieving.py
new file mode 100644
index 00000000..81e29cd0
--- /dev/null
+++ b/server/szurubooru/tests/api/test_metric_retrieving.py
@@ -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
diff --git a/server/szurubooru/tests/func/test_metrics.py b/server/szurubooru/tests/func/test_metrics.py
new file mode 100644
index 00000000..c33be623
--- /dev/null
+++ b/server/szurubooru/tests/func/test_metrics.py
@@ -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
diff --git a/server/szurubooru/tests/func/test_similar.py b/server/szurubooru/tests/func/test_similar.py
new file mode 100644
index 00000000..5346cb05
--- /dev/null
+++ b/server/szurubooru/tests/func/test_similar.py
@@ -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])
diff --git a/server/szurubooru/tests/model/test_metric.py b/server/szurubooru/tests/model/test_metric.py
new file mode 100644
index 00000000..cf0fa56d
--- /dev/null
+++ b/server/szurubooru/tests/model/test_metric.py
@@ -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
diff --git a/server/szurubooru/tests/search/configs/test_post_metric_search_config.py b/server/szurubooru/tests/search/configs/test_post_metric_search_config.py
new file mode 100644
index 00000000..4eba809b
--- /dev/null
+++ b/server/szurubooru/tests/search/configs/test_post_metric_search_config.py
@@ -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)