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 @@ +
    + + + <%= ctx.makeCheckbox({ + text: 'Show values on posts', + name: 'show-values-on-posts', + checked: ctx.showValuesOnPost, + class: 'append'}) %> + Start sorting +
    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 %>"

    +
    +
    +
    + <% if (window.innerWidth <= 1000) { %> +
    + <% } %> +
    +
    + <% if (window.innerWidth <= 1000) { %> + + <% } %> + + + <% if (window.innerWidth <= 1000) { %> + Skip + <% } %> +
    +
    +
    +
    + + <% if (window.innerWidth > 1000) { %> +
    + <% } %> + +
    + <% if (window.innerWidth > 1000) { %> + + Skip + <% } %> +
    +
    +
    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) { %> + +
    <% 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)