64 Commits
2.0 ... 2.2

Author SHA1 Message Date
rr-
fb71b81c62 client/comments: fix top margin in block quotes 2017-01-10 17:32:12 +01:00
rr-
592d2a7dae client/posts: fix uploading posts from URLs 2017-01-08 23:52:20 +01:00
rr-
76eab79828 client: fix leftover code 2017-01-08 22:32:05 +01:00
rr-
5229ce5774 client/posts: fix videos being always looped
fixes #115
2017-01-08 22:29:05 +01:00
rr-
43198daba3 client/posts: wrap with big progress
fixes #114
2017-01-08 22:29:05 +01:00
rr-
e5f08b454c client/tags: fix list bullets in tag suggestions
fixes #113
2017-01-08 22:29:05 +01:00
rr-
8d8165a0d7 server/tags: fix order of aliases in export
fixes #112
2017-01-08 22:29:05 +01:00
rr-
a703195c6c client/posts: fix reordering uploads
fixes #111
2017-01-08 22:29:05 +01:00
rr-
133ed522da client/posts: fix dup finder for swf and webm
fixes #110
2017-01-08 22:28:50 +01:00
rr-
b366d8981c client/api: fix null reference error 2017-01-08 20:56:48 +01:00
rr-
ecf347ef6e client/api: handle expired uploads 2017-01-08 11:04:49 +01:00
rr-
cc969a808f client/posts: show ! in title for similar posts 2017-01-08 10:25:29 +01:00
rr-
cb8bb0f23b client/util: fix style 2017-01-08 10:25:29 +01:00
rr-
beb8d8091b client/api: better promise aborting 2017-01-08 10:25:29 +01:00
rr-
8a73f7e400 client: rework promise error handling 2017-01-08 10:25:29 +01:00
rr-
5c0765c30e client/build: remove extra printer
It kept hanging node. Fuck.
2017-01-08 10:25:29 +01:00
rr-
df663e7b35 client/build: ditch watch
This shit has been always triggering 150 times for every single changed
file; now it simply doesn't fucking work.
2017-01-08 10:25:29 +01:00
rr-
5bf3d5da44 client/api: use temporary upload api 2017-01-08 10:25:29 +01:00
rr-
be6f8d7f46 client/api: merge URL and Blob based file uploads 2017-01-08 10:25:29 +01:00
rr-
036fa9ee39 server/uploads: add file upload api 2017-01-08 10:25:29 +01:00
rr-
f00cc5f3fa client/posts: search for similar posts on upload 2017-01-08 02:26:26 +01:00
rr-
d1bb33ecf0 client/posts: tweak upload appearance and UX 2017-01-08 02:26:13 +01:00
rr-
4cb613a5c9 server/posts: change reverse image search API
Add exact duplicates search; refactor to use classes over dictionaries
2017-01-07 14:07:31 +01:00
rr-
04b820c730 client/comments: fix missing thumbnail margins 2017-01-07 00:00:00 +01:00
rr-
02d90cb5e8 client/comments: fix comment control tab margins 2017-01-04 23:41:27 +01:00
rr-
ac98b7d8e6 client/posts: fix merge could be used only once 2017-01-03 22:07:47 +01:00
rr-
58fabc6e36 client/merge: add search button 2017-01-03 21:58:32 +01:00
rr-
9edaaffec2 server/posts: fix post relations
Trying to relate post to itself resulted in 500 ISE.
2017-01-03 21:37:38 +01:00
rr-
627574a9c2 server: make pylint happier 2017-01-03 21:35:08 +01:00
rr-
902a0d3fe0 server/db: fix closing DB sessions
Certain exception scenarios led to small disasters. Moved database
session management directly to router, since it's that sensitive.
2017-01-03 21:29:48 +01:00
rr-
ef079121a9 server/rest: simplify error handling flow 2017-01-03 21:17:41 +01:00
rr-
4340b4d9b2 client/posts: fix resize modes on chrome 2017-01-03 20:14:27 +01:00
rr-
e2fcd08ce9 client/comments: fix header wrapping on chrome 2017-01-03 19:37:59 +01:00
rr-
42bf4b12a2 client/comments: fix 1px jumping on edit preview 2017-01-03 19:37:15 +01:00
rr-
4ecd05d8b2 client/comments: don't use flexbox 2017-01-03 19:35:53 +01:00
rr-
f301ca9a8a server/image-hash: fix handling invalid input 2016-12-26 19:03:04 +01:00
rr-
e8636a7775 docs/api: fix stupid wording 2016-12-26 15:00:16 +01:00
rr-
a7a5cc8180 server/posts: expose reverse image search 2016-12-26 15:00:16 +01:00
rr-
1a59a74d63 server/image-hash: add image search engine 2016-12-26 15:00:16 +01:00
rr-
b9fa64317d docs: specify expected Python version 2016-12-26 11:57:05 +01:00
rr-
5981b5a0da client/css: fix stacking uploads in upload form 2016-12-25 21:52:25 +01:00
rr-
fe0ba63f19 client/comments: rework comments appearance and UX 2016-12-25 21:49:39 +01:00
rr-
f0573be715 client/css: improve list margins in comments 2016-12-22 23:45:15 +01:00
rr-
cf24d63fa4 client/css: fix lists in comments css inheritance
Markdown lists in comments inherited some unwanted CSS rules. The fix is
to make the culprit rules apply to more specific elements.
2016-12-22 23:45:14 +01:00
rr-
40fa118cca client/settings: fix hint button placement 2016-12-22 23:45:14 +01:00
rr-
32d498c74b client/markdown: allow to specify image size 2016-12-22 23:41:43 +01:00
rr-
6bf5764c6c client/posts: fix adding loop flag to non videos 2016-11-27 22:05:12 +01:00
rr-
9ae2b6aa44 client/notes: fix notes being added twice
Slight issue with event listeners.
2016-11-21 18:11:30 +01:00
rr-
42666706d9 server/util: fix API queries for empty ?options 2016-11-20 16:02:45 +01:00
rr-
e21a31e72f client/posts: fix hiding notes on interaction
Fixes #108
2016-11-13 19:10:55 +01:00
rr-
81080da06f client/settings: add ability to autoplay videos 2016-11-11 23:14:51 +01:00
rr-
bf0342df71 client/views: refactor make(Non)VoidElement
Merge into one function
2016-11-11 23:08:50 +01:00
rr-
143a015473 client/posts: control over video loops on upload
Also loop videos by default
2016-11-11 22:35:58 +01:00
rr-
20a5a58734 client/markdown: recognize entity links 2016-11-11 21:52:07 +01:00
rr-
c0d484689b server: postpone circular dependency evaluation
Hopefully this improves importing with python 3.4
2016-11-07 19:28:54 +01:00
rr-
b44b2aef7e client/posts: fix mass tag case sensitivity
Mass tagging with `TAG` marked posts tagged with `tag` as untagged.
2016-10-27 17:54:11 +02:00
rr-
39973386c6 client/posts: fix editing post safety
Broken by 865c4f3b79
2016-10-23 19:49:40 +02:00
rr-
141c9fcdc9 server/tags: merge also tag relations 2016-10-22 18:02:50 +02:00
rr-
995cd4610d server: drop old style class declarations 2016-10-22 14:43:52 +02:00
rr-
f1445b9c24 client/posts: add post merging 2016-10-22 14:05:56 +02:00
rr-
8c0fa7f49e client/posts: fix post mgmt privilege checking 2016-10-22 14:03:34 +02:00
rr-
9aa59a228e client/css: align radioboxes to first line 2016-10-22 14:03:34 +02:00
rr-
e71718c50d server/posts: add replaceContent to post merging 2016-10-21 22:34:45 +02:00
rr-
9d6a0e0173 server/posts: add post merging 2016-10-21 21:48:38 +02:00
111 changed files with 2933 additions and 1180 deletions

163
API.md
View File

