Fix .travis.yml to current HEAD

This commit is contained in:
nothink 2018-07-26 02:33:16 +09:00
commit 2334553fba
306 changed files with 12168 additions and 5258 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@ config.yaml
*/*_modules/
.coverage
.cache
docker-compose.yml

View file

@ -9,24 +9,30 @@ matrix:
include:
- language: python
python:
- "3.5"
- "3.6"
before_install:
- sudo apt-get -y install software-properties-common
- sudo add-apt-repository -y ppa:mc3man/trusty-media
- sudo apt-get update
- sudo apt-get -y --allow-unauthenticated install ffmpeg
- cp config.yaml.dist config.yaml
- sed -i -e 's/^database:$/database:\ postgres:\/\/szuru:dog@localhost:5432\/szuru_test/' config.yaml
- sudo -i -u postgres createuser szuru -D -R -S
- sudo -i -u postgres createdb szuru_test
- sudo -i -u postgres psql -c "ALTER USER szuru PASSWORD 'dog';"
- sed -i -e 's/^api_url:/api_url:\ http:\/\/localhost\/api\//' config.yaml
- sed -i -e 's/^base_url:/base_url:\ http:\/\/localhost\//' config.yaml
- sed -i -e 's/^data_url:/data_url:\ http:\/\/localhost\/data\//' config.yaml
- sed -i -e 's/^data_dir:/data_dir:\ \/data\//' config.yaml
install:
- cd server
- cp config.yaml.dist ../config.yaml.dist
- cp config.yaml.dist ../config.yaml
- sed -i -e 's/^#debug:/debug:/' ../config.yaml
- sed -i -e 's/^#show_sql:/show_sql:/' ../config.yaml
- sed -i -e 's/^#data_url:/data_url:/' ../config.yaml
- sed -i -e 's/^#data_dir:/data_dir:/' ../config.yaml
- sed -i -e 's/^#database:$/database:\ postgres:\/\/szuru:dog@localhost:5432\/szuru_test/' ../config.yaml
- sed -i -e 's/^#elasticsearch:/elasticsearch:/' ../config.yaml
- sed -i -e 's/^# / /' ../config.yaml
install:
- pip install -r requirements.txt
- pip install -r dev-requirements.txt
script:
- pycodestyle wait-for-es generate-thumb szurubooru/
- ./wait-for-es
- alembic upgrade head
- py.test

250
API.md
View file

@ -7,6 +7,7 @@
1. [General rules](#general-rules)
- [Authentication](#authentication)
- [User token authentication](#user-token-authentication)
- [Basic requests](#basic-requests)
- [File uploads](#file-uploads)
- [Error handling](#error-handling)
@ -56,6 +57,11 @@
- [Updating user](#updating-user)
- [Getting user](#getting-user)
- [Deleting user](#deleting-user)
- User Tokens
- [Listing user tokens](#listing-user-tokens)
- [Creating user token](#creating-user-token)
- [Updating user token](#updating-user-token)
- [Deleting user token](#deleting-user-token)
- Password reset
- [Password reset - step 1: mail request](#password-reset---step-2-confirmation)
- [Password reset - step 2: confirmation](#password-reset---step-2-confirmation)
@ -70,8 +76,10 @@
- [User](#user)
- [Micro user](#micro-user)
- [User token](#user-token)
- [Tag category](#tag-category)
- [Tag](#tag)
- [Micro tag](#micro-tag)
- [Post](#post)
- [Micro post](#micro-post)
- [Note](#note)
@ -90,7 +98,8 @@
## Authentication
Authentication is achieved by means of [basic HTTP
auth](https://en.wikipedia.org/wiki/Basic_access_authentication). For this
auth](https://en.wikipedia.org/wiki/Basic_access_authentication) or through the
use of [user token authentication](#user-token-authentication). For this
reason, it is recommended to connect through HTTPS. There are no sessions, so
every privileged request must be authenticated. Available privileges depend on
the user's rank. The way how rank translates to privileges is defined in the
@ -100,6 +109,24 @@ It is recommended to add `?bump-login` GET parameter to the first request in a
client "session" (where the definition of a session is up to the client), so
that the user's last login time is kept up to date.
## User token authentication
User token authentication works similarly to [basic HTTP
auth](https://en.wikipedia.org/wiki/Basic_access_authentication). Because it
operates similarly to ***basic HTTP auth*** it is still recommended to connect
through HTTPS. The authorization header uses the type of `Token` and the
username and token are encoded as Base64 and sent as the second parameter.
Example header for user1:token-is-more-secure
```
Authorization: Token dXNlcjE6dG9rZW4taXMtbW9yZS1zZWN1cmU=
```
The benefit of token authentication is that beyond the initial login to acquire
the first token, there is no need to transmit the user password in plaintext
via basic auth. Additionally tokens can be revoked at anytime allowing a
cleaner interface for isolating clients from user credentials.
## Basic requests
Every request must use `Content-Type: application/json` and `Accept:
@ -254,12 +281,6 @@ data.
Lists all tag categories. Doesn't use paging.
**Note**: independently, the server exports current tag category list
snapshots to the data directory under `tags.json` name. Its purpose is to
reduce the trips frontend needs to make when doing autocompletion, and ease
caching. The data directory and its URL are controlled with `data_dir` and
`data_url` variables in server's configuration.
## Creating tag category
- **Request**
@ -404,7 +425,7 @@ data.
## Listing tags
- **Request**
`GET /tags/?page=<page>&pageSize=<page-size>&query=<query>`
`GET /tags/?offset=<initial-pos>&limit=<page-size>&query=<query>`
- **Output**
@ -419,12 +440,6 @@ data.
Searches for tags.
**Note**: independently, the server exports current tag list snapshots to
the data directory under `tags.json` name. Its purpose is to reduce the
trips frontend needs to make when doing autocompletion, and ease caching.
The data directory and its URL are controlled with `data_dir` and
`data_url` variables in server's configuration.
**Anonymous tokens**
Same as `name` token.
@ -432,9 +447,9 @@ data.
**Named tokens**
| `<key>` | Description |
| ------------------- | ------------------------------------- |
| ------------------- | ----------------------------------------- |
| `name` | having given name (accepts wildcards) |
| `category` | having given category |
| `category` | having given category (accepts wildcards) |
| `creation-date` | created at given date |
| `creation-time` | alias of `creation-date` |
| `last-edit-date` | edited at given date |
@ -675,7 +690,7 @@ data.
## Listing posts
- **Request**
`GET /posts/?page=<page>&pageSize=<page-size>&query=<query>`
`GET /posts/?offset=<initial-pos>&limit=<page-size>&query=<query>`
- **Output**
@ -697,19 +712,20 @@ data.
**Named tokens**
| `<key>` | Description |
| ------------------ | ---------------------------------------------------------- |
| -------------------- | ---------------------------------------------------------- |
| `id` | having given post number |
| `tag` | having given tag |
| `tag` | having given tag (accepts wildcards) |
| `score` | having given score |
| `uploader` | uploaded by given user |
| `uploader` | uploaded by given user (accepts wildcards) |
| `upload` | alias of upload |
| `submit` | alias of upload |
| `comment` | commented by given user |
| `fav` | favorited by given user |
| `comment` | commented by given user (accepts wildcards) |
| `fav` | favorited by given user (accepts wildcards) |
| `tag-count` | having given number of tags |
| `comment-count` | having given number of comments |
| `fav-count` | favorited by given number of users |
| `note-count` | having given number of annotations |
| `note-text` | having given note text (accepts wildcards) |
| `relation-count` | having given number of relations |
| `feature-count` | having been featured given number of times |
| `type` | given type of posts. `<value>` can be either `image`, `animation` (or `animated` or `anim`), `flash` (or `swf`) or `video` (or `webm`). |
@ -718,9 +734,13 @@ data.
| `image-width` | having given image width (where applicable) |
| `image-height` | having given image height (where applicable) |
| `image-area` | having given number of pixels (image width * image height) |
| `image-aspect-ratio` | having given aspect ratio (image width / image height) |
| `image-ar` | alias of `image-aspect-ratio` |
| `width` | alias of `image-width` |
| `height` | alias of `image-height` |
| `area` | alias of `image-area` |
| `ar` | alias of `image-aspect-ratio` |
| `aspect-ratio` | alias of `image-aspect-ratio` |
| `creation-date` | posted at given date |
| `creation-time` | alias of `creation-date` |
| `date` | alias of `creation-date` |
@ -1097,7 +1117,7 @@ data.
## Listing comments
- **Request**
`GET /comments/?page=<page>&pageSize=<page-size>&query=<query>`
`GET /comments/?offset=<initial-pos>&limit=<page-size>&query=<query>`
- **Output**
@ -1286,7 +1306,7 @@ data.
## Listing users
- **Request**
`GET /users/?page=<page>&pageSize=<page-size>&query=<query>`
`GET /users/?offset=<initial-pos>&limit=<page-size>&query=<query>`
- **Output**
@ -1475,6 +1495,112 @@ data.
Deletes existing user.
## Listing user tokens
- **Request**
`GET /user-tokens/<user_name>`
- **Output**
An [unpaged search result resource](#unpaged-search-result), for which
`<resource>` is a [user token resource](#user-token).
- **Errors**
- privileges are too low
- **Description**
Searches for user tokens for the given user.
## Creating user token
- **Request**
`POST /user-token/<user_name>`
- **Input**
```json5
{
"enabled": <enabled>, // optional
"note": <note>, // optional
"expirationTime": <expiration-time> // optional
}
```
- **Output**
A [user token resource](#user-token).
- **Errors**
- privileges are too low
- **Description**
Creates a new user token that can be used for authentication of API
endpoints instead of a password.
## Updating user token
- **Request**
`PUT /user-token/<user_name>/<token>`
- **Input**
```json5
{
"version": <version>,
"enabled": <enabled>, // optional
"note": <note>, // optional
"expirationTime": <expiration-time> // optional
}
```
- **Output**
A [user token resource](#user-token).
- **Errors**
- the version is outdated
- the user token does not exist
- privileges are too low
- **Description**
Updates an existing user token using specified parameters. All fields
except the [`version`](#versioning) are optional - update concerns only
provided fields.
## Deleting user token
- **Request**
`DELETE /user-token/<user_name>/<token>`
- **Input**
```json5
{
"version": <version>
}
```
- **Output**
```json5
{}
```
- **Errors**
- the token does not exist
- privileges are too low
- **Description**
Deletes existing user token.
## Password reset - step 1: mail request
- **Request**
@ -1534,7 +1660,7 @@ data.
## Listing snapshots
- **Request**
`GET /snapshots/?page=<page>&pageSize=<page-size>&query=<query>`
`GET /snapshots/?offset=<initial-pos>&limit=<page-size>&query=<query>`
- **Output**
@ -1556,13 +1682,13 @@ data.
**Named tokens**
| `<key>` | Description |
| ----------------- | --------------------------------------------- |
| ----------------- | ---------------------------------------------------------------- |
| `type` | involving given resource type |
| `id` | involving given resource id |
| `date` | created at given date |
| `time` | alias of `date` |
| `operation` | `modified`, `created`, `deleted` or `merged` |
| `user` | name of the user that created given snapshot |
| `user` | name of the user that created given snapshot (accepts wildcards) |
**Sort style tokens**
@ -1707,6 +1833,38 @@ A single user.
A [user resource](#user) stripped down to `name` and `avatarUrl` fields.
## User token
**Description**
A single user token.
**Structure**
```json5
{
"user": <user>,
"token": <token>,
"note": <token>,
"enabled": <enabled>,
"expirationTime": <expiration-time>,
"version": <version>,
"creationTime": <creation-time>,
"lastEditTime": <last-edit-time>,
"lastUsageTime": <last-usage-time>
}
```
**Field meaning**
- `<user>`: micro user. See [micro user](#micro-user).
- `<token>`: the token that can be used to authenticate the user.
- `<note>`: a note that describes the token.
- `<enabled>`: whether the token is still valid for authentication.
- `<expiration-time>`: time when the token expires. It must include the timezone as per RFC 3339.
- `<version>`: resource version. See [versioning](#versioning).
- `<creation-time>`: time the user token was created, formatted as per RFC 3339.
- `<last-edit-time>`: time the user token was edited, formatted as per RFC 3339.
- `<last-usage-time>`: the last time this token was used during a login involving `?bump-login`, formatted as per RFC 3339.
## Tag category
**Description**
@ -1761,16 +1919,23 @@ A single tag. Tags are used to let users search for posts.
- `<names>`: a list of tag names (aliases). Tagging a post with any name will
automatically assign the first name from this list.
- `<category>`: the name of the category the given tag belongs to.
- `<implications>`: a list of implied tag names. Implied tags are automatically
appended by the web client on usage.
- `<suggestions>`: a list of suggested tag names. Suggested tags are shown to
the user by the web client on usage.
- `<implications>`: a list of implied tags, serialized as [micro
tag resource](#micro-tag). Implied tags are automatically appended by the web
client on usage.
- `<suggestions>`: a list of suggested tags, serialized as [micro
tag resource](#micro-tag). Suggested tags are shown to the user by the web
client on usage.
- `<creation-time>`: time the tag was created, formatted as per RFC 3339.
- `<last-edit-time>`: time the tag was edited, formatted as per RFC 3339.
- `<usage-count>`: the number of posts the tag was used in.
- `<description>`: the tag description (instructions how to use, history etc.)
The client should render is as Markdown.
## Micro tag
**Description**
A [tag resource](#tag) stripped down to `names`, `category` and `usages` fields.
## Post
**Description**
@ -1809,12 +1974,12 @@ One file together with its metadata posted to the site.
"lastFeatureTime": <last-feature-time>,
"favoritedBy": <favorited-by>,
"hasCustomThumbnail": <has-custom-thumbnail>,
"mimeType": <mime-type>
"comments": {
"mimeType": <mime-type>,
"comments": [
<comment>,
<comment>,
<comment>
}
]
}
```
@ -1851,7 +2016,8 @@ One file together with its metadata posted to the site.
- `<thumbnail-url>`: where the post thumbnail is located.
- `<flags>`: various flags such as whether the post is looped, represented as
array of plain strings.
- `<tags>`: list of tag names the post is tagged with.
- `<tags>`: list of tags the post is tagged with, serialized as [micro
tag resource](#micro-tag).
- `<relations>`: a list of related posts, serialized as [micro post
resources](#micro-post). Links to related posts are shown
to the user by the web client.
@ -2162,8 +2328,8 @@ A result of search operation that involves paging.
```json5
{
"query": <query>, // same as in input
"page": <page>, // same as in input
"pageSize": <page-size>,
"offset": <offset>, // same as in input
"limit": <page-size>,
"total": <total-count>,
"results": [
<resource>,
@ -2176,7 +2342,7 @@ A result of search operation that involves paging.
**Field meaning**
- `<query>`: the query passed in the original request that contains standard
[search query](#search).
- `<page>`: the page number, passed in the original request.
- `<offset>`: the record starting offset, passed in the original request.
- `<page-size>`: number of records on one page.
- `<total-count>`: how many resources were found. To get the page count, divide
this number by `<page-size>`.
@ -2253,6 +2419,9 @@ Date/time values can be of following form:
Some fields, such as user names, can take wildcards (`*`).
You can escape special characters such as `:` and `-` by prepending them with a
backslash: `\\`.
**Example**
Searching for posts with following query:
@ -2261,3 +2430,8 @@ Searching for posts with following query:
will show flash files tagged as sea, that were liked by seven people at most,
uploaded by user Pirate.
Searching for posts with `re:zero` will show an error message about unknown
named token.
Searching for posts with `re\:zero` will show posts tagged with `re:zero`.

212
INSTALL-OLD.md Normal file
View file

@ -0,0 +1,212 @@
**This installation guide is deprecated and might be out
of date! It is recommended that you deploy using
[Docker](https://github.com/rr-/szurubooru/blob/master/INSTALL.md)
instead.**
This guide assumes Arch Linux. Although exact instructions for other
distributions are different, the steps stay roughly the same.
### Installing hard dependencies
```console
user@host:~$ sudo pacman -S postgresql
user@host:~$ sudo pacman -S python
user@host:~$ sudo pacman -S python-pip
user@host:~$ sudo pacman -S ffmpeg
user@host:~$ sudo pacman -S npm
user@host:~$ sudo pacman -S elasticsearch
user@host:~$ sudo pip install virtualenv
user@host:~$ python --version
Python 3.5.1
```
The reason `ffmpeg` is used over, say, `ImageMagick` or even `PIL` is because of
Flash and video posts.
### Setting up a database
First, basic `postgres` configuration:
```console
user@host:~$ sudo -i -u postgres initdb --locale en_US.UTF-8 -E UTF8 -D /var/lib/postgres/data
user@host:~$ sudo systemctl start postgresql
user@host:~$ sudo systemctl enable postgresql
```
Then creating a database:
```console
user@host:~$ sudo -i -u postgres createuser --interactive
Enter name of role to add: szuru
Shall the new role be a superuser? (y/n) n
Shall the new role be allowed to create databases? (y/n) n
Shall the new role be allowed to create more new roles? (y/n) n
user@host:~$ sudo -i -u postgres createdb szuru
user@host:~$ sudo -i -u postgres psql -c "ALTER USER szuru PASSWORD 'dog';"
```
### Setting up elasticsearch
```console
user@host:~$ sudo systemctl start elasticsearch
user@host:~$ sudo systemctl enable elasticsearch
```
### Preparing environment
Getting `szurubooru`:
```console
user@host:~$ git clone https://github.com/rr-/szurubooru.git szuru
user@host:~$ cd szuru
```
Installing frontend dependencies:
```console
user@host:szuru$ cd client
user@host:szuru/client$ npm install
```
`npm` sandboxes dependencies by default, i.e. installs them to
`./node_modules`. This is good, because it avoids polluting the system with the
project's dependencies. To make Python work the same way, we'll use
`virtualenv`. Installing backend dependencies with `virtualenv` looks like
this:
```console
user@host:szuru/client$ cd ../server
user@host:szuru/server$ virtualenv python_modules # consistent with node_modules
user@host:szuru/server$ source python_modules/bin/activate # enters the sandbox
(python_modules) user@host:szuru/server$ pip install -r requirements.txt # installs the dependencies
```
### Preparing `szurubooru` for first run
1. Compile the frontend:
```console
user@host:szuru$ cd client
user@host:szuru/client$ node build.js
```
You can include the flags `--no-transpile` to disable the JavaScript
transpiler, which provides compatibility with older browsers, and
`--debug` to generate JS source mappings.
2. Configure things:
```console
user@host:szuru/client$ cd ..
user@host:szuru$ mv server/config.yaml.dist .
user@host:szuru$ cp config.yaml.dist config.yaml
user@host:szuru$ vim config.yaml
```
Pay extra attention to these fields:
- data directory,
- data URL,
- database,
- the `smtp` section.
3. Upgrade the database:
```console
user@host:szuru/client$ cd ../server
user@host:szuru/server$ source python_modules/bin/activate
(python_modules) user@host:szuru/server$ alembic upgrade head
```
`alembic` should have been installed during installation of `szurubooru`'s
dependencies.
4. Run the tests:
```console
(python_modules) user@host:szuru/server$ pytest
```
It is recommended to rebuild the frontend after each change to configuration.
### Wiring `szurubooru` to the web server
`szurubooru` is divided into two parts: public static files, and the API. It
tries not to impose any networking configurations on the user, so it is the
user's responsibility to wire these to their web server.
The static files are located in the `client/public/data` directory and are
meant to be exposed directly to the end users.
The API should be exposed using WSGI server such as `waitress`, `gunicorn` or
similar. Other configurations might be possible but I didn't pursue them.
API calls are made to the relative URL `/api/`. Your HTTP server should be
configured to proxy this URL format to the WSGI server. Some users may prefer
to use a dedicated reverse proxy for this, to incorporate additional features
such as load balancing and SSL.
Note that the API URL in the virtual host configuration needs to be the same as
the one in the `config.yaml`, so that client knows how to access the backend!
#### Example
In this example:
- The booru is accessed from `http://example.com/`
- The API is accessed from `http://example.com/api`
- The API server listens locally on port 6666, and is proxied by nginx
- The static files are served from `/srv/www/booru/client/public/data`
**nginx configuration**:
```nginx
server {
listen 80;
server_name example.com;
location ~ ^/api$ {
return 302 /api/;
}
location ~ ^/api/(.*)$ {
if ($request_uri ~* "/api/(.*)") { # preserve PATH_INFO as-is
proxy_pass http://127.0.0.1:6666/$1;
}
}
location / {
root /srv/www/booru/client/public;
try_files $uri /index.htm;
}
}
```
**`config.yaml`**:
```yaml
data_url: 'http://example.com/data/'
data_dir: '/srv/www/booru/client/public/data'
```
To run the server using `waitress`:
```console
user@host:szuru/server$ source python_modules/bin/activate
(python_modules) user@host:szuru/server$ pip install waitress
(python_modules) user@host:szuru/server$ waitress-serve --port 6666 szurubooru.facade:app
```
or `gunicorn`:
```console
user@host:szuru/server$ source python_modules/bin/activate
(python_modules) user@host:szuru/server$ pip install gunicorn
(python_modules) user@host:szuru/server$ gunicorn szurubooru.facade:app -b 127.0.0.1:6666
```

View file

@ -1,187 +1,64 @@
This guide assumes Arch Linux. Although exact instructions for other
distributions are different, the steps stay roughly the same.
This assumes that you have Docker and Docker Compose already installed.
### Installing hard dependencies
### Prepare things
```console
user@host:~$ sudo pacman -S postgresql
user@host:~$ sudo pacman -S python
user@host:~$ sudo pacman -S python-pip
user@host:~$ sudo pacman -S ffmpeg
user@host:~$ sudo pacman -S npm
user@host:~$ sudo pacman -S elasticsearch
user@host:~$ sudo pip install virtualenv
user@host:~$ python --version
Python 3.5.1
```
The reason `ffmpeg` is used over, say, `ImageMagick` or even `PIL` is because of
Flash and video posts.
### Setting up a database
First, basic `postgres` configuration:
```console
user@host:~$ sudo -i -u postgres initdb --locale en_US.UTF-8 -E UTF8 -D /var/lib/postgres/data
user@host:~$ sudo systemctl start postgresql
user@host:~$ sudo systemctl enable postgresql
```
Then creating a database:
```console
user@host:~$ sudo -i -u postgres createuser --interactive
Enter name of role to add: szuru
Shall the new role be a superuser? (y/n) n
Shall the new role be allowed to create databases? (y/n) n
Shall the new role be allowed to create more new roles? (y/n) n
user@host:~$ sudo -i -u postgres createdb szuru
user@host:~$ sudo -i -u postgres psql -c "ALTER USER szuru PASSWORD 'dog';"
```
### Setting up elasticsearch
```console
user@host:~$ sudo systemctl start elasticsearch
user@host:~$ sudo systemctl enable elasticsearch
```
### Preparing environment
Getting `szurubooru`:
1. Getting `szurubooru`:
```console
user@host:~$ git clone https://github.com/rr-/szurubooru.git szuru
user@host:~$ cd szuru
```
Installing frontend dependencies:
2. Configure the application:
```console
user@host:szuru$ cd client
user@host:szuru/client$ npm install
```
`npm` sandboxes dependencies by default, i.e. installs them to
`./node_modules`. This is good, because it avoids polluting the system with the
project's dependencies. To make Python work the same way, we'll use
`virtualenv`. Installing backend dependencies with `virtualenv` looks like
this:
```console
user@host:szuru/client$ cd ../server
user@host:szuru/server$ virtualenv python_modules # consistent with node_modules
user@host:szuru/server$ source python_modules/bin/activate # enters the sandbox
(python_modules) user@host:szuru/server$ pip install -r requirements.txt # installs the dependencies
```
### Preparing `szurubooru` for first run
1. Configure things:
```console
user@host:szuru$ cp config.yaml.dist config.yaml
user@host:szuru$ vim config.yaml
user@host:szuru$ cp server/config.yaml.dist config.yaml
user@host:szuru$ edit config.yaml
```
Pay extra attention to these fields:
- base URL,
- API URL,
- data directory,
- data URL,
- database,
- secret
- the `smtp` section.
2. Compile the frontend:
You can omit lines when you want to use the defaults of that field.
3. Configure Docker Compose:
```console
user@host:szuru$ cd client
user@host:szuru/client$ npm run build
user@host:szuru$ cp docker-compose.yml.example docker-compose.yml
user@host:szuru$ edit docker-compose.yml
```
3. Upgrade the database:
Read the comments to guide you. For production use, it is *important*
that you configure the volumes appropriately to avoid data loss.
### Running the Application
1. Configurations for ElasticSearch:
You may need to raise the `vm.max_map_count`
parameter to at least `262144` in order for the
ElasticSearch container to function. Instructions
on how to do so are provided
[here](https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html#docker-cli-run-prod-mode).
2. Build or update the containers:
```console
user@host:szuru/client$ cd ../server
user@host:szuru/server$ source python_modules/bin/activate
(python_modules) user@host:szuru/server$ alembic upgrade head
user@host:szuru$ docker-compose pull
user@host:szuru$ docker-compose build --pull
```
`alembic` should have been installed during installation of `szurubooru`'s
dependencies.
This will build both the frontend and backend containers, and may take
some time.
4. Run the tests:
3. Start and stop the the application
```console
(python_modules) user@host:szuru/server$ ./test
# To start:
user@host:szuru$ docker-compose up -d
# To monitor (CTRL+C to exit):
user@host:szuru$ docker-compose logs -f
# To stop
user@host:szuru$ docker-compose down
```
It is recommended to rebuild the frontend after each change to configuration.
### Wiring `szurubooru` to the web server
`szurubooru` is divided into two parts: public static files, and the API. It
tries not to impose any networking configurations on the user, so it is the
user's responsibility to wire these to their web server.
Below are described the methods to integrate the API into a web server:
1. Run API locally with `waitress`, and bind it with a reverse proxy. In this
approach, the user needs to (from within `virtualenv`) install `waitress`
with `pip install waitress` and then start `szurubooru` with `./host-waitress`
from within the `server/` directory (see `--help` for details). Then the
user needs to add a virtual host that delegates the API requests to the
local API server, and the browser requests to the `client/public/`
directory.
2. Alternatively, Apache users can use `mod_wsgi`.
3. Alternatively, users can use other WSGI frontends such as `gunicorn` or
`uwsgi`, but they'll need to write wrapper scripts themselves.
Note that the API URL in the virtual host configuration needs to be the same as
the one in the `config.yaml`, so that client knows how to access the backend!
#### Example
**nginx configuration** - wiring API `http://great.dude/api/` to
`localhost:6666` to avoid fiddling with CORS:
```nginx
server {
listen 80;
server_name great.dude;
merge_slashes off; # to support post tags such as ///
location ~ ^/api$ {
return 302 /api/;
}
location ~ ^/api/(.*)$ {
proxy_pass http://127.0.0.1:6666/$1$is_args$args;
}
location / {
root /home/rr-/src/maintained/szurubooru/client/public;
try_files $uri /index.htm;
}
}
```
**`config.yaml`**:
```yaml
api_url: 'http://big.dude/api/'
base_url: 'http://big.dude/'
data_url: 'http://big.dude/data/'
data_dir: '/home/rr-/src/maintained/szurubooru/client/public/data'
```
Then the backend is started with `host-waitress` from within `virtualenv` and
`./server/` directory.

View file

@ -13,6 +13,7 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
- Post comments
- Post notes / annotations, including arbitrary polygons
- Rich JSON REST API ([see documentation](https://github.com/rr-/szurubooru/blob/master/API.md))
- Token based authentication for clients
- Rich search system
- Rich privilege system
- Autocomplete in search and while editing tags
@ -33,6 +34,7 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
- FFmpeg
- node.js
It is recommended that you use Docker for deployment.
[See installation instructions.](https://github.com/rr-/szurubooru/blob/master/INSTALL.md)
## Screenshots

View file

@ -1 +1 @@
{ "presets": ["es2015"] }
{ "presets": ["env"] }

6
client/.dockerignore Normal file
View file

@ -0,0 +1,6 @@
node_modules/*
package-lock.json
Dockerfile
.dockerignore
**/.gitignore

