Comparison
This commit is contained in:
parent
d102578b54
commit
30da0803c3
87 changed files with 3878 additions and 294 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -13,3 +13,7 @@ server/**/lib/
|
|||
server/**/bin/
|
||||
server/**/pyvenv.cfg
|
||||
__pycache__/
|
||||
|
||||
# IDE files
|
||||
.idea
|
||||
*.iml
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -27,6 +27,8 @@ FROM nginx:alpine as release
|
|||
RUN apk --no-cache add dumb-init
|
||||
COPY --from=approot / /
|
||||
|
||||
RUN chmod +x /docker-start.sh
|
||||
|
||||
CMD ["/docker-start.sh"]
|
||||
VOLUME ["/data"]
|
||||
|
||||
|
|
45
client/Dockerfile - Copy
Normal file
45
client/Dockerfile - Copy
Normal file
|
@ -0,0 +1,45 @@
|
|||
FROM node:lts as builder
|
||||
WORKDIR /opt/app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . ./
|
||||
|
||||
ARG BUILD_INFO="docker-latest"
|
||||
ARG CLIENT_BUILD_ARGS="--debug"
|
||||
RUN BASE_URL="__BASEURL__" node build.js --gzip ${CLIENT_BUILD_ARGS}
|
||||
|
||||
FROM scratch as approot
|
||||
|
||||
COPY docker-start.sh /
|
||||
|
||||
WORKDIR /etc/nginx
|
||||
COPY nginx.conf.docker ./nginx.conf
|
||||
|
||||
WORKDIR /var/www
|
||||
COPY --from=builder /opt/app/public/ .
|
||||
|
||||
|
||||
FROM nginx:alpine as release
|
||||
|
||||
RUN apk --no-cache add dumb-init
|
||||
COPY --from=approot / /
|
||||
|
||||
RUN chmod +x /docker-start.sh
|
||||
|
||||
CMD ["/docker-start.sh"]
|
||||
VOLUME ["/data"]
|
||||
|
||||
ARG DOCKER_REPO
|
||||
ARG BUILD_DATE
|
||||
ARG SOURCE_COMMIT
|
||||
LABEL \
|
||||
maintainer="" \
|
||||
org.opencontainers.image.title="${DOCKER_REPO}" \
|
||||
org.opencontainers.image.url="https://github.com/rr-/szurubooru" \
|
||||
org.opencontainers.image.documentation="https://github.com/rr-/szurubooru/blob/${SOURCE_COMMIT}/doc/INSTALL.md" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
org.opencontainers.image.source="https://github.com/rr-/szurubooru" \
|
||||
org.opencontainers.image.revision="${SOURCE_COMMIT}" \
|
||||
org.opencontainers.image.licenses="GPL-3.0"
|
424
client/build - Copy.js
Normal file
424
client/build - Copy.js
Normal file
|
@ -0,0 +1,424 @@
|
|||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// -------------------------------------------------
|
||||
|
||||
const webapp_icons = [
|
||||
{ name: 'android-chrome-192x192.png', size: 192 },
|
||||
{ name: 'android-chrome-512x512.png', size: 512 },
|
||||
{ name: 'apple-touch-icon.png', size: 180 },
|
||||
{ name: 'mstile-150x150.png', size: 150 }
|
||||
];
|
||||
|
||||
const webapp_splash_screens = [
|
||||
{ w: 640, h: 1136, center: 320 },
|
||||
{ w: 750, h: 1294, center: 375 },
|
||||
{ w: 1125, h: 2436, center: 565 },
|
||||
{ w: 1242, h: 2148, center: 625 },
|
||||
{ w: 1536, h: 2048, center: 770 },
|
||||
{ w: 1668, h: 2224, center: 820 },
|
||||
{ w: 2048, h: 2732, center: 1024 }
|
||||
];
|
||||
|
||||
const external_js = [
|
||||
'dompurify',
|
||||
'js-cookie',
|
||||
'marked',
|
||||
'mousetrap',
|
||||
'nprogress',
|
||||
'superagent',
|
||||
'underscore',
|
||||
];
|
||||
|
||||
const app_manifest = {
|
||||
name: 'szurubooru',
|
||||
icons: [
|
||||
{
|
||||
src: baseUrl() + 'img/android-chrome-192x192.png',
|
||||
type: 'image/png',
|
||||
sizes: '192x192'
|
||||
},
|
||||
{
|
||||
src: baseUrl() + 'img/android-chrome-512x512.png',
|
||||
type: 'image/png',
|
||||
sizes: '512x512'
|
||||
}
|
||||
],
|
||||
start_url: baseUrl(),
|
||||
theme_color: '#24aadd',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone'
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
|
||||
const fs = require('fs');
|
||||
const glob = require('glob');
|
||||
const path = require('path');
|
||||
const util = require('util');
|
||||
const execSync = require('child_process').execSync;
|
||||
const browserify = require('browserify');
|
||||
const chokidar = require('chokidar');
|
||||
const WebSocket = require('ws');
|
||||
var PrettyError = require('pretty-error');
|
||||
var pe = new PrettyError();
|
||||
|
||||
function readTextFile(path) {
|
||||
return fs.readFileSync(path, 'utf-8');
|
||||
}
|
||||
|
||||
function gzipFile(file) {
|
||||
file = path.normalize(file);
|
||||
execSync('gzip -6 -k ' + file);
|
||||
}
|
||||
|
||||
function baseUrl() {
|
||||
return process.env.BASE_URL ? process.env.BASE_URL : '/';
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
|
||||
function bundleHtml() {
|
||||
const underscore = require('underscore');
|
||||
const babelify = require('babelify');
|
||||
|
||||
function minifyHtml(html) {
|
||||
return require('html-minifier').minify(html, {
|
||||
removeComments: true,
|
||||
collapseWhitespace: true,
|
||||
conservativeCollapse: true,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
const baseHtml = readTextFile('./html/index.htm')
|
||||
.replace('<!-- Base HTML Placeholder -->', `<base href="${baseUrl()}"/>`);
|
||||
fs.writeFileSync('./public/index.htm', minifyHtml(baseHtml));
|
||||
|
||||
let compiledTemplateJs = [
|
||||
`'use strict';`,
|
||||
`let _ = require('underscore');`,
|
||||
`let templates = {};`
|
||||
];
|
||||
|
||||
for (const file of glob.sync('./html/**/*.tpl')) {
|
||||
const name = path.basename(file, '.tpl').replace(/_/g, '-');
|
||||
const placeholders = [];
|
||||
let templateText = readTextFile(file);
|
||||
templateText = templateText.replace(
|
||||
/<%.*?%>/ig,
|
||||
(match) => {
|
||||
const ret = '%%%TEMPLATE' + placeholders.length;
|
||||
placeholders.push(match);
|
||||
return ret;
|
||||
});
|
||||
templateText = minifyHtml(templateText);
|
||||
templateText = templateText.replace(
|
||||
/%%%TEMPLATE(\d+)/g,
|
||||
(match, number) => { return placeholders[number]; });
|
||||
|
||||
const functionText = underscore.template(
|
||||
templateText, { variable: 'ctx' }).source;
|
||||
|
||||
compiledTemplateJs.push(`templates['${name}'] = ${functionText};`);
|
||||
}
|
||||
compiledTemplateJs.push('module.exports = templates;');
|
||||
|
||||
fs.writeFileSync('./js/.templates.autogen.js', compiledTemplateJs.join('\n'));
|
||||
console.info('Bundled HTML');
|
||||
}
|
||||
|
||||
function bundleCss() {
|
||||
const stylus = require('stylus');
|
||||
|
||||
function minifyCss(css) {
|
||||
return require('csso').minify(css).css;
|
||||
}
|
||||
|
||||
let css = '';
|
||||
for (const file of glob.sync('./css/**/*.styl')) {
|
||||
css += stylus.render(readTextFile(file), { filename: file });
|
||||
}
|
||||
fs.writeFileSync('./public/css/app.min.css', minifyCss(css));
|
||||
if (process.argv.includes('--gzip')) {
|
||||
gzipFile('./public/css/app.min.css');
|
||||
}
|
||||
|
||||
fs.copyFileSync(
|
||||
'./node_modules/@fortawesome/fontawesome-free/css/all.min.css',
|
||||
'./public/css/vendor.min.css');
|
||||
if (process.argv.includes('--gzip')) {
|
||||
gzipFile('./public/css/vendor.min.css');
|
||||
}
|
||||
|
||||
console.info('Bundled CSS');
|
||||
}
|
||||
|
||||
function minifyJs(path) {
|
||||
return require('terser').minify(
|
||||
fs.readFileSync(path, 'utf-8'), { compress: { unused: false } }).code;
|
||||
}
|
||||
|
||||
function writeJsBundle(b, path, compress, callback) {
|
||||
let outputFile = fs.createWriteStream(path);
|
||||
b.bundle().on('error', (e) => console.error(pe.render(e))).pipe(outputFile);
|
||||
outputFile.on('finish', () => {
|
||||
if (compress) {
|
||||
fs.writeFileSync(path, minifyJs(path));
|
||||
}
|
||||
callback();
|
||||
});
|
||||
}
|
||||
|
||||
function bundleVendorJs(compress) {
|
||||
let b = browserify();
|
||||
for (let lib of external_js) {
|
||||
b.require(lib);
|
||||
}
|
||||
if (!process.argv.includes('--no-transpile')) {
|
||||
b.add(require.resolve('babel-polyfill'));
|
||||
}
|
||||
const file = './public/js/vendor.min.js';
|
||||
writeJsBundle(b, file, compress, () => {
|
||||
if (process.argv.includes('--gzip')) {
|
||||
gzipFile(file);
|
||||
}
|
||||
console.info('Bundled vendor JS');
|
||||
});
|
||||
}
|
||||
|
||||
function bundleAppJs(b, compress, callback) {
|
||||
const file = './public/js/app.min.js';
|
||||
writeJsBundle(b, file, compress, () => {
|
||||
if (process.argv.includes('--gzip')) {
|
||||
gzipFile(file);
|
||||
}
|
||||
console.info('Bundled app JS');
|
||||
callback();
|
||||
});
|
||||
}
|
||||
|
||||
function bundleJs() {
|
||||
if (!process.argv.includes('--no-vendor-js')) {
|
||||
bundleVendorJs(true);
|
||||
}
|
||||
|
||||
if (!process.argv.includes('--no-app-js')) {
|
||||
let watchify = require('watchify');
|
||||
let b = browserify({ debug: process.argv.includes('--debug') });
|
||||
if (!process.argv.includes('--no-transpile')) {
|
||||
b = b.transform('babelify');
|
||||
}
|
||||
b = b.external(external_js).add(glob.sync('./js/**/*.js'));
|
||||
const compress = !process.argv.includes('--debug');
|
||||
bundleAppJs(b, compress, () => { });
|
||||
}
|
||||
}
|
||||
|
||||
const environment = process.argv.includes('--watch') ? "development" : "production";
|
||||
|
||||
function bundleConfig() {
|
||||
function getVersion() {
|
||||
let build_info = process.env.BUILD_INFO;
|
||||
if (!build_info) {
|
||||
try {
|
||||
build_info = execSync('git describe --always --dirty --long --tags').toString();
|
||||
} catch (e) {
|
||||
console.warn('Cannot find build version');
|
||||
build_info = 'unknown';
|
||||
}
|
||||
}
|
||||
return build_info.trim();
|
||||
}
|
||||
const config = {
|
||||
meta: {
|
||||
version: getVersion(),
|
||||
buildDate: new Date().toUTCString()
|
||||
},
|
||||
environment: environment
|
||||
};
|
||||
|
||||
fs.writeFileSync('./js/.config.autogen.json', JSON.stringify(config));
|
||||
console.info('Generated config file');
|
||||
}
|
||||
|
||||
function bundleBinaryAssets() {
|
||||
fs.copyFileSync('./img/favicon.png', './public/img/favicon.png');
|
||||
console.info('Copied images');
|
||||
|
||||
fs.copyFileSync('./fonts/open_sans.woff2', './public/webfonts/open_sans.woff2')
|
||||
for (let file of glob.sync('./node_modules/@fortawesome/fontawesome-free/webfonts/*.*')) {
|
||||
if (fs.lstatSync(file).isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
fs.copyFileSync(file, path.join('./public/webfonts/', path.basename(file)));
|
||||
}
|
||||
if (process.argv.includes('--gzip')) {
|
||||
for (let file of glob.sync('./public/webfonts/*.*')) {
|
||||
if (file.endsWith('woff2')) {
|
||||
continue;
|
||||
}
|
||||
gzipFile(file);
|
||||
}
|
||||
}
|
||||
console.info('Copied fonts')
|
||||
}
|
||||
|
||||
function bundleWebAppFiles() {
|
||||
const Jimp = require('jimp');
|
||||
|
||||
fs.writeFileSync('./public/manifest.json', JSON.stringify(app_manifest));
|
||||
console.info('Generated app manifest');
|
||||
|
||||
Promise.all(webapp_icons.map(icon => {
|
||||
return Jimp.read('./img/app.png')
|
||||
.then(file => {
|
||||
file.resize(icon.size, Jimp.AUTO, Jimp.RESIZE_BEZIER)
|
||||
.write(path.join('./public/img/', icon.name));
|
||||
});
|
||||
}))
|
||||
.then(() => {
|
||||
console.info('Generated webapp icons');
|
||||
});
|
||||
|
||||
Promise.all(webapp_splash_screens.map(dim => {
|
||||
return Jimp.read('./img/splash.png')
|
||||
.then(file => {
|
||||
file.resize(dim.center, Jimp.AUTO, Jimp.RESIZE_BEZIER)
|
||||
.background(0xFFFFFFFF)
|
||||
.contain(dim.w, dim.center,
|
||||
Jimp.HORIZONTAL_ALIGN_CENTER | Jimp.VERTICAL_ALIGN_MIDDLE)
|
||||
.contain(dim.w, dim.h,
|
||||
Jimp.HORIZONTAL_ALIGN_CENTER | Jimp.VERTICAL_ALIGN_MIDDLE)
|
||||
.write(path.join('./public/img/',
|
||||
'apple-touch-startup-image-' + dim.w + 'x' + dim.h + '.png'));
|
||||
});
|
||||
}))
|
||||
.then(() => {
|
||||
console.info('Generated splash screens');
|
||||
});
|
||||
}
|
||||
|
||||
function makeOutputDirs() {
|
||||
const dirs = [
|
||||
'./public',
|
||||
'./public/css',
|
||||
'./public/webfonts',
|
||||
'./public/img',
|
||||
'./public/js'
|
||||
];
|
||||
for (let dir of dirs) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, 0o755);
|
||||
console.info('Created directory: ' + dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function watch() {
|
||||
let wss = new WebSocket.Server({ port: 8080 });
|
||||
const liveReload = !process.argv.includes('--no-live-reload');
|
||||
|
||||
function emitReload() {
|
||||
if (liveReload) {
|
||||
console.log("Requesting live reload.")
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send("reload");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
chokidar.watch('./fonts/**/*').on('change', () => {
|
||||
try {
|
||||
bundleBinaryAssets();
|
||||
emitReload();
|
||||
} catch (e) {
|
||||
console.error(pe.render(e));
|
||||
}
|
||||
});
|
||||
chokidar.watch('./img/**/*').on('change', () => {
|
||||
try {
|
||||
bundleWebAppFiles();
|
||||
emitReload();
|
||||
} catch (e) {
|
||||
console.error(pe.render(e));
|
||||
}
|
||||
});
|
||||
chokidar.watch('./html/**/*.tpl').on('change', () => {
|
||||
try {
|
||||
bundleHtml();
|
||||
} catch (e) {
|
||||
console.error(pe.render(e));
|
||||
}
|
||||
});
|
||||
chokidar.watch('./css/**/*.styl').on('change', () => {
|
||||
try {
|
||||
bundleCss()
|
||||
emitReload();
|
||||
} catch (e) {
|
||||
console.error(pe.render(e));
|
||||
}
|
||||
});
|
||||
|
||||
bundleBinaryAssets();
|
||||
bundleWebAppFiles();
|
||||
bundleCss();
|
||||
bundleHtml();
|
||||
|
||||
bundleVendorJs(true);
|
||||
|
||||
let watchify = require('watchify');
|
||||
let b = browserify({
|
||||
debug: process.argv.includes('--debug'),
|
||||
entries: ['js/main.js'],
|
||||
cache: {},
|
||||
packageCache: {},
|
||||
});
|
||||
|
||||
b.plugin(watchify);
|
||||
|
||||
if (!process.argv.includes('--no-transpile')) {
|
||||
b = b.transform('babelify');
|
||||
}
|
||||
b = b.external(external_js).add(glob.sync('./js/**/*.js'));
|
||||
const compress = false;
|
||||
|
||||
function bundle(id) {
|
||||
console.info("Rebundling app JS...");
|
||||
let start = new Date();
|
||||
bundleAppJs(b, compress, () => {
|
||||
let end = new Date() - start;
|
||||
console.info('Rebundled in %ds.', end / 1000)
|
||||
emitReload();
|
||||
});
|
||||
}
|
||||
|
||||
b.on('update', bundle);
|
||||
bundle();
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
|
||||
console.log("Building for '" + environment + "' environment.");
|
||||
makeOutputDirs();
|
||||
bundleConfig();
|
||||
if (process.argv.includes('--watch')) {
|
||||
watch();
|
||||
} else {
|
||||
if (!process.argv.includes('--no-binary-assets')) {
|
||||
bundleBinaryAssets();
|
||||
}
|
||||
if (!process.argv.includes('--no-web-app-files')) {
|
||||
bundleWebAppFiles();
|
||||
}
|
||||
if (!process.argv.includes('--no-html')) {
|
||||
bundleHtml();
|
||||
}
|
||||
if (!process.argv.includes('--no-css')) {
|
||||
bundleCss();
|
||||
}
|
||||
if (!process.argv.includes('--no-js')) {
|
||||
bundleJs();
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
56
client/css/metric-sorter-view.styl
Normal file
56
client/css/metric-sorter-view.styl
Normal file
|
@ -0,0 +1,56 @@
|
|||
@import colors
|
||||
|
||||
#metric-sorter
|
||||
width: 100%
|
||||
@media (max-width: 1000px)
|
||||
padding: 0 !important
|
||||
background-color: white !important
|
||||
h2
|
||||
display: none
|
||||
.messages .message
|
||||
margin-top: 0.5em
|
||||
margin-bottom: 0
|
||||
form
|
||||
width: 100%
|
||||
.posts-container
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
margin-bottom: 1em
|
||||
@media (max-width: 1000px)
|
||||
margin-left: -1em
|
||||
margin-right: -1em
|
||||
margin-top: -1em
|
||||
.left-post-container, .right-post-container
|
||||
flex: 1
|
||||
width: 100%
|
||||
.append
|
||||
color: $inactive-link-color
|
||||
.sorting-buttons
|
||||
display: flex
|
||||
@media (min-width: 1000px)
|
||||
padding: 0 0.5em
|
||||
@media (max-width: 1000px)
|
||||
padding: 0.5em 1em
|
||||
width: 100%
|
||||
.compare-block
|
||||
margin: auto
|
||||
button
|
||||
width: 1.3em
|
||||
height: 1.3em
|
||||
font-size: 200%
|
||||
@media (min-width: 1000px)
|
||||
padding: 0
|
||||
padding-top: 0.05em
|
||||
@media (max-width: 1000px)
|
||||
padding: 0
|
||||
i
|
||||
transform: rotate(90deg)
|
||||
|
||||
@media (max-width: 1000px)
|
||||
display: flex
|
||||
.save-btn
|
||||
margin: auto
|
||||
margin-right: 1em
|
||||
.skip-btn
|
||||
margin: auto
|
||||
margin-left: 1em
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
49
client/css/post-metric-input.styl
Normal file
49
client/css/post-metric-input.styl
Normal file
|
@ -0,0 +1,49 @@
|
|||
@import colors
|
||||
|
||||
.metric-controls
|
||||
margin-left: 1.4em
|
||||
display: inline-block
|
||||
color: $inactive-link-color
|
||||
|
||||
hr.separator
|
||||
display: none
|
||||
|
||||
ul.compact-unset-metrics, ul.compact-post-metrics
|
||||
width: 100%
|
||||
margin: 0.5em 0 0 0
|
||||
padding: 0
|
||||
li
|
||||
margin: 0
|
||||
width: 100%
|
||||
line-height: 140%
|
||||
overflow: hidden
|
||||
text-overflow: ellipsis
|
||||
transition: background-color 0.5s linear
|
||||
a
|
||||
display: inline
|
||||
a:focus
|
||||
outline: 0
|
||||
box-shadow: inset 0 0 0 2px $main-color
|
||||
.remove-metric
|
||||
color: $inactive-link-color
|
||||
unselectable()
|
||||
margin-right: 0.5em
|
||||
i
|
||||
padding-right: 0.4em
|
||||
.metric-bounds
|
||||
color: $inactive-link-color
|
||||
unselectable()
|
||||
font-size: 90%
|
||||
margin-left: 0.7em
|
||||
|
||||
ul.compact-post-metrics
|
||||
input[type=number]
|
||||
margin-left: 0.5em
|
||||
margin-bottom: 0.5em
|
||||
width: 4em
|
||||
min-width: 60px
|
||||
label
|
||||
display: none
|
||||
.range-delimiter
|
||||
margin-left: 0.5em
|
||||
color: $inactive-link-color
|
10
client/css/settings.styl
Normal file
10
client/css/settings.styl
Normal file
|
@ -0,0 +1,10 @@
|
|||
#settings
|
||||
.uploadSafety
|
||||
&>label
|
||||
width: 100%
|
||||
.radio-wrapper
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
.radio-wrapper label
|
||||
flex-grow: 1
|
||||
display: inline-block
|
|
@ -21,11 +21,10 @@
|
|||
.details
|
||||
font-size: 90%
|
||||
line-height: 130%
|
||||
.image
|
||||
margin: 0.25em 0.6em 0.25em 0
|
||||
.thumbnail
|
||||
width: 3em
|
||||
height: 3em
|
||||
margin: 0.25em 0.6em 0 0
|
||||
|
||||
.darktheme .user-list
|
||||
ul li
|
||||
|
|
16
client/hooks/build
Normal file
16
client/hooks/build
Normal file
|
@ -0,0 +1,16 @@
|
|||
#!/bin/sh
|
||||
|
||||
CLOSEST_VER=$(git describe --tags --abbrev=0 ${SOURCE_COMMIT})
|
||||
if git describe --exact-match --abbrev=0 ${SOURCE_COMMIT} 2> /dev/null; then
|
||||
BUILD_INFO="v${CLOSEST_VER}"
|
||||
else
|
||||
BUILD_INFO="v${CLOSEST_VER}-edge-$(git rev-parse --short ${SOURCE_COMMIT})"
|
||||
fi
|
||||
|
||||
echo "Using BUILD_INFO=${BUILD_INFO}"
|
||||
docker build \
|
||||
--build-arg BUILD_INFO=${BUILD_INFO} \
|
||||
--build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \
|
||||
--build-arg SOURCE_COMMIT \
|
||||
--build-arg DOCKER_REPO \
|
||||
-f $DOCKERFILE_PATH -t $IMAGE_NAME .
|
19
client/hooks/post_push
Normal file
19
client/hooks/post_push
Normal file
|
@ -0,0 +1,19 @@
|
|||
#!/bin/sh
|
||||
|
||||
add_tag() {
|
||||
echo "Also tagging image as ${DOCKER_REPO}:${1}"
|
||||
docker tag $IMAGE_NAME $DOCKER_REPO:$1
|
||||
docker push $DOCKER_REPO:$1
|
||||
}
|
||||
|
||||
CLOSEST_VER=$(git describe --tags --abbrev=0)
|
||||
CLOSEST_MAJOR_VER=$(echo ${CLOSEST_VER} | cut -d'.' -f1)
|
||||
CLOSEST_MINOR_VER=$(echo ${CLOSEST_VER} | cut -d'.' -f2)
|
||||
|
||||
add_tag "${CLOSEST_MAJOR_VER}-edge"
|
||||
add_tag "${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}-edge"
|
||||
|
||||
if git describe --exact-match --abbrev=0 2> /dev/null; then
|
||||
add_tag "${CLOSEST_MAJOR_VER}"
|
||||
add_tag "${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}"
|
||||
fi
|
26
client/html/compact_metric_list_item.tpl
Normal file
26
client/html/compact_metric_list_item.tpl
Normal file
|
@ -0,0 +1,26 @@
|
|||
<li><!--
|
||||
--><% if (ctx.editMode) { %><!--
|
||||
--><a href="<%- ctx.formatClientLink('tag', ctx.tag.names[0]) %>"
|
||||
class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
|
||||
--><i class='fa fa-sliders-h tag-icon'></i><!--
|
||||
--></a><!--
|
||||
--><a href="<%- ctx.formatClientLink('posts', {
|
||||
query: 'metric-' + ctx.escapeTagName(ctx.tag.names[0]) +
|
||||
':' + ctx.tag.metric.min + '..' + ctx.tag.metric.max +
|
||||
' sort:metric-' + ctx.escapeTagName(ctx.tag.names[0])
|
||||
}) %>"
|
||||
class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
|
||||
--><%- ctx.tag.names[0] %> <!--
|
||||
--></a><!--
|
||||
--><span class='metric-bounds' data-pseudo-content=
|
||||
'<%- ctx.tag.metric.min %> — <%- ctx.tag.metric.max %>'></span><!--
|
||||
--><span class='metric-controls'>Set<!--
|
||||
--><a href class='create-exact'> exact</a><!--
|
||||
--><a href class='create-range'> range</a><!--
|
||||
--><a href='<%= ctx.getMetricSorterUrl(ctx.post.id, {
|
||||
metrics: ctx.tag.names[0],
|
||||
query: ctx.query}) %>'
|
||||
class='sort'> sort</a><!--
|
||||
--></span><!--
|
||||
--><% } %><!--
|
||||
--></li>
|
32
client/html/compact_post_metric_list_item.tpl
Normal file
32
client/html/compact_post_metric_list_item.tpl
Normal file
|
@ -0,0 +1,32 @@
|
|||
<li><!--
|
||||
--><% if (ctx.editMode) { %><!--
|
||||
--><a href class='remove-metric' data-pseudo-content='×'/><!--
|
||||
--><a href="<%- ctx.formatClientLink('posts', {
|
||||
query: 'metric-' + ctx.escapeTagName(ctx.tag.names[0]) +
|
||||
':' + ctx.tag.metric.min + '..' + ctx.tag.metric.max +
|
||||
' sort:metric-' + ctx.escapeTagName(ctx.tag.names[0])
|
||||
}) %>"
|
||||
class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
|
||||
--><i class='fas fa-angle-right tag-icon'></i><!--
|
||||
--><%- ctx.postMetric.tagName %>:</a><!--
|
||||
--><%= ctx.makeNumericInput({
|
||||
name: 'value',
|
||||
value: ctx.postMetric.value,
|
||||
step: 'any',
|
||||
min: ctx.tag.metric.min,
|
||||
max: ctx.tag.metric.max,
|
||||
}) %><!--
|
||||
--><% } else { %><!--
|
||||
--><a href="<%- ctx.formatClientLink('tag', ctx.tag.names[0]) %>"
|
||||
class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
|
||||
--><i class='fas fa-angle-right tag-icon'></i><!--
|
||||
--></a><!--
|
||||
--><a href="<%- ctx.formatClientLink('posts', {
|
||||
query: 'metric-' + ctx.escapeTagName(ctx.tag.names[0]) +
|
||||
':' + ctx.tag.metric.min + '..' + ctx.tag.metric.max +
|
||||
' sort:metric-' + ctx.escapeTagName(ctx.tag.names[0])
|
||||
}) %>"
|
||||
class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
|
||||
--><%- ctx.postMetric.tagName %>: <%- ctx.postMetric.value || 0 %></a><!--
|
||||
--><% } %><!--
|
||||
--></li>
|
42
client/html/compact_post_metric_range_list_item.tpl
Normal file
42
client/html/compact_post_metric_range_list_item.tpl
Normal file
|
@ -0,0 +1,42 @@
|
|||
<li><!--
|
||||
--><% if (ctx.editMode) { %><!--
|
||||
--><a class='remove-metric' data-pseudo-content='×'/><!--
|
||||
--><a href="<%- ctx.formatClientLink('posts', {
|
||||
query: 'metric-' + ctx.escapeTagName(ctx.tag.names[0]) +
|
||||
':' + ctx.tag.metric.min + '..' + ctx.tag.metric.max +
|
||||
' sort:metric-' + ctx.escapeTagName(ctx.tag.names[0])
|
||||
}) %>"
|
||||
class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
|
||||
--><i class='fas fa-arrows-alt-h tag-icon'></i><!--
|
||||
--><%- ctx.postMetricRange.tagName %>:</a><!--
|
||||
--><%= ctx.makeNumericInput({
|
||||
name: 'low',
|
||||
value: ctx.postMetricRange.low,
|
||||
step: 'any',
|
||||
min: ctx.tag.metric.min,
|
||||
max: ctx.tag.metric.max,
|
||||
}) %><!--
|
||||
--><span class='range-delimiter'>—</span><!--
|
||||
--><%= ctx.makeNumericInput({
|
||||
name: 'high',
|
||||
value: ctx.postMetricRange.high,
|
||||
step: 'any',
|
||||
min: ctx.tag.metric.min,
|
||||
max: ctx.tag.metric.max,
|
||||
}) %><!--
|
||||
--><% } else { %><!--
|
||||
--><a href="<%- ctx.formatClientLink('tag', ctx.tag.names[0]) %>"
|
||||
class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
|
||||
--><i class='fas fa-arrows-alt-h tag-icon'></i><!--
|
||||
--></a><!--
|
||||
--><a href="<%- ctx.formatClientLink('posts', {
|
||||
query: 'metric-' + ctx.escapeTagName(ctx.tag.names[0]) +
|
||||
':' + ctx.tag.metric.min + '..' + ctx.tag.metric.max +
|
||||
' sort:metric-' + ctx.escapeTagName(ctx.tag.names[0])
|
||||
}) %>"
|
||||
class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
|
||||
--><%- ctx.postMetricRange.tagName %>:
|
||||
<%- ctx.postMetricRange.low || 0 %> — <%- ctx.postMetricRange.high || 0 %><!--
|
||||
--></a><!--
|
||||
--><% } %><!--
|
||||
--></li>
|
|
@ -19,6 +19,11 @@ shortcuts:</p>
|
|||
<td>Go to newer/older page or post</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><kbd>R</kbd></td>
|
||||
<td>Go to random post</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><kbd>F</kbd></td>
|
||||
<td>Cycle post fit mode</td>
|
||||
|
@ -36,7 +41,7 @@ shortcuts:</p>
|
|||
|
||||
<tr>
|
||||
<td><kbd>Delete</kbd></td>
|
||||
<td>Delete post (while in edit mode)</td>
|
||||
<td>(In edit mode) delete post</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset='utf-8'/>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1'/>
|
||||
<meta name='theme-color' content='#24aadd'/>
|
||||
<meta name='apple-mobile-web-app-capable' content='yes'/>
|
||||
<meta name='apple-mobile-web-app-status-bar-style' content='black'/>
|
||||
|
|
11
client/html/metric_header.tpl
Normal file
11
client/html/metric_header.tpl
Normal file
|
@ -0,0 +1,11 @@
|
|||
<form class='horizontal'>
|
||||
<ul class='metric-list'></ul>
|
||||
<wbr>
|
||||
<%= ctx.makeCheckbox({
|
||||
text: 'Show values on posts',
|
||||
name: 'show-values-on-posts',
|
||||
checked: ctx.showValuesOnPost,
|
||||
class: 'append'}) %>
|
||||
<a class='mousetrap button append close sorting'
|
||||
href="<%= ctx.getMetricSorterUrl('random', ctx.parameters) %>">Start sorting</a>
|
||||
</form>
|
6
client/html/metric_header_item.tpl
Normal file
6
client/html/metric_header_item.tpl
Normal file
|
@ -0,0 +1,6 @@
|
|||
<li class="<%= ctx.makeCssName(ctx.metric.tag.category, 'tag') %><%
|
||||
if (ctx.selected) { %> selected<% } %>">
|
||||
<a href class="<%= ctx.makeCssName(ctx.metric.tag.category, 'tag') %><%
|
||||
if (ctx.selected) { %> selected<% } %>"><%
|
||||
%><%- ctx.metric.tag.names[0] %></a>
|
||||
</li>
|
39
client/html/metric_sorter.tpl
Normal file
39
client/html/metric_sorter.tpl
Normal file
|
@ -0,0 +1,39 @@
|
|||
<div class='content-wrapper' id='metric-sorter'>
|
||||
<h2>Sorting metric "<%- ctx.primaryMetric %>"</h2>
|
||||
<form>
|
||||
<div class='posts-container'>
|
||||
<div class='left-post-container'></div>
|
||||
<% if (window.innerWidth <= 1000) { %>
|
||||
<div class='messages'></div>
|
||||
<% } %>
|
||||
<div class='sorting-buttons'>
|
||||
<div class='compare-block'>
|
||||
<% if (window.innerWidth <= 1000) { %>
|
||||
<input class='mousetrap save-btn' type='submit' value='Save'>
|
||||
<% } %>
|
||||
<button class='compare left-lt-right'>
|
||||
<i class='fa fa-less-than'></i>
|
||||
</button>
|
||||
<button class='compare left-gt-right'>
|
||||
<i class='fa fa-greater-than'></i>
|
||||
</button>
|
||||
<% if (window.innerWidth <= 1000) { %>
|
||||
<a href class='mousetrap append skip-btn'>Skip</a>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
<div class='right-post-container'></div>
|
||||
</div>
|
||||
|
||||
<% if (window.innerWidth > 1000) { %>
|
||||
<div class='messages'></div>
|
||||
<% } %>
|
||||
|
||||
<div class='buttons'>
|
||||
<% if (window.innerWidth > 1000) { %>
|
||||
<input class='mousetrap save-btn' type='submit' value='Save'>
|
||||
<a href class='mousetrap append skip-btn'>Skip</a>
|
||||
<% } %>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
5
client/html/metric_sorter_side.tpl
Normal file
5
client/html/metric_sorter_side.tpl
Normal file
|
@ -0,0 +1,5 @@
|
|||
<% if (ctx.post) { %>
|
||||
<a href='<%= ctx.getPostUrl(ctx.post.id, ctx.parameters) %>'>
|
||||
<div class='post-container'></div>
|
||||
</a>
|
||||
<% } %>
|
|
@ -29,7 +29,20 @@
|
|||
<span class='vim-nav-hint'>Next post ></span>
|
||||
</a>
|
||||
</article>
|
||||
<% if (ctx.canEditPosts || ctx.canDeletePosts || ctx.canFeaturePosts) { %>
|
||||
<article class='random-post'>
|
||||
<% if (ctx.randomPostId) { %>
|
||||
<% if (ctx.editMode) { %>
|
||||
<a rel='next' href='<%= ctx.getPostEditUrl(ctx.randomPostId, ctx.parameters) %>'>
|
||||
<% } else { %>
|
||||
<a rel='next' href='<%= ctx.getPostUrl(ctx.randomPostId, ctx.parameters) %>'>
|
||||
<% } %>
|
||||
<% } else { %>
|
||||
<a rel='next' class='inactive'>
|
||||
<% } %>
|
||||
<i class='fa fa-random'></i>
|
||||
<span class='vim-nav-hint'>Random post</span>
|
||||
</a>
|
||||
</article>
|
||||
<article class='edit-post'>
|
||||
<% if (ctx.editMode) { %>
|
||||
<a href='<%= ctx.getPostUrl(ctx.post.id, ctx.parameters) %>'>
|
||||
|
@ -37,13 +50,16 @@
|
|||
<span class='vim-nav-hint'>Back to view mode</span>
|
||||
</a>
|
||||
<% } else { %>
|
||||
<a href='<%= ctx.getPostEditUrl(ctx.post.id, ctx.parameters) %>'>
|
||||
<i class='fa fa-pencil'></i>
|
||||
<span class='vim-nav-hint'>Edit post</span>
|
||||
<% if (ctx.canEditPosts || ctx.canDeletePosts || ctx.canFeaturePosts) { %>
|
||||
<a href='<%= ctx.getPostEditUrl(ctx.post.id, ctx.parameters) %>'>
|
||||
<% } else { %>
|
||||
<a class='inactive'>
|
||||
<% } %>
|
||||
<i class='fa fa-pencil'></i>
|
||||
<span class='vim-nav-hint'>Edit post</span>
|
||||
</a>
|
||||
<% } %>
|
||||
</article>
|
||||
<% } %>
|
||||
</nav>
|
||||
|
||||
<div class='sidebar-container'></div>
|
||||
|
@ -52,15 +68,13 @@
|
|||
<div class='content'>
|
||||
<div class='post-container'></div>
|
||||
|
||||
<div class='after-mobile-controls'>
|
||||
<% if (ctx.canCreateComments) { %>
|
||||
<h2>Add comment</h2>
|
||||
<div class='comment-form-container'></div>
|
||||
<% } %>
|
||||
<% if (ctx.canListComments) { %>
|
||||
<div class='comments-container'></div>
|
||||
<% } %>
|
||||
|
||||
<% if (ctx.canListComments) { %>
|
||||
<div class='comments-container'></div>
|
||||
<% } %>
|
||||
</div>
|
||||
<% if (ctx.canCreateComments) { %>
|
||||
<h2>Add comment</h2>
|
||||
<div class='comment-form-container'></div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
5
client/html/post_metric_input.tpl
Normal file
5
client/html/post_metric_input.tpl
Normal file
|
@ -0,0 +1,5 @@
|
|||
<div class='metric-input'>
|
||||
<ul class='compact-unset-metrics'></ul>
|
||||
<hr class='separator'>
|
||||
<ul class='compact-post-metrics'></ul>
|
||||
</div>
|
|
@ -17,8 +17,8 @@
|
|||
'video/mp4': 'MPEG-4',
|
||||
'video/quicktime': 'MOV',
|
||||
'application/x-shockwave-flash': 'SWF',
|
||||
}[ctx.post.mimeType] %><!--
|
||||
--></a>
|
||||
}[ctx.post.mimeType] %>
|
||||
</a>
|
||||
(<%- ctx.post.canvasWidth %>x<%- ctx.post.canvasHeight %>)
|
||||
<% if (ctx.post.flags.length) { %><!--
|
||||
--><% if (ctx.post.flags.includes('loop')) { %><i class='fa fa-repeat'></i><% } %><!--
|
||||
|
@ -99,10 +99,10 @@
|
|||
--><% if (ctx.canListPosts) { %><!--
|
||||
--><a href='<%- ctx.formatClientLink('posts', {query: ctx.escapeTagName(tag.names[0])}) %>' class='<%= ctx.makeCssName(tag.category, 'tag') %>'><!--
|
||||
--><% } %><!--
|
||||
--><%- ctx.getPrettyName(tag.names[0]) %><!--
|
||||
--><%- ctx.getPrettyName(tag.names[0]) %> <!--
|
||||
--><% if (ctx.canListPosts) { %><!--
|
||||
--></a><!--
|
||||
--><% } %> <!--
|
||||
--><% } %><!--
|
||||
--><span class='tag-usages' data-pseudo-content='<%- tag.postCount %>'></span><!--
|
||||
--></li><!--
|
||||
--><% } %><!--
|
||||
|
|
|
@ -4,6 +4,10 @@
|
|||
%><wbr/><%
|
||||
%><input class='mousetrap' type='submit' value='Search'/><%
|
||||
%><wbr/><%
|
||||
%><button id='randomize-button' class='icon-button'><%
|
||||
%><i class="fa fa-random"><%
|
||||
%></button><%
|
||||
%><wbr/><%
|
||||
%><% if (ctx.enableSafety) { %><%
|
||||
%><input data-safety=safe type='button' class='mousetrap safety safety-safe <%- ctx.settings.listPosts.safe ? '' : 'disabled' %>'/><%
|
||||
%><input data-safety=sketchy type='button' class='mousetrap safety safety-sketchy <%- ctx.settings.listPosts.sketchy ? '' : 'disabled' %>'/><%
|
||||
|
@ -16,6 +20,7 @@
|
|||
%><form class='horizontal bulk-edit bulk-edit-tags'><%
|
||||
%><span class='append hint'>Tagging with:</span><%
|
||||
%><a href class='mousetrap button append open'>Mass tag</a><%
|
||||
%><wbr/><%
|
||||
%><%= ctx.makeTextInput({name: 'tag', value: ctx.parameters.tag}) %><%
|
||||
%><input class='mousetrap start' type='submit' value='Start tagging'/><%
|
||||
%><a href class='mousetrap button append close'>Stop tagging</a><%
|
||||
|
|
5
client/html/similar_post_item.tpl
Normal file
5
client/html/similar_post_item.tpl
Normal file
|
@ -0,0 +1,5 @@
|
|||
<li>
|
||||
<a href='<%= ctx.getPostUrl(ctx.id, ctx.parameters) %>'>
|
||||
<%= ctx.makeThumbnail(ctx.thumbnailUrl) %>
|
||||
</a>
|
||||
</li>
|
40
client/html/tag_metric.tpl
Normal file
40
client/html/tag_metric.tpl
Normal file
|
@ -0,0 +1,40 @@
|
|||
<div class='tag-metric'>
|
||||
<form class='horizontal edit-metric'>
|
||||
<div class='metric-bounds-edit'>
|
||||
<%= ctx.makeNumericInput({
|
||||
text: 'Minimum',
|
||||
name: 'metric-min',
|
||||
value: ctx.metricMin,
|
||||
step: 'any',
|
||||
readonly: !ctx.canEditMetricBounds,
|
||||
}) %>
|
||||
<%= ctx.makeNumericInput({
|
||||
text: 'Maximum',
|
||||
name: 'metric-max',
|
||||
value: ctx.metricMax,
|
||||
step: 'any',
|
||||
readonly: !ctx.canEditMetricBounds,
|
||||
}) %>
|
||||
</div>
|
||||
|
||||
<% if (ctx.tag.metric && ctx.canDeleteMetric) { %>
|
||||
<div class='confirmation'>
|
||||
<%= ctx.makeCheckbox({name: 'confirm-delete',
|
||||
text: 'I confirm that I want to delete this metric.'}) %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class='messages'></div>
|
||||
|
||||
<div class='buttons'><!--
|
||||
--><% if (!ctx.tag.metric && ctx.canCreateMetric) { %><!--
|
||||
--><input type='submit' value='Create metric'/><!--
|
||||
--><% } else if (ctx.tag.metric && ctx.canEditMetricBounds) { %><!--
|
||||
--><input type='submit' value='Update metric'/><!--
|
||||
--><% } %><!--
|
||||
--><% if (ctx.tag.metric && ctx.canDeleteMetric) { %><!--
|
||||
--><input type='button' name='delete' class='delete' value='Delete metric'/><!--
|
||||
--><% } %>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
|
@ -23,6 +23,15 @@
|
|||
pattern: ctx.passwordPattern,
|
||||
}) %>
|
||||
</li>
|
||||
<li>
|
||||
<%= ctx.makeCodeInput({
|
||||
text: 'Entry code',
|
||||
name: 'code',
|
||||
placeholder: 'Secret code',
|
||||
required: true,
|
||||
pattern: ctx.codePattern,
|
||||
}) %>
|
||||
</li>
|
||||
<li>
|
||||
<%= ctx.makeEmailInput({
|
||||
text: 'Email',
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
187
client/js/controllers/metric_sorter_contoller.js
Normal file
187
client/js/controllers/metric_sorter_contoller.js
Normal file
|
@ -0,0 +1,187 @@
|
|||
'use strict';
|
||||
|
||||
const api = require('../api.js');
|
||||
const router = require('../router.js');
|
||||
const views = require('../util/views.js');
|
||||
const topNavigation = require('../models/top_navigation.js');
|
||||
const Post = require('../models/post.js');
|
||||
const PostMetric = require('../models/post_metric.js');
|
||||
const PostMetricRange = require('../models/post_metric_range.js');
|
||||
const PostList = require('../models/post_list.js');
|
||||
const MetricSorterView = require('../views/metric_sorter_view.js');
|
||||
const EmptyView = require('../views/empty_view.js');
|
||||
|
||||
const LEFT = 'left';
|
||||
const RIGHT = 'right';
|
||||
|
||||
class MetricSorterController {
|
||||
constructor(ctx) {
|
||||
if (!api.hasPrivilege('posts:view') ||
|
||||
!api.hasPrivilege('metrics:edit:posts')) {
|
||||
this._view = new EmptyView();
|
||||
this._view.showError('You don\'t have privileges to edit post metric values.');
|
||||
return;
|
||||
}
|
||||
|
||||
topNavigation.activate('posts');
|
||||
topNavigation.setTitle('Sorting metrics');
|
||||
|
||||
this._ctx = ctx;
|
||||
this._metricNames = (ctx.parameters.metrics || '')
|
||||
.split(' ')
|
||||
.filter(m => m);
|
||||
if (!this._metricNames.length) {
|
||||
this._view = new EmptyView();
|
||||
this._view.showError('No metrics selected');
|
||||
return;
|
||||
}
|
||||
this._primaryMetricName = this._metricNames[0];
|
||||
|
||||
this._view = new MetricSorterView({
|
||||
primaryMetric: this._primaryMetricName,
|
||||
greaterPost: RIGHT,
|
||||
});
|
||||
this._view.addEventListener('submit', e => this._evtSubmit(e));
|
||||
this._view.addEventListener('skip', e => this._evtSkip(e));
|
||||
this._view.addEventListener('changeMetric', e => this._evtChangeMetric(e));
|
||||
|
||||
if (ctx.parameters.id === 'random') {
|
||||
this.startSortingRandomPost();
|
||||
} else {
|
||||
this.startSortingPost(ctx.parameters.id);
|
||||
}
|
||||
}
|
||||
|
||||
startSortingPost(id) {
|
||||
this._view.clearMessages();
|
||||
this._foundExactValue = false;
|
||||
Post.get(id).then(post => {
|
||||
this._unsortedPost = post;
|
||||
this._view.installLeftPost(post);
|
||||
this.reloadMedianPost();
|
||||
}).catch(error => {
|
||||
this._view.showError(error.message)
|
||||
});
|
||||
}
|
||||
|
||||
startSortingRandomPost() {
|
||||
this._view.clearMessages();
|
||||
this._getRandomUnsortedPostId().then(id => {
|
||||
this._ctx.parameters.id = id;
|
||||
router.replace(views.getMetricSorterUrl(id, this._ctx.parameters));
|
||||
this.startSortingPost(id);
|
||||
}).catch(error => {
|
||||
this._view.showError(error.message)
|
||||
});
|
||||
}
|
||||
|
||||
reloadMedianPost() {
|
||||
const metricName = this._primaryMetricName;
|
||||
let range = this._getOrCreateRange(this._unsortedPost, metricName);
|
||||
this._tryGetMedianPost(metricName, range).then(medianResponse => {
|
||||
if (medianResponse.post) {
|
||||
this._sortedPost = medianResponse.post;
|
||||
this._view.installRightPost(this._sortedPost);
|
||||
} else {
|
||||
// No existing metrics, apply the median value
|
||||
this._foundExactValue = true;
|
||||
let exactValue = (medianResponse.range.low + medianResponse.range.high) / 2;
|
||||
this._view.showSuccess(`Found exact value: ${exactValue}`);
|
||||
this._setExactMetric(this._unsortedPost, metricName, exactValue);
|
||||
//TODO: maybe allow to set exact value?
|
||||
}
|
||||
}).catch(error => {
|
||||
this._view.showError(error.message)
|
||||
});
|
||||
}
|
||||
|
||||
_getRandomUnsortedPostId() {
|
||||
let unsetMetricsQuery = this._metricNames
|
||||
.map(m => `${m} -metric:${m}`)
|
||||
.join(' ');
|
||||
let filterQuery = this._ctx.parameters.query || '';
|
||||
let unsetFullQuery = `${filterQuery} ${unsetMetricsQuery} sort:random`;
|
||||
|
||||
return PostList.search(unsetFullQuery,
|
||||
this._ctx.parameters.skips || 0, 1, ['id']).then(response => {
|
||||
if (!response.results.length) {
|
||||
return Promise.reject(new Error('No posts found'));
|
||||
} else {
|
||||
return Promise.resolve(response.results.at(0).id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_tryGetMedianPost(metric, range) {
|
||||
let low = range.low + 0.000000001;
|
||||
let high = range.high - 0.000000001;
|
||||
let median_query = `metric-${metric}:${low}..${high} sort:metric-${metric}`;
|
||||
return PostList.getMedian(median_query, []).then(response => {
|
||||
return Promise.resolve({
|
||||
range: range,
|
||||
post: response.results.at(0)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_getOrCreateRange(post, metricName) {
|
||||
let range = post.metricRanges.findByTagName(metricName);
|
||||
if (!range) {
|
||||
let tag = post.tags.findByName(metricName);
|
||||
range = PostMetricRange.create(post.id, tag);
|
||||
post.metricRanges.add(range);
|
||||
}
|
||||
return range;
|
||||
}
|
||||
|
||||
_setExactMetric(post, metricName, value) {
|
||||
let range = post.metricRanges.findByTagName(metricName);
|
||||
if (!range) {
|
||||
post.metricRanges.remove(range);
|
||||
}
|
||||
let tag = post.tags.findByName(metricName);
|
||||
let exactMetric = PostMetric.create(post.id, tag);
|
||||
exactMetric.value = value;
|
||||
post.metrics.add(exactMetric);
|
||||
}
|
||||
|
||||
_evtSubmit(e) {
|
||||
let range = this._getOrCreateRange(this._unsortedPost, this._primaryMetricName);
|
||||
if (this._foundExactValue) {
|
||||
this._unsortedPost.metricRanges.remove(range);
|
||||
} else {
|
||||
let medianValue = this._sortedPost.metrics.findByTagName(this._primaryMetricName).value;
|
||||
if (e.detail.greaterPost === LEFT) {
|
||||
range.low = medianValue;
|
||||
} else {
|
||||
range.high = medianValue;
|
||||
}
|
||||
}
|
||||
this._unsortedPost.save().then(() => {
|
||||
if (this._foundExactValue) {
|
||||
this.startSortingRandomPost();
|
||||
} else {
|
||||
this.reloadMedianPost();
|
||||
}
|
||||
}, error => {
|
||||
this._view.showError(error.message)
|
||||
});
|
||||
}
|
||||
|
||||
_evtSkip(e) {
|
||||
this._ctx.parameters.skips = (this._ctx.parameters.skips || 0) + 1;
|
||||
this.startSortingRandomPost();
|
||||
}
|
||||
|
||||
_evtChangeMetric(e) {
|
||||
// this._primaryMetricName = e.detail.metricName;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = router => {
|
||||
router.enter(
|
||||
['post', ':id', 'metric-sorter'],
|
||||
(ctx, next) => {
|
||||
ctx.controller = new MetricSorterController(ctx);
|
||||
});
|
||||
};
|
|
@ -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(
|
||||
|
|
|
@ -43,8 +43,6 @@ class PoolListController {
|
|||
this._headerView.addEventListener(
|
||||
"submit",
|
||||
(e) => this._evtSubmit(e),
|
||||
);
|
||||
this._headerView.addEventListener(
|
||||
"navigate",
|
||||
(e) => this._evtNavigate(e)
|
||||
);
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
85
client/js/controls/metric_header_control.js
Normal file
85
client/js/controls/metric_header_control.js
Normal file
|
@ -0,0 +1,85 @@
|
|||
'use strict';
|
||||
|
||||
const events = require('../events.js');
|
||||
const misc = require('../util/misc.js');
|
||||
const views = require('../util/views.js');
|
||||
const MetricList = require('../models/metric_list.js');
|
||||
|
||||
const mainTemplate = views.getTemplate('metric-header');
|
||||
const metricItemTemplate = views.getTemplate('metric-header-item');
|
||||
|
||||
class MetricHeaderControl extends events.EventTarget {
|
||||
constructor(hostNode, ctx) {
|
||||
super();
|
||||
this._ctx = ctx;
|
||||
this._hostNode = hostNode;
|
||||
this._selectedMetrics = new MetricList();
|
||||
|
||||
this._headerNode = mainTemplate(ctx);
|
||||
this._metricListNode = this._headerNode.querySelector('ul.metric-list');
|
||||
|
||||
this._hostNode.insertBefore(
|
||||
this._headerNode, this._hostNode.nextSibling);
|
||||
|
||||
MetricList.loadAll().then(response => {
|
||||
this._ctx.allMetrics = response.results;
|
||||
this._addSelectedMetrics(ctx.parameters.metrics);
|
||||
this._installMetrics(response.results);
|
||||
this._refreshStartSortingButton();
|
||||
});
|
||||
}
|
||||
|
||||
_addSelectedMetrics(metricsStr) {
|
||||
let selectedNames = (metricsStr || '').split(' ');
|
||||
for (let metric of [...this._ctx.allMetrics]) {
|
||||
if (selectedNames.includes(metric.tag.names[0])) {
|
||||
this._selectedMetrics.add(metric);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_installMetrics(metrics) {
|
||||
for (let metric of metrics) {
|
||||
const node = metricItemTemplate(Object.assign({},
|
||||
{
|
||||
metric: metric,
|
||||
selected: this._selectedMetrics.includes(metric),
|
||||
},
|
||||
this._ctx));
|
||||
node.addEventListener('click', e =>
|
||||
this._evtMetricClicked(e, node, metric));
|
||||
this._metricListNode.appendChild(node);
|
||||
}
|
||||
}
|
||||
|
||||
_evtMetricClicked(e, node, metric) {
|
||||
e.preventDefault();
|
||||
node.classList.toggle('selected');
|
||||
node.querySelector('a').classList.toggle('selected');
|
||||
if (this._selectedMetrics.includes(metric)) {
|
||||
this._selectedMetrics.remove(metric);
|
||||
} else {
|
||||
this._selectedMetrics.add(metric);
|
||||
}
|
||||
this._ctx.parameters = Object.assign({},
|
||||
this._ctx.parameters, {
|
||||
metrics: this._selectedMetrics
|
||||
.map(m => m.tag.names[0]).join(' '),
|
||||
});
|
||||
this._refreshStartSortingButton();
|
||||
this.dispatchEvent(new CustomEvent('submit'));
|
||||
}
|
||||
|
||||
_refreshStartSortingButton() {
|
||||
let btn = this._hostNode.querySelector('a.sorting');
|
||||
btn.hidden = !this._selectedMetrics.length;
|
||||
btn.setAttribute('href', views.getMetricSorterUrl('random', this._ctx.parameters));
|
||||
}
|
||||
|
||||
refreshQuery(query) {
|
||||
this._ctx.parameters.query = query;
|
||||
this._refreshStartSortingButton();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MetricHeaderControl;
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -427,7 +427,7 @@ class PostEditSidebarControl extends events.EventTarget {
|
|||
: undefined,
|
||||
|
||||
thumbnail:
|
||||
this._newPostThumbnail !== undefined && this._newPostThumbnail !== null
|
||||
this._newPostThumbnail !== undefined
|
||||
? this._newPostThumbnail
|
||||
: undefined,
|
||||
|
||||
|
|
164
client/js/controls/post_metric_input_control.js
Normal file
164
client/js/controls/post_metric_input_control.js
Normal file
|
@ -0,0 +1,164 @@
|
|||
'use strict';
|
||||
|
||||
const uri = require('../util/uri.js');
|
||||
const PostMetric = require('../models/post_metric.js');
|
||||
const PostMetricRange = require('../models/post_metric_range.js');
|
||||
const events = require('../events.js');
|
||||
const views = require('../util/views.js');
|
||||
|
||||
const mainTemplate = views.getTemplate('post-metric-input');
|
||||
const metricNodeTemplate = views.getTemplate('compact-metric-list-item');
|
||||
const postMetricNodeTemplate = views.getTemplate('compact-post-metric-list-item');
|
||||
const postMetricRangeNodeTemplate = views.getTemplate('compact-post-metric-range-list-item');
|
||||
|
||||
class PostMetricInputControl extends events.EventTarget {
|
||||
constructor(hostNode, ctx) {
|
||||
super();
|
||||
this._ctx = ctx;
|
||||
this._post = ctx.post;
|
||||
this._hostNode = hostNode;
|
||||
|
||||
// dom
|
||||
const editAreaNode = mainTemplate({
|
||||
tags: this._post.tags,
|
||||
postMetrics: this._post.metrics,
|
||||
});
|
||||
this._editAreaNode = editAreaNode;
|
||||
this._metricListNode = editAreaNode.querySelector('ul.compact-unset-metrics');
|
||||
this._separatorNode = editAreaNode.querySelector('hr.separator');
|
||||
this._postMetricListNode = editAreaNode.querySelector('ul.compact-post-metrics');
|
||||
|
||||
// show
|
||||
this._hostNode.style.display = 'none';
|
||||
this._hostNode.parentNode.insertBefore(
|
||||
this._editAreaNode, hostNode.nextSibling);
|
||||
|
||||
// add existing metrics and post metrics:
|
||||
this.refreshContent();
|
||||
}
|
||||
|
||||
refreshContent() {
|
||||
this._metricListNode.innerHTML = '';
|
||||
for (let tag of this._post.tags.filterMetrics()) {
|
||||
const metricNode = this._createMetricNode(tag);
|
||||
this._metricListNode.appendChild(metricNode);
|
||||
}
|
||||
this._postMetricListNode.innerHTML = '';
|
||||
for (let pm of this._post.metrics) {
|
||||
const postMetricNode = this._createPostMetricNode(pm);
|
||||
this._postMetricListNode.appendChild(postMetricNode);
|
||||
}
|
||||
for (let pmr of this._post.metricRanges) {
|
||||
const postMetricRangeNode = this._createPostMetricRangeNode(pmr);
|
||||
this._postMetricListNode.appendChild(postMetricRangeNode);
|
||||
}
|
||||
this._separatorNode.style.display =
|
||||
this._postMetricListNode.innerHTML ? 'block' : 'none';
|
||||
}
|
||||
|
||||
_createMetricNode(tag) {
|
||||
const node = metricNodeTemplate({
|
||||
editMode: true,
|
||||
tag: tag,
|
||||
post: this._post,
|
||||
query: this._ctx.parameters.query,
|
||||
});
|
||||
const createExactNode = node.querySelector('a.create-exact');
|
||||
if (this._post.metrics.hasTagName(tag.names[0])) {
|
||||
createExactNode.style.display = 'none';
|
||||
} else {
|
||||
createExactNode.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
this.createPostMetric(tag);
|
||||
});
|
||||
}
|
||||
const createRangeNode = node.querySelector('a.create-range');
|
||||
if (this._post.metricRanges.hasTagName(tag.names[0])) {
|
||||
createRangeNode.style.display = 'none';
|
||||
} else {
|
||||
createRangeNode.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
this.createPostMetricRange(tag);
|
||||
});
|
||||
}
|
||||
const sortNode = node.querySelector('a.sort');
|
||||
if (this._post.metrics.hasTagName(tag.names[0])) {
|
||||
sortNode.style.display = 'none';
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
_createPostMetricNode(pm) {
|
||||
const tag = this._post.tags.findByName(pm.tagName);
|
||||
const node = postMetricNodeTemplate({
|
||||
editMode: true,
|
||||
postMetric: pm,
|
||||
tag: tag,
|
||||
});
|
||||
node.querySelector('input[name=value]').addEventListener('change', e => {
|
||||
pm.value = e.target.value;
|
||||
this.dispatchEvent(new CustomEvent('change'));
|
||||
});
|
||||
node.querySelector('.remove-metric').addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
this.deletePostMetric(pm);
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
_createPostMetricRangeNode(pmr) {
|
||||
const tag = this._post.tags.findByName(pmr.tagName);
|
||||
const node = postMetricRangeNodeTemplate({
|
||||
editMode: true,
|
||||
postMetricRange: pmr,
|
||||
tag: tag,
|
||||
});
|
||||
node.querySelector('input[name=low]').addEventListener('change', e => {
|
||||
pmr.low = e.target.value;
|
||||
this.dispatchEvent(new CustomEvent('change'));
|
||||
});
|
||||
node.querySelector('input[name=high]').addEventListener('change', e => {
|
||||
pmr.high = e.target.value;
|
||||
this.dispatchEvent(new CustomEvent('change'));
|
||||
});
|
||||
node.querySelector('.remove-metric').addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
this.deletePostMetricRange(pmr);
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
createPostMetric(tag) {
|
||||
let postMetricRange = this._post.metricRanges.findByTagName(tag.names[0]);
|
||||
if (postMetricRange) {
|
||||
this._post.metricRanges.remove(postMetricRange);
|
||||
}
|
||||
this._post.metrics.add(PostMetric.create(this._post.id, tag));
|
||||
this.refreshContent();
|
||||
this.dispatchEvent(new CustomEvent('change'));
|
||||
}
|
||||
|
||||
createPostMetricRange(tag) {
|
||||
let postMetric = this._post.metrics.findByTagName(tag.names[0]);
|
||||
if (postMetric) {
|
||||
this._post.metrics.remove(postMetric);
|
||||
}
|
||||
this._post.metricRanges.add(PostMetricRange.create(this._post.id, tag));
|
||||
this.refreshContent();
|
||||
this.dispatchEvent(new CustomEvent('change'));
|
||||
}
|
||||
|
||||
deletePostMetric(pm) {
|
||||
this._post.metrics.remove(pm);
|
||||
this.refreshContent();
|
||||
this.dispatchEvent(new CustomEvent('change'));
|
||||
}
|
||||
|
||||
deletePostMetricRange(pmr) {
|
||||
this._post.metricRanges.remove(pmr);
|
||||
this.refreshContent();
|
||||
this.dispatchEvent(new CustomEvent('change'));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PostMetricInputControl;
|
51
client/js/controls/post_metric_list_control.js
Normal file
51
client/js/controls/post_metric_list_control.js
Normal file
|
@ -0,0 +1,51 @@
|
|||
'use strict';
|
||||
|
||||
const events = require('../events.js');
|
||||
const views = require('../util/views.js');
|
||||
|
||||
const postMetricNodeTemplate = views.getTemplate('compact-post-metric-list-item');
|
||||
const postMetricRangeNodeTemplate = views.getTemplate('compact-post-metric-range-list-item');
|
||||
|
||||
class PostMetricListControl extends events.EventTarget {
|
||||
constructor(listNode, post) {
|
||||
super();
|
||||
this._post = post;
|
||||
this._listNode = listNode;
|
||||
|
||||
this._refreshContent();
|
||||
}
|
||||
|
||||
_refreshContent() {
|
||||
this._listNode.innerHTML = '';
|
||||
for (let pm of this._post.metrics) {
|
||||
const postMetricNode = this._createPostMetricNode(pm);
|
||||
this._listNode.appendChild(postMetricNode);
|
||||
}
|
||||
for (let pmr of this._post.metricRanges) {
|
||||
const postMetricRangeNode = this._createPostMetricRangeNode(pmr);
|
||||
this._listNode.appendChild(postMetricRangeNode);
|
||||
}
|
||||
}
|
||||
|
||||
_createPostMetricNode(pm) {
|
||||
const tag = this._post.tags.findByName(pm.tagName);
|
||||
const node = postMetricNodeTemplate({
|
||||
editMode: false,
|
||||
postMetric: pm,
|
||||
tag: tag,
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
_createPostMetricRangeNode(pmr) {
|
||||
const tag = this._post.tags.findByName(pmr.tagName);
|
||||
const node = postMetricRangeNodeTemplate({
|
||||
editMode: false,
|
||||
postMetricRange: pmr,
|
||||
tag: tag,
|
||||
});
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PostMetricListControl;
|
88
client/js/models/metric.js
Normal file
88
client/js/models/metric.js
Normal file
|
@ -0,0 +1,88 @@
|
|||
'use strict';
|
||||
|
||||
const api = require('../api.js');
|
||||
const uri = require('../util/uri.js');
|
||||
const events = require('../events.js');
|
||||
const misc = require('../util/misc.js');
|
||||
const Tag = require('./tag.js');
|
||||
|
||||
class Metric extends events.EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this._orig = {};
|
||||
|
||||
this._updateFromResponse({});
|
||||
}
|
||||
|
||||
get version() { return this._version; }
|
||||
get min() { return this._min; }
|
||||
get max() { return this._max; }
|
||||
get tag() { return this._tag; }
|
||||
|
||||
set min(value) { this._min = value; }
|
||||
set max(value) { this._max = value; }
|
||||
|
||||
static fromResponse(response) {
|
||||
const ret = new Metric();
|
||||
ret._updateFromResponse(response);
|
||||
return ret;
|
||||
}
|
||||
|
||||
static get(name) {
|
||||
//TODO get metric. Or only via tag?
|
||||
return api.get(uri.formatApiLink('metric', name))
|
||||
.then(response => {
|
||||
return Promise.resolve(Metric.fromResponse(response));
|
||||
});
|
||||
}
|
||||
|
||||
save() {
|
||||
const detail = {version: this._version};
|
||||
|
||||
if (this._min !== this._orig._min) {
|
||||
detail.min = this._min;
|
||||
}
|
||||
if (this._max !== this._orig._max) {
|
||||
detail.max = this._max;
|
||||
}
|
||||
|
||||
return api.post(uri.formatApiLink('metrics'), detail)
|
||||
.then(response => {
|
||||
this._updateFromResponse(response);
|
||||
this.dispatchEvent(new CustomEvent('change', {
|
||||
detail: {
|
||||
metric: this,
|
||||
},
|
||||
}));
|
||||
return Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
delete() {
|
||||
return api.delete(
|
||||
uri.formatApiLink('metric', this._orig),
|
||||
{version: this._version})
|
||||
.then(response => {
|
||||
this.dispatchEvent(new CustomEvent('delete', {
|
||||
detail: {
|
||||
metric: this,
|
||||
},
|
||||
}));
|
||||
return Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
_updateFromResponse(response) {
|
||||
const map = {
|
||||
_version: response.version,
|
||||
_min: response.min,
|
||||
_max: response.max,
|
||||
_tag: Tag.fromResponse(response.tag || {}),
|
||||
};
|
||||
|
||||
Object.assign(this, map);
|
||||
Object.assign(this._orig, map);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Metric;
|
24
client/js/models/metric_list.js
Normal file
24
client/js/models/metric_list.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
'use strict';
|
||||
|
||||
const api = require('../api.js');
|
||||
const uri = require('../util/uri.js');
|
||||
const AbstractList = require('./abstract_list.js');
|
||||
const Metric = require('./metric.js');
|
||||
|
||||
class MetricList extends AbstractList {
|
||||
static loadAll() {
|
||||
return api.get(
|
||||
uri.formatApiLink('metrics'))
|
||||
.then(response => {
|
||||
return Promise.resolve(Object.assign(
|
||||
{},
|
||||
response,
|
||||
{results: MetricList.fromResponse(response.results)}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
MetricList._itemClass = Metric;
|
||||
MetricList._itemName = 'metric';
|
||||
|
||||
module.exports = MetricList;
|
|
@ -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) {
|
||||
|
|
|
@ -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) => {
|
||||
|
|
39
client/js/models/post_metric.js
Normal file
39
client/js/models/post_metric.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
'use strict';
|
||||
|
||||
const events = require('../events.js');
|
||||
|
||||
class PostMetric extends events.EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this._updateFromResponse({});
|
||||
}
|
||||
|
||||
static create(postId, tag) {
|
||||
const metric = new PostMetric();
|
||||
metric._postId = postId;
|
||||
metric._tagName = tag.names[0];
|
||||
metric._value = tag.metric.min;
|
||||
return metric;
|
||||
}
|
||||
|
||||
static fromResponse(response) {
|
||||
const metric = new PostMetric();
|
||||
metric._updateFromResponse(response);
|
||||
return metric;
|
||||
}
|
||||
|
||||
get tagName() { return this._tagName; }
|
||||
get postId() { return this._postId; }
|
||||
get value() { return this._value; }
|
||||
|
||||
set value(value) { this._value = value; }
|
||||
|
||||
_updateFromResponse(response) {
|
||||
this._version = response.version;
|
||||
this._postId = response.post_id;
|
||||
this._tagName = response.tag_name;
|
||||
this._value = response.value;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PostMetric;
|
24
client/js/models/post_metric_list.js
Normal file
24
client/js/models/post_metric_list.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
'use strict';
|
||||
|
||||
const AbstractList = require('./abstract_list.js');
|
||||
const PostMetric = require('./post_metric.js');
|
||||
|
||||
class PostMetricList extends AbstractList {
|
||||
findByTagName(testName) {
|
||||
for (let postMetric of this._list) {
|
||||
if (postMetric.tagName.toLowerCase() === testName.toLowerCase()) {
|
||||
return postMetric;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
hasTagName(testName) {
|
||||
return !!this.findByTagName(testName);
|
||||
}
|
||||
}
|
||||
|
||||
PostMetricList._itemClass = PostMetric;
|
||||
PostMetricList._itemName = 'postMetric';
|
||||
|
||||
module.exports = PostMetricList;
|
43
client/js/models/post_metric_range.js
Normal file
43
client/js/models/post_metric_range.js
Normal file
|
@ -0,0 +1,43 @@
|
|||
'use strict';
|
||||
|
||||
const events = require('../events.js');
|
||||
|
||||
class PostMetricRange extends events.EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this._updateFromResponse({});
|
||||
}
|
||||
|
||||
static create(postId, tag) {
|
||||
const metric = new PostMetricRange();
|
||||
metric._postId = postId;
|
||||
metric._tagName = tag.names[0];
|
||||
metric._low = tag.metric.min;
|
||||
metric._high = tag.metric.max;
|
||||
return metric;
|
||||
}
|
||||
|
||||
static fromResponse(response) {
|
||||
const metric = new PostMetricRange();
|
||||
metric._updateFromResponse(response);
|
||||
return metric;
|
||||
}
|
||||
|
||||
get tagName() { return this._tagName; }
|
||||
get postId() { return this._postId; }
|
||||
get low() { return this._low; }
|
||||
get high() { return this._high; }
|
||||
|
||||
set low(value) { this._low = value; }
|
||||
set high(value) { this._high = value; }
|
||||
|
||||
_updateFromResponse(response) {
|
||||
this._version = response.version;
|
||||
this._postId = response.post_id;
|
||||
this._tagName = response.tag_name;
|
||||
this._low = response.low;
|
||||
this._high = response.high;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PostMetricRange;
|
24
client/js/models/post_metric_range_list.js
Normal file
24
client/js/models/post_metric_range_list.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
'use strict';
|
||||
|
||||
const AbstractList = require('./abstract_list.js');
|
||||
const PostMetricRange = require('./post_metric_range.js');
|
||||
|
||||
class PostMetricRangeList extends AbstractList {
|
||||
findByTagName(testName) {
|
||||
for (let pmr of this._list) {
|
||||
if (pmr.tagName.toLowerCase() === testName.toLowerCase()) {
|
||||
return pmr;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
hasTagName(testName) {
|
||||
return !!this.findByTagName(testName);
|
||||
}
|
||||
}
|
||||
|
||||
PostMetricRangeList._itemClass = PostMetricRange;
|
||||
PostMetricRangeList._itemName = 'postMetricRange';
|
||||
|
||||
module.exports = PostMetricRangeList;
|
|
@ -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); }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
138
client/js/views/metric_sorter_view.js
Normal file
138
client/js/views/metric_sorter_view.js
Normal file
|
@ -0,0 +1,138 @@
|
|||
'use strict';
|
||||
|
||||
const events = require('../events.js');
|
||||
const views = require('../util/views.js');
|
||||
const iosCorrectedInnerHeight = require('ios-inner-height');
|
||||
const PostContentControl = require('../controls/post_content_control.js');
|
||||
|
||||
const template = views.getTemplate('metric-sorter');
|
||||
const sideTemplate = views.getTemplate('metric-sorter-side');
|
||||
|
||||
//TODO: find a way to export these constants once
|
||||
const LEFT = 'left';
|
||||
const RIGHT = 'right';
|
||||
|
||||
class MetricSorterView extends events.EventTarget {
|
||||
constructor(ctx) {
|
||||
super();
|
||||
|
||||
this._ctx = ctx;
|
||||
this._hostNode = document.getElementById('content-holder');
|
||||
views.replaceContent(this._hostNode, template(ctx));
|
||||
this._formNode.addEventListener('submit', e => this._evtFormSubmit(e));
|
||||
this._skipButtonNode.addEventListener('click', e => this._evtSkipClick(e));
|
||||
this._compareLessBtnNode.addEventListener('click', e => this._evtCompareClick(e));
|
||||
this._compareGreaterBtnNode.addEventListener('click', e => this._evtCompareClick(e));
|
||||
this._refreshCompareButton();
|
||||
}
|
||||
|
||||
installLeftPost(post) {
|
||||
this._leftPostControl = this._installPostControl(post, this._leftSideNode);
|
||||
}
|
||||
|
||||
installRightPost(post) {
|
||||
this._rightPostControl = this._installPostControl(post, this._rightSideNode);
|
||||
}
|
||||
|
||||
_installPostControl(post, sideNode) {
|
||||
views.replaceContent(
|
||||
sideNode,
|
||||
sideTemplate(Object.assign({}, this._ctx, {
|
||||
post: post,
|
||||
})));
|
||||
let containerNode = this._getSidePostContainerNode(sideNode);
|
||||
return new PostContentControl(
|
||||
containerNode,
|
||||
post,
|
||||
() => {
|
||||
// TODO: come up with a more reliable resizing mechanism
|
||||
return window.innerWidth < 1000 ?
|
||||
[
|
||||
window.innerWidth,
|
||||
iosCorrectedInnerHeight() / 2
|
||||
] : [
|
||||
containerNode.getBoundingClientRect().width,
|
||||
window.innerHeight - containerNode.getBoundingClientRect().top -
|
||||
this._buttonsNode.getBoundingClientRect().height * 2
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
clearMessages() {
|
||||
views.clearMessages(this._hostNode);
|
||||
}
|
||||
|
||||
enableForm() {
|
||||
views.enableForm(this._formNode);
|
||||
}
|
||||
|
||||
disableForm() {
|
||||
views.disableForm(this._formNode);
|
||||
}
|
||||
|
||||
showSuccess(message) {
|
||||
views.showSuccess(this._hostNode, message);
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
views.showError(this._hostNode, message);
|
||||
}
|
||||
|
||||
get _formNode() {
|
||||
return this._hostNode.querySelector('form');
|
||||
}
|
||||
|
||||
get _leftSideNode() {
|
||||
return this._hostNode.querySelector('.left-post-container');
|
||||
}
|
||||
|
||||
get _rightSideNode() {
|
||||
return this._hostNode.querySelector('.right-post-container');
|
||||
}
|
||||
|
||||
get _compareGreaterBtnNode() {
|
||||
return this._hostNode.querySelector('.left-gt-right')
|
||||
}
|
||||
|
||||
get _compareLessBtnNode() {
|
||||
return this._hostNode.querySelector('.left-lt-right')
|
||||
}
|
||||
|
||||
get _buttonsNode() {
|
||||
return this._hostNode.querySelector('.buttons');
|
||||
}
|
||||
|
||||
get _skipButtonNode() {
|
||||
return this._hostNode.querySelector('.skip-btn');
|
||||
}
|
||||
|
||||
_getSidePostContainerNode(sideNode) {
|
||||
return sideNode.querySelector('.post-container');
|
||||
}
|
||||
|
||||
_evtSkipClick(e) {
|
||||
e.preventDefault();
|
||||
this.dispatchEvent(new CustomEvent('skip'));
|
||||
}
|
||||
|
||||
_evtFormSubmit(e) {
|
||||
e.preventDefault();
|
||||
this.dispatchEvent(new CustomEvent('submit', {
|
||||
detail: {
|
||||
greaterPost: this._ctx.greaterPost,
|
||||
}}));
|
||||
}
|
||||
|
||||
_evtCompareClick(e) {
|
||||
e.preventDefault();
|
||||
this._ctx.greaterPost = this._ctx.greaterPost === LEFT ? RIGHT : LEFT;
|
||||
this._refreshCompareButton();
|
||||
}
|
||||
|
||||
_refreshCompareButton() {
|
||||
this._compareGreaterBtnNode.hidden = this._ctx.greaterPost === RIGHT;
|
||||
this._compareLessBtnNode.hidden = this._ctx.greaterPost === LEFT;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MetricSorterView;
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]");
|
||||
}
|
||||
|
|
97
client/js/views/tag_metric_view.js
Normal file
97
client/js/views/tag_metric_view.js
Normal file
|
@ -0,0 +1,97 @@
|
|||
'use strict';
|
||||
|
||||
const events = require('../events.js');
|
||||
const api = require('../api.js');
|
||||
const views = require('../util/views.js');
|
||||
const Metric = require('../models/metric.js');
|
||||
|
||||
const template = views.getTemplate('tag-metric');
|
||||
|
||||
class TagMetricView extends events.EventTarget {
|
||||
constructor(ctx) {
|
||||
super();
|
||||
|
||||
this._tag = ctx.tag;
|
||||
this._hostNode = ctx.hostNode;
|
||||
|
||||
if (ctx.tag.metric) {
|
||||
ctx.metricMin = ctx.tag.metric.min;
|
||||
ctx.metricMax = ctx.tag.metric.max;
|
||||
} else {
|
||||
// default new values
|
||||
ctx.metricMin = 0;
|
||||
ctx.metricMax = 10;
|
||||
}
|
||||
|
||||
views.replaceContent(this._hostNode, template(ctx));
|
||||
|
||||
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
|
||||
if (this._deleteButtonNode) {
|
||||
this._deleteButtonNode.addEventListener('click', e => this._evtDelete(e));
|
||||
}
|
||||
}
|
||||
|
||||
_evtSubmit(e) {
|
||||
e.preventDefault();
|
||||
this.dispatchEvent(new CustomEvent('submit', {
|
||||
detail: {
|
||||
tag: this._tag,
|
||||
metricMin: this._minFieldNode.value,
|
||||
metricMax: this._maxFieldNode.value,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
_evtDelete(e) {
|
||||
e.preventDefault();
|
||||
if (!this._deleteConfirmationNode.checked) {
|
||||
this.showError('Please confirm deletion.')
|
||||
} else {
|
||||
this.dispatchEvent(new CustomEvent('delete', {
|
||||
detail: {tag: this._tag},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
clearMessages() {
|
||||
views.clearMessages(this._hostNode);
|
||||
}
|
||||
|
||||
enableForm() {
|
||||
views.enableForm(this._formNode);
|
||||
}
|
||||
|
||||
disableForm() {
|
||||
views.disableForm(this._formNode);
|
||||
}
|
||||
|
||||
showSuccess(message) {
|
||||
views.showSuccess(this._hostNode, message);
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
views.showError(this._hostNode, message);
|
||||
}
|
||||
|
||||
get _formNode() {
|
||||
return this._hostNode.querySelector('form');
|
||||
}
|
||||
|
||||
get _minFieldNode() {
|
||||
return this._formNode.querySelector('input[name=metric-min]');
|
||||
}
|
||||
|
||||
get _maxFieldNode() {
|
||||
return this._formNode.querySelector('input[name=metric-max]');
|
||||
}
|
||||
|
||||
get _deleteConfirmationNode() {
|
||||
return this._formNode.querySelector('input[name=confirm-delete]');
|
||||
}
|
||||
|
||||
get _deleteButtonNode() {
|
||||
return this._formNode.querySelector('input[name=delete]');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TagMetricView;
|
BIN
customdocker2 master.7z
Normal file
BIN
customdocker2 master.7z
Normal file
Binary file not shown.
BIN
customdocker2 stable master v2.7z
Normal file
BIN
customdocker2 stable master v2.7z
Normal file
Binary file not shown.
38
doc/API.md
38
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": <name>,
|
||||
"color": <color>,
|
||||
"order": <order>
|
||||
"order": <order> // optional
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -952,29 +951,6 @@ data.
|
|||
|
||||
Retrieves information about an existing post.
|
||||
|
||||
## Getting around post
|
||||
- **Request**
|
||||
|
||||
`GET /post/<id>/around`
|
||||
|
||||
- **Output**
|
||||
|
||||
```json5
|
||||
{
|
||||
"prev": <post-resource>,
|
||||
"next": <post-resource>
|
||||
}
|
||||
```
|
||||
|
||||
- **Errors**
|
||||
|
||||
- the post does not exist
|
||||
- privileges are too low
|
||||
|
||||
- **Description**
|
||||
|
||||
Retrieves information about posts that are before or after an existing post.
|
||||
|
||||
## Deleting post
|
||||
- **Request**
|
||||
|
||||
|
@ -1389,7 +1365,7 @@ data.
|
|||
## Creating pool
|
||||
- **Request**
|
||||
|
||||
`POST /pool`
|
||||
`POST /pools/create`
|
||||
|
||||
- **Input**
|
||||
|
||||
|
@ -2491,7 +2467,7 @@ One file together with its metadata posted to the site.
|
|||
## Micro post
|
||||
**Description**
|
||||
|
||||
A [post resource](#post) stripped down to `id` and `thumbnailUrl` fields.
|
||||
A [post resource](#post) stripped down to `name` and `thumbnailUrl` fields.
|
||||
|
||||
## Note
|
||||
**Description**
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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=/
|
||||
|
|
|
@ -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
|
|
@ -24,14 +24,21 @@ RUN apk --no-cache add \
|
|||
py3-pynacl \
|
||||
py3-tz \
|
||||
py3-pyrfc3339
|
||||
RUN pip3 install --no-cache-dir --disable-pip-version-check \
|
||||
|
||||
# Upgrade pip and setuptools
|
||||
RUN pip3 install --no-cache-dir --disable-pip-version-check --upgrade pip setuptools wheel
|
||||
|
||||
# Install required Python packages with PEP 517 disabled
|
||||
RUN pip3 install --no-cache-dir --disable-pip-version-check --no-build-isolation \
|
||||
"alembic>=0.8.5" \
|
||||
"coloredlogs==5.0" \
|
||||
"pyheif==0.6.1" \
|
||||
"heif-image-plugin>=0.3.2" \
|
||||
yt-dlp \
|
||||
"pillow-avif-plugin~=1.1.0"
|
||||
RUN apk --no-cache del py3-pip
|
||||
youtube_dl \
|
||||
"pillow-avif-plugin>=1.1.0"
|
||||
|
||||
# Debugging: Print the installed packages
|
||||
RUN apk list --installed
|
||||
|
||||
COPY ./ /opt/app/
|
||||
RUN rm -rf /opt/app/szurubooru/tests
|
||||
|
@ -75,6 +82,8 @@ RUN apk --no-cache add \
|
|||
&& addgroup -g ${PGID} app \
|
||||
&& adduser -SDH -h /opt/app -g '' -G app -u ${PUID} app \
|
||||
&& chown -R app:app /opt/app /data
|
||||
|
||||
RUN chmod +x /opt/app/docker-start.sh
|
||||
|
||||
USER app
|
||||
CMD ["/opt/app/docker-start.sh"]
|
||||
|
|
115
server/Dockerfile - Copy
Normal file
115
server/Dockerfile - Copy
Normal file
|
@ -0,0 +1,115 @@
|
|||
ARG ALPINE_VERSION=3.14
|
||||
|
||||
|
||||
FROM alpine:$ALPINE_VERSION as prereqs
|
||||
WORKDIR /opt/app
|
||||
|
||||
RUN apk --no-cache add py3-pip build-base python3-dev libffi-dev libheif-dev \
|
||||
&& pip3 install --no-cache-dir --disable-pip-version-check --upgrade cffi \
|
||||
&& pip3 install --no-cache-dir --disable-pip-version-check --upgrade pyheif-pillow-opener \
|
||||
&& apk --no-cache del py3-pip build-base python3-dev libffi-dev libheif-dev \
|
||||
&& apk --no-cache add python3 python3-dev ffmpeg openssl-dev py3-pip py3-yaml py3-psycopg2 py3-sqlalchemy py3-certifi py3-numpy py3-pillow py3-pynacl py3-tz py3-pyrfc3339 build-base \
|
||||
&& apk --no-cache add libheif libavif libheif-dev libavif-dev \
|
||||
&& pip3 install --upgrade --disable-pip-version-check wheel alembic "coloredlogs==5.0" youtube_dl pillow-avif-plugin pyheif-pillow-opener \
|
||||
&& apk --no-cache del py3-pip
|
||||
|
||||
|
||||
RUN apk --no-cache add \
|
||||
python3 \
|
||||
python3-dev \
|
||||
ffmpeg \
|
||||
openssl-dev \
|
||||
py3-pip \
|
||||
# from requirements.txt:
|
||||
py3-yaml \
|
||||
py3-psycopg2 \
|
||||
py3-sqlalchemy \
|
||||
py3-certifi \
|
||||
py3-numpy \
|
||||
py3-pillow \
|
||||
py3-pynacl \
|
||||
py3-tz \
|
||||
py3-pyrfc3339 \
|
||||
build-base \
|
||||
&& apk --no-cache add \
|
||||
libheif \
|
||||
libavif \
|
||||
libheif-dev \
|
||||
libavif-dev \
|
||||
&& pip3 install --upgrade --disable-pip-version-check \
|
||||
wheel \
|
||||
alembic \
|
||||
"coloredlogs==5.0" \
|
||||
youtube_dl \
|
||||
pillow-avif-plugin \
|
||||
pyheif-pillow-opener \
|
||||
&& apk --no-cache del py3-pip
|
||||
|
||||
COPY ./ /opt/app/
|
||||
RUN rm -rf /opt/app/szurubooru/tests
|
||||
|
||||
|
||||
FROM prereqs as testing
|
||||
WORKDIR /opt/app
|
||||
|
||||
RUN apk --no-cache add \
|
||||
py3-pip \
|
||||
py3-pytest \
|
||||
py3-pytest-cov \
|
||||
postgresql \
|
||||
&& pip3 install --no-cache-dir --disable-pip-version-check \
|
||||
pytest-pgsql \
|
||||
freezegun \
|
||||
&& apk --no-cache del py3-pip \
|
||||
&& addgroup app \
|
||||
&& adduser -SDH -h /opt/app -g '' -G app app \
|
||||
&& chown app:app /opt/app
|
||||
|
||||
COPY --chown=app:app ./szurubooru/tests /opt/app/szurubooru/tests/
|
||||
|
||||
ENV TEST_ENVIRONMENT="true"
|
||||
USER app
|
||||
ENTRYPOINT ["pytest", "--tb=short"]
|
||||
CMD ["szurubooru/"]
|
||||
|
||||
|
||||
FROM prereqs as release
|
||||
WORKDIR /opt/app
|
||||
|
||||
ARG PUID=1000
|
||||
ARG PGID=1000
|
||||
|
||||
RUN apk --no-cache add \
|
||||
dumb-init \
|
||||
py3-setuptools \
|
||||
py3-waitress \
|
||||
&& mkdir -p /opt/app /data \
|
||||
&& addgroup -g ${PGID} app \
|
||||
&& adduser -SDH -h /opt/app -g '' -G app -u ${PUID} app \
|
||||
&& chown -R app:app /opt/app /data
|
||||
|
||||
|
||||
|
||||
RUN chmod +x /opt/app/docker-start.sh
|
||||
|
||||
USER app
|
||||
CMD ["/opt/app/docker-start.sh"]
|
||||
|
||||
ARG PORT=6666
|
||||
ENV PORT=${PORT}
|
||||
EXPOSE ${PORT}
|
||||
|
||||
VOLUME ["/data/"]
|
||||
|
||||
ARG DOCKER_REPO
|
||||
ARG BUILD_DATE
|
||||
ARG SOURCE_COMMIT
|
||||
LABEL \
|
||||
maintainer="" \
|
||||
org.opencontainers.image.title="${DOCKER_REPO}" \
|
||||
org.opencontainers.image.url="https://github.com/rr-/szurubooru" \
|
||||
org.opencontainers.image.documentation="https://github.com/rr-/szurubooru/blob/${SOURCE_COMMIT}/doc/INSTALL.md" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
org.opencontainers.image.source="https://github.com/rr-/szurubooru" \
|
||||
org.opencontainers.image.revision="${SOURCE_COMMIT}" \
|
||||
org.opencontainers.image.licenses="GPL-3.0"
|
104
server/Dockerfile - Copy (2)
Normal file
104
server/Dockerfile - Copy (2)
Normal file
|
@ -0,0 +1,104 @@
|
|||
ARG ALPINE_VERSION=3.13
|
||||
|
||||
|
||||
FROM alpine:$ALPINE_VERSION as prereqs
|
||||
WORKDIR /opt/app
|
||||
|
||||
RUN apk --no-cache add \
|
||||
python3 \
|
||||
python3-dev \
|
||||
py3-pip \
|
||||
build-base \
|
||||
libheif \
|
||||
libheif-dev \
|
||||
libavif \
|
||||
libavif-dev \
|
||||
ffmpeg \
|
||||
# from requirements.txt:
|
||||
py3-yaml \
|
||||
py3-psycopg2 \
|
||||
py3-sqlalchemy \
|
||||
py3-certifi \
|
||||
py3-numpy \
|
||||
py3-pillow \
|
||||
py3-pynacl \
|
||||
py3-tz \
|
||||
py3-pyrfc3339 \
|
||||
&& pip3 install --no-cache-dir --disable-pip-version-check \
|
||||
"alembic>=0.8.5" \
|
||||
"coloredlogs==5.0" \
|
||||
"pyheif==0.6.1" \
|
||||
"heif-image-plugin>=0.3.2" \
|
||||
youtube_dl \
|
||||
"pillow-avif-plugin>=1.1.0" \
|
||||
&& apk --no-cache del py3-pip
|
||||
|
||||
COPY ./ /opt/app/
|
||||
RUN rm -rf /opt/app/szurubooru/tests
|
||||
|
||||
|
||||
FROM --platform=$BUILDPLATFORM prereqs as testing
|
||||
WORKDIR /opt/app
|
||||
|
||||
RUN apk --no-cache add \
|
||||
py3-pip \
|
||||
py3-pytest \
|
||||
py3-pytest-cov \
|
||||
postgresql \
|
||||
&& pip3 install --no-cache-dir --disable-pip-version-check \
|
||||
pytest-pgsql \
|
||||
freezegun \
|
||||
&& apk --no-cache del py3-pip \
|
||||
&& addgroup app \
|
||||
&& adduser -SDH -h /opt/app -g '' -G app app \
|
||||
&& chown app:app /opt/app
|
||||
|
||||
COPY --chown=app:app ./szurubooru/tests /opt/app/szurubooru/tests/
|
||||
|
||||
ENV TEST_ENVIRONMENT="true"
|
||||
USER app
|
||||
ENTRYPOINT ["pytest", "--tb=short"]
|
||||
CMD ["szurubooru/"]
|
||||
|
||||
|
||||
FROM prereqs as release
|
||||
WORKDIR /opt/app
|
||||
|
||||
ARG PUID=1000
|
||||
ARG PGID=1000
|
||||
|
||||
RUN apk --no-cache add \
|
||||
dumb-init \
|
||||
py3-setuptools \
|
||||
py3-waitress \
|
||||
&& mkdir -p /opt/app /data \
|
||||
&& addgroup -g ${PGID} app \
|
||||
&& adduser -SDH -h /opt/app -g '' -G app -u ${PUID} app \
|
||||
&& chown -R app:app /opt/app /data
|
||||
|
||||
RUN chmod +x /opt/app/docker-start.sh
|
||||
|
||||
USER app
|
||||
CMD ["/opt/app/docker-start.sh"]
|
||||
|
||||
ARG PORT=6666
|
||||
ENV PORT=${PORT}
|
||||
EXPOSE ${PORT}
|
||||
|
||||
ARG THREADS=4
|
||||
ENV THREADS=${THREADS}
|
||||
|
||||
VOLUME ["/data/"]
|
||||
|
||||
ARG DOCKER_REPO
|
||||
ARG BUILD_DATE
|
||||
ARG SOURCE_COMMIT
|
||||
LABEL \
|
||||
maintainer="" \
|
||||
org.opencontainers.image.title="${DOCKER_REPO}" \
|
||||
org.opencontainers.image.url="https://github.com/rr-/szurubooru" \
|
||||
org.opencontainers.image.documentation="https://github.com/rr-/szurubooru/blob/${SOURCE_COMMIT}/doc/INSTALL.md" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
org.opencontainers.image.source="https://github.com/rr-/szurubooru" \
|
||||
org.opencontainers.image.revision="${SOURCE_COMMIT}" \
|
||||
org.opencontainers.image.licenses="GPL-3.0"
|
|
@ -2,7 +2,7 @@
|
|||
set -e
|
||||
cd /opt/app
|
||||
|
||||
alembic upgrade head
|
||||
alembic upgrade heads
|
||||
|
||||
echo "Starting szurubooru API on port ${PORT} - Running on ${THREADS} threads"
|
||||
exec waitress-serve-3 --port ${PORT} --threads ${THREADS} szurubooru.facade:app
|
||||
echo "Starting szurubooru API on port ${PORT}"
|
||||
exec waitress-serve-3 --port ${PORT} szurubooru.facade:app
|
||||
|
|
7
server/hooks/build
Normal file
7
server/hooks/build
Normal file
|
@ -0,0 +1,7 @@
|
|||
#!/bin/sh
|
||||
|
||||
docker build \
|
||||
--build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \
|
||||
--build-arg SOURCE_COMMIT \
|
||||
--build-arg DOCKER_REPO \
|
||||
-f $DOCKERFILE_PATH -t $IMAGE_NAME .
|
19
server/hooks/post_push
Normal file
19
server/hooks/post_push
Normal file
|
@ -0,0 +1,19 @@
|
|||
#!/bin/sh
|
||||
|
||||
add_tag() {
|
||||
echo "Also tagging image as ${DOCKER_REPO}:${1}"
|
||||
docker tag $IMAGE_NAME $DOCKER_REPO:$1
|
||||
docker push $DOCKER_REPO:$1
|
||||
}
|
||||
|
||||
CLOSEST_VER=$(git describe --tags --abbrev=0)
|
||||
CLOSEST_MAJOR_VER=$(echo ${CLOSEST_VER} | cut -d'.' -f1)
|
||||
CLOSEST_MINOR_VER=$(echo ${CLOSEST_VER} | cut -d'.' -f2)
|
||||
|
||||
add_tag "${CLOSEST_MAJOR_VER}-edge"
|
||||
add_tag "${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}-edge"
|
||||
|
||||
if git describe --exact-match --abbrev=0 2> /dev/null; then
|
||||
add_tag "${CLOSEST_MAJOR_VER}"
|
||||
add_tag "${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}"
|
||||
fi
|
8
server/hooks/test
Normal file
8
server/hooks/test
Normal file
|
@ -0,0 +1,8 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
docker run --rm \
|
||||
-t $(docker build --target testing -q .) \
|
||||
--color=no szurubooru/
|
||||
|
||||
exit $?
|
|
@ -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
|
||||
|
|
94
server/szurubooru/api/metric_api.py
Normal file
94
server/szurubooru/api/metric_api.py
Normal file
|
@ -0,0 +1,94 @@
|
|||
from math import ceil
|
||||
from typing import Optional, List, Dict
|
||||
from szurubooru import db, model, search, rest
|
||||
from szurubooru.func import (
|
||||
auth, metrics, snapshots, serialization, tags, versions
|
||||
)
|
||||
|
||||
|
||||
_search_executor_config = search.configs.PostMetricSearchConfig()
|
||||
_search_executor = search.Executor(_search_executor_config)
|
||||
|
||||
|
||||
def _serialize_metric(
|
||||
ctx: rest.Context, metric: model.Metric) -> rest.Response:
|
||||
return metrics.serialize_metric(
|
||||
metric, options=serialization.get_serialization_options(ctx)
|
||||
)
|
||||
|
||||
|
||||
def _serialize_post_metric(
|
||||
ctx: rest.Context, post_metric: model.PostMetric) -> rest.Response:
|
||||
return metrics.serialize_post_metric(
|
||||
post_metric, options=serialization.get_serialization_options(ctx)
|
||||
)
|
||||
|
||||
|
||||
def _get_metric(params: Dict[str, str]) -> model.Metric:
|
||||
return metrics.get_metric_by_tag_name(params["tag_name"])
|
||||
|
||||
|
||||
@rest.routes.get("/metrics/?")
|
||||
def get_metrics(
|
||||
ctx: rest.Context, params: Dict[str, str] = {}) -> rest.Response:
|
||||
auth.verify_privilege(ctx.user, "metrics:list")
|
||||
all_metrics = metrics.get_all_metrics()
|
||||
return {
|
||||
"results": [_serialize_metric(ctx, metric) for metric in all_metrics]
|
||||
}
|
||||
|
||||
|
||||
@rest.routes.post("/metrics/?")
|
||||
def create_metric(
|
||||
ctx: rest.Context, params: Dict[str, str] = {}) -> rest.Response:
|
||||
auth.verify_privilege(ctx.user, "metrics:create")
|
||||
tag_name = ctx.get_param_as_string("tag_name")
|
||||
tag = tags.get_tag_by_name(tag_name)
|
||||
min = ctx.get_param_as_float("min")
|
||||
max = ctx.get_param_as_float("max")
|
||||
|
||||
metric = metrics.create_metric(tag, min, max)
|
||||
ctx.session.flush()
|
||||
# snapshots.create(metric, ctx.user)
|
||||
ctx.session.commit()
|
||||
return _serialize_metric(ctx, metric)
|
||||
|
||||
|
||||
@rest.routes.delete("/metric/(?P<tag_name>.+)")
|
||||
def delete_metric(ctx: rest.Context, params: Dict[str, str]) -> rest.Response:
|
||||
metric = _get_metric(params)
|
||||
versions.verify_version(metric, ctx)
|
||||
auth.verify_privilege(ctx.user, "metrics:delete")
|
||||
# snapshots.delete(metric, ctx.user)
|
||||
metrics.delete_metric(metric)
|
||||
ctx.session.commit()
|
||||
return {}
|
||||
|
||||
|
||||
@rest.routes.get("/post-metrics/?")
|
||||
def get_post_metrics(
|
||||
ctx: rest.Context, params: Dict[str, str] = {}) -> rest.Response:
|
||||
auth.verify_privilege(ctx.user, "metrics:list")
|
||||
return _search_executor.execute_and_serialize(
|
||||
ctx, lambda post_metric: _serialize_post_metric(ctx, post_metric))
|
||||
|
||||
|
||||
@rest.routes.get("/post-metrics/median/(?P<tag_name>.+)")
|
||||
def get_post_metrics_median(
|
||||
ctx: rest.Context, params: Dict[str, str] = {}) -> rest.Response:
|
||||
auth.verify_privilege(ctx.user, "metrics:list")
|
||||
metric = _get_metric(params)
|
||||
tag_name = params["tag_name"]
|
||||
query_text = ctx.get_param_as_string(
|
||||
"query",
|
||||
default="%s:%f..%f" % (tag_name, metric.min, metric.max))
|
||||
total_count = _search_executor.count(query_text)
|
||||
offset = ceil(total_count/2) - 1
|
||||
_, results = _search_executor.execute(query_text, offset, 1)
|
||||
return {
|
||||
"query": query_text,
|
||||
"offset": offset,
|
||||
"limit": 1,
|
||||
"total": len(results),
|
||||
"results": list([_serialize_post_metric(ctx, pm) for pm in results])
|
||||
}
|
273
server/szurubooru/func/metrics.py
Normal file
273
server/szurubooru/func/metrics.py
Normal file
|
@ -0,0 +1,273 @@
|
|||
import sqlalchemy as sa
|
||||
from typing import Any, Optional, List, Dict, Callable
|
||||
from szurubooru import db, model, errors, rest
|
||||
from szurubooru.func import serialization, tags, util, versions
|
||||
|
||||
|
||||
class MetricDoesNotExistsError(errors.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class MetricAlreadyExistsError(errors.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidMetricError(errors.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class PostMissingTagError(errors.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class MetricValueOutOfRangeError(errors.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class MetricSerializer(serialization.BaseSerializer):
|
||||
def __init__(self, metric: model.Metric):
|
||||
self.metric = metric
|
||||
|
||||
def _serializers(self) -> Dict[str, Callable[[], Any]]:
|
||||
return {
|
||||
"version": lambda: self.metric.version,
|
||||
"min": lambda: self.metric.min,
|
||||
"max": lambda: self.metric.max,
|
||||
"exact_count": lambda: self.metric.post_metric_count,
|
||||
"range_count": lambda: self.metric.post_metric_range_count,
|
||||
"tag": lambda: tags.serialize_tag(self.metric.tag, [
|
||||
"names", "category", "description", "usages"])
|
||||
}
|
||||
|
||||
|
||||
class PostMetricSerializer(serialization.BaseSerializer):
|
||||
def __init__(self, post_metric: model.PostMetric):
|
||||
self.post_metric = post_metric
|
||||
|
||||
def _serializers(self) -> Dict[str, Callable[[], Any]]:
|
||||
return {
|
||||
"tag_name": lambda: self.post_metric.metric.tag_name,
|
||||
"post_id": lambda: self.post_metric.post_id,
|
||||
"value": lambda: self.post_metric.value,
|
||||
}
|
||||
|
||||
|
||||
class PostMetricRangeSerializer(serialization.BaseSerializer):
|
||||
def __init__(self, post_metric_range: model.PostMetricRange):
|
||||
self.post_metric_range = post_metric_range
|
||||
|
||||
def _serializers(self) -> Dict[str, Callable[[], Any]]:
|
||||
return {
|
||||
"tag_name": lambda: self.post_metric_range.metric.tag_name,
|
||||
"post_id": lambda: self.post_metric_range.post_id,
|
||||
"low": lambda: self.post_metric_range.low,
|
||||
"high": lambda: self.post_metric_range.high,
|
||||
}
|
||||
|
||||
|
||||
def serialize_metric(
|
||||
metric: model.Metric,
|
||||
options: List[str] = []) -> Optional[rest.Response]:
|
||||
if not metric:
|
||||
return None
|
||||
return MetricSerializer(metric).serialize(options)
|
||||
|
||||
|
||||
def serialize_post_metric(
|
||||
post_metric: model.PostMetric,
|
||||
options: List[str] = []) -> Optional[rest.Response]:
|
||||
if not post_metric:
|
||||
return None
|
||||
return PostMetricSerializer(post_metric).serialize(options)
|
||||
|
||||
|
||||
def serialize_post_metric_range(
|
||||
post_metric_range: model.PostMetricRange,
|
||||
options: List[str] = []) -> Optional[rest.Response]:
|
||||
if not post_metric_range:
|
||||
return None
|
||||
return PostMetricRangeSerializer(post_metric_range).serialize(options)
|
||||
|
||||
|
||||
def try_get_metric_by_tag_name(tag_name: str) -> Optional[model.Metric]:
|
||||
return (
|
||||
db.session
|
||||
.query(model.Metric)
|
||||
.filter(sa.func.lower(model.Metric.tag_name) == tag_name.lower())
|
||||
.one_or_none())
|
||||
|
||||
|
||||
def get_metric_by_tag_name(tag_name: str) -> model.Metric:
|
||||
metric = try_get_metric_by_tag_name(tag_name)
|
||||
if not metric:
|
||||
raise MetricDoesNotExistsError("Metric %r not found." % tag_name)
|
||||
return metric
|
||||
|
||||
|
||||
def get_all_metrics() -> List[model.Metric]:
|
||||
return db.session.query(model.Metric).all()
|
||||
|
||||
|
||||
def get_all_metric_tag_names() -> List[str]:
|
||||
return [
|
||||
tag_name.name for tag_name in util.flatten_list(
|
||||
[metric.tag.names for metric in get_all_metrics()]
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def try_get_post_metric(
|
||||
post: model.Post,
|
||||
metric: model.Metric) -> Optional[model.PostMetric]:
|
||||
return (
|
||||
db.session
|
||||
.query(model.PostMetric)
|
||||
.filter(model.PostMetric.metric == metric)
|
||||
.filter(model.PostMetric.post == post)
|
||||
.one_or_none())
|
||||
|
||||
|
||||
def try_get_post_metric_range(
|
||||
post: model.Post,
|
||||
metric: model.Metric) -> Optional[model.PostMetricRange]:
|
||||
return (
|
||||
db.session
|
||||
.query(model.PostMetricRange)
|
||||
.filter(model.PostMetricRange.metric == metric)
|
||||
.filter(model.PostMetricRange.post == post)
|
||||
.one_or_none())
|
||||
|
||||
|
||||
def create_metric(
|
||||
tag: model.Tag,
|
||||
min: float,
|
||||
max: float) -> model.Metric:
|
||||
assert tag
|
||||
if tag.metric:
|
||||
raise MetricAlreadyExistsError("Tag already has a metric.")
|
||||
if min >= max:
|
||||
raise InvalidMetricError("Metric min(%r) >= max(%r)" % (min, max))
|
||||
metric = model.Metric(tag=tag, min=min, max=max)
|
||||
db.session.add(metric)
|
||||
return metric
|
||||
|
||||
|
||||
def update_or_create_metric(
|
||||
tag: model.Tag,
|
||||
metric_data: Any) -> Optional[model.Metric]:
|
||||
assert tag
|
||||
for field in ("min", "max"):
|
||||
if field not in metric_data:
|
||||
raise InvalidMetricError("Metric is missing %r field." % field)
|
||||
|
||||
min, max = metric_data["min"], metric_data["max"]
|
||||
if min >= max:
|
||||
raise InvalidMetricError("Metric min(%r) >= max(%r)" % (min, max))
|
||||
if tag.metric:
|
||||
tag.metric.min = min
|
||||
tag.metric.max = max
|
||||
versions.bump_version(tag.metric)
|
||||
return None
|
||||
else:
|
||||
return create_metric(tag=tag, min=min, max=max)
|
||||
|
||||
|
||||
def update_or_create_post_metric(
|
||||
post: model.Post,
|
||||
metric: model.Metric,
|
||||
value: float) -> model.PostMetric:
|
||||
assert post
|
||||
assert metric
|
||||
if metric.tag not in post.tags:
|
||||
raise PostMissingTagError(
|
||||
"Post doesn\"t have this tag.")
|
||||
if value < metric.min or value > metric.max:
|
||||
raise MetricValueOutOfRangeError(
|
||||
"Metric value %r out of range." % value)
|
||||
post_metric = try_get_post_metric(post, metric)
|
||||
if not post_metric:
|
||||
post_metric = model.PostMetric(post=post, metric=metric, value=value)
|
||||
db.session.add(post_metric)
|
||||
else:
|
||||
post_metric.value = value
|
||||
versions.bump_version(post_metric)
|
||||
return post_metric
|
||||
|
||||
|
||||
def update_or_create_post_metrics(post: model.Post, metrics_data: Any) -> None:
|
||||
"""
|
||||
Overwrites any existing post metrics, deletes other existing post metrics.
|
||||
"""
|
||||
assert post
|
||||
post.metrics = []
|
||||
for metric_data in metrics_data:
|
||||
for field in ("tag_name", "value"):
|
||||
if field not in metric_data:
|
||||
raise InvalidMetricError("Metric is missing %r field." % field)
|
||||
value = float(metric_data["value"])
|
||||
tag_name = metric_data["tag_name"]
|
||||
tag = tags.get_tag_by_name(tag_name)
|
||||
if not tag.metric:
|
||||
raise MetricDoesNotExistsError(
|
||||
"Tag %r has no metric." % tag_name)
|
||||
post_metric = update_or_create_post_metric(post, tag.metric, value)
|
||||
post.metrics.append(post_metric)
|
||||
|
||||
|
||||
def update_or_create_post_metric_range(
|
||||
post: model.Post,
|
||||
metric: model.Metric,
|
||||
low: float,
|
||||
high: float) -> model.PostMetricRange:
|
||||
assert post
|
||||
assert metric
|
||||
if metric.tag not in post.tags:
|
||||
raise PostMissingTagError(
|
||||
"Post doesn\"t have this tag.")
|
||||
for value in (low, high):
|
||||
if value < metric.min or value > metric.max:
|
||||
raise MetricValueOutOfRangeError(
|
||||
"Metric value %r out of range." % value)
|
||||
if low >= high:
|
||||
raise InvalidMetricError(
|
||||
"Metric range low(%r) >= high(%r)" % (low, high))
|
||||
post_metric_range = try_get_post_metric_range(post, metric)
|
||||
if not post_metric_range:
|
||||
post_metric_range = model.PostMetricRange(
|
||||
post=post, metric=metric, low=low, high=high)
|
||||
db.session.add(post_metric_range)
|
||||
else:
|
||||
post_metric_range.low = low
|
||||
post_metric_range.high = high
|
||||
versions.bump_version(post_metric_range)
|
||||
return post_metric_range
|
||||
|
||||
|
||||
def update_or_create_post_metric_ranges(
|
||||
post: model.Post,
|
||||
metric_ranges_data: Any) -> None:
|
||||
"""
|
||||
Overwrites any existing post metrics, deletes other existing post metrics.
|
||||
"""
|
||||
assert post
|
||||
post.metric_ranges = []
|
||||
for metric_data in metric_ranges_data:
|
||||
for field in ("tag_name", "low", "high"):
|
||||
if field not in metric_data:
|
||||
raise InvalidMetricError(
|
||||
"Metric range is missing %r field." % field)
|
||||
low = float(metric_data["low"])
|
||||
high = float(metric_data["high"])
|
||||
tag_name = metric_data["tag_name"]
|
||||
tag = tags.get_tag_by_name(tag_name)
|
||||
if not tag.metric:
|
||||
raise MetricDoesNotExistsError(
|
||||
"Tag %r has no metric." % tag_name)
|
||||
post_metric_range = update_or_create_post_metric_range(
|
||||
post, tag.metric, low, high)
|
||||
post.metric_ranges.append(post_metric_range)
|
||||
|
||||
|
||||
def delete_metric(metric: model.Metric) -> None:
|
||||
assert metric
|
||||
db.session.delete(metric)
|
|
@ -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])
|
||||
|
|
32
server/szurubooru/func/similar.py
Normal file
32
server/szurubooru/func/similar.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
from typing import List
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from szurubooru import db, model, search
|
||||
|
||||
_search_executor_config = search.configs.PostSearchConfig()
|
||||
_search_executor = search.Executor(_search_executor_config)
|
||||
|
||||
|
||||
# TODO(hunternif): this ignores the query, e.g. rating.
|
||||
# (But we're actually using a "similar" search query on the client anyway.)
|
||||
def find_similar_posts(
|
||||
source_post: model.Post, limit: int, query_text: str = ''
|
||||
) -> List[model.Post]:
|
||||
post_alias = sa.orm.aliased(model.Post)
|
||||
pt_alias = sa.orm.aliased(model.PostTag)
|
||||
result = (
|
||||
db.session.query(post_alias)
|
||||
.join(pt_alias, pt_alias.post_id == post_alias.post_id)
|
||||
.filter(
|
||||
sa.sql.or_(
|
||||
pt_alias.tag_id == tag.tag_id for tag in source_post.tags
|
||||
)
|
||||
)
|
||||
.filter(pt_alias.post_id != source_post.post_id)
|
||||
.group_by(post_alias.post_id)
|
||||
.order_by(sa.func.count(pt_alias.tag_id).desc())
|
||||
.order_by(post_alias.post_id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return result
|
141
server/szurubooru/model/metric.py
Normal file
141
server/szurubooru/model/metric.py
Normal file
|
@ -0,0 +1,141 @@
|
|||
import sqlalchemy as sa
|
||||
from szurubooru.model.base import Base
|
||||
from szurubooru.model.post import PostTag
|
||||
from szurubooru.model.tag import TagName
|
||||
|
||||
|
||||
class PostMetric(Base):
|
||||
__tablename__ = 'post_metric'
|
||||
|
||||
post_id = sa.Column(
|
||||
'post_id',
|
||||
sa.Integer,
|
||||
sa.ForeignKey('post.id'),
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
index=True)
|
||||
tag_id = sa.Column(
|
||||
'tag_id',
|
||||
sa.Integer,
|
||||
sa.ForeignKey('metric.tag_id'),
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
index=True)
|
||||
version = sa.Column('version', sa.Integer, default=1, nullable=False)
|
||||
value = sa.Column('value', sa.Float, nullable=False, index=True)
|
||||
|
||||
post = sa.orm.relationship('Post')
|
||||
metric = sa.orm.relationship('Metric', back_populates='post_metrics')
|
||||
|
||||
__table_args__ = (sa.ForeignKeyConstraint(
|
||||
(post_id, tag_id),
|
||||
(PostTag.post_id, PostTag.tag_id),
|
||||
ondelete='cascade'),
|
||||
)
|
||||
__mapper_args__ = {
|
||||
'version_id_col': version,
|
||||
'version_id_generator': False,
|
||||
# when deleting tag or post, cascade will ensure this post metric is
|
||||
# also deleted, but sqlalchemy will try to delete it twice because of
|
||||
# the cascade on foreign key into PostTag. This silences the error:
|
||||
'confirm_deleted_rows': False,
|
||||
}
|
||||
|
||||
|
||||
class PostMetricRange(Base):
|
||||
"""
|
||||
Could be a metric in the process of finding its exact value, e.g. by sorting.
|
||||
It has upper and lower boundaries that will converge at the final value.
|
||||
"""
|
||||
__tablename__ = 'post_metric_range'
|
||||
|
||||
post_id = sa.Column(
|
||||
'post_id',
|
||||
sa.Integer,
|
||||
sa.ForeignKey('post.id'),
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
index=True)
|
||||
tag_id = sa.Column(
|
||||
'tag_id',
|
||||
sa.Integer,
|
||||
sa.ForeignKey('metric.tag_id'),
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
index=True)
|
||||
version = sa.Column('version', sa.Integer, default=1, nullable=False)
|
||||
low = sa.Column('low', sa.Float, nullable=False)
|
||||
high = sa.Column('high', sa.Float, nullable=False)
|
||||
|
||||
post = sa.orm.relationship('Post')
|
||||
metric = sa.orm.relationship('Metric', back_populates='post_metric_ranges')
|
||||
|
||||
__table_args__ = (sa.ForeignKeyConstraint(
|
||||
(post_id, tag_id),
|
||||
(PostTag.post_id, PostTag.tag_id),
|
||||
ondelete='cascade'),
|
||||
)
|
||||
__mapper_args__ = {
|
||||
'version_id_col': version,
|
||||
'version_id_generator': False,
|
||||
# when deleting tag or post, cascade will ensure this post metric is
|
||||
# also deleted, but sqlalchemy will try to delete it twice because of
|
||||
# the cascade on foreign key into PostTag. This silences the error:
|
||||
'confirm_deleted_rows': False,
|
||||
}
|
||||
|
||||
|
||||
class Metric(Base):
|
||||
"""
|
||||
Must be attached to a tag, tag_id is primary key.
|
||||
"""
|
||||
__tablename__ = 'metric'
|
||||
|
||||
tag_id = sa.Column(
|
||||
'tag_id',
|
||||
sa.Integer,
|
||||
sa.ForeignKey('tag.id'),
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
index=True)
|
||||
version = sa.Column('version', sa.Integer, default=1, nullable=False)
|
||||
min = sa.Column('min', sa.Float, nullable=False)
|
||||
max = sa.Column('max', sa.Float, nullable=False)
|
||||
|
||||
tag = sa.orm.relationship('Tag')
|
||||
post_metrics = sa.orm.relationship(
|
||||
'PostMetric', back_populates='metric', cascade='all, delete-orphan')
|
||||
post_metric_ranges = sa.orm.relationship(
|
||||
'PostMetricRange', back_populates='metric', cascade='all, delete-orphan')
|
||||
|
||||
tag_name = sa.orm.column_property(
|
||||
(
|
||||
sa.sql.expression.select([TagName.name])
|
||||
.where(TagName.tag_id == tag_id)
|
||||
.order_by(TagName.order)
|
||||
.limit(1)
|
||||
.as_scalar()
|
||||
))
|
||||
|
||||
post_metric_count = sa.orm.column_property(
|
||||
(
|
||||
sa.sql.expression.select(
|
||||
[sa.sql.expression.func.count(PostMetric.post_id)])
|
||||
.where(PostMetric.tag_id == tag_id)
|
||||
.correlate_except(PostMetric)
|
||||
),
|
||||
deferred=True)
|
||||
|
||||
post_metric_range_count = sa.orm.column_property(
|
||||
(
|
||||
sa.sql.expression.select(
|
||||
[sa.sql.expression.func.count(PostMetricRange.post_id)])
|
||||
.where(PostMetricRange.tag_id == tag_id)
|
||||
.correlate_except(PostMetricRange)
|
||||
),
|
||||
deferred=True)
|
||||
|
||||
__mapper_args__ = {
|
||||
'version_id_col': version,
|
||||
'version_id_generator': False,
|
||||
}
|
|
@ -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}
|
|
@ -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)
|
||||
|
|
63
server/szurubooru/tests/api/test_metric_retrieving.py
Normal file
63
server/szurubooru/tests/api/test_metric_retrieving.py
Normal file
|
@ -0,0 +1,63 @@
|
|||
from szurubooru import api, db, model
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def inject_config(config_injector):
|
||||
config_injector(
|
||||
{
|
||||
"privileges": {
|
||||
"metrics:list": model.User.RANK_REGULAR,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize('query,expected_value', [
|
||||
('', 5),
|
||||
('mytag:0..', 5),
|
||||
('mytag:..10', 5),
|
||||
('mytag:0..10', 5),
|
||||
('mytag:2..8', 5),
|
||||
('mytag:0..8', 4),
|
||||
('mytag:0..6', 4),
|
||||
('mytag:0..5.5', 4),
|
||||
('mytag:0..4', 1),
|
||||
('mytag:1..4', 1),
|
||||
('mytag:2..3', None),
|
||||
])
|
||||
def test_median(
|
||||
query,
|
||||
expected_value,
|
||||
tag_factory,
|
||||
post_factory,
|
||||
metric_factory,
|
||||
post_metric_factory,
|
||||
context_factory,
|
||||
user_factory):
|
||||
tag = tag_factory(names=['mytag'])
|
||||
post1 = post_factory(tags=[tag])
|
||||
post4 = post_factory(tags=[tag])
|
||||
post5 = post_factory(tags=[tag])
|
||||
post6 = post_factory(tags=[tag])
|
||||
post10 = post_factory(tags=[tag])
|
||||
metric = metric_factory(tag=tag, min=0, max=10)
|
||||
pm1 = post_metric_factory(metric=metric, post=post1, value=1)
|
||||
pm4 = post_metric_factory(metric=metric, post=post4, value=4)
|
||||
pm5 = post_metric_factory(metric=metric, post=post5, value=5)
|
||||
pm6 = post_metric_factory(metric=metric, post=post6, value=6)
|
||||
pm10 = post_metric_factory(metric=metric, post=post10, value=10)
|
||||
db.session.add_all([tag, metric, pm1, pm4, pm5, pm6, pm10,
|
||||
post1, post4, post5, post6, post10])
|
||||
db.session.flush()
|
||||
response = api.metric_api.get_post_metrics_median(
|
||||
context_factory(
|
||||
params={'query': query},
|
||||
user=user_factory(rank=model.User.RANK_REGULAR)),
|
||||
{'tag_name': 'mytag'})
|
||||
if not expected_value:
|
||||
assert response['total'] == 0
|
||||
assert len(response['results']) == 0
|
||||
else:
|
||||
assert response['total'] == 1
|
||||
assert response['results'][0]['value'] == expected_value
|
459
server/szurubooru/tests/func/test_metrics.py
Normal file
459
server/szurubooru/tests/func/test_metrics.py
Normal file
|
@ -0,0 +1,459 @@
|
|||
import pytest
|
||||
from szurubooru import db, model
|
||||
from szurubooru.func import metrics
|
||||
|
||||
|
||||
def test_serialize_metric(tag_category_factory, tag_factory):
|
||||
cat = tag_category_factory(name="cat")
|
||||
tag = tag_factory(names=["tag1"], category=cat)
|
||||
metric = model.Metric(tag=tag, min=1, max=2)
|
||||
db.session.add(metric)
|
||||
db.session.flush()
|
||||
result = metrics.serialize_metric(metric)
|
||||
assert result == {
|
||||
"version": 1,
|
||||
"min": 1,
|
||||
"max": 2,
|
||||
"exact_count": 0,
|
||||
"range_count": 0,
|
||||
"tag": {
|
||||
"names": ["tag1"],
|
||||
"category": "cat",
|
||||
"description": None,
|
||||
"usages": 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_serialize_post_metric(post_factory, tag_factory, metric_factory):
|
||||
tag = tag_factory(names=["mytag"])
|
||||
post = post_factory(id=456, tags=[tag])
|
||||
metric = metric_factory(tag)
|
||||
post_metric = model.PostMetric(post=post, metric=metric, value=-12.3)
|
||||
db.session.add_all([post, tag, metric, post_metric])
|
||||
db.session.flush()
|
||||
result = metrics.serialize_post_metric(post_metric)
|
||||
assert result == {
|
||||
"tag_name": "mytag",
|
||||
"post_id": 456,
|
||||
"value": -12.3,
|
||||
}
|
||||
|
||||
|
||||
def test_serialize_post_metric_range(post_factory, tag_factory, metric_factory):
|
||||
tag = tag_factory(names=["mytag"])
|
||||
post = post_factory(id=456, tags=[tag])
|
||||
metric = metric_factory(tag)
|
||||
post_metric_range = model.PostMetricRange(
|
||||
post=post, metric=metric, low=-1.2, high=3.4)
|
||||
db.session.add_all([post, tag, metric, post_metric_range])
|
||||
db.session.flush()
|
||||
result = metrics.serialize_post_metric_range(post_metric_range)
|
||||
assert result == {
|
||||
"tag_name": "mytag",
|
||||
"post_id": 456,
|
||||
"low": -1.2,
|
||||
"high": 3.4
|
||||
}
|
||||
|
||||
|
||||
def test_try_get_metric_by_tag_name(tag_factory, metric_factory):
|
||||
tag = tag_factory(names=["mytag"])
|
||||
metric = metric_factory(tag)
|
||||
db.session.add_all([tag, metric])
|
||||
db.session.flush()
|
||||
assert metrics.try_get_metric_by_tag_name("unknown") is None
|
||||
assert metrics.try_get_metric_by_tag_name("mytag") is metric
|
||||
|
||||
|
||||
def test_try_get_post_metric(
|
||||
post_factory, metric_factory, post_metric_factory):
|
||||
metric1 = metric_factory()
|
||||
metric2 = metric_factory()
|
||||
post = post_factory(tags=[metric1.tag, metric2.tag])
|
||||
post_metric = post_metric_factory(post=post, metric=metric1)
|
||||
db.session.add_all([post, metric1, metric2, post_metric])
|
||||
db.session.flush()
|
||||
assert metrics.try_get_post_metric(post, metric2) is None
|
||||
assert metrics.try_get_post_metric(post, metric1) is post_metric
|
||||
|
||||
|
||||
def test_try_get_post_metric_range(
|
||||
post_factory, metric_factory, post_metric_range_factory):
|
||||
metric1 = metric_factory()
|
||||
metric2 = metric_factory()
|
||||
post = post_factory(tags=[metric1.tag, metric2.tag])
|
||||
post_metric_range = post_metric_range_factory(post=post, metric=metric1)
|
||||
db.session.add_all([post, metric1, metric2, post_metric_range])
|
||||
db.session.flush()
|
||||
assert metrics.try_get_post_metric_range(post, metric2) is None
|
||||
assert metrics.try_get_post_metric_range(post, metric1) is post_metric_range
|
||||
|
||||
|
||||
def test_get_all_metrics(metric_factory):
|
||||
metric1 = metric_factory()
|
||||
metric2 = metric_factory()
|
||||
metric3 = metric_factory()
|
||||
db.session.add_all([metric1, metric2, metric3])
|
||||
db.session.flush()
|
||||
all_metrics = metrics.get_all_metrics()
|
||||
assert len(all_metrics) == 3
|
||||
assert metric1 in all_metrics
|
||||
assert metric2 in all_metrics
|
||||
assert metric3 in all_metrics
|
||||
|
||||
|
||||
def test_get_all_metric_tag_names(tag_factory, metric_factory):
|
||||
tag1 = tag_factory(names=["abc", "def"])
|
||||
tag2 = tag_factory(names=["ghi"])
|
||||
metric1 = metric_factory(tag=tag1)
|
||||
metric2 = metric_factory(tag=tag2)
|
||||
db.session.add_all([metric1, metric2])
|
||||
db.session.flush()
|
||||
assert metrics.get_all_metric_tag_names() == ["abc", "def", "ghi"]
|
||||
|
||||
|
||||
def test_create_metric(tag_factory):
|
||||
tag = tag_factory()
|
||||
db.session.add(tag)
|
||||
new_metric = metrics.create_metric(tag, 1, 2)
|
||||
assert new_metric is not None
|
||||
db.session.flush()
|
||||
assert tag.metric is not None
|
||||
assert tag.metric.min == 1
|
||||
assert tag.metric.max == 2
|
||||
|
||||
|
||||
def test_create_metric_with_existing_metric(tag_factory):
|
||||
tag = tag_factory()
|
||||
tag.metric = model.Metric()
|
||||
with pytest.raises(metrics.MetricAlreadyExistsError):
|
||||
metrics.create_metric(tag, 1, 2)
|
||||
|
||||
|
||||
def test_create_metric_with_invalid_params(tag_factory):
|
||||
tag = tag_factory()
|
||||
with pytest.raises(metrics.InvalidMetricError):
|
||||
metrics.create_metric(tag, 2, 1)
|
||||
|
||||
|
||||
def test_update_or_create_metric(tag_factory):
|
||||
tag = tag_factory()
|
||||
db.session.add(tag)
|
||||
new_metric = metrics.update_or_create_metric(tag, {"min": 1, "max": 2})
|
||||
assert new_metric is not None
|
||||
db.session.flush()
|
||||
assert tag.metric is not None
|
||||
assert tag.metric.min == 1
|
||||
assert tag.metric.max == 2
|
||||
assert tag.metric.version == 1
|
||||
|
||||
new_metric = metrics.update_or_create_metric(tag, {"min": 3, "max": 4})
|
||||
assert new_metric is None
|
||||
db.session.flush()
|
||||
assert tag.metric.min == 3
|
||||
assert tag.metric.max == 4
|
||||
assert tag.metric.version == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("params", [
|
||||
{"min": 1}, {"max": 2}, {"min": 2, "max": 1}
|
||||
])
|
||||
def test_update_or_create_metric_with_invalid_params(tag_factory, params):
|
||||
tag = tag_factory()
|
||||
with pytest.raises(metrics.InvalidMetricError):
|
||||
metrics.update_or_create_metric(tag, params)
|
||||
|
||||
|
||||
# Post metrics
|
||||
|
||||
def test_update_or_create_post_metric_without_tag(post_factory, metric_factory):
|
||||
post = post_factory()
|
||||
metric = metric_factory()
|
||||
with pytest.raises(metrics.PostMissingTagError):
|
||||
metrics.update_or_create_post_metric(post, metric, 1.5)
|
||||
|
||||
|
||||
def test_update_or_create_post_metric_with_value_out_of_range(
|
||||
post_factory, metric_factory):
|
||||
metric = metric_factory()
|
||||
post = post_factory(tags=[metric.tag])
|
||||
with pytest.raises(metrics.MetricValueOutOfRangeError):
|
||||
metrics.update_or_create_post_metric(post, metric, -99)
|
||||
|
||||
|
||||
def test_update_or_create_post_metric_create(post_factory, metric_factory):
|
||||
metric = metric_factory()
|
||||
post = post_factory(tags=[metric.tag])
|
||||
db.session.add(metric)
|
||||
db.session.flush()
|
||||
post_metric = metrics.update_or_create_post_metric(post, metric, 1.5)
|
||||
assert post_metric.value == 1.5
|
||||
|
||||
|
||||
def test_update_or_create_post_metric_update(post_factory, metric_factory):
|
||||
metric = metric_factory()
|
||||
post1 = post_factory(tags=[metric.tag])
|
||||
post2 = post_factory(tags=[metric.tag])
|
||||
post_metric1 = model.PostMetric(post=post1, metric=metric, value=1.2)
|
||||
post_metric2 = model.PostMetric(post=post2, metric=metric, value=5.6)
|
||||
db.session.add_all([post1, post2, post_metric1, post_metric2])
|
||||
db.session.flush()
|
||||
assert post_metric1.version == 1
|
||||
assert post_metric2.version == 1
|
||||
|
||||
metrics.update_or_create_post_metric(post1, metric, 3.4)
|
||||
db.session.flush()
|
||||
|
||||
assert db.session.query(model.PostMetric).count() == 2
|
||||
assert post_metric1.value == 3.4
|
||||
assert post_metric1.version == 2
|
||||
assert post_metric2.value == 5.6
|
||||
assert post_metric2.version == 1
|
||||
|
||||
|
||||
def test_update_or_create_post_metrics_missing_tag(
|
||||
post_factory, tag_factory, metric_factory):
|
||||
post = post_factory()
|
||||
tag = tag_factory(names=["tag1"])
|
||||
metric = metric_factory(tag)
|
||||
db.session.add(metric)
|
||||
db.session.flush()
|
||||
data = [{"tag_name": "tag1", "value": 1.5}]
|
||||
with pytest.raises(metrics.PostMissingTagError):
|
||||
metrics.update_or_create_post_metrics(post, data)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("params", [
|
||||
[{}],
|
||||
[{"tag_name": "tag"}],
|
||||
[{"value": 1.5}]
|
||||
])
|
||||
def test_update_or_create_post_metrics_with_missing_fields(
|
||||
params, post_factory):
|
||||
post = post_factory()
|
||||
with pytest.raises(metrics.InvalidMetricError):
|
||||
metrics.update_or_create_post_metrics(post, params)
|
||||
|
||||
|
||||
def test_update_or_create_post_metrics_with_invalid_tag(
|
||||
post_factory, tag_factory):
|
||||
tag = tag_factory(names=["tag1"])
|
||||
post = post_factory(tags=[tag])
|
||||
db.session.add(tag)
|
||||
db.session.flush()
|
||||
data = [{"tag_name": "tag1", "value": 2}]
|
||||
with pytest.raises(metrics.MetricDoesNotExistsError):
|
||||
metrics.update_or_create_post_metrics(post, data)
|
||||
|
||||
|
||||
def test_update_or_create_post_metrics(
|
||||
post_factory, tag_factory, metric_factory):
|
||||
tag1 = tag_factory(names=["tag1"])
|
||||
tag2 = tag_factory(names=["tag2"])
|
||||
post = post_factory(tags=[tag1, tag2])
|
||||
metric1 = metric_factory(tag1)
|
||||
metric2 = metric_factory(tag2)
|
||||
db.session.add_all([metric1, metric2])
|
||||
db.session.flush()
|
||||
|
||||
data = [
|
||||
{"tag_name": "tag1", "value": 1.2},
|
||||
{"tag_name": "tag2", "value": 3.4},
|
||||
]
|
||||
metrics.update_or_create_post_metrics(post, data)
|
||||
db.session.flush()
|
||||
|
||||
assert len(post.metrics) == 2
|
||||
assert post.metrics[0].value == 1.2
|
||||
assert post.metrics[1].value == 3.4
|
||||
|
||||
|
||||
def test_update_or_create_post_metrics_with_trim(
|
||||
post_factory, tag_factory, metric_factory, post_metric_factory):
|
||||
tag1 = tag_factory(names=["tag1"])
|
||||
tag2 = tag_factory(names=["tag2"])
|
||||
post = post_factory(tags=[tag1, tag2])
|
||||
metric1 = metric_factory(tag1)
|
||||
metric2 = metric_factory(tag2)
|
||||
post_metric = post_metric_factory(post=post, metric=metric1, value=1.2)
|
||||
db.session.add_all([post, tag1, tag2, metric1, metric2, post_metric])
|
||||
db.session.flush()
|
||||
assert len(post.metrics) == 1
|
||||
assert post.metrics[0].metric == metric1
|
||||
assert post.metrics[0].value == 1.2
|
||||
|
||||
data = [
|
||||
{"tag_name": "tag2", "value": 3.4},
|
||||
]
|
||||
metrics.update_or_create_post_metrics(post, data)
|
||||
db.session.flush()
|
||||
|
||||
assert len(post.metrics) == 1
|
||||
assert post.metrics[0].metric == metric2
|
||||
assert post.metrics[0].value == 3.4
|
||||
|
||||
|
||||
# Post metric ranges
|
||||
|
||||
def test_update_or_create_post_metric_range_without_tag(
|
||||
post_factory, metric_factory):
|
||||
post = post_factory()
|
||||
metric = metric_factory()
|
||||
with pytest.raises(metrics.PostMissingTagError):
|
||||
metrics.update_or_create_post_metric_range(post, metric, 2, 3)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("low, high", [
|
||||
(-99, 1), (1, 99),
|
||||
])
|
||||
def test_update_or_create_post_metric_range_with_values_out_of_range(
|
||||
low, high, post_factory, metric_factory):
|
||||
metric = metric_factory()
|
||||
post = post_factory(tags=[metric.tag])
|
||||
with pytest.raises(metrics.MetricValueOutOfRangeError):
|
||||
metrics.update_or_create_post_metric_range(post, metric, low, high)
|
||||
|
||||
|
||||
def test_update_or_create_post_metric_range_create(
|
||||
post_factory, metric_factory):
|
||||
metric = metric_factory()
|
||||
post = post_factory(tags=[metric.tag])
|
||||
db.session.add(metric)
|
||||
db.session.flush()
|
||||
post_metric_range = metrics.update_or_create_post_metric_range(
|
||||
post, metric, 2, 3)
|
||||
assert post_metric_range.low == 2
|
||||
assert post_metric_range.high == 3
|
||||
|
||||
|
||||
def test_update_or_create_post_metric_range_update(
|
||||
post_factory, metric_factory):
|
||||
metric = metric_factory()
|
||||
post = post_factory(tags=[metric.tag])
|
||||
post_metric_range = model.PostMetricRange(
|
||||
post=post, metric=metric, low=2, high=3)
|
||||
db.session.add(post_metric_range)
|
||||
db.session.flush()
|
||||
assert post_metric_range.version == 1
|
||||
|
||||
metrics.update_or_create_post_metric_range(post, metric, 4, 5)
|
||||
db.session.flush()
|
||||
|
||||
assert post_metric_range.low == 4
|
||||
assert post_metric_range.high == 5
|
||||
assert post_metric_range.version == 2
|
||||
|
||||
|
||||
def test_update_or_create_post_metric_ranges_missing_tag(
|
||||
post_factory, tag_factory, metric_factory):
|
||||
post = post_factory()
|
||||
tag = tag_factory(names=["tag1"])
|
||||
metric = metric_factory(tag)
|
||||
db.session.add(metric)
|
||||
db.session.flush()
|
||||
data = [{"tag_name": "tag1", "low": 2, "high": 3}]
|
||||
with pytest.raises(metrics.PostMissingTagError):
|
||||
metrics.update_or_create_post_metric_ranges(post, data)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("params", [
|
||||
[{}],
|
||||
[{"tag_name": "tag"}],
|
||||
[{"tag_name": "tag", "low": 2}],
|
||||
[{"low": 2, "high": 3}],
|
||||
])
|
||||
def test_update_or_create_post_metric_ranges_with_missing_fields(
|
||||
params, post_factory, tag_factory):
|
||||
tag = tag_factory(names=["tag"])
|
||||
post = post_factory(tags=[tag])
|
||||
with pytest.raises(metrics.InvalidMetricError):
|
||||
metrics.update_or_create_post_metric_ranges(post, params)
|
||||
|
||||
|
||||
def test_update_or_create_post_metric_ranges_with_invalid_tag(
|
||||
post_factory, tag_factory):
|
||||
tag = tag_factory(names=["tag1"])
|
||||
post = post_factory(tags=[tag])
|
||||
db.session.add(tag)
|
||||
db.session.flush()
|
||||
data = [{"tag_name": "tag1", "low": 2, "high": 3}]
|
||||
with pytest.raises(metrics.MetricDoesNotExistsError):
|
||||
metrics.update_or_create_post_metric_ranges(post, data)
|
||||
|
||||
|
||||
def test_update_or_create_post_metric_ranges_with_invalid_values(
|
||||
post_factory, tag_factory, metric_factory):
|
||||
tag = tag_factory(names=["tag1"])
|
||||
post = post_factory(tags=[tag])
|
||||
metric = metric_factory(tag=tag)
|
||||
db.session.add_all([metric, tag])
|
||||
db.session.flush()
|
||||
data = [
|
||||
{"tag_name": "tag1", "low": 4, "high": 2},
|
||||
]
|
||||
with pytest.raises(metrics.InvalidMetricError):
|
||||
metrics.update_or_create_post_metric_ranges(post, data)
|
||||
|
||||
|
||||
def test_update_or_create_post_metric_ranges(
|
||||
post_factory, tag_factory, metric_factory):
|
||||
tag1 = tag_factory(names=["tag1"])
|
||||
tag2 = tag_factory(names=["tag2"])
|
||||
post = post_factory(tags=[tag1, tag2])
|
||||
metric1 = metric_factory(tag1)
|
||||
metric2 = metric_factory(tag2)
|
||||
db.session.add_all([metric1, metric2])
|
||||
db.session.flush()
|
||||
|
||||
data = [
|
||||
{"tag_name": "tag1", "low": 2, "high": 3},
|
||||
{"tag_name": "tag2", "low": 4, "high": 5},
|
||||
]
|
||||
metrics.update_or_create_post_metric_ranges(post, data)
|
||||
db.session.flush()
|
||||
|
||||
assert len(post.metric_ranges) == 2
|
||||
assert post.metric_ranges[0].low == 2
|
||||
assert post.metric_ranges[0].high == 3
|
||||
assert post.metric_ranges[1].low == 4
|
||||
assert post.metric_ranges[1].high == 5
|
||||
|
||||
|
||||
def test_update_or_create_post_metric_ranges_with_trim(
|
||||
post_factory, tag_factory, metric_factory, post_metric_range_factory):
|
||||
tag1 = tag_factory(names=["tag1"])
|
||||
tag2 = tag_factory(names=["tag2"])
|
||||
post = post_factory(tags=[tag1, tag2])
|
||||
metric1 = metric_factory(tag1)
|
||||
metric2 = metric_factory(tag2)
|
||||
post_metric_range = post_metric_range_factory(
|
||||
post=post, metric=metric1, low=1, high=2)
|
||||
db.session.add_all([post, tag1, tag2, metric1, metric2, post_metric_range])
|
||||
db.session.flush()
|
||||
assert len(post.metric_ranges) == 1
|
||||
assert post.metric_ranges[0].metric == metric1
|
||||
assert post.metric_ranges[0].low == 1
|
||||
assert post.metric_ranges[0].high == 2
|
||||
|
||||
data = [
|
||||
{"tag_name": "tag2", "low": 3, "high": 4},
|
||||
]
|
||||
metrics.update_or_create_post_metric_ranges(post, data)
|
||||
db.session.flush()
|
||||
|
||||
assert len(post.metric_ranges) == 1
|
||||
assert post.metric_ranges[0].metric == metric2
|
||||
assert post.metric_ranges[0].low == 3
|
||||
assert post.metric_ranges[0].high == 4
|
||||
|
||||
|
||||
def test_delete_metric(metric_factory):
|
||||
metric1 = metric_factory()
|
||||
metric2 = metric_factory()
|
||||
db.session.add_all([metric1, metric2])
|
||||
db.session.flush()
|
||||
assert db.session.query(model.Metric).count() == 2
|
||||
metrics.delete_metric(metric2)
|
||||
db.session.flush()
|
||||
assert db.session.query(model.Metric).count() == 1
|
60
server/szurubooru/tests/func/test_similar.py
Normal file
60
server/szurubooru/tests/func/test_similar.py
Normal file
|
@ -0,0 +1,60 @@
|
|||
import pytest
|
||||
from szurubooru import db
|
||||
from szurubooru.func import similar
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def verify_posts():
|
||||
def verify(actual_posts, expected_posts):
|
||||
actual_post_ids = list([p.post_id for p in actual_posts])
|
||||
expected_post_ids = list([p.post_id for p in expected_posts])
|
||||
assert actual_post_ids == expected_post_ids
|
||||
|
||||
return verify
|
||||
|
||||
|
||||
def test_find_similar_posts(post_factory, tag_factory, verify_posts):
|
||||
tagA = tag_factory(names=["a"])
|
||||
tagB = tag_factory(names=["b"])
|
||||
tagC = tag_factory(names=["c"])
|
||||
postA = post_factory(id=1, tags=[tagA])
|
||||
postAB = post_factory(id=2, tags=[tagA, tagB])
|
||||
postAC = post_factory(id=3, tags=[tagA, tagC])
|
||||
postABC = post_factory(id=4, tags=[tagA, tagB, tagC])
|
||||
postBC = post_factory(id=5, tags=[tagB, tagC])
|
||||
db.session.add_all([tagA, tagB, tagC, postA, postAB, postAC, postABC, postBC])
|
||||
db.session.flush()
|
||||
|
||||
results = similar.find_similar_posts(postBC, 10)
|
||||
verify_posts(results, [postABC, postAC, postAB])
|
||||
|
||||
results = similar.find_similar_posts(postBC, 2)
|
||||
verify_posts(results, [postABC, postAC])
|
||||
|
||||
results = similar.find_similar_posts(postABC, 10)
|
||||
verify_posts(results, [postBC, postAC, postAB, postA])
|
||||
|
||||
results = similar.find_similar_posts(postA, 10)
|
||||
verify_posts(results, [postABC, postAC, postAB]) # sorted by id
|
||||
|
||||
results = similar.find_similar_posts(postAB, 10)
|
||||
verify_posts(results, [postABC, postBC, postAC, postA])
|
||||
|
||||
results = similar.find_similar_posts(postAC, 10)
|
||||
verify_posts(results, [postABC, postBC, postAB, postA])
|
||||
|
||||
|
||||
def test_find_similar_posts_with_limit(post_factory, tag_factory, verify_posts):
|
||||
tagA = tag_factory(names=["a"])
|
||||
tagB = tag_factory(names=["b"])
|
||||
tagC = tag_factory(names=["c"])
|
||||
tagD = tag_factory(names=["d"])
|
||||
tagE = tag_factory(names=["e"])
|
||||
postA = post_factory(id=111, tags=[tagA])
|
||||
postAB = post_factory(id=112, tags=[tagA, tagB])
|
||||
postABCDE = post_factory(id=113, tags=[tagA, tagB, tagC, tagD, tagE])
|
||||
db.session.add_all([tagA, tagB, tagC, tagD, tagE, postA, postAB, postABCDE])
|
||||
db.session.flush()
|
||||
|
||||
results = similar.find_similar_posts(postABCDE, 10)
|
||||
verify_posts(results, [postAB, postA])
|
235
server/szurubooru/tests/model/test_metric.py
Normal file
235
server/szurubooru/tests/model/test_metric.py
Normal file
|
@ -0,0 +1,235 @@
|
|||
from szurubooru import db, model
|
||||
|
||||
import pytest
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def inject_config(config_injector):
|
||||
config_injector(
|
||||
{"secret": "secret", "data_dir": "", "delete_source_files": False}
|
||||
)
|
||||
|
||||
def test_saving_metric(post_factory, tag_factory):
|
||||
tag = tag_factory()
|
||||
post = post_factory(tags=[tag])
|
||||
metric = model.Metric(tag=tag, min=1., max=10.)
|
||||
post_metric = model.PostMetric(metric=metric, post=post, value=5.5)
|
||||
post_metric_range = model.PostMetricRange(metric=metric, post=post,
|
||||
low=2., high=8.)
|
||||
db.session.add_all([post, tag, metric, post_metric, post_metric_range])
|
||||
db.session.commit()
|
||||
|
||||
assert metric.tag_id is not None
|
||||
assert post_metric.tag_id is not None
|
||||
assert post_metric.post_id is not None
|
||||
assert post_metric_range.tag_id is not None
|
||||
assert post_metric_range.post_id is not None
|
||||
assert tag.metric.tag_id == tag.tag_id
|
||||
assert tag.metric.min == 1.
|
||||
assert tag.metric.max == 10.
|
||||
|
||||
metric = (
|
||||
db.session
|
||||
.query(model.Metric)
|
||||
.filter(model.Metric.tag_id == tag.tag_id)
|
||||
.one())
|
||||
assert metric.min == 1.
|
||||
assert metric.max == 10.
|
||||
|
||||
post_metric = (
|
||||
db.session
|
||||
.query(model.PostMetric)
|
||||
.filter(model.PostMetric.tag_id == tag.tag_id and
|
||||
model.PostMetric.post_id == post.post_id)
|
||||
.one())
|
||||
assert post_metric.value == 5.5
|
||||
|
||||
post_metric_range = (
|
||||
db.session
|
||||
.query(model.PostMetricRange)
|
||||
.filter(model.PostMetricRange.tag_id == tag.tag_id and
|
||||
model.PostMetricRange.post_id == post.post_id)
|
||||
.one())
|
||||
assert post_metric_range.low == 2.
|
||||
assert post_metric_range.high == 8.
|
||||
|
||||
tag = (
|
||||
db.session
|
||||
.query(model.Tag)
|
||||
.filter(model.Tag.tag_id == metric.tag_id)
|
||||
.one())
|
||||
assert tag.metric == metric
|
||||
|
||||
|
||||
def test_cascade_delete_metric(post_factory, tag_factory):
|
||||
tag = tag_factory()
|
||||
post1 = post_factory(tags=[tag])
|
||||
post2 = post_factory(tags=[tag])
|
||||
metric = model.Metric(tag=tag, min=1., max=10.)
|
||||
post_metric1 = model.PostMetric(metric=metric, post=post1, value=2.3)
|
||||
post_metric2 = model.PostMetric(metric=metric, post=post2, value=4.5)
|
||||
post_metric_range = model.PostMetricRange(
|
||||
metric=metric, post=post2, low=2, high=8)
|
||||
db.session.add_all([post1, post2, tag, metric, post_metric1, post_metric2,
|
||||
post_metric_range])
|
||||
db.session.flush()
|
||||
|
||||
assert not db.session.dirty
|
||||
assert db.session.query(model.Post).count() == 2
|
||||
assert db.session.query(model.Tag).count() == 1
|
||||
assert db.session.query(model.Metric).count() == 1
|
||||
assert db.session.query(model.PostMetric).count() == 2
|
||||
assert db.session.query(model.PostMetricRange).count() == 1
|
||||
|
||||
db.session.delete(metric)
|
||||
db.session.commit()
|
||||
|
||||
assert not db.session.dirty
|
||||
assert db.session.query(model.Post).count() == 2
|
||||
assert db.session.query(model.Tag).count() == 1
|
||||
assert db.session.query(model.Metric).count() == 0
|
||||
assert db.session.query(model.PostMetric).count() == 0
|
||||
assert db.session.query(model.PostMetricRange).count() == 0
|
||||
|
||||
|
||||
def test_cascade_delete_tag(post_factory, tag_factory):
|
||||
tag1 = tag_factory()
|
||||
tag2 = tag_factory()
|
||||
post = post_factory(tags=[tag1, tag2])
|
||||
metric1 = model.Metric(tag=tag1, min=1., max=10.)
|
||||
metric2 = model.Metric(tag=tag2, min=2., max=20.)
|
||||
post_metric1 = model.PostMetric(metric=metric1, post=post, value=2.3)
|
||||
post_metric2 = model.PostMetric(metric=metric2, post=post, value=4.5)
|
||||
post_metric_range1 = model.PostMetricRange(
|
||||
metric=metric1, post=post, low=2, high=8)
|
||||
post_metric_range2 = model.PostMetricRange(
|
||||
metric=metric2, post=post, low=2, high=8)
|
||||
db.session.add_all([post, tag1, tag2, metric1, metric2, post_metric1,
|
||||
post_metric2, post_metric_range1, post_metric_range2])
|
||||
db.session.commit()
|
||||
|
||||
assert not db.session.dirty
|
||||
assert db.session.query(model.Post).count() == 1
|
||||
assert db.session.query(model.Tag).count() == 2
|
||||
assert db.session.query(model.Metric).count() == 2
|
||||
assert db.session.query(model.PostMetric).count() == 2
|
||||
assert db.session.query(model.PostMetricRange).count() == 2
|
||||
|
||||
db.session.delete(tag2)
|
||||
db.session.commit()
|
||||
|
||||
assert not db.session.dirty
|
||||
assert db.session.query(model.Post).count() == 1
|
||||
assert db.session.query(model.Tag).count() == 1
|
||||
assert db.session.query(model.Metric).count() == 1
|
||||
assert db.session.query(model.PostMetric).count() == 1
|
||||
assert db.session.query(model.PostMetricRange).count() == 1
|
||||
|
||||
|
||||
def test_cascade_delete_post(post_factory, tag_factory):
|
||||
tag = tag_factory()
|
||||
post1 = post_factory(tags=[tag])
|
||||
post2 = post_factory(tags=[tag])
|
||||
metric = model.Metric(tag=tag, min=1., max=10.)
|
||||
post_metric1 = model.PostMetric(metric=metric, post=post1, value=2.3)
|
||||
post_metric2 = model.PostMetric(metric=metric, post=post2, value=4.5)
|
||||
post_metric_range1 = model.PostMetricRange(
|
||||
metric=metric, post=post1, low=2, high=8)
|
||||
post_metric_range2 = model.PostMetricRange(
|
||||
metric=metric, post=post2, low=2, high=8)
|
||||
db.session.add_all([post1, post2, tag, metric, post_metric1, post_metric2,
|
||||
post_metric_range1, post_metric_range2])
|
||||
db.session.commit()
|
||||
|
||||
assert not db.session.dirty
|
||||
assert db.session.query(model.Post).count() == 2
|
||||
assert db.session.query(model.Tag).count() == 1
|
||||
assert db.session.query(model.Metric).count() == 1
|
||||
assert db.session.query(model.PostMetric).count() == 2
|
||||
assert db.session.query(model.PostMetricRange).count() == 2
|
||||
|
||||
db.session.delete(post2)
|
||||
db.session.commit()
|
||||
|
||||
assert not db.session.dirty
|
||||
assert db.session.query(model.Post).count() == 1
|
||||
assert db.session.query(model.Tag).count() == 1
|
||||
assert db.session.query(model.Metric).count() == 1
|
||||
assert db.session.query(model.PostMetric).count() == 1
|
||||
assert db.session.query(model.PostMetricRange).count() == 1
|
||||
|
||||
|
||||
def test_delete_post_metric_no_cascade(
|
||||
post_factory, tag_factory, metric_factory,
|
||||
post_metric_factory, post_metric_range_factory):
|
||||
tag = tag_factory()
|
||||
post = post_factory(tags=[tag])
|
||||
metric = metric_factory(tag=tag)
|
||||
post_metric = post_metric_factory(post=post, metric=metric)
|
||||
post_metric_range = post_metric_range_factory(post=post, metric=metric)
|
||||
db.session.add(metric)
|
||||
db.session.commit()
|
||||
assert len(metric.post_metrics) == 1
|
||||
|
||||
db.session.delete(post_metric)
|
||||
db.session.delete(post_metric_range)
|
||||
db.session.commit()
|
||||
assert len(metric.post_metrics) == 0
|
||||
assert len(metric.post_metric_ranges) == 0
|
||||
|
||||
|
||||
def test_tag_without_metric(tag_factory):
|
||||
tag = tag_factory(names=['mytag'])
|
||||
assert tag.metric is None
|
||||
db.session.add(tag)
|
||||
db.session.commit()
|
||||
tag = (
|
||||
db.session
|
||||
.query(model.Tag)
|
||||
.join(model.TagName)
|
||||
.filter(model.TagName.name == 'mytag')
|
||||
.one())
|
||||
assert tag.metric is None
|
||||
|
||||
|
||||
def test_metric_counts(post_factory, metric_factory):
|
||||
metric = metric_factory()
|
||||
post1 = post_factory(tags=[metric.tag])
|
||||
post2 = post_factory(tags=[metric.tag])
|
||||
post_metric1 = model.PostMetric(post=post1, metric=metric, value=1.2)
|
||||
post_metric2 = model.PostMetric(post=post2, metric=metric, value=3.4)
|
||||
post_metric_range = model.PostMetricRange(post=post1, metric=metric, low=5.6, high=7.8)
|
||||
db.session.add_all([metric, post_metric1, post_metric2, post_metric_range])
|
||||
db.session.flush()
|
||||
assert metric.post_metric_count == 2
|
||||
assert metric.post_metric_range_count == 1
|
||||
|
||||
|
||||
def test_cascade_on_remove_tag_from_post(
|
||||
post_factory, tag_factory, metric_factory,
|
||||
post_metric_factory, post_metric_range_factory):
|
||||
tag = tag_factory()
|
||||
post = post_factory(tags=[tag])
|
||||
metric = metric_factory(tag=tag)
|
||||
post_metric = post_metric_factory(post=post, metric=metric)
|
||||
post_metric_range = post_metric_range_factory(post=post, metric=metric)
|
||||
db.session.add_all([post, tag, metric, post_metric, post_metric_range])
|
||||
db.session.commit()
|
||||
|
||||
assert not db.session.dirty
|
||||
assert db.session.query(model.Post).count() == 1
|
||||
assert db.session.query(model.Tag).count() == 1
|
||||
assert db.session.query(model.PostTag).count() == 1
|
||||
assert db.session.query(model.Metric).count() == 1
|
||||
assert db.session.query(model.PostMetric).count() == 1
|
||||
assert db.session.query(model.PostMetricRange).count() == 1
|
||||
|
||||
post.tags.clear()
|
||||
db.session.commit()
|
||||
|
||||
assert not db.session.dirty
|
||||
assert db.session.query(model.Post).count() == 1
|
||||
assert db.session.query(model.Tag).count() == 1
|
||||
assert db.session.query(model.PostTag).count() == 0
|
||||
assert db.session.query(model.Metric).count() == 1
|
||||
assert db.session.query(model.PostMetric).count() == 0
|
||||
assert db.session.query(model.PostMetricRange).count() == 0
|
|
@ -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)
|
Reference in a new issue