56 Commits
2.1 ... 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
95 changed files with 1765 additions and 1082 deletions

118
API.md
View File

@ -42,6 +42,7 @@
- [Removing post from favorites](#removing-post-from-favorites) - [Removing post from favorites](#removing-post-from-favorites)
- [Getting featured post](#getting-featured-post) - [Getting featured post](#getting-featured-post)
- [Featuring post](#featuring-post) - [Featuring post](#featuring-post)
- [Reverse image search](#reverse-image-search)
- Comments - Comments
- [Listing comments](#listing-comments) - [Listing comments](#listing-comments)
- [Creating comment](#creating-comment) - [Creating comment](#creating-comment)
@ -62,6 +63,8 @@
- [Listing snapshots](#listing-snapshots) - [Listing snapshots](#listing-snapshots)
- Global info - Global info
- [Getting global info](#getting-global-info) - [Getting global info](#getting-global-info)
- File uploads
- [Uploading temporary file](#uploading-temporary-file)
3. [Resources](#resources) 3. [Resources](#resources)
@ -76,6 +79,7 @@
- [Snapshot](#snapshot) - [Snapshot](#snapshot)
- [Unpaged search result](#unpaged-search-result) - [Unpaged search result](#unpaged-search-result)
- [Paged search result](#paged-search-result) - [Paged search result](#paged-search-result)
- [Image search result](#image-search-result)
4. [Search](#search) 4. [Search](#search)
@ -103,16 +107,28 @@ application/json`. An exception to this rule are requests that upload files.
## File uploads ## File uploads
Requests that upload files must use `multipart/form-data` encoding. JSON Requests that upload files must use `multipart/form-data` encoding. Any request
metadata must then be included as field of name `metadata`, whereas files must that bundles user files, must send the request data (which is JSON) as an
be included as separate fields with names specific to each request type. 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 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 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` way. The files, however, should be passed as regular fields appended with a
suffix. For example, to download a file named `content` from `Url` suffix. For example, to use `http://example.com/file.jpg` in an API that
`http://example.com/file.jpg`, the client should pass accepts a file named `content`, the client should pass
`{"contentUrl":"http://example.com/file.jpg"}` as part of the message body. `{"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 ## Error handling
@ -787,7 +803,7 @@ data.
- **Files** - **Files**
- `content` - the content of the content. - `content` - the content of the post.
- `thumbnail` - the content of custom thumbnail (optional). - `thumbnail` - the content of custom thumbnail (optional).
- **Output** - **Output**
@ -835,7 +851,7 @@ data.
- **Files** - **Files**
- `content` - the content of the content (optional). - `content` - the content of the post (optional).
- `thumbnail` - the content of custom thumbnail (optional). - `thumbnail` - the content of custom thumbnail (optional).
- **Output** - **Output**
@ -1057,6 +1073,27 @@ data.
Features a post on the main page in web client. 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 ## Listing comments
- **Request** - **Request**
@ -1570,6 +1607,35 @@ data.
exception of privilege array keys being converted to lower camel case to exception of privilege array keys being converted to lower camel case to
match the API convention. 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 # Resources
@ -2118,6 +2184,40 @@ A result of search operation that involves paging.
details on this field, check the documentation for given API call. 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
Search queries are built of tokens that are separated by spaces. Each token can 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 python-pip
user@host:~$ sudo pacman -S ffmpeg user@host:~$ sudo pacman -S ffmpeg
user@host:~$ sudo pacman -S npm user@host:~$ sudo pacman -S npm
user@host:~$ sudo pacman -S elasticsearch
user@host:~$ sudo pip install virtualenv user@host:~$ sudo pip install virtualenv
user@host:~$ python --version user@host:~$ python --version
Python 3.5.1 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 ### Preparing environment
Getting `szurubooru`: Getting `szurubooru`:

View File

@ -26,7 +26,7 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
## Requirements ## Requirements
- Python - Python 3.5
- Postgres - Postgres
- FFmpeg - FFmpeg
- node.js - 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(); const config = getConfig();
bundleConfig(config); bundleConfig(config);
bundleBinaryAssets(); bundleBinaryAssets();

View File

@ -1,60 +1,14 @@
@import colors @import colors
$comment-header-background-color = $top-navigation-color
$comment-border-color = #DDD
.comment-form-container .comment-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
margin: 0 0 1em 0 margin: 0 0 1em 0
padding: 0 padding: 0 0 0 60px
display: -webkit-flex
display: flex
.avatar .avatar
margin-right: 1em float: left
-webkit-flex-shrink: 0 margin-left: -60px
flex-shrink: 0
vertical-align: top vertical-align: top
.thumbnail .thumbnail
@ -63,25 +17,72 @@
a a
display: inline-block display: inline-block
.body nav:not(.active), .tab:not(.active)
flex-grow: 1 display: none
.comment
border: 1px solid $comment-border-color
header header
white-space: nowrap white-space: nowrap
line-height: 16pt font-size: 95%
vertical-align: middle vertical-align: middle
margin-bottom: 0.5em position: relative
background: $top-navigation-color background: $comment-header-background-color
padding: 0.2em 0.5em 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
.nickname, .date, .score-container, .edit
margin-right: 2em
.date, .score-container, .edit, .delete
font-size: 95%
.edit, .delete, .score-container a, .nickname a .edit, .delete, .score-container a, .nickname a
&:not(.inactive) &:not(.inactive)
color: mix($main-color, $inactive-tab-text-color) color: mix($main-color, $inactive-tab-text-color)
.edit, .delete
font-size: 80%
i i
margin-right: 0.3em margin-right: 0.3em
@ -96,15 +97,28 @@
display: inline-block display: inline-block
width: 2em width: 2em
.body
width: auto
margin: 1em
.keep-height
position: relative
textarea
position: absolute
width: 100%
height: 100%
.tab.edit
min-height: 150px
.messages .messages
margin: 1em 0 margin: 1em 0
.comment-content .comment-content
ul ul, ol
list-style-position: inside list-style-position: inside
margin: 1em 0 margin: 1em 0
padding: 0 padding: 0 0 0 1.5em
.sjis .sjis
font-family: 'MS PGothic', ' ', 'IPAMonaPGothic', 'Trebuchet MS', Verdana, Futura, Arial, Helvetica, sans-serif font-family: 'MS PGothic', ' ', 'IPAMonaPGothic', 'Trebuchet MS', Verdana, Futura, Arial, Helvetica, sans-serif
@ -118,9 +132,6 @@
white-space: pre white-space: pre
word-wrap: normal word-wrap: normal
p:first-child
margin-top: 0
.spoiler .spoiler
background: #eee background: #eee
color: #eee color: #eee
@ -140,5 +151,7 @@
background: #fafafa background: #fafafa
color: #444 color: #444
blockquote :last-child :first-child
margin-bottom: 0 margin-top: 0
:last-child
margin-bottom: 0

View File

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

View File

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

View File

@ -4,17 +4,15 @@ form
display: block display: block
width: 20em width: 20em
ul .input
list-style-type: none list-style-type: none
margin: 0 0 1em 0 margin: 0 0 2em 0
padding: 0 padding: 0
li li
margin-top: 1.2em margin-top: 1.2em
label label
display: block display: block
padding: 0.3em 0 padding: 0.3em 0
.input
margin-bottom: 2em
.input li:first-child label:not(.radio):not(.checkbox):not(.file-dropper), .input li:first-child label:not(.radio):not(.checkbox):not(.file-dropper),
.input li:first-child .input li:first-child
padding-top: 0 padding-top: 0

View File

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

View File

@ -14,9 +14,6 @@
.right-post-container .right-post-container
width: 47% width: 47%
float: right float: right
input[type=text]
width: 8em
margin-top: -2px
.post-mirror .post-mirror
margin-bottom: 1em margin-bottom: 1em
&:after &:after
@ -31,3 +28,10 @@
margin-right: 0.35em margin-right: 0.35em
.target-post, .target-post-content .target-post, .target-post-content
margin: 1em 0 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 text-align: left
label label
display: none display: none !important
form form
width: auto width: auto
margin-bottom: 0.75em margin-bottom: 0.75em

View File

@ -1,5 +1,6 @@
@import colors @import colors
$upload-header-background-color = $top-navigation-color
$upload-border-color = #DDD
$cancel-button-color = tomato $cancel-button-color = tomato
#post-upload #post-upload
@ -35,42 +36,114 @@ $cancel-button-color = tomato
.skip-duplicates .skip-duplicates
margin-left: 1em margin-left: 1em
.messages form>.messages
margin-top: 1em margin-top: 1em
.uploadables-container .uploadables-container
li list-style-type: none
margin: 0
padding: 0
.uploadable-container
clear: both
margin: 0 0 1.2em 0 margin: 0 0 1.2em 0
padding-left: 13em
.uploadable &>.thumbnail-wrapper
.file
margin: 0.3em 0
overflow: hidden
white-space: nowrap
text-align: left
text-overflow: ellipsis
.anonymous
margin: 0.3em 0
.safety
margin: 0.3em 0
label
display: inline-block
margin-right: 1em
.thumbnail-wrapper
float: left float: left
width: 12.5em width: 12em
height: 7em height: 8em
margin: 0.2em 1em 0 0 margin: 0 0 0 -13em
.thumbnail .thumbnail
width: 100% width: 100%
height: 100% height: 100%
.controls .uploadable
float: right border: 1px solid $upload-border-color
a min-height: 8em
color: $inactive-link-color box-sizing: border-box
margin-left: 0.5em
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

View File

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

View File

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

View File

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

View File

@ -1,57 +1,85 @@
<div class='comment'> <div class='comment-container'>
<div class='avatar'> <div class='avatar'>
<% if (ctx.comment.user && ctx.comment.user.name && ctx.canViewUsers) { %> <% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %>
<a href='/user/<%- encodeURIComponent(ctx.comment.user.name) %>'> <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> </a>
<% } %> <% } %>
</div> </div>
<div class='body'> <div class='comment'>
<header><% <header>
%><span class='nickname'><% <nav class='edit tabs'>
%><% if (ctx.comment.user && ctx.comment.user.name && ctx.canViewUsers) { %><% <ul>
%><a href='/user/<%- encodeURIComponent(ctx.comment.user.name) %>'><% <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.user ? ctx.user.name : 'Deleted user' %><%
%><% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %><%
%></a><%
%><% } %><%
%></span></strong>
<span class='date'><%
%>commented <%= ctx.makeRelativeTime(ctx.comment ? ctx.comment.creationTime : null) %><%
%></span><%
%><wbr><%
%><span class='score-container'></span><%
%><% if (ctx.canEditComment || ctx.canDeleteComment) { %><%
%><span class='action-container'><%
%><% if (ctx.canEditComment) { %><%
%><a href class='edit'><%
%><i class='fa fa-pencil'></i>&nbsp;edit<%
%></a><%
%><% } %><%
%><% if (ctx.canDeleteComment) { %><%
%><a href class='delete'><%
%><i class='fa fa-remove'></i>&nbsp;delete<%
%></a><%
%><% } %><%
%></span><%
%><% } %><% %><% } %><%
%></nav><%
%><%- ctx.comment.user ? ctx.comment.user.name : 'Deleted user' %><%
%><% if (ctx.comment.user && ctx.comment.user.name && ctx.canViewUsers) { %><%
%></a><%
%><% } %><%
%></span><%
%><wbr><%
%><span class='date'><%
%><%= ctx.makeRelativeTime(ctx.comment.creationTime) %><%
%></span><%
%><wbr><%
%><span class='score-container'></span><%
%><wbr><%
%><% if (ctx.canEditComment) { %><%
%><a href class='edit'><%
%><i class='fa fa-pencil'></i> edit<%
%></a><%
%><% } %><%
%><wbr><%
%><% if (ctx.canDeleteComment) { %><%
%><a href class='delete'><%
%><i class='fa fa-remove'></i> delete<%
%></a><%
%><% } %><%
%></header> %></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>
</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> </tr>
</tbody> </tbody>
</table> </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,33 +1,33 @@
<div class='content-wrapper' id='login'> <div class='content-wrapper' id='login'>
<h1>Log in</h1> <h1>Log in</h1>
<form> <form>
<div class='input'> <ul class='input'>
<ul> <li>
<li> <%= ctx.makeTextInput({
<%= ctx.makeTextInput({ text: 'User name',
text: 'User name', name: 'name',
name: 'name', required: true,
required: true, pattern: ctx.userNamePattern,
pattern: ctx.userNamePattern, }) %>
}) %> </li>
</li> <li>
<li> <%= ctx.makePasswordInput({
<%= ctx.makePasswordInput({ text: 'Password',
text: 'Password', name: 'password',
name: 'password', required: true,
required: true, pattern: ctx.passwordPattern,
pattern: ctx.passwordPattern, }) %>
}) %> </li>
</li> <li>
<li> <%= ctx.makeCheckbox({
<%= ctx.makeCheckbox({ text: 'Remember me',
text: 'Remember me', name: 'remember-user',
name: 'remember-user', }) %>
}) %> </li>
</li> </ul>
</ul>
</div>
<div class='messages'></div> <div class='messages'></div>
<div class='buttons'> <div class='buttons'>
<input type='submit' value='Log in'/> <input type='submit' value='Log in'/>
<% if (ctx.canSendMails) { %> <% if (ctx.canSendMails) { %>

View File

@ -1,20 +1,20 @@
<div class='content-wrapper' id='password-reset'> <div class='content-wrapper' id='password-reset'>
<h1>Password reset</h1> <h1>Password reset</h1>
<form autocomplete='off'> <form autocomplete='off'>
<div class='input'> <ul class='input'>
<ul> <li>
<li> <%= ctx.makeTextInput({
<%= ctx.makeTextInput({ text: 'User name or e-mail address',
text: 'User name or e-mail address', name: 'user-name',
name: 'user-name', required: true,
required: true, }) %>
}) %> </li>
</li> </ul>
</ul>
</div>
<p><small>Proceeding will send an e-mail that contains a password reset <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. 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> It is recommended to change that password to something else.</small></p>
<div class='messages'></div> <div class='messages'></div>
<div class='buttons'> <div class='buttons'>
<input type='submit' value='Proceed'/> <input type='submit' value='Proceed'/>

View File

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

View File

@ -1,6 +1,6 @@
<div class='post-merge'> <div class='post-merge'>
<form> <form>
<ul> <ul class='input'>
<li class='post-mirror'> <li class='post-mirror'>
<div class='left-post-container'></div> <div class='left-post-container'></div>
<div class='right-post-container'></div> <div class='right-post-container'></div>

View File

@ -1,8 +1,12 @@
<% if (ctx.editable) { %> <header>
<p>Post # <input type='text' pattern='^[0-9]+$' value='<%- ctx.post ? ctx.post.id : '' %>'/></p> <label for='merge-id-<%- ctx.name %>'>Post #</label>
<% } else { %> <% if (ctx.editable) { %>
<p>Post # <input type='text' pattern='^[0-9]+$' value='<%- ctx.post ? ctx.post.id : '' %>' readonly/></p> <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) { %> <% if (ctx.post) { %>
<div class='post-thumbnail'> <div class='post-thumbnail'>

View File

@ -1,10 +1,4 @@
<li class='uploadable'> <li class='uploadable-container'>
<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>
<div class='thumbnail-wrapper'> <div class='thumbnail-wrapper'>
<% if (['image'].includes(ctx.uploadable.type)) { %> <% if (['image'].includes(ctx.uploadable.type)) { %>
@ -29,28 +23,81 @@
<% } %> <% } %>
</div> </div>
<div class='file'> <div class='uploadable'>
<strong><%= ctx.uploadable.name %></strong> <header>
</div> <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>
<div class='safety'> <span class='filename'><%= ctx.uploadable.name %></span>
<% for (let safety of ['safe', 'sketchy', 'unsafe']) { %> </header>
<%= ctx.makeRadio({
name: 'safety-' + ctx.uploadable.key,
value: safety,
text: safety[0].toUpperCase() + safety.substr(1),
selectedValue: ctx.uploadable.safety,
}) %>
<% } %>
</div>
<% if (ctx.canUploadAnonymously) { %> <div class='body'>
<div class='anonymous'> <div class='safety'>
<%= ctx.makeCheckbox({ <% for (let safety of ['safe', 'sketchy', 'unsafe']) { %>
text: 'Upload anonymously', <%= ctx.makeRadio({
name: 'anonymous', name: 'safety-' + ctx.uploadable.key,
checked: ctx.uploadable.anonymous, value: safety,
}) %> text: safety[0].toUpperCase() + safety.substr(1),
selectedValue: ctx.uploadable.safety,
}) %>
<% } %>
</div>
<div class='options'>
<% if (ctx.canUploadAnonymously) { %>
<div class='anonymous'>
<%= ctx.makeCheckbox({
text: 'Upload anonymously',
name: 'anonymous',
checked: ctx.uploadable.anonymous,
}) %>
</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>
<% } %> </div>
</li> </li>

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<div class='tag-merge'> <div class='tag-merge'>
<form> <form>
<ul> <ul class='input'>
<li class='target'> <li class='target'>
<%= ctx.makeTextInput({required: true, text: 'Target tag', pattern: ctx.tagNamePattern}) %> <%= ctx.makeTextInput({required: true, text: 'Target tag', pattern: ctx.tagNamePattern}) %>
</li> </li>

View File

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

View File

@ -1,16 +1,15 @@
<div id='user-delete'> <div id='user-delete'>
<form> <form>
<div class='input'> <ul class='input'>
<ul> <li>
<li> <%= ctx.makeCheckbox({
<%= ctx.makeCheckbox({ name: 'confirm-deletion',
name: 'confirm-deletion', text: 'I confirm that I want to delete this account.',
text: 'I confirm that I want to delete this account.', required: true,
required: true, }) %>
}) %> </li>
</li> </ul>
</ul>
</div>
<div class='messages'></div> <div class='messages'></div>
<div class='buttons'> <div class='buttons'>
<input type='submit' value='Delete account'/> <input type='submit' value='Delete account'/>

View File

@ -4,44 +4,44 @@
<input class='anticomplete' type='text' name='fakeuser'/> <input class='anticomplete' type='text' name='fakeuser'/>
<input class='anticomplete' type='password' name='fakepass'/> <input class='anticomplete' type='password' name='fakepass'/>
<div class='input'> <ul class='input'>
<ul> <li>
<li> <%= ctx.makeTextInput({
<%= ctx.makeTextInput({ text: 'User name',
text: 'User name', name: 'name',
name: 'name', placeholder: 'letters, digits, _, -',
placeholder: 'letters, digits, _, -', required: true,
required: true, pattern: ctx.userNamePattern,
pattern: ctx.userNamePattern, }) %>
}) %> </li>
</li> <li>
<li> <%= ctx.makePasswordInput({
<%= ctx.makePasswordInput({ text: 'Password',
text: 'Password', name: 'password',
name: 'password', placeholder: '5+ characters',
placeholder: '5+ characters', required: true,
required: true, pattern: ctx.passwordPattern,
pattern: ctx.passwordPattern, }) %>
}) %> </li>
</li> <li>
<li> <%= ctx.makeEmailInput({
<%= ctx.makeEmailInput({ text: 'Email',
text: 'Email', name: 'email',
name: 'email', placeholder: 'optional',
placeholder: 'optional', }) %>
}) %> <p class='hint'>
<p class='hint'> Used for password reminder and to show a <a href='http://gravatar.com/'>Gravatar</a>.
Used for password reminder and to show a <a href='http://gravatar.com/'>Gravatar</a>. Leave blank for random Gravatar.
Leave blank for random Gravatar. </p>
</p> </li>
</li> </ul>
</ul>
</div>
<div class='messages'></div> <div class='messages'></div>
<div class='buttons'> <div class='buttons'>
<input type='submit' value='Create an account'/> <input type='submit' value='Create an account'/>
</div> </div>
</form> </form>
<div class='info'> <div class='info'>
<p>Registered users can:</p> <p>Registered users can:</p>
<ul> <ul>

View File

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

View File

@ -1,10 +1,12 @@
'use strict'; 'use strict';
const nprogress = require('nprogress');
const cookies = require('js-cookie'); const cookies = require('js-cookie');
const request = require('superagent'); const request = require('superagent');
const config = require('./config.js'); const config = require('./config.js');
const events = require('./events.js'); const events = require('./events.js');
const progress = require('./util/progress.js');
let fileTokens = {};
class Api extends events.EventTarget { class Api extends events.EventTarget {
constructor() { constructor() {
@ -39,7 +41,7 @@ class Api extends events.EventTarget {
resolve(this.cache[url]); resolve(this.cache[url]);
}); });
} }
return this._process(url, request.get, {}, {}, options) return this._wrappedRequest(url, request.get, {}, {}, options)
.then(response => { .then(response => {
this.cache[url] = response; this.cache[url] = response;
return Promise.resolve(response); return Promise.resolve(response);
@ -48,83 +50,17 @@ class Api extends events.EventTarget {
post(url, data, files, options) { post(url, data, files, options) {
this.cache = {}; 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) { put(url, data, files, options) {
this.cache = {}; this.cache = {};
return this._process(url, request.put, data, files, options); return this._wrappedRequest(url, request.put, data, files, options);
} }
delete(url, data, options) { delete(url, data, options) {
this.cache = {}; this.cache = {};
return this._process(url, request.delete, data, {}, options); return this._wrappedRequest(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;
} }
hasPrivilege(lookup) { hasPrivilege(lookup) {
@ -149,18 +85,10 @@ class Api extends events.EventTarget {
} }
loginFromCookies() { loginFromCookies() {
return new Promise((resolve, reject) => { const auth = cookies.getJSON('auth');
const auth = cookies.getJSON('auth'); return auth && auth.user && auth.password ?
if (auth && auth.user && auth.password) { this.login(auth.user, auth.password, true) :
this.login(auth.user, auth.password, true) Promise.resolve();
.then(resolve)
.catch(errorMessage => {
reject(errorMessage);
});
} else {
resolve();
}
});
} }
login(userName, userPassword, doRemember) { login(userName, userPassword, doRemember) {
@ -181,8 +109,8 @@ class Api extends events.EventTarget {
this.user = response; this.user = response;
resolve(); resolve();
this.dispatchEvent(new CustomEvent('login')); this.dispatchEvent(new CustomEvent('login'));
}, response => { }, error => {
reject(response.description || response || 'Unknown error'); reject(error);
this.logout(); this.logout();
}); });
}); });
@ -216,6 +144,161 @@ class Api extends events.EventTarget {
const request = matches[2]; const request = matches[2];
return [baseUrl, request]; 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(); module.exports = new Api();

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,6 @@ const router = require('../router.js');
const api = require('../api.js'); const api = require('../api.js');
const misc = require('../util/misc.js'); const misc = require('../util/misc.js');
const settings = require('../models/settings.js'); const settings = require('../models/settings.js');
const Comment = require('../models/comment.js');
const Post = require('../models/post.js'); const Post = require('../models/post.js');
const PostList = require('../models/post_list.js'); const PostList = require('../models/post_list.js');
const PostDetailView = require('../views/post_detail_view.js'); const PostDetailView = require('../views/post_detail_view.js');
@ -18,18 +17,10 @@ class PostDetailController extends BasePostController {
Post.get(ctx.parameters.id).then(post => { Post.get(ctx.parameters.id).then(post => {
this._id = ctx.parameters.id; this._id = ctx.parameters.id;
post.addEventListener('change', e => this._evtSaved(e, section)); post.addEventListener('change', e => this._evtSaved(e, section));
this._installView(post, section);
this._view = new PostDetailView({ }, error => {
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));
}, errorMessage => {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError(errorMessage); this._view.showError(error.message);
}); });
} }
@ -37,14 +28,25 @@ class PostDetailController extends BasePostController {
this._view.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) { _evtSelect(e) {
this._view.clearMessages(); this._view.clearMessages();
this._view.disableForm(); this._view.disableForm();
Post.get(e.detail.postId).then(post => { Post.get(e.detail.postId).then(post => {
this._view.selectPost(post); this._view.selectPost(post);
this._view.enableForm(); this._view.enableForm();
}, errorMessage => { }, error => {
this._view.showError(errorMessage); this._view.showError(error.message);
this._view.enableForm(); this._view.enableForm();
}); });
} }
@ -62,16 +64,12 @@ class PostDetailController extends BasePostController {
this._view.disableForm(); this._view.disableForm();
e.detail.post.merge(e.detail.targetPost.id, e.detail.useOldContent) e.detail.post.merge(e.detail.targetPost.id, e.detail.useOldContent)
.then(() => { .then(() => {
this._view = new PostDetailView({ this._installView(e.detail.post, 'merge');
post: e.detail.targetPost,
section: 'merge',
canMerge: api.hasPrivilege('posts:merge'),
});
this._view.showSuccess('Post merged.'); this._view.showSuccess('Post merged.');
router.replace( router.replace(
'/post/' + e.detail.targetPost.id + '/merge', null, false); '/post/' + e.detail.targetPost.id + '/merge', null, false);
}, errorMessage => { }, error => {
this._view.showError(errorMessage); this._view.showError(error.message);
this._view.enableForm(); this._view.enableForm();
}); });
} }

View File

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

View File

@ -69,10 +69,10 @@ class PostMainController extends BasePostController {
'merge', e => this._evtMergePost(e)); 'merge', e => this._evtMergePost(e));
} }
if (this._view.commentFormControl) { if (this._view.commentControl) {
this._view.commentFormControl.addEventListener( this._view.commentControl.addEventListener(
'change', e => this._evtCommentChange(e)); 'change', e => this._evtCommentChange(e));
this._view.commentFormControl.addEventListener( this._view.commentControl.addEventListener(
'submit', e => this._evtCreateComment(e)); 'submit', e => this._evtCreateComment(e));
} }
@ -84,9 +84,9 @@ class PostMainController extends BasePostController {
this._view.commentListControl.addEventListener( this._view.commentListControl.addEventListener(
'delete', e => this._evtDeleteComment(e)); 'delete', e => this._evtDeleteComment(e));
} }
}, errorMessage => { }, error => {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError(errorMessage); this._view.showError(error.message);
}); });
} }
@ -117,8 +117,8 @@ class PostMainController extends BasePostController {
.then(() => { .then(() => {
this._view.sidebarControl.showSuccess('Post featured.'); this._view.sidebarControl.showSuccess('Post featured.');
this._view.sidebarControl.enableForm(); this._view.sidebarControl.enableForm();
}, errorMessage => { }, error => {
this._view.sidebarControl.showError(errorMessage); this._view.sidebarControl.showError(error.message);
this._view.sidebarControl.enableForm(); this._view.sidebarControl.enableForm();
}); });
} }
@ -135,8 +135,8 @@ class PostMainController extends BasePostController {
misc.disableExitConfirmation(); misc.disableExitConfirmation();
const ctx = router.show('/posts'); const ctx = router.show('/posts');
ctx.controller.showSuccess('Post deleted.'); ctx.controller.showSuccess('Post deleted.');
}, errorMessage => { }, error => {
this._view.sidebarControl.showError(errorMessage); this._view.sidebarControl.showError(error.message);
this._view.sidebarControl.enableForm(); this._view.sidebarControl.enableForm();
}); });
} }
@ -168,8 +168,8 @@ class PostMainController extends BasePostController {
this._view.sidebarControl.showSuccess('Post saved.'); this._view.sidebarControl.showSuccess('Post saved.');
this._view.sidebarControl.enableForm(); this._view.sidebarControl.enableForm();
misc.disableExitConfirmation(); misc.disableExitConfirmation();
}, errorMessage => { }, error => {
this._view.sidebarControl.showError(errorMessage); this._view.sidebarControl.showError(error.message);
this._view.sidebarControl.enableForm(); this._view.sidebarControl.enableForm();
}); });
} }
@ -183,18 +183,18 @@ class PostMainController extends BasePostController {
} }
_evtCreateComment(e) { _evtCreateComment(e) {
// TODO: disable form this._view.commentControl.disableForm();
const comment = Comment.create(this._post.id); const comment = Comment.create(this._post.id);
comment.text = e.detail.text; comment.text = e.detail.text;
comment.save() comment.save()
.then(() => { .then(() => {
this._post.comments.add(comment); this._post.comments.add(comment);
this._view.commentFormControl.setText(''); this._view.commentControl.exitEditMode();
// TODO: enable form this._view.commentControl.enableForm();
misc.disableExitConfirmation(); misc.disableExitConfirmation();
}, errorMessage => { }, error => {
this._view.commentFormControl.showError(errorMessage); this._view.commentControl.showError(error.message);
// TODO: enable form this._view.commentControl.enableForm();
}); });
} }
@ -202,24 +202,20 @@ class PostMainController extends BasePostController {
// TODO: disable form // TODO: disable form
e.detail.comment.text = e.detail.text; e.detail.comment.text = e.detail.text;
e.detail.comment.save() e.detail.comment.save()
.catch(errorMessage => { .catch(error => {
e.detail.target.showError(errorMessage); e.detail.target.showError(error.message);
// TODO: enable form // TODO: enable form
}); });
} }
_evtScoreComment(e) { _evtScoreComment(e) {
e.detail.comment.setScore(e.detail.score) e.detail.comment.setScore(e.detail.score)
.catch(errorMessage => { .catch(error => window.alert(error.message));
window.alert(errorMessage);
});
} }
_evtDeleteComment(e) { _evtDeleteComment(e) {
e.detail.comment.delete() e.detail.comment.delete()
.catch(errorMessage => { .catch(error => window.alert(error.message));
window.alert(errorMessage);
});
} }
_evtScorePost(e) { _evtScorePost(e) {
@ -227,9 +223,7 @@ class PostMainController extends BasePostController {
return; return;
} }
e.detail.post.setScore(e.detail.score) e.detail.post.setScore(e.detail.score)
.catch(errorMessage => { .catch(error => window.alert(error.message));
window.alert(errorMessage);
});
} }
_evtFavoritePost(e) { _evtFavoritePost(e) {
@ -237,9 +231,7 @@ class PostMainController extends BasePostController {
return; return;
} }
e.detail.post.addToFavorites() e.detail.post.addToFavorites()
.catch(errorMessage => { .catch(error => window.alert(error.message));
window.alert(errorMessage);
});
} }
_evtUnfavoritePost(e) { _evtUnfavoritePost(e) {
@ -247,9 +239,7 @@ class PostMainController extends BasePostController {
return; return;
} }
e.detail.post.removeFromFavorites() e.detail.post.removeFromFavorites()
.catch(errorMessage => { .catch(error => window.alert(error.message));
window.alert(errorMessage);
});
} }
} }

View File

@ -3,14 +3,19 @@
const api = require('../api.js'); const api = require('../api.js');
const router = require('../router.js'); const router = require('../router.js');
const misc = require('../util/misc.js'); const misc = require('../util/misc.js');
const progress = require('../util/progress.js');
const topNavigation = require('../models/top_navigation.js'); const topNavigation = require('../models/top_navigation.js');
const Post = require('../models/post.js'); const Post = require('../models/post.js');
const PostUploadView = require('../views/post_upload_view.js'); const PostUploadView = require('../views/post_upload_view.js');
const EmptyView = require('../views/empty_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 { class PostUploadController {
constructor() { constructor() {
this._lastPromise = null; this._lastCancellablePromise = null;
if (!api.hasPrivilege('posts:create')) { if (!api.hasPrivilege('posts:create')) {
this._view = new EmptyView(); this._view = new EmptyView();
@ -22,6 +27,7 @@ class PostUploadController {
topNavigation.setTitle('Upload'); topNavigation.setTitle('Upload');
this._view = new PostUploadView({ this._view = new PostUploadView({
canUploadAnonymously: api.hasPrivilege('posts:create:anonymous'), canUploadAnonymously: api.hasPrivilege('posts:create:anonymous'),
canViewPosts: api.hasPrivilege('posts:view'),
}); });
this._view.addEventListener('change', e => this._evtChange(e)); this._view.addEventListener('change', e => this._evtChange(e));
this._view.addEventListener('submit', e => this._evtSubmit(e)); this._view.addEventListener('submit', e => this._evtSubmit(e));
@ -33,13 +39,13 @@ class PostUploadController {
misc.enableExitConfirmation(); misc.enableExitConfirmation();
} else { } else {
misc.disableExitConfirmation(); misc.disableExitConfirmation();
this._view.clearMessages();
} }
this._view.clearMessages();
} }
_evtCancel(e) { _evtCancel(e) {
if (this._lastPromise) { if (this._lastCancellablePromise) {
this._lastPromise.abort(); this._lastCancellablePromise.abort();
} }
} }
@ -47,45 +53,94 @@ class PostUploadController {
this._view.disableForm(); this._view.disableForm();
this._view.clearMessages(); this._view.clearMessages();
e.detail.uploadables.reduce((promise, uploadable) => { e.detail.uploadables.reduce(
return promise.then(() => { (promise, uploadable) =>
let post = new Post(); promise.then(() => this._uploadSinglePost(
post.safety = uploadable.safety; uploadable, e.detail.skipDuplicates)),
if (uploadable.url) { Promise.resolve())
post.newContentUrl = uploadable.url; .then(() => {
} else { this._view.clearMessages();
post.newContent = uploadable.file; misc.disableExitConfirmation();
const ctx = router.show('/posts');
ctx.controller.showSuccess('Posts uploaded.');
}, 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();
});
}
_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);
} }
let modelPromise = post.save(uploadable.anonymous); // notify about similar posts
this._lastPromise = modelPromise; 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);
}
}
return modelPromise // no duplicates, proceed with saving
.then(() => { let post = this._uploadableToPost(uploadable);
this._view.removeUploadable(uploadable); let savePromise = post.save(uploadable.anonymous)
return Promise.resolve(); .then(() => {
}).catch(errorMessage => { this._view.removeUploadable(uploadable);
// XXX: return Promise.resolve();
// lame, API eats error codes so we need to match });
// messages instead this._lastCancellablePromise = savePromise;
if (e.detail.skipDuplicates && return savePromise;
errorMessage.match(/already uploaded/)) { }).then(result => {
return Promise.resolve(); progress.done();
} return Promise.resolve(result);
return Promise.reject(errorMessage); }, error => {
}); error.uploadable = uploadable;
}); progress.done();
}, Promise.resolve()) return Promise.reject(error);
});
}
.then(() => { _uploadableToPost(uploadable) {
misc.disableExitConfirmation(); let post = new Post();
const ctx = router.show('/posts'); post.safety = uploadable.safety;
ctx.controller.showSuccess('Posts uploaded.'); post.flags = uploadable.flags;
}, errorMessage => { post.tags = uploadable.tags;
this._view.showError(errorMessage); post.relations = uploadable.relations;
this._view.enableForm(); post.newContent = uploadable.url || uploadable.file;
return Promise.reject(); return post;
});
} }
} }

View File

@ -29,9 +29,9 @@ class TagCategoriesController {
canSetDefault: api.hasPrivilege('tagCategories:setDefault'), canSetDefault: api.hasPrivilege('tagCategories:setDefault'),
}); });
this._view.addEventListener('submit', e => this._evtSubmit(e)); this._view.addEventListener('submit', e => this._evtSubmit(e));
}, errorMessage => { }, error => {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError(errorMessage); this._view.showError(error.message);
}); });
} }
@ -43,9 +43,9 @@ class TagCategoriesController {
tags.refreshExport(); tags.refreshExport();
this._view.enableForm(); this._view.enableForm();
this._view.showSuccess('Changes saved.'); this._view.showSuccess('Changes saved.');
}, errorMessage => { }, error => {
this._view.enableForm(); 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('submit', e => this._evtUpdate(e));
this._view.addEventListener('merge', e => this._evtMerge(e)); this._view.addEventListener('merge', e => this._evtMerge(e));
this._view.addEventListener('delete', e => this._evtDelete(e)); this._view.addEventListener('delete', e => this._evtDelete(e));
}, errorMessage => { }, error => {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError(errorMessage); this._view.showError(error.message);
}); });
} }
@ -86,8 +86,8 @@ class TagController {
e.detail.tag.save().then(() => { e.detail.tag.save().then(() => {
this._view.showSuccess('Tag saved.'); this._view.showSuccess('Tag saved.');
this._view.enableForm(); this._view.enableForm();
}, errorMessage => { }, error => {
this._view.showError(errorMessage); this._view.showError(error.message);
this._view.enableForm(); this._view.enableForm();
}); });
} }
@ -100,8 +100,8 @@ class TagController {
this._view.enableForm(); this._view.enableForm();
router.replace( router.replace(
'/tag/' + e.detail.targetTagName + '/merge', null, false); '/tag/' + e.detail.targetTagName + '/merge', null, false);
}, errorMessage => { }, error => {
this._view.showError(errorMessage); this._view.showError(error.message);
this._view.enableForm(); this._view.enableForm();
}); });
} }
@ -113,8 +113,8 @@ class TagController {
.then(() => { .then(() => {
const ctx = router.show('/tags/'); const ctx = router.show('/tags/');
ctx.controller.showSuccess('Tag deleted.'); ctx.controller.showSuccess('Tag deleted.');
}, errorMessage => { }, error => {
this._view.showError(errorMessage); this._view.showError(error.message);
this._view.enableForm(); this._view.enableForm();
}); });
} }

View File

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

View File

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

View File

@ -1,55 +1,87 @@
'use strict'; 'use strict';
const api = require('../api.js'); const api = require('../api.js');
const misc = require('../util/misc.js');
const events = require('../events.js'); const events = require('../events.js');
const views = require('../util/views.js'); const views = require('../util/views.js');
const CommentFormControl = require('../controls/comment_form_control.js');
const template = views.getTemplate('comment'); const template = views.getTemplate('comment');
const scoreTemplate = views.getTemplate('score'); const scoreTemplate = views.getTemplate('score');
class CommentControl extends events.EventTarget { class CommentControl extends events.EventTarget {
constructor(hostNode, comment) { constructor(hostNode, comment, onlyEditing) {
super(); super();
this._hostNode = hostNode; this._hostNode = hostNode;
this._comment = comment; this._comment = comment;
this._onlyEditing = onlyEditing;
comment.addEventListener('change', e => this._evtChange(e)); if (comment) {
comment.addEventListener('changeScore', e => this._evtChangeScore(e)); 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'; const infix = isLoggedIn ? 'own' : 'any';
views.replaceContent(this._hostNode, template({ views.replaceContent(this._hostNode, template({
comment: this._comment, comment: comment,
user: comment ? comment.user : api.user,
canViewUsers: api.hasPrivilege('users:view'), canViewUsers: api.hasPrivilege('users:view'),
canEditComment: api.hasPrivilege(`comments:edit:${infix}`), canEditComment: api.hasPrivilege(`comments:edit:${infix}`),
canDeleteComment: api.hasPrivilege(`comments:delete:${infix}`), canDeleteComment: api.hasPrivilege(`comments:delete:${infix}`),
onlyEditing: onlyEditing,
})); }));
if (this._editButtonNode) { if (this._editButtonNodes) {
this._editButtonNode.addEventListener( for (let node of this._editButtonNodes) {
'click', e => this._evtEditClick(e)); node.addEventListener('click', e => this._evtEditClick(e));
}
} }
if (this._deleteButtonNode) { if (this._deleteButtonNode) {
this._deleteButtonNode.addEventListener( this._deleteButtonNode.addEventListener(
'click', e => this._evtDeleteClick(e)); 'click', e => this._evtDeleteClick(e));
} }
this._formControl = new CommentFormControl( if (this._previewEditingButtonNode) {
this._hostNode.querySelector('.comment-form-container'), this._previewEditingButtonNode.addEventListener(
this._comment, 'click', e => this._evtPreviewEditingClick(e));
true); }
events.proxyEvent(this._formControl, this, 'submit');
if (this._saveChangesButtonNode) {
this._saveChangesButtonNode.addEventListener(
'click', e => this._evtSaveChangesClick(e));
}
if (this._cancelEditingButtonNode) {
this._cancelEditingButtonNode.addEventListener(
'click', e => this._evtCancelEditingClick(e));
}
this._installScore(); 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() { get _scoreContainerNode() {
return this._hostNode.querySelector('.score-container'); return this._hostNode.querySelector('.score-container');
} }
get _editButtonNode() { get _editButtonNodes() {
return this._hostNode.querySelector('.edit'); return this._hostNode.querySelectorAll('li.edit>a, a.edit');
}
get _previewEditingButtonNode() {
return this._hostNode.querySelector('li.preview>a');
} }
get _deleteButtonNode() { get _deleteButtonNode() {
@ -64,12 +96,32 @@ class CommentControl extends events.EventTarget {
return this._hostNode.querySelector('.downvote'); 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() { _installScore() {
views.replaceContent( views.replaceContent(
this._scoreContainerNode, this._scoreContainerNode,
scoreTemplate({ scoreTemplate({
score: this._comment.score, score: this._comment ? this._comment.score : 0,
ownScore: this._comment.ownScore, ownScore: this._comment ? this._comment.ownScore : 0,
canScore: api.hasPrivilege('comments:score'), 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) { _evtEditClick(e) {
e.preventDefault(); e.preventDefault();
this._formControl.enterEditMode(); this.enterEditMode();
} }
_evtScoreClick(e, score) { _evtScoreClick(e, score) {
@ -114,12 +197,69 @@ class CommentControl extends events.EventTarget {
} }
_evtChange(e) { _evtChange(e) {
this._formControl.exitEditMode(); this.exitEditMode();
} }
_evtChangeScore(e) { _evtChangeScore(e) {
this._installScore(); 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; 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) { _installCommentNode(comment) {
const commentListItemNode = document.createElement('li'); const commentListItemNode = document.createElement('li');
const commentControl = new CommentControl( const commentControl = new CommentControl(
commentListItemNode, comment); commentListItemNode, comment, false);
events.proxyEvent(commentControl, this, 'submit'); events.proxyEvent(commentControl, this, 'submit');
events.proxyEvent(commentControl, this, 'score'); events.proxyEvent(commentControl, this, 'score');
events.proxyEvent(commentControl, this, 'delete'); events.proxyEvent(commentControl, this, 'delete');

View File

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

View File

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

View File

@ -58,7 +58,7 @@ const api = require('./api.js');
tags.refreshExport(); // we don't care about errors tags.refreshExport(); // we don't care about errors
api.loginFromCookies().then(() => { api.loginFromCookies().then(() => {
router.start(); router.start();
}, errorMessage => { }, error => {
if (window.location.href.indexOf('login') !== -1) { if (window.location.href.indexOf('login') !== -1) {
api.forget(); api.forget();
router.start(); router.start();
@ -66,6 +66,6 @@ api.loginFromCookies().then(() => {
const ctx = router.start('/'); const ctx = router.start('/');
ctx.controller.showError( ctx.controller.showError(
'An error happened while trying to log you in: ' + '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 id() { return this._id; }
get postId() { return this._postId; } get postId() { return this._postId; }
get text() { return this._text; } get text() { return this._text || ''; }
get user() { return this._user; } get user() { return this._user; }
get creationTime() { return this._creationTime; } get creationTime() { return this._creationTime; }
get lastEditTime() { return this._lastEditTime; } get lastEditTime() { return this._lastEditTime; }
@ -50,8 +50,6 @@ class Comment extends events.EventTarget {
}, },
})); }));
return Promise.resolve(); return Promise.resolve();
}, response => {
return Promise.reject(response.description);
}); });
} }
@ -66,8 +64,6 @@ class Comment extends events.EventTarget {
}, },
})); }));
return Promise.resolve(); return Promise.resolve();
}, response => {
return Promise.reject(response.description);
}); });
} }
@ -81,8 +77,6 @@ class Comment extends events.EventTarget {
}, },
})); }));
return Promise.resolve(); return Promise.resolve();
}, response => {
return Promise.reject(response.description);
}); });
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -61,9 +61,6 @@ class TagCategoryList extends AbstractList {
.then(response => { .then(response => {
this._deletedCategories = []; this._deletedCategories = [];
return Promise.resolve(); 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)) return api.get('/user/' + encodeURIComponent(name))
.then(response => { .then(response => {
return Promise.resolve(User.fromResponse(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(); return Promise.resolve();
}, response => {
return Promise.reject(response.description);
}); });
} }
@ -105,8 +101,6 @@ class User extends events.EventTarget {
}, },
})); }));
return Promise.resolve(); return Promise.resolve();
}, response => {
return Promise.reject(response.description);
}); });
} }

View File

@ -1,6 +1,7 @@
'use strict'; 'use strict';
const marked = require('marked'); const marked = require('marked');
const config = require('../config.js');
class BaseMarkdownWrapper { class BaseMarkdownWrapper {
preprocess(text) { preprocess(text) {
@ -62,6 +63,17 @@ class TagPermalinkFixWrapper extends BaseMarkdownWrapper {
//post, user and tags permalinks //post, user and tags permalinks
class EntityPermalinkWrapper extends BaseMarkdownWrapper { class EntityPermalinkWrapper extends BaseMarkdownWrapper {
preprocess(text) { 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( text = text.replace(
/(^|^\(|(?:[^\]])\(|[\s<>\[\]\)])([+#@][a-zA-Z0-9_-]+)/g, /(^|^\(|(?:[^\]])\(|[\s<>\[\]\)])([+#@][a-zA-Z0-9_-]+)/g,
'$1[$2]($2)'); '$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(); 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 = { const options = {
renderer: renderer, renderer: renderer,
breaks: true, breaks: true,
@ -133,7 +174,7 @@ function formatMarkdown(text) {
} }
function formatInlineMarkdown(text) { function formatInlineMarkdown(text) {
const renderer = new marked.Renderer(); const renderer = createRenderer();
const options = { const options = {
renderer: renderer, renderer: renderer,
breaks: true, breaks: true,

View File

@ -217,6 +217,19 @@ function escapeSearchTerm(text) {
return text.replace(/([a-z_-]):/g, '$1\\:'); 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 = { module.exports = {
range: range, range: range,
formatUrlParameters: formatUrlParameters, formatUrlParameters: formatUrlParameters,
@ -236,4 +249,5 @@ module.exports = {
arraysDiffer: arraysDiffer, arraysDiffer: arraysDiffer,
decamelize: decamelize, decamelize: decamelize,
escapeSearchTerm: escapeSearchTerm, 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 :
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 = {};
} }
attrs.for = options.id; attrs.for = options.id;
return makeNonVoidElement('label', attrs, options.text); return makeElement('label', attrs, options.text);
} }
function makeFileSize(fileSize) { function makeFileSize(fileSize) {
@ -33,30 +33,25 @@ function makeMarkdown(text) {
} }
function makeRelativeTime(time) { function makeRelativeTime(time) {
return makeNonVoidElement( return makeElement(
'time', 'time', {datetime: time, title: time}, misc.formatRelativeTime(time));
{datetime: time, title: time},
misc.formatRelativeTime(time));
} }
function makeThumbnail(url) { function makeThumbnail(url) {
return makeNonVoidElement( return makeElement(
'span', 'span',
url ? url ?
{ {class: 'thumbnail', style: `background-image: url(\'${url}\')`} :
class: 'thumbnail',
style: `background-image: url(\'${url}\')`,
} :
{class: 'thumbnail empty'}, {class: 'thumbnail empty'},
makeVoidElement('img', {alt: 'thumbnail', src: url})); makeElement('img', {alt: 'thumbnail', src: url}));
} }
function makeRadio(options) { function makeRadio(options) {
_imbueId(options); _imbueId(options);
return makeNonVoidElement( return makeElement(
'label', 'label',
{for: options.id}, {for: options.id},
makeVoidElement( makeElement(
'input', 'input',
{ {
id: options.id, id: options.id,
@ -66,16 +61,16 @@ function makeRadio(options) {
checked: options.selectedValue === options.value, checked: options.selectedValue === options.value,
disabled: options.readonly, disabled: options.readonly,
required: options.required, required: options.required,
}) + }),
makeNonVoidElement('span', {class: 'radio'}, options.text)); makeElement('span', {class: 'radio'}, options.text));
} }
function makeCheckbox(options) { function makeCheckbox(options) {
_imbueId(options); _imbueId(options);
return makeNonVoidElement( return makeElement(
'label', 'label',
{for: options.id}, {for: options.id},
makeVoidElement( makeElement(
'input', 'input',
{ {
id: options.id, id: options.id,
@ -86,30 +81,29 @@ function makeCheckbox(options) {
options.checked : false, options.checked : false,
disabled: options.readonly, disabled: options.readonly,
required: options.required, required: options.required,
}) + }),
makeNonVoidElement('span', {class: 'checkbox'}, options.text)); makeElement('span', {class: 'checkbox'}, options.text));
} }
function makeSelect(options) { function makeSelect(options) {
return _makeLabel(options) + return _makeLabel(options) +
makeNonVoidElement( makeElement(
'select', 'select',
{ {
id: options.id, id: options.id,
name: options.name, name: options.name,
disabled: options.readonly, disabled: options.readonly,
}, },
Object.keys(options.keyValues).map(key => { ...Object.keys(options.keyValues).map(key =>
return makeNonVoidElement( makeElement(
'option', 'option',
{value: key, selected: key === options.selectedKey}, {value: key, selected: key === options.selectedKey},
options.keyValues[key]); options.keyValues[key])));
}).join(''));
} }
function makeInput(options) { function makeInput(options) {
options.value = options.value || ''; options.value = options.value || '';
return _makeLabel(options) + makeVoidElement('input', options); return _makeLabel(options) + makeElement('input', options);
} }
function makeButton(options) { function makeButton(options) {
@ -125,7 +119,7 @@ function makeTextInput(options) {
function makeTextarea(options) { function makeTextarea(options) {
const value = options.value || ''; const value = options.value || '';
delete options.value; delete options.value;
return _makeLabel(options) + makeNonVoidElement('textarea', options, value); return _makeLabel(options) + makeElement('textarea', options, value);
} }
function makePasswordInput(options) { function makePasswordInput(options) {
@ -139,7 +133,7 @@ function makeEmailInput(options) {
} }
function makeColorInput(options) { function makeColorInput(options) {
const textInput = makeVoidElement( const textInput = makeElement(
'input', { 'input', {
type: 'text', type: 'text',
value: options.value || '', value: options.value || '',
@ -147,13 +141,9 @@ function makeColorInput(options) {
style: 'color: ' + options.value, style: 'color: ' + options.value,
disabled: true, disabled: true,
}); });
const colorInput = makeVoidElement( const colorInput = makeElement(
'input', { 'input', {type: 'color', value: options.value || ''});
type: 'color', return makeElement('label', {class: 'color'}, colorInput, textInput);
value: options.value || '',
});
return makeNonVoidElement(
'label', {class: 'color'}, colorInput + textInput);
} }
function makeNumericInput(options) { function makeNumericInput(options) {
@ -183,7 +173,7 @@ function makePostLink(id, includeHash) {
text = '@' + id; text = '@' + id;
} }
return api.hasPrivilege('posts:view') ? return api.hasPrivilege('posts:view') ?
makeNonVoidElement( makeElement(
'a', 'a',
{'href': '/post/' + encodeURIComponent(id)}, {'href': '/post/' + encodeURIComponent(id)},
misc.escapeHtml(text)) : misc.escapeHtml(text)) :
@ -198,14 +188,14 @@ function makeTagLink(name, includeHash) {
text = '#' + text; text = '#' + text;
} }
return api.hasPrivilege('tags:view') ? return api.hasPrivilege('tags:view') ?
makeNonVoidElement( makeElement(
'a', 'a',
{ {
'href': '/tag/' + encodeURIComponent(name), 'href': '/tag/' + encodeURIComponent(name),
'class': misc.makeCssName(category, 'tag'), 'class': misc.makeCssName(category, 'tag'),
}, },
misc.escapeHtml(text)) : misc.escapeHtml(text)) :
makeNonVoidElement( makeElement(
'span', 'span',
{'class': misc.makeCssName(category, 'tag')}, {'class': misc.makeCssName(category, 'tag')},
misc.escapeHtml(text)); misc.escapeHtml(text));
@ -215,12 +205,10 @@ function makeUserLink(user) {
let text = makeThumbnail(user ? user.avatarUrl : null); let text = makeThumbnail(user ? user.avatarUrl : null);
text += user && user.name ? misc.escapeHtml(user.name) : 'Anonymous'; text += user && user.name ? misc.escapeHtml(user.name) : 'Anonymous';
const link = user && api.hasPrivilege('users:view') ? const link = user && api.hasPrivilege('users:view') ?
makeNonVoidElement( makeElement(
'a', 'a', {'href': '/user/' + encodeURIComponent(user.name)}, text) :
{'href': '/user/' + encodeURIComponent(user.name)},
text) :
text; text;
return makeNonVoidElement('span', {class: 'user'}, link); return makeElement('span', {class: 'user'}, link);
} }
function makeFlexboxAlign(options) { function makeFlexboxAlign(options) {
@ -250,12 +238,10 @@ function _serializeElement(name, attributes) {
.join(' '); .join(' ');
} }
function makeNonVoidElement(name, attributes, content) { function makeElement(name, attrs, ...content) {
return `<${_serializeElement(name, attributes)}>${content}</${name}>`; return content.length !== undefined ?
} `<${_serializeElement(name, attrs)}>${content.join('')}</${name}>` :
`<${_serializeElement(name, attrs)}/>`;
function makeVoidElement(name, attributes) {
return `<${_serializeElement(name, attributes)}/>`;
} }
function emptyContent(target) { function emptyContent(target) {
@ -281,25 +267,30 @@ function showMessage(target, message, className) {
if (!message) { if (!message) {
message = 'Unknown message'; message = 'Unknown message';
} }
const messagesHolder = target.querySelector('.messages'); const messagesHolderNode = target.querySelector('.messages');
if (!messagesHolder) { if (!messagesHolderNode) {
return false; return false;
} }
/* TODO: animate this */ const textNode = document.createElement('div');
const node = document.createElement('div'); textNode.innerHTML = message.replace(/\n/g, '<br/>');
node.innerHTML = message.replace(/\n/g, '<br/>'); textNode.classList.add('message');
node.classList.add('message'); textNode.classList.add(className);
node.classList.add(className); const wrapperNode = document.createElement('div');
const wrapper = document.createElement('div'); wrapperNode.classList.add('message-wrapper');
wrapper.classList.add('message-wrapper'); wrapperNode.appendChild(textNode);
wrapper.appendChild(node); messagesHolderNode.appendChild(wrapperNode);
messagesHolder.appendChild(wrapper);
return true; return true;
} }
function appendExclamationMark() {
if (!document.title.startsWith('!')) {
document.oldTitle = document.title;
document.title = `! ${document.title}`;
}
}
function showError(target, message) { function showError(target, message) {
document.oldTitle = document.title; appendExclamationMark();
document.title = `! ${document.title}`;
return showMessage(target, misc.formatInlineMarkdown(message), 'error'); return showMessage(target, misc.formatInlineMarkdown(message), 'error');
} }
@ -316,9 +307,9 @@ function clearMessages(target) {
document.title = document.oldTitle; document.title = document.oldTitle;
document.oldTitle = null; document.oldTitle = null;
} }
const messagesHolder = target.querySelector('.messages'); for (let messagesHolderNode of target.querySelectorAll('.messages')) {
/* TODO: animate that */ emptyContent(messagesHolderNode);
emptyContent(messagesHolder); }
} }
function htmlToDom(html) { function htmlToDom(html) {
@ -391,6 +382,7 @@ function getTemplate(templatePath) {
makeUserLink: makeUserLink, makeUserLink: makeUserLink,
makeFlexboxAlign: makeFlexboxAlign, makeFlexboxAlign: makeFlexboxAlign,
makeAccessKey: makeAccessKey, makeAccessKey: makeAccessKey,
makeElement: makeElement,
makeCssName: misc.makeCssName, makeCssName: misc.makeCssName,
makeNumericInput: makeNumericInput, makeNumericInput: makeNumericInput,
}); });
@ -504,25 +496,24 @@ document.addEventListener('click', e => {
}); });
module.exports = { module.exports = {
htmlToDom: htmlToDom, htmlToDom: htmlToDom,
getTemplate: getTemplate, getTemplate: getTemplate,
emptyContent: emptyContent, emptyContent: emptyContent,
replaceContent: replaceContent, replaceContent: replaceContent,
enableForm: enableForm, enableForm: enableForm,
disableForm: disableForm, disableForm: disableForm,
decorateValidator: decorateValidator, decorateValidator: decorateValidator,
makeVoidElement: makeVoidElement, makeTagLink: makeTagLink,
makeNonVoidElement: makeNonVoidElement, makePostLink: makePostLink,
makeTagLink: makeTagLink, makeCheckbox: makeCheckbox,
makePostLink: makePostLink, makeRadio: makeRadio,
makeCheckbox: makeCheckbox, syncScrollPosition: syncScrollPosition,
makeRadio: makeRadio, slideDown: slideDown,
syncScrollPosition: syncScrollPosition, slideUp: slideUp,
slideDown: slideDown, monitorNodeRemoval: monitorNodeRemoval,
slideUp: slideUp, clearMessages: clearMessages,
monitorNodeRemoval: monitorNodeRemoval, appendExclamationMark: appendExclamationMark,
clearMessages: clearMessages, showError: showError,
showError: showError, showSuccess: showSuccess,
showSuccess: showSuccess, showInfo: showInfo,
showInfo: showInfo,
}; };

View File

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

View File

@ -10,8 +10,8 @@ const PostReadonlySidebarControl =
require('../controls/post_readonly_sidebar_control.js'); require('../controls/post_readonly_sidebar_control.js');
const PostEditSidebarControl = const PostEditSidebarControl =
require('../controls/post_edit_sidebar_control.js'); require('../controls/post_edit_sidebar_control.js');
const CommentControl = require('../controls/comment_control.js');
const CommentListControl = require('../controls/comment_list_control.js'); const CommentListControl = require('../controls/comment_list_control.js');
const CommentFormControl = require('../controls/comment_form_control.js');
const template = views.getTemplate('post-main'); const template = views.getTemplate('post-main');
@ -101,9 +101,8 @@ class PostMainView {
return; return;
} }
this.commentFormControl = new CommentFormControl( this.commentControl = new CommentControl(
commentFormContainer, null, false, 150); commentFormContainer, null, true);
this.commentFormControl.enterEditMode();
} }
_installComments(comments) { _installComments(comments) {

View File

@ -53,25 +53,28 @@ class PostMergeView extends events.EventTarget {
} }
_refreshLeftSide() { _refreshLeftSide() {
views.replaceContent( this._refreshSide(this._leftPost, this._leftSideNode, 'left', false);
this._leftSideNode,
sideTemplate(Object.assign({}, this._ctx, {
post: this._leftPost,
name: 'left',
editable: false})));
} }
_refreshRightSide() { _refreshRightSide() {
views.replaceContent( this._refreshSide(this._rightPost, this._rightSideNode, 'right', true);
this._rightSideNode, }
sideTemplate(Object.assign({}, this._ctx, {
post: this._rightPost,
name: 'right',
editable: true})));
if (this._targetPostFieldNode) { _refreshSide(post, sideNode, sideName, isEditable) {
this._targetPostFieldNode.addEventListener( views.replaceContent(
'keydown', e => this._evtTargetPostFieldKeyDown(e)); 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));
} }
} }
@ -94,7 +97,7 @@ class PostMergeView extends events.EventTarget {
})); }));
} }
_evtTargetPostFieldKeyDown(e) { _evtPostSearchFieldKeyDown(e) {
const key = e.which; const key = e.which;
if (key !== KEY_RETURN) { if (key !== KEY_RETURN) {
return; return;
@ -103,7 +106,17 @@ class PostMergeView extends events.EventTarget {
e.preventDefault(); e.preventDefault();
this.dispatchEvent(new CustomEvent('select', { this.dispatchEvent(new CustomEvent('select', {
detail: { detail: {
postId: this._targetPostFieldNode.value, postId: e.target.value,
},
}));
}
_evtPostSearchButtonClick(e, textNode) {
e.target.blur();
e.preventDefault();
this.dispatchEvent(new CustomEvent('select', {
detail: {
postId: textNode.value,
}, },
})); }));
} }
@ -119,11 +132,6 @@ class PostMergeView extends events.EventTarget {
get _rightSideNode() { get _rightSideNode() {
return this._hostNode.querySelector('.right-post-container'); return this._hostNode.querySelector('.right-post-container');
} }
get _targetPostFieldNode() {
return this._formNode.querySelector(
'.post-mirror input:not([readonly])[type=text]');
}
} }
module.exports = PostMergeView; module.exports = PostMergeView;

View File

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

View File

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

View File

@ -3,7 +3,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build": "node build.js", "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": { "dependencies": {
"babel-polyfill": "^6.7.4", "babel-polyfill": "^6.7.4",
@ -27,8 +27,5 @@
"superagent": "^1.8.3", "superagent": "^1.8.3",
"uglify-js": "git://github.com/mishoo/UglifyJS2.git#harmony", "uglify-js": "git://github.com/mishoo/UglifyJS2.git#harmony",
"underscore": "^1.8.3" "underscore": "^1.8.3"
},
"devDependencies": {
"watch": "latest"
} }
} }