31
client/Dockerfile Normal file
View file

@ -0,0 +1,31 @@
FROM node:9 as builder
WORKDIR /opt/app
COPY package.json ./
RUN npm install
COPY . ./
ARG BUILD_INFO="docker-latest"
ARG CLIENT_BUILD_ARGS=""
RUN node build.js ${CLIENT_BUILD_ARGS}
RUN find public/ -type f -size +5k -print0 | xargs -0 -- gzip -6 -k
FROM nginx:alpine
WORKDIR /var/www
RUN \
# Create init file
echo "#!/bin/sh" >> /init && \
echo 'sed -i "s|__BACKEND__|${BACKEND_HOST}|" /etc/nginx/nginx.conf' \
>> /init && \
echo 'exec nginx -g "daemon off;"' >> /init && \
chmod a+x /init
CMD ["/init"]
VOLUME ["/data"]
COPY nginx.conf.docker /etc/nginx/nginx.conf
COPY --from=builder /opt/app/public/ .

View file

@ -5,20 +5,6 @@ const glob = require('glob');
const path = require('path');
const util = require('util');
const execSync = require('child_process').execSync;
const camelcase = require('camelcase');
function convertKeysToCamelCase(input) {
let result = {};
Object.keys(input).map((key, _) => {
const value = input[key];
if (value !== null && value.constructor == Object) {
result[camelcase(key)] = convertKeysToCamelCase(value);
} else {
result[camelcase(key)] = value;
}
});
return result;
}
function readTextFile(path) {
return fs.readFileSync(path, 'utf-8');
@ -29,37 +15,27 @@ function writeFile(path, content) {
}
function getVersion() {
return execSync('git describe --always --dirty --long --tags')
.toString()
.trim();
let build_info = process.env.BUILD_INFO;
if (build_info) {
return build_info.trim();
} else {
try {
build_info = execSync('git describe --always --dirty --long --tags')
.toString();
} catch (e) {
console.warn('Cannot find build version');
return 'unknown';
}
return build_info.trim();
}
}
function getConfig() {
const yaml = require('js-yaml');
const merge = require('merge');
const camelcaseKeys = require('camelcase-keys');
function parseConfigFile(path) {
let result = yaml.load(readTextFile(path, 'utf-8'));
return convertKeysToCamelCase(result);
}
let config = parseConfigFile('../config.yaml.dist');
try {
const localConfig = parseConfigFile('../config.yaml');
config = merge.recursive(config, localConfig);
} catch (e) {
console.warn('Local config does not exist, ignoring');
}
config.canSendMails = !!config.smtp.host;
delete config.secret;
delete config.smtp;
delete config.database;
config.meta = {
let config = {
meta: {
version: getVersion(),
buildDate: new Date().toUTCString(),
buildDate: new Date().toUTCString()
}
};
return config;
@ -70,11 +46,11 @@ function copyFile(source, target) {
}
function minifyJs(path) {
return require('uglify-js').minify(path, {compress: {unused: false}}).code;
return require('terser').minify(fs.readFileSync(path, 'utf-8'), {compress: {unused: false}}).code;
}
function minifyCss(css) {
return require('csso').minify(css);
return require('csso').minify(css).css;
}
function minifyHtml(html) {
@ -85,15 +61,11 @@ function minifyHtml(html) {
}).trim();
}
function bundleHtml(config) {
function bundleHtml() {
const underscore = require('underscore');
const babelify = require('babelify');
const baseHtml = readTextFile('./html/index.htm', 'utf-8');
const finalHtml = baseHtml
.replace(
/(<title>)(.*)(<\/title>)/,
util.format('$1%s$3', config.name));
writeFile('./public/index.htm', minifyHtml(finalHtml));
writeFile('./public/index.htm', minifyHtml(baseHtml));
glob('./html/**/*.tpl', {}, (er, files) => {
let compiledTemplateJs = '\'use strict\'\n';
@ -143,7 +115,7 @@ function bundleCss() {
});
}
function bundleJs(config) {
function bundleJs() {
const browserify = require('browserify');
const external = [
'underscore',
@ -170,7 +142,7 @@ function bundleJs(config) {
for (let lib of external) {
b.require(lib);
}
if (config.transpile) {
if (!process.argv.includes('--no-transpile')) {
b.add(require.resolve('babel-polyfill'));
}
writeJsBundle(
@ -179,15 +151,15 @@ function bundleJs(config) {
if (!process.argv.includes('--no-app-js')) {
let outputFile = fs.createWriteStream('./public/js/app.min.js');
let b = browserify({debug: config.debug});
if (config.transpile) {
let b = browserify({debug: process.argv.includes('--debug')});
if (!process.argv.includes('--no-transpile')) {
b = b.transform('babelify');
}
writeJsBundle(
b.external(external).add(files),
'./public/js/app.min.js',
'Bundled app JS',
!config.debug);
!process.argv.includes('--debug'));
}
});
}
@ -217,11 +189,11 @@ const config = getConfig();
bundleConfig(config);
bundleBinaryAssets();
if (!process.argv.includes('--no-html')) {
bundleHtml(config);
bundleHtml();
}
if (!process.argv.includes('--no-css')) {
bundleCss();
}
if (!process.argv.includes('--no-js')) {
bundleJs(config);
bundleJs();
}

View file

@ -55,3 +55,5 @@ $hovered-first-note-point-color = red
$safety-safe = #88D488
$safety-sketchy = #F3D75F
$safety-unsafe = #F3985F
$scrollbar-thumb-color = $main-color
$scrollbar-bg-color = $input-enabled-background-color

View file

@ -3,7 +3,6 @@ $comment-header-background-color = $top-navigation-color
$comment-border-color = #DDD
.comment-container
margin: 0 0 1em 0
padding: 0 0 0 60px
.avatar
@ -124,7 +123,7 @@ $comment-border-color = #DDD
font-family: 'MS PGothic', ' ', 'IPAMonaPGothic', 'Trebuchet MS', Verdana, Futura, Arial, Helvetica, sans-serif
background: #fbfbfb
color: #111
font-size: 12pt
font-size: 1em
line-height: 1
margin: 0
padding: 4px

View file

@ -2,3 +2,8 @@
list-style-type: none
margin: 0
padding: 0
>li
margin-bottom: 1em
&:last-child
margin-bottom: 0

View file

@ -1,15 +1,24 @@
@import colors
$comment-border-color = $top-navigation-color
.global-comment-list
text-align: left
&>ul
list-style-type: none
margin: 1em 0
margin: 1em 0 0
padding: 0
@media (max-width: 700px)
&>li
margin-bottom: 5em
padding: 1vw
margin-top: 2em
padding-top: 2em
border-top: 3px solid $comment-border-color
&:first-child
margin-top: 0
padding-top: 0
border-top: none
@media (max-width: 700px)
.post-thumbnail
margin-bottom: 1em
.thumbnail
@ -19,7 +28,6 @@
@media (min-width: 700px)
&>li
padding-left: 13em
margin-bottom: 2em
.post-thumbnail
float: left
margin: 0 0 1em -13em

View file

@ -17,6 +17,8 @@ form
.input li:first-child
padding-top: 0
margin-top: 0
form:not(.horizontal)
.hint
margin-top: 0.2em
margin-bottom: 0
@ -29,13 +31,22 @@ form.horizontal
margin-bottom: 1em
.input, .buttons, ul
display: inline-block
vertical-align: middle
vertical-align: top
margin: 0
padding: 0
input
vertical-align: middle
vertical-align: top
.buttons
margin-right: 0.5em
@media (max-width: 1000px)
display: block
.input, .buttons, ul
display: block
margin-top: 0.5em
&:first-child
margin-top: 0
.buttons
margin-right: 0
@ -126,6 +137,38 @@ input[type=checkbox]:focus + .checkbox:before
/*
* Date and time inputs
*/
input[type=date],
input[type=time]
vertical-align: top
font-family: 'Droid Sans', sans-serif
font-size: 100%
padding: 0.2em 0.3em
box-sizing: border-box
border: 2px solid $input-enabled-border-color
background: $input-enabled-background-color
color: $input-enabled-text-color
box-shadow: none /* :-moz-submit-invalid on FF */
transition: border-color 0.1s linear, background-color 0.1s linear
&:disabled
border: 2px solid $input-disabled-border-color
background: $input-disabled-background-color
color: $input-disabled-text-color
&:focus
border-color: $main-color
&[readonly]
border: 2px solid $input-disabled-border-color
background: $input-disabled-background-color
color: $input-disabled-text-color
/*
* Regular inputs
*/
@ -170,13 +213,25 @@ input:disabled
cursor: not-allowed
label.color
white-space: nowrap
position: relative
display: flex
input[type=text]
margin-right: 0.25em
width: auto
.preview
display: inline-block
text-align: center
pointer-events: none
input[type=color]
position: absolute
opacity: 0
padding: 0 0.5em
border: 2px solid black
&:after
content: 'A'
.background-preview
border-right: 0
color: transparent
.text-preview
border-left: 0
form.show-validation .input
input:invalid
@ -199,10 +254,13 @@ input[type=submit]
cursor: pointer
font-size: 100%
padding: 0.2em 0.7em
border-radius: 0
border: 2px solid $button-enabled-background-color
background: $button-enabled-background-color
color: $button-enabled-text-color
outline: 0 /* something on Chrome */
-moz-appearance: none
-webkit-appearance: none
&:disabled
cursor: default
@ -231,25 +289,26 @@ input::-moz-focus-inner
* File dropper
*/
.file-dropper-holder
display: flex
flex-wrap: wrap
.file-dropper
display: block
width: 100%
background: $window-color
border: 3px dashed #eee
padding: 0.3em 0.5em
line-height: 140%
text-align: center
cursor: pointer
overflow: hidden
word-wrap: break-word
input
.url-holder
display: flex
margin-top: 0.5em
width: auto
input, button
min-width: 0 /* firefox being sassy */
width: auto !important /* don't inherit anything weird */
input
flex: 1
button
margin-top: 0.5em
width: 8em
margin-left: 0.5em
input[type=file]:disabled+.file-dropper
cursor: default

View file

@ -21,19 +21,28 @@ body
margin: 0
color: $text-color
font-family: 'Open Sans', sans-serif
font-size: 12pt
line-height: 18pt
font-size: 1em
line-height: 1.4
@media (max-width: 800px)
font-size: 10pt
line-height: 15pt
font-size: 0.875em
@media (max-width: 1200px)
font-size: 11pt
line-height: 16.5pt
font-size: 0.95em
h1, h2, h3
font-weight: normal
margin-bottom: 1em
h1
font-size: 2em
h2
font-size: 1.5em
p,
ol,
ul
margin: 1em 0
th
font-weight: normal
@ -61,8 +70,10 @@ form .fa-question-circle-o
vertical-align: middle
#content-holder
padding: 1.5vw
padding: 1.5em
text-align: center
@media (max-width: 1000px)
padding: 1em
>.content-wrapper
box-sizing: border-box /* make max-width: 100% on this element include padding */
text-align: left
@ -70,9 +81,26 @@ form .fa-question-circle-o
margin: 0 auto
>*:first-child, form h1
margin-top: 0
nav.buttons
ul
display: block
max-width: 100%
white-space: nowrap
overflow-x: auto
&::-webkit-scrollbar
height: 6px
background-color: $scrollbar-bg-color
&::-webkit-scrollbar-thumb
background-color: $scrollbar-thumb-color
>.content-wrapper:not(.transparent)
background: $top-navigation-color
padding: 2vw
padding: 1.8em
@media (max-width: 1000px)
padding: 1.5em
.content,
.content .subcontent
>*:last-child
margin-bottom: 0
hr
border: 0
@ -125,6 +153,39 @@ nav
li
display: inline-block
float: left
a
padding: 0 1.5em
#mobile-navigation-toggle
display: none
width: 100%
padding: 0 1em
line-height: 2.3em
font-family: inherit
border: none
background: none
color: $active-tab-text-color
.site-name
display: block
float: left
max-width: 50vw
overflow: hidden
text-overflow: ellipsis
.toggle-icon
display: block
float: right
@media (max-width: 1000px)
text-align: left
li
display: none
float: none
a
display: block
padding: 0 1em
#mobile-navigation-toggle
display: block
&.opened
li
display: block
ul li[data-name=account],
ul li[data-name=register],
ul li[data-name=login],
@ -141,6 +202,8 @@ nav
margin-right: 0.6em
margin-left: calc(0.6em - 1.2em)
float: left
@media (max-width: 1000px)
display: none
a .access-key
text-decoration: underline
@ -194,6 +257,14 @@ a .access-key
margin-top: 0 !important
margin-bottom: 0 !important
.table-wrap
overflow-x: auto
&::-webkit-scrollbar
height: 6px
background-color: $scrollbar-bg-color
&::-webkit-scrollbar-thumb
background-color: $scrollbar-thumb-color
/* hack to prevent text from being copied */
[data-pseudo-content]:before {
content: attr(data-pseudo-content)

View file

@ -16,7 +16,7 @@
color: mix($text-color, $inactive-link-color)
font-size: 120%
i
font-size: 12pt
font-size: 1em
color: $inactive-link-color
float: right
line-height: 2em

View file

@ -16,6 +16,10 @@
font-size: 1.6em
&:first-child
margin-top: 0
@media (max-width: 1000px)
margin-top: 1.5em
&:first-child
margin-top: 0
nav
ul
margin: 0 auto

View file

@ -6,13 +6,16 @@
margin-bottom: 1em
h1
line-height: initial
font-size: 30pt
font-size: 2.5em
margin: 0
.messages
text-align: center
.message
margin-bottom: 2em
margin: 0 auto 2em auto
form
display: inline-block
width: auto
vertical-align: middle
margin: 0 0 2em 0
@ -31,6 +34,8 @@
display: flex
align-items: center
justify-content: center
&:empty
margin-bottom: 0
nav
a
@ -50,6 +55,8 @@
li
display: inline
white-space: nowrap
@media (max-width: 800px)
display: block
.sep
word-spacing: 1.1em
background-repeat: no-repeat

View file

@ -8,7 +8,7 @@
.page
position: relative
.page-header
margin: 0.5em 0.5em 0.5em 0
margin: 0.5em 0
position: relative
&:before
display: block

View file

@ -0,0 +1,2 @@
#password-reset
max-width: 30em

View file

@ -54,10 +54,12 @@
.icon:not(:first-of-type)
margin-left: 1em
.masstag
.edit-overlay
position: absolute
top: 0.5em
left: 0.5em
.tag-flipper
display: inline-block
padding: 0.5em
box-sizing: border-box
@ -68,7 +70,7 @@
height: 1em
text-align: center
line-height: 1em
font-size: 20pt
font-size: 1.6em
&.tagged
background: rgba(0, 230, 0, 0.7)
&:after
@ -82,6 +84,37 @@
&[data-disabled]
background: rgba(200, 200, 200, 0.7)
.safety-flipper a
display: inline-block
margin: 0.1em
box-sizing: border-box
border: 0
display: inline-block
width: 1.2em
height: 1.2em
text-align: center
line-height: 1em
font-size: 1.6em
border: 3px solid
&.safety-safe
background-color: darken($safety-safe, 5%)
border-color: @background-color
&:not(.active)
background-color: alpha(@background-color, 0.3)
&.safety-sketchy
background-color: $safety-sketchy
border-color: @background-color
&:not(.active)
background-color: alpha(@background-color, 0.3)
&.safety-unsafe
background-color: $safety-unsafe
border-color: @background-color
&:not(.active)
background-color: alpha(@background-color, 0.3)
&[data-disabled]
background: rgba(200, 200, 200, 0.7)
.thumbnail
background-position: 50% 30%
width: 100%
@ -112,29 +145,59 @@
margin-bottom: 0.75em
*
vertical-align: top
@media (max-width: 1000px)
display: block
input
margin-bottom: 0.25em
margin-right: 0.25em
input[name=search-text]
width: 25em
input[name=masstag]
width: 12em
.masstag-hint, .open-masstag
margin-right: 1em
@media (max-width: 1000px)
display: block
width: 100%
margin-bottom: 0.5em
.append
vertical-align: middle
font-size: 0.95em
color: $inactive-link-color
.masstag
&:not(.active)
.bulk-edit
&:not(.opened)
.close
display: none
&.opened
.open
display: none
&.hidden
display: none
.bulk-edit-tags
&.opened
.hint
@media (max-width: 1000px)
display: block
margin-bottom: 0.5em
&:not(.opened)
[type=text],
.start-tagging,
.stop-tagging
.start
display: none
.masstag-hint
display: none
&.active
.open-masstag
.hint
display: none
input[name=tag]
width: 12em
@media (max-width: 1000px)
display: block
width: 100%
margin-bottom: 0.5em
.append
&.open,
&.hint
@media (max-width: 1000px)
margin-left: 0
.hint
margin-right: 1em
.bulk-edit-safety
.append
@media (max-width: 1000px)
margin-left: 0
.safety
margin-right: 0.25em

View file

@ -33,6 +33,8 @@
i
font-size: 140%
text-align: center
@media (max-width: 800px)
margin-top: 2em
>.content
width: 100%
@ -50,6 +52,7 @@
order: 2
min-width: 100%
max-width: 0
margin-right: 0
>.content
order: 1
@ -130,10 +133,18 @@
display: inline-block
.management
ul
list-style-type: none
margin: 0
padding: 0
li
margin: 0
padding: 0
label
form
width: auto
label:not(.file-dropper)
margin-bottom: 0.3em
display: block

View file

@ -22,6 +22,8 @@ $cancel-button-color = tomato
.file-dropper
font-size: 150%
padding: 2em
small
font-size: 60%
input[type=submit]
margin-top: 1em

View file

@ -8,11 +8,16 @@ $snapshot-merged-background-color = #FEC
ul
margin: 0 auto
padding: 0
width: 100%
max-width: 35em
list-style-type: none
li
margin-bottom: 1em
&:last-child
margin-bottom: 0
.time
float: right
@ -39,6 +44,3 @@ $snapshot-merged-background-color = #FEC
background: $snapshot-merged-background-color
&+.details
background: lighten($snapshot-merged-background-color, 50%)
div.details
margin-bottom: 2em

View file

@ -2,7 +2,7 @@
.content-wrapper.tag-categories
width: 100%
max-width: 40em
max-width: 45em
table
border-spacing: 0
width: 100%
@ -11,11 +11,18 @@
td, th
padding: .4em
&.color
text-align: center
input[type=text]
width: 8em
&.usages
text-align: center
&.remove, &.set-default
white-space: pre
th
white-space: nowrap
&:first-child
padding-left: 0
&:last-child
padding-right: 0
tfoot
display: none
form

View file

@ -11,6 +11,7 @@
th, td
padding: 0.1em 0.5em
th
white-space: nowrap
background: $top-navigation-color
.names
width: 28%
@ -46,7 +47,10 @@
form
width: auto
input[name=search-text]
max-width: 15em
width: 25em
@media (max-width: 1000px)
width: 100%
.append
vertical-align: middle
font-size: 0.95em
color: $inactive-link-color

View file

@ -33,7 +33,10 @@
form
width: auto
input[name=search-text]
max-width: 15em
width: 25em
@media (max-width: 1000px)
width: 100%
.append
vertical-align: middle
font-size: 0.95em
color: $inactive-link-color

View file

@ -1,3 +1,6 @@
@import colors
$token-border-color = $active-tab-background-color
#user
width: 100%
max-width: 35em
@ -37,7 +40,43 @@
height: 1px
clear: both
#user-delete form
#user-tokens
.token-flex-container
width: 100%
display: flex;
flex-direction column;
padding-bottom: 0.5em;
.full-width
width: 100%
.token-flex-row
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 0.2em;
.no-wrap
white-space: nowrap;
.token-input
min-height: 2em;
line-height: 2em;
text-align: center;
.token-flex-column
display: flex;
flex-direction: column;
.token-flex-labels
padding-right: 0.5em
hr
border-top: 3px solid $token-border-color
form
width: 100%;
#user-delete form
width: 100%

View file

@ -1,7 +1,7 @@
<div class='comment-container'>
<div class='avatar'>
<% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %>
<a href='/user/<%- encodeURIComponent(ctx.user.name) %>'>
<a href='<%- ctx.formatClientLink('user', ctx.user.name) %>'>
<% } %>
<%= ctx.makeThumbnail(ctx.user ? ctx.user.avatarUrl : null) %>
@ -23,7 +23,7 @@
<nav class='readonly'><%
%><strong><span class='nickname'><%
%><% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %><%
%><a href='/user/<%- encodeURIComponent(ctx.user.name) %>'><%
%><a href='<%- ctx.formatClientLink('user', ctx.user.name) %>'><%
%><% } %><%
%><%- ctx.user ? ctx.user.name : 'Deleted user' %><%

View file

@ -1,10 +1,10 @@
<div class='global-comment-list'>
<ul><!--
--><% for (let post of ctx.results) { %><!--
--><% for (let post of ctx.response.results) { %><!--
--><li><!--
--><div class='post-thumbnail'><!--
--><% if (ctx.canViewPosts) { %><!--
--><a href='/post/<%- encodeURIComponent(post.id) %>'><!--
--><a href='<%- ctx.formatClientLink('post', post.id) %>'><!--
--><% } %><!--
--><%= ctx.makeThumbnail(post.thumbnailUrl) %><!--
--><% if (ctx.canViewPosts) { %><!--

View file

@ -1,5 +1,7 @@
<div class='pager'>
<div class='page-header-holder'></div>
<div class='messages'></div>
<div class='page-guard top'></div>
<div class='pages-holder'></div>
<div class='page-guard bottom'></div>
</div>

View file

@ -8,9 +8,19 @@
<% } %>
<br/>
Or just click on this box.
<% if (ctx.extraText) { %>
<br/>
<small><%= ctx.extraText %></small>
<% } %>
</label>
<% if (ctx.allowUrls) { %>
<input type='text' name='url' placeholder='Alternatively, paste an URL here.'/>
<div class='url-holder'>
<input type='text' name='url' placeholder='<%- ctx.urlPlaceholder %>'/>
<% if (ctx.lock) { %>
<button>Confirm</button>
<% } else { %>
<button>Add URL</button>
<% } %>
</div>
<% } %>
</div>

View file

@ -1,11 +1,11 @@
<div class='content-wrapper' id='help'>
<nav class='buttons primary'><!--
--><ul><!--
--><li data-name='about'><a href='/help/about'>About</a></li><!--
--><li data-name='keyboard'><a href='/help/keyboard'>Keyboard</a></li><!--
--><li data-name='search'><a href='/help/search'>Search syntax</a></li><!--
--><li data-name='comments'><a href='/help/comments'>Comments</a></li><!--
--><li data-name='tos'><a href='/help/tos'>Terms of service</a></li><!--
--><li data-name='about'><a href='<%- ctx.formatClientLink('help', 'about') %>'>About</a></li><!--
--><li data-name='keyboard'><a href='<%- ctx.formatClientLink('help', 'keyboard') %>'>Keyboard</a></li><!--
--><li data-name='search'><a href='<%- ctx.formatClientLink('help', 'search') %>'>Search syntax</a></li><!--
--><li data-name='comments'><a href='<%- ctx.formatClientLink('help', 'comments') %>'>Comments</a></li><!--
--><li data-name='tos'><a href='<%- ctx.formatClientLink('help', 'tos') %>'>Terms of service</a></li><!--
--></ul><!--
--></nav>

View file

@ -33,10 +33,15 @@ shortcuts:</p>
<td><kbd>P</kbd></td>
<td>Focus first post in post list</td>
</tr>
<tr>
<td><kbd>Delete</kbd></td>
<td>Delete post (while in edit mode)</td>
</tr>
</tbody>
</table>
<p>Additionally, each item in top navigation can be accessed using feature
called &ldquo;access keys&rdquo;. Pressing underlined letter while holding
Shfit or Alt+Shift (depending on your browser) will go to the desired page
(most browsers) or focus the link (IE).</p>
<p>Additionally, each item in the top navigation can be accessed using a
feature called &ldquo;access keys&rdquo;. Pressing the underlined letter while
holding Shift or Alt+Shift (depending on your browser) will go to the desired
page (most browsers) or focus the link (IE).</p>

View file

@ -1,9 +1,9 @@
<nav class='buttons secondary'><!--
--><ul><!--
--><li data-name='default'><a href='/help/search'>General</a></li><!--
--><li data-name='posts'><a href='/help/search/posts'>Posts</a></li><!--
--><li data-name='users'><a href='/help/search/users'>Users</a></li><!--
--><li data-name='tags'><a href='/help/search/tags'>Tags</a></li><!--
--><li data-name='default'><a href='<%- ctx.formatClientLink('help', 'search') %>'>General</a></li><!--
--><li data-name='posts'><a href='<%- ctx.formatClientLink('help', 'search', 'posts') %>'>Posts</a></li><!--
--><li data-name='users'><a href='<%- ctx.formatClientLink('help', 'search', 'users') %>'>Users</a></li><!--
--><li data-name='tags'><a href='<%- ctx.formatClientLink('help', 'search', 'tags') %>'>Tags</a></li><!--
--></ul><!--
--></nav>

View file

@ -80,6 +80,9 @@ take following form:</p>
<code>,desc</code> to control the sort direction, which can be also controlled
by negating the whole token.</p>
<p>You can escape special characters such as <code>:</code> and <code>-</code>
by prepending them with a backslash: <code>\\</code>.</p>
<h1>Example</h1>
<p>Searching for posts with following query:</p>
@ -89,3 +92,8 @@ by negating the whole token.</p>
<p>will show flash files tagged as sea, that were liked by seven people at
most, uploaded by user Pirate.</p>
<p>Searching for posts with <code>re:zero</code> will show an error message
about unknown named token.</p>
<p>Searching for posts with <code>re\:zero</code> will show posts tagged with
<code>re:zero</code>.</p>

View file

@ -12,7 +12,7 @@
</tr>
<tr>
<td><code>tag</code></td>
<td>having given tag</td>
<td>having given tag (accepts wildcards)</td>
</tr>
<tr>
<td><code>score</code></td>
@ -20,7 +20,7 @@
</tr>
<tr>
<td><code>uploader</code></td>
<td>uploaded by given user</td>
<td>uploaded by given use (accepts wildcards)r</td>
</tr>
<tr>
<td><code>upload</code></td>
@ -32,11 +32,11 @@
</tr>
<tr>
<td><code>comment</code></td>
<td>commented by given user</td>
<td>commented by given user (accepts wildcards)</td>
</tr>
<tr>
<td><code>fav</code></td>
<td>favorited by given user</td>
<td>favorited by given user (accepts wildcards)</td>
</tr>
<tr>
<td><code>tag-count</code></td>
@ -54,6 +54,10 @@
<td><code>note-count</code></td>
<td>having given number of annotations</td>
</tr>
<tr>
<td><code>note-text</code></td>
<td>having given note text (accepts wildcards)</td>
</tr>
<tr>
<td><code>relation-count</code></td>
<td>having given number of relations</td>
@ -86,6 +90,14 @@
<td><code>image-area</code></td>
<td>having given number of pixels (image width * image height)</td>
</tr>
<tr>
<td><code>image-aspect-ratio</code></td>
<td>having given aspect ratio (image width / image height)</td>
</tr>
<tr>
<td><code>image-ar</code></td>
<td>alias of <code>image-aspect-ratio</code></td>
</tr>
<tr>
<td><code>width</code></td>
<td>alias of <code>image-width</code></td>
@ -98,6 +110,14 @@
<td><code>area</code></td>
<td>alias of <code>image-area</code></td>
</tr>
<tr>
<td><code>aspect-ratio</code></td>
<td>alias of <code>image-aspect-ratio</code></td>
</tr>
<tr>
<td><code>ar</code></td>
<td>alias of <code>image-aspect-ratio</code></td>
</tr>
<tr>
<td><code>creation-date</code></td>
<td>posted at given date</td>

View file

@ -12,7 +12,7 @@
</tr>
<tr>
<td><code>category</code></td>
<td>having given category</td>
<td>having given category (accepts wildcards)</td>
</tr>
<tr>
<td><code>creation-date</code></td>

View file

@ -8,7 +8,7 @@
<%= ctx.makeTextInput({name: 'search-text', placeholder: 'enter some tags'}) %>
<input type='submit' value='Search'/>
<span class=sep>or</span>
<a href='/posts'>browse all posts</a>
<a href='<%- ctx.formatClientLink('posts') %>'>browse all posts</a>
</form>
<% } %>
<div class='post-info-container'></div>

View file

@ -2,6 +2,6 @@
<li><%- ctx.postCount %> posts</li><span class='sep'>
</span><li><%= ctx.makeFileSize(ctx.diskUsage) %></li><span class='sep'>
</span><li>Build <a class='version' href='https://github.com/rr-/szurubooru/commits/master'><%- ctx.version %></a> from <%= ctx.makeRelativeTime(ctx.buildDate) %></li><span class='sep'>
</span><% if (ctx.canListSnapshots) { %><li><a href='/history'>History</a></li><span class='sep'>
</span><% if (ctx.canListSnapshots) { %><li><a href='<%- ctx.formatClientLink('history') %>'>History</a></li><span class='sep'>
</span><% } %>
</ul>

View file

@ -3,7 +3,7 @@
<head>
<meta charset='utf-8'/>
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1'>
<title><!-- configured in the config file --></title>
<title>Loading...</title>
<link href='/css/app.min.css' rel='stylesheet' type='text/css'/>
<link href='/css/vendor.min.css' rel='stylesheet' type='text/css'/>
<link rel='shortcut icon' type='image/png' href='/img/favicon.png'/>

View file

@ -30,9 +30,7 @@
<div class='buttons'>
<input type='submit' value='Log in'/>
<% if (ctx.canSendMails) { %>
<a class='append' href='/password-reset'>Forgot the password?</a>
<% } %>
<a class='append' href='<%- ctx.formatClientLink('password-reset') %>'>Forgot the password?</a>
</div>
</form>
</div>

View file

@ -1,17 +1,17 @@
<nav class='buttons'>
<ul>
<li>
<% if (ctx.prevLinkActive) { %>
<a class='prev' href='<%- ctx.prevLink %>'>
<% if (ctx.prevPage !== ctx.currentPage) { %>
<a rel='prev' class='prev' href='<%- ctx.getClientUrlForPage(ctx.pages.get(ctx.prevPage).offset, ctx.pages.get(ctx.prevPage).limit) %>'>
<% } else { %>
<a class='prev disabled'>
<a rel='prev' class='prev disabled'>
<% } %>
<i class='fa fa-chevron-left'></i>
<span class='vim-nav-hint'>&lt; Previous page</span>
</a>
</li>
<% for (let page of ctx.pages) { %>
<% for (let page of ctx.pages.values()) { %>
<% if (page.ellipsis) { %>
<li>&hellip;</li>
<% } else { %>
@ -20,16 +20,16 @@
<% } else { %>
<li>
<% } %>
<a href='<%- page.link %>'><%- page.number %></a>
<a href='<%- ctx.getClientUrlForPage(page.offset, page.limit) %>'><%- page.number %></a>
</li>
<% } %>
<% } %>
<li>
<% if (ctx.nextLinkActive) { %>
<a class='next' href='<%- ctx.nextLink %>'>
<% if (ctx.nextPage !== ctx.currentPage) { %>
<a rel='next' class='next' href='<%- ctx.getClientUrlForPage(ctx.pages.get(ctx.nextPage).offset, ctx.pages.get(ctx.nextPage).limit) %>'>
<% } else { %>
<a class='next disabled'>
<a rel='next' class='next disabled'>
<% } %>
<i class='fa fa-chevron-right'></i>
<span class='vim-nav-hint'>Next page &gt;</span>

View file

@ -1,5 +1,5 @@
<div class='not-found'>
<h1>Not found</h1>
<p><%- ctx.path %> is not a valid URL.</p>
<p><a href='/'>Back to main page</a></p>
<p><a href='<%- ctx.formatClientLink() %>'>Back to main page</a></p>
</div>

View file

@ -1,5 +1,6 @@
<div class='content-wrapper' id='password-reset'>
<h1>Password reset</h1>
<% if (ctx.canSendMails) { %>
<form autocomplete='off'>
<ul class='input'>
<li>
@ -20,4 +21,10 @@
<input type='submit' value='Proceed'/>
</div>
</form>
<% } else { %>
<p>We do not support automatic password resetting.</p>
<% if (ctx.contactEmail) { %>
<p>Please send an e-mail to <a href='mailto:<%- ctx.contactEmail %>'><%- ctx.contactEmail %></a> to go through a manual procedure.</p>
<% } %>
<% } %>
</div>

View file

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

View file

@ -4,7 +4,7 @@
<div class='messages'></div>
<% if (ctx.canEditPostSafety) { %>
<% if (ctx.enableSafety && ctx.canEditPostSafety) { %>
<section class='safety'>
<label>Safety</label>
<div class='radio-wrapper'>
@ -55,9 +55,7 @@
<% if (ctx.canEditPostTags) { %>
<section class='tags'>
<%= ctx.makeTextInput({
value: ctx.post.tags.join(' '),
}) %>
<%= ctx.makeTextInput({}) %>
</section>
<% } %>
@ -66,6 +64,12 @@
<a href class='add'>Add a note</a>
<%= ctx.makeTextarea({disabled: true, text: 'Content (supports Markdown)', rows: '8'}) %>
<a href class='delete inactive'>Delete selected note</a>
<% if (ctx.hasClipboard) { %>
<br/>
<a href class='copy'>Export notes to clipboard</a>
<br/>
<a href class='paste'>Import notes from clipboard</a>
<% } %>
</section>
<% } %>

View file

@ -4,12 +4,12 @@
<article class='previous-post'>
<% if (ctx.prevPostId) { %>
<% if (ctx.editMode) { %>
<a href='<%= ctx.getPostEditUrl(ctx.prevPostId, ctx.parameters) %>'>
<a rel='prev' href='<%= ctx.getPostEditUrl(ctx.prevPostId, ctx.parameters) %>'>
<% } else { %>
<a href='<%= ctx.getPostUrl(ctx.prevPostId, ctx.parameters) %>'>
<a rel='prev' href='<%= ctx.getPostUrl(ctx.prevPostId, ctx.parameters) %>'>
<% } %>
<% } else { %>
<a class='inactive'>
<a rel='prev' class='inactive'>
<% } %>
<i class='fa fa-chevron-left'></i>
<span class='vim-nav-hint'>&lt; Previous post</span>
@ -18,12 +18,12 @@
<article class='next-post'>
<% if (ctx.nextPostId) { %>
<% if (ctx.editMode) { %>
<a href='<%= ctx.getPostEditUrl(ctx.nextPostId, ctx.parameters) %>'>
<a rel='next' href='<%= ctx.getPostEditUrl(ctx.nextPostId, ctx.parameters) %>'>
<% } else { %>
<a href='<%= ctx.getPostUrl(ctx.nextPostId, ctx.parameters) %>'>
<a rel='next' href='<%= ctx.getPostUrl(ctx.nextPostId, ctx.parameters) %>'>
<% } %>
<% } else { %>
<a class='inactive'>
<a rel='next' class='inactive'>
<% } %>
<i class='fa fa-chevron-right'></i>
<span class='vim-nav-hint'>Next post &gt;</span>

View file

@ -20,10 +20,12 @@
<%= ctx.makeRelativeTime(ctx.post.creationTime) %>
</section>
<% if (ctx.enableSafety) { %>
<section class='safety'>
<i class='fa fa-circle safety-<%- ctx.post.safety %>'></i><!--
--><%- ctx.post.safety[0].toUpperCase() + ctx.post.safety.slice(1) %>
</section>
<% } %>
<section class='zoom'>
<a href class='fit-original'>Original zoom</a> &middot;
@ -34,8 +36,8 @@
<section class='search'>
Search on
<a href='http://iqdb.org/?url=<%- encodeURIComponent(ctx.post.contentUrl) %>'>IQDB</a> &middot;
<a href='https://www.google.com/searchbyimage?&image_url=<%- encodeURIComponent(ctx.post.contentUrl) %>'>Google Images</a>
<a href='http://iqdb.org/?url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>IQDB</a> &middot;
<a href='https://www.google.com/searchbyimage?&image_url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>Google Images</a>
</section>
<section class='social'>
@ -67,20 +69,20 @@
--><% for (let tag of ctx.post.tags) { %><!--
--><li><!--
--><% if (ctx.canViewTags) { %><!--
--><a href='/tag/<%- encodeURIComponent(tag) %>' class='<%= ctx.makeCssName(ctx.getTagCategory(tag), 'tag') %>'><!--
--><a href='<%- ctx.formatClientLink('tag', tag.names[0]) %>' class='<%= ctx.makeCssName(tag.category, 'tag') %>'><!--
--><i class='fa fa-tag'></i><!--
--><% } %><!--
--><% if (ctx.canViewTags) { %><!--
--></a><!--
--><% } %><!--
--><% if (ctx.canListPosts) { %><!--
--><a href='/posts/query=<%- encodeURIComponent(tag) %>' class='<%= ctx.makeCssName(ctx.getTagCategory(tag), 'tag') %>'><!--
--><a href='<%- ctx.formatClientLink('posts', {query: tag.names[0]}) %>' class='<%= ctx.makeCssName(tag.category, 'tag') %>'><!--
--><% } %><!--
--><%- tag %>&#32;<!--
--><%- tag.names[0] %>&#32;<!--
--><% if (ctx.canListPosts) { %><!--
--></a><!--
--><% } %><!--
--><span class='tag-usages' data-pseudo-content='<%- ctx.getTagUsages(tag) %>'></span><!--
--><span class='tag-usages' data-pseudo-content='<%- tag.postCount %>'></span><!--
--></li><!--
--><% } %><!--
--></ul>

View file

@ -41,6 +41,7 @@
</header>
<div class='body'>
<% if (ctx.enableSafety) { %>
<div class='safety'>
<% for (let safety of ['safe', 'sketchy', 'unsafe']) { %>
<%= ctx.makeRadio({
@ -51,6 +52,7 @@
}) %>
<% } %>
</div>
<% } %>
<div class='options'>
<% if (ctx.canUploadAnonymously) { %>

View file

@ -1,24 +1,31 @@
<div class='post-list-header'><%
%><form class='horizontal'><%
%><form class='horizontal search'><%
%><%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.parameters.query}) %><%
%><wbr/><%
%><input class='mousetrap' type='submit' value='Search'/><%
%><wbr/><%
%><% if (ctx.enableSafety) { %><%
%><input data-safety=safe type='button' class='mousetrap safety safety-safe <%- ctx.settings.listPosts.safe ? '' : 'disabled' %>'/><%
%><input data-safety=sketchy type='button' class='mousetrap safety safety-sketchy <%- ctx.settings.listPosts.sketchy ? '' : 'disabled' %>'/><%
%><input data-safety=unsafe type='button' class='mousetrap safety safety-unsafe <%- ctx.settings.listPosts.unsafe ? '' : 'disabled' %>'/><%
%><wbr/><%
%><a class='mousetrap button append' href='/help/search/posts'>Syntax help</a><%
%><% if (ctx.canMassTag) { %><%
%><wbr/><%
%><span class='masstag'><%
%><span class='append masstag-hint'>Tagging with:</span><%
%><a href class='mousetrap button append open-masstag'>Mass tag</a><%
%><wbr/><%
%><%= ctx.makeTextInput({name: 'masstag', value: ctx.parameters.tag}) %><%
%><input class='mousetrap start-tagging' type='submit' value='Start tagging'/><%
%><a href class='mousetrap button append stop-tagging'>Stop tagging</a><%
%></span><%
%><% } %><%
%><wbr/><%
%><a class='mousetrap button append' href='<%- ctx.formatClientLink('help', 'search', 'posts') %>'>Syntax help</a><%
%></form><%
%><% if (ctx.canBulkEditTags) { %><%
%><form class='horizontal bulk-edit bulk-edit-tags'><%
%><span class='append hint'>Tagging with:</span><%
%><a href class='mousetrap button append open'>Mass tag</a><%
%><wbr/><%
%><%= ctx.makeTextInput({name: 'tag', value: ctx.parameters.tag}) %><%
%><input class='mousetrap start' type='submit' value='Start tagging'/><%
%><a href class='mousetrap button append close'>Stop tagging</a><%
%></form><%
%><% } %><%
%><% if (ctx.enableSafety && ctx.canBulkEditSafety) { %><%
%><form class='horizontal bulk-edit bulk-edit-safety'><%
%><a href class='mousetrap button append open'>Mass edit safety</a><%
%><a href class='mousetrap button append close'>Stop editing safety</a><%
%></form><%
%><% } %><%
%></div>

View file

@ -1,11 +1,11 @@
<div class='post-list'>
<% if (ctx.results.length) { %>
<% if (ctx.response.results.length) { %>
<ul>
<% for (let post of ctx.results) { %>
<li>
<% for (let post of ctx.response.results) { %>
<li data-post-id='<%= post.id %>'>
<a class='thumbnail-wrapper <%= post.tags.length > 0 ? "tags" : "no-tags" %>'
title='@<%- post.id %> (<%- post.type %>)&#10;&#10;Tags: <%- post.tags.map(tag => '#' + tag).join(' ') || 'none' %>'
href='<%= ctx.canViewPosts ? ctx.getPostUrl(post.id, ctx.parameters) : "" %>'>
title='@<%- post.id %> (<%- post.type %>)&#10;&#10;Tags: <%- post.tags.map(tag => '#' + tag.names[0]).join(' ') || 'none' %>'
href='<%= ctx.canViewPosts ? ctx.getPostUrl(post.id, ctx.parameters) : '' %>'>
<%= ctx.makeThumbnail(post.thumbnailUrl) %>
<span class='type' data-type='<%- post.type %>'>
<%- post.type %>
@ -33,10 +33,20 @@
</span>
<% } %>
</a>
<% if (ctx.canMassTag && ctx.parameters && ctx.parameters.tag) { %>
<a href data-post-id='<%= post.id %>' class='masstag'>
<span class='edit-overlay'>
<% if (ctx.canBulkEditTags && ctx.parameters && ctx.parameters.tag) { %>
<a href class='tag-flipper'>
</a>
<% } %>
<% if (ctx.canBulkEditSafety && ctx.parameters && ctx.parameters.safety) { %>
<span class='safety-flipper'>
<% for (let safety of ['safe', 'sketchy', 'unsafe']) { %>
<a href data-safety='<%- safety %>' class='safety-<%- safety %><%- post.safety === safety ? ' active' : '' %>'>
</a>
<% } %>
</span>
<% } %>
</span>
</li>
<% } %>
<%= ctx.makeFlexboxAlign() %>

View file

@ -5,7 +5,7 @@
<ul class='input'>
<li>
<%= ctx.makeCheckbox({
text: "Enable keyboard shortcuts <a class='append icon' href='/help/keyboard'><i class='fa fa-question-circle-o'></i></a>",
text: "Enable keyboard shortcuts <a class='append icon' href='" + ctx.formatClientLink('help', 'keyboard') + "'><i class='fa fa-question-circle-o'></i></a>",
name: 'keyboard-shortcuts',
checked: ctx.browsingSettings.keyboardShortcuts,
}) %>

View file

@ -1,7 +1,7 @@
<div class='snapshot-list'>
<% if (ctx.results.length) { %>
<% if (ctx.response.results.length) { %>
<ul>
<% for (let item of ctx.results) { %>
<% for (let item of ctx.response.results) { %>
<li>
<div class='header operation-<%= item.operation %>'>
<span class='time'>

View file

@ -2,15 +2,15 @@
<h1><%- ctx.tag.names[0] %></h1>
<nav class='buttons'><!--
--><ul><!--
--><li data-name='summary'><a href='/tag/<%- encodeURIComponent(ctx.tag.names[0]) %>'>Summary</a></li><!--
--><li data-name='summary'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0]) %>'>Summary</a></li><!--
--><% if (ctx.canEditAnything) { %><!--
--><li data-name='edit'><a href='/tag/<%- encodeURIComponent(ctx.tag.names[0]) %>/edit'>Edit</a></li><!--
--><li data-name='edit'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0], 'edit') %>'>Edit</a></li><!--
--><% } %><!--
--><% if (ctx.canMerge) { %><!--
--><li data-name='merge'><a href='/tag/<%- encodeURIComponent(ctx.tag.names[0]) %>/merge'>Merge with&hellip;</a></li><!--
--><li data-name='merge'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0], 'merge') %>'>Merge with&hellip;</a></li><!--
--><% } %><!--
--><% if (ctx.canDelete) { %><!--
--><li data-name='delete'><a href='/tag/<%- encodeURIComponent(ctx.tag.names[0]) %>/delete'>Delete</a></li><!--
--><li data-name='delete'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0], 'delete') %>'>Delete</a></li><!--
--><% } %><!--
--></ul><!--
--></nav>

View file

@ -1,6 +1,7 @@
<div class='content-wrapper tag-categories'>
<form>
<h1>Tag categories</h1>
<div class="table-wrap">
<table>
<thead>
<tr>
@ -12,6 +13,7 @@
<tbody>
</tbody>
</table>
</div>
<% if (ctx.canCreate) { %>
<p><a href class='add'>Add new category</a></p>

View file

@ -19,7 +19,7 @@
</td>
<td class='usages'>
<% if (ctx.tagCategory.name) { %>
<a href='/tags/query=category:<%- encodeURIComponent(ctx.tagCategory.name) %>'>
<a href='<%- ctx.formatClientLink('tags', {query: 'category:' + ctx.tagCategory.name}) %>'>
<%- ctx.tagCategory.tagCount %>
</a>
<% } else { %>

View file

@ -1,6 +1,6 @@
<div class='tag-delete'>
<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='<%- ctx.formatClientLink('posts', {query: ctx.tag.names[0]}) %>'><%- ctx.tag.postCount %> usage(s)</a>.</p>
<ul class='input'>
<li>

View file

@ -22,18 +22,12 @@
</li>
<li class='implications'>
<% if (ctx.canEditImplications) { %>
<%= ctx.makeTextInput({
text: 'Implications',
value: ctx.tag.implications.join(' '),
}) %>
<%= ctx.makeTextInput({text: 'Implications'}) %>
<% } %>
</li>
<li class='suggestions'>
<% if (ctx.canEditSuggestions) { %>
<%= ctx.makeTextInput({
text: 'Suggestions',
value: ctx.tag.suggestions.join(' '),
}) %>
<%= ctx.makeTextInput({text: 'Suggestions'}) %>
<% } %>
</li>
<li class='description'>

View file

@ -2,12 +2,14 @@
<form>
<ul class='input'>
<li class='target'>
<%= ctx.makeTextInput({required: true, text: 'Target tag', pattern: ctx.tagNamePattern}) %>
<%= ctx.makeTextInput({name: 'target-tag', required: true, text: 'Target tag', pattern: ctx.tagNamePattern}) %>
</li>
<li>
<p>Usages in posts, suggestions and implications will be
merged. Category and aliases need to be handled manually.</p>
merged. Category needs to be handled manually.</p>
<%= ctx.makeCheckbox({name: 'alias', text: 'Make this tag an alias of the target tag.'}) %>
<%= ctx.makeCheckbox({required: true, text: 'I confirm that I want to merge this tag.'}) %>
</li>

View file

@ -9,7 +9,7 @@
Aliases:<br/>
<ul><!--
--><% for (let name of ctx.tag.names.slice(1)) { %><!--
--><li><%= ctx.makeTagLink(name) %></li><!--
--><li><%= ctx.makeTagLink(name, false, false, ctx.tag) %></li><!--
--><% } %><!--
--></ul>
</section>
@ -18,7 +18,7 @@
Implications:<br/>
<ul><!--
--><% for (let tag of ctx.tag.implications) { %><!--
--><li><%= ctx.makeTagLink(tag) %></li><!--
--><li><%= ctx.makeTagLink(tag.names[0], false, false, tag) %></li><!--
--><% } %><!--
--></ul>
</section>
@ -27,7 +27,7 @@
Suggestions:<br/>
<ul><!--
--><% for (let tag of ctx.tag.suggestions) { %><!--
--><li><%= ctx.makeTagLink(tag) %></li><!--
--><li><%= ctx.makeTagLink(tag.names[0], false, false, tag) %></li><!--
--><% } %><!--
--></ul>
</section>
@ -36,6 +36,6 @@
<section class='description'>
<hr/>
<%= ctx.makeMarkdown(ctx.tag.description || 'This tag has no description yet.') %>
<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='<%- ctx.formatClientLink('posts', {query: ctx.tag.names[0]}) %>'><%- ctx.tag.postCount %> usage(s)</a>.</p>
</section>
</div>

View file

@ -8,9 +8,9 @@
<div class='buttons'>
<input type='submit' value='Search'/>
<a class='button append' href='/help/search/tags'>Syntax help</a>
<a class='button append' href='<%- ctx.formatClientLink('help', 'search', 'tags') %>'>Syntax help</a>
<% if (ctx.canEditTagCategories) { %>
<a class='append' href='/tag-categories'>Tag categories</a>
<a class='append' href='<%- ctx.formatClientLink('tag-categories') %>'>Tag categories</a>
<% } %>
</div>
</form>

View file

@ -1,58 +1,58 @@
<div class='tag-list'>
<% if (ctx.results.length) { %>
<div class='tag-list table-wrap'>
<% if (ctx.response.results.length) { %>
<table>
<thead>
<th class='names'>
<% if (ctx.query == 'sort:name' || !ctx.query) { %>
<a href='/tags/query=-sort:name'>Tag name(s)</a>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:name'}) %>'>Tag name(s)</a>
<% } else { %>
<a href='/tags/query=sort:name'>Tag name(s)</a>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:name'}) %>'>Tag name(s)</a>
<% } %>
</th>
<th class='implications'>
<% if (ctx.query == 'sort:implication-count') { %>
<a href='/tags/query=-sort:implication-count'>Implications</a>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:implication-count'}) %>'>Implications</a>
<% } else { %>
<a href='/tags/query=sort:implication-count'>Implications</a>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:implication-count'}) %>'>Implications</a>
<% } %>
</th>
<th class='suggestions'>
<% if (ctx.query == 'sort:suggestion-count') { %>
<a href='/tags/query=-sort:suggestion-count'>Suggestions</a>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:suggestion-count'}) %>'>Suggestions</a>
<% } else { %>
<a href='/tags/query=sort:suggestion-count'>Suggestions</a>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:suggestion-count'}) %>'>Suggestions</a>
<% } %>
</th>
<th class='usages'>
<% if (ctx.query == 'sort:usages') { %>
<a href='/tags/query=-sort:usages'>Usages</a>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:usages'}) %>'>Usages</a>
<% } else { %>
<a href='/tags/query=sort:usages'>Usages</a>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:usages'}) %>'>Usages</a>
<% } %>
</th>
<th class='creation-time'>
<% if (ctx.query == 'sort:creation-time') { %>
<a href='/tags/query=-sort:creation-time'>Created on</a>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:creation-time'}) %>'>Created on</a>
<% } else { %>
<a href='/tags/query=sort:creation-time'>Created on</a>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:creation-time'}) %>'>Created on</a>
<% } %>
</th>
</thead>
<tbody>
<% for (let tag of ctx.results) { %>
<% for (let tag of ctx.response.results) { %>
<tr>
<td class='names'>
<ul>
<% for (let name of tag.names) { %>
<li><%= ctx.makeTagLink(name) %></li>
<li><%= ctx.makeTagLink(name, false, false, tag) %></li>
<% } %>
</ul>
</td>
<td class='implications'>
<% if (tag.implications.length) { %>
<ul>
<% for (let name of tag.implications) { %>
<li><%= ctx.makeTagLink(name) %></li>
<% for (let relation of tag.implications) { %>
<li><%= ctx.makeTagLink(relation.names[0], false, false, relation) %></li>
<% } %>
</ul>
<% } else { %>
@ -62,8 +62,8 @@
<td class='suggestions'>
<% if (tag.suggestions.length) { %>
<ul>
<% for (let name of tag.suggestions) { %>
<li><%= ctx.makeTagLink(name) %></li>
<% for (let relation of tag.suggestions) { %>
<li><%= ctx.makeTagLink(relation.names[0], false, false, relation) %></li>
<% } %>
</ul>
<% } else { %>

View file

@ -1,5 +1,9 @@
<nav id='top-navigation' class='buttons'><!--
--><ul><!--
--><button id="mobile-navigation-toggle"><!--
--><span class="site-name"><%- ctx.name %></span><!--
--><span class="toggle-icon"><i class="fa fa-bars"></i></span><!--
--></button><!--
--><% for (let item of ctx.items) { %><!--
--><% if (item.available) { %><!--
--><li data-name='<%- item.key %>'><!--

View file

@ -2,12 +2,15 @@
<h1><%- ctx.user.name %></h1>
<nav class='buttons'><!--
--><ul><!--
--><li data-name='summary'><a href='/user/<%- encodeURIComponent(ctx.user.name) %>'>Summary</a></li><!--
--><li data-name='summary'><a href='<%- ctx.formatClientLink('user', ctx.user.name) %>'>Summary</a></li><!--
--><% if (ctx.canEditAnything) { %><!--
--><li data-name='edit'><a href='/user/<%- encodeURIComponent(ctx.user.name) %>/edit'>Account settings</a></li><!--
--><li data-name='edit'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'edit') %>'>Settings</a></li><!--
--><% } %><!--
--><% if (ctx.canListTokens) { %><!--
--><li data-name='list-tokens'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'list-tokens') %>'>Login tokens</a></li><!--
--><% } %><!--
--><% if (ctx.canDelete) { %><!--
--><li data-name='delete'><a href='/user/<%- encodeURIComponent(ctx.user.name) %>/delete'>Account deletion</a></li><!--
--><li data-name='delete'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'delete') %>'>Delete</a></li><!--
--><% } %><!--
--></ul><!--
--></nav>

View file

@ -51,6 +51,6 @@
<li><i class='fa fa-star-half-o'></i> vote up/down on posts and comments</li>
</ul>
<hr/>
<p>By creating an account, you are agreeing to the <a href='/help/tos'>Terms of Service</a>.</p>
<p>By creating an account, you are agreeing to the <a href='<%- ctx.formatClientLink('help', 'tos') %>'>Terms of Service</a>.</p>
</div>
</div>

View file

@ -10,9 +10,9 @@
<nav>
<p><strong>Quick links</strong></p>
<ul>
<li><a href='/posts/query=submit:<%- encodeURIComponent(ctx.user.name) %>'><%- ctx.user.uploadedPostCount %> uploads</a></li>
<li><a href='/posts/query=fav:<%- encodeURIComponent(ctx.user.name) %>'><%- ctx.user.favoritePostCount %> favorites</a></li>
<li><a href='/posts/query=comment:<%- encodeURIComponent(ctx.user.name) %>'><%- ctx.user.commentCount %> comments</a></li>
<li><a href='<%- ctx.formatClientLink('posts', {query: 'submit:' + ctx.user.name}) %>'><%- ctx.user.uploadedPostCount %> uploads</a></li>
<li><a href='<%- ctx.formatClientLink('posts', {query: 'fav:' + ctx.user.name}) %>'><%- ctx.user.favoritePostCount %> favorites</a></li>
<li><a href='<%- ctx.formatClientLink('posts', {query: 'comment:' + ctx.user.name}) %>'><%- ctx.user.commentCount %> comments</a></li>
</ul>
</nav>
@ -20,8 +20,8 @@
<nav>
<p><strong>Only visible to you</strong></p>
<ul>
<li><a href='/posts/query=special:liked'><%- ctx.user.likedPostCount %> liked posts</a></li>
<li><a href='/posts/query=special:disliked'><%- ctx.user.dislikedPostCount %> disliked posts</a></li>
<li><a href='<%- ctx.formatClientLink('posts', {query: 'special:liked'}) %>'><%- ctx.user.likedPostCount %> liked posts</a></li>
<li><a href='<%- ctx.formatClientLink('posts', {query: 'special:disliked'}) %>'><%- ctx.user.dislikedPostCount %> disliked posts</a></li>
</ul>
</nav>
<% } %>

View file

@ -0,0 +1,74 @@
<div id='user-tokens'>
<div class='messages'></div>
<% if (ctx.tokens.length > 0) { %>
<div class='token-flex-container'>
<% _.each(ctx.tokens, function(token, index) { %>
<div class='token-flex-row'>
<div class='token-flex-column token-flex-labels'>
<div class='token-flex-row'>Token:</div>
<div class='token-flex-row'>Note:</div>
<div class='token-flex-row'>Created:</div>
<div class='token-flex-row'>Expires:</div>
<div class='token-flex-row no-wrap'>Last used:</div>
</div>
<div class='token-flex-column full-width'>
<div class='token-flex-row'><%= token.token %></div>
<div class='token-flex-row'>
<% if (token.note !== null) { %>
<%= token.note %>
<% } else { %>
No note
<% } %>
<a class='token-change-note' data-token-id='<%= index %>' href='#'>(change)</a>
</div>
<div class='token-flex-row'><%= ctx.makeRelativeTime(token.creationTime) %></div>
<div class='token-flex-row'>
<% if (token.expirationTime) { %>
<%= ctx.makeRelativeTime(token.expirationTime) %>
<% } else { %>
No expiration
<% } %>
</div>
<div class='token-flex-row'><%= ctx.makeRelativeTime(token.lastUsageTime) %></div>
</div>
</div>
<div class='token-flex-row'>
<div class='token-flex-column full-width'>
<div class='token-flex-row'>
<form class='token' data-token-id='<%= index %>'>
<% if (token.isCurrentAuthToken) { %>
<input type='submit' value='Delete and logout'
title='This token is used to authenticate this client, deleting it will force a logout.'/>
<% } else { %>
<input type='submit' value='Delete'/>
<% } %>
</form>
</div>
</div>
</div>
<hr/>
<% }); %>
</div>
<% } else { %>
<h2>No Registered Tokens</h2>
<% } %>
<form id='create-token-form'>
<ul class='input'>
<li class='note'>
<%= ctx.makeTextInput({
text: 'Note',
id: 'note',
}) %>
</li>
<li class='expirationTime'>
<%= ctx.makeDateInput({
text: 'Expires',
id: 'expirationTime',
}) %>
</li>
</ul>
<div class='buttons'>
<input type='submit' value='Create token'/>
</div>
</form>
</div>

View file

@ -8,7 +8,7 @@
<div class='buttons'>
<input type='submit' value='Search'/>
<a class='append' href='/help/search/users'>Syntax help</a>
<a class='append' href='<%- ctx.formatClientLink('help', 'search', 'users') %>'>Syntax help</a>
</div>
</form>
</div>

View file

@ -1,10 +1,10 @@
<div class='user-list'>
<ul><!--
--><% for (let user of ctx.results) { %><!--
--><% for (let user of ctx.response.results) { %><!--
--><li>
<div class='wrapper'>
<% if (ctx.canViewUsers) { %>
<a class='image' href='/user/<%- encodeURIComponent(user.name) %>'>
<a class='image' href='<%- ctx.formatClientLink('user', user.name) %>'>
<% } %>
<%= ctx.makeThumbnail(user.avatarUrl) %>
<% if (ctx.canViewUsers) { %>
@ -12,7 +12,7 @@
<% } %>
<div class='details'>
<% if (ctx.canViewUsers) { %>
<a href='/user/<%- encodeURIComponent(user.name) %>'>
<a href='<%- ctx.formatClientLink('user', user.name) %>'>
<% } %>
<%- user.name %>
<% if (ctx.canViewUsers) { %>

View file

@ -2,11 +2,12 @@
const cookies = require('js-cookie');
const request = require('superagent');
const config = require('./config.js');
const events = require('./events.js');
const progress = require('./util/progress.js');
const uri = require('./util/uri.js');
let fileTokens = {};
let remoteConfig = null;
class Api extends events.EventTarget {
constructor() {
@ -14,6 +15,7 @@ class Api extends events.EventTarget {
this.user = null;
this.userName = null;
this.userPassword = null;
this.token = null;
this.cache = {};
this.allRanks = [
'anonymous',
@ -63,14 +65,53 @@ class Api extends events.EventTarget {
return this._wrappedRequest(url, request.delete, data, {}, options);
}
fetchConfig() {
if (remoteConfig === null) {
return this.get(uri.formatApiLink('info'))
.then(response => {
remoteConfig = response.config;
});
} else {
return Promise.resolve();
}
}
getName() {
return remoteConfig.name;
}
getTagNameRegex() {
return remoteConfig.tagNameRegex;
}
getPasswordRegex() {
return remoteConfig.passwordRegex;
}
getUserNameRegex() {
return remoteConfig.userNameRegex;
}
getContactEmail() {
return remoteConfig.contactEmail;
}
canSendMails() {
return !!remoteConfig.canSendMails;
}
safetyEnabled() {
return !!remoteConfig.enableSafety;
}
hasPrivilege(lookup) {
let minViableRank = null;
for (let privilege of Object.keys(config.privileges)) {
if (!privilege.startsWith(lookup)) {
for (let p of Object.keys(remoteConfig.privileges)) {
if (!p.startsWith(lookup)) {
continue;
}
const rankName = config.privileges[privilege];
const rankIndex = this.allRanks.indexOf(rankName);
const rankIndex = this.allRanks.indexOf(
remoteConfig.privileges[p]);
if (minViableRank === null || rankIndex < minViableRank) {
minViableRank = rankIndex;
}
@ -86,11 +127,76 @@ class Api extends events.EventTarget {
loginFromCookies() {
const auth = cookies.getJSON('auth');
return auth && auth.user && auth.password ?
this.login(auth.user, auth.password, true) :
return auth && auth.user && auth.token ?
this.loginWithToken(auth.user, auth.token, true) :
Promise.resolve();
}
loginWithToken(userName, token, doRemember) {
this.cache = {};
return new Promise((resolve, reject) => {
this.userName = userName;
this.token = token;
this.get('/user/' + userName + '?bump-login=true')
.then(response => {
const options = {};
if (doRemember) {
options.expires = 365;
}
cookies.set(
'auth',
{'user': userName, 'token': token},
options);
this.user = response;
resolve();
this.dispatchEvent(new CustomEvent('login'));
}, error => {
reject(error);
this.logout();
});
});
}
createToken(userName, options) {
let userTokenRequest = {
enabled: true,
note: 'Web Login Token'
};
if (typeof options.expires !== 'undefined') {
userTokenRequest.expirationTime = new Date().addDays(options.expires).toISOString()
}
return new Promise((resolve, reject) => {
this.post('/user-token/' + userName, userTokenRequest)
.then(response => {
cookies.set(
'auth',
{'user': userName, 'token': response.token},
options);
this.userName = userName;
this.token = response.token;
this.userPassword = null;
}, error => {
reject(error);
});
});
}
deleteToken(userName, userToken) {
return new Promise((resolve, reject) => {
this.delete('/user-token/' + userName + '/' + userToken, {})
.then(response => {
const options = {};
cookies.set(
'auth',
{'user': userName, 'token': null},
options);
resolve();
}, error => {
reject(error);
});
});
}
login(userName, userPassword, doRemember) {
this.cache = {};
return new Promise((resolve, reject) => {
@ -102,10 +208,7 @@ class Api extends events.EventTarget {
if (doRemember) {
options.expires = 365;
}
cookies.set(
'auth',
{'user': userName, 'password': userPassword},
options);
this.createToken(this.userName, options);
this.user = response;
resolve();
this.dispatchEvent(new CustomEvent('login'));
@ -117,9 +220,20 @@ class Api extends events.EventTarget {
}
logout() {
let self = this;
this.deleteToken(this.userName, this.token)
.then(response => {
self._logout();
}, error => {
self._logout();
});
}
_logout() {
this.user = null;
this.userName = null;
this.userPassword = null;
this.token = null;
this.dispatchEvent(new CustomEvent('logout'));
}
@ -136,9 +250,13 @@ class Api extends events.EventTarget {
}
}
isCurrentAuthToken(userToken) {
return userToken.token === this.token;
}
_getFullUrl(url) {
const fullUrl =
(config.apiUrl + '/' + url).replace(/([^:])\/+/g, '$1/');
('/api/' + url).replace(/([^:])\/+/g, '$1/');
const matches = fullUrl.match(/^([^?]*)\??(.*)$/);
const baseUrl = matches[1];
const request = matches[2];
@ -257,7 +375,11 @@ class Api extends events.EventTarget {
}
try {
if (this.userName && this.userPassword) {
if (this.userName && this.token) {
req.auth = null;
req.set('Authorization', 'Token '
+ new Buffer(this.userName + ":" + this.token).toString('base64'))
} else if (this.userName && this.userPassword) {
req.auth(
this.userName,
encodeURIComponent(this.userPassword)

View file

@ -2,6 +2,7 @@
const router = require('../router.js');
const api = require('../api.js');
const uri = require('../util/uri.js');
const topNavigation = require('../models/top_navigation.js');
const LoginView = require('../views/login_view.js');
@ -21,7 +22,7 @@ class LoginController {
api.forget();
api.login(e.detail.name, e.detail.password, e.detail.remember)
.then(() => {
const ctx = router.show('/');
const ctx = router.show(uri.formatClientLink());
ctx.controller.showSuccess('Logged in');
}, error => {
this._loginView.showError(error.message);
@ -34,16 +35,16 @@ class LogoutController {
constructor() {
api.forget();
api.logout();
const ctx = router.show('/');
const ctx = router.show(uri.formatClientLink());
ctx.controller.showSuccess('Logged out');
}
}
module.exports = router => {
router.enter('/login', (ctx, next) => {
router.enter(['login'], (ctx, next) => {
ctx.controller = new LoginController();
});
router.enter('/logout', (ctx, next) => {
router.enter(['logout'], (ctx, next) => {
ctx.controller = new LogoutController();
});
};

View file

@ -1,7 +1,7 @@
'use strict';
const api = require('../api.js');
const misc = require('../util/misc.js');
const uri = require('../util/uri.js');
const PostList = require('../models/post_list.js');
const topNavigation = require('../models/top_navigation.js');
const PageController = require('../controllers/page_controller.js');
@ -25,14 +25,16 @@ class CommentsController {
this._pageController = new PageController();
this._pageController.run({
parameters: ctx.parameters,
getClientUrlForPage: page => {
defaultLimit: 10,
getClientUrlForPage: (offset, limit) => {
const parameters = Object.assign(
{}, ctx.parameters, {page: page});
return '/comments/' + misc.formatUrlParameters(parameters);
{}, ctx.parameters, {offset: offset, limit: limit});
return uri.formatClientLink('comments', parameters);
},
requestPage: page => {
requestPage: (offset, limit) => {
return PostList.search(
'sort:comment-date comment-count-min:1', page, 10, fields);
'sort:comment-date comment-count-min:1',
offset, limit, fields);
},
pageRenderer: pageCtx => {
Object.assign(pageCtx, {
@ -69,7 +71,6 @@ class CommentsController {
};
module.exports = router => {
router.enter('/comments/:parameters?',
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
router.enter(['comments'],
(ctx, next) => { new CommentsController(ctx); });
};

View file

@ -12,13 +12,13 @@ class HelpController {
}
module.exports = router => {
router.enter('/help', (ctx, next) => {
router.enter(['help'], (ctx, next) => {
new HelpController();
});
router.enter('/help/:section', (ctx, next) => {
router.enter(['help', ':section'], (ctx, next) => {
new HelpController(ctx.parameters.section);
});
router.enter('/help/:section/:subsection', (ctx, next) => {
router.enter(['help', ':section', ':subsection'], (ctx, next) => {
new HelpController(ctx.parameters.section, ctx.parameters.subsection);
});
};

View file

@ -12,7 +12,7 @@ class HomeController {
topNavigation.setTitle('Home');
this._homeView = new HomeView({
name: config.name,
name: api.getName(),
version: config.meta.version,
buildDate: config.meta.buildDate,
canListSnapshots: api.hasPrivilege('snapshots:list'),
@ -44,7 +44,7 @@ class HomeController {
};
module.exports = router => {
router.enter('/', (ctx, next) => {
router.enter([], (ctx, next) => {
ctx.controller = new HomeController();
});
};

View file

@ -12,7 +12,7 @@ class NotFoundController {
};
module.exports = router => {
router.enter('*', (ctx, next) => {
router.enter(null, (ctx, next) => {
ctx.controller = new NotFoundController(ctx.canonicalPath);
});
};

View file

@ -18,12 +18,6 @@ class PageController {
}
run(ctx) {
const extendedContext = {
getClientUrlForPage: ctx.getClientUrlForPage,
parameters: ctx.parameters,
};
ctx.pageContext = Object.assign({}, extendedContext);
this._view.run(ctx);
}

View file

@ -2,6 +2,7 @@
const router = require('../router.js');
const api = require('../api.js');
const uri = require('../util/uri.js');
const topNavigation = require('../models/top_navigation.js');
const PasswordResetView = require('../views/password_reset_view.js');
@ -20,7 +21,7 @@ class PasswordResetController {
this._passwordResetView.disableForm();
api.forget();
api.logout();
api.get('/password-reset/' + e.detail.userNameOrEmail)
api.get(uri.formatApiLink('password-reset', e.detail.userNameOrEmail))
.then(() => {
this._passwordResetView.showSuccess(
'E-mail has been sent. To finish the procedure, ' +
@ -37,26 +38,26 @@ class PasswordResetFinishController {
api.forget();
api.logout();
let password = null;
api.post('/password-reset/' + name, {token: token})
api.post(uri.formatApiLink('password-reset', name), {token: token})
.then(response => {
password = response.password;
return api.login(name, password, false);
}).then(() => {
const ctx = router.show('/');
const ctx = router.show(uri.formatClientLink());
ctx.controller.showSuccess('New password: ' + password);
}, error => {
const ctx = router.show('/');
const ctx = router.show(uri.formatClientLink());
ctx.controller.showError(error.message);
});
}
}
module.exports = router => {
router.enter('/password-reset', (ctx, next) => {
router.enter(['password-reset'], (ctx, next) => {
ctx.controller = new PasswordResetController();
});
router.enter(/\/password-reset\/([^:]+):([^:]+)$/, (ctx, next) => {
ctx.controller = new PasswordResetFinishController(
ctx.parameters[0], ctx.parameters[1]);
router.enter(['password-reset', ':descriptor'], (ctx, next) => {
const [name, token] = ctx.parameters.descriptor.split(':', 2);
ctx.controller = new PasswordResetFinishController(name, token);
});
};

View file

@ -3,6 +3,7 @@
const router = require('../router.js');
const api = require('../api.js');
const misc = require('../util/misc.js');
const uri = require('../util/uri.js');
const settings = require('../models/settings.js');
const Post = require('../models/post.js');
const PostList = require('../models/post_list.js');
@ -55,7 +56,8 @@ class PostDetailController extends BasePostController {
misc.disableExitConfirmation();
if (this._id !== e.detail.post.id) {
router.replace(
'/post/' + e.detail.post.id + '/' + section, null, false);
uri.formatClientLink('post', e.detail.post.id, section),
null, false);
}
}
@ -67,7 +69,9 @@ class PostDetailController extends BasePostController {
this._installView(e.detail.post, 'merge');
this._view.showSuccess('Post merged.');
router.replace(
'/post/' + e.detail.targetPost.id + '/merge', null, false);
uri.formatClientLink(
'post', e.detail.targetPost.id, 'merge'),
null, false);
}, error => {
this._view.showError(error.message);
this._view.enableForm();
@ -77,7 +81,7 @@ class PostDetailController extends BasePostController {
module.exports = router => {
router.enter(
'/post/:id/merge',
['post', ':id', 'merge'],
(ctx, next) => {
ctx.controller = new PostDetailController(ctx, 'merge');
});

View file

@ -1,8 +1,9 @@
'use strict';
const router = require('../router.js');
const api = require('../api.js');
const settings = require('../models/settings.js');
const misc = require('../util/misc.js');
const uri = require('../util/uri.js');
const PostList = require('../models/post_list.js');
const topNavigation = require('../models/top_navigation.js');
const PageController = require('../controllers/page_controller.js');
@ -11,7 +12,7 @@ const PostsPageView = require('../views/posts_page_view.js');
const EmptyView = require('../views/empty_view.js');
const fields = [
'id', 'thumbnailUrl', 'type',
'id', 'thumbnailUrl', 'type', 'safety',
'score', 'favoriteCount', 'commentCount', 'tags', 'version'];
class PostListController {
@ -31,8 +32,12 @@ class PostListController {
this._headerView = new PostsHeaderView({
hostNode: this._pageController.view.pageHeaderHolderNode,
parameters: ctx.parameters,
canMassTag: api.hasPrivilege('tags:masstag'),
massTagTags: this._massTagTags,
enableSafety: api.safetyEnabled(),
canBulkEditTags: api.hasPrivilege('posts:bulk-edit:tags'),
canBulkEditSafety: api.hasPrivilege('posts:bulk-edit:safety'),
bulkEdit: {
tags: this._bulkEditTags
},
});
this._headerView.addEventListener(
'navigate', e => this._evtNavigate(e));
@ -44,68 +49,65 @@ class PostListController {
this._pageController.showSuccess(message);
}
get _massTagTags() {
get _bulkEditTags() {
return (this._ctx.parameters.tag || '').split(/\s+/).filter(s => s);
}
_evtNavigate(e) {
history.pushState(
null,
window.title,
'/posts/' + misc.formatUrlParameters(e.detail.parameters));
router.showNoDispatch(
uri.formatClientLink('posts', e.detail.parameters));
Object.assign(this._ctx.parameters, e.detail.parameters);
this._syncPageController();
}
_evtTag(e) {
for (let tag of this._massTagTags) {
e.detail.post.addTag(tag);
}
e.detail.post.save().catch(error => window.alert(error.message));
Promise.all(
this._bulkEditTags.map(tag =>
e.detail.post.tags.addByName(tag)))
.then(e.detail.post.save())
.catch(error => window.alert(error.message));
}
_evtUntag(e) {
for (let tag of this._massTagTags) {
e.detail.post.removeTag(tag);
for (let tag of this._bulkEditTags) {
e.detail.post.tags.removeByName(tag);
}
e.detail.post.save().catch(error => window.alert(error.message));
}
_decorateSearchQuery(text) {
const browsingSettings = settings.get();
let disabledSafety = [];
for (let key of Object.keys(browsingSettings.listPosts)) {
if (browsingSettings.listPosts[key] === false) {
disabledSafety.push(key);
}
}
if (disabledSafety.length) {
text = `-rating:${disabledSafety.join(',')} ${text}`;
}
return text.trim();
_evtChangeSafety(e) {
e.detail.post.safety = e.detail.safety;
e.detail.post.save().catch(error => window.alert(error.message));
}
_syncPageController() {
this._pageController.run({
parameters: this._ctx.parameters,
getClientUrlForPage: page => {
return '/posts/' + misc.formatUrlParameters(
Object.assign({}, this._ctx.parameters, {page: page}));
defaultLimit: parseInt(settings.get().postsPerPage),
getClientUrlForPage: (offset, limit) => {
const parameters = Object.assign(
{}, this._ctx.parameters, {offset: offset, limit: limit});
return uri.formatClientLink('posts', parameters);
},
requestPage: page => {
requestPage: (offset, limit) => {
return PostList.search(
this._decorateSearchQuery(this._ctx.parameters.query),
page, settings.get().postsPerPage, fields);
this._ctx.parameters.query, offset, limit, fields);
},
pageRenderer: pageCtx => {
Object.assign(pageCtx, {
canViewPosts: api.hasPrivilege('posts:view'),
canMassTag: api.hasPrivilege('tags:masstag'),
massTagTags: this._massTagTags,
canBulkEditTags: api.hasPrivilege('posts:bulk-edit:tags'),
canBulkEditSafety:
api.hasPrivilege('posts:bulk-edit:safety'),
bulkEdit: {
tags: this._bulkEditTags,
},
});
const view = new PostsPageView(pageCtx);
view.addEventListener('tag', e => this._evtTag(e));
view.addEventListener('untag', e => this._evtUntag(e));
view.addEventListener(
'changeSafety', e => this._evtChangeSafety(e));
return view;
},
});
@ -114,7 +116,6 @@ class PostListController {
module.exports = router => {
router.enter(
'/posts/:parameters(.*)?',
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
['posts'],
(ctx, next) => { ctx.controller = new PostListController(ctx); });
};

View file

@ -2,6 +2,7 @@
const router = require('../router.js');
const api = require('../api.js');
const uri = require('../util/uri.js');
const misc = require('../util/misc.js');
const settings = require('../models/settings.js');
const Comment = require('../models/comment.js');
@ -19,8 +20,8 @@ class PostMainController extends BasePostController {
Promise.all([
Post.get(ctx.parameters.id),
PostList.getAround(
ctx.parameters.id, this._decorateSearchQuery(
parameters ? parameters.query : '')),
ctx.parameters.id,
parameters ? parameters.query : null),
]).then(responses => {
const [post, aroundResponse] = responses;
@ -29,8 +30,8 @@ class PostMainController extends BasePostController {
if (parameters.query) {
ctx.state.parameters = parameters;
const url = editMode ?
'/post/' + ctx.parameters.id + '/edit' :
'/post/' + ctx.parameters.id;
uri.formatClientLink('post', ctx.parameters.id, 'edit') :
uri.formatClientLink('post', ctx.parameters.id);
router.replace(url, ctx.state, false);
}
@ -90,20 +91,6 @@ class PostMainController extends BasePostController {
});
}
_decorateSearchQuery(text) {
const browsingSettings = settings.get();
let disabledSafety = [];
for (let key of Object.keys(browsingSettings.listPosts)) {
if (browsingSettings.listPosts[key] === false) {
disabledSafety.push(key);
}
}
if (disabledSafety.length) {
text = `-rating:${disabledSafety.join(',')} ${text}`;
}
return text.trim();
}
_evtFitModeChange(e) {
const browsingSettings = settings.get();
browsingSettings.fitMode = e.detail.mode;
@ -124,7 +111,7 @@ class PostMainController extends BasePostController {
}
_evtMergePost(e) {
router.show('/post/' + e.detail.post.id + '/merge');
router.show(uri.formatClientLink('post', e.detail.post.id, 'merge'));
}
_evtDeletePost(e) {
@ -133,7 +120,7 @@ class PostMainController extends BasePostController {
e.detail.post.delete()
.then(() => {
misc.disableExitConfirmation();
const ctx = router.show('/posts');
const ctx = router.show(uri.formatClientLink('posts'));
ctx.controller.showSuccess('Post deleted.');
}, error => {
this._view.sidebarControl.showError(error.message);
@ -145,9 +132,6 @@ class PostMainController extends BasePostController {
this._view.sidebarControl.disableForm();
this._view.sidebarControl.clearMessages();
const post = e.detail.post;
if (e.detail.tags !== undefined) {
post.tags = e.detail.tags;
}
if (e.detail.safety !== undefined) {
post.safety = e.detail.safety;
}
@ -244,8 +228,7 @@ class PostMainController extends BasePostController {
}
module.exports = router => {
router.enter('/post/:id/edit/:parameters(.*)?',
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
router.enter(['post', ':id', 'edit'],
(ctx, next) => {
// restore parameters from history state
if (ctx.state.parameters) {
@ -254,8 +237,7 @@ module.exports = router => {
ctx.controller = new PostMainController(ctx, true);
});
router.enter(
'/post/:id/:parameters(.*)?',
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
['post', ':id'],
(ctx, next) => {
// restore parameters from history state
if (ctx.state.parameters) {

View file

@ -2,10 +2,12 @@
const api = require('../api.js');
const router = require('../router.js');
const uri = require('../util/uri.js');
const misc = require('../util/misc.js');
const progress = require('../util/progress.js');
const topNavigation = require('../models/top_navigation.js');
const Post = require('../models/post.js');
const Tag = require('../models/tag.js');
const PostUploadView = require('../views/post_upload_view.js');
const EmptyView = require('../views/empty_view.js');
@ -28,6 +30,7 @@ class PostUploadController {
this._view = new PostUploadView({
canUploadAnonymously: api.hasPrivilege('posts:create:anonymous'),
canViewPosts: api.hasPrivilege('posts:view'),
enableSafety: api.safetyEnabled(),
});
this._view.addEventListener('change', e => this._evtChange(e));
this._view.addEventListener('submit', e => this._evtSubmit(e));
@ -61,7 +64,7 @@ class PostUploadController {
.then(() => {
this._view.clearMessages();
misc.disableExitConfirmation();
const ctx = router.show('/posts');
const ctx = router.show(uri.formatClientLink('posts'));
ctx.controller.showSuccess('Posts uploaded.');
}, error => {
if (error.uploadable) {
@ -95,16 +98,20 @@ class PostUploadController {
return reverseSearchPromise.then(searchResult => {
if (searchResult) {
// notify about exact duplicate
if (searchResult.exactPost && !skipDuplicates) {
if (searchResult.exactPost) {
if (skipDuplicates) {
this._view.removeUploadable(uploadable);
return Promise.resolve();
} else {
let error = new Error('Post already uploaded ' +
`(@${searchResult.exactPost.id})`);
error.uploadable = uploadable;
return Promise.reject(error);
}
}
// notify about similar posts
if (!searchResult.exactPost &&
searchResult.similarPosts.length) {
if (searchResult.similarPosts.length) {
let error = new Error(
`Found ${searchResult.similarPosts.length} similar ` +
'posts.\nYou can resume or discard this upload.');
@ -137,7 +144,11 @@ class PostUploadController {
let post = new Post();
post.safety = uploadable.safety;
post.flags = uploadable.flags;
post.tags = uploadable.tags;
for (let tagName of uploadable.tags) {
const tag = new Tag();
tag.names = [tagName];
post.tags.add(tag);
}
post.relations = uploadable.relations;
post.newContent = uploadable.url || uploadable.file;
return post;
@ -145,7 +156,7 @@ class PostUploadController {
}
module.exports = router => {
router.enter('/upload', (ctx, next) => {
router.enter(['upload'], (ctx, next) => {
ctx.controller = new PostUploadController();
});
};

View file

@ -22,7 +22,7 @@ class SettingsController {
};
module.exports = router => {
router.enter('/settings', (ctx, next) => {
router.enter(['settings'], (ctx, next) => {
ctx.controller = new SettingsController();
});
};

View file

@ -1,7 +1,7 @@
'use strict';
const api = require('../api.js');
const misc = require('../util/misc.js');
const uri = require('../util/uri.js');
const SnapshotList = require('../models/snapshot_list.js');
const PageController = require('../controllers/page_controller.js');
const topNavigation = require('../models/top_navigation.js');
@ -22,13 +22,14 @@ class SnapshotsController {
this._pageController = new PageController();
this._pageController.run({
parameters: ctx.parameters,
getClientUrlForPage: page => {
defaultLimit: 25,
getClientUrlForPage: (offset, limit) => {
const parameters = Object.assign(
{}, ctx.parameters, {page: page});
return '/history/' + misc.formatUrlParameters(parameters);
{}, ctx.parameters, {offset: offset, limit: limit});
return uri.formatClientLink('history', parameters);
},
requestPage: page => {
return SnapshotList.search('', page, 25);
requestPage: (offset, limit) => {
return SnapshotList.search('', offset, limit);
},
pageRenderer: pageCtx => {
Object.assign(pageCtx, {
@ -43,7 +44,6 @@ class SnapshotsController {
}
module.exports = router => {
router.enter('/history/:parameters?',
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
router.enter(['history'],
(ctx, next) => { ctx.controller = new SnapshotsController(ctx); });
};

View file

@ -40,7 +40,7 @@ class TagCategoriesController {
this._view.disableForm();
this._tagCategories.save()
.then(() => {
tags.refreshExport();
tags.refreshCategoryColorMap();
this._view.enableForm();
this._view.showSuccess('Changes saved.');
}, error => {
@ -51,7 +51,7 @@ class TagCategoriesController {
}
module.exports = router => {
router.enter('/tag-categories', (ctx, next) => {
router.enter(['tag-categories'], (ctx, next) => {
ctx.controller = new TagCategoriesController(ctx, next);
});
};

View file

@ -3,8 +3,9 @@
const router = require('../router.js');
const api = require('../api.js');
const misc = require('../util/misc.js');
const tags = require('../tags.js');
const uri = require('../util/uri.js');
const Tag = require('../models/tag.js');
const TagCategoryList = require('../models/tag_category_list.js');
const topNavigation = require('../models/top_navigation.js');
const TagView = require('../views/tag_view.js');
const EmptyView = require('../views/empty_view.js');
@ -17,7 +18,12 @@ class TagController {
return;
}
Tag.get(ctx.parameters.name).then(tag => {
Promise.all([
TagCategoryList.get(),
Tag.get(ctx.parameters.name),
]).then(responses => {
const [tagCategoriesResponse, tag] = responses;
topNavigation.activate('tags');
topNavigation.setTitle('Tag #' + tag.names[0]);
@ -25,7 +31,7 @@ class TagController {
tag.addEventListener('change', e => this._evtSaved(e, section));
const categories = {};
for (let category of tags.getAllCategories()) {
for (let category of tagCategoriesResponse.results) {
categories[category.name] = category.name;
}
@ -61,7 +67,8 @@ class TagController {
misc.disableExitConfirmation();
if (this._name !== e.detail.tag.names[0]) {
router.replace(
'/tag/' + e.detail.tag.names[0] + '/' + section, null, false);
uri.formatClientLink('tag', e.detail.tag.names[0], section),
null, false);
}
}
@ -74,12 +81,6 @@ class TagController {
if (e.detail.category !== undefined) {
e.detail.tag.category = e.detail.category;
}
if (e.detail.implications !== undefined) {
e.detail.tag.implications = e.detail.implications;
}
if (e.detail.suggestions !== undefined) {
e.detail.tag.suggestions = e.detail.suggestions;
}
if (e.detail.description !== undefined) {
e.detail.tag.description = e.detail.description;
}
@ -95,11 +96,15 @@ class TagController {
_evtMerge(e) {
this._view.clearMessages();
this._view.disableForm();
e.detail.tag.merge(e.detail.targetTagName).then(() => {
e.detail.tag
.merge(e.detail.targetTagName, e.detail.addAlias)
.then(() => {
this._view.showSuccess('Tag merged.');
this._view.enableForm();
router.replace(
'/tag/' + e.detail.targetTagName + '/merge', null, false);
uri.formatClientLink(
'tag', e.detail.targetTagName, 'merge'),
null, false);
}, error => {
this._view.showError(error.message);
this._view.enableForm();
@ -111,7 +116,7 @@ class TagController {
this._view.disableForm();
e.detail.tag.delete()
.then(() => {
const ctx = router.show('/tags/');
const ctx = router.show(uri.formatClientLink('tags'));
ctx.controller.showSuccess('Tag deleted.');
}, error => {
this._view.showError(error.message);
@ -121,16 +126,16 @@ class TagController {
}
module.exports = router => {
router.enter('/tag/:name(.+?)/edit', (ctx, next) => {
router.enter(['tag', ':name', 'edit'], (ctx, next) => {
ctx.controller = new TagController(ctx, 'edit');
});
router.enter('/tag/:name(.+?)/merge', (ctx, next) => {
router.enter(['tag', ':name', 'merge'], (ctx, next) => {
ctx.controller = new TagController(ctx, 'merge');
});
router.enter('/tag/:name(.+?)/delete', (ctx, next) => {
router.enter(['tag', ':name', 'delete'], (ctx, next) => {
ctx.controller = new TagController(ctx, 'delete');
});
router.enter('/tag/:name(.+)', (ctx, next) => {
router.enter(['tag', ':name'], (ctx, next) => {
ctx.controller = new TagController(ctx, 'summary');
});
};

View file

@ -1,7 +1,8 @@
'use strict';
const router = require('../router.js');
const api = require('../api.js');
const misc = require('../util/misc.js');
const uri = require('../util/uri.js');
const TagList = require('../models/tag_list.js');
const topNavigation = require('../models/top_navigation.js');
const PageController = require('../controllers/page_controller.js');
@ -10,7 +11,12 @@ const TagsPageView = require('../views/tags_page_view.js');
const EmptyView = require('../views/empty_view.js');
const fields = [
'names', 'suggestions', 'implications', 'creationTime', 'usages'];
'names',
'suggestions',
'implications',
'creationTime',
'usages',
'category'];
class TagListController {
constructor(ctx) {
@ -46,10 +52,8 @@ class TagListController {
}
_evtNavigate(e) {
history.pushState(
null,
window.title,
'/tags/' + misc.formatUrlParameters(e.detail.parameters));
router.showNoDispatch(
uri.formatClientLink('tags', e.detail.parameters));
Object.assign(this._ctx.parameters, e.detail.parameters);
this._syncPageController();
}
@ -57,14 +61,15 @@ class TagListController {
_syncPageController() {
this._pageController.run({
parameters: this._ctx.parameters,
getClientUrlForPage: page => {
defaultLimit: 50,
getClientUrlForPage: (offset, limit) => {
const parameters = Object.assign(
{}, this._ctx.parameters, {page: page});
return '/tags/' + misc.formatUrlParameters(parameters);
{}, this._ctx.parameters, {offset: offset, limit: limit});
return uri.formatClientLink('tags', parameters);
},
requestPage: page => {
requestPage: (offset, limit) => {
return TagList.search(
this._ctx.parameters.query, page, 50, fields);
this._ctx.parameters.query, offset, limit, fields);
},
pageRenderer: pageCtx => {
return new TagsPageView(pageCtx);
@ -75,7 +80,6 @@ class TagListController {
module.exports = router => {
router.enter(
'/tags/:parameters(.*)?',
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
['tags'],
(ctx, next) => { ctx.controller = new TagListController(ctx); });
};

View file

@ -6,6 +6,7 @@ const TopNavigationView = require('../views/top_navigation_view.js');
class TopNavigationController {
constructor() {
api.fetchConfig().then(() => {
this._topNavigationView = new TopNavigationView();
topNavigation.addEventListener(
@ -15,6 +16,7 @@ class TopNavigationController {
api.addEventListener('logout', e => this._evtAuthChange(e));
this._render();
});
}
_evtAuthChange(e) {
@ -47,10 +49,12 @@ class TopNavigationController {
topNavigation.hide('users');
}
if (api.isLoggedIn()) {
if (!api.hasPrivilege('users:create:any')) {
topNavigation.hide('register');
}
topNavigation.hide('login');
} else {
if (!api.hasPrivilege('users:create')) {
if (!api.hasPrivilege('users:create:self')) {
topNavigation.hide('register');
}
topNavigation.hide('account');
@ -62,6 +66,7 @@ class TopNavigationController {
this._updateNavigationFromPrivileges();
this._topNavigationView.render({
items: topNavigation.getAll(),
name: api.getName()
});
this._topNavigationView.activate(
topNavigation.activeItem ? topNavigation.activeItem.key : '');

View file

@ -2,10 +2,11 @@
const router = require('../router.js');
const api = require('../api.js');
const uri = require('../util/uri.js');
const misc = require('../util/misc.js');
const config = require('../config.js');
const views = require('../util/views.js');
const User = require('../models/user.js');
const UserToken = require('../models/user_token.js');
const topNavigation = require('../models/top_navigation.js');
const UserView = require('../views/user_view.js');
const EmptyView = require('../views/empty_view.js');
@ -20,8 +21,28 @@ class UserController {
return;
}
this._successMessages = [];
this._errorMessages = [];
let userTokenPromise = Promise.resolve([]);
if (section === 'list-tokens') {
userTokenPromise = UserToken.get(userName)
.then(userTokens => {
return userTokens.map(token => {
token.isCurrentAuthToken = api.isCurrentAuthToken(token);
return token;
});
}, error => {
return [];
});
}
topNavigation.setTitle('User ' + userName);
User.get(userName).then(user => {
Promise.all([
userTokenPromise,
User.get(userName)
]).then(responses => {
const [userTokens, user] = responses;
const isLoggedIn = api.isLoggedIn(user);
const infix = isLoggedIn ? 'self' : 'any';
@ -47,6 +68,7 @@ class UserController {
} else {
topNavigation.activate('users');
}
this._view = new UserView({
user: user,
section: section,
@ -57,18 +79,51 @@ class UserController {
canEditRank: api.hasPrivilege(`users:edit:${infix}:rank`),
canEditAvatar: api.hasPrivilege(`users:edit:${infix}:avatar`),
canEditAnything: api.hasPrivilege(`users:edit:${infix}`),
canListTokens: api.hasPrivilege(`userTokens:list:${infix}`),
canCreateToken: api.hasPrivilege(`userTokens:create:${infix}`),
canEditToken: api.hasPrivilege(`userTokens:edit:${infix}`),
canDeleteToken: api.hasPrivilege(`userTokens:delete:${infix}`),
canDelete: api.hasPrivilege(`users:delete:${infix}`),
ranks: ranks,
tokens: userTokens,
});
this._view.addEventListener('change', e => this._evtChange(e));
this._view.addEventListener('submit', e => this._evtUpdate(e));
this._view.addEventListener('delete', e => this._evtDelete(e));
this._view.addEventListener('create-token', e => this._evtCreateToken(e));
this._view.addEventListener('delete-token', e => this._evtDeleteToken(e));
this._view.addEventListener('update-token', e => this._evtUpdateToken(e));
for (let message of this._successMessages) {
this.showSuccess(message);
}
for (let message of this._errorMessages) {
this.showError(message);
}
}, error => {
this._view = new EmptyView();
this._view.showError(error.message);
});
}
showSuccess(message) {
if (typeof this._view === 'undefined') {
this._successMessages.push(message)
} else {
this._view.showSuccess(message);
}
}
showError(message) {
if (typeof this._view === 'undefined') {
this._errorMessages.push(message)
} else {
this._view.showError(message);
}
}
_evtChange(e) {
misc.enableExitConfirmation();
}
@ -77,7 +132,8 @@ class UserController {
misc.disableExitConfirmation();
if (this._name !== e.detail.user.name) {
router.replace(
'/user/' + e.detail.user.name + '/' + section, null, false);
uri.formatClientLink('user', e.detail.user.name, section),
null, false);
}
}
@ -135,10 +191,10 @@ class UserController {
api.logout();
}
if (api.hasPrivilege('users:list')) {
const ctx = router.show('/users');
const ctx = router.show(uri.formatClientLink('users'));
ctx.controller.showSuccess('Account deleted.');
} else {
const ctx = router.show('/');
const ctx = router.show(uri.formatClientLink());
ctx.controller.showSuccess('Account deleted.');
}
}, error => {
@ -146,16 +202,66 @@ class UserController {
this._view.enableForm();
});
}
_evtCreateToken(e) {
this._view.clearMessages();
this._view.disableForm();
UserToken.create(e.detail.user.name, e.detail.note, e.detail.expirationTime)
.then(response => {
const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
ctx.controller.showSuccess('Token ' + response.token + ' created.');
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}
_evtDeleteToken(e) {
this._view.clearMessages();
this._view.disableForm();
if (api.isCurrentAuthToken(e.detail.userToken)) {
router.show(uri.formatClientLink('logout'));
} else {
e.detail.userToken.delete(e.detail.user.name)
.then(() => {
const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
ctx.controller.showSuccess('Token ' + e.detail.userToken.token + ' deleted.');
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}
}
_evtUpdateToken(e) {
this._view.clearMessages();
this._view.disableForm();
if (e.detail.note !== undefined) {
e.detail.userToken.note = e.detail.note;
}
e.detail.userToken.save(e.detail.user.name).then(response => {
const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
ctx.controller.showSuccess('Token ' + response.token + ' updated.');
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}
}
module.exports = router => {
router.enter('/user/:name', (ctx, next) => {
router.enter(['user', ':name'], (ctx, next) => {
ctx.controller = new UserController(ctx, 'summary');
});
router.enter('/user/:name/edit', (ctx, next) => {
router.enter(['user', ':name', 'edit'], (ctx, next) => {
ctx.controller = new UserController(ctx, 'edit');
});
router.enter('/user/:name/delete', (ctx, next) => {
router.enter(['user', ':name', 'list-tokens'], (ctx, next) => {
ctx.controller = new UserController(ctx, 'list-tokens');
});
router.enter(['user', ':name', 'delete'], (ctx, next) => {
ctx.controller = new UserController(ctx, 'delete');
});
};

View file

@ -1,7 +1,8 @@
'use strict';
const api = require('../api.js');
const misc = require('../util/misc.js');
const router = require('../router.js');
const uri = require('../util/uri.js');
const UserList = require('../models/user_list.js');
const topNavigation = require('../models/top_navigation.js');
const PageController = require('../controllers/page_controller.js');
@ -38,10 +39,8 @@ class UserListController {
}
_evtNavigate(e) {
history.pushState(
null,
window.title,
'/users/' + misc.formatUrlParameters(e.detail.parameters));
router.showNoDispatch(
uri.formatClientLink('users', e.detail.parameters));
Object.assign(this._ctx.parameters, e.detail.parameters);
this._syncPageController();
}
@ -49,13 +48,15 @@ class UserListController {
_syncPageController() {
this._pageController.run({
parameters: this._ctx.parameters,
getClientUrlForPage: page => {
defaultLimit: 30,
getClientUrlForPage: (offset, limit) => {
const parameters = Object.assign(
{}, this._ctx.parameters, {page: page});
return '/users/' + misc.formatUrlParameters(parameters);
{}, this._ctx.parameters, {offset, offset, limit: limit});
return uri.formatClientLink('users', parameters);
},
requestPage: page => {
return UserList.search(this._ctx.parameters.query, page);
requestPage: (offset, limit) => {
return UserList.search(
this._ctx.parameters.query, offset, limit);
},
pageRenderer: pageCtx => {
Object.assign(pageCtx, {
@ -69,7 +70,6 @@ class UserListController {
module.exports = router => {
router.enter(
'/users/:parameters(.*)?',
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
['users'],
(ctx, next) => { ctx.controller = new UserListController(ctx); });
};

View file

@ -2,6 +2,7 @@
const router = require('../router.js');
const api = require('../api.js');
const uri = require('../util/uri.js');
const User = require('../models/user.js');
const topNavigation = require('../models/top_navigation.js');
const RegistrationView = require('../views/registration_view.js');
@ -9,7 +10,7 @@ const EmptyView = require('../views/empty_view.js');
class UserRegistrationController {
constructor() {
if (!api.hasPrivilege('users:create')) {
if (!api.hasPrivilege('users:create:self')) {
this._view = new EmptyView();
this._view.showError('Registration is closed.');
return;
@ -28,12 +29,22 @@ class UserRegistrationController {
user.name = e.detail.name;
user.email = e.detail.email;
user.password = e.detail.password;
const isLoggedIn = api.isLoggedIn();
user.save().then(() => {
if (isLoggedIn) {
return Promise.resolve();
} else {
api.forget();
return api.login(e.detail.name, e.detail.password, false);
}
}).then(() => {
const ctx = router.show('/');
if (isLoggedIn) {
const ctx = router.show(uri.formatClientLink('users'));
ctx.controller.showSuccess('User added!');
} else {
const ctx = router.show(uri.formatClientLink());
ctx.controller.showSuccess('Welcome aboard!');
}
}, error => {
this._view.showError(error.message);
this._view.enableForm();
@ -42,7 +53,7 @@ class UserRegistrationController {
}
module.exports = router => {
router.enter('/register', (ctx, next) => {
router.enter(['register'], (ctx, next) => {
new UserRegistrationController();
});
};

View file

@ -28,10 +28,7 @@ class AutoCompleteControl {
this._sourceInputNode = sourceInputNode;
this._options = {};
Object.assign(this._options, {
transform: null,
verticalShift: 2,
source: null,
addSpace: false,
maxResults: 15,
getTextToFind: () => {
const value = sourceInputNode.value;
@ -56,7 +53,7 @@ class AutoCompleteControl {
this._isVisible = false;
}
defaultConfirmStrategy(text) {
replaceSelectedText(result, addSpace) {
const start = _getSelectionStart(this._sourceInputNode);
let prefix = '';
let suffix = this._sourceInputNode.value.substring(start);
@ -66,30 +63,25 @@ class AutoCompleteControl {
prefix = this._sourceInputNode.value.substring(0, index + 1);
middle = this._sourceInputNode.value.substring(index + 1);
}
this._sourceInputNode.value = prefix + text + ' ' + suffix.trimLeft();
if (!this._options.addSpace) {
this._sourceInputNode.value = (
prefix + result.toString() + ' ' + suffix.trimLeft());
if (!addSpace) {
this._sourceInputNode.value = this._sourceInputNode.value.trim();
}
this._sourceInputNode.focus();
}
_delete(text) {
if (this._options.transform) {
text = this._options.transform(text);
}
_delete(result) {
if (this._options.delete) {
this._options.delete(text);
this._options.delete(result);
}
}
_confirm(text) {
if (this._options.transform) {
text = this._options.transform(text);
}
_confirm(result) {
if (this._options.confirm) {
this._options.confirm(text);
this._options.confirm(result);
} else {
this.defaultConfirmStrategy(text);
this.defaultConfirmStrategy(result);
}
}
@ -104,7 +96,6 @@ class AutoCompleteControl {
this.hide();
} else {
this._updateResults(textToFind);
this._refreshList();
}
}
@ -209,15 +200,16 @@ class AutoCompleteControl {
}
_updateResults(textToFind) {
this._options.getMatches(textToFind).then(matches => {
const oldResults = this._results.slice();
this._results =
this._options.getMatches(textToFind)
.slice(0, this._options.maxResults);
this._results = matches.slice(0, this._options.maxResults);
const oldResultsHash = JSON.stringify(oldResults);
const newResultsHash = JSON.stringify(this._results);
if (oldResultsHash !== newResultsHash) {
this._activeResult = -1;
}
this._refreshList();
});
}
_refreshList() {

View file

@ -5,15 +5,21 @@ const views = require('../util/views.js');
const template = views.getTemplate('file-dropper');
const KEY_RETURN = 13;
class FileDropperControl extends events.EventTarget {
constructor(target, options) {
super();
this._options = options;
const source = template({
allowMultiple: this._options.allowMultiple,
allowUrls: this._options.allowUrls,
extraText: options.extraText,
allowMultiple: options.allowMultiple,
allowUrls: options.allowUrls,
lock: options.lock,
id: 'file-' + Math.random().toString(36).substring(7),
urlPlaceholder:
options.urlPlaceholder || 'Alternatively, paste an URL here.',
});
this._dropperNode = source.querySelector('.file-dropper');
@ -21,7 +27,7 @@ class FileDropperControl extends events.EventTarget {
this._urlConfirmButtonNode = source.querySelector('button');
this._fileInputNode = source.querySelector('input[type=file]');
this._fileInputNode.style.display = 'none';
this._fileInputNode.multiple = this._options.allowMultiple || false;
this._fileInputNode.multiple = options.allowMultiple || false;
this._counter = 0;
this._dropperNode.addEventListener(
@ -36,8 +42,12 @@ class FileDropperControl extends events.EventTarget {
'change', e => this._evtFileChange(e));
if (this._urlInputNode) {
this._urlInputNode.addEventListener(
'keydown', e => this._evtUrlInputKeyDown(e));
}
if (this._urlConfirmButtonNode) {
this._urlConfirmButtonNode.addEventListener(
'click', e => this._evtUrlConfirm(e));
'click', e => this._evtUrlConfirmButtonClick(e));
}
this._originalHtml = this._dropperNode.innerHTML;
@ -61,6 +71,10 @@ class FileDropperControl extends events.EventTarget {
_emitUrls(urls) {
urls = Array.from(urls).map(url => url.trim());
if (this._options.lock) {
this._dropperNode.innerText =
urls.map(url => url.split(/\//).reverse()[0]).join(', ');
}
for (let url of urls) {
if (!url) {
return;
@ -105,7 +119,17 @@ class FileDropperControl extends events.EventTarget {
this._emitFiles(e.dataTransfer.files);
}
_evtUrlConfirm(e) {
_evtUrlInputKeyDown(e) {
if (e.which !== KEY_RETURN) {
return;
}
e.preventDefault();
this._dropperNode.classList.remove('active');
this._emitUrls(this._urlInputNode.value.split(/[\r\n]/));
this._urlInputNode.value = '';
}
_evtUrlConfirmButtonClick(e) {
e.preventDefault();
this._dropperNode.classList.remove('active');
this._emitUrls(this._urlInputNode.value.split(/[\r\n]/));

View file

@ -5,18 +5,23 @@ const views = require('../util/views.js');
const optimizedResize = require('../util/optimized_resize.js');
class PostContentControl {
constructor(hostNode, post, viewportSizeCalculator) {
constructor(hostNode, post, viewportSizeCalculator, fitFunctionOverride) {
this._post = post;
this._viewportSizeCalculator = viewportSizeCalculator;
this._hostNode = hostNode;
this._template = views.getTemplate('post-content');
let fitMode = settings.get().fitMode;
if (typeof fitFunctionOverride !== 'undefined') {
fitMode = fitFunctionOverride;
}
this._currentFitFunction = {
'fit-both': this.fitBoth,
'fit-original': this.fitOriginal,
'fit-width': this.fitWidth,
'fit-height': this.fitHeight,
}[settings.get().fitMode] || this.fitBoth;
}[fitMode] || this.fitBoth;
this._install();

View file

@ -4,6 +4,8 @@ const api = require('../api.js');
const events = require('../events.js');
const misc = require('../util/misc.js');
const views = require('../util/views.js');
const Note = require('../models/note.js');
const Point = require('../models/point.js');
const TagInputControl = require('./tag_input_control.js');
const ExpanderControl = require('../controls/expander_control.js');
const FileDropperControl = require('../controls/file_dropper_control.js');
@ -23,6 +25,8 @@ class PostEditSidebarControl extends events.EventTarget {
views.replaceContent(this._hostNode, template({
post: this._post,
enableSafety: api.safetyEnabled(),
hasClipboard: document.queryCommandSupported('copy'),
canEditPostSafety: api.hasPrivilege('posts:edit:safety'),
canEditPostSource: api.hasPrivilege('posts:edit:source'),
canEditPostTags: api.hasPrivilege('posts:edit:tags'),
@ -67,15 +71,22 @@ class PostEditSidebarControl extends events.EventTarget {
}
if (this._tagInputNode) {
this._tagControl = new TagInputControl(this._tagInputNode);
this._tagControl = new TagInputControl(
this._tagInputNode, post.tags);
}
if (this._contentInputNode) {
this._contentFileDropper = new FileDropperControl(
this._contentInputNode, {lock: true});
this._contentInputNode, {
allowUrls: true,
lock: true,
urlPlaceholder: '...or paste an URL here.'});
this._contentFileDropper.addEventListener('fileadd', e => {
this._newPostContent = e.detail.files[0];
});
this._contentFileDropper.addEventListener('urladd', e => {
this._newPostContent = e.detail.urls[0];
});
}
if (this._thumbnailInputNode) {
@ -99,6 +110,16 @@ class PostEditSidebarControl extends events.EventTarget {
'click', e => this._evtAddNoteClick(e));
}
if (this._copyNotesLinkNode) {
this._copyNotesLinkNode.addEventListener(
'click', e => this._evtCopyNotesClick(e));
}
if (this._pasteNotesLinkNode) {
this._pasteNotesLinkNode.addEventListener(
'click', e => this._evtPasteNotesClick(e));
}
if (this._deleteNoteLinkNode) {
this._deleteNoteLinkNode.addEventListener(
'click', e => this._evtDeleteNoteClick(e));
@ -150,8 +171,9 @@ class PostEditSidebarControl extends events.EventTarget {
});
}
this._tagControl.addEventListener('change', e => {
this._post.tags = this._tagControl.tags;
this._tagControl.addEventListener(
'change', e => {
this.dispatchEvent(new CustomEvent('change'));
this._syncExpanderTitles();
});
@ -244,6 +266,50 @@ class PostEditSidebarControl extends events.EventTarget {
this._postNotesOverlayControl.switchToDrawing();
}
_evtCopyNotesClick(e) {
e.preventDefault();
let textarea = document.createElement('textarea');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.value = JSON.stringify([...this._post.notes].map(note => ({
polygon: [...note.polygon].map(
point => [point.x, point.y]),
text: note.text,
})));
document.body.appendChild(textarea);
textarea.select();
let success = false;
try {
success = document.execCommand('copy');
} catch (err) {
}
textarea.blur();
document.body.removeChild(textarea);
alert(success
? 'Notes copied to clipboard.'
: 'Failed to copy the text to clipboard. Sorry.');
}
_evtPasteNotesClick(e) {
e.preventDefault();
const text = window.prompt(
'Please enter the exported notes snapshot:');
if (!text) {
return;
}
const notesObj = JSON.parse(text);
this._post.notes.clear();
for (let noteObj of notesObj) {
let note = new Note();
for (let pointObj of noteObj.polygon) {
note.polygon.add(new Point(pointObj[0], pointObj[1]));
}
note.text = noteObj.text;
this._post.notes.add(note);
}
}
_evtDeleteNoteClick(e) {
e.preventDefault();
if (e.target.classList.contains('inactive')) {
@ -274,7 +340,8 @@ class PostEditSidebarControl extends events.EventTarget {
undefined,
relations: this._relationsInputNode ?
misc.splitByWhitespace(this._relationsInputNode.value) :
misc.splitByWhitespace(this._relationsInputNode.value)
.map(x => parseInt(x)) :
undefined,
content: this._newPostContent ?
@ -341,6 +408,14 @@ class PostEditSidebarControl extends events.EventTarget {
return this._formNode.querySelector('.notes .add');
}
get _copyNotesLinkNode() {
return this._formNode.querySelector('.notes .copy');
}
get _pasteNotesLinkNode() {
return this._formNode.querySelector('.notes .paste');
}
get _deleteNoteLinkNode() {
return this._formNode.querySelector('.notes .delete');
}

View file

@ -72,10 +72,8 @@ function _getNoteSize(note) {
}
class State {
constructor(control) {
constructor(control, stateName) {
this._control = control;
const stateName = misc.decamelize(
this.constructor.name.replace(/State/, ''));
_setNodeState(control._hostNode, stateName);
_setNodeState(control._textNode, stateName);
}
@ -132,7 +130,7 @@ class State {
class ReadOnlyState extends State {
constructor(control) {
super(control);
super(control, 'read-only');
if (_clearEditedNote(control._hostNode)) {
this._control.dispatchEvent(new CustomEvent('blur'));
}
@ -146,7 +144,7 @@ class ReadOnlyState extends State {
class PassiveState extends State {
constructor(control) {
super(control);
super(control, 'passive');
if (_clearEditedNote(control._hostNode)) {
this._control.dispatchEvent(new CustomEvent('blur'));
}
@ -163,13 +161,13 @@ class PassiveState extends State {
}
class ActiveState extends State {
constructor(control, note) {
super(control);
constructor(control, note, stateName) {
super(control, stateName);
if (_clearEditedNote(control._hostNode)) {
this._control.dispatchEvent(new CustomEvent('blur'));
}
keyboard.pause();
if (note !== undefined) {
if (note !== null) {
this._note = note;
this._control.dispatchEvent(
new CustomEvent('focus', {
@ -182,7 +180,7 @@ class ActiveState extends State {
class SelectedState extends ActiveState {
constructor(control, note) {
super(control, note);
super(control, note, 'selected');
this._clickTimeout = null;
this._control._hideNoteText();
}
@ -299,7 +297,7 @@ class SelectedState extends ActiveState {
class MovingPointState extends ActiveState {
constructor(control, note, notePoint, mousePoint) {
super(control, note);
super(control, note, 'moving-point');
this._notePoint = notePoint;
this._originalNotePoint = {x: notePoint.x, y: notePoint.y};
this._originalPosition = mousePoint;
@ -328,7 +326,7 @@ class MovingPointState extends ActiveState {
class MovingNoteState extends ActiveState {
constructor(control, note, mousePoint) {
super(control, note);
super(control, note, 'moving-note');
this._originalPolygon = [...note.polygon].map(
point => ({x: point.x, y: point.y}));
this._originalPosition = mousePoint;
@ -360,7 +358,7 @@ class MovingNoteState extends ActiveState {
class ScalingNoteState extends ActiveState {
constructor(control, note, mousePoint) {
super(control, note);
super(control, note, 'scaling-note');
this._originalPolygon = [...note.polygon].map(
point => ({x: point.x, y: point.y}));
this._originalMousePoint = mousePoint;
@ -402,7 +400,7 @@ class ScalingNoteState extends ActiveState {
class ReadyToDrawState extends ActiveState {
constructor(control) {
super(control);
super(control, null, 'ready-to-draw');
}
evtNoteMouseDown(e, hoveredNote) {
@ -423,7 +421,7 @@ class ReadyToDrawState extends ActiveState {
class DrawingRectangleState extends ActiveState {
constructor(control, mousePoint) {
super(control);
super(control, null, 'drawing-rectangle');
this._note = this._createNote();
this._note.polygon.add(new Point(mousePoint.x, mousePoint.y));
this._note.polygon.add(new Point(mousePoint.x, mousePoint.y));
@ -460,7 +458,7 @@ class DrawingRectangleState extends ActiveState {
class DrawingPolygonState extends ActiveState {
constructor(control, mousePoint) {
super(control);
super(control, null, 'drawing-polygon');
this._note = this._createNote();
this._note.polygon.add(new Point(mousePoint.x, mousePoint.y));
this._note.polygon.add(new Point(mousePoint.x, mousePoint.y));

View file

@ -2,7 +2,6 @@
const api = require('../api.js');
const events = require('../events.js');
const tags = require('../tags.js');
const views = require('../util/views.js');
const template = views.getTemplate('post-readonly-sidebar');
@ -21,8 +20,7 @@ class PostReadonlySidebarControl extends events.EventTarget {
views.replaceContent(this._hostNode, template({
post: this._post,
getTagCategory: this._getTagCategory,
getTagUsages: this._getTagUsages,
enableSafety: api.safetyEnabled(),
canListPosts: api.hasPrivilege('posts:list'),
canEditPosts: api.hasPrivilege('posts:edit'),
canViewTags: api.hasPrivilege('tags:view'),
@ -159,16 +157,6 @@ class PostReadonlySidebarControl extends events.EventTarget {
newNode.classList.add('active');
}
_getTagUsages(name) {
const tag = tags.getTagByName(name);
return tag ? tag.usages : 0;
}
_getTagCategory(name) {
const tag = tags.getTagByName(name);
return tag ? tag.category : 'unknown';
}
_evtAddToFavoritesClick(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('favorite', {

View file

@ -1,9 +1,29 @@
'use strict';
const tags = require('../tags.js');
const misc = require('../util/misc.js');
const views = require('../util/views.js');
const TagList = require('../models/tag_list.js');
const AutoCompleteControl = require('./auto_complete_control.js');
function _tagListToMatches(tags, options) {
return [...tags].sort((tag1, tag2) => {
return tag2.usages - tag1.usages;
}).map(tag => {
let cssName = misc.makeCssName(tag.category, 'tag');
if (options.isTaggedWith(tag.names[0])) {
cssName += ' disabled';
}
const caption = (
'<span class="' + cssName + '">'
+ misc.escapeHtml(tag.names[0] + ' (' + tag.postCount + ')')
+ '</span>');
return {
caption: caption,
value: tag,
};
});
}
class TagAutoCompleteControl extends AutoCompleteControl {
constructor(input, options) {
const minLengthForPartialSearch = 3;
@ -13,31 +33,20 @@ class TagAutoCompleteControl extends AutoCompleteControl {
}, options);
options.getMatches = text => {
const transform = x => x.toLowerCase();
const match = text.length < minLengthForPartialSearch ?
(a, b) => a.startsWith(b) :
(a, b) => a.includes(b);
text = transform(text);
return Array.from(tags.getNameToTagMap().entries())
.filter(kv => match(transform(kv[0]), text))
.sort((kv1, kv2) => {
return kv2[1].usages - kv1[1].usages;
})
.map(kv => {
const origName = tags.getOriginalTagName(kv[0]);
const category = kv[1].category;
const usages = kv[1].usages;
let cssName = misc.makeCssName(category, 'tag');
if (options.isTaggedWith(kv[0])) {
cssName += ' disabled';
}
return {
caption: misc.unindent`
<span class="${cssName}">
${misc.escapeHtml(origName)} (${usages})
</span>`,
value: origName,
};
const term = misc.escapeSearchTerm(text);
const query = (
text.length < minLengthForPartialSearch
? term + '*'
: '*' + term + '*') + ' sort:usages';
return new Promise((resolve, reject) => {
TagList.search(
query, 0, this._options.maxResults,
['names', 'category', 'usages'])
.then(
response => resolve(
_tagListToMatches(response.results, this._options)),
reject);
});
};

View file

@ -3,6 +3,8 @@
const api = require('../api.js');
const tags = require('../tags.js');
const misc = require('../util/misc.js');
const uri = require('../util/uri.js');
const Tag = require('../models/tag.js');
const settings = require('../models/settings.js');
const events = require('../events.js');
const views = require('../util/views.js');
@ -79,11 +81,12 @@ class SuggestionList {
}
class TagInputControl extends events.EventTarget {
constructor(hostNode) {
constructor(hostNode, tagList) {
super();
this.tags = [];
this.tags = tagList;
this._hostNode = hostNode;
this._suggestions = new SuggestionList();
this._tagToListItemNode = new Map();
// dom
const editAreaNode = template();
@ -97,16 +100,18 @@ class TagInputControl extends events.EventTarget {
getTextToFind: () => {
return this._tagInputNode.value;
},
confirm: text => {
confirm: tag => {
this._tagInputNode.value = '';
this.addTag(text, SOURCE_USER_INPUT);
// XXX: tags from autocomplete don't contain implications
// so they need to be looked up in API
this.addTagByName(tag.names[0], SOURCE_USER_INPUT);
},
delete: text => {
delete: tag => {
this._tagInputNode.value = '';
this.deleteTag(text);
this.deleteTag(tag);
},
verticalShift: -2,
isTaggedWith: tagName => this.isTaggedWith(tagName),
isTaggedWith: tagName => this.tags.isTaggedWith(tagName),
});
// dom events
@ -126,114 +131,81 @@ class TagInputControl extends events.EventTarget {
this._hostNode.parentNode.insertBefore(
this._editAreaNode, hostNode.nextSibling);
this.addEventListener('change', e => this._evtTagsChanged(e));
this.addEventListener('add', e => this._evtTagAdded(e));
this.addEventListener('remove', e => this._evtTagRemoved(e));
// add existing tags
this.addMultipleTags(this._hostNode.value, SOURCE_INIT);
for (let tag of [...this.tags]) {
const listItemNode = this._createListItemNode(tag);
this._tagListNode.appendChild(listItemNode);
}
}
isTaggedWith(tagName) {
let actualTag = null;
[tagName, actualTag] = this._transformTagName(tagName);
return this.tags
.map(t => t.toLowerCase())
.includes(tagName.toLowerCase());
}
addMultipleTags(text, source) {
addTagByText(text, source) {
for (let tagName of text.split(/\s+/).filter(word => word).reverse()) {
this.addTag(tagName, source);
this.addTagByName(tagName, source);
}
}
addTag(tagName, source) {
tagName = tags.getOriginalTagName(tagName);
if (!tagName) {
addTagByName(name, source) {
name = name.trim();
if (!name) {
return;
}
let actualTag = null;
[tagName, actualTag] = this._transformTagName(tagName);
if (!this.isTaggedWith(tagName)) {
this.tags.push(tagName);
}
this.dispatchEvent(new CustomEvent('add', {
detail: {
tagName: tagName,
source: source,
},
}));
this.dispatchEvent(new CustomEvent('change'));
// XXX: perhaps we should aggregate suggestions from all implications
// for call to the _suggestRelations
if (source !== SOURCE_INIT && source !== SOURCE_CLIPBOARD) {
for (let otherTagName of tags.getAllImplications(tagName)) {
this.addTag(otherTagName, SOURCE_IMPLICATION);
}
}
return Tag.get(name).then(tag => {
return this.addTag(tag, source);
}, () => {
const tag = new Tag();
tag.names = [name];
tag.category = null;
return this.addTag(tag, source);
});
}
deleteTag(tagName) {
if (!tagName) {
return;
}
let actualTag = null;
[tagName, actualTag] = this._transformTagName(tagName);
if (!this.isTaggedWith(tagName)) {
return;
}
this._hideAutoComplete();
this.tags = this.tags.filter(
t => t.toLowerCase() != tagName.toLowerCase());
this.dispatchEvent(new CustomEvent('remove', {
detail: {
tagName: tagName,
},
}));
this.dispatchEvent(new CustomEvent('change'));
}
_evtTagsChanged(e) {
this._hostNode.value = this.tags.join(' ');
this._hostNode.dispatchEvent(new CustomEvent('change'));
}
_evtTagAdded(e) {
const tagName = e.detail.tagName;
const actualTag = tags.getTagByName(tagName);
let listItemNode = this._getListItemNodeFromTagName(tagName);
const alreadyAdded = !!listItemNode;
if (alreadyAdded) {
if (e.detail.source !== SOURCE_IMPLICATION) {
addTag(tag, source) {
if (source != SOURCE_INIT && this.tags.isTaggedWith(tag.names[0])) {
const listItemNode = this._getListItemNode(tag);
if (source !== SOURCE_IMPLICATION) {
listItemNode.classList.add('duplicate');
_fadeOutListItemNodeStatus(listItemNode);
}
} else {
listItemNode = this._createListItemNode(tagName);
if (!actualTag) {
return Promise.resolve();
}
return this.tags.addByName(tag.names[0], false).then(() => {
const listItemNode = this._createListItemNode(tag);
if (!tag.category) {
listItemNode.classList.add('new');
}
if (e.detail.source === SOURCE_IMPLICATION) {
if (source === SOURCE_IMPLICATION) {
listItemNode.classList.add('implication');
}
this._tagListNode.prependChild(listItemNode);
}
_fadeOutListItemNodeStatus(listItemNode);
if ([SOURCE_USER_INPUT, SOURCE_SUGGESTION].includes(e.detail.source) &&
actualTag) {
this._loadSuggestions(actualTag);
}
return Promise.all(
tag.implications.map(
implication => this.addTagByName(
implication.names[0], SOURCE_IMPLICATION)));
}).then(() => {
this.dispatchEvent(new CustomEvent('add', {
detail: {tag: tag, source: source},
}));
this.dispatchEvent(new CustomEvent('change'));
return Promise.resolve();
});
}
_evtTagRemoved(e) {
const listItemNode = this._getListItemNodeFromTagName(e.detail.tagName);
if (listItemNode) {
listItemNode.parentNode.removeChild(listItemNode);
deleteTag(tag) {
if (!this.tags.isTaggedWith(tag.names[0])) {
return;
}
this.tags.removeByName(tag.names[0]);
this._hideAutoComplete();
this._deleteListItemNode(tag);
this.dispatchEvent(new CustomEvent('remove', {
detail: {tag: tag},
}));
this.dispatchEvent(new CustomEvent('change'));
}
_evtInputPaste(e) {
@ -247,7 +219,7 @@ class TagInputControl extends events.EventTarget {
return;
}
this._hideAutoComplete();
this.addMultipleTags(pastedText, SOURCE_CLIPBOARD);
this.addTagByText(pastedText, SOURCE_CLIPBOARD);
this._tagInputNode.value = '';
}
@ -258,7 +230,7 @@ class TagInputControl extends events.EventTarget {
_evtAddTagButtonClick(e) {
e.preventDefault();
this.addTag(this._tagInputNode.value, SOURCE_USER_INPUT);
this.addTagByName(this._tagInputNode.value, SOURCE_USER_INPUT);
this._tagInputNode.value = '';
}
@ -271,36 +243,14 @@ class TagInputControl extends events.EventTarget {
if (e.which == KEY_RETURN || e.which == KEY_SPACE) {
e.preventDefault();
this._hideAutoComplete();
this.addMultipleTags(this._tagInputNode.value, SOURCE_USER_INPUT);
this.addTagByText(this._tagInputNode.value, SOURCE_USER_INPUT);
this._tagInputNode.value = '';
}
}
_transformTagName(tagName) {
const actualTag = tags.getTagByName(tagName);
if (actualTag) {
tagName = actualTag.names[0];
}
return [tagName, actualTag];
}
_getListItemNodeFromTagName(tagName) {
let actualTag = null;
[tagName, actualTag] = this._transformTagName(tagName);
for (let listItemNode of this._tagListNode.querySelectorAll('li')) {
if (listItemNode.getAttribute('data-tag').toLowerCase() ===
tagName.toLowerCase()) {
return listItemNode;
}
}
return null;
}
_createListItemNode(tagName) {
let actualTag = null;
[tagName, actualTag] = this._transformTagName(tagName);
const className = actualTag ?
misc.makeCssName(actualTag.category, 'tag') :
_createListItemNode(tag) {
const className = tag.category ?
misc.makeCssName(tag.category, 'tag') :
null;
const tagLinkNode = document.createElement('a');
@ -308,7 +258,8 @@ class TagInputControl extends events.EventTarget {
tagLinkNode.classList.add(className);
}
tagLinkNode.setAttribute(
'href', '/tag/' + encodeURIComponent(tagName));
'href', uri.formatClientLink('tag', tag.names[0]));
const tagIconNode = document.createElement('i');
tagIconNode.classList.add('fa');
tagIconNode.classList.add('fa-tag');
@ -319,13 +270,13 @@ class TagInputControl extends events.EventTarget {
searchLinkNode.classList.add(className);
}
searchLinkNode.setAttribute(
'href', '/posts/query=' + encodeURIComponent(tagName));
searchLinkNode.textContent = tagName + ' ';
'href', uri.formatClientLink('posts', {query: tag.names[0]}));
searchLinkNode.textContent = tag.names[0] + ' ';
searchLinkNode.addEventListener('click', e => {
e.preventDefault();
if (actualTag) {
this._suggestions.clear();
this._loadSuggestions(actualTag);
if (tag.postCount > 0) {
this._loadSuggestions(tag);
this._removeSuggestionsPopupOpacity();
} else {
this._closeSuggestionsPopup();
@ -334,8 +285,7 @@ class TagInputControl extends events.EventTarget {
const usagesNode = document.createElement('span');
usagesNode.classList.add('tag-usages');
usagesNode.setAttribute(
'data-pseudo-content', actualTag ? actualTag.usages : 0);
usagesNode.setAttribute('data-pseudo-content', tag.postCount);
const removalLinkNode = document.createElement('a');
removalLinkNode.classList.add('remove-tag');
@ -343,24 +293,42 @@ class TagInputControl extends events.EventTarget {
removalLinkNode.setAttribute('data-pseudo-content', '×');
removalLinkNode.addEventListener('click', e => {
e.preventDefault();
this.deleteTag(tagName);
this.deleteTag(tag);
});
const listItemNode = document.createElement('li');
listItemNode.setAttribute('data-tag', tagName);
listItemNode.appendChild(removalLinkNode);
listItemNode.appendChild(tagLinkNode);
listItemNode.appendChild(searchLinkNode);
listItemNode.appendChild(usagesNode);
for (let name of tag.names) {
this._tagToListItemNode.set(name, listItemNode);
}
return listItemNode;
}
_deleteListItemNode(tag) {
const listItemNode = this._getListItemNode(tag);
if (listItemNode) {
listItemNode.parentNode.removeChild(listItemNode);
}
for (let name of tag.names) {
this._tagToListItemNode.delete(name);
}
}
_getListItemNode(tag) {
return this._tagToListItemNode.get(tag.names[0]);
}
_loadSuggestions(tag) {
const browsingSettings = settings.get();
if (!browsingSettings.tagSuggestions) {
return;
}
api.get('/tag-siblings/' + tag.names[0], {noProgress: true})
api.get(
uri.formatApiLink('tag-siblings', tag.names[0]),
{noProgress: true})
.then(response => {
return Promise.resolve(response.results);
}, response => {
@ -396,23 +364,22 @@ class TagInputControl extends events.EventTarget {
for (let tuple of this._suggestions.getAll()) {
const tagName = tuple.tagName;
const weight = tuple.weight;
if (this.isTaggedWith(tagName)) {
if (this.tags.isTaggedWith(tagName)) {
continue;
}
const actualTag = tags.getTagByName(tagName);
const addLinkNode = document.createElement('a');
addLinkNode.textContent = tagName;
addLinkNode.classList.add('add-tag');
addLinkNode.setAttribute('href', '');
if (actualTag) {
Tag.get(tagName).then(tag => {
addLinkNode.classList.add(
misc.makeCssName(actualTag.category, 'tag'));
}
misc.makeCssName(tag.category, 'tag'));
});
addLinkNode.addEventListener('click', e => {
e.preventDefault();
listNode.removeChild(listItemNode);
this.addTag(tagName, SOURCE_SUGGESTION);
this.addTagByName(tagName, SOURCE_SUGGESTION);
});
const weightNode = document.createElement('span');

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