@ -36,11 +36,13 @@
- [Updating post](#updating-post)
- [Getting post](#getting-post)
- [Deleting post](#deleting-post)
- [Merging posts](#merging-posts)
- [Rating post](#rating-post)
- [Adding post to favorites](#adding-post-to-favorites)
- [Removing post from favorites](#removing-post-from-favorites)
- [Getting featured post](#getting-featured-post)
- [Featuring post](#featuring-post)
- [Reverse image search](#reverse-image-search)
- Comments
- [Listing comments](#listing-comments)
- [Creating comment](#creating-comment)
@ -61,6 +63,8 @@
- [Listing snapshots](#listing-snapshots)
- Global info
- [Getting global info](#getting-global-info)
- File uploads
- [Uploading temporary file](#uploading-temporary-file)
3. [Resources](#resources)
@ -75,6 +79,7 @@
- [Snapshot](#snapshot)
- [Unpaged search result](#unpaged-search-result)
- [Paged search result](#paged-search-result)
- [Image search result](#image-search-result)
4. [Search](#search)
@ -102,16 +107,28 @@ application/json`. An exception to this rule are requests that upload files.
## File uploads
Requests that upload files must use `multipart/form-data` encoding. JSON
metadata must then be included as field of name `metadata`, whereas files must
be included as separate fields with names specific to each request type.
Requests that upload files must use `multipart/form-data` encoding. Any request
that bundles user files, must send the request data (which is JSON) as an
additional file with the special name of `metadata` (whereas the actual files
must have names specific to the API that is being used.)
Alternatively, the server can download the files from the Internet on client's
behalf. In that case, the request doesn't need to be specially encoded in any
way. The files, however, should be passed as regular fields appended with `Url`
suffix. For example, to download a file named `content` from
`http://example.com/file.jpg`, the client should pass
`{"contentUrl":"http://example.com/file.jpg"}` as part of the message body.
way. The files, however, should be passed as regular fields appended with a
`Url` suffix. For example, to use `http://example.com/file.jpg` in an API that
accepts a file named `content`, the client should pass
`{"contentUrl":"http://example.com/file.jpg"}` as a part of the JSON message
body.
Finally, in some cases the user might want to reuse one file between the
requests to save the bandwidth (for example, reverse search + consecutive
upload). In this case one should use [temporary file
uploads](#uploading-temporary-file), and pass the tokens returned by the API as
regular fields appended with a `Token` suffix. For example, to use previously
uploaded data, which was given token `deadbeef`, in an API that accepts a file
named `content`, the client should pass `{"contentToken":"deadbeef"}` as part
of the JSON message body. If the file with the particular token doesn't exist
or it has expired, the server will show an error.
## Error handling
@ -617,10 +634,9 @@ data.
- **Description**
Removes source tag and merges all of its usages to the target tag. Source
tag properties such as category, tag relations etc. do not get transferred
and are discarded. The target tag effectively remains unchanged with the
exception of the set of posts it's used in.
Removes source tag and merges all of its usages, suggestions and
implications to the target tag. Other tag properties such as category and
aliases do not get transferred and are discarded.
## Listing tag siblings
- **Request**
@ -787,7 +803,7 @@ data.
- **Files**
- `content` - the content of the content.
- `content` - the content of the post.
- `thumbnail` - the content of custom thumbnail (optional).
- **Output**
@ -835,7 +851,7 @@ data.
- **Files**
- `content` - the content of the content (optional).
- `content` - the content of the post (optional).
- `thumbnail` - the content of custom thumbnail (optional).
- **Output**
@ -910,6 +926,43 @@ data.
Deletes existing post. Related posts and tags are kept.
## Merging posts
- **Request**
`POST /post-merge/`
- **Input**
```json5
{
"removeVersion": <source-post-version>,
"remove": <source-post-id>,
"mergeToVersion": <target-post-version>,
"mergeTo": <target-post-id>,
"replaceContent": <true-or-false>
}
```
- **Output**
A [post resource](#post) containing the merged post.
- **Errors**
- the version of either post is outdated
- the source or target post does not exist
- the source post is the same as the target post
- privileges are too low
- **Description**
Removes source post and merges all of its tags, relations, scores,
favorites and comments to the target post. If `replaceContent` is set to
true, content of the target post is replaced using the content of the
source post; otherwise it remains unchanged. Source post properties such as
its safety, source, whether to loop the video and other scalar values do
not get transferred and are discarded.
## Rating post
- **Request**
@ -1020,6 +1073,27 @@ data.
Features a post on the main page in web client.
## Reverse image search
- **Request**
`POST /posts/reverse-search`
- **Files**
- `content` - the image to search for.
- **Output**
An [image search result](#image-search-result).
- **Errors**
- privileges are too low
- **Description**
Retrieves posts that look like the input image.
## Listing comments
- **Request**
@ -1533,6 +1607,35 @@ data.
exception of privilege array keys being converted to lower camel case to
match the API convention.
## Uploading temporary file
- **Request**
`POST /uploads`
- **Files**
- `content` - the content of the file to upload. Note that in this
particular API, one can't use token-based uploads.
- **Output**
```json5
{
"token": <token>
}
```
- **Errors**
- privileges are too low
- **Description**
Puts a file in temporary storage and assigns it a token that can be used in
other requests. The files uploaded that way are deleted after a short while
so clients shouldn't use it as a free upload service.
# Resources
@ -2081,6 +2184,40 @@ A result of search operation that involves paging.
details on this field, check the documentation for given API call.
## Image search result
**Description**
A result of reverse image search operation.
**Structure**
```json5
{
"exactPost": <exact-post>,
"similarPosts": [
{
"distance": <distance>,
"post": <similar-post>
},
{
"distance": <distance>,
"post": <similar-post>
},
...
]
}
```
**Field meaning**
- `exact-post`: a [post resource](#post) that is exact byte-to-byte duplicate
of the input file. May be `null`.
- `<similar-post>`: a [post resource](#post) that isn't exact duplicate, but
visually resembles the input file. Works only on images and animations, i.e.
does not work for videos and Flash movies. For non-images and corrupted
images, this list is empty.
- `<distance>`: distance from the original image (0..1). The lower this value
is, the more similar the post is.
# Search
Search queries are built of tokens that are separated by spaces. Each token can

View File

@ -9,6 +9,7 @@ user@host:~$ sudo pacman -S python
user@host:~$ sudo pacman -S python-pip
user@host:~$ sudo pacman -S ffmpeg
user@host:~$ sudo pacman -S npm
user@host:~$ sudo pacman -S elasticsearch
user@host:~$ sudo pip install virtualenv
user@host:~$ python --version
Python 3.5.1
@ -43,6 +44,13 @@ user@host:~$ sudo -i -u postgres psql -c "ALTER USER szuru PASSWORD 'dog';"
### Setting up elasticsearch
```console
user@host:~$ sudo systemctl start elasticsearch
user@host:~$ sudo systemctl enable elasticsearch
```
### Preparing environment
Getting `szurubooru`:

View File

@ -26,7 +26,7 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
## Requirements
- Python
- Python 3.5
- Postgres
- FFmpeg
- node.js

View File

@ -213,13 +213,6 @@ function bundleBinaryAssets() {
});
}
process.on('uncaughtException', (error) => {
const stack = error.stack;
delete error.stack;
console.log(error);
console.log(stack);
});
const config = getConfig();
bundleConfig(config);
bundleBinaryAssets();

View File

@ -1,60 +1,14 @@
@import colors
$comment-header-background-color = $top-navigation-color
$comment-border-color = #DDD
.comment-form-container
&:not(.editing)
.tabs nav
display: none
.tabs .edit.tab
display: none
.comment-content
margin-left: 0.5em
&.editing
.tab:not(.active)
display: none
.tabs-wrapper
background: $active-tab-background-color
padding: 0.3em
.tab-wrapper[data-tab='preview']
background: $window-color
.tab.preview
padding: 1em
.tab.edit
textarea
resize: vertical
width: 100%
max-height: 80vh
box-sizing: padding-box
vertical-align: top /* ghost margin on chrome */
form
width: auto
margin: 0
&:after
display: block
height: 1px
content: ' '
clear: both
nav
vertical-align: middle !important
&.buttons
margin: 0 0.3em 0.5em 0 !important
float: left
&.actions
float: left
margin: 0.3em 0 0.5em 0 !important
.comment
.comment-container
margin: 0 0 1em 0
padding: 0
display: -webkit-flex
display: flex
padding: 0 0 0 60px
.avatar
margin-right: 1em
-webkit-flex-shrink: 0
flex-shrink: 0
float: left
margin-left: -60px
vertical-align: top
.thumbnail
@ -63,25 +17,72 @@
a
display: inline-block
.body
flex-grow: 1
nav:not(.active), .tab:not(.active)
display: none
.comment
border: 1px solid $comment-border-color
header
white-space: nowrap
line-height: 16pt
vertical-align: middle
margin-bottom: 0.5em
background: $top-navigation-color
padding: 0.2em 0.5em
.nickname, .date, .score-container, .edit
margin-right: 2em
.date, .score-container, .edit, .delete
font-size: 95%
vertical-align: middle
position: relative
background: $comment-header-background-color
border-bottom: 1px solid $comment-border-color
nav.edit
padding: 0.25em 1em 0 1em
line-height: 2em
ul
list-style-type: none
margin: -1px 0 -1px 0
padding: 0
li
display: inline-block
border: 1px solid transparent
a
padding: 0 1em
&.active
background: $window-color
border: 1px solid $comment-border-color
border-bottom: 1px solid $window-color
nav.readonly
padding: 0 1em
line-height: 2.25em
.date, .score-container, .edit
margin-right: 2em
.score-container, .link-container
display: inline-block
&:before
position: absolute
display: block
content: ' '
width: 0
height: 0
left: -1.5em
top: calc(50% - 0.75em)
border: 0.75em solid transparent
border-right: 0.75em solid darken($comment-border-color, 10%)
&:after
position: absolute
display: block
content: ' '
width: 0
height: 0
left: calc(-1.5em + 1px)
top: calc(50% - 0.75em)
border: 0.75em solid transparent
border-right: 0.75em solid $comment-header-background-color
.edit, .delete, .score-container a, .nickname a
&:not(.inactive)
color: mix($main-color, $inactive-tab-text-color)
.edit, .delete
font-size: 80%
i
margin-right: 0.3em
@ -96,15 +97,28 @@
display: inline-block
width: 2em
.body
width: auto
margin: 1em
.keep-height
position: relative
textarea
position: absolute
width: 100%
height: 100%
.tab.edit
min-height: 150px
.messages
margin: 1em 0
.comment-content
ul
ul, ol
list-style-position: inside
margin: 1em 0
padding: 0
padding: 0 0 0 1.5em
.sjis
font-family: 'MS PGothic', ' ', 'IPAMonaPGothic', 'Trebuchet MS', Verdana, Futura, Arial, Helvetica, sans-serif
@ -118,9 +132,6 @@
white-space: pre
word-wrap: normal
p:first-child
margin-top: 0
.spoiler
background: #eee
color: #eee
@ -140,5 +151,7 @@
background: #fafafa
color: #444
blockquote :last-child
:first-child
margin-top: 0
:last-child
margin-bottom: 0

View File

@ -1,4 +1,4 @@
.comments>ul
list-style-type: none
margin: 0 0 2em 0
margin: 0
padding: 0

View File

@ -18,14 +18,18 @@
@media (min-width: 700px)
&>li
display: flex
padding-left: 13em
margin-bottom: 2em
.post-thumbnail
float: left
margin: 0 0 1em -13em
.thumbnail
width: 12em
height: 8em
&>li
clear: both
.post-thumbnail
vertical-align: top
margin-right: 1em

View File

@ -4,17 +4,15 @@ form
display: block
width: 20em
ul
.input
list-style-type: none
margin: 0 0 1em 0
margin: 0 0 2em 0
padding: 0
li
margin-top: 1.2em
label
display: block
padding: 0.3em 0
.input
margin-bottom: 2em
.input li:first-child label:not(.radio):not(.checkbox):not(.file-dropper),
.input li:first-child
padding-top: 0
@ -65,10 +63,9 @@ input[type=radio], input[type=checkbox]
.radio:before, .checkbox:before
transition: border-color 0.1s linear
position: absolute
top: 50%
left: 0
top: 0.15em
display: block
margin-top: -10px
width: 16px
height: 16px
background: $input-enabled-background-color
@ -79,10 +76,10 @@ input[type=radio], input[type=checkbox]
background: $main-color
transition: opacity 0.1s linear
position: absolute
top: 50%
left: 5px
top: 0.15em
margin-top: 5px
display: block
margin-top: -5px
width: 10px
height: 10px
border-radius: 50%
@ -92,10 +89,10 @@ input[type=radio], input[type=checkbox]
.checkbox:after
transition: opacity 0.1s linear
position: absolute
top: 50%
top: 0.15em
left: 6px
display: block
margin-top: -7px
margin-top: 3px
width: 5px
height: 9px
border-right: 3px solid $main-color

View File

@ -8,16 +8,7 @@
margin: 0 auto
position: relative
img, object, video, .post-overlay
position: absolute
height: 100%
width: 100%
left: 0
right: 0
top: 0
bottom: 0
.post-overlay>*
.resize-listener
position: absolute
left: 0
right: 0

View File

@ -0,0 +1,37 @@
#post
width: 100%
max-width: 40em
h1
margin-top: 0
form
width: 100%
.buttons i
margin-right: 0.5em
.post-merge
.left-post-container
width: 47%
float: left
.right-post-container
width: 47%
float: right
.post-mirror
margin-bottom: 1em
&:after
display: block
height: 1px
content: ' '
clear: both
.post-thumbnail .thumbnail
width: 100%
height: 9em
.target-post .thumbnail
margin-right: 0.35em
.target-post, .target-post-content
margin: 1em 0
header
margin-bottom: 1em
label
display: inline-block
margin-top: 2px
input[type=text]
width: 6em

View File

@ -106,7 +106,7 @@
text-align: left
label
display: none
display: none !important
form
width: auto
margin-bottom: 0.75em

View File

@ -120,11 +120,12 @@
margin-bottom: 1em
.safety
&>label
width: 100%
.radio-wrapper
display: flex
flex-wrap: wrap
label:not(.radio)
width: 100%
.radio
.radio-wrapper label
flex-grow: 1
display: inline-block

View File

@ -1,5 +1,6 @@
@import colors
$upload-header-background-color = $top-navigation-color
$upload-border-color = #DDD
$cancel-button-color = tomato
#post-upload
@ -35,38 +36,114 @@ $cancel-button-color = tomato
.skip-duplicates
margin-left: 1em
.messages
form>.messages
margin-top: 1em
.uploadables-container
line-height: 200%
list-style-type: none
margin: 0
padding: 0
li
.uploadable-container
clear: both
margin: 0 0 1.2em 0
padding-left: 13em
.uploadable
.file
overflow: hidden
white-space: nowrap
text-align: left
text-overflow: ellipsis
.safety
label
margin-right: 1em
.thumbnail-wrapper
&>.thumbnail-wrapper
float: left
width: 12.5em
height: 7em
margin: 0.2em 1em 0 0
width: 12em
height: 8em
margin: 0 0 0 -13em
.thumbnail
width: 100%
height: 100%
.controls
.uploadable
border: 1px solid $upload-border-color
min-height: 8em
box-sizing: border-box
header
line-height: 1.5em
padding: 0.25em 1em
text-align: left
background: $upload-header-background-color
border-bottom: 1px solid $upload-border-color
nav
&:first-of-type
float: left
a
margin: 0 0.5em 0 0
&:last-of-type
float: right
a
margin: 0 0 0 0.5em
ul
list-style-type: none
ul, li
display: inline-block
margin: 0
padding: 0
span.filename
padding: 0 0.5em
display: block
overflow: hidden
white-space: nowrap
text-overflow: ellipsis
.body
margin: 1em
.anonymous
margin: 0.3em 0
.safety
margin: 0.3em 0
label
display: inline-block
margin-right: 1em
.options div
display: inline-block
margin: 0 1em 0 0
.messages
margin-top: 1em
.message:last-child
margin-bottom: 0
.lookalikes
list-style-type: none
margin: 0
padding: 0
li
clear: both
margin: 1em 0 0 0
padding-left: 7em
font-size: 90%
.thumbnail-wrapper
float: left
width: 6em
height: 4em
margin: 0 0 0 -7em
.thumbnail
width: 100%
height: 100%
.description
margin-right: 0.5em
display: inline-block
.controls
float: right
display: inline-block
&:first-child .move-up
color: $inactive-link-color
&:last-child .move-down
color: $inactive-link-color
margin-left: 0.5em

View File

@ -55,6 +55,7 @@ div.tag-input
padding: 0.2em 1em
margin: 0
ul
list-style-type: none
margin: 0
overflow-y: auto
overflow-x: none
@ -87,7 +88,8 @@ div.tag-input
ul.compact-tags
width: 100%
margin-top: 0.5em
margin: 0.5em 0 0 0
padding: 0
li
margin: 0
width: 100%

View File

@ -41,7 +41,7 @@
.tag-list-header
label
display: none
display: none !important
text-align: left
form
width: auto

View File

@ -28,7 +28,7 @@
.user-list-header
label
display: none
display: none !important
text-align: left
form
width: auto

View File

@ -1,57 +1,85 @@
<div class='comment'>
<div class='comment-container'>
<div class='avatar'>
<% if (ctx.comment.user && ctx.comment.user.name && ctx.canViewUsers) { %>
<a href='/user/<%- encodeURIComponent(ctx.comment.user.name) %>'>
<% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %>
<a href='/user/<%- encodeURIComponent(ctx.user.name) %>'>
<% } %>
<%= ctx.makeThumbnail(ctx.comment.user ? ctx.comment.user.avatarUrl : null) %>
<%= ctx.makeThumbnail(ctx.user ? ctx.user.avatarUrl : null) %>
<% if (ctx.comment.user && ctx.comment.user.name && ctx.canViewUsers) { %>
<% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %>
</a>
<% } %>
</div>
<div class='body'>
<header><%
%><span class='nickname'><%
%><% if (ctx.comment.user && ctx.comment.user.name && ctx.canViewUsers) { %><%
%><a href='/user/<%- encodeURIComponent(ctx.comment.user.name) %>'><%
<div class='comment'>
<header>
<nav class='edit tabs'>
<ul>
<li class='edit'><a href>Write</a></li>
<li class='preview'><a href>Preview</a></li>
</ul>
</nav>
<nav class='readonly'><%
%><strong><span class='nickname'><%
%><% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %><%
%><a href='/user/<%- encodeURIComponent(ctx.user.name) %>'><%
%><% } %><%
%><%- ctx.comment.user ? ctx.comment.user.name : 'Deleted user' %><%
%><%- ctx.user ? ctx.user.name : 'Deleted user' %><%
%><% if (ctx.comment.user && ctx.comment.user.name && ctx.canViewUsers) { %><%
%><% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %><%
%></a><%
%><% } %><%
%></span><%
%></span></strong>
%><wbr><%
%><span class='date'><%
%><%= ctx.makeRelativeTime(ctx.comment.creationTime) %><%
<span class='date'><%
%>commented <%= ctx.makeRelativeTime(ctx.comment ? ctx.comment.creationTime : null) %><%
%></span><%
%><wbr><%
%><span class='score-container'></span><%
%><wbr><%
%><% if (ctx.canEditComment || ctx.canDeleteComment) { %><%
%><span class='action-container'><%
%><% if (ctx.canEditComment) { %><%
%><a href class='edit'><%
%><i class='fa fa-pencil'></i> edit<%
%><i class='fa fa-pencil'></i>&nbsp;edit<%
%></a><%
%><% } %><%
%><wbr><%
%><% if (ctx.canDeleteComment) { %><%
%><a href class='delete'><%
%><i class='fa fa-remove'></i> delete<%
%><i class='fa fa-remove'></i>&nbsp;delete<%
%></a><%
%><% } %><%
%></span><%
%><% } %><%
%></nav><%
%></header>
<div class='comment-form-container'></div>
<form class='body'>
<div class='keep-height'>
<div class='tab preview'>
<div class='comment-content'>
<%= ctx.makeMarkdown(ctx.comment ? ctx.comment.text : '') %>
</div>
</div>
<div class='tab edit'>
<textarea required minlength=1><%- ctx.comment ? ctx.comment.text : '' %></textarea>
</div>
</div>
<nav class='edit'>
<div class='messages'></div>
<input type='submit' class='save-changes' value='Save'/>
<% if (!ctx.onlyEditing) { %>
<input type='button' class='cancel-editing discourage' value='Cancel'/>
<% } %>
</div>
</form>
</div>
</div>

View File

@ -1,31 +0,0 @@
<div class='tabs'>
<form>
<div class='tabs-wrapper'><%
%><div class='tab-wrapper'><%
%><div class='preview tab'><%
%><div class='comment-content'><%
%><%= ctx.makeMarkdown(ctx.comment.text) %><%
%></div><%
%></div><%
%><div class='edit tab'><%
%><textarea required minlength=1><%- ctx.comment.text %></textarea><%
%></div><%
%></div><%
%></div>
<nav class='buttons'>
<ul>
<li class='preview'><a href>Preview</a></li>
<li class='edit'><a href>Edit</a></li>
</ul>
</nav>
<nav class='actions'>
<input type='submit' class='save' value='Save'/>
<input type='button' class='cancel discourage' value='Cancel'/>
</nav>
</form>
<div class='messages'></div>
</div>

View File

@ -28,3 +28,11 @@
</tr>
</tbody>
</table>
<p>You can also specify the size of embedded images like this:</p>
<ul>
<li><code>![alt](href =WIDTHx "title")</code></li>
<li><code>![alt](href =xHEIGHT "title")</code></li>
<li><code>![alt](href =WIDTHxHEIGHT "title")</code></li>
</ul>

View File

@ -1,8 +1,7 @@
<div class='content-wrapper' id='login'>
<h1>Log in</h1>
<form>
<div class='input'>
<ul>
<ul class='input'>
<li>
<%= ctx.makeTextInput({
text: 'User name',
@ -26,8 +25,9 @@
}) %>
</li>
</ul>
</div>
<div class='messages'></div>
<div class='buttons'>
<input type='submit' value='Log in'/>
<% if (ctx.canSendMails) { %>

View File

@ -1,8 +1,7 @@
<div class='content-wrapper' id='password-reset'>
<h1>Password reset</h1>
<form autocomplete='off'>
<div class='input'>
<ul>
<ul class='input'>
<li>
<%= ctx.makeTextInput({
text: 'User name or e-mail address',
@ -11,10 +10,11 @@
}) %>
</li>
</ul>
</div>
<p><small>Proceeding will send an e-mail that contains a password reset
link. Clicking it is going to generate a new password for your account.
It is recommended to change that password to something else.</small></p>
<div class='messages'></div>
<div class='buttons'>
<input type='submit' value='Proceed'/>

View File

@ -1,30 +1,33 @@
<div class='post-content post-type-<%- ctx.post.type %>'>
<% if (['image', 'animation'].includes(ctx.post.type)) { %>
<img alt='' src='<%- ctx.post.contentUrl %>'/>
<img class='resize-listener' alt='' src='<%- ctx.post.contentUrl %>'/>
<% } else if (ctx.post.type === 'flash') { %>
<object width='<%- ctx.post.canvasWidth %>' height='<%- ctx.post.canvasHeight %>' data='<%- ctx.post.contentUrl %>'>
<object class='resize-listener' width='<%- ctx.post.canvasWidth %>' height='<%- ctx.post.canvasHeight %>' data='<%- ctx.post.contentUrl %>'>
<param name='wmode' value='opaque'/>
<param name='movie' value='<%- ctx.post.contentUrl %>'/>
</object>
<% } else if (ctx.post.type === 'video') { %>
<% if ((ctx.post.flags || []).includes('loop')) { %>
<video id='video' controls loop='loop'>
<% } else { %>
<video id='video' controls>
<% } %>
<source type='<%- ctx.post.mimeType %>' src='<%- ctx.post.contentUrl %>'/>
Your browser doesn't support HTML5 videos.
</video>
<%= ctx.makeElement(
'video', {
class: 'resize-listener',
controls: true,
loop: (ctx.post.flags || []).includes('loop'),
autoplay: ctx.autoplay,
},
ctx.makeElement('source', {
type: ctx.post.mimeType,
src: ctx.post.contentUrl,
}),
'Your browser doesn\'t support HTML5 videos.')
%>
<% } else { console.log(new Error('Unknown post type')); } %>
<div class='post-overlay'>
<div class='post-overlay resize-listener'>
</div>
</div>

View File

@ -0,0 +1,12 @@
<div class='content-wrapper' id='post'>
<h1>Post #<%- ctx.post.id %></h1>
<nav class='buttons'><!--
--><ul><!--
--><li><a href='/post/<%- ctx.post.id %>'><i class='fa fa-reply'></i> Main view</a></li><!--
--><% if (ctx.canMerge) { %><!--
--><li data-name='merge'><a href='/post/<%- ctx.post.id %>/merge'>Merge with&hellip;</a></li><!--
--><% } %><!--
--></ul><!--
--></nav>
<div class='post-content-holder'></div>
</div>

View File

@ -7,6 +7,7 @@
<% if (ctx.canEditPostSafety) { %>
<section class='safety'>
<label>Safety</label>
<div class='radio-wrapper'>
<%= ctx.makeRadio({
name: 'safety',
class: 'safety-safe',
@ -25,6 +26,7 @@
selectedValue: ctx.post.safety,
class: 'safety-unsafe',
text: 'Unsafe'}) %>
</div>
</section>
<% } %>
@ -82,12 +84,15 @@
</section>
<% } %>
<% if (ctx.canFeaturePosts) { %>
<% if (ctx.canFeaturePosts || ctx.canDeletePosts || ctx.canMergePosts) { %>
<section class='management'>
<ul>
<% if (ctx.canFeaturePosts) { %>
<li><a href class='feature'>Feature this post on main page</a></li>
<% } %>
<% if (ctx.canMergePosts) { %>
<li><a href class='merge'>Merge this post with another</a></li>
<% } %>
<% if (ctx.canDeletePosts) { %>
<li><a href class='delete'>Delete this post</a></li>
<% } %>

View File

@ -0,0 +1,23 @@
<div class='post-merge'>
<form>
<ul class='input'>
<li class='post-mirror'>
<div class='left-post-container'></div>
<div class='right-post-container'></div>
</li>
<li>
<p>Tags, relations, scores, favorites and comments will be
merged. All other properties need to be handled manually.</p>
<%= ctx.makeCheckbox({required: true, text: 'I confirm that I want to merge these posts.'}) %>
</li>
</ul>
<div class='messages'></div>
<div class='buttons'>
<input type='submit' value='Merge posts'/>
</div>
</form>
</div>

View File

@ -0,0 +1,52 @@
<header>
<label for='merge-id-<%- ctx.name %>'>Post #</label>
<% if (ctx.editable) { %>
<input type='text' id='merge-id-<%-ctx.name %>' pattern='^[0-9]+$' value='<%- ctx.post ? ctx.post.id : '' %>'/>
<input type='button' value='Search'/>
<% } else { %>
<input type='text' id='merge-id-<%-ctx.name %>' pattern='^[0-9]+$' value='<%- ctx.post ? ctx.post.id : '' %>' readonly/>
<% } %>
</header>
<% if (ctx.post) { %>
<div class='post-thumbnail'>
<a rel='external' href='<%- ctx.post.contentUrl %>'>
<%= ctx.makeThumbnail(ctx.post.thumbnailUrl) %>
</a>
</div>
<div class='target-post'>
<%= ctx.makeRadio({
required: true,
text: 'Merge to this post<br/><small>' +
ctx.makeUserLink(ctx.post.user) +
', ' +
ctx.makeRelativeTime(ctx.post.creationTime) +
'</small>',
name: 'target-post',
value: ctx.name,
}) %>
</div>
<div class='target-post-content'>
<%= ctx.makeRadio({
required: true,
text: 'Use this file<br/><small>' +
ctx.makeFileSize(ctx.post.fileSize) + ' ' +
{
'image/gif': 'GIF',
'image/jpeg': 'JPEG',
'image/png': 'PNG',
'video/webm': 'WEBM',
'application/x-shockwave-flash': 'SWF',
}[ctx.post.mimeType] +
' (' +
(ctx.post.canvasWidth ?
`${ctx.post.canvasWidth}x${ctx.post.canvasHeight}` :
'?') +
')</small>',
name: 'target-post-content',
value: ctx.name,
}) %>
<p>
</p>
</div>
<% } %>

View File

@ -1,10 +1,4 @@
<li class='uploadable'>
<div class='controls'>
<a href class='move-up'><i class='fa fa-chevron-up'></i></a>
<a href class='move-down'><i class='fa fa-chevron-down'></i></a>
<a href class='remove'><i class='fa fa-remove'></i></a>
</div>
<li class='uploadable-container'>
<div class='thumbnail-wrapper'>
<% if (['image'].includes(ctx.uploadable.type)) { %>
@ -29,10 +23,24 @@
<% } %>
</div>
<div class='file'>
<strong><%= ctx.uploadable.name %></strong>
</div>
<div class='uploadable'>
<header>
<nav>
<ul>
<li><a href class='move-up'><i class='fa fa-chevron-up'></i></a></li>
<li><a href class='move-down'><i class='fa fa-chevron-down'></i></a></li>
</ul>
</nav>
<nav>
<ul>
<li><a href class='remove'><i class='fa fa-remove'></i></a></li>
</ul>
</nav>
<span class='filename'><%= ctx.uploadable.name %></span>
</header>
<div class='body'>
<div class='safety'>
<% for (let safety of ['safe', 'sketchy', 'unsafe']) { %>
<%= ctx.makeRadio({
@ -44,6 +52,7 @@
<% } %>
</div>
<div class='options'>
<% if (ctx.canUploadAnonymously) { %>
<div class='anonymous'>
<%= ctx.makeCheckbox({
@ -53,4 +62,42 @@
}) %>
</div>
<% } %>
<% if (['video'].includes(ctx.uploadable.type)) { %>
<div class='loop-video'>
<%= ctx.makeCheckbox({
text: 'Loop video',
name: 'loop-video',
checked: ctx.uploadable.flags.includes('loop'),
}) %>
</div>
<% } %>
</div>
<div class='messages'></div>
<% if (ctx.uploadable.lookalikes.length) { %>
<ul class='lookalikes'>
<% for (let lookalike of ctx.uploadable.lookalikes) { %>
<li>
<a class='thumbnail-wrapper' title='@<%- lookalike.post.id %>'
href='<%= ctx.canViewPosts ? ctx.getPostUrl(lookalike.post.id) : "" %>'>
<%= ctx.makeThumbnail(lookalike.post.thumbnailUrl) %>
</a>
<div class='description'>
Similar post: <%= ctx.makePostLink(lookalike.post.id, true) %>
<br/>
<%- Math.round((1-lookalike.distance) * 100) %>% match
</div>
<div class='controls'>
<%= ctx.makeCheckbox({text: 'Copy tags', name: 'copy-tags'}) %>
<br/>
<%= ctx.makeCheckbox({text: 'Add relation', name: 'add-relation'}) %>
</div>
</li>
<% } %>
</ul>
<% } %>
</div>
</div>
</li>

View File

@ -5,11 +5,10 @@
<ul class='input'>
<li>
<%= ctx.makeCheckbox({
text: 'Enable keyboard shortcuts',
text: "Enable keyboard shortcuts <a class='append icon' href='/help/keyboard'><i class='fa fa-question-circle-o'></i></a>",
name: 'keyboard-shortcuts',
checked: ctx.browsingSettings.keyboardShortcuts,
}) %>
<a class='append icon' href='/help/keyboard'><i class='fa fa-question-circle-o'></i></a>
</li>
<li>
@ -56,6 +55,14 @@
}) %>
<p class='hint'>Shows a popup with suggested tags in edit forms.</p>
</li>
<li>
<%= ctx.makeCheckbox({
text: 'Automatically play video posts',
name: 'autoplay-videos',
checked: ctx.browsingSettings.autoplayVideos,
}) %>
</li>
</ul>
<div class='messages'></div>

View File

@ -2,7 +2,7 @@
<form>
<p>This tag has <a href='/posts/query=<%- encodeURIComponent(ctx.tag.names[0]) %>'><%- ctx.tag.postCount %> usage(s)</a>.</p>
<ul>
<ul class='input'>
<li>
<%= ctx.makeCheckbox({
name: 'confirm-deletion',

View File

@ -1,6 +1,6 @@
<div class='content-wrapper tag-edit'>
<form>
<ul>
<ul class='input'>
<li class='names'>
<% if (ctx.canEditNames) { %>
<%= ctx.makeTextInput({

View File

@ -1,14 +1,14 @@
<div class='tag-merge'>
<form>
<p>Proceeding will remove this tag and retag its posts with the tag
specified below. Aliases, suggestions and implications are discarded
and need to be handled manually.</p>
<ul>
<ul class='input'>
<li class='target'>
<%= ctx.makeTextInput({required: true, text: 'Target tag', pattern: ctx.tagNamePattern}) %>
</li>
<li class='confirm'>
<li>
<p>Usages in posts, suggestions and implications will be
merged. Category and aliases need to be handled manually.</p>
<%= ctx.makeCheckbox({required: true, text: 'I confirm that I want to merge this tag.'}) %>
</li>
</ul>

View File

@ -1,12 +1,11 @@
<div class='tag-list-header'>
<form class='horizontal'>
<div class='input'>
<ul>
<ul class='input'>
<li>
<%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.parameters.query}) %>
</li>
</ul>
</div>
<div class='buttons'>
<input type='submit' value='Search'/>
<a class='button append' href='/help/search/tags'>Syntax help</a>

View File

@ -1,7 +1,6 @@
<div id='user-delete'>
<form>
<div class='input'>
<ul>
<ul class='input'>
<li>
<%= ctx.makeCheckbox({
name: 'confirm-deletion',
@ -10,7 +9,7 @@
}) %>
</li>
</ul>
</div>
<div class='messages'></div>
<div class='buttons'>
<input type='submit' value='Delete account'/>

View File

@ -4,8 +4,7 @@
<input class='anticomplete' type='text' name='fakeuser'/>
<input class='anticomplete' type='password' name='fakepass'/>
<div class='input'>
<ul>
<ul class='input'>
<li>
<%= ctx.makeTextInput({
text: 'User name',
@ -36,12 +35,13 @@
</p>
</li>
</ul>
</div>
<div class='messages'></div>
<div class='buttons'>
<input type='submit' value='Create an account'/>
</div>
</form>
<div class='info'>
<p>Registered users can:</p>
<ul>

View File

@ -1,12 +1,11 @@
<div class='user-list-header'>
<form class='horizontal'>
<div class='input'>
<ul>
<ul class='input'>
<li>
<%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.parameters.query}) %>
</li>
</ul>
</div>
<div class='buttons'>
<input type='submit' value='Search'/>
<a class='append' href='/help/search/users'>Syntax help</a>

View File

@ -1,10 +1,12 @@
'use strict';
const nprogress = require('nprogress');
const cookies = require('js-cookie');
const request = require('superagent');
const config = require('./config.js');
const events = require('./events.js');
const progress = require('./util/progress.js');
let fileTokens = {};
class Api extends events.EventTarget {
constructor() {
@ -39,7 +41,7 @@ class Api extends events.EventTarget {
resolve(this.cache[url]);
});
}
return this._process(url, request.get, {}, {}, options)
return this._wrappedRequest(url, request.get, {}, {}, options)
.then(response => {
this.cache[url] = response;
return Promise.resolve(response);
@ -48,83 +50,17 @@ class Api extends events.EventTarget {
post(url, data, files, options) {
this.cache = {};
return this._process(url, request.post, data, files, options);
return this._wrappedRequest(url, request.post, data, files, options);
}
put(url, data, files, options) {
this.cache = {};
return this._process(url, request.put, data, files, options);
return this._wrappedRequest(url, request.put, data, files, options);
}
delete(url, data, options) {
this.cache = {};
return this._process(url, request.delete, data, {}, options);
}
_process(url, requestFactory, data, files, options) {
options = options || {};
const [fullUrl, query] = this._getFullUrl(url);
let abortFunction = null;
let promise = new Promise((resolve, reject) => {
let req = requestFactory(fullUrl);
req.set('Accept', 'application/json');
if (query) {
req.query(query);
}
if (data) {
req.attach('metadata', new Blob([JSON.stringify(data)]));
}
if (files) {
for (let key of Object.keys(files)) {
req.attach(key, files[key] || new Blob());
}
}
try {
if (this.userName && this.userPassword) {
req.auth(
this.userName,
encodeURIComponent(this.userPassword)
.replace(/%([0-9A-F]{2})/g, (match, p1) => {
return String.fromCharCode('0x' + p1);
}));
}
} catch (e) {
reject({
title: 'Authentication error',
description: 'Malformed credentials'});
}
if (!options.noProgress) {
nprogress.start();
}
abortFunction = () => {
req.abort(); // does *NOT* call the callback passed in .end()
nprogress.done();
reject({
title: 'Cancelled',
description:
'The request was aborted due to user cancel.'});
};
req.end((error, response) => {
nprogress.done();
if (error) {
reject(response && response.body ? response.body : {
title: 'Networking error',
description: error.message});
} else {
resolve(response.body);
}
});
});
promise.abort = () => abortFunction();
return promise;
return this._wrappedRequest(url, request.delete, data, {}, options);
}
hasPrivilege(lookup) {
@ -149,18 +85,10 @@ class Api extends events.EventTarget {
}
loginFromCookies() {
return new Promise((resolve, reject) => {
const auth = cookies.getJSON('auth');
if (auth && auth.user && auth.password) {
this.login(auth.user, auth.password, true)
.then(resolve)
.catch(errorMessage => {
reject(errorMessage);
});
} else {
resolve();
}
});
return auth && auth.user && auth.password ?
this.login(auth.user, auth.password, true) :
Promise.resolve();
}
login(userName, userPassword, doRemember) {
@ -181,8 +109,8 @@ class Api extends events.EventTarget {
this.user = response;
resolve();
this.dispatchEvent(new CustomEvent('login'));
}, response => {
reject(response.description || response || 'Unknown error');
}, error => {
reject(error);
this.logout();
});
});
@ -216,6 +144,161 @@ class Api extends events.EventTarget {
const request = matches[2];
return [baseUrl, request];
}
_getFileId(file) {
if (file.constructor === String) {
return file;
}
return file.name + file.size;
}
_wrappedRequest(url, requestFactory, data, files, options) {
// transform the request: upload each file, then make the request use
// its tokens.
data = Object.assign({}, data);
let abortFunction = () => {};
let promise = Promise.resolve();
if (files) {
for (let key of Object.keys(files)) {
const file = files[key];
const fileId = this._getFileId(file);
if (fileTokens[fileId]) {
data[key + 'Token'] = fileTokens[fileId];
} else {
promise = promise
.then(() => {
let uploadPromise = this._upload(file);
abortFunction = () => uploadPromise.abort();
return uploadPromise;
})
.then(token => {
abortFunction = () => {};
fileTokens[fileId] = token;
data[key + 'Token'] = token;
return Promise.resolve();
});
}
}
}
promise = promise.then(
() => {
let requestPromise = this._rawRequest(
url, requestFactory, data, {}, options);
abortFunction = () => requestPromise.abort();
return requestPromise;
})
.catch(error => {
if (error.response && error.response.name ===
'MissingOrExpiredRequiredFileError') {
for (let key of Object.keys(files)) {
const file = files[key];
const fileId = this._getFileId(file);
fileTokens[fileId] = null;
}
error.message =
'The uploaded file has expired; ' +
'please resend the form to reupload.';
}
return Promise.reject(error);
});
promise.abort = () => abortFunction();
return promise;
}
_upload(file, options) {
let abortFunction = () => {};
let returnedPromise = new Promise((resolve, reject) => {
let uploadPromise = this._rawRequest(
'/uploads', request.post, {}, {content: file}, options);
abortFunction = () => uploadPromise.abort();
return uploadPromise.then(
response => {
abortFunction = () => {};
return resolve(response.token);
}, reject);
});
returnedPromise.abort = () => abortFunction();
return returnedPromise;
}
_rawRequest(url, requestFactory, data, files, options) {
options = options || {};
data = Object.assign({}, data);
const [fullUrl, query] = this._getFullUrl(url);
let abortFunction = () => {};
let returnedPromise = new Promise((resolve, reject) => {
let req = requestFactory(fullUrl);
req.set('Accept', 'application/json');
if (query) {
req.query(query);
}
if (files) {
for (let key of Object.keys(files)) {
const value = files[key];
if (value.constructor === String) {
data[key + 'Url'] = value;
} else {
req.attach(key, value || new Blob());
}
}
}
if (data) {
if (files && Object.keys(files).length) {
req.attach('metadata', new Blob([JSON.stringify(data)]));
} else {
req.set('Content-Type', 'application/json');
req.send(data);
}
}
try {
if (this.userName && this.userPassword) {
req.auth(
this.userName,
encodeURIComponent(this.userPassword)
.replace(/%([0-9A-F]{2})/g, (match, p1) => {
return String.fromCharCode('0x' + p1);
}));
}
} catch (e) {
reject(
new Error('Authentication error (malformed credentials)'));
}
if (!options.noProgress) {
progress.start();
}
abortFunction = () => {
req.abort(); // does *NOT* call the callback passed in .end()
progress.done();
reject(
new Error('The request was aborted due to user cancel.'));
};
req.end((error, response) => {
progress.done();
abortFunction = () => {};
if (error) {
if (response && response.body) {
error = new Error(
response.body.description || 'Unknown error');
error.response = response.body;
}
reject(error);
} else {
resolve(response.body);
}
});
});
returnedPromise.abort = () => abortFunction();
return returnedPromise;
}
}
module.exports = new Api();

View File

@ -23,8 +23,8 @@ class LoginController {
.then(() => {
const ctx = router.show('/');
ctx.controller.showSuccess('Logged in');
}, errorMessage => {
this._loginView.showError(errorMessage);
}, error => {
this._loginView.showError(error.message);
this._loginView.enableForm();
});
}

View File

@ -0,0 +1,20 @@
'use strict';
const api = require('../api.js');
const topNavigation = require('../models/top_navigation.js');
const EmptyView = require('../views/empty_view.js');
class BasePostController {
constructor(ctx) {
if (!api.hasPrivilege('posts:view')) {
this._view = new EmptyView();
this._view.showError('You don\'t have privileges to view posts.');
return;
}
topNavigation.activate('posts');
topNavigation.setTitle('Post #' + ctx.parameters.id.toString());
}
}
module.exports = BasePostController;

View File

@ -51,24 +51,20 @@ class CommentsController {
// TODO: disable form
e.detail.comment.text = e.detail.text;
e.detail.comment.save()
.catch(errorMessage => {
e.detail.target.showError(errorMessage);
.catch(error => {
e.detail.target.showError(error.message);
// TODO: enable form
});
}
_evtScore(e) {
e.detail.comment.setScore(e.detail.score)
.catch(errorMessage => {
window.alert(errorMessage);
});
.catch(error => window.alert(error.message));
}
_evtDelete(e) {
e.detail.comment.delete()
.catch(errorMessage => {
window.alert(errorMessage);
});
.catch(error => window.alert(error.message));
}
};

View File

@ -31,9 +31,7 @@ class HomeController {
featuringTime: info.featuringTime,
});
},
errorMessage => {
this._homeView.showError(errorMessage);
});
error => this._homeView.showError(error.message));
}
showSuccess(message) {

View File

@ -25,8 +25,8 @@ class PasswordResetController {
this._passwordResetView.showSuccess(
'E-mail has been sent. To finish the procedure, ' +
'please click the link it contains.');
}, response => {
this._passwordResetView.showError(response.description);
}, error => {
this._passwordResetView.showError(error.message);
this._passwordResetView.enableForm();
});
}
@ -41,14 +41,12 @@ class PasswordResetFinishController {
.then(response => {
password = response.password;
return api.login(name, password, false);
}, response => {
return Promise.reject(response.description);
}).then(() => {
const ctx = router.show('/');
ctx.controller.showSuccess('New password: ' + password);
}, errorMessage => {
}, error => {
const ctx = router.show('/');
ctx.controller.showError(errorMessage);
ctx.controller.showError(error.message);
});
}
}

View File

@ -0,0 +1,84 @@
'use strict';
const router = require('../router.js');
const api = require('../api.js');
const misc = require('../util/misc.js');
const settings = require('../models/settings.js');
const Post = require('../models/post.js');
const PostList = require('../models/post_list.js');
const PostDetailView = require('../views/post_detail_view.js');
const BasePostController = require('./base_post_controller.js');
const EmptyView = require('../views/empty_view.js');
class PostDetailController extends BasePostController {
constructor(ctx, section) {
super(ctx);
Post.get(ctx.parameters.id).then(post => {
this._id = ctx.parameters.id;
post.addEventListener('change', e => this._evtSaved(e, section));
this._installView(post, section);
}, error => {
this._view = new EmptyView();
this._view.showError(error.message);
});
}
showSuccess(message) {
this._view.showSuccess(message);
}
_installView(post, section) {
this._view = new PostDetailView({
post: post,
section: section,
canMerge: api.hasPrivilege('posts:merge'),
});
this._view.addEventListener('select', e => this._evtSelect(e));
this._view.addEventListener('merge', e => this._evtMerge(e));
}
_evtSelect(e) {
this._view.clearMessages();
this._view.disableForm();
Post.get(e.detail.postId).then(post => {
this._view.selectPost(post);
this._view.enableForm();
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}
_evtSaved(e, section) {
misc.disableExitConfirmation();
if (this._id !== e.detail.post.id) {
router.replace(
'/post/' + e.detail.post.id + '/' + section, null, false);
}
}
_evtMerge(e) {
this._view.clearMessages();
this._view.disableForm();
e.detail.post.merge(e.detail.targetPost.id, e.detail.useOldContent)
.then(() => {
this._installView(e.detail.post, 'merge');
this._view.showSuccess('Post merged.');
router.replace(
'/post/' + e.detail.targetPost.id + '/merge', null, false);
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}
}
module.exports = router => {
router.enter(
'/post/:id/merge',
(ctx, next) => {
ctx.controller = new PostDetailController(ctx, 'merge');
});
};

View File

@ -61,20 +61,14 @@ class PostListController {
for (let tag of this._massTagTags) {
e.detail.post.addTag(tag);
}
e.detail.post.save()
.catch(errorMessage => {
window.alert(errorMessage);
});
e.detail.post.save().catch(error => window.alert(error.message));
}
_evtUntag(e) {
for (let tag of this._massTagTags) {
e.detail.post.removeTag(tag);
}
e.detail.post.save()
.catch(errorMessage => {
window.alert(errorMessage);
});
e.detail.post.save().catch(error => window.alert(error.message));
}
_decorateSearchQuery(text) {

View File

@ -7,26 +7,19 @@ const settings = require('../models/settings.js');
const Comment = require('../models/comment.js');
const Post = require('../models/post.js');
const PostList = require('../models/post_list.js');
const topNavigation = require('../models/top_navigation.js');
const PostView = require('../views/post_view.js');
const PostMainView = require('../views/post_main_view.js');
const BasePostController = require('./base_post_controller.js');
const EmptyView = require('../views/empty_view.js');
class PostController {
constructor(id, editMode, ctx) {
if (!api.hasPrivilege('posts:view')) {
this._view = new EmptyView();
this._view.showError('You don\'t have privileges to view posts.');
return;
}
topNavigation.activate('posts');
topNavigation.setTitle('Post #' + id.toString());
class PostMainController extends BasePostController {
constructor(ctx, editMode) {
super(ctx);
let parameters = ctx.parameters;
Promise.all([
Post.get(id),
Post.get(ctx.parameters.id),
PostList.getAround(
id, this._decorateSearchQuery(
ctx.parameters.id, this._decorateSearchQuery(
parameters ? parameters.query : '')),
]).then(responses => {
const [post, aroundResponse] = responses;
@ -36,13 +29,13 @@ class PostController {
if (parameters.query) {
ctx.state.parameters = parameters;
const url = editMode ?
'/post/' + id + '/edit' :
'/post/' + id;
'/post/' + ctx.parameters.id + '/edit' :
'/post/' + ctx.parameters.id;
router.replace(url, ctx.state, false);
}
this._post = post;
this._view = new PostView({
this._view = new PostMainView({
post: post,
editMode: editMode,
prevPostId: aroundResponse.prev ? aroundResponse.prev.id : null,
@ -72,12 +65,14 @@ class PostController {
'feature', e => this._evtFeaturePost(e));
this._view.sidebarControl.addEventListener(
'delete', e => this._evtDeletePost(e));
this._view.sidebarControl.addEventListener(
'merge', e => this._evtMergePost(e));
}
if (this._view.commentFormControl) {
this._view.commentFormControl.addEventListener(
if (this._view.commentControl) {
this._view.commentControl.addEventListener(
'change', e => this._evtCommentChange(e));
this._view.commentFormControl.addEventListener(
this._view.commentControl.addEventListener(
'submit', e => this._evtCreateComment(e));
}
@ -89,9 +84,9 @@ class PostController {
this._view.commentListControl.addEventListener(
'delete', e => this._evtDeleteComment(e));
}
}, errorMessage => {
}, error => {
this._view = new EmptyView();
this._view.showError(errorMessage);
this._view.showError(error.message);
});
}
@ -122,12 +117,16 @@ class PostController {
.then(() => {
this._view.sidebarControl.showSuccess('Post featured.');
this._view.sidebarControl.enableForm();
}, errorMessage => {
this._view.sidebarControl.showError(errorMessage);
}, error => {
this._view.sidebarControl.showError(error.message);
this._view.sidebarControl.enableForm();
});
}
_evtMergePost(e) {
router.show('/post/' + e.detail.post.id + '/merge');
}
_evtDeletePost(e) {
this._view.sidebarControl.disableForm();
this._view.sidebarControl.clearMessages();
@ -136,8 +135,8 @@ class PostController {
misc.disableExitConfirmation();
const ctx = router.show('/posts');
ctx.controller.showSuccess('Post deleted.');
}, errorMessage => {
this._view.sidebarControl.showError(errorMessage);
}, error => {
this._view.sidebarControl.showError(error.message);
this._view.sidebarControl.enableForm();
});
}
@ -169,8 +168,8 @@ class PostController {
this._view.sidebarControl.showSuccess('Post saved.');
this._view.sidebarControl.enableForm();
misc.disableExitConfirmation();
}, errorMessage => {
this._view.sidebarControl.showError(errorMessage);
}, error => {
this._view.sidebarControl.showError(error.message);
this._view.sidebarControl.enableForm();
});
}
@ -184,18 +183,18 @@ class PostController {
}
_evtCreateComment(e) {
// TODO: disable form
this._view.commentControl.disableForm();
const comment = Comment.create(this._post.id);
comment.text = e.detail.text;
comment.save()
.then(() => {
this._post.comments.add(comment);
this._view.commentFormControl.setText('');
// TODO: enable form
this._view.commentControl.exitEditMode();
this._view.commentControl.enableForm();
misc.disableExitConfirmation();
}, errorMessage => {
this._view.commentFormControl.showError(errorMessage);
// TODO: enable form
}, error => {
this._view.commentControl.showError(error.message);
this._view.commentControl.enableForm();
});
}
@ -203,24 +202,20 @@ class PostController {
// TODO: disable form
e.detail.comment.text = e.detail.text;
e.detail.comment.save()
.catch(errorMessage => {
e.detail.target.showError(errorMessage);
.catch(error => {
e.detail.target.showError(error.message);
// TODO: enable form
});
}
_evtScoreComment(e) {
e.detail.comment.setScore(e.detail.score)
.catch(errorMessage => {
window.alert(errorMessage);
});
.catch(error => window.alert(error.message));
}
_evtDeleteComment(e) {
e.detail.comment.delete()
.catch(errorMessage => {
window.alert(errorMessage);
});
.catch(error => window.alert(error.message));
}
_evtScorePost(e) {
@ -228,9 +223,7 @@ class PostController {
return;
}
e.detail.post.setScore(e.detail.score)
.catch(errorMessage => {
window.alert(errorMessage);
});
.catch(error => window.alert(error.message));
}
_evtFavoritePost(e) {
@ -238,9 +231,7 @@ class PostController {
return;
}
e.detail.post.addToFavorites()
.catch(errorMessage => {
window.alert(errorMessage);
});
.catch(error => window.alert(error.message));
}
_evtUnfavoritePost(e) {
@ -248,9 +239,7 @@ class PostController {
return;
}
e.detail.post.removeFromFavorites()
.catch(errorMessage => {
window.alert(errorMessage);
});
.catch(error => window.alert(error.message));
}
}
@ -262,7 +251,7 @@ module.exports = router => {
if (ctx.state.parameters) {
Object.assign(ctx.parameters, ctx.state.parameters);
}
ctx.controller = new PostController(ctx.parameters.id, true, ctx);
ctx.controller = new PostMainController(ctx, true);
});
router.enter(
'/post/:id/:parameters(.*)?',
@ -272,6 +261,6 @@ module.exports = router => {
if (ctx.state.parameters) {
Object.assign(ctx.parameters, ctx.state.parameters);
}
ctx.controller = new PostController(ctx.parameters.id, false, ctx);
ctx.controller = new PostMainController(ctx, false);
});
};

View File

@ -3,14 +3,19 @@
const api = require('../api.js');
const router = require('../router.js');
const misc = require('../util/misc.js');
const progress = require('../util/progress.js');
const topNavigation = require('../models/top_navigation.js');
const Post = require('../models/post.js');
const PostUploadView = require('../views/post_upload_view.js');
const EmptyView = require('../views/empty_view.js');
const genericErrorMessage =
'One of the posts needs your attention; ' +
'click "resume upload" when you\'re ready.';
class PostUploadController {
constructor() {
this._lastPromise = null;
this._lastCancellablePromise = null;
if (!api.hasPrivilege('posts:create')) {
this._view = new EmptyView();
@ -22,6 +27,7 @@ class PostUploadController {
topNavigation.setTitle('Upload');
this._view = new PostUploadView({
canUploadAnonymously: api.hasPrivilege('posts:create:anonymous'),
canViewPosts: api.hasPrivilege('posts:view'),
});
this._view.addEventListener('change', e => this._evtChange(e));
this._view.addEventListener('submit', e => this._evtSubmit(e));
@ -33,13 +39,13 @@ class PostUploadController {
misc.enableExitConfirmation();
} else {
misc.disableExitConfirmation();
}
this._view.clearMessages();
}
}
_evtCancel(e) {
if (this._lastPromise) {
this._lastPromise.abort();
if (this._lastCancellablePromise) {
this._lastCancellablePromise.abort();
}
}
@ -47,46 +53,95 @@ class PostUploadController {
this._view.disableForm();
this._view.clearMessages();
e.detail.uploadables.reduce((promise, uploadable) => {
return promise.then(() => {
let post = new Post();
post.safety = uploadable.safety;
if (uploadable.url) {
post.newContentUrl = uploadable.url;
} else {
post.newContent = uploadable.file;
}
let modelPromise = post.save(uploadable.anonymous);
this._lastPromise = modelPromise;
return modelPromise
.then(() => {
this._view.removeUploadable(uploadable);
return Promise.resolve();
}).catch(errorMessage => {
// XXX:
// lame, API eats error codes so we need to match
// messages instead
if (e.detail.skipDuplicates &&
errorMessage.match(/already uploaded/)) {
return Promise.resolve();
}
return Promise.reject(errorMessage);
});
});
}, Promise.resolve())
e.detail.uploadables.reduce(
(promise, uploadable) =>
promise.then(() => this._uploadSinglePost(
uploadable, e.detail.skipDuplicates)),
Promise.resolve())
.then(() => {
this._view.clearMessages();
misc.disableExitConfirmation();
const ctx = router.show('/posts');
ctx.controller.showSuccess('Posts uploaded.');
}, errorMessage => {
this._view.showError(errorMessage);
}, error => {
if (error.uploadable) {
if (error.similarPosts) {
error.uploadable.lookalikes = error.similarPosts;
this._view.updateUploadable(error.uploadable);
this._view.showInfo(genericErrorMessage);
this._view.showInfo(
error.message, error.uploadable);
} else {
this._view.showError(genericErrorMessage);
this._view.showError(
error.message, error.uploadable);
}
} else {
this._view.showError(error.message);
}
this._view.enableForm();
return Promise.reject();
});
}
_uploadSinglePost(uploadable, skipDuplicates) {
progress.start();
let reverseSearchPromise = Promise.resolve();
if (!uploadable.lookalikesConfirmed) {
reverseSearchPromise =
Post.reverseSearch(uploadable.url || uploadable.file);
}
this._lastCancellablePromise = reverseSearchPromise;
return reverseSearchPromise.then(searchResult => {
if (searchResult) {
// notify about exact duplicate
if (searchResult.exactPost && !skipDuplicates) {
let error = new Error('Post already uploaded ' +
`(@${searchResult.exactPost.id})`);
error.uploadable = uploadable;
return Promise.reject(error);
}
// notify about similar posts
if (!searchResult.exactPost &&
searchResult.similarPosts.length) {
let error = new Error(
`Found ${searchResult.similarPosts.length} similar ` +
'posts.\nYou can resume or discard this upload.');
error.uploadable = uploadable;
error.similarPosts = searchResult.similarPosts;
return Promise.reject(error);
}
}
// no duplicates, proceed with saving
let post = this._uploadableToPost(uploadable);
let savePromise = post.save(uploadable.anonymous)
.then(() => {
this._view.removeUploadable(uploadable);
return Promise.resolve();
});
this._lastCancellablePromise = savePromise;
return savePromise;
}).then(result => {
progress.done();
return Promise.resolve(result);
}, error => {
error.uploadable = uploadable;
progress.done();
return Promise.reject(error);
});
}
_uploadableToPost(uploadable) {
let post = new Post();
post.safety = uploadable.safety;
post.flags = uploadable.flags;
post.tags = uploadable.tags;
post.relations = uploadable.relations;
post.newContent = uploadable.url || uploadable.file;
return post;
}
}
module.exports = router => {

View File

@ -29,9 +29,9 @@ class TagCategoriesController {
canSetDefault: api.hasPrivilege('tagCategories:setDefault'),
});
this._view.addEventListener('submit', e => this._evtSubmit(e));
}, errorMessage => {
}, error => {
this._view = new EmptyView();
this._view.showError(errorMessage);
this._view.showError(error.message);
});
}
@ -43,9 +43,9 @@ class TagCategoriesController {
tags.refreshExport();
this._view.enableForm();
this._view.showSuccess('Changes saved.');
}, errorMessage => {
}, error => {
this._view.enableForm();
this._view.showError(errorMessage);
this._view.showError(error.message);
});
}
}

View File

@ -47,9 +47,9 @@ class TagController {
this._view.addEventListener('submit', e => this._evtUpdate(e));
this._view.addEventListener('merge', e => this._evtMerge(e));
this._view.addEventListener('delete', e => this._evtDelete(e));
}, errorMessage => {
}, error => {
this._view = new EmptyView();
this._view.showError(errorMessage);
this._view.showError(error.message);
});
}
@ -86,8 +86,8 @@ class TagController {
e.detail.tag.save().then(() => {
this._view.showSuccess('Tag saved.');
this._view.enableForm();
}, errorMessage => {
this._view.showError(errorMessage);
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}
@ -100,8 +100,8 @@ class TagController {
this._view.enableForm();
router.replace(
'/tag/' + e.detail.targetTagName + '/merge', null, false);
}, errorMessage => {
this._view.showError(errorMessage);
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}
@ -113,8 +113,8 @@ class TagController {
.then(() => {
const ctx = router.show('/tags/');
ctx.controller.showSuccess('Tag deleted.');
}, errorMessage => {
this._view.showError(errorMessage);
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}

View File

@ -63,9 +63,9 @@ class UserController {
this._view.addEventListener('change', e => this._evtChange(e));
this._view.addEventListener('submit', e => this._evtUpdate(e));
this._view.addEventListener('delete', e => this._evtDelete(e));
}, errorMessage => {
}, error => {
this._view = new EmptyView();
this._view.showError(errorMessage);
this._view.showError(error.message);
});
}
@ -115,13 +115,11 @@ class UserController {
e.detail.password || api.userPassword,
false) :
Promise.resolve();
}, errorMessage => {
return Promise.reject(errorMessage);
}).then(() => {
this._view.showSuccess('Settings updated.');
this._view.enableForm();
}, errorMessage => {
this._view.showError(errorMessage);
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}
@ -143,8 +141,8 @@ class UserController {
const ctx = router.show('/');
ctx.controller.showSuccess('Account deleted.');
}
}, errorMessage => {
this._view.showError(errorMessage);
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}

View File

@ -31,13 +31,11 @@ class UserRegistrationController {
user.save().then(() => {
api.forget();
return api.login(e.detail.name, e.detail.password, false);
}, errorMessage => {
return Promise.reject(errorMessage);
}).then(() => {
const ctx = router.show('/');
ctx.controller.showSuccess('Welcome aboard!');
}, errorMessage => {
this._view.showError(errorMessage);
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}

View File

@ -1,55 +1,87 @@
'use strict';
const api = require('../api.js');
const misc = require('../util/misc.js');
const events = require('../events.js');
const views = require('../util/views.js');
const CommentFormControl = require('../controls/comment_form_control.js');
const template = views.getTemplate('comment');
const scoreTemplate = views.getTemplate('score');
class CommentControl extends events.EventTarget {
constructor(hostNode, comment) {
constructor(hostNode, comment, onlyEditing) {
super();
this._hostNode = hostNode;
this._comment = comment;
this._onlyEditing = onlyEditing;
comment.addEventListener('change', e => this._evtChange(e));
comment.addEventListener('changeScore', e => this._evtChangeScore(e));
if (comment) {
comment.addEventListener(
'change', e => this._evtChange(e));
comment.addEventListener(
'changeScore', e => this._evtChangeScore(e));
}
const isLoggedIn = api.isLoggedIn(this._comment.user);
const isLoggedIn = comment && api.isLoggedIn(comment.user);
const infix = isLoggedIn ? 'own' : 'any';
views.replaceContent(this._hostNode, template({
comment: this._comment,
comment: comment,
user: comment ? comment.user : api.user,
canViewUsers: api.hasPrivilege('users:view'),
canEditComment: api.hasPrivilege(`comments:edit:${infix}`),
canDeleteComment: api.hasPrivilege(`comments:delete:${infix}`),
onlyEditing: onlyEditing,
}));
if (this._editButtonNode) {
this._editButtonNode.addEventListener(
'click', e => this._evtEditClick(e));
if (this._editButtonNodes) {
for (let node of this._editButtonNodes) {
node.addEventListener('click', e => this._evtEditClick(e));
}
}
if (this._deleteButtonNode) {
this._deleteButtonNode.addEventListener(
'click', e => this._evtDeleteClick(e));
}
this._formControl = new CommentFormControl(
this._hostNode.querySelector('.comment-form-container'),
this._comment,
true);
events.proxyEvent(this._formControl, this, 'submit');
if (this._previewEditingButtonNode) {
this._previewEditingButtonNode.addEventListener(
'click', e => this._evtPreviewEditingClick(e));
}
if (this._saveChangesButtonNode) {
this._saveChangesButtonNode.addEventListener(
'click', e => this._evtSaveChangesClick(e));
}
if (this._cancelEditingButtonNode) {
this._cancelEditingButtonNode.addEventListener(
'click', e => this._evtCancelEditingClick(e));
}
this._installScore();
if (onlyEditing) {
this._selectNav('edit');
this._selectTab('edit');
} else {
this._selectNav('readonly');
this._selectTab('preview');
}
}
get _formNode() {
return this._hostNode.querySelector('form');
}
get _scoreContainerNode() {
return this._hostNode.querySelector('.score-container');
}
get _editButtonNode() {
return this._hostNode.querySelector('.edit');
get _editButtonNodes() {
return this._hostNode.querySelectorAll('li.edit>a, a.edit');
}
get _previewEditingButtonNode() {
return this._hostNode.querySelector('li.preview>a');
}
get _deleteButtonNode() {
@ -64,12 +96,32 @@ class CommentControl extends events.EventTarget {
return this._hostNode.querySelector('.downvote');
}
get _saveChangesButtonNode() {
return this._hostNode.querySelector('.save-changes');
}
get _cancelEditingButtonNode() {
return this._hostNode.querySelector('.cancel-editing');
}
get _textareaNode() {
return this._hostNode.querySelector('.tab.edit textarea');
}
get _contentNode() {
return this._hostNode.querySelector('.tab.preview .comment-content');
}
get _heightKeeperNode() {
return this._hostNode.querySelector('.keep-height');
}
_installScore() {
views.replaceContent(
this._scoreContainerNode,
scoreTemplate({
score: this._comment.score,
ownScore: this._comment.ownScore,
score: this._comment ? this._comment.score : 0,
ownScore: this._comment ? this._comment.ownScore : 0,
canScore: api.hasPrivilege('comments:score'),
}));
@ -83,9 +135,40 @@ class CommentControl extends events.EventTarget {
}
}
enterEditMode() {
this._selectNav('edit');
this._selectTab('edit');
}
exitEditMode() {
if (this._onlyEditing) {
this._selectNav('edit');
this._selectTab('edit');
this._setText('');
} else {
this._selectNav('readonly');
this._selectTab('preview');
this._setText(this._comment.text);
}
this._forgetHeight();
views.clearMessages(this._hostNode);
}
enableForm() {
views.enableForm(this._formNode);
}
disableForm() {
views.disableForm(this._formNode);
}
showError(message) {
views.showError(this._hostNode, message);
}
_evtEditClick(e) {
e.preventDefault();
this._formControl.enterEditMode();
this.enterEditMode();
}
_evtScoreClick(e, score) {
@ -114,12 +197,69 @@ class CommentControl extends events.EventTarget {
}
_evtChange(e) {
this._formControl.exitEditMode();
this.exitEditMode();
}
_evtChangeScore(e) {
this._installScore();
}
_evtPreviewEditingClick(e) {
e.preventDefault();
this._contentNode.innerHTML =
misc.formatMarkdown(this._textareaNode.value);
this._selectTab('edit');
this._selectTab('preview');
}
_evtEditClick(e) {
e.preventDefault();
this.enterEditMode();
}
_evtSaveChangesClick(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', {
detail: {
target: this,
comment: this._comment,
text: this._textareaNode.value,
},
}));
}
_evtCancelEditingClick(e) {
e.preventDefault();
this.exitEditMode();
}
_setText(text) {
this._textareaNode.value = text;
this._contentNode.innerHTML = misc.formatMarkdown(text);
}
_selectNav(modeName) {
for (let node of this._hostNode.querySelectorAll('nav')) {
node.classList.toggle('active', node.classList.contains(modeName));
}
}
_selectTab(tabName) {
this._ensureHeight();
for (let node of this._hostNode.querySelectorAll('.tab, .tabs li')) {
node.classList.toggle('active', node.classList.contains(tabName));
}
}
_ensureHeight() {
this._heightKeeperNode.style.minHeight =
this._heightKeeperNode.getBoundingClientRect().height + 'px';
}
_forgetHeight() {
this._heightKeeperNode.style.minHeight = null;
}
};
module.exports = CommentControl;

View File

@ -1,141 +0,0 @@
'use strict';
const events = require('../events.js');
const misc = require('../util/misc.js');
const views = require('../util/views.js');
const template = views.getTemplate('comment-form');
class CommentFormControl extends events.EventTarget {
constructor(hostNode, comment, canCancel, minHeight) {
super();
this._hostNode = hostNode;
this._comment = comment || {text: ''};
this._canCancel = canCancel;
this._minHeight = minHeight || 150;
const sourceNode = template({
comment: this._comment,
});
const previewTabButton = sourceNode.querySelector('.buttons .preview');
const editTabButton = sourceNode.querySelector('.buttons .edit');
const formNode = sourceNode.querySelector('form');
const cancelButton = sourceNode.querySelector('.cancel');
const textareaNode = sourceNode.querySelector('form textarea');
previewTabButton.addEventListener(
'click', e => this._evtPreviewClick(e));
editTabButton.addEventListener(
'click', e => this._evtEditClick(e));
formNode.addEventListener('submit', e => this._evtSaveClick(e));
if (this._canCancel) {
cancelButton
.addEventListener('click', e => this._evtCancelClick(e));
} else {
cancelButton.style.display = 'none';
}
for (let event of ['cut', 'paste', 'drop', 'keydown']) {
textareaNode.addEventListener(event, e => {
window.setTimeout(() => this._growTextArea(), 0);
});
}
textareaNode.addEventListener('change', e => {
this.dispatchEvent(new CustomEvent('change', {
detail: {
target: this,
},
}));
this._growTextArea();
});
views.replaceContent(this._hostNode, sourceNode);
}
enterEditMode() {
this._freezeTabHeights();
this._hostNode.classList.add('editing');
this._selectTab('edit');
this._growTextArea();
}
exitEditMode() {
this._hostNode.classList.remove('editing');
this._hostNode.querySelector('.tab-wrapper').style.minHeight = null;
views.clearMessages(this._hostNode);
this.setText(this._comment.text);
}
get _textareaNode() {
return this._hostNode.querySelector('.edit.tab textarea');
}
get _contentNode() {
return this._hostNode.querySelector('.preview.tab .comment-content');
}
setText(text) {
this._textareaNode.value = text;
this._contentNode.innerHTML = misc.formatMarkdown(text);
}
showError(message) {
views.showError(this._hostNode, message);
}
_evtPreviewClick(e) {
e.preventDefault();
this._contentNode.innerHTML =
misc.formatMarkdown(this._textareaNode.value);
this._freezeTabHeights();
this._selectTab('preview');
}
_evtEditClick(e) {
e.preventDefault();
this.enterEditMode();
}
_evtSaveClick(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', {
detail: {
target: this,
comment: this._comment,
text: this._textareaNode.value,
},
}));
}
_evtCancelClick(e) {
e.preventDefault();
this.exitEditMode();
}
_selectTab(tabName) {
this._freezeTabHeights();
const tabWrapperNode = this._hostNode.querySelector('.tab-wrapper');
tabWrapperNode.setAttribute('data-tab', tabName);
for (let tab of this._hostNode.querySelectorAll('.tab, .buttons li')) {
tab.classList.toggle('active', tab.classList.contains(tabName));
}
}
_freezeTabHeights() {
const tabsNode = this._hostNode.querySelector('.tab-wrapper');
const tabsHeight = tabsNode.getBoundingClientRect().height;
tabsNode.style.minHeight = tabsHeight + 'px';
}
_growTextArea() {
this._textareaNode.style.height =
Math.max(
this._minHeight || 0,
this._textareaNode.scrollHeight) + 'px';
}
};
module.exports = CommentFormControl;

View File

@ -34,7 +34,7 @@ class CommentListControl extends events.EventTarget {
_installCommentNode(comment) {
const commentListItemNode = document.createElement('li');
const commentControl = new CommentControl(
commentListItemNode, comment);
commentListItemNode, comment, false);
events.proxyEvent(commentControl, this, 'submit');
events.proxyEvent(commentControl, this, 'score');
events.proxyEvent(commentControl, this, 'delete');

View File

@ -86,8 +86,12 @@ class PostContentControl {
}
_resize(width, height) {
this._postContentNode.style.width = width + 'px';
this._postContentNode.style.height = height + 'px';
const resizeListenerNodes = [this._postContentNode].concat(
...this._postContentNode.querySelectorAll('.resize-listener'));
for (let node of resizeListenerNodes) {
node.style.width = width + 'px';
node.style.height = height + 'px';
}
}
_refreshSize() {
@ -102,7 +106,10 @@ class PostContentControl {
}
_reinstall() {
const newNode = this._template({post: this._post});
const newNode = this._template({
post: this._post,
autoplay: settings.get().autoplayVideos,
});
if (settings.get().transparencyGrid) {
newNode.classList.add('transparency-grid');
}

View File

@ -36,6 +36,7 @@ class PostEditSidebarControl extends events.EventTarget {
canCreateAnonymousPosts: api.hasPrivilege('posts:create:anonymous'),
canDeletePosts: api.hasPrivilege('posts:delete'),
canFeaturePosts: api.hasPrivilege('posts:feature'),
canMergePosts: api.hasPrivilege('posts:merge'),
}));
new ExpanderControl(
@ -108,6 +109,11 @@ class PostEditSidebarControl extends events.EventTarget {
'click', e => this._evtFeatureClick(e));
}
if (this._mergeLinkNode) {
this._mergeLinkNode.addEventListener(
'click', e => this._evtMergeClick(e));
}
if (this._deleteLinkNode) {
this._deleteLinkNode.addEventListener(
'click', e => this._evtDeleteClick(e));
@ -186,6 +192,15 @@ class PostEditSidebarControl extends events.EventTarget {
}
}
_evtMergeClick(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('merge', {
detail: {
post: this._post,
},
}));
}
_evtDeleteClick(e) {
e.preventDefault();
if (confirm('Are you sure you want to delete this post?')) {
@ -244,7 +259,7 @@ class PostEditSidebarControl extends events.EventTarget {
detail: {
post: this._post,
safety: this._safetyButtonNodes.legnth ?
safety: this._safetyButtonNodes.length ?
Array.from(this._safetyButtonNodes)
.filter(node => node.checked)[0]
.value.toLowerCase() :
@ -314,6 +329,10 @@ class PostEditSidebarControl extends events.EventTarget {
return this._formNode.querySelector('.management .feature');
}
get _mergeLinkNode() {
return this._formNode.querySelector('.management .merge');
}
get _deleteLinkNode() {
return this._formNode.querySelector('.management .delete');
}

View File

@ -440,8 +440,8 @@ class DrawingRectangleState extends ActiveState {
const y2 = this._note.polygon.at(2).y;
const width = (x2 - x1) * this._control.boundingBox.width;
const height = (y2 - y1) * this._control.boundingBox.height;
if (width < 20 && height < 20) {
this._control._deleteDomNode(this._note);
if (width < 20 && height < 20) {
this._control._state = new ReadyToDrawState(this._control);
} else {
this._control._post.notes.add(this._note);
@ -533,6 +533,7 @@ class DrawingPolygonState extends ActiveState {
if (this._note.polygon.length <= 2) {
this._cancel();
} else {
this._control._deleteDomNode(this._note);
this._control._post.notes.add(this._note);
this._control._state = new SelectedState(this._control, this._note);
}
@ -546,6 +547,7 @@ class PostNotesOverlayControl extends events.EventTarget {
this._hostNode = hostNode;
this._svgNode = document.createElementNS(svgNS, 'svg');
this._svgNode.classList.add('resize-listener');
this._svgNode.classList.add('notes-overlay');
this._svgNode.setAttribute('preserveAspectRatio', 'none');
this._svgNode.setAttribute('viewBox', '0 0 1 1');
@ -557,6 +559,9 @@ class PostNotesOverlayControl extends events.EventTarget {
this._post.notes.addEventListener('remove', e => {
this._deleteDomNode(e.detail.note);
});
this._post.notes.addEventListener('add', e => {
this._createPolygonNode(e.detail.note);
});
const keyHandler = e => this._evtCanvasKeyDown(e);
document.addEventListener('keydown', keyHandler);

View File

@ -34,7 +34,8 @@ controllers.push(require('./controllers/auth_controller.js'));
controllers.push(require('./controllers/password_reset_controller.js'));
controllers.push(require('./controllers/comments_controller.js'));
controllers.push(require('./controllers/snapshots_controller.js'));
controllers.push(require('./controllers/post_controller.js'));
controllers.push(require('./controllers/post_detail_controller.js'));
controllers.push(require('./controllers/post_main_controller.js'));
controllers.push(require('./controllers/post_list_controller.js'));
controllers.push(require('./controllers/post_upload_controller.js'));
controllers.push(require('./controllers/tag_controller.js'));
@ -57,7 +58,7 @@ const api = require('./api.js');
tags.refreshExport(); // we don't care about errors
api.loginFromCookies().then(() => {
router.start();
}, errorMessage => {
}, error => {
if (window.location.href.indexOf('login') !== -1) {
api.forget();
router.start();
@ -65,6 +66,6 @@ api.loginFromCookies().then(() => {
const ctx = router.start('/');
ctx.controller.showError(
'An error happened while trying to log you in: ' +
errorMessage);
error.message);
}
});

View File

@ -23,7 +23,7 @@ class Comment extends events.EventTarget {
get id() { return this._id; }
get postId() { return this._postId; }
get text() { return this._text; }
get text() { return this._text || ''; }
get user() { return this._user; }
get creationTime() { return this._creationTime; }
get lastEditTime() { return this._lastEditTime; }
@ -50,8 +50,6 @@ class Comment extends events.EventTarget {
},
}));
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}
@ -66,8 +64,6 @@ class Comment extends events.EventTarget {
},
}));
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}
@ -81,8 +77,6 @@ class Comment extends events.EventTarget {
},
}));
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}

View File

@ -15,8 +15,6 @@ class Info {
Post.fromResponse(response.featuredPost) :
undefined
}));
}, response => {
return Promise.reject(response.description);
});
}
}

View File

@ -39,7 +39,6 @@ class Post extends events.EventTarget {
get canvasHeight() { return this._canvasHeight || 450; }
get fileSize() { return this._fileSize || 0; }
get newContent() { throw 'Invalid operation'; }
get newContentUrl() { throw 'Invalid operation'; }
get newThumbnail() { throw 'Invalid operation'; }
get flags() { return this._flags; }
@ -60,7 +59,6 @@ class Post extends events.EventTarget {
set safety(value) { this._safety = value; }
set relations(value) { this._relations = value; }
set newContent(value) { this._newContent = value; }
set newContentUrl(value) { this._newContentUrl = value; }
set newThumbnail(value) { this._newThumbnail = value; }
static fromResponse(response) {
@ -69,17 +67,34 @@ class Post extends events.EventTarget {
return ret;
}
static reverseSearch(content) {
let apiPromise = api.post(
'/posts/reverse-search', {}, {content: content});
let returnedPromise = apiPromise
.then(response => {
if (response.exactPost) {
response.exactPost = Post.fromResponse(response.exactPost);
}
for (let item of response.similarPosts) {
item.post = Post.fromResponse(item.post);
}
return Promise.resolve(response);
});
returnedPromise.abort = () => apiPromise.abort();
return returnedPromise;
}
static get(id) {
return api.get('/post/' + id)
.then(response => {
return Promise.resolve(Post.fromResponse(response));
}, response => {
return Promise.reject(response.description);
});
}
isTaggedWith(tagName) {
return this._tags.map(s => s.toLowerCase()).includes(tagName);
return this._tags
.map(s => s.toLowerCase())
.includes(tagName.toLowerCase());
}
addTag(tagName, addImplications) {
@ -100,7 +115,7 @@ class Post extends events.EventTarget {
}
save(anonymous) {
const files = [];
const files = {};
const detail = {version: this._version};
// send only changed fields to avoid user privilege violation
@ -128,8 +143,6 @@ class Post extends events.EventTarget {
}
if (this._newContent) {
files.content = this._newContent;
} else if (this._newContentUrl) {
detail.contentUrl = this._newContentUrl;
}
if (this._newThumbnail !== undefined) {
files.thumbnail = this._newThumbnail;
@ -139,11 +152,11 @@ class Post extends events.EventTarget {
api.put('/post/' + this._id, detail, files) :
api.post('/posts', detail, files);
let returnedPromise = apiPromise.then(response => {
return apiPromise.then(response => {
this._updateFromResponse(response);
this.dispatchEvent(
new CustomEvent('change', {detail: {post: this}}));
if (this._newContent || this._newContentUrl) {
if (this._newContent) {
this.dispatchEvent(
new CustomEvent('changeContent', {detail: {post: this}}));
}
@ -152,27 +165,20 @@ class Post extends events.EventTarget {
new CustomEvent('changeThumbnail', {detail: {post: this}}));
}
return Promise.resolve();
}, response => {
if (response.name === 'PostAlreadyUploadedError') {
return Promise.reject(
`Post already uploaded (@${response.otherPostId})`);
}, error => {
if (error.response &&
error.response.name === 'PostAlreadyUploadedError') {
error.message =
`Post already uploaded (@${error.response.otherPostId})`;
}
return Promise.reject(response.description);
return Promise.reject(error);
});
returnedPromise.abort = () => {
apiPromise.abort();
};
return returnedPromise;
}
feature() {
return api.post('/featured-post', {id: this._id})
.then(response => {
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}
@ -185,8 +191,27 @@ class Post extends events.EventTarget {
},
}));
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}
merge(targetId, useOldContent) {
return api.get('/post/' + encodeURIComponent(targetId))
.then(response => {
return api.post('/post-merge/', {
removeVersion: this._version,
remove: this._id,
mergeToVersion: response.version,
mergeTo: targetId,
replaceContent: useOldContent,
});
}).then(response => {
this._updateFromResponse(response);
this.dispatchEvent(new CustomEvent('change', {
detail: {
post: this,
},
}));
return Promise.resolve();
});
}
@ -208,8 +233,6 @@ class Post extends events.EventTarget {
},
}));
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}
@ -231,8 +254,6 @@ class Post extends events.EventTarget {
},
}));
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}
@ -254,8 +275,6 @@ class Post extends events.EventTarget {
},
}));
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}

View File

@ -9,12 +9,7 @@ class PostList extends AbstractList {
const url =
`/post/${id}/around?fields=id` +
`&query=${encodeURIComponent(searchQuery)}`;
return api.get(url)
.then(response => {
return Promise.resolve(response);
}).catch(response => {
return Promise.reject(response.description);
});
return api.get(url);
}
static search(text, page, pageSize, fields) {

View File

@ -14,6 +14,7 @@ const defaultSettings = {
transparencyGrid: true,
fitMode: 'fit-both',
tagSuggestions: true,
autoplayVideos: false,
postsPerPage: 42,
};

View File

@ -36,8 +36,6 @@ class Tag extends events.EventTarget {
return api.get('/tag/' + encodeURIComponent(name))
.then(response => {
return Promise.resolve(Tag.fromResponse(response));
}, response => {
return Promise.reject(response.description);
});
}
@ -73,8 +71,6 @@ class Tag extends events.EventTarget {
},
}));
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}
@ -87,8 +83,6 @@ class Tag extends events.EventTarget {
mergeToVersion: response.version,
mergeTo: targetName,
});
}, response => {
return Promise.reject(response);
}).then(response => {
this._updateFromResponse(response);
this.dispatchEvent(new CustomEvent('change', {
@ -97,8 +91,6 @@ class Tag extends events.EventTarget {
},
}));
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}
@ -113,8 +105,6 @@ class Tag extends events.EventTarget {
},
}));
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}

View File

@ -58,8 +58,6 @@ class TagCategory extends events.EventTarget {
},
}));
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}
@ -74,8 +72,6 @@ class TagCategory extends events.EventTarget {
},
}));
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}

View File

@ -61,9 +61,6 @@ class TagCategoryList extends AbstractList {
.then(response => {
this._deletedCategories = [];
return Promise.resolve();
}, errorMessage => {
return Promise.reject(
errorMessage.description || errorMessage);
});
}

View File

@ -43,8 +43,6 @@ class User extends events.EventTarget {
return api.get('/user/' + encodeURIComponent(name))
.then(response => {
return Promise.resolve(User.fromResponse(response));
}, response => {
return Promise.reject(response.description);
});
}
@ -89,8 +87,6 @@ class User extends events.EventTarget {
},
}));
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}
@ -105,8 +101,6 @@ class User extends events.EventTarget {
},
}));
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}