View File

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

View File

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

View File

@ -7,3 +7,7 @@ pytest-cov>=2.2.1
freezegun>=0.3.6 freezegun>=0.3.6
coloredlogs==5.0 coloredlogs==5.0
pycodestyle>=2.0.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.comment_api
import szurubooru.api.password_reset_api import szurubooru.api.password_reset_api
import szurubooru.api.snapshot_api import szurubooru.api.snapshot_api
import szurubooru.api.upload_api

View File

@ -205,3 +205,21 @@ def get_posts_around(ctx, params):
_search_executor.config.user = ctx.user _search_executor.config.user = ctx.user
return _search_executor.get_around_and_serialize( return _search_executor.get_around_and_serialize(
ctx, params['post_id'], lambda post: _serialize_post(ctx, post)) 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 pass
class MissingOrExpiredRequiredFileError(MissingRequiredFileError):
pass
class MissingRequiredParameterError(ValidationError): class MissingRequiredParameterError(ValidationError):
pass pass

View File

@ -1,10 +1,13 @@
''' Exports create_app. ''' ''' Exports create_app. '''
import os import os
import time
import logging import logging
import threading
import coloredlogs import coloredlogs
import sqlalchemy.orm.exc import sqlalchemy.orm.exc
from szurubooru import config, errors, rest from szurubooru import config, errors, rest
from szurubooru.func import posts, file_uploads
# pylint: disable=unused-import # pylint: disable=unused-import
from szurubooru import api, middleware from szurubooru import api, middleware
@ -78,6 +81,15 @@ def validate_config():
raise errors.ConfigError('Database is not configured') 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(): def create_app():
''' Create a WSGI compatible App object. ''' ''' Create a WSGI compatible App object. '''
validate_config() validate_config()
@ -87,6 +99,11 @@ def create_app():
if config.config['show_sql']: if config.config['show_sql']:
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) 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.AuthError, _on_auth_error)
rest.errors.handle(errors.ValidationError, _on_validation_error) rest.errors.handle(errors.ValidationError, _on_validation_error)
rest.errors.handle(errors.SearchError, _on_search_error) rest.errors.handle(errors.SearchError, _on_search_error)

View File

@ -1,6 +1,5 @@
import datetime import datetime
from szurubooru import db, errors from szurubooru import db, errors
from szurubooru.func import scores
class InvalidFavoriteTargetError(errors.ValidationError): class InvalidFavoriteTargetError(errors.ValidationError):
@ -36,6 +35,7 @@ def unset_favorite(entity, user):
def set_favorite(entity, user): def set_favorite(entity, user):
from szurubooru.func import scores
assert entity assert entity
assert user assert user
try: 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)) 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): def move(source_path, target_path):
return os.rename(_get_full_path(source_path), _get_full_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

@ -2,7 +2,7 @@ import datetime
import sqlalchemy import sqlalchemy
from szurubooru import config, db, errors from szurubooru import config, db, errors
from szurubooru.func import ( from szurubooru.func import (
users, scores, comments, tags, util, mime, images, files) users, scores, comments, tags, util, mime, images, files, image_hash)
EMPTY_PIXEL = \ EMPTY_PIXEL = \
@ -57,6 +57,12 @@ class InvalidPostFlagError(errors.ValidationError):
pass pass
class PostLookalike(image_hash.Lookalike):
def __init__(self, score, distance, post):
super().__init__(score, distance, post.post_id)
self.post = post
SAFETY_MAP = { SAFETY_MAP = {
db.Post.SAFETY_SAFE: 'safe', db.Post.SAFETY_SAFE: 'safe',
db.Post.SAFETY_SKETCHY: 'sketchy', db.Post.SAFETY_SKETCHY: 'sketchy',
@ -260,13 +266,22 @@ def _after_post_update(_mapper, _connection, post):
_sync_post_content(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): def _sync_post_content(post):
regenerate_thumb = False regenerate_thumb = False
if hasattr(post, '__content'): 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') delattr(post, '__content')
regenerate_thumb = True 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 hasattr(post, '__thumbnail'):
if getattr(post, '__thumbnail'): if getattr(post, '__thumbnail'):
@ -368,6 +383,8 @@ def update_post_relations(post, new_post_ids):
.all() .all()
if len(new_posts) != len(new_post_ids): if len(new_posts) != len(new_post_ids):
raise InvalidPostRelationError('One of relations does not exist.') 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_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] relations_to_add = [p for p in new_posts if p.post_id not in old_post_ids]
@ -521,3 +538,42 @@ def merge_posts(source_post, target_post, replace_content):
if replace_content: if replace_content:
content = files.get(get_post_content_path(source_post)) content = files.get(get_post_content_path(source_post))
update_post_content(target_post, content) 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 import datetime
from szurubooru import db, errors from szurubooru import db, errors
from szurubooru.func import favorites
class InvalidScoreTargetError(errors.ValidationError): class InvalidScoreTargetError(errors.ValidationError):
@ -47,6 +46,7 @@ def get_score(entity, user):
def set_score(entity, user, score): def set_score(entity, user, score):
from szurubooru.func import favorites
assert entity assert entity
assert user assert user
if not score: if not score:

View File

@ -104,7 +104,10 @@ def export_to_json():
'color': result[2], '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: if not result[0] in tags:
tags[result[0]] = {'names': []} tags[result[0]] = {'names': []}
tags[result[0]]['names'].append(result[1]) tags[result[0]]['names'].append(result[1])

View File

@ -32,7 +32,7 @@ def get_serialization_options(ctx):
def serialize_entity(entity, field_factories, options): def serialize_entity(entity, field_factories, options):
if not entity: if not entity:
return None return None
if not options: if not options or len(options) == 0:
options = field_factories.keys() options = field_factories.keys()
ret = {} ret = {}
for key in options: for key in options:
@ -162,3 +162,8 @@ def value_exceeds_column_size(value, column):
if max_length is None: if max_length is None:
return False return False
return len(value) > max_length 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. ''' ''' Various hooks that get executed for each request. '''
import szurubooru.middleware.db_session
import szurubooru.middleware.authenticator import szurubooru.middleware.authenticator
import szurubooru.middleware.cache_purger import szurubooru.middleware.cache_purger
import szurubooru.middleware.request_logger 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(): with alembic.context.begin_transaction():
alembic.context.run_migrations() alembic.context.run_migrations()
if alembic.context.is_offline_mode(): if alembic.context.is_offline_mode():
run_migrations_offline() run_migrations_offline()
else: else:

View File

@ -3,6 +3,7 @@ import cgi
import json import json
import re import re
from datetime import datetime from datetime import datetime
from szurubooru import db
from szurubooru.func import util from szurubooru.func import util
from szurubooru.rest import errors, middleware, routes, context from szurubooru.rest import errors, middleware, routes, context
@ -65,37 +66,41 @@ def _create_context(env):
def application(env, start_response): def application(env, start_response):
try: try:
try: ctx = _create_context(env)
ctx = _create_context(env) if 'application/json' not in ctx.get_header('Accept'):
if 'application/json' not in ctx.get_header('Accept'): raise errors.HttpNotAcceptable(
raise errors.HttpNotAcceptable( 'ValidationError',
'ValidationError', 'This API only supports JSON responses.')
'This API only supports JSON responses.')
for url, allowed_methods in routes.routes.items(): for url, allowed_methods in routes.routes.items():
match = re.fullmatch(url, ctx.url) match = re.fullmatch(url, ctx.url)
if not match: if match:
continue
if ctx.method not in allowed_methods: if ctx.method not in allowed_methods:
raise errors.HttpMethodNotAllowed( raise errors.HttpMethodNotAllowed(
'ValidationError', 'ValidationError',
'Allowed methods: %r' % allowed_methods) '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: for hook in middleware.pre_hooks:
hook(ctx) hook(ctx)
handler = allowed_methods[ctx.method]
try: try:
response = handler(ctx, match.groupdict()) response = handler(ctx, match.groupdict())
finally: finally:
for hook in middleware.post_hooks: for hook in middleware.post_hooks:
hook(ctx) hook(ctx)
finally:
db.session.remove()
start_response('200', [('content-type', 'application/json')]) start_response('200', [('content-type', 'application/json')])
return (_dump_json(response).encode('utf-8'),) return (_dump_json(response).encode('utf-8'),)
raise errors.HttpNotFound(
'ValidationError',
'Requested path ' + ctx.url + ' was not found.')
except Exception as ex: except Exception as ex:
for exception_type, handler in errors.error_handlers.items(): for exception_type, handler in errors.error_handlers.items():

View File

@ -1,5 +1,5 @@
from szurubooru import errors from szurubooru import errors
from szurubooru.func import net from szurubooru.func import net, file_uploads
def _lower_first(source): def _lower_first(source):
@ -43,18 +43,26 @@ class Context:
def get_header(self, name): def get_header(self, name):
return self._headers.get(name, None) return self._headers.get(name, None)
def has_file(self, name): def has_file(self, name, allow_tokens=True):
return name in self._files or name + 'Url' in self._params return (name in self._files
or name + 'Url' in self._params
or (allow_tokens and name + 'Token' in self._params))
def get_file(self, name, required=False): def get_file(self, name, required=False, allow_tokens=True):
ret = None
if name in self._files: if name in self._files:
return self._files[name] ret = self._files[name]
if name + 'Url' in self._params: elif name + 'Url' in self._params:
return net.download(self._params[name + 'Url']) ret = net.download(self._params[name + 'Url'])
if not required: elif allow_tokens and name + 'Token' in self._params:
return None ret = file_uploads.get(self._params[name + 'Token'])
raise errors.MissingRequiredFileError( if required and not ret:
'Required file %r is missing.' % name) raise errors.MissingOrExpiredRequiredFileError(
'Required file %r is missing or has expired.' % name)
if required and not ret:
raise errors.MissingRequiredFileError(
'Required file %r is missing.' % name)
return ret
def has_param(self, name): def has_param(self, name):
return name in self._params return name in self._params

View File

@ -66,7 +66,8 @@ def _create_user_filter():
def wrapper(query, criterion, negated): def wrapper(query, criterion, negated):
if isinstance(criterion, criteria.PlainCriterion) \ if isinstance(criterion, criteria.PlainCriterion) \
and not criterion.value: and not criterion.value:
expr = db.Post.user_id == None # sic # pylint: disable=singleton-comparison
expr = db.Post.user_id == None
if negated: if negated:
expr = ~expr expr = ~expr
return query.filter(expr) return query.filter(expr)

View File

@ -3,6 +3,7 @@ import contextlib
import os import os
import random import random
import string import string
from unittest.mock import patch
from datetime import datetime from datetime import datetime
import pytest import pytest
import freezegun import freezegun
@ -154,6 +155,13 @@ def tag_factory():
return factory return factory
@pytest.yield_fixture(autouse=True)
def skip_post_hashing():
with patch('szurubooru.func.image_hash.add_image'), \
patch('szurubooru.func.image_hash.delete_image'):
yield
@pytest.fixture @pytest.fixture
def post_factory(): def post_factory():
# pylint: disable=invalid-name # pylint: disable=invalid-name

View File

@ -3,7 +3,8 @@ from unittest.mock import patch
from datetime import datetime from datetime import datetime
import pytest import pytest
from szurubooru import db from szurubooru import db
from szurubooru.func import (posts, users, comments, tags, images, files, util) from szurubooru.func import (
posts, users, comments, tags, images, files, util, image_hash)
@pytest.mark.parametrize('input_mime_type,expected_url', [ @pytest.mark.parametrize('input_mime_type,expected_url', [
@ -316,13 +317,20 @@ def test_update_post_content_for_new_post(
else: else:
assert not post.post_id assert not post.post_id
assert not os.path.exists(output_file_path) assert not os.path.exists(output_file_path)
posts.update_post_content(post, read_asset(input_file)) content = read_asset(input_file)
posts.update_post_content(post, content)
assert not os.path.exists(output_file_path) assert not os.path.exists(output_file_path)
db.session.flush() db.session.flush()
assert post.mime_type == expected_mime_type assert post.mime_type == expected_mime_type
assert post.type == expected_type assert post.type == expected_type
assert post.checksum == 'crc' assert post.checksum == 'crc'
assert os.path.exists(output_file_path) assert os.path.exists(output_file_path)
if post.type in (db.Post.TYPE_IMAGE, db.Post.TYPE_ANIMATION):
image_hash.delete_image.assert_called_once_with(post.post_id)
image_hash.add_image.assert_called_once_with(post.post_id, content)
else:
image_hash.delete_image.assert_not_called()
image_hash.add_image.assert_not_called()
def test_update_post_content_to_existing_content( def test_update_post_content_to_existing_content(
@ -533,6 +541,14 @@ def test_update_post_relations_with_nonexisting_posts():
posts.update_post_relations(post, [100]) posts.update_post_relations(post, [100])
def test_update_post_relations_with_itself(post_factory):
post = post_factory()
db.session.add(post)
db.session.flush()
with pytest.raises(posts.InvalidPostRelationError):
posts.update_post_relations(post, [post.post_id])
def test_update_post_notes(): def test_update_post_notes():
post = db.Post() post = db.Post()
posts.update_post_notes( posts.update_post_notes(