View File

@ -1,6 +1,7 @@
'use strict';
const marked = require('marked');
const config = require('../config.js');
class BaseMarkdownWrapper {
preprocess(text) {
@ -62,6 +63,17 @@ class TagPermalinkFixWrapper extends BaseMarkdownWrapper {
//post, user and tags permalinks
class EntityPermalinkWrapper extends BaseMarkdownWrapper {
preprocess(text) {
// URL-based permalinks
let baseUrl = config.baseUrl.replace(/\/+$/, '');
text = text.replace(
new RegExp('\\b' + baseUrl + '/post/(\\d+)/?\\b', 'g'), '@$1');
text = text.replace(
new RegExp('\\b' + baseUrl + '/tag/([a-zA-Z0-9_-]+?)/?', 'g'),
'#$1');
text = text.replace(
new RegExp('\\b' + baseUrl + '/user/([a-zA-Z0-9_-]+?)/?', 'g'),
'+$1');
text = text.replace(
/(^|^\(|(?:[^\]])\(|[\s<>\[\]\)])([+#@][a-zA-Z0-9_-]+)/g,
'$1[$2]($2)');
@ -103,8 +115,37 @@ class StrikeThroughWrapper extends BaseMarkdownWrapper {
}
}
function formatMarkdown(text) {
function createRenderer() {
function sanitize(str) {
return str.replace(/&<"/g, m => {
if (m === '&') {
return '&amp;';
}
if (m === '<') {
return '&lt;';
}
return '&quot;';
});
}
const renderer = new marked.Renderer();
renderer.image = (href, title, alt) => {
let [_, url, width, height] =
/^(.+?)(?:\s=\s*(\d*)\s*x\s*(\d*)\s*)?$/.exec(href);
let res = '<img src="' + sanitize(url) + '" alt="' + sanitize(alt);
if (width) {
res += '" width="' + width;
}
if (height) {
res += '" height="' + height;
}
return res + '">';
};
return renderer;
}
function formatMarkdown(text) {
const renderer = createRenderer();
const options = {
renderer: renderer,
breaks: true,
@ -133,7 +174,7 @@ function formatMarkdown(text) {
}
function formatInlineMarkdown(text) {
const renderer = new marked.Renderer();
const renderer = createRenderer();
const options = {
renderer: renderer,
breaks: true,

View File

@ -217,6 +217,19 @@ function escapeSearchTerm(text) {
return text.replace(/([a-z_-]):/g, '$1\\:');
}
function dataURItoBlob(dataURI) {
const chunks = dataURI.split(',');
const byteString = chunks[0].indexOf('base64') >= 0 ?
window.atob(chunks[1]) :
unescape(chunks[1]);
const mimeString = chunks[0].split(':')[1].split(';')[0];
const data = new Uint8Array(byteString.length);
for (var i = 0; i < byteString.length; i++) {
data[i] = byteString.charCodeAt(i);
}
return new Blob([data], {type: mimeString});
}
module.exports = {
range: range,
formatUrlParameters: formatUrlParameters,
@ -236,4 +249,5 @@ module.exports = {
arraysDiffer: arraysDiffer,
decamelize: decamelize,
escapeSearchTerm: escapeSearchTerm,
dataURItoBlob: dataURItoBlob,
};

View File

@ -56,3 +56,6 @@ Number.prototype.between = function(a, b, inclusive) {
this >= min && this <= max :
this > min && this < max;
};
// non standard
Promise.prototype.abort = () => {};

View File

@ -0,0 +1,24 @@
const nprogress = require('nprogress');
let nesting = 0;
function start() {
if (!nesting) {
nprogress.start();
}
nesting++;
}
function done() {
nesting--;
if (nesting > 0) {
nprogress.inc();
} else {
nprogress.done();
}
}
module.exports = {
start: start,
done: done,
};

View File

@ -21,7 +21,7 @@ function _makeLabel(options, attrs) {
attrs = {};
}
attrs.for = options.id;
return makeNonVoidElement('label', attrs, options.text);
return makeElement('label', attrs, options.text);
}
function makeFileSize(fileSize) {
@ -33,27 +33,25 @@ function makeMarkdown(text) {
}
function makeRelativeTime(time) {
return makeNonVoidElement(
'time',
{datetime: time, title: time},
misc.formatRelativeTime(time));
return makeElement(
'time', {datetime: time, title: time}, misc.formatRelativeTime(time));
}
function makeThumbnail(url) {
return makeNonVoidElement(
return makeElement(
'span',
url ?
{
class: 'thumbnail',
style: `background-image: url(\'${url}\')`,
} :
{class: 'thumbnail', style: `background-image: url(\'${url}\')`} :
{class: 'thumbnail empty'},
makeVoidElement('img', {alt: 'thumbnail', src: url}));
makeElement('img', {alt: 'thumbnail', src: url}));
}
function makeRadio(options) {
_imbueId(options);
return makeVoidElement(
return makeElement(
'label',
{for: options.id},
makeElement(
'input',
{
id: options.id,
@ -63,13 +61,16 @@ function makeRadio(options) {
checked: options.selectedValue === options.value,
disabled: options.readonly,
required: options.required,
}) +
_makeLabel(options, {class: 'radio'});
}),
makeElement('span', {class: 'radio'}, options.text));
}
function makeCheckbox(options) {
_imbueId(options);
return makeVoidElement(
return makeElement(
'label',
{for: options.id},
makeElement(
'input',
{
id: options.id,
@ -80,30 +81,29 @@ function makeCheckbox(options) {
options.checked : false,
disabled: options.readonly,
required: options.required,
}) +
_makeLabel(options, {class: 'checkbox'});
}),
makeElement('span', {class: 'checkbox'}, options.text));
}
function makeSelect(options) {
return _makeLabel(options) +
makeNonVoidElement(
makeElement(
'select',
{
id: options.id,
name: options.name,
disabled: options.readonly,
},
Object.keys(options.keyValues).map(key => {
return makeNonVoidElement(
...Object.keys(options.keyValues).map(key =>
makeElement(
'option',
{value: key, selected: key === options.selectedKey},
options.keyValues[key]);
}).join(''));
options.keyValues[key])));
}
function makeInput(options) {
options.value = options.value || '';
return _makeLabel(options) + makeVoidElement('input', options);
return _makeLabel(options) + makeElement('input', options);
}
function makeButton(options) {
@ -119,7 +119,7 @@ function makeTextInput(options) {
function makeTextarea(options) {
const value = options.value || '';
delete options.value;
return _makeLabel(options) + makeNonVoidElement('textarea', options, value);
return _makeLabel(options) + makeElement('textarea', options, value);
}
function makePasswordInput(options) {
@ -133,7 +133,7 @@ function makeEmailInput(options) {
}
function makeColorInput(options) {
const textInput = makeVoidElement(
const textInput = makeElement(
'input', {
type: 'text',
value: options.value || '',
@ -141,13 +141,9 @@ function makeColorInput(options) {
style: 'color: ' + options.value,
disabled: true,
});
const colorInput = makeVoidElement(
'input', {
type: 'color',
value: options.value || '',
});
return makeNonVoidElement(
'label', {class: 'color'}, colorInput + textInput);
const colorInput = makeElement(
'input', {type: 'color', value: options.value || ''});
return makeElement('label', {class: 'color'}, colorInput, textInput);
}
function makeNumericInput(options) {
@ -177,7 +173,7 @@ function makePostLink(id, includeHash) {
text = '@' + id;
}
return api.hasPrivilege('posts:view') ?
makeNonVoidElement(
makeElement(
'a',
{'href': '/post/' + encodeURIComponent(id)},
misc.escapeHtml(text)) :
@ -192,14 +188,14 @@ function makeTagLink(name, includeHash) {
text = '#' + text;
}
return api.hasPrivilege('tags:view') ?
makeNonVoidElement(
makeElement(
'a',
{
'href': '/tag/' + encodeURIComponent(name),
'class': misc.makeCssName(category, 'tag'),
},
misc.escapeHtml(text)) :
makeNonVoidElement(
makeElement(
'span',
{'class': misc.makeCssName(category, 'tag')},
misc.escapeHtml(text));
@ -209,12 +205,10 @@ function makeUserLink(user) {
let text = makeThumbnail(user ? user.avatarUrl : null);
text += user && user.name ? misc.escapeHtml(user.name) : 'Anonymous';
const link = user && api.hasPrivilege('users:view') ?
makeNonVoidElement(
'a',
{'href': '/user/' + encodeURIComponent(user.name)},
text) :
makeElement(
'a', {'href': '/user/' + encodeURIComponent(user.name)}, text) :
text;
return makeNonVoidElement('span', {class: 'user'}, link);
return makeElement('span', {class: 'user'}, link);
}
function makeFlexboxAlign(options) {
@ -244,12 +238,10 @@ function _serializeElement(name, attributes) {
.join(' ');
}
function makeNonVoidElement(name, attributes, content) {
return `<${_serializeElement(name, attributes)}>${content}</${name}>`;
}
function makeVoidElement(name, attributes) {
return `<${_serializeElement(name, attributes)}/>`;
function makeElement(name, attrs, ...content) {
return content.length !== undefined ?
`<${_serializeElement(name, attrs)}>${content.join('')}</${name}>` :
`<${_serializeElement(name, attrs)}/>`;
}
function emptyContent(target) {
@ -275,25 +267,30 @@ function showMessage(target, message, className) {
if (!message) {
message = 'Unknown message';
}
const messagesHolder = target.querySelector('.messages');
if (!messagesHolder) {
const messagesHolderNode = target.querySelector('.messages');
if (!messagesHolderNode) {
return false;
}
/* TODO: animate this */
const node = document.createElement('div');
node.innerHTML = message.replace(/\n/g, '<br/>');
node.classList.add('message');
node.classList.add(className);
const wrapper = document.createElement('div');
wrapper.classList.add('message-wrapper');
wrapper.appendChild(node);
messagesHolder.appendChild(wrapper);
const textNode = document.createElement('div');
textNode.innerHTML = message.replace(/\n/g, '<br/>');
textNode.classList.add('message');
textNode.classList.add(className);
const wrapperNode = document.createElement('div');
wrapperNode.classList.add('message-wrapper');
wrapperNode.appendChild(textNode);
messagesHolderNode.appendChild(wrapperNode);
return true;
}
function showError(target, message) {
function appendExclamationMark() {
if (!document.title.startsWith('!')) {
document.oldTitle = document.title;
document.title = `! ${document.title}`;
}
}
function showError(target, message) {
appendExclamationMark();
return showMessage(target, misc.formatInlineMarkdown(message), 'error');
}
@ -310,9 +307,9 @@ function clearMessages(target) {
document.title = document.oldTitle;
document.oldTitle = null;
}
const messagesHolder = target.querySelector('.messages');
/* TODO: animate that */
emptyContent(messagesHolder);
for (let messagesHolderNode of target.querySelectorAll('.messages')) {
emptyContent(messagesHolderNode);
}
}
function htmlToDom(html) {
@ -385,6 +382,7 @@ function getTemplate(templatePath) {
makeUserLink: makeUserLink,
makeFlexboxAlign: makeFlexboxAlign,
makeAccessKey: makeAccessKey,
makeElement: makeElement,
makeCssName: misc.makeCssName,
makeNumericInput: makeNumericInput,
});
@ -505,8 +503,6 @@ module.exports = {
enableForm: enableForm,
disableForm: disableForm,
decorateValidator: decorateValidator,
makeVoidElement: makeVoidElement,
makeNonVoidElement: makeNonVoidElement,
makeTagLink: makeTagLink,
makePostLink: makePostLink,
makeCheckbox: makeCheckbox,
@ -516,6 +512,7 @@ module.exports = {
slideUp: slideUp,
monitorNodeRemoval: monitorNodeRemoval,
clearMessages: clearMessages,
appendExclamationMark: appendExclamationMark,
showError: showError,
showSuccess: showSuccess,
showInfo: showInfo,

View File

@ -114,8 +114,8 @@ class EndlessPageView {
this._working--;
resolve(pageNode);
});
}, response => {
this.showError(response.description);
}, error => {
this.showError(error.message);
this._working--;
reject();
});

View File

@ -0,0 +1,80 @@
'use strict';
const events = require('../events.js');
const views = require('../util/views.js');
const PostMergeView = require('./post_merge_view.js');
const EmptyView = require('../views/empty_view.js');
const template = views.getTemplate('post-detail');
class PostDetailView extends events.EventTarget {
constructor(ctx) {
super();
this._ctx = ctx;
ctx.post.addEventListener('change', e => this._evtChange(e));
ctx.section = ctx.section || 'summary';
this._hostNode = document.getElementById('content-holder');
this._install();
}
_install() {
const ctx = this._ctx;
views.replaceContent(this._hostNode, template(ctx));
for (let item of this._hostNode.querySelectorAll('[data-name]')) {
item.classList.toggle(
'active', item.getAttribute('data-name') === ctx.section);
}
ctx.hostNode = this._hostNode.querySelector('.post-content-holder');
if (ctx.section === 'merge') {
if (!this._ctx.canMerge) {
this._view = new EmptyView();
this._view.showError(
'You don\'t have privileges to merge posts.');
} else {
this._view = new PostMergeView(ctx);
events.proxyEvent(this._view, this, 'select');
events.proxyEvent(this._view, this, 'submit', 'merge');
}
} else {
// this._view = new PostSummaryView(ctx);
}
views.syncScrollPosition();
}
clearMessages() {
this._view.clearMessages();
}
enableForm() {
this._view.enableForm();
}
disableForm() {
this._view.disableForm();
}
showSuccess(message) {
this._view.showSuccess(message);
}
showError(message) {
this._view.showError(message);
}
selectPost(post) {
this._view.selectPost(post);
}
_evtChange(e) {
this._ctx.post = e.detail.post;
this._install(this._ctx);
}
}
module.exports = PostDetailView;

View File

@ -10,12 +10,12 @@ const PostReadonlySidebarControl =
require('../controls/post_readonly_sidebar_control.js');
const PostEditSidebarControl =
require('../controls/post_edit_sidebar_control.js');
const CommentControl = require('../controls/comment_control.js');
const CommentListControl = require('../controls/comment_list_control.js');
const CommentFormControl = require('../controls/comment_form_control.js');
const template = views.getTemplate('post');
const template = views.getTemplate('post-main');
class PostView {
class PostMainView {
constructor(ctx) {
this._hostNode = document.getElementById('content-holder');
@ -101,9 +101,8 @@ class PostView {
return;
}
this.commentFormControl = new CommentFormControl(
commentFormContainer, null, false, 150);
this.commentFormControl.enterEditMode();
this.commentControl = new CommentControl(
commentFormContainer, null, true);
}
_installComments(comments) {
@ -118,4 +117,4 @@ class PostView {
}
}
module.exports = PostView;
module.exports = PostMainView;

View File

@ -0,0 +1,137 @@
'use strict';
const config = require('../config.js');
const events = require('../events.js');
const views = require('../util/views.js');
const KEY_RETURN = 13;
const template = views.getTemplate('post-merge');
const sideTemplate = views.getTemplate('post-merge-side');
class PostMergeView extends events.EventTarget {
constructor(ctx) {
super();
this._ctx = ctx;
this._post = ctx.post;
this._hostNode = ctx.hostNode;
this._leftPost = ctx.post;
this._rightPost = null;
views.replaceContent(this._hostNode, template(this._ctx));
views.decorateValidator(this._formNode);
this._refreshLeftSide();
this._refreshRightSide();
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
}
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);
}
selectPost(post) {
this._rightPost = post;
this._refreshRightSide();
}
_refreshLeftSide() {
this._refreshSide(this._leftPost, this._leftSideNode, 'left', false);
}
_refreshRightSide() {
this._refreshSide(this._rightPost, this._rightSideNode, 'right', true);
}
_refreshSide(post, sideNode, sideName, isEditable) {
views.replaceContent(
sideNode,
sideTemplate(Object.assign({}, this._ctx, {
post: post,
name: sideName,
editable: isEditable})));
let postIdNode = sideNode.querySelector('input[type=text]');
let searchButtonNode = sideNode.querySelector('input[type=button]');
if (isEditable) {
postIdNode.addEventListener(
'keydown', e => this._evtPostSearchFieldKeyDown(e));
searchButtonNode.addEventListener(
'click', e => this._evtPostSearchButtonClick(e, postIdNode));
}
}
_evtSubmit(e) {
e.preventDefault();
const checkedTargetPost = this._formNode.querySelector(
'.target-post :checked').value;
const checkedTargetPostContent = this._formNode.querySelector(
'.target-post-content :checked').value;
this.dispatchEvent(new CustomEvent('submit', {
detail: {
post: checkedTargetPost == 'left' ?
this._rightPost :
this._leftPost,
targetPost: checkedTargetPost == 'left' ?
this._leftPost :
this._rightPost,
useOldContent: checkedTargetPostContent !== checkedTargetPost,
},
}));
}
_evtPostSearchFieldKeyDown(e) {
const key = e.which;
if (key !== KEY_RETURN) {
return;
}
e.target.blur();
e.preventDefault();
this.dispatchEvent(new CustomEvent('select', {
detail: {
postId: e.target.value,
},
}));
}
_evtPostSearchButtonClick(e, textNode) {
e.target.blur();
e.preventDefault();
this.dispatchEvent(new CustomEvent('select', {
detail: {
postId: textNode.value,
},
}));
}
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');
}
}
module.exports = PostMergeView;

View File

@ -7,8 +7,6 @@ const FileDropperControl = require('../controls/file_dropper_control.js');
const template = views.getTemplate('post-upload');
const rowTemplate = views.getTemplate('post-upload-row');
let globalOrder = 0;
function _mimeTypeToPostType(mimeType) {
return {
'application/x-shockwave-flash': 'flash',
@ -23,10 +21,13 @@ function _mimeTypeToPostType(mimeType) {
class Uploadable extends events.EventTarget {
constructor() {
super();
this.lookalikes = [];
this.lookalikesConfirmed = false;
this.safety = 'safe';
this.flags = [];
this.tags = [];
this.relations = [];
this.anonymous = false;
this.order = globalOrder;
globalOrder++;
}
destroy() {
@ -47,6 +48,12 @@ class Uploadable extends events.EventTarget {
get name() {
throw new Error('Not implemented');
}
_initComplete() {
if (['video'].includes(this.type)) {
this.flags.push('loop');
}
}
}
class File extends Uploadable {
@ -66,6 +73,7 @@ class File extends Uploadable {
new CustomEvent('finish', {detail: {uploadable: this}}));
});
}
this._initComplete();
}
destroy() {
@ -96,6 +104,7 @@ class Url extends Uploadable {
super();
this.url = url;
this.dispatchEvent(new CustomEvent('finish'));
this._initComplete();
}
get mimeType() {
@ -139,7 +148,11 @@ class PostUploadView extends events.EventTarget {
this._cancelButtonNode.disabled = true;
this._uploadables = new Map();
this._uploadables = [];
this._uploadables.find = u => {
return this._uploadables.findIndex(u2 => u.key === u2.key);
};
this._contentFileDropper = new FileDropperControl(
this._contentInputNode,
{
@ -178,23 +191,32 @@ class PostUploadView extends events.EventTarget {
views.showSuccess(this._hostNode, message);
}
showError(message) {
views.showError(this._hostNode, message);
showError(message, uploadable) {
this._showMessage(views.showError, message, uploadable);
}
showInfo(message, uploadable) {
this._showMessage(views.showInfo, message, uploadable);
views.appendExclamationMark();
}
_showMessage(functor, message, uploadable) {
functor(uploadable ? uploadable.rowNode : this._hostNode, message);
}
addUploadables(uploadables) {
this._formNode.classList.remove('inactive');
let duplicatesFound = 0;
for (let uploadable of uploadables) {
if (this._uploadables.has(uploadable.key)) {
if (this._uploadables.find(uploadable) !== -1) {
duplicatesFound++;
continue;
}
this._uploadables.set(uploadable.key, uploadable);
this._uploadables.push(uploadable);
this._emit('change');
this._createRowNode(uploadable);
this._renderRowNode(uploadable);
uploadable.addEventListener(
'finish', e => this._updateRowNode(e.detail.uploadable));
'finish', e => this._updateThumbnailNode(e.detail.uploadable));
}
if (duplicatesFound) {
let message = null;
@ -211,19 +233,24 @@ class PostUploadView extends events.EventTarget {
}
removeUploadable(uploadable) {
if (!this._uploadables.has(uploadable.key)) {
if (this._uploadables.find(uploadable) === -1) {
return;
}
uploadable.destroy();
uploadable.rowNode.parentNode.removeChild(uploadable.rowNode);
this._uploadables.delete(uploadable.key);
this._normalizeUploadablesOrder();
this._uploadables.splice(this._uploadables.find(uploadable), 1);
this._emit('change');
if (!this._uploadables.size) {
if (!this._uploadables.length) {
this._formNode.classList.add('inactive');
this._submitButtonNode.value = 'Upload all';
}
}
updateUploadable(uploadable) {
uploadable.lookalikesConfirmed = true;
this._renderRowNode(uploadable);
}
_evtFilesAdded(e) {
this.addUploadables(e.detail.files.map(file => new File(file)));
}
@ -239,9 +266,37 @@ class PostUploadView extends events.EventTarget {
_evtFormSubmit(e) {
e.preventDefault();
for (let uploadable of this._uploadables) {
this._updateUploadableFromDom(uploadable);
}
this._submitButtonNode.value = 'Resume upload';
this._emit('submit');
}
_updateUploadableFromDom(uploadable) {
const rowNode = uploadable.rowNode;
uploadable.safety =
rowNode.querySelector('.safety input:checked').value;
uploadable.anonymous =
rowNode.querySelector('.anonymous input').checked;
uploadable.flags = [];
if (rowNode.querySelector('.loop-video input:checked')) {
uploadable.flags.push('loop');
}
uploadable.tags = [];
uploadable.relations = [];
for (let [i, lookalike] of uploadable.lookalikes.entries()) {
let lookalikeNode = rowNode.querySelector(
`.lookalikes li:nth-child(${i + 1})`);
if (lookalikeNode.querySelector('[name=copy-tags]').checked) {
uploadable.tags = uploadable.tags.concat(lookalike.post.tags);
}
if (lookalikeNode.querySelector('[name=add-relation]').checked) {
uploadable.relations.push(lookalike.post.id);
}
}
}
_evtRemoveClick(e, uploadable) {
e.preventDefault();
if (this._uploading) {
@ -250,55 +305,25 @@ class PostUploadView extends events.EventTarget {
this.removeUploadable(uploadable);
}
_evtMoveUpClick(e, uploadable) {
_evtMoveClick(e, uploadable, delta) {
e.preventDefault();
if (this._uploading) {
return;
}
let sortedUploadables = this._getSortedUploadables();
if (uploadable.order > 0) {
uploadable.order--;
const prevUploadable = sortedUploadables[uploadable.order];
prevUploadable.order++;
uploadable.rowNode.parentNode.insertBefore(
uploadable.rowNode, prevUploadable.rowNode);
let index = this._uploadables.find(uploadable);
if ((index + delta).between(-1, this._uploadables.length)) {
let uploadable1 = this._uploadables[index];
let uploadable2 = this._uploadables[index + delta];
this._uploadables[index] = uploadable2;
this._uploadables[index + delta] = uploadable1;
if (delta === 1) {
this._listNode.insertBefore(
uploadable2.rowNode, uploadable1.rowNode);
} else {
this._listNode.insertBefore(
uploadable1.rowNode, uploadable2.rowNode);
}
}
_evtMoveDownClick(e, uploadable) {
e.preventDefault();
if (this._uploading) {
return;
}
let sortedUploadables = this._getSortedUploadables();
if (uploadable.order + 1 < sortedUploadables.length) {
uploadable.order++;
const nextUploadable = sortedUploadables[uploadable.order];
nextUploadable.order--;
uploadable.rowNode.parentNode.insertBefore(
nextUploadable.rowNode, uploadable.rowNode);
}
}
_evtSafetyRadioboxChange(e, uploadable) {
uploadable.safety = e.target.value;
}
_evtAnonymityCheckboxChange(e, uploadable) {
uploadable.anonymous = e.target.checked;
}
_normalizeUploadablesOrder() {
let sortedUploadables = this._getSortedUploadables();
for (let i = 0; i < sortedUploadables.length; i++) {
sortedUploadables[i].order = i;
}
}
_getSortedUploadables() {
let sortedUploadables = [...this._uploadables.values()];
sortedUploadables.sort((a, b) => a.order - b.order);
return sortedUploadables;
}
_emit(eventType) {
@ -306,37 +331,32 @@ class PostUploadView extends events.EventTarget {
new CustomEvent(
eventType,
{detail: {
uploadables: this._getSortedUploadables(),
uploadables: this._uploadables,
skipDuplicates: this._skipDuplicatesCheckboxNode.checked,
}}));
}
_createRowNode(uploadable) {
_renderRowNode(uploadable) {
const rowNode = rowTemplate(Object.assign(
{}, this._ctx, {uploadable: uploadable}));
if (uploadable.rowNode) {
uploadable.rowNode.parentNode.replaceChild(
rowNode, uploadable.rowNode);
} else {
this._listNode.appendChild(rowNode);
for (let radioboxNode of rowNode.querySelectorAll('.safety input')) {
radioboxNode.addEventListener(
'change', e => this._evtSafetyRadioboxChange(e, uploadable));
}
const anonymousCheckboxNode = rowNode.querySelector('.anonymous input');
if (anonymousCheckboxNode) {
anonymousCheckboxNode.addEventListener(
'change', e => this._evtAnonymityCheckboxChange(e, uploadable));
}
rowNode.querySelector('a.remove').addEventListener(
'click', e => this._evtRemoveClick(e, uploadable));
rowNode.querySelector('a.move-up').addEventListener(
'click', e => this._evtMoveUpClick(e, uploadable));
rowNode.querySelector('a.move-down').addEventListener(
'click', e => this._evtMoveDownClick(e, uploadable));
uploadable.rowNode = rowNode;
rowNode.querySelector('a.remove').addEventListener('click',
e => this._evtRemoveClick(e, uploadable));
rowNode.querySelector('a.move-up').addEventListener('click',
e => this._evtMoveClick(e, uploadable, -1));
rowNode.querySelector('a.move-down').addEventListener('click',
e => this._evtMoveClick(e, uploadable, 1));
}
_updateRowNode(uploadable) {
_updateThumbnailNode(uploadable) {
const rowNode = rowTemplate(Object.assign(
{}, this._ctx, {uploadable: uploadable}));
views.replaceContent(

View File

@ -35,6 +35,7 @@ class SettingsView extends events.EventTarget {
keyboardShortcuts: this._find('keyboard-shortcuts').checked,
transparencyGrid: this._find('transparency-grid').checked,
tagSuggestions: this._find('tag-suggestions').checked,
autoplayVideos: this._find('autoplay-videos').checked,
postsPerPage: this._find('posts-per-page').value,
},
}));

View File

@ -3,7 +3,7 @@
"private": true,
"scripts": {
"build": "node build.js",
"watch": "watch 'npm run build -- --no-vendor-js' html js css img --wait=1 --interval=0.5 --ignoreDotFiles"
"watch": "c1=\"\";while :;do c2=$(find html js css img -type f -and -not -iname '*autogen*'|sort|xargs cat|md5sum);[[ $c1 != $c2 ]]&&npm run build -- --no-vendor-js;c1=$c2;sleep 1;done"
},
"dependencies": {
"babel-polyfill": "^6.7.4",
@ -27,8 +27,5 @@
"superagent": "^1.8.3",
"uglify-js": "git://github.com/mishoo/UglifyJS2.git#harmony",
"underscore": "^1.8.3"
},
"devDependencies": {
"watch": "latest"
}
}

View File

@ -32,6 +32,11 @@ smtp:
user: # example: bot
pass: # example: groovy123
# used for reverse image search
elasticsearch:
host: localhost
port: 9200
limits:
users_per_page: 20
posts_per_page: 40
@ -68,6 +73,7 @@ privileges:
'posts:create:anonymous': regular
'posts:create:identified': regular
'posts:list': anonymous
'posts:reverse_search': regular
'posts:view': anonymous
'posts:edit:content': power
'posts:edit:flags': regular
@ -80,6 +86,7 @@ privileges:
'posts:feature': moderator
'posts:delete': moderator
'posts:score': regular
'posts:merge': moderator
'posts:favorite': regular
'tags:create': regular
@ -112,3 +119,5 @@ privileges:
'comments:score': regular
'snapshots:list': power
'uploads:create': regular

View File

@ -15,6 +15,7 @@ reports=no
disable=
# we're not java
missing-docstring,
broad-except,
# covered better by pycodestyle
bad-continuation,

View File

@ -7,3 +7,7 @@ pytest-cov>=2.2.1
freezegun>=0.3.6
coloredlogs==5.0
pycodestyle>=2.0.0
image-match>=1.1.0
scipy>=0.18.1
elasticsearch>=5.0.0
elasticsearch-dsl>=5.0.0

View File

@ -6,3 +6,4 @@ import szurubooru.api.tag_category_api
import szurubooru.api.comment_api
import szurubooru.api.password_reset_api
import szurubooru.api.snapshot_api
import szurubooru.api.upload_api

View File

@ -124,6 +124,23 @@ def delete_post(ctx, params):
return {}
@routes.post('/post-merge/?')
def merge_posts(ctx, _params=None):
source_post_id = ctx.get_param_as_string('remove', required=True) or ''
target_post_id = ctx.get_param_as_string('mergeTo', required=True) or ''
replace_content = ctx.get_param_as_bool('replaceContent')
source_post = posts.get_post_by_id(source_post_id)
target_post = posts.get_post_by_id(target_post_id)
versions.verify_version(source_post, ctx, 'removeVersion')
versions.verify_version(target_post, ctx, 'mergeToVersion')
versions.bump_version(target_post)
auth.verify_privilege(ctx.user, 'posts:merge')
posts.merge_posts(source_post, target_post, replace_content)
snapshots.merge(source_post, target_post, ctx.user)
ctx.session.commit()
return _serialize_post(ctx, target_post)
@routes.get('/featured-post/?')
def get_featured_post(ctx, _params=None):
post = posts.try_get_featured_post()
@ -188,3 +205,21 @@ def get_posts_around(ctx, params):
_search_executor.config.user = ctx.user
return _search_executor.get_around_and_serialize(
ctx, params['post_id'], lambda post: _serialize_post(ctx, post))
@routes.post('/posts/reverse-search/?')
def get_posts_by_image(ctx, _params=None):
auth.verify_privilege(ctx.user, 'posts:reverse_search')
content = ctx.get_file('content', required=True)
return {
'exactPost':
_serialize_post(ctx, posts.search_by_image_exact(content)),
'similarPosts':
[
{
'distance': lookalike.distance,
'post': _serialize_post(ctx, lookalike.post),
}
for lookalike in posts.search_by_image(content)
],
}

View File

@ -0,0 +1,10 @@
from szurubooru.rest import routes
from szurubooru.func import auth, file_uploads
@routes.post('/uploads/?')
def create_temporary_file(ctx, _params=None):
auth.verify_privilege(ctx.user, 'uploads:create')
content = ctx.get_file('content', required=True, allow_tokens=False)
token = file_uploads.save(content)
return {'token': token}

View File

@ -36,6 +36,10 @@ class MissingRequiredFileError(ValidationError):
pass
class MissingOrExpiredRequiredFileError(MissingRequiredFileError):
pass
class MissingRequiredParameterError(ValidationError):
pass

View File

@ -1,10 +1,13 @@
''' Exports create_app. '''
import os
import time
import logging
import threading
import coloredlogs
import sqlalchemy.orm.exc
from szurubooru import config, errors, rest
from szurubooru.func import posts, file_uploads
# pylint: disable=unused-import
from szurubooru import api, middleware
@ -78,6 +81,15 @@ def validate_config():
raise errors.ConfigError('Database is not configured')
def purge_old_uploads():
while True:
try:
file_uploads.purge_old_uploads()
except Exception as ex:
logging.exception(ex)
time.sleep(60 * 5)
def create_app():
''' Create a WSGI compatible App object. '''
validate_config()
@ -87,6 +99,11 @@ def create_app():
if config.config['show_sql']:
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
purge_thread = threading.Thread(target=purge_old_uploads)
purge_thread.daemon = True
purge_thread.start()
posts.populate_reverse_search()
rest.errors.handle(errors.AuthError, _on_auth_error)
rest.errors.handle(errors.ValidationError, _on_validation_error)
rest.errors.handle(errors.SearchError, _on_search_error)

View File

@ -1,14 +1,14 @@
from datetime import datetime
class LruCacheItem(object):
class LruCacheItem:
def __init__(self, key, value):
self.key = key
self.value = value
self.timestamp = datetime.utcnow()
class LruCache(object):
class LruCache:
def __init__(self, length, delta=None):
self.length = length
self.delta = delta

View File

@ -1,6 +1,5 @@
import datetime
from szurubooru import db, errors
from szurubooru.func import scores
class InvalidFavoriteTargetError(errors.ValidationError):
@ -36,6 +35,7 @@ def unset_favorite(entity, user):
def set_favorite(entity, user):
from szurubooru.func import scores
assert entity
assert user
try:

View File

@ -0,0 +1,29 @@
import datetime
from szurubooru.func import files, util
MAX_MINUTES = 60
def _get_path(checksum):
return 'temporary-uploads/%s.dat' % checksum
def purge_old_uploads():
now = datetime.datetime.now()
for file in files.scan('temporary-uploads'):
file_time = datetime.datetime.fromtimestamp(file.stat().st_ctime)
if now - file_time > datetime.timedelta(minutes=MAX_MINUTES):
files.delete('temporary-uploads/%s' % file.name)
def get(checksum):
return files.get('temporary-uploads/%s.dat' % checksum)
def save(content):
checksum = util.get_sha1(content)
path = _get_path(checksum)
if not files.has(path):
files.save(path, content)
return checksum

View File

@ -16,6 +16,12 @@ def has(path):
return os.path.exists(_get_full_path(path))
def scan(path):
if has(path):
return os.scandir(_get_full_path(path))
return []
def move(source_path, target_path):
return os.rename(_get_full_path(source_path), _get_full_path(target_path))

View File

@ -0,0 +1,70 @@
import elasticsearch
import elasticsearch_dsl
from image_match.elasticsearch_driver import SignatureES
from szurubooru import config
# pylint: disable=invalid-name
es = elasticsearch.Elasticsearch([{
'host': config.config['elasticsearch']['host'],
'port': config.config['elasticsearch']['port'],
}])
session = SignatureES(es, index='szurubooru')
class Lookalike:
def __init__(self, score, distance, path):
self.score = score
self.distance = distance
self.path = path
def add_image(path, image_content):
if not path or not image_content:
return
session.add_image(path=path, img=image_content, bytestream=True)
def delete_image(path):
if not path:
return
try:
es.delete_by_query(
index=session.index,
doc_type=session.doc_type,
body={'query': {'term': {'path': path}}})
except elasticsearch.exceptions.NotFoundError:
pass
def search_by_image(image_content):
try:
for result in session.search_image(
path=image_content, # sic
bytestream=True):
yield Lookalike(
score=result['score'],
distance=result['dist'],
path=result['path'])
except elasticsearch.exceptions.ElasticsearchException:
raise
except Exception:
yield from []
def purge():
es.delete_by_query(
index=session.index,
doc_type=session.doc_type,
body={'query': {'match_all': {}}})
def get_all_paths():
try:
search = (
elasticsearch_dsl.Search(
using=es, index=session.index, doc_type=session.doc_type)
.source(['path']))
return set(h.path for h in search.scan())
except elasticsearch.exceptions.NotFoundError:
return set()

View File

@ -14,7 +14,7 @@ _SCALE_FIT_FMT = \
r'scale=iw*max({width}/iw\,{height}/ih):ih*max({width}/iw\,{height}/ih)'
class Image(object):
class Image:
def __init__(self, content):
self.content = content
self._reload_info()

View File

@ -2,7 +2,7 @@ import datetime
import sqlalchemy
from szurubooru import config, db, errors
from szurubooru.func import (
users, scores, comments, tags, util, mime, images, files)
users, scores, comments, tags, util, mime, images, files, image_hash)
EMPTY_PIXEL = \
@ -57,6 +57,12 @@ class InvalidPostFlagError(errors.ValidationError):
pass
class PostLookalike(image_hash.Lookalike):
def __init__(self, score, distance, post):
super().__init__(score, distance, post.post_id)
self.post = post
SAFETY_MAP = {
db.Post.SAFETY_SAFE: 'safe',
db.Post.SAFETY_SKETCHY: 'sketchy',
@ -260,13 +266,22 @@ def _after_post_update(_mapper, _connection, post):
_sync_post_content(post)
@sqlalchemy.events.event.listens_for(db.Post, 'before_delete')
def _before_post_delete(_mapper, _connection, post):
image_hash.delete_image(post.post_id)
def _sync_post_content(post):
regenerate_thumb = False
if hasattr(post, '__content'):
files.save(get_post_content_path(post), getattr(post, '__content'))
content = getattr(post, '__content')
files.save(get_post_content_path(post), content)
delattr(post, '__content')
regenerate_thumb = True
if post.type in (db.Post.TYPE_IMAGE, db.Post.TYPE_ANIMATION):
image_hash.delete_image(post.post_id)
image_hash.add_image(post.post_id, content)
if hasattr(post, '__thumbnail'):
if getattr(post, '__thumbnail'):
@ -368,6 +383,8 @@ def update_post_relations(post, new_post_ids):
.all()
if len(new_posts) != len(new_post_ids):
raise InvalidPostRelationError('One of relations does not exist.')
if post.post_id in new_post_ids:
raise InvalidPostRelationError('Post cannot relate to itself.')
relations_to_del = [p for p in old_posts if p.post_id not in new_post_ids]
relations_to_add = [p for p in new_posts if p.post_id not in old_post_ids]
@ -440,3 +457,123 @@ def feature_post(post, user):
def delete(post):
assert post
db.session.delete(post)
def merge_posts(source_post, target_post, replace_content):
assert source_post
assert target_post
if source_post.post_id == target_post.post_id:
raise InvalidPostRelationError('Cannot merge post with itself.')
def merge_tables(table, anti_dup_func, source_post_id, target_post_id):
alias1 = table
alias2 = sqlalchemy.orm.util.aliased(table)
update_stmt = (sqlalchemy.sql.expression.update(alias1)
.where(alias1.post_id == source_post_id))
if anti_dup_func is not None:
update_stmt = (update_stmt
.where(~sqlalchemy.exists()
.where(anti_dup_func(alias1, alias2))
.where(alias2.post_id == target_post_id)))
update_stmt = update_stmt.values(post_id=target_post_id)
db.session.execute(update_stmt)
def merge_tags(source_post_id, target_post_id):
merge_tables(
db.PostTag,
lambda alias1, alias2: alias1.tag_id == alias2.tag_id,
source_post_id,
target_post_id)
def merge_scores(source_post_id, target_post_id):
merge_tables(
db.PostScore,
lambda alias1, alias2: alias1.user_id == alias2.user_id,
source_post_id,
target_post_id)
def merge_favorites(source_post_id, target_post_id):
merge_tables(
db.PostFavorite,
lambda alias1, alias2: alias1.user_id == alias2.user_id,
source_post_id,
target_post_id)
def merge_comments(source_post_id, target_post_id):
merge_tables(db.Comment, None, source_post_id, target_post_id)
def merge_relations(source_post_id, target_post_id):
alias1 = db.PostRelation
alias2 = sqlalchemy.orm.util.aliased(db.PostRelation)
update_stmt = (sqlalchemy.sql.expression.update(alias1)
.where(alias1.parent_id == source_post_id)
.where(alias1.child_id != target_post_id)
.where(~sqlalchemy.exists()
.where(alias2.child_id == alias1.child_id)
.where(alias2.parent_id == target_post_id))
.values(parent_id=target_post_id))
db.session.execute(update_stmt)
update_stmt = (sqlalchemy.sql.expression.update(alias1)
.where(alias1.child_id == source_post_id)
.where(alias1.parent_id != target_post_id)
.where(~sqlalchemy.exists()
.where(alias2.parent_id == alias1.parent_id)
.where(alias2.child_id == target_post_id))
.values(child_id=target_post_id))
db.session.execute(update_stmt)
merge_tags(source_post.post_id, target_post.post_id)
merge_comments(source_post.post_id, target_post.post_id)
merge_scores(source_post.post_id, target_post.post_id)
merge_favorites(source_post.post_id, target_post.post_id)
merge_relations(source_post.post_id, target_post.post_id)
delete(source_post)
db.session.flush()
if replace_content:
content = files.get(get_post_content_path(source_post))
update_post_content(target_post, content)
def search_by_image_exact(image_content):
checksum = util.get_sha1(image_content)
return db.session \
.query(db.Post) \
.filter(db.Post.checksum == checksum) \
.one_or_none()
def search_by_image(image_content):
for result in image_hash.search_by_image(image_content):
yield PostLookalike(
score=result.score,
distance=result.distance,
post=get_post_by_id(result.path))
def populate_reverse_search():
excluded_post_ids = image_hash.get_all_paths()
post_ids_to_hash = (db.session
.query(db.Post.post_id)
.filter(
(db.Post.type == db.Post.TYPE_IMAGE) |
(db.Post.type == db.Post.TYPE_ANIMATION))
.filter(~db.Post.post_id.in_(excluded_post_ids))
.order_by(db.Post.post_id.asc())
.all())
for post_ids_chunk in util.chunks(post_ids_to_hash, 100):
posts_chunk = (db.session
.query(db.Post)
.filter(db.Post.post_id.in_(post_ids_chunk))
.all())
for post in posts_chunk:
content_path = get_post_content_path(post)
if files.has(content_path):
image_hash.add_image(post.post_id, files.get(content_path))

View File

@ -1,6 +1,5 @@
import datetime
from szurubooru import db, errors
from szurubooru.func import favorites
class InvalidScoreTargetError(errors.ValidationError):
@ -47,6 +46,7 @@ def get_score(entity, user):
def set_score(entity, user, score):
from szurubooru.func import favorites
assert entity
assert user
if not score:

View File

@ -104,7 +104,10 @@ def export_to_json():
'color': result[2],
}
for result in db.session.query(db.TagName.tag_id, db.TagName.name).all():
for result in (db.session
.query(db.TagName.tag_id, db.TagName.name)
.order_by(db.TagName.order)
.all()):
if not result[0] in tags:
tags[result[0]] = {'names': []}
tags[result[0]]['names'].append(result[1])
@ -223,16 +226,49 @@ def merge_tags(source_tag, target_tag):
assert target_tag
if source_tag.tag_id == target_tag.tag_id:
raise InvalidTagRelationError('Cannot merge tag with itself.')
pt1 = db.PostTag
pt2 = sqlalchemy.orm.util.aliased(db.PostTag)
update_stmt = (sqlalchemy.sql.expression.update(pt1)
.where(db.PostTag.tag_id == source_tag.tag_id)
def merge_posts(source_tag_id, target_tag_id):
alias1 = db.PostTag
alias2 = sqlalchemy.orm.util.aliased(db.PostTag)
update_stmt = (sqlalchemy.sql.expression.update(alias1)
.where(alias1.tag_id == source_tag_id))
update_stmt = (update_stmt
.where(~sqlalchemy.exists()
.where(pt2.post_id == pt1.post_id)
.where(pt2.tag_id == target_tag.tag_id))
.values(tag_id=target_tag.tag_id))
.where(alias1.post_id == alias2.post_id)
.where(alias2.tag_id == target_tag_id)))
update_stmt = update_stmt.values(tag_id=target_tag_id)
db.session.execute(update_stmt)
def merge_relations(table, source_tag_id, target_tag_id):
alias1 = table
alias2 = sqlalchemy.orm.util.aliased(table)
update_stmt = (sqlalchemy.sql.expression.update(alias1)
.where(alias1.parent_id == source_tag_id)
.where(alias1.child_id != target_tag_id)
.where(~sqlalchemy.exists()
.where(alias2.child_id == alias1.child_id)
.where(alias2.parent_id == target_tag_id))
.values(parent_id=target_tag_id))
db.session.execute(update_stmt)
update_stmt = (sqlalchemy.sql.expression.update(alias1)
.where(alias1.child_id == source_tag_id)
.where(alias1.parent_id != target_tag_id)
.where(~sqlalchemy.exists()
.where(alias2.parent_id == alias1.parent_id)
.where(alias2.child_id == target_tag_id))
.values(child_id=target_tag_id))
db.session.execute(update_stmt)
def merge_suggestions(source_tag_id, target_tag_id):
merge_relations(db.TagSuggestion, source_tag_id, target_tag_id)
def merge_implications(source_tag_id, target_tag_id):
merge_relations(db.TagImplication, source_tag_id, target_tag_id)
merge_posts(source_tag.tag_id, target_tag.tag_id)
merge_suggestions(source_tag.tag_id, target_tag.tag_id)
merge_implications(source_tag.tag_id, target_tag.tag_id)
delete(source_tag)

View File

@ -32,7 +32,7 @@ def get_serialization_options(ctx):
def serialize_entity(entity, field_factories, options):
if not entity:
return None
if not options:
if not options or len(options) == 0:
options = field_factories.keys()
ret = {}
for key in options:
@ -162,3 +162,8 @@ def value_exceeds_column_size(value, column):
if max_length is None:
return False
return len(value) > max_length
def chunks(source_list, part_size):
for i in range(0, len(source_list), part_size):
yield source_list[i:i + part_size]

View File

@ -1,6 +1,5 @@
''' Various hooks that get executed for each request. '''
import szurubooru.middleware.db_session
import szurubooru.middleware.authenticator
import szurubooru.middleware.cache_purger
import szurubooru.middleware.request_logger

View File

@ -1,12 +0,0 @@
from szurubooru import db
from szurubooru.rest import middleware
@middleware.pre_hook
def _process_request(ctx):
ctx.session = db.session()
@middleware.post_hook
def _process_response(_ctx):
db.session.remove()

View File

@ -61,6 +61,7 @@ def run_migrations_online():
with alembic.context.begin_transaction():
alembic.context.run_migrations()
if alembic.context.is_offline_mode():
run_migrations_offline()
else:

View File

@ -3,6 +3,7 @@ import cgi
import json
import re
from datetime import datetime
from szurubooru import db
from szurubooru.func import util
from szurubooru.rest import errors, middleware, routes, context
@ -64,7 +65,6 @@ def _create_context(env):
def application(env, start_response):
try:
try:
ctx = _create_context(env)
if 'application/json' not in ctx.get_header('Accept'):
@ -74,29 +74,34 @@ def application(env, start_response):
for url, allowed_methods in routes.routes.items():
match = re.fullmatch(url, ctx.url)
if not match:
continue
if match:
if ctx.method not in allowed_methods:
raise errors.HttpMethodNotAllowed(
'ValidationError',
'Allowed methods: %r' % allowed_methods)
handler = allowed_methods[ctx.method]
break
else:
raise errors.HttpNotFound(
'ValidationError',
'Requested path ' + ctx.url + ' was not found.')
try:
ctx.session = db.session()
try:
for hook in middleware.pre_hooks:
hook(ctx)
handler = allowed_methods[ctx.method]
try:
response = handler(ctx, match.groupdict())
finally:
for hook in middleware.post_hooks:
hook(ctx)
finally:
db.session.remove()
start_response('200', [('content-type', 'application/json')])
return (_dump_json(response).encode('utf-8'),)
raise errors.HttpNotFound(
'ValidationError',
'Requested path ' + ctx.url + ' was not found.')
except Exception as ex:
for exception_type, handler in errors.error_handlers.items():
if isinstance(ex, exception_type):

Some files were not shown because too many files have changed in this diff Show More