Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2273b984c | |||
| 7be397b76e | |||
| d82a7674ce | |||
| 38723a4d20 | |||
| 1fc3b3dd4e | |||
| d2a684650a | |||
| 82659391f9 |
-47
@@ -1,47 +0,0 @@
|
||||
# This file contains your instance configuration
|
||||
# Some documentation about these variables is available here:
|
||||
# https://docs.joinplu.me/environment/
|
||||
|
||||
## GENERAL SETTINGS ##
|
||||
|
||||
# The directory containing database migrations
|
||||
# For Postgres: migrations/postgres
|
||||
# For SQlite: migrations/sqlite
|
||||
MIGRATION_DIRECTORY=migrations/postgres
|
||||
|
||||
# The URL of your database (or its path for SQlite databases)
|
||||
DATABASE_URL=postgres://plume:plume@localhost/plume
|
||||
|
||||
# The domain of your instance
|
||||
BASE_URL=plu.me
|
||||
|
||||
# The secret key for private cookies and CSRF protection
|
||||
# You can generate one with `openssl rand -base64 32`
|
||||
ROCKET_SECRET_KEY=
|
||||
|
||||
# Port and address which Plume will use
|
||||
ROCKET_PORT=7878
|
||||
ROCKET_ADDRESS=127.0.0.1
|
||||
|
||||
## MAIL CONFIG ##
|
||||
#MAIL_SERVER=smtp.plu.me
|
||||
#MAIL_ADDRESS=no-reply@plu.me
|
||||
#MAIL_USER=plume
|
||||
#MAIL_PASSWORD=
|
||||
#MAIL_HELO_NAME=no-reply@plu.me
|
||||
|
||||
## ADVANCED OPTIONS ##
|
||||
#MEDIA_UPLOAD_DIRECTORY=static/media
|
||||
#SEARCH_INDEX=search_index
|
||||
|
||||
# Sample logo configuration
|
||||
#PLUME_LOGO=icons/trwnh/paragraphs/plumeParagraphs.svg
|
||||
#PLUME_LOGO_FAVICON=icons/trwnh/paragraphs/plumeParagraphs32.png
|
||||
#PLUME_LOGO_48=icons/trwnh/paragraphs/plumeParagraphs48.png
|
||||
#PLUME_LOGO_72=icons/trwnh/paragraphs/plumeParagraphs72.png
|
||||
#PLUME_LOGO_96=icons/trwnh/paragraphs/plumeParagraphs96.png
|
||||
#PLUME_LOGO_144=icons/trwnh/paragraphs/plumeParagraphs144.png
|
||||
#PLUME_LOGO_160=icons/trwnh/paragraphs/plumeParagraphs160.png
|
||||
#PLUME_LOGO_192=icons/trwnh/paragraphs/plumeParagraphs192.png
|
||||
#PLUME_LOGO_256=icons/trwnh/paragraphs/plumeParagraphs256.png
|
||||
#PLUME_LOGO_512=icons/trwnh/paragraphs/plumeParagraphs512.png
|
||||
+4
-1
@@ -7,7 +7,7 @@ translations
|
||||
.env
|
||||
Rocket.toml
|
||||
!.gitkeep
|
||||
static
|
||||
static/media
|
||||
docker-compose.yml
|
||||
*.db
|
||||
*.sqlite
|
||||
@@ -16,5 +16,8 @@ docker-compose.yml
|
||||
tags.*
|
||||
!tags.rs
|
||||
search_index
|
||||
main.css
|
||||
*.wasm
|
||||
*.js
|
||||
.buildconfig
|
||||
__pycache__
|
||||
|
||||
+46
-2
@@ -1,2 +1,46 @@
|
||||
You can read our Code of Conduct [here](https://docs.joinplu.me/organization/code-of-conduct).
|
||||
By contributing to this repository, you agree to be bound by this Code of Conduct.
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, sexual identity and orientation, etc.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at baptiste@gelez.xyz. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
|
||||
|
||||
[homepage]: http://contributor-covenant.org
|
||||
[version]: http://contributor-covenant.org/version/1/4/
|
||||
|
||||
Generated
+207
-473
File diff suppressed because it is too large
Load Diff
+8
-9
@@ -1,14 +1,13 @@
|
||||
[package]
|
||||
authors = ["Plume contributors"]
|
||||
name = "plume"
|
||||
version = "0.4.0"
|
||||
version = "0.3.0"
|
||||
repository = "https://github.com/Plume-org/Plume"
|
||||
|
||||
[dependencies]
|
||||
activitypub = "0.1.3"
|
||||
askama_escape = "0.1"
|
||||
atom_syndication = "0.6"
|
||||
clap = "2.33"
|
||||
colored = "1.8"
|
||||
dotenv = "0.14"
|
||||
gettext = { git = "https://github.com/Plume-org/gettext/", rev = "294c54d74c699fbc66502b480a37cc66c1daa7f3" }
|
||||
@@ -16,22 +15,22 @@ gettext-macros = { git = "https://github.com/Plume-org/gettext-macros/", rev = "
|
||||
gettext-utils = { git = "https://github.com/Plume-org/gettext-macros/", rev = "a7c605f7edd6bfbfbfe7778026bfefd88d82db10" }
|
||||
guid-create = "0.1"
|
||||
heck = "0.3.0"
|
||||
lettre = "0.9.2"
|
||||
lettre_email = "0.9.2"
|
||||
num_cpus = "1.10"
|
||||
lettre = { git = "https://github.com/lettre/lettre", rev = "c988b1760ad8179d9e7f3fb8594d2b86cf2a0a49" }
|
||||
lettre_email = { git = "https://github.com/lettre/lettre", rev = "c988b1760ad8179d9e7f3fb8594d2b86cf2a0a49" }
|
||||
num_cpus = "1.0"
|
||||
rocket = "0.4.0"
|
||||
rocket_contrib = { version = "0.4.0", features = ["json"] }
|
||||
rocket_i18n = { git = "https://github.com/Plume-org/rocket_i18n", rev = "e922afa7c366038b3433278c03b1456b346074f2" }
|
||||
rpassword = "4.0"
|
||||
rpassword = "3.0"
|
||||
runtime-fmt = "0.3.0"
|
||||
scheduled-thread-pool = "0.2.2"
|
||||
scheduled-thread-pool = "0.2.0"
|
||||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
serde_qs = "0.5"
|
||||
serde_qs = "0.4"
|
||||
shrinkwraprs = "0.2.1"
|
||||
validator = "0.8"
|
||||
validator_derive = "0.8"
|
||||
webfinger = "0.4.1"
|
||||
webfinger = "0.3.1"
|
||||
|
||||
[[bin]]
|
||||
name = "plume"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<!--
|
||||
If your issue is not purely technical, it should be debated on Loomio first
|
||||
|
||||
https://framavox.org/g/WK40YHMA/plume
|
||||
-->
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Plume version (and/or commit):
|
||||
@@ -1,55 +1,29 @@
|
||||
<h1 align="center">
|
||||
<img src="https://raw.githubusercontent.com/Plume-org/Plume/master/assets/icons/trwnh/feather/plumeFeather64.png" alt="Plume's logo">
|
||||
<img src="https://raw.githubusercontent.com/Plume-org/Plume/master/static/icons/trwnh/feather/plumeFeather64.png" alt="Plume's logo">
|
||||
Plume
|
||||
</h1>
|
||||
<p align="center">
|
||||
<a href="https://github.com/Plume-org/Plume/"><img alt="CircleCI" src="https://img.shields.io/circleci/build/gh/Plume-org/Plume.svg"></a>
|
||||
<a href="https://codecov.io/gh/Plume-org/Plume"><img src="https://codecov.io/gh/Plume-org/Plume/branch/master/graph/badge.svg" alt="Code coverage"></a>
|
||||
<a title="Crowdin" target="_blank" href="https://crowdin.com/project/plume"><img src="https://d322cqt584bo4o.cloudfront.net/plume/localized.svg"></a>
|
||||
<a href="https://hub.docker.com/r/plumeorg/plume"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/plumeorg/plume.svg"></a>
|
||||
<a href="https://liberapay.com/Plume"><img alt="Liberapay patrons" src="https://img.shields.io/liberapay/patrons/Plume.svg"></a>
|
||||
<a href="https://travis-ci.org/Plume-org/Plume"><img src="https://travis-ci.org/Plume-org/Plume.svg?branch=master" alt="Build status"></a>
|
||||
<a href="hhttps://codecov.io/gh/Plume-org/Plume"><img src="https://codecov.io/gh/Plume-org/Plume/branch/master/graph/badge.svg" alt="Code coverage"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://joinplu.me/">Website</a>
|
||||
—
|
||||
<a href="https://docs.joinplu.me/">Documentation</a>
|
||||
—
|
||||
<a href="https://docs.joinplu.me/contribute">Contribute</a>
|
||||
<a href="https://contribute.joinplu.me/">Contribute</a>
|
||||
—
|
||||
<a href="https://joinplu.me/#instances">Instances list</a>
|
||||
</p>
|
||||
|
||||
Plume is a **federated blogging engine**, based on *ActivityPub*. It is written in *Rust*, with the *Rocket* framework, and *Diesel* to interact with the database.
|
||||
The front-end uses *Ructe* templates, *WASM* and *SCSS*.
|
||||
Plume is a federated blogging engine, based on ActivityPub. It uses the Rocket framework, and Diesel to interact with the database.
|
||||
|
||||
## Features
|
||||
It is not yet ready for production use, but we have all the basic features (account management, blogs, articles, comments, etc) and a basic federation.
|
||||
|
||||
A lot of features are still missing, but what is already here should be quite stable. Current and planned features include:
|
||||
Feel free to join our Matrix room: `#plume:disroot.org` to discuss about the project!
|
||||
|
||||
- **A blog-centric approach**: you can create as much blogs as you want with your account, to keep your different publications separated.
|
||||
- **Media management**: you can upload pictures to illustrate your articles, but also audio files if you host a podcast, and manage them all from Plume.
|
||||
- **Federation**: Plume is part of a network of interconnected websites called the Fediverse. Each of these websites (often called *instances*) have their own
|
||||
rules and thematics, but they can all communicate with each other.
|
||||
- **Collaborative writing**: invite other people to your blogs, and write articles together.
|
||||
We also have a [Loomio group](https://framavox.org/g/WK40YHMA/plume) to debate and vote and features. Feel free to join it to take part in Plume's decision process.
|
||||
|
||||
## Get involved
|
||||
For more informations on the different ways to contribute, check out our [contribution page](https://contribute.joinplu.me). We are welcoming any kind of contribution, and you can probably find a way to help.
|
||||
|
||||
If you want to have regular news about the project, the best place is probably [our blog](https://fediverse.blog/~/PlumeDev), or our Matrix room: [`#plume:disroot.org`](https://riot.im/app/#/room/#plume:disroot.org).
|
||||
|
||||
If you want to contribute more, a good first step is to read [our contribution guides](https://docs.joinplu.me/contribute). We accept all kind of contribution:
|
||||
|
||||
- [Back-end or front-end development](https://docs.joinplu.me/contribute/development/)
|
||||
- [Translations](https://docs.joinplu.me/contribute/translations/)
|
||||
- [Documentation](https://docs.joinplu.me/contribute/documentation/)
|
||||
- UI and/or UX design (we don't have a dedicated guide yet, but [we can talk](https://docs.joinplu.me/contribute/discussion/) to see how we can work together!)
|
||||
- [Taking part in discussions](https://docs.joinplu.me/contribute/discussion/)
|
||||
- [Financial support](https://docs.joinplu.me/contribute/donations/)
|
||||
|
||||
But this list is not exhaustive and if you want to contribute differently you are welcome too!
|
||||
|
||||
As we want the various spaces related to the project (GitHub, Matrix, Loomio, etc) to be as safe as possible for everyone, we adopted [a code of conduct](https://docs.joinplu.me/organization/code-of-conduct). Please read it and make sure you accept it before contributing.
|
||||
|
||||
## Starting your own instance
|
||||
|
||||
We provide various way to install Plume: from source, with pre-built binaries, with Docker or with YunoHost.
|
||||
For detailed explanations, please refer to [the documentation](https://docs.joinplu.me/installation/).
|
||||
As we want the various spaces related to the project (GitHub, Matrix, Loomio, etc) to be as safe as possible for everyone, we adopted [a code of conduct](https://github.com/Plume-org/Plume/blob/master/CODE_OF_CONDUCT.md). Please read it and make sure you accept it before contributing.
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
* {
|
||||
font-family: monospace;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
@import '_variables';
|
||||
|
||||
/* Color Scheme */
|
||||
$gray: #1a3854;
|
||||
$black: #102e4a;
|
||||
$white: #F8F8F8;
|
||||
$purple: #7765E3;
|
||||
$lightpurple: #c2bbee;
|
||||
$red: #d16666;
|
||||
$yellow: #ff934f;
|
||||
$blue: #7f96ff;
|
||||
|
||||
$background: $black;
|
||||
$form-input-background: $gray;
|
||||
$form-input-border: $white;
|
||||
$text-color: $white;
|
||||
$primary: $purple;
|
||||
$primary-text-color: $white; // text color on primary background (buttons for instance)
|
||||
$success-color: $blue;
|
||||
@@ -1,12 +0,0 @@
|
||||
/* color palette: https://coolors.co/23f0c7-ef767a-7765e3-6457a6-ffe347 */
|
||||
|
||||
@import url('./feather.css');
|
||||
@import url('./fonts/Route159/Route159.css');
|
||||
@import url('./fonts/Lora/Lora.css');
|
||||
@import url('./fonts/Playfair_Display/PlayfairDisplay.css');
|
||||
|
||||
@import 'dark_variables';
|
||||
@import 'global';
|
||||
@import 'header';
|
||||
@import 'article';
|
||||
@import 'forms';
|
||||
@@ -1,12 +0,0 @@
|
||||
/* color palette: https://coolors.co/23f0c7-ef767a-7765e3-6457a6-ffe347 */
|
||||
|
||||
@import url('./feather.css');
|
||||
@import url('./fonts/Route159/Route159.css');
|
||||
@import url('./fonts/Lora/Lora.css');
|
||||
@import url('./fonts/Playfair_Display/PlayfairDisplay.css');
|
||||
|
||||
@import 'variables';
|
||||
@import 'global';
|
||||
@import 'header';
|
||||
@import 'article';
|
||||
@import 'forms';
|
||||
@@ -1,8 +1,8 @@
|
||||
extern crate rsass;
|
||||
extern crate ructe;
|
||||
use ructe::Ructe;
|
||||
use ructe::*;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::{env, ffi::OsStr, fs::*, io::Write, path::*};
|
||||
use std::{env, fs::*, io::Write, path::PathBuf};
|
||||
|
||||
fn compute_static_hash() -> String {
|
||||
//"find static/ -type f ! -path 'static/media/*' | sort | xargs stat -c'%n %Y' | openssl dgst -r"
|
||||
@@ -37,17 +37,25 @@ fn compute_static_hash() -> String {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
Ructe::from_env()
|
||||
.expect("This must be run with cargo")
|
||||
.compile_templates("templates")
|
||||
.expect("compile templates");
|
||||
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
|
||||
let in_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("templates");
|
||||
compile_templates(&in_dir, &out_dir).expect("compile templates");
|
||||
|
||||
compile_themes().expect("Theme compilation error");
|
||||
recursive_copy(&Path::new("assets").join("icons"), &Path::new("static"))
|
||||
.expect("Couldn't copy icons");
|
||||
recursive_copy(&Path::new("assets").join("images"), &Path::new("static"))
|
||||
.expect("Couldn't copy images");
|
||||
create_dir_all(&Path::new("static").join("media")).expect("Couldn't init media directory");
|
||||
println!("cargo:rerun-if-changed=static/css/_article.scss");
|
||||
println!("cargo:rerun-if-changed=static/css/_forms.scss");
|
||||
println!("cargo:rerun-if-changed=static/css/_global.scss");
|
||||
println!("cargo:rerun-if-changed=static/css/_header.scss");
|
||||
println!("cargo:rerun-if-changed=static/css/_variables.scss");
|
||||
println!("cargo:rerun-if-changed=static/css/main.scss");
|
||||
let mut out = File::create("static/css/main.css").expect("Couldn't create main.css");
|
||||
out.write_all(
|
||||
&rsass::compile_scss_file(
|
||||
"static/css/main.scss".as_ref(),
|
||||
rsass::OutputStyle::Compressed,
|
||||
)
|
||||
.expect("Error during SCSS compilation"),
|
||||
)
|
||||
.expect("Couldn't write CSS output");
|
||||
|
||||
let cache_id = &compute_static_hash()[..8];
|
||||
println!("cargo:rerun-if-changed=target/deploy/plume-front.wasm");
|
||||
@@ -66,91 +74,3 @@ fn main() {
|
||||
|
||||
println!("cargo:rustc-env=CACHE_ID={}", cache_id)
|
||||
}
|
||||
|
||||
fn compile_themes() -> std::io::Result<()> {
|
||||
let input_dir = Path::new("assets").join("themes");
|
||||
let output_dir = Path::new("static").join("css");
|
||||
|
||||
let themes = find_themes(input_dir)?;
|
||||
|
||||
for theme in themes {
|
||||
compile_theme(&theme, &output_dir)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_themes(path: PathBuf) -> std::io::Result<Vec<PathBuf>> {
|
||||
let ext = path.extension().and_then(OsStr::to_str);
|
||||
if metadata(&path)?.is_dir() {
|
||||
Ok(read_dir(&path)?.fold(vec![], |mut themes, ch| {
|
||||
if let Ok(ch) = ch {
|
||||
if let Ok(mut new) = find_themes(ch.path()) {
|
||||
themes.append(&mut new);
|
||||
}
|
||||
}
|
||||
themes
|
||||
}))
|
||||
} else if (ext == Some("scss") || ext == Some("sass"))
|
||||
&& !path.file_name().unwrap().to_str().unwrap().starts_with('_')
|
||||
{
|
||||
Ok(vec![path.clone()])
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
fn compile_theme(path: &Path, out_dir: &Path) -> std::io::Result<()> {
|
||||
let name = path
|
||||
.components()
|
||||
.skip_while(|c| *c != Component::Normal(OsStr::new("themes")))
|
||||
.skip(1)
|
||||
.filter_map(|c| {
|
||||
c.as_os_str()
|
||||
.to_str()
|
||||
.unwrap_or_default()
|
||||
.splitn(2, '.')
|
||||
.next()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
|
||||
let dir = path.parent().unwrap();
|
||||
|
||||
let out = out_dir.join(name);
|
||||
create_dir_all(&out)?;
|
||||
|
||||
// copy files of the theme that are not scss
|
||||
for ch in read_dir(&dir)? {
|
||||
recursive_copy(&ch?.path(), &out)?;
|
||||
}
|
||||
|
||||
// compile the .scss/.sass file
|
||||
let mut out = File::create(out.join("theme.css"))?;
|
||||
out.write_all(
|
||||
&rsass::compile_scss_file(path, rsass::OutputStyle::Compressed)
|
||||
.expect("SCSS compilation error"),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn recursive_copy(path: &Path, out_dir: &Path) -> std::io::Result<()> {
|
||||
if metadata(path)?.is_dir() {
|
||||
let out = out_dir.join(path.file_name().unwrap());
|
||||
create_dir_all(out.clone())?;
|
||||
|
||||
for ch in read_dir(path)? {
|
||||
recursive_copy(&ch?.path(), &out)?;
|
||||
}
|
||||
} else {
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
|
||||
let ext = path.extension().and_then(OsStr::to_str);
|
||||
if ext != Some("scss") && ext != Some("sass") {
|
||||
copy(path, out_dir.join(path.file_name().unwrap()))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
--#!|_conn, path: &Path| {
|
||||
--#! let mut pb = path.to_path_buf();
|
||||
--#! pb.push("search_index");
|
||||
--#! std::fs::remove_dir_all(pb).map_err(Error::from)
|
||||
--#!}
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Your SQL goes here
|
||||
--#!|conn: &Connection, path: &Path| {
|
||||
--#! let mut pb = path.to_path_buf();
|
||||
--#! pb.push("search_index");
|
||||
--#! let searcher = super::search::Searcher::create(&pb)?;
|
||||
--#! searcher.fill(conn)?;
|
||||
--#! searcher.commit();
|
||||
--#! Ok(())
|
||||
--#!}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE password_reset_requests;
|
||||
@@ -1,9 +0,0 @@
|
||||
CREATE TABLE password_reset_requests (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR NOT NULL,
|
||||
token VARCHAR NOT NULL,
|
||||
expiration_date TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX password_reset_requests_token ON password_reset_requests (token);
|
||||
CREATE UNIQUE INDEX password_reset_requests_email ON password_reset_requests (email);
|
||||
@@ -1,4 +0,0 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT 'f';
|
||||
UPDATE users SET is_admin = 't' WHERE role = 0;
|
||||
ALTER TABLE users DROP COLUMN role;
|
||||
@@ -1,4 +0,0 @@
|
||||
-- Your SQL goes here
|
||||
ALTER TABLE users ADD COLUMN role INTEGER NOT NULL DEFAULT 2;
|
||||
UPDATE users SET role = 0 WHERE is_admin = 't';
|
||||
ALTER TABLE users DROP COLUMN is_admin;
|
||||
@@ -1,4 +0,0 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
ALTER TABLE blogs DROP COLUMN theme;
|
||||
ALTER TABLE users DROP COLUMN preferred_theme;
|
||||
ALTER TABLE users DROP COLUMN hide_custom_css;
|
||||
@@ -1,4 +0,0 @@
|
||||
-- Your SQL goes here
|
||||
ALTER TABLE blogs ADD COLUMN theme VARCHAR;
|
||||
ALTER TABLE users ADD COLUMN preferred_theme VARCHAR;
|
||||
ALTER TABLE users ADD COLUMN hide_custom_css BOOLEAN NOT NULL DEFAULT 'f';
|
||||
@@ -1,6 +0,0 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
|
||||
DROP TABLE timeline;
|
||||
DROP TABLE timeline_definition;
|
||||
DROP TABLE list_elems;
|
||||
DROP TABLE lists;
|
||||
@@ -1,31 +0,0 @@
|
||||
-- Your SQL goes here
|
||||
|
||||
CREATE TABLE timeline_definition(
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id integer REFERENCES users ON DELETE CASCADE,
|
||||
name VARCHAR NOT NULL,
|
||||
query VARCHAR NOT NULL,
|
||||
CONSTRAINT timeline_unique_user_name UNIQUE(user_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE timeline(
|
||||
id SERIAL PRIMARY KEY,
|
||||
post_id integer NOT NULL REFERENCES posts ON DELETE CASCADE,
|
||||
timeline_id integer NOT NULL REFERENCES timeline_definition ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE lists(
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR NOT NULL,
|
||||
user_id integer REFERENCES users ON DELETE CASCADE,
|
||||
type integer NOT NULL,
|
||||
CONSTRAINT list_unique_user_name UNIQUE(user_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE list_elems(
|
||||
id SERIAL PRIMARY KEY,
|
||||
list_id integer NOT NULL REFERENCES lists ON DELETE CASCADE,
|
||||
user_id integer REFERENCES users ON DELETE CASCADE,
|
||||
blog_id integer REFERENCES blogs ON DELETE CASCADE,
|
||||
word VARCHAR
|
||||
);
|
||||
@@ -1,4 +0,0 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
DELETE FROM timeline_definition WHERE name = 'Your feed';
|
||||
DELETE FROM timeline_definition WHERE name = 'Local feed' AND query = 'local';
|
||||
DELETE FROM timeline_definition WHERE name = 'Federared feed' AND query = 'all';
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Your SQL goes here
|
||||
INSERT INTO timeline_definition (name, query) VALUES
|
||||
('Local feed', 'local'),
|
||||
('Federated feed', 'all');
|
||||
INSERT INTO timeline_definition (user_id,name,query)
|
||||
select id,'Your feed',CONCAT('followed or [',fqn,']') from users;
|
||||
@@ -1,8 +0,0 @@
|
||||
DELETE FROM timeline WHERE id IN
|
||||
(
|
||||
SELECT timeline.id FROM timeline
|
||||
INNER JOIN timeline_definition ON timeline.timeline_id = timeline_definition.id
|
||||
WHERE timeline_definition.query LIKE 'followed or [%]' OR
|
||||
timeline_definition.query = 'local' OR
|
||||
timeline_definition.query = 'all'
|
||||
);
|
||||
@@ -1,18 +0,0 @@
|
||||
INSERT INTO timeline (post_id, timeline_id)
|
||||
SELECT posts.id,timeline_definition.id FROM posts,timeline_definition
|
||||
WHERE timeline_definition.query = 'all';
|
||||
|
||||
INSERT INTO timeline (post_id, timeline_id)
|
||||
SELECT posts.id,timeline_definition.id FROM posts
|
||||
CROSS JOIN timeline_definition
|
||||
INNER JOIN blogs ON posts.blog_id = blogs.id
|
||||
INNER JOIN instances ON blogs.instance_id = instances.id
|
||||
WHERE timeline_definition.query = 'local' and instances.local = true;
|
||||
|
||||
INSERT INTO timeline (post_id, timeline_id)
|
||||
SELECT posts.id,timeline_definition.id FROM posts
|
||||
INNER JOIN blog_authors ON posts.blog_id = blog_authors.blog_id
|
||||
LEFT JOIN follows ON blog_authors.author_id = follows.following_id
|
||||
INNER JOIN timeline_definition ON follows.follower_id = timeline_definition.user_id
|
||||
or blog_authors.author_id = timeline_definition.user_id
|
||||
WHERE timeline_definition.query LIKE 'followed or [%]';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
--#!|_conn, path: &Path| {
|
||||
--#! let mut pb = path.to_path_buf();
|
||||
--#! pb.push("search_index");
|
||||
--#! std::fs::remove_dir_all(pb).map_err(Error::from)
|
||||
--#!}
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Your SQL goes here
|
||||
--#!|conn: &Connection, path: &Path| {
|
||||
--#! let mut pb = path.to_path_buf();
|
||||
--#! pb.push("search_index");
|
||||
--#! let searcher = super::search::Searcher::create(&pb)?;
|
||||
--#! searcher.fill(conn)?;
|
||||
--#! searcher.commit();
|
||||
--#! Ok(())
|
||||
--#!}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE password_reset_requests;
|
||||
@@ -1,9 +0,0 @@
|
||||
CREATE TABLE password_reset_requests (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
email VARCHAR NOT NULL,
|
||||
token VARCHAR NOT NULL,
|
||||
expiration_date DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX password_reset_requests_token ON password_reset_requests (token);
|
||||
CREATE UNIQUE INDEX password_reset_requests_email ON password_reset_requests (email);
|
||||
@@ -1,72 +0,0 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
CREATE TABLE IF NOT EXISTS "users_without_role" (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
username VARCHAR NOT NULL,
|
||||
display_name VARCHAR NOT NULL DEFAULT '',
|
||||
outbox_url VARCHAR NOT NULL UNIQUE,
|
||||
inbox_url VARCHAR NOT NULL UNIQUE,
|
||||
is_admin BOOLEAN NOT NULL DEFAULT 'f',
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
email TEXT,
|
||||
hashed_password TEXT,
|
||||
instance_id INTEGER REFERENCES instances(id) ON DELETE CASCADE NOT NULL,
|
||||
creation_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ap_url TEXT NOT NULL default '' UNIQUE,
|
||||
private_key TEXT,
|
||||
public_key TEXT NOT NULL DEFAULT '',
|
||||
shared_inbox_url VARCHAR,
|
||||
followers_endpoint VARCHAR NOT NULL DEFAULT '' UNIQUE,
|
||||
avatar_id INTEGER REFERENCES medias(id) ON DELETE CASCADE,
|
||||
last_fetched_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
fqn TEXT NOT NULL DEFAULT '',
|
||||
summary_html TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY (avatar_id) REFERENCES medias(id) ON DELETE SET NULL,
|
||||
CONSTRAINT blog_authors_unique UNIQUE (username, instance_id)
|
||||
);
|
||||
|
||||
INSERT INTO users_without_role SELECT
|
||||
id,
|
||||
username,
|
||||
display_name,
|
||||
outbox_url,
|
||||
inbox_url,
|
||||
't',
|
||||
summary,
|
||||
email,
|
||||
hashed_password,
|
||||
instance_id,
|
||||
creation_date,
|
||||
ap_url,
|
||||
private_key,
|
||||
public_key,
|
||||
shared_inbox_url,
|
||||
followers_endpoint,
|
||||
avatar_id,
|
||||
last_fetched_date,
|
||||
fqn,
|
||||
summary
|
||||
FROM users WHERE role = 0;
|
||||
INSERT INTO users_without_role SELECT
|
||||
id,
|
||||
username,
|
||||
display_name,
|
||||
outbox_url,
|
||||
inbox_url,
|
||||
'f',
|
||||
summary,
|
||||
email,
|
||||
hashed_password,
|
||||
instance_id,
|
||||
creation_date,
|
||||
ap_url,
|
||||
private_key,
|
||||
public_key,
|
||||
shared_inbox_url,
|
||||
followers_endpoint,
|
||||
avatar_id,
|
||||
last_fetched_date,
|
||||
fqn,
|
||||
summary
|
||||
FROM users WHERE role != 0;
|
||||
DROP TABLE users;
|
||||
ALTER TABLE users_without_role RENAME TO users;
|
||||
@@ -1,74 +0,0 @@
|
||||
-- Your SQL goes here
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "users_with_role" (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
username VARCHAR NOT NULL,
|
||||
display_name VARCHAR NOT NULL DEFAULT '',
|
||||
outbox_url VARCHAR NOT NULL UNIQUE,
|
||||
inbox_url VARCHAR NOT NULL UNIQUE,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
email TEXT,
|
||||
hashed_password TEXT,
|
||||
instance_id INTEGER REFERENCES instances(id) ON DELETE CASCADE NOT NULL,
|
||||
creation_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ap_url TEXT NOT NULL default '' UNIQUE,
|
||||
private_key TEXT,
|
||||
public_key TEXT NOT NULL DEFAULT '',
|
||||
shared_inbox_url VARCHAR,
|
||||
followers_endpoint VARCHAR NOT NULL DEFAULT '' UNIQUE,
|
||||
avatar_id INTEGER REFERENCES medias(id) ON DELETE CASCADE,
|
||||
last_fetched_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
fqn TEXT NOT NULL DEFAULT '',
|
||||
summary_html TEXT NOT NULL DEFAULT '',
|
||||
role INTEGER NOT NULL DEFAULT 2,
|
||||
FOREIGN KEY (avatar_id) REFERENCES medias(id) ON DELETE SET NULL,
|
||||
CONSTRAINT blog_authors_unique UNIQUE (username, instance_id)
|
||||
);
|
||||
|
||||
|
||||
INSERT INTO users_with_role SELECT
|
||||
id,
|
||||
username,
|
||||
display_name,
|
||||
outbox_url,
|
||||
inbox_url,
|
||||
summary,
|
||||
email,
|
||||
hashed_password,
|
||||
instance_id,
|
||||
creation_date,
|
||||
ap_url,
|
||||
private_key,
|
||||
public_key,
|
||||
shared_inbox_url,
|
||||
followers_endpoint,
|
||||
avatar_id,
|
||||
last_fetched_date,
|
||||
fqn,
|
||||
summary,
|
||||
0
|
||||
FROM users WHERE is_admin = 't';
|
||||
INSERT INTO users_with_role SELECT
|
||||
id,
|
||||
username,
|
||||
display_name,
|
||||
outbox_url,
|
||||
inbox_url,
|
||||
summary,
|
||||
email,
|
||||
hashed_password,
|
||||
instance_id,
|
||||
creation_date,
|
||||
ap_url,
|
||||
private_key,
|
||||
public_key,
|
||||
shared_inbox_url,
|
||||
followers_endpoint,
|
||||
avatar_id,
|
||||
last_fetched_date,
|
||||
fqn,
|
||||
summary,
|
||||
2
|
||||
FROM users WHERE is_admin = 'f';
|
||||
DROP TABLE users;
|
||||
ALTER TABLE users_with_role RENAME TO users;
|
||||
@@ -1,88 +0,0 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
|
||||
CREATE TABLE blogs_before_themes (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
actor_id VARCHAR NOT NULL,
|
||||
title VARCHAR NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
outbox_url VARCHAR NOT NULL UNIQUE,
|
||||
inbox_url VARCHAR NOT NULL UNIQUE,
|
||||
instance_id INTEGER REFERENCES instances(id) ON DELETE CASCADE NOT NULL,
|
||||
creation_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ap_url text not null default '' UNIQUE,
|
||||
private_key TEXT,
|
||||
public_key TEXT NOT NULL DEFAULT '',
|
||||
fqn TEXT NOT NULL DEFAULT '',
|
||||
summary_html TEXT NOT NULL DEFAULT '',
|
||||
icon_id INTEGER REFERENCES medias(id) ON DELETE SET NULL DEFAULT NULL,
|
||||
banner_id INTEGER REFERENCES medias(id) ON DELETE SET NULL DEFAULT NULL,
|
||||
CONSTRAINT blog_unique UNIQUE (actor_id, instance_id)
|
||||
);
|
||||
INSERT INTO blogs_before_themes SELECT
|
||||
id,
|
||||
actor_id,
|
||||
title,
|
||||
summary,
|
||||
outbox_url,
|
||||
inbox_url,
|
||||
instance_id,
|
||||
creation_date,
|
||||
ap_url,
|
||||
private_key,
|
||||
public_key,
|
||||
fqn,
|
||||
summary_html,
|
||||
icon_id,
|
||||
banner_id
|
||||
FROM blogs;
|
||||
DROP TABLE blogs;
|
||||
ALTER TABLE blogs_before_themes RENAME TO blogs;
|
||||
|
||||
CREATE TABLE users_before_themes (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
username VARCHAR NOT NULL,
|
||||
display_name VARCHAR NOT NULL DEFAULT '',
|
||||
outbox_url VARCHAR NOT NULL UNIQUE,
|
||||
inbox_url VARCHAR NOT NULL UNIQUE,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
email TEXT,
|
||||
hashed_password TEXT,
|
||||
instance_id INTEGER REFERENCES instances(id) ON DELETE CASCADE NOT NULL,
|
||||
creation_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ap_url TEXT NOT NULL default '' UNIQUE,
|
||||
private_key TEXT,
|
||||
public_key TEXT NOT NULL DEFAULT '',
|
||||
shared_inbox_url VARCHAR,
|
||||
followers_endpoint VARCHAR NOT NULL DEFAULT '' UNIQUE,
|
||||
avatar_id INTEGER REFERENCES medias(id) ON DELETE CASCADE,
|
||||
last_fetched_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
fqn TEXT NOT NULL DEFAULT '',
|
||||
summary_html TEXT NOT NULL DEFAULT '',
|
||||
role INTEGER NOT NULL DEFAULT 2,
|
||||
FOREIGN KEY (avatar_id) REFERENCES medias(id) ON DELETE SET NULL,
|
||||
CONSTRAINT blog_authors_unique UNIQUE (username, instance_id)
|
||||
);
|
||||
INSERT INTO users_before_themes SELECT
|
||||
id,
|
||||
username,
|
||||
display_name,
|
||||
outbox_url,
|
||||
inbox_url,
|
||||
summary,
|
||||
email,
|
||||
hashed_password,
|
||||
instance_id,
|
||||
creation_date,
|
||||
ap_url,
|
||||
private_key,
|
||||
public_key,
|
||||
shared_inbox_url,
|
||||
followers_endpoint,
|
||||
avatar_id,
|
||||
last_fetched_date,
|
||||
fqn,
|
||||
summary_html,
|
||||
role
|
||||
FROM users;
|
||||
DROP TABLE users;
|
||||
ALTER TABLE users_before_themes RENAME TO users;
|
||||
@@ -1,4 +0,0 @@
|
||||
-- Your SQL goes here
|
||||
ALTER TABLE blogs ADD COLUMN theme VARCHAR;
|
||||
ALTER TABLE users ADD COLUMN preferred_theme VARCHAR;
|
||||
ALTER TABLE users ADD COLUMN hide_custom_css BOOLEAN NOT NULL DEFAULT 'f';
|
||||
@@ -1,6 +0,0 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
|
||||
DROP TABLE timeline;
|
||||
DROP TABLE timeline_definition;
|
||||
DROP TABLE list_elems;
|
||||
DROP TABLE lists;
|
||||
@@ -1,31 +0,0 @@
|
||||
-- Your SQL goes here
|
||||
|
||||
CREATE TABLE timeline_definition(
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR NOT NULL,
|
||||
query VARCHAR NOT NULL,
|
||||
CONSTRAINT timeline_unique_user_name UNIQUE(user_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE timeline(
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
post_id integer NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
timeline_id integer NOT NULL REFERENCES timeline_definition(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE lists(
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR NOT NULL,
|
||||
user_id integer REFERENCES users(id) ON DELETE CASCADE,
|
||||
type integer NOT NULL,
|
||||
CONSTRAINT timeline_unique_user_name UNIQUE(user_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE list_elems(
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
list_id integer NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
|
||||
user_id integer REFERENCES users(id) ON DELETE CASCADE,
|
||||
blog_id integer REFERENCES blogs(id) ON DELETE CASCADE,
|
||||
word VARCHAR
|
||||
);
|
||||
@@ -1,4 +0,0 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
DELETE FROM timeline_definition WHERE name = 'Your feed';
|
||||
DELETE FROM timeline_definition WHERE name = 'Local feed' AND query = 'local';
|
||||
DELETE FROM timeline_definition WHERE name = 'Federared feed' AND query = 'all';
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Your SQL goes here
|
||||
INSERT INTO timeline_definition (name, query) VALUES
|
||||
('Local feed', 'local'),
|
||||
('Federated feed', 'all');
|
||||
INSERT INTO timeline_definition (user_id,name,query)
|
||||
select id,'Your feed','followed or ['||fqn||']' from users;
|
||||
@@ -1,8 +0,0 @@
|
||||
DELETE FROM timeline WHERE id IN
|
||||
(
|
||||
SELECT timeline.id FROM timeline
|
||||
INNER JOIN timeline_definition ON timeline.timeline_id = timeline_definition.id
|
||||
WHERE timeline_definition.query LIKE 'followed or [%]' OR
|
||||
timeline_definition.query = 'local' OR
|
||||
timeline_definition.query = 'all'
|
||||
);
|
||||
@@ -1,17 +0,0 @@
|
||||
INSERT INTO timeline (post_id, timeline_id)
|
||||
SELECT posts.id,timeline_definition.id FROM posts,timeline_definition
|
||||
WHERE timeline_definition.query = 'all';
|
||||
|
||||
INSERT INTO timeline (post_id, timeline_id)
|
||||
SELECT posts.id,timeline_definition.id FROM posts,timeline_definition
|
||||
INNER JOIN blogs ON posts.blog_id = blogs.id
|
||||
INNER JOIN instances ON blogs.instance_id = instances.id
|
||||
WHERE timeline_definition.query = 'local' and instances.local = 1;
|
||||
|
||||
INSERT INTO timeline (post_id, timeline_id)
|
||||
SELECT posts.id,timeline_definition.id FROM posts
|
||||
INNER JOIN blog_authors ON posts.blog_id = blog_authors.blog_id
|
||||
LEFT JOIN follows ON blog_authors.author_id = follows.following_id
|
||||
INNER JOIN timeline_definition ON follows.follower_id = timeline_definition.user_id
|
||||
or blog_authors.author_id = timeline_definition.user_id
|
||||
WHERE timeline_definition.query LIKE 'followed or [%]';
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "plume-api"
|
||||
version = "0.4.0"
|
||||
version = "0.3.0"
|
||||
authors = ["Plume contributors"]
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "plume-cli"
|
||||
version = "0.4.0"
|
||||
version = "0.3.0"
|
||||
authors = ["Plume contributors"]
|
||||
|
||||
[[bin]]
|
||||
@@ -10,7 +10,7 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
clap = "2.33"
|
||||
dotenv = "0.14"
|
||||
rpassword = "4.0"
|
||||
rpassword = "3.0"
|
||||
|
||||
[dependencies.diesel]
|
||||
features = ["r2d2", "chrono"]
|
||||
|
||||
@@ -25,11 +25,7 @@ fn main() {
|
||||
.subcommand(users::command());
|
||||
let matches = app.clone().get_matches();
|
||||
|
||||
match dotenv::dotenv() {
|
||||
Ok(path) => println!("Configuration read from {}", path.display()),
|
||||
Err(ref e) if e.not_found() => eprintln!("no .env was found"),
|
||||
e => e.map(|_| ()).unwrap(),
|
||||
}
|
||||
dotenv::dotenv().ok();
|
||||
let conn = Conn::establish(CONFIG.database_url.as_str());
|
||||
let _ = conn.as_ref().map(|conn| Instance::cache_local(conn));
|
||||
|
||||
|
||||
+5
-11
@@ -93,11 +93,8 @@ fn init<'a>(args: &ArgMatches<'a>, conn: &Connection) {
|
||||
}
|
||||
|
||||
fn refill<'a>(args: &ArgMatches<'a>, conn: &Connection, searcher: Option<Searcher>) {
|
||||
let path = args.value_of("path");
|
||||
let path = match path {
|
||||
Some(path) => Path::new(path).join("search_index"),
|
||||
None => Path::new(&CONFIG.search_index).to_path_buf(),
|
||||
};
|
||||
let path = args.value_of("path").unwrap_or(".");
|
||||
let path = Path::new(path).join("search_index");
|
||||
let searcher = searcher.unwrap_or_else(|| Searcher::open(&path).unwrap());
|
||||
|
||||
searcher.fill(conn).expect("Couldn't import post");
|
||||
@@ -106,12 +103,9 @@ fn refill<'a>(args: &ArgMatches<'a>, conn: &Connection, searcher: Option<Searche
|
||||
}
|
||||
|
||||
fn unlock<'a>(args: &ArgMatches<'a>) {
|
||||
let path = match args.value_of("path") {
|
||||
None => Path::new(&CONFIG.search_index),
|
||||
Some(x) => Path::new(x),
|
||||
};
|
||||
let meta = Path::new(path).join(".tantivy-meta.lock");
|
||||
let path = args.value_of("path").unwrap_or(".");
|
||||
let meta = Path::new(path).join("search_index/.tantivy-meta.lock");
|
||||
remove_file(meta).unwrap();
|
||||
let writer = Path::new(path).join(".tantivy-writer.lock");
|
||||
let writer = Path::new(path).join("search_index/.tantivy-writer.lock");
|
||||
remove_file(writer).unwrap();
|
||||
}
|
||||
|
||||
+1
-17
@@ -52,12 +52,6 @@ pub fn command<'a, 'b>() -> App<'a, 'b> {
|
||||
.long("admin")
|
||||
.help("Makes the user an administrator of the instance"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("moderator")
|
||||
.short("m")
|
||||
.long("moderator")
|
||||
.help("Makes the user a moderator of the instance"),
|
||||
)
|
||||
.about("Create a new user on this instance"),
|
||||
)
|
||||
.subcommand(
|
||||
@@ -100,17 +94,7 @@ fn new<'a>(args: &ArgMatches<'a>, conn: &Connection) {
|
||||
.value_of("display-name")
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| super::ask_for("Display name"));
|
||||
|
||||
let admin = args.is_present("admin");
|
||||
let moderator = args.is_present("moderator");
|
||||
let role = if admin {
|
||||
Role::Admin
|
||||
} else if moderator {
|
||||
Role::Moderator
|
||||
} else {
|
||||
Role::Normal
|
||||
};
|
||||
|
||||
let bio = args.value_of("biography").unwrap_or("").to_string();
|
||||
let email = args
|
||||
.value_of("email")
|
||||
@@ -129,7 +113,7 @@ fn new<'a>(args: &ArgMatches<'a>, conn: &Connection) {
|
||||
conn,
|
||||
username,
|
||||
display_name,
|
||||
role,
|
||||
admin,
|
||||
&bio,
|
||||
email,
|
||||
User::hash_pass(&password).expect("Couldn't hash password"),
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
[package]
|
||||
name = "plume-common"
|
||||
version = "0.4.0"
|
||||
version = "0.3.0"
|
||||
authors = ["Plume contributors"]
|
||||
|
||||
[dependencies]
|
||||
activitypub = "0.1.1"
|
||||
activitystreams-derive = "0.1.1"
|
||||
activitystreams-derive = "0.1.0"
|
||||
activitystreams-traits = "0.1.0"
|
||||
array_tool = "1.0"
|
||||
base64 = "0.10"
|
||||
heck = "0.3.0"
|
||||
hex = "0.3"
|
||||
hyper = "0.12.33"
|
||||
hyper = "0.12.28"
|
||||
openssl = "0.10.22"
|
||||
rocket = "0.4.0"
|
||||
reqwest = "0.9"
|
||||
@@ -19,7 +19,6 @@ serde = "1.0"
|
||||
serde_derive = "1.0"
|
||||
serde_json = "1.0"
|
||||
shrinkwraprs = "0.2.1"
|
||||
tokio = "0.1.22"
|
||||
|
||||
[dependencies.chrono]
|
||||
features = ["serde"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use activitypub::{Activity, Link, Object};
|
||||
use array_tool::vec::Uniq;
|
||||
use reqwest::r#async::ClientBuilder;
|
||||
use reqwest::ClientBuilder;
|
||||
use rocket::{
|
||||
http::Status,
|
||||
request::{FromRequest, Request},
|
||||
@@ -8,7 +8,6 @@ use rocket::{
|
||||
Outcome,
|
||||
};
|
||||
use serde_json;
|
||||
use tokio::prelude::*;
|
||||
|
||||
use self::sign::Signable;
|
||||
|
||||
@@ -130,36 +129,38 @@ where
|
||||
.sign(sender)
|
||||
.expect("activity_pub::broadcast: signature error");
|
||||
|
||||
let mut rt = tokio::runtime::current_thread::Runtime::new()
|
||||
.expect("Error while initializing tokio runtime for federation");
|
||||
let client = ClientBuilder::new()
|
||||
.connect_timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("Can't build client");
|
||||
for inbox in boxes {
|
||||
// TODO: run it in Sidekiq or something like that
|
||||
let body = signed.to_string();
|
||||
let mut headers = request::headers();
|
||||
headers.insert("Digest", request::Digest::digest(&body));
|
||||
rt.spawn(
|
||||
client
|
||||
.post(&inbox)
|
||||
.headers(headers.clone())
|
||||
.header(
|
||||
"Signature",
|
||||
request::signature(sender, &headers)
|
||||
.expect("activity_pub::broadcast: request signature error"),
|
||||
)
|
||||
.body(body)
|
||||
.send()
|
||||
.and_then(|r| r.into_body().concat2())
|
||||
.map(move |response| {
|
||||
println!("Successfully sent activity to inbox ({})", inbox);
|
||||
println!("Response: \"{:?}\"\n", response)
|
||||
})
|
||||
.map_err(|e| println!("Error while sending to inbox ({:?})", e)),
|
||||
);
|
||||
let res = ClientBuilder::new()
|
||||
.connect_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
.build()
|
||||
.and_then(|client| {
|
||||
client
|
||||
.post(&inbox)
|
||||
.headers(headers.clone())
|
||||
.header(
|
||||
"Signature",
|
||||
request::signature(sender, &headers)
|
||||
.expect("activity_pub::broadcast: request signature error"),
|
||||
)
|
||||
.body(body)
|
||||
.send()
|
||||
});
|
||||
match res {
|
||||
Ok(mut r) => {
|
||||
println!("Successfully sent activity to inbox ({})", inbox);
|
||||
if let Ok(response) = r.text() {
|
||||
println!("Response: \"{:?}\"\n\n", response)
|
||||
} else {
|
||||
println!("Error while reading response")
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Error while sending to inbox ({:?})", e),
|
||||
}
|
||||
}
|
||||
rt.run().unwrap();
|
||||
}
|
||||
|
||||
#[derive(Shrinkwrap, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -20,7 +20,6 @@ extern crate shrinkwraprs;
|
||||
extern crate serde_derive;
|
||||
#[macro_use]
|
||||
extern crate serde_json;
|
||||
extern crate tokio;
|
||||
|
||||
pub mod activity_pub;
|
||||
pub mod utils;
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
[package]
|
||||
name = "plume-front"
|
||||
version = "0.4.0"
|
||||
version = "0.3.0"
|
||||
authors = ["Plume contributors"]
|
||||
|
||||
[dependencies]
|
||||
stdweb = "=0.4.18"
|
||||
stdweb-internal-runtime = "=0.1.4"
|
||||
stdweb = "=0.4.14"
|
||||
stdweb-internal-runtime = "=0.1.3"
|
||||
gettext = { git = "https://github.com/Plume-org/gettext/", rev = "294c54d74c699fbc66502b480a37cc66c1daa7f3" }
|
||||
gettext-macros = { git = "https://github.com/Plume-org/gettext-macros/", rev = "a7c605f7edd6bfbfbfe7778026bfefd88d82db10" }
|
||||
gettext-utils = { git = "https://github.com/Plume-org/gettext-macros/", rev = "a7c605f7edd6bfbfbfe7778026bfefd88d82db10" }
|
||||
lazy_static = "1.3"
|
||||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
|
||||
+25
-191
@@ -1,6 +1,3 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use std::sync::Mutex;
|
||||
use stdweb::{
|
||||
unstable::{TryFrom, TryInto},
|
||||
web::{event::*, html_element::*, *},
|
||||
@@ -19,26 +16,17 @@ macro_rules! mv {
|
||||
fn get_elt_value(id: &'static str) -> String {
|
||||
let elt = document().get_element_by_id(id).unwrap();
|
||||
let inp: Result<InputElement, _> = elt.clone().try_into();
|
||||
let textarea: Result<TextAreaElement, _> = elt.clone().try_into();
|
||||
let select: Result<SelectElement, _> = elt.try_into();
|
||||
inp.map(|i| i.raw_value()).unwrap_or_else(|_| {
|
||||
textarea
|
||||
.map(|t| t.value())
|
||||
.unwrap_or_else(|_| select.unwrap().raw_value())
|
||||
})
|
||||
let textarea: Result<TextAreaElement, _> = elt.try_into();
|
||||
inp.map(|i| i.raw_value())
|
||||
.unwrap_or_else(|_| textarea.unwrap().value())
|
||||
}
|
||||
|
||||
fn set_value<S: AsRef<str>>(id: &'static str, val: S) {
|
||||
let elt = document().get_element_by_id(id).unwrap();
|
||||
let inp: Result<InputElement, _> = elt.clone().try_into();
|
||||
let textarea: Result<TextAreaElement, _> = elt.clone().try_into();
|
||||
let select: Result<SelectElement, _> = elt.try_into();
|
||||
let textarea: Result<TextAreaElement, _> = elt.try_into();
|
||||
inp.map(|i| i.set_raw_value(val.as_ref()))
|
||||
.unwrap_or_else(|_| {
|
||||
textarea
|
||||
.map(|t| t.set_value(val.as_ref()))
|
||||
.unwrap_or_else(|_| select.unwrap().set_raw_value(val.as_ref()))
|
||||
})
|
||||
.unwrap_or_else(|_| textarea.unwrap().set_value(val.as_ref()))
|
||||
}
|
||||
|
||||
fn no_return(evt: KeyDownEvent) {
|
||||
@@ -74,148 +62,7 @@ impl From<stdweb::private::ConversionError> for EditorError {
|
||||
EditorError::TypeError
|
||||
}
|
||||
}
|
||||
const AUTOSAVE_DEBOUNCE_TIME: u32 = 5000;
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct AutosaveInformation {
|
||||
contents: String,
|
||||
cover: String,
|
||||
last_saved: f64,
|
||||
license: String,
|
||||
subtitle: String,
|
||||
tags: String,
|
||||
title: String,
|
||||
}
|
||||
js_serializable!(AutosaveInformation);
|
||||
fn is_basic_editor() -> bool {
|
||||
if let Some(basic_editor) = window().local_storage().get("basic-editor") {
|
||||
basic_editor == "true"
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
fn get_title() -> String {
|
||||
if is_basic_editor() {
|
||||
get_elt_value("title")
|
||||
} else {
|
||||
let title_field = HtmlElement::try_from(
|
||||
document()
|
||||
.query_selector("#plume-editor > h1")
|
||||
.ok()
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
.ok()
|
||||
.unwrap();
|
||||
title_field.inner_text()
|
||||
}
|
||||
}
|
||||
fn get_autosave_id() -> String {
|
||||
format!(
|
||||
"editor_contents={}",
|
||||
window().location().unwrap().pathname().unwrap()
|
||||
)
|
||||
}
|
||||
fn get_editor_contents() -> String {
|
||||
if is_basic_editor() {
|
||||
get_elt_value("editor-content")
|
||||
} else {
|
||||
let editor =
|
||||
HtmlElement::try_from(document().query_selector("article").ok().unwrap().unwrap())
|
||||
.ok()
|
||||
.unwrap();
|
||||
editor.child_nodes().iter().fold(String::new(), |md, ch| {
|
||||
let to_append = match ch.node_type() {
|
||||
NodeType::Element => {
|
||||
if js! { return @{&ch}.tagName; } == "DIV" {
|
||||
(js! { return @{&ch}.innerHTML; })
|
||||
.try_into()
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
(js! { return @{&ch}.outerHTML; })
|
||||
.try_into()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
NodeType::Text => ch.node_value().unwrap_or_default(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
format!("{}\n\n{}", md, to_append)
|
||||
})
|
||||
}
|
||||
}
|
||||
fn get_subtitle() -> String {
|
||||
if is_basic_editor() {
|
||||
get_elt_value("subtitle")
|
||||
} else {
|
||||
let subtitle_element = HtmlElement::try_from(
|
||||
document()
|
||||
.query_selector("#plume-editor > h2")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
.ok()
|
||||
.unwrap();
|
||||
subtitle_element.inner_text()
|
||||
}
|
||||
}
|
||||
fn autosave() {
|
||||
let info = AutosaveInformation {
|
||||
contents: get_editor_contents(),
|
||||
title: get_title(),
|
||||
subtitle: get_subtitle(),
|
||||
tags: get_elt_value("tags"),
|
||||
license: get_elt_value("license"),
|
||||
last_saved: Date::now(),
|
||||
cover: get_elt_value("cover"),
|
||||
};
|
||||
let id = get_autosave_id();
|
||||
match window()
|
||||
.local_storage()
|
||||
.insert(&id, &serde_json::to_string(&info).unwrap())
|
||||
{
|
||||
Ok(_) => {}
|
||||
_ => console!(log, "Autosave failed D:"),
|
||||
}
|
||||
}
|
||||
//This is only necessary until we go to stdweb 4.20 at least
|
||||
fn confirm(message: &str) -> bool {
|
||||
let result: bool = js! {return confirm(@{message});} == true;
|
||||
result
|
||||
}
|
||||
fn load_autosave() {
|
||||
if let Some(autosave_str) = window().local_storage().get(&get_autosave_id()) {
|
||||
let autosave_info: AutosaveInformation = serde_json::from_str(&autosave_str).ok().unwrap();
|
||||
let message = i18n!(
|
||||
CATALOG,
|
||||
"Do you want to load the local autosave last edited at {}?";
|
||||
Date::from_time(autosave_info.last_saved).to_date_string()
|
||||
);
|
||||
if confirm(&message) {
|
||||
set_value("editor-content", &autosave_info.contents);
|
||||
set_value("title", &autosave_info.title);
|
||||
set_value("subtitle", &autosave_info.subtitle);
|
||||
set_value("tags", &autosave_info.tags);
|
||||
set_value("license", &autosave_info.license);
|
||||
set_value("cover", &autosave_info.cover);
|
||||
} else {
|
||||
clear_autosave();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn clear_autosave() {
|
||||
window().local_storage().remove(&get_autosave_id());
|
||||
console!(log, &format!("Saved to {}", &get_autosave_id()));
|
||||
}
|
||||
lazy_static! {
|
||||
static ref AUTOSAVE_TIMEOUT: Mutex<Option<TimeoutHandle>> = Mutex::new(None);
|
||||
}
|
||||
fn autosave_debounce() {
|
||||
let timeout = &mut AUTOSAVE_TIMEOUT.lock().unwrap();
|
||||
if let Some(timeout) = timeout.take() {
|
||||
timeout.clear();
|
||||
}
|
||||
**timeout = Some(window().set_clearable_timeout(autosave, AUTOSAVE_DEBOUNCE_TIME));
|
||||
}
|
||||
|
||||
fn init_widget(
|
||||
parent: &Element,
|
||||
tag: &'static str,
|
||||
@@ -253,40 +100,29 @@ fn filter_paste(elt: &HtmlElement) {
|
||||
}
|
||||
|
||||
pub fn init() -> Result<(), EditorError> {
|
||||
if let Some(ed) = document().get_element_by_id("plume-fallback-editor") {
|
||||
load_autosave();
|
||||
ed.add_event_listener(|_: SubmitEvent| clear_autosave());
|
||||
}
|
||||
// Check if the user wants to use the basic editor
|
||||
if window()
|
||||
.local_storage()
|
||||
.get("basic-editor")
|
||||
.map(|x| x == "true")
|
||||
.unwrap_or(true)
|
||||
{
|
||||
if let Some(editor) = document().get_element_by_id("plume-fallback-editor") {
|
||||
if let Ok(Some(title_label)) = document().query_selector("label[for=title]") {
|
||||
let editor_button = document().create_element("a")?;
|
||||
js! { @{&editor_button}.href = "#"; }
|
||||
editor_button.add_event_listener(|_: ClickEvent| {
|
||||
window().local_storage().remove("basic-editor");
|
||||
window().history().go(0).ok(); // refresh
|
||||
});
|
||||
editor_button.append_child(
|
||||
&document().create_text_node(&i18n!(CATALOG, "Open the rich text editor")),
|
||||
);
|
||||
editor.insert_before(&editor_button, &title_label).ok();
|
||||
document()
|
||||
.get_element_by_id("editor-content")
|
||||
.unwrap()
|
||||
.add_event_listener(|_: KeyDownEvent| autosave_debounce());
|
||||
if let Some(basic_editor) = window().local_storage().get("basic-editor") {
|
||||
if basic_editor == "true" {
|
||||
if let Some(editor) = document().get_element_by_id("plume-fallback-editor") {
|
||||
if let Ok(Some(title_label)) = document().query_selector("label[for=title]") {
|
||||
let editor_button = document().create_element("a")?;
|
||||
js! { @{&editor_button}.href = "#"; }
|
||||
editor_button.add_event_listener(|_: ClickEvent| {
|
||||
window().local_storage().remove("basic-editor");
|
||||
window().history().go(0).ok(); // refresh
|
||||
});
|
||||
editor_button.append_child(
|
||||
&document().create_text_node(&i18n!(CATALOG, "Open the rich text editor")),
|
||||
);
|
||||
editor.insert_before(&editor_button, &title_label).ok();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
init_editor()
|
||||
}
|
||||
|
||||
// If we didn't returned above
|
||||
init_editor()
|
||||
}
|
||||
|
||||
fn init_editor() -> Result<(), EditorError> {
|
||||
@@ -334,7 +170,6 @@ fn init_editor() -> Result<(), EditorError> {
|
||||
}).ok();
|
||||
};
|
||||
}), 0);
|
||||
autosave_debounce();
|
||||
}));
|
||||
|
||||
document().get_element_by_id("publish")?.add_event_listener(
|
||||
@@ -470,7 +305,6 @@ fn init_popup(
|
||||
cover.parent_element().unwrap().remove_child(&cover).ok();
|
||||
old_ed.append_child(&cover);
|
||||
set_value("license", get_elt_value("popup-license"));
|
||||
clear_autosave();
|
||||
js! {
|
||||
@{&old_ed}.submit();
|
||||
};
|
||||
|
||||
+5
-11
@@ -8,8 +8,7 @@ extern crate gettext_macros;
|
||||
extern crate lazy_static;
|
||||
#[macro_use]
|
||||
extern crate stdweb;
|
||||
extern crate serde;
|
||||
extern crate serde_json;
|
||||
|
||||
use stdweb::web::{event::*, *};
|
||||
|
||||
init_i18n!(
|
||||
@@ -47,15 +46,10 @@ lazy_static! {
|
||||
let catalogs = include_i18n!();
|
||||
let lang = js! { return navigator.language }.into_string().unwrap();
|
||||
let lang = lang.splitn(2, '-').next().unwrap_or("en");
|
||||
|
||||
let english_position = catalogs
|
||||
.iter()
|
||||
.position(|(language_code, _)| *language_code == "en")
|
||||
.unwrap();
|
||||
catalogs
|
||||
.iter()
|
||||
.find(|(l, _)| l == &lang)
|
||||
.unwrap_or(&catalogs[english_position])
|
||||
.unwrap_or(&catalogs[0])
|
||||
.clone()
|
||||
.1
|
||||
};
|
||||
@@ -69,19 +63,19 @@ fn main() {
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Toggle menu on mobile devices
|
||||
/// Toggle menu on mobile device
|
||||
///
|
||||
/// It should normally be working fine even without this code
|
||||
/// But :focus-within is not yet supported by Webkit/Blink
|
||||
fn menu() {
|
||||
if let Some(button) = document().get_element_by_id("menu") {
|
||||
if let Some(menu) = document().get_element_by_id("content") {
|
||||
button.add_event_listener(|_: TouchEnd| {
|
||||
button.add_event_listener(|_: ClickEvent| {
|
||||
document()
|
||||
.get_element_by_id("menu")
|
||||
.map(|menu| menu.class_list().add("show"));
|
||||
});
|
||||
menu.add_event_listener(|_: TouchEnd| {
|
||||
menu.add_event_listener(|_: ClickEvent| {
|
||||
document()
|
||||
.get_element_by_id("menu")
|
||||
.map(|menu| menu.class_list().remove("show"));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "plume-macro"
|
||||
version = "0.4.0"
|
||||
version = "0.1.0"
|
||||
authors = ["Trinity Pointard <trinity.pointard@insa-rennes.fr>"]
|
||||
edition = "2018"
|
||||
description = "Plume procedural macros"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
[package]
|
||||
name = "plume-models"
|
||||
version = "0.4.0"
|
||||
version = "0.3.0"
|
||||
authors = ["Plume contributors"]
|
||||
|
||||
[dependencies]
|
||||
activitypub = "0.1.1"
|
||||
ammonia = "2.1.1"
|
||||
askama_escape = "0.1"
|
||||
bcrypt = "0.5"
|
||||
bcrypt = "0.4"
|
||||
guid-create = "0.1"
|
||||
heck = "0.3.0"
|
||||
itertools = "0.8.0"
|
||||
@@ -17,14 +17,13 @@ openssl = "0.10.22"
|
||||
rocket = "0.4.0"
|
||||
rocket_i18n = { git = "https://github.com/Plume-org/rocket_i18n", rev = "e922afa7c366038b3433278c03b1456b346074f2" }
|
||||
reqwest = "0.9"
|
||||
scheduled-thread-pool = "0.2.2"
|
||||
scheduled-thread-pool = "0.2.0"
|
||||
serde = "1.0"
|
||||
serde_derive = "1.0"
|
||||
serde_json = "1.0"
|
||||
tantivy = "0.10.1"
|
||||
url = "2.1"
|
||||
walkdir = "2.2"
|
||||
webfinger = "0.4.1"
|
||||
tantivy = "0.9.1"
|
||||
url = "1.7"
|
||||
webfinger = "0.3.1"
|
||||
whatlang = "0.7.1"
|
||||
shrinkwraprs = "0.2.1"
|
||||
diesel-derive-newtype = "0.1.2"
|
||||
@@ -50,5 +49,5 @@ path = "../plume-macro"
|
||||
diesel_migrations = "1.3.0"
|
||||
|
||||
[features]
|
||||
postgres = ["diesel/postgres", "plume-macro/postgres" ]
|
||||
sqlite = ["diesel/sqlite", "plume-macro/sqlite" ]
|
||||
postgres = ["diesel/postgres", "plume-macro/postgres"]
|
||||
sqlite = ["diesel/sqlite", "plume-macro/sqlite"]
|
||||
|
||||
@@ -14,26 +14,10 @@ impl<'a, 'r> FromRequest<'a, 'r> for Admin {
|
||||
|
||||
fn from_request(request: &'a Request<'r>) -> request::Outcome<Admin, ()> {
|
||||
let user = request.guard::<User>()?;
|
||||
if user.is_admin() {
|
||||
if user.is_admin {
|
||||
Outcome::Success(Admin(user))
|
||||
} else {
|
||||
Outcome::Failure((Status::Unauthorized, ()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Same as `Admin` but for moderators.
|
||||
pub struct Moderator(pub User);
|
||||
|
||||
impl<'a, 'r> FromRequest<'a, 'r> for Moderator {
|
||||
type Error = ();
|
||||
|
||||
fn from_request(request: &'a Request<'r>) -> request::Outcome<Moderator, ()> {
|
||||
let user = request.guard::<User>()?;
|
||||
if user.is_moderator() {
|
||||
Outcome::Success(Moderator(user))
|
||||
} else {
|
||||
Outcome::Failure((Status::Unauthorized, ()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-60
@@ -1,11 +1,6 @@
|
||||
use activitypub::{
|
||||
actor::Group,
|
||||
collection::{OrderedCollection, OrderedCollectionPage},
|
||||
object::Image,
|
||||
CustomObject,
|
||||
};
|
||||
use activitypub::{actor::Group, collection::OrderedCollection, object::Image, CustomObject};
|
||||
use chrono::NaiveDateTime;
|
||||
use diesel::{self, ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SaveChangesDsl};
|
||||
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl, SaveChangesDsl};
|
||||
use openssl::{
|
||||
hash::MessageDigest,
|
||||
pkey::{PKey, Private},
|
||||
@@ -27,7 +22,7 @@ use safe_string::SafeString;
|
||||
use schema::blogs;
|
||||
use search::Searcher;
|
||||
use users::User;
|
||||
use {ap_url, Connection, Error, PlumeRocket, Result, ITEMS_PER_PAGE};
|
||||
use {Connection, Error, PlumeRocket, Result};
|
||||
|
||||
pub type CustomGroup = CustomObject<ApSignature, Group>;
|
||||
|
||||
@@ -49,7 +44,6 @@ pub struct Blog {
|
||||
pub summary_html: SafeString,
|
||||
pub icon_id: Option<i32>,
|
||||
pub banner_id: Option<i32>,
|
||||
pub theme: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Insertable)]
|
||||
@@ -67,7 +61,6 @@ pub struct NewBlog {
|
||||
pub summary_html: SafeString,
|
||||
pub icon_id: Option<i32>,
|
||||
pub banner_id: Option<i32>,
|
||||
pub theme: Option<String>,
|
||||
}
|
||||
|
||||
const BLOG_PREFIX: &str = "~";
|
||||
@@ -140,8 +133,10 @@ impl Blog {
|
||||
pub fn find_by_fqn(c: &PlumeRocket, fqn: &str) -> Result<Blog> {
|
||||
let from_db = blogs::table
|
||||
.filter(blogs::fqn.eq(fqn))
|
||||
.first(&*c.conn)
|
||||
.optional()?;
|
||||
.limit(1)
|
||||
.load::<Blog>(&*c.conn)?
|
||||
.into_iter()
|
||||
.next();
|
||||
if let Some(from_db) = from_db {
|
||||
Ok(from_db)
|
||||
} else {
|
||||
@@ -150,7 +145,7 @@ impl Blog {
|
||||
}
|
||||
|
||||
fn fetch_from_webfinger(c: &PlumeRocket, acct: &str) -> Result<Blog> {
|
||||
resolve_with_prefix(Prefix::Group, acct.to_owned(), true)?
|
||||
resolve(acct.to_owned(), true)?
|
||||
.links
|
||||
.into_iter()
|
||||
.find(|l| l.mime_type == Some(String::from("application/activity+json")))
|
||||
@@ -225,49 +220,12 @@ impl Blog {
|
||||
coll.collection_props.items = serde_json::to_value(self.get_activities(conn)?)?;
|
||||
coll.collection_props
|
||||
.set_total_items_u64(self.get_activities(conn)?.len() as u64)?;
|
||||
coll.collection_props
|
||||
.set_first_link(Id::new(ap_url(&format!("{}?page=1", &self.outbox_url))))?;
|
||||
coll.collection_props
|
||||
.set_last_link(Id::new(ap_url(&format!(
|
||||
"{}?page={}",
|
||||
&self.outbox_url,
|
||||
(self.get_activities(conn)?.len() as u64 + ITEMS_PER_PAGE as u64 - 1) as u64
|
||||
/ ITEMS_PER_PAGE as u64
|
||||
))))?;
|
||||
Ok(ActivityStream::new(coll))
|
||||
}
|
||||
pub fn outbox_page(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
(min, max): (i32, i32),
|
||||
) -> Result<ActivityStream<OrderedCollectionPage>> {
|
||||
let mut coll = OrderedCollectionPage::default();
|
||||
let acts = self.get_activity_page(&conn, (min, max))?;
|
||||
//This still doesn't do anything because the outbox
|
||||
//doesn't do anything yet
|
||||
coll.collection_page_props.set_next_link(Id::new(&format!(
|
||||
"{}?page={}",
|
||||
&self.outbox_url,
|
||||
min / ITEMS_PER_PAGE + 1
|
||||
)))?;
|
||||
coll.collection_page_props.set_prev_link(Id::new(&format!(
|
||||
"{}?page={}",
|
||||
&self.outbox_url,
|
||||
min / ITEMS_PER_PAGE - 1
|
||||
)))?;
|
||||
coll.collection_props.items = serde_json::to_value(acts)?;
|
||||
Ok(ActivityStream::new(coll))
|
||||
}
|
||||
|
||||
fn get_activities(&self, _conn: &Connection) -> Result<Vec<serde_json::Value>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
fn get_activity_page(
|
||||
&self,
|
||||
_conn: &Connection,
|
||||
(_min, _max): (i32, i32),
|
||||
) -> Result<Vec<serde_json::Value>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
pub fn get_keypair(&self) -> Result<PKey<Private>> {
|
||||
PKey::from_rsa(Rsa::private_key_from_pem(
|
||||
@@ -314,7 +272,7 @@ impl Blog {
|
||||
pub fn icon_url(&self, conn: &Connection) -> String {
|
||||
self.icon_id
|
||||
.and_then(|id| Media::get(conn, id).and_then(|m| m.url()).ok())
|
||||
.unwrap_or_else(|| "/static/images/default-avatar.png".to_string())
|
||||
.unwrap_or_else(|| "/static/default-avatar.png".to_string())
|
||||
}
|
||||
|
||||
pub fn banner_url(&self, conn: &Connection) -> Option<String> {
|
||||
@@ -434,7 +392,6 @@ impl FromId<PlumeRocket> for Blog {
|
||||
.summary_string()
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
theme: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -612,8 +569,9 @@ pub(crate) mod tests {
|
||||
Instance::get_local().unwrap().id
|
||||
);
|
||||
// TODO add tests for remote instance
|
||||
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -713,8 +671,9 @@ pub(crate) mod tests {
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|b| b.id == blog[1].id));
|
||||
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -737,8 +696,9 @@ pub(crate) mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(Blog::find_by_fqn(&r, "SomeName").unwrap().id, blog.id);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -760,8 +720,9 @@ pub(crate) mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(blog.fqn, "SomeName");
|
||||
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -772,8 +733,9 @@ pub(crate) mod tests {
|
||||
|
||||
blogs[0].delete(conn, &get_searcher()).unwrap();
|
||||
assert!(Blog::get(conn, blogs[0].id).is_err());
|
||||
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -842,8 +804,9 @@ pub(crate) mod tests {
|
||||
assert!(Blog::get(conn, blog[1].id).is_err());
|
||||
user[1].delete(conn, &searcher).unwrap();
|
||||
assert!(Blog::get(conn, blog[0].id).is_err());
|
||||
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -904,6 +867,6 @@ pub(crate) mod tests {
|
||||
assert_eq!(blog.banner_url(conn), blogs[0].banner_url(conn));
|
||||
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,7 +436,8 @@ mod tests {
|
||||
}
|
||||
_ => panic!("Unexpected result"),
|
||||
};
|
||||
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@ pub struct Config {
|
||||
pub search_index: String,
|
||||
pub rocket: Result<RocketConfig, RocketError>,
|
||||
pub logo: LogoConfig,
|
||||
pub default_theme: String,
|
||||
pub media_directory: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -201,8 +199,5 @@ lazy_static! {
|
||||
search_index: var("SEARCH_INDEX").unwrap_or_else(|_| "search_index".to_owned()),
|
||||
rocket: get_rocket_config(),
|
||||
logo: LogoConfig::default(),
|
||||
default_theme: var("DEFAULT_THEME").unwrap_or_else(|_| "default-light".to_owned()),
|
||||
media_directory: var("MEDIA_UPLOAD_DIRECTORY")
|
||||
.unwrap_or_else(|_| "static/media".to_owned()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,19 +59,3 @@ impl CustomizeConnection<Connection, ConnError> for PragmaForeignKey {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use diesel::Connection as _;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TestConnectionCustomizer;
|
||||
impl CustomizeConnection<Connection, ConnError> for TestConnectionCustomizer {
|
||||
fn on_acquire(&self, conn: &mut Connection) -> Result<(), ConnError> {
|
||||
PragmaForeignKey.on_acquire(conn)?;
|
||||
Ok(conn.begin_test_transaction().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -235,8 +235,7 @@ mod tests {
|
||||
)
|
||||
.expect("Couldn't insert new follow");
|
||||
assert_eq!(follow.ap_url, String::from("https://some.url/"));
|
||||
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ pub(crate) mod tests {
|
||||
}
|
||||
_ => panic!("Unexpected result"),
|
||||
};
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -169,6 +170,7 @@ pub(crate) mod tests {
|
||||
}
|
||||
_ => panic!("Unexpected result"),
|
||||
};
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -210,6 +212,7 @@ pub(crate) mod tests {
|
||||
}
|
||||
_ => panic!("Unexpected result"),
|
||||
};
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -252,8 +255,9 @@ pub(crate) mod tests {
|
||||
"type": "Delete",
|
||||
});
|
||||
assert!(super::inbox(&r, ok_act).is_ok());
|
||||
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -278,6 +282,7 @@ pub(crate) mod tests {
|
||||
"type": "Delete",
|
||||
});
|
||||
assert!(super::inbox(&r, ok_act).is_ok());
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -331,6 +336,7 @@ pub(crate) mod tests {
|
||||
}
|
||||
_ => panic!("Unexpected result"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -356,6 +362,7 @@ pub(crate) mod tests {
|
||||
}
|
||||
_ => panic!("Unexpected result"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -394,6 +401,7 @@ pub(crate) mod tests {
|
||||
"type": "Undo",
|
||||
});
|
||||
assert!(super::inbox(&r, ok_act).is_ok());
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -432,6 +440,7 @@ pub(crate) mod tests {
|
||||
"type": "Undo",
|
||||
});
|
||||
assert!(super::inbox(&r, ok_act).is_ok());
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -470,6 +479,7 @@ pub(crate) mod tests {
|
||||
"type": "Undo",
|
||||
});
|
||||
assert!(super::inbox(&r, ok_act).is_ok());
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -499,6 +509,7 @@ pub(crate) mod tests {
|
||||
});
|
||||
|
||||
super::inbox(&r, act).unwrap();
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
|
||||
use std::iter::Iterator;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use ap_url;
|
||||
@@ -7,7 +8,7 @@ use medias::Media;
|
||||
use plume_common::utils::md_to_html;
|
||||
use safe_string::SafeString;
|
||||
use schema::{instances, users};
|
||||
use users::{Role, User};
|
||||
use users::User;
|
||||
use {Connection, Error, Result};
|
||||
|
||||
#[derive(Clone, Identifiable, Queryable)]
|
||||
@@ -60,8 +61,11 @@ impl Instance {
|
||||
pub fn get_local_uncached(conn: &Connection) -> Result<Instance> {
|
||||
instances::table
|
||||
.filter(instances::local.eq(true))
|
||||
.first(conn)
|
||||
.map_err(Error::from)
|
||||
.limit(1)
|
||||
.load::<Instance>(conn)?
|
||||
.into_iter()
|
||||
.nth(0)
|
||||
.ok_or(Error::NotFound)
|
||||
}
|
||||
|
||||
pub fn cache_local(conn: &Connection) {
|
||||
@@ -113,7 +117,7 @@ impl Instance {
|
||||
pub fn has_admin(&self, conn: &Connection) -> Result<bool> {
|
||||
users::table
|
||||
.filter(users::instance_id.eq(self.id))
|
||||
.filter(users::role.eq(Role::Admin as i32))
|
||||
.filter(users::is_admin.eq(true))
|
||||
.load::<User>(conn)
|
||||
.map_err(Error::from)
|
||||
.map(|r| !r.is_empty())
|
||||
@@ -122,8 +126,9 @@ impl Instance {
|
||||
pub fn main_admin(&self, conn: &Connection) -> Result<User> {
|
||||
users::table
|
||||
.filter(users::instance_id.eq(self.id))
|
||||
.filter(users::role.eq(Role::Admin as i32))
|
||||
.first(conn)
|
||||
.filter(users::is_admin.eq(true))
|
||||
.limit(1)
|
||||
.get_result::<User>(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
@@ -144,7 +149,6 @@ impl Instance {
|
||||
open_registrations: bool,
|
||||
short_description: SafeString,
|
||||
long_description: SafeString,
|
||||
default_license: String,
|
||||
) -> Result<()> {
|
||||
let (sd, _, _) = md_to_html(
|
||||
short_description.as_ref(),
|
||||
@@ -166,7 +170,6 @@ impl Instance {
|
||||
instances::long_description.eq(long_description),
|
||||
instances::short_description_html.eq(sd),
|
||||
instances::long_description_html.eq(ld),
|
||||
instances::default_license.eq(default_license),
|
||||
))
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
@@ -183,60 +186,6 @@ impl Instance {
|
||||
.get_result(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
/// Returns a list of the local instance themes (all files matching `static/css/NAME/theme.css`)
|
||||
///
|
||||
/// The list only contains the name of the themes, without their extension or full path.
|
||||
pub fn list_themes() -> Result<Vec<String>> {
|
||||
// List all the files in static/css
|
||||
std::path::Path::new("static")
|
||||
.join("css")
|
||||
.read_dir()
|
||||
.map(|files| {
|
||||
files
|
||||
.filter_map(std::result::Result::ok)
|
||||
// Only keep actual directories (each theme has its own dir)
|
||||
.filter(|f| f.file_type().map(|t| t.is_dir()).unwrap_or(false))
|
||||
// Only keep the directory name (= theme name)
|
||||
.filter_map(|f| {
|
||||
f.path()
|
||||
.file_name()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.map(std::borrow::ToOwned::to_owned)
|
||||
})
|
||||
// Ignore the one starting with "blog-": these are the blog themes
|
||||
.filter(|f| !f.starts_with("blog-"))
|
||||
.collect()
|
||||
})
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
/// Returns a list of the local blog themes (all files matching `static/css/blog-NAME/theme.css`)
|
||||
///
|
||||
/// The list only contains the name of the themes, without their extension or full path.
|
||||
pub fn list_blog_themes() -> Result<Vec<String>> {
|
||||
// List all the files in static/css
|
||||
std::path::Path::new("static")
|
||||
.join("css")
|
||||
.read_dir()
|
||||
.map(|files| {
|
||||
files
|
||||
.filter_map(std::result::Result::ok)
|
||||
// Only keep actual directories (each theme has its own dir)
|
||||
.filter(|f| f.file_type().map(|t| t.is_dir()).unwrap_or(false))
|
||||
// Only keep the directory name (= theme name)
|
||||
.filter_map(|f| {
|
||||
f.path()
|
||||
.file_name()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.map(std::borrow::ToOwned::to_owned)
|
||||
})
|
||||
// Only keep the one starting with "blog-": these are the blog themes
|
||||
.filter(|f| f.starts_with("blog-"))
|
||||
.collect()
|
||||
})
|
||||
.map_err(Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -338,6 +287,7 @@ pub(crate) mod tests {
|
||||
res.short_description_html.get(),
|
||||
&inserted.short_description_html
|
||||
);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -398,6 +348,7 @@ pub(crate) mod tests {
|
||||
assert!(last_domaine <= page[0].public_domain);
|
||||
last_domaine = page[0].public_domain.clone();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -460,6 +411,7 @@ pub(crate) mod tests {
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -476,7 +428,6 @@ pub(crate) mod tests {
|
||||
false,
|
||||
SafeString::new("[short](#link)"),
|
||||
SafeString::new("[long_description](/with_link)"),
|
||||
"CC-BY-SAO".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
let inst = Instance::get(conn, inst.id).unwrap();
|
||||
@@ -495,7 +446,7 @@ pub(crate) mod tests {
|
||||
inst.short_description_html,
|
||||
SafeString::new("<p><a href=\"#link\">short</a></p>\n")
|
||||
);
|
||||
assert_eq!(inst.default_license, "CC-BY-SAO".to_owned());
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
+19
-21
@@ -33,7 +33,6 @@ extern crate serde_json;
|
||||
#[macro_use]
|
||||
extern crate tantivy;
|
||||
extern crate url;
|
||||
extern crate walkdir;
|
||||
extern crate webfinger;
|
||||
extern crate whatlang;
|
||||
|
||||
@@ -63,11 +62,9 @@ pub enum Error {
|
||||
SerDe,
|
||||
Search(search::SearcherError),
|
||||
Signature,
|
||||
TimelineQuery(timeline::query::QueryError),
|
||||
Unauthorized,
|
||||
Url,
|
||||
Webfinger,
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl From<bcrypt::BcryptError> for Error {
|
||||
@@ -75,7 +72,7 @@ impl From<bcrypt::BcryptError> for Error {
|
||||
Error::Signature
|
||||
}
|
||||
}
|
||||
pub const ITEMS_PER_PAGE: i32 = 12;
|
||||
|
||||
impl From<openssl::error::ErrorStack> for Error {
|
||||
fn from(_: openssl::error::ErrorStack) -> Self {
|
||||
Error::Signature
|
||||
@@ -139,12 +136,6 @@ impl From<search::SearcherError> for Error {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<timeline::query::QueryError> for Error {
|
||||
fn from(err: timeline::query::QueryError) -> Self {
|
||||
Error::TimelineQuery(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
Error::Io(err)
|
||||
@@ -181,8 +172,11 @@ macro_rules! find_by {
|
||||
pub fn $fn(conn: &crate::Connection, $($col: $type),+) -> Result<Self> {
|
||||
$table::table
|
||||
$(.filter($table::$col.eq($col)))+
|
||||
.first(conn)
|
||||
.map_err(Error::from)
|
||||
.limit(1)
|
||||
.load::<Self>(conn)?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(Error::NotFound)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -228,8 +222,11 @@ macro_rules! get {
|
||||
pub fn get(conn: &crate::Connection, id: i32) -> Result<Self> {
|
||||
$table::table
|
||||
.filter($table::id.eq(id))
|
||||
.first(conn)
|
||||
.map_err(Error::from)
|
||||
.limit(1)
|
||||
.load::<Self>(conn)?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(Error::NotFound)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -282,8 +279,11 @@ macro_rules! last {
|
||||
pub fn last(conn: &crate::Connection) -> Result<Self> {
|
||||
$table::table
|
||||
.order_by($table::id.desc())
|
||||
.first(conn)
|
||||
.map_err(Error::from)
|
||||
.limit(1)
|
||||
.load::<Self>(conn)?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(Error::NotFound)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -300,6 +300,8 @@ pub fn ap_url(url: &str) -> String {
|
||||
mod tests {
|
||||
use db_conn;
|
||||
use diesel::r2d2::ConnectionManager;
|
||||
#[cfg(feature = "sqlite")]
|
||||
use diesel::{dsl::sql_query, RunQueryDsl};
|
||||
use migrations::IMPORTED_MIGRATIONS;
|
||||
use plume_common::utils::random_hex;
|
||||
use scheduled_thread_pool::ScheduledThreadPool;
|
||||
@@ -327,7 +329,7 @@ mod tests {
|
||||
lazy_static! {
|
||||
static ref DB_POOL: db_conn::DbPool = {
|
||||
let pool = db_conn::DbPool::builder()
|
||||
.connection_customizer(Box::new(db_conn::tests::TestConnectionCustomizer))
|
||||
.connection_customizer(Box::new(db_conn::PragmaForeignKey))
|
||||
.build(ConnectionManager::<Conn>::new(CONFIG.database_url.as_str()))
|
||||
.unwrap();
|
||||
let dir = temp_dir().join(format!("plume-test-{}", random_hex()));
|
||||
@@ -361,21 +363,17 @@ pub mod headers;
|
||||
pub mod inbox;
|
||||
pub mod instance;
|
||||
pub mod likes;
|
||||
pub mod lists;
|
||||
pub mod medias;
|
||||
pub mod mentions;
|
||||
pub mod migrations;
|
||||
pub mod notifications;
|
||||
pub mod password_reset_requests;
|
||||
pub mod plume_rocket;
|
||||
pub mod post_authors;
|
||||
pub mod posts;
|
||||
pub mod reshares;
|
||||
pub mod safe_string;
|
||||
#[allow(unused_imports)]
|
||||
pub mod schema;
|
||||
pub mod search;
|
||||
pub mod tags;
|
||||
pub mod timeline;
|
||||
pub mod users;
|
||||
pub use plume_rocket::PlumeRocket;
|
||||
|
||||
@@ -9,7 +9,6 @@ use plume_common::activity_pub::{
|
||||
};
|
||||
use posts::Post;
|
||||
use schema::likes;
|
||||
use timeline::*;
|
||||
use users::User;
|
||||
use {Connection, Error, PlumeRocket, Result};
|
||||
|
||||
@@ -100,8 +99,6 @@ impl AsObject<User, activity::Like, &PlumeRocket> for Post {
|
||||
},
|
||||
)?;
|
||||
res.notify(&c.conn)?;
|
||||
|
||||
Timeline::add_to_all_timelines(c, &self, Kind::Like(&actor))?;
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,555 +0,0 @@
|
||||
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
|
||||
|
||||
use blogs::Blog;
|
||||
use schema::{blogs, list_elems, lists, users};
|
||||
use std::convert::{TryFrom, TryInto};
|
||||
use users::User;
|
||||
use {Connection, Error, Result};
|
||||
|
||||
/// Represent what a list is supposed to store. Represented in database as an integer
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ListType {
|
||||
User,
|
||||
Blog,
|
||||
Word,
|
||||
Prefix,
|
||||
}
|
||||
|
||||
impl TryFrom<i32> for ListType {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(i: i32) -> std::result::Result<Self, ()> {
|
||||
match i {
|
||||
0 => Ok(ListType::User),
|
||||
1 => Ok(ListType::Blog),
|
||||
2 => Ok(ListType::Word),
|
||||
3 => Ok(ListType::Prefix),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<i32> for ListType {
|
||||
fn into(self) -> i32 {
|
||||
match self {
|
||||
ListType::User => 0,
|
||||
ListType::Blog => 1,
|
||||
ListType::Word => 2,
|
||||
ListType::Prefix => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Queryable, Identifiable)]
|
||||
pub struct List {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub user_id: Option<i32>,
|
||||
type_: i32,
|
||||
}
|
||||
|
||||
#[derive(Default, Insertable)]
|
||||
#[table_name = "lists"]
|
||||
struct NewList<'a> {
|
||||
pub name: &'a str,
|
||||
pub user_id: Option<i32>,
|
||||
type_: i32,
|
||||
}
|
||||
|
||||
macro_rules! func {
|
||||
(@elem User $id:expr, $value:expr) => {
|
||||
NewListElem {
|
||||
list_id: $id,
|
||||
user_id: Some(*$value),
|
||||
blog_id: None,
|
||||
word: None,
|
||||
}
|
||||
};
|
||||
(@elem Blog $id:expr, $value:expr) => {
|
||||
NewListElem {
|
||||
list_id: $id,
|
||||
user_id: None,
|
||||
blog_id: Some(*$value),
|
||||
word: None,
|
||||
}
|
||||
};
|
||||
(@elem Word $id:expr, $value:expr) => {
|
||||
NewListElem {
|
||||
list_id: $id,
|
||||
user_id: None,
|
||||
blog_id: None,
|
||||
word: Some($value),
|
||||
}
|
||||
};
|
||||
(@elem Prefix $id:expr, $value:expr) => {
|
||||
NewListElem {
|
||||
list_id: $id,
|
||||
user_id: None,
|
||||
blog_id: None,
|
||||
word: Some($value),
|
||||
}
|
||||
};
|
||||
(@in_type User) => { i32 };
|
||||
(@in_type Blog) => { i32 };
|
||||
(@in_type Word) => { &str };
|
||||
(@in_type Prefix) => { &str };
|
||||
(@out_type User) => { User };
|
||||
(@out_type Blog) => { Blog };
|
||||
(@out_type Word) => { String };
|
||||
(@out_type Prefix) => { String };
|
||||
|
||||
(add: $fn:ident, $kind:ident) => {
|
||||
pub fn $fn(&self, conn: &Connection, vals: &[func!(@in_type $kind)]) -> Result<()> {
|
||||
if self.kind() != ListType::$kind {
|
||||
return Err(Error::InvalidValue);
|
||||
}
|
||||
diesel::insert_into(list_elems::table)
|
||||
.values(
|
||||
vals
|
||||
.iter()
|
||||
.map(|u| func!(@elem $kind self.id, u))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.execute(conn)?;
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
|
||||
(list: $fn:ident, $kind:ident, $table:ident) => {
|
||||
pub fn $fn(&self, conn: &Connection) -> Result<Vec<func!(@out_type $kind)>> {
|
||||
if self.kind() != ListType::$kind {
|
||||
return Err(Error::InvalidValue);
|
||||
}
|
||||
list_elems::table
|
||||
.filter(list_elems::list_id.eq(self.id))
|
||||
.inner_join($table::table)
|
||||
.select($table::all_columns)
|
||||
.load(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
(set: $fn:ident, $kind:ident, $add:ident) => {
|
||||
pub fn $fn(&self, conn: &Connection, val: &[func!(@in_type $kind)]) -> Result<()> {
|
||||
if self.kind() != ListType::$kind {
|
||||
return Err(Error::InvalidValue);
|
||||
}
|
||||
self.clear(conn)?;
|
||||
self.$add(conn, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Queryable, Identifiable)]
|
||||
struct ListElem {
|
||||
pub id: i32,
|
||||
pub list_id: i32,
|
||||
pub user_id: Option<i32>,
|
||||
pub blog_id: Option<i32>,
|
||||
pub word: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Insertable)]
|
||||
#[table_name = "list_elems"]
|
||||
struct NewListElem<'a> {
|
||||
pub list_id: i32,
|
||||
pub user_id: Option<i32>,
|
||||
pub blog_id: Option<i32>,
|
||||
pub word: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl List {
|
||||
last!(lists);
|
||||
get!(lists);
|
||||
|
||||
fn insert(conn: &Connection, val: NewList) -> Result<Self> {
|
||||
diesel::insert_into(lists::table)
|
||||
.values(val)
|
||||
.execute(conn)?;
|
||||
List::last(conn)
|
||||
}
|
||||
|
||||
pub fn list_for_user(conn: &Connection, user_id: Option<i32>) -> Result<Vec<Self>> {
|
||||
if let Some(user_id) = user_id {
|
||||
lists::table
|
||||
.filter(lists::user_id.eq(user_id))
|
||||
.load::<Self>(conn)
|
||||
.map_err(Error::from)
|
||||
} else {
|
||||
lists::table
|
||||
.filter(lists::user_id.is_null())
|
||||
.load::<Self>(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_for_user_by_name(
|
||||
conn: &Connection,
|
||||
user_id: Option<i32>,
|
||||
name: &str,
|
||||
) -> Result<Self> {
|
||||
if let Some(user_id) = user_id {
|
||||
lists::table
|
||||
.filter(lists::user_id.eq(user_id))
|
||||
.filter(lists::name.eq(name))
|
||||
.first(conn)
|
||||
.map_err(Error::from)
|
||||
} else {
|
||||
lists::table
|
||||
.filter(lists::user_id.is_null())
|
||||
.filter(lists::name.eq(name))
|
||||
.first(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(conn: &Connection, name: &str, user: Option<&User>, kind: ListType) -> Result<Self> {
|
||||
Self::insert(
|
||||
conn,
|
||||
NewList {
|
||||
name,
|
||||
user_id: user.map(|u| u.id),
|
||||
type_: kind.into(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the kind of a list
|
||||
pub fn kind(&self) -> ListType {
|
||||
self.type_.try_into().expect("invalid list was constructed")
|
||||
}
|
||||
|
||||
/// Return Ok(true) if the list contain the given user, Ok(false) otherwiser,
|
||||
/// and Err(_) on error
|
||||
pub fn contains_user(&self, conn: &Connection, user: i32) -> Result<bool> {
|
||||
private::ListElem::user_in_list(conn, self, user)
|
||||
}
|
||||
|
||||
/// Return Ok(true) if the list contain the given blog, Ok(false) otherwiser,
|
||||
/// and Err(_) on error
|
||||
pub fn contains_blog(&self, conn: &Connection, blog: i32) -> Result<bool> {
|
||||
private::ListElem::blog_in_list(conn, self, blog)
|
||||
}
|
||||
|
||||
/// Return Ok(true) if the list contain the given word, Ok(false) otherwiser,
|
||||
/// and Err(_) on error
|
||||
pub fn contains_word(&self, conn: &Connection, word: &str) -> Result<bool> {
|
||||
private::ListElem::word_in_list(conn, self, word)
|
||||
}
|
||||
|
||||
/// Return Ok(true) if the list match the given prefix, Ok(false) otherwiser,
|
||||
/// and Err(_) on error
|
||||
pub fn contains_prefix(&self, conn: &Connection, word: &str) -> Result<bool> {
|
||||
private::ListElem::prefix_in_list(conn, self, word)
|
||||
}
|
||||
|
||||
/// Insert new users in a list
|
||||
func! {add: add_users, User}
|
||||
|
||||
/// Insert new blogs in a list
|
||||
func! {add: add_blogs, Blog}
|
||||
|
||||
/// Insert new words in a list
|
||||
func! {add: add_words, Word}
|
||||
|
||||
/// Insert new prefixes in a list
|
||||
func! {add: add_prefixes, Prefix}
|
||||
|
||||
/// Get all users in the list
|
||||
func! {list: list_users, User, users}
|
||||
|
||||
/// Get all blogs in the list
|
||||
func! {list: list_blogs, Blog, blogs}
|
||||
|
||||
/// Get all words in the list
|
||||
pub fn list_words(&self, conn: &Connection) -> Result<Vec<String>> {
|
||||
self.list_stringlike(conn, ListType::Word)
|
||||
}
|
||||
|
||||
/// Get all prefixes in the list
|
||||
pub fn list_prefixes(&self, conn: &Connection) -> Result<Vec<String>> {
|
||||
self.list_stringlike(conn, ListType::Prefix)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn list_stringlike(&self, conn: &Connection, t: ListType) -> Result<Vec<String>> {
|
||||
if self.kind() != t {
|
||||
return Err(Error::InvalidValue);
|
||||
}
|
||||
list_elems::table
|
||||
.filter(list_elems::list_id.eq(self.id))
|
||||
.filter(list_elems::word.is_not_null())
|
||||
.select(list_elems::word)
|
||||
.load::<Option<String>>(conn)
|
||||
.map_err(Error::from)
|
||||
.map(|r| r.into_iter().filter_map(|o| o).collect::<Vec<String>>())
|
||||
}
|
||||
|
||||
pub fn clear(&self, conn: &Connection) -> Result<()> {
|
||||
diesel::delete(list_elems::table.filter(list_elems::list_id.eq(self.id)))
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
func! {set: set_users, User, add_users}
|
||||
func! {set: set_blogs, Blog, add_blogs}
|
||||
func! {set: set_words, Word, add_words}
|
||||
func! {set: set_prefixes, Prefix, add_prefixes}
|
||||
}
|
||||
|
||||
mod private {
|
||||
pub use super::*;
|
||||
use diesel::{
|
||||
dsl,
|
||||
sql_types::{Nullable, Text},
|
||||
IntoSql, TextExpressionMethods,
|
||||
};
|
||||
|
||||
impl ListElem {
|
||||
insert!(list_elems, NewListElem);
|
||||
|
||||
pub fn user_in_list(conn: &Connection, list: &List, user: i32) -> Result<bool> {
|
||||
dsl::select(dsl::exists(
|
||||
list_elems::table
|
||||
.filter(list_elems::list_id.eq(list.id))
|
||||
.filter(list_elems::user_id.eq(Some(user))),
|
||||
))
|
||||
.get_result(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn blog_in_list(conn: &Connection, list: &List, blog: i32) -> Result<bool> {
|
||||
dsl::select(dsl::exists(
|
||||
list_elems::table
|
||||
.filter(list_elems::list_id.eq(list.id))
|
||||
.filter(list_elems::blog_id.eq(Some(blog))),
|
||||
))
|
||||
.get_result(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn word_in_list(conn: &Connection, list: &List, word: &str) -> Result<bool> {
|
||||
dsl::select(dsl::exists(
|
||||
list_elems::table
|
||||
.filter(list_elems::list_id.eq(list.id))
|
||||
.filter(list_elems::word.eq(word)),
|
||||
))
|
||||
.get_result(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn prefix_in_list(conn: &Connection, list: &List, word: &str) -> Result<bool> {
|
||||
dsl::select(dsl::exists(
|
||||
list_elems::table
|
||||
.filter(
|
||||
word.into_sql::<Nullable<Text>>()
|
||||
.like(list_elems::word.concat("%")),
|
||||
)
|
||||
.filter(list_elems::list_id.eq(list.id)),
|
||||
))
|
||||
.get_result(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use blogs::tests as blog_tests;
|
||||
use diesel::Connection;
|
||||
use tests::db;
|
||||
|
||||
#[test]
|
||||
fn list_type() {
|
||||
for i in 0..4 {
|
||||
assert_eq!(i, Into::<i32>::into(ListType::try_from(i).unwrap()));
|
||||
}
|
||||
ListType::try_from(4).unwrap_err();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_lists() {
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, _) = blog_tests::fill_database(conn);
|
||||
|
||||
let l1 = List::new(conn, "list1", None, ListType::User).unwrap();
|
||||
let l2 = List::new(conn, "list2", None, ListType::Blog).unwrap();
|
||||
let l1u = List::new(conn, "list1", Some(&users[0]), ListType::Word).unwrap();
|
||||
|
||||
let l_eq = |l1: &List, l2: &List| {
|
||||
assert_eq!(l1.id, l2.id);
|
||||
assert_eq!(l1.user_id, l2.user_id);
|
||||
assert_eq!(l1.name, l2.name);
|
||||
assert_eq!(l1.type_, l2.type_);
|
||||
};
|
||||
|
||||
let l1bis = List::get(conn, l1.id).unwrap();
|
||||
l_eq(&l1, &l1bis);
|
||||
|
||||
let l_inst = List::list_for_user(conn, None).unwrap();
|
||||
let l_user = List::list_for_user(conn, Some(users[0].id)).unwrap();
|
||||
assert_eq!(2, l_inst.len());
|
||||
assert_eq!(1, l_user.len());
|
||||
assert!(l_inst.iter().all(|l| l.id != l1u.id));
|
||||
|
||||
l_eq(&l1u, &l_user[0]);
|
||||
if l_inst[0].id == l1.id {
|
||||
l_eq(&l1, &l_inst[0]);
|
||||
l_eq(&l2, &l_inst[1]);
|
||||
} else {
|
||||
l_eq(&l1, &l_inst[1]);
|
||||
l_eq(&l2, &l_inst[0]);
|
||||
}
|
||||
|
||||
l_eq(
|
||||
&l1,
|
||||
&List::find_for_user_by_name(conn, l1.user_id, &l1.name).unwrap(),
|
||||
);
|
||||
l_eq(
|
||||
&&l1u,
|
||||
&List::find_for_user_by_name(conn, l1u.user_id, &l1u.name).unwrap(),
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_list() {
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, blogs) = blog_tests::fill_database(conn);
|
||||
|
||||
let l = List::new(conn, "list", None, ListType::User).unwrap();
|
||||
|
||||
assert_eq!(l.kind(), ListType::User);
|
||||
assert!(l.list_users(conn).unwrap().is_empty());
|
||||
|
||||
assert!(!l.contains_user(conn, users[0].id).unwrap());
|
||||
assert!(l.add_users(conn, &[users[0].id]).is_ok());
|
||||
assert!(l.contains_user(conn, users[0].id).unwrap());
|
||||
|
||||
assert!(l.add_users(conn, &[users[1].id]).is_ok());
|
||||
assert!(l.contains_user(conn, users[0].id).unwrap());
|
||||
assert!(l.contains_user(conn, users[1].id).unwrap());
|
||||
assert_eq!(2, l.list_users(conn).unwrap().len());
|
||||
|
||||
assert!(l.set_users(conn, &[users[0].id]).is_ok());
|
||||
assert!(l.contains_user(conn, users[0].id).unwrap());
|
||||
assert!(!l.contains_user(conn, users[1].id).unwrap());
|
||||
assert_eq!(1, l.list_users(conn).unwrap().len());
|
||||
assert!(users[0] == l.list_users(conn).unwrap()[0]);
|
||||
|
||||
l.clear(conn).unwrap();
|
||||
assert!(l.list_users(conn).unwrap().is_empty());
|
||||
|
||||
assert!(l.add_blogs(conn, &[blogs[0].id]).is_err());
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blog_list() {
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, blogs) = blog_tests::fill_database(conn);
|
||||
|
||||
let l = List::new(conn, "list", None, ListType::Blog).unwrap();
|
||||
|
||||
assert_eq!(l.kind(), ListType::Blog);
|
||||
assert!(l.list_blogs(conn).unwrap().is_empty());
|
||||
|
||||
assert!(!l.contains_blog(conn, blogs[0].id).unwrap());
|
||||
assert!(l.add_blogs(conn, &[blogs[0].id]).is_ok());
|
||||
assert!(l.contains_blog(conn, blogs[0].id).unwrap());
|
||||
|
||||
assert!(l.add_blogs(conn, &[blogs[1].id]).is_ok());
|
||||
assert!(l.contains_blog(conn, blogs[0].id).unwrap());
|
||||
assert!(l.contains_blog(conn, blogs[1].id).unwrap());
|
||||
assert_eq!(2, l.list_blogs(conn).unwrap().len());
|
||||
|
||||
assert!(l.set_blogs(conn, &[blogs[0].id]).is_ok());
|
||||
assert!(l.contains_blog(conn, blogs[0].id).unwrap());
|
||||
assert!(!l.contains_blog(conn, blogs[1].id).unwrap());
|
||||
assert_eq!(1, l.list_blogs(conn).unwrap().len());
|
||||
assert_eq!(blogs[0].id, l.list_blogs(conn).unwrap()[0].id);
|
||||
|
||||
l.clear(conn).unwrap();
|
||||
assert!(l.list_blogs(conn).unwrap().is_empty());
|
||||
|
||||
assert!(l.add_users(conn, &[users[0].id]).is_err());
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_word_list() {
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let l = List::new(conn, "list", None, ListType::Word).unwrap();
|
||||
|
||||
assert_eq!(l.kind(), ListType::Word);
|
||||
assert!(l.list_words(conn).unwrap().is_empty());
|
||||
|
||||
assert!(!l.contains_word(conn, "plume").unwrap());
|
||||
assert!(l.add_words(conn, &["plume"]).is_ok());
|
||||
assert!(l.contains_word(conn, "plume").unwrap());
|
||||
assert!(!l.contains_word(conn, "plumelin").unwrap());
|
||||
|
||||
assert!(l.add_words(conn, &["amsterdam"]).is_ok());
|
||||
assert!(l.contains_word(conn, "plume").unwrap());
|
||||
assert!(l.contains_word(conn, "amsterdam").unwrap());
|
||||
assert_eq!(2, l.list_words(conn).unwrap().len());
|
||||
|
||||
assert!(l.set_words(conn, &["plume"]).is_ok());
|
||||
assert!(l.contains_word(conn, "plume").unwrap());
|
||||
assert!(!l.contains_word(conn, "amsterdam").unwrap());
|
||||
assert_eq!(1, l.list_words(conn).unwrap().len());
|
||||
assert_eq!("plume", l.list_words(conn).unwrap()[0]);
|
||||
|
||||
l.clear(conn).unwrap();
|
||||
assert!(l.list_words(conn).unwrap().is_empty());
|
||||
|
||||
assert!(l.add_prefixes(conn, &["something"]).is_err());
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prefix_list() {
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let l = List::new(conn, "list", None, ListType::Prefix).unwrap();
|
||||
|
||||
assert_eq!(l.kind(), ListType::Prefix);
|
||||
assert!(l.list_prefixes(conn).unwrap().is_empty());
|
||||
|
||||
assert!(!l.contains_prefix(conn, "plume").unwrap());
|
||||
assert!(l.add_prefixes(conn, &["plume"]).is_ok());
|
||||
assert!(l.contains_prefix(conn, "plume").unwrap());
|
||||
assert!(l.contains_prefix(conn, "plumelin").unwrap());
|
||||
|
||||
assert!(l.add_prefixes(conn, &["amsterdam"]).is_ok());
|
||||
assert!(l.contains_prefix(conn, "plume").unwrap());
|
||||
assert!(l.contains_prefix(conn, "amsterdam").unwrap());
|
||||
assert_eq!(2, l.list_prefixes(conn).unwrap().len());
|
||||
|
||||
assert!(l.set_prefixes(conn, &["plume"]).is_ok());
|
||||
assert!(l.contains_prefix(conn, "plume").unwrap());
|
||||
assert!(!l.contains_prefix(conn, "amsterdam").unwrap());
|
||||
assert_eq!(1, l.list_prefixes(conn).unwrap().len());
|
||||
assert_eq!("plume", l.list_prefixes(conn).unwrap()[0]);
|
||||
|
||||
l.clear(conn).unwrap();
|
||||
assert!(l.list_prefixes(conn).unwrap().is_empty());
|
||||
|
||||
assert!(l.add_words(conn, &["something"]).is_err());
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
+12
-18
@@ -62,14 +62,7 @@ impl MediaCategory {
|
||||
impl Media {
|
||||
insert!(medias, NewMedia);
|
||||
get!(medias);
|
||||
|
||||
pub fn for_user(conn: &Connection, owner: i32) -> Result<Vec<Media>> {
|
||||
medias::table
|
||||
.filter(medias::owner_id.eq(owner))
|
||||
.order(medias::id.desc())
|
||||
.load::<Self>(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
list_by!(medias, for_user, owner_id as i32);
|
||||
|
||||
pub fn list_all_medias(conn: &Connection) -> Result<Vec<Media>> {
|
||||
medias::table.load::<Media>(conn).map_err(Error::from)
|
||||
@@ -82,7 +75,6 @@ impl Media {
|
||||
) -> Result<Vec<Media>> {
|
||||
medias::table
|
||||
.filter(medias::owner_id.eq(user.id))
|
||||
.order(medias::id.desc())
|
||||
.offset(i64::from(min))
|
||||
.limit(i64::from(max - min))
|
||||
.load::<Media>(conn)
|
||||
@@ -148,12 +140,10 @@ impl Media {
|
||||
if self.is_remote {
|
||||
Ok(self.remote_url.clone().unwrap_or_default())
|
||||
} else {
|
||||
let p = Path::new(&self.file_path);
|
||||
let filename: String = p.file_name().unwrap().to_str().unwrap().to_owned();
|
||||
Ok(ap_url(&format!(
|
||||
"{}/static/media/{}",
|
||||
"{}/{}",
|
||||
Instance::get_local()?.public_domain,
|
||||
&filename
|
||||
self.file_path
|
||||
)))
|
||||
}
|
||||
}
|
||||
@@ -204,11 +194,10 @@ impl Media {
|
||||
.next()
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| String::from("png"));
|
||||
let path = Path::new(&super::CONFIG.media_directory).join(format!(
|
||||
"{}.{}",
|
||||
GUID::rand().to_string(),
|
||||
ext
|
||||
));
|
||||
let path =
|
||||
Path::new("static")
|
||||
.join("media")
|
||||
.join(format!("{}.{}", GUID::rand().to_string(), ext));
|
||||
|
||||
let mut dest = fs::File::create(path.clone()).ok()?;
|
||||
reqwest::get(remote_url.as_str())
|
||||
@@ -327,6 +316,8 @@ pub(crate) mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
//set_owner
|
||||
|
||||
#[test]
|
||||
fn delete() {
|
||||
let conn = &db();
|
||||
@@ -355,11 +346,13 @@ pub(crate) mod tests {
|
||||
assert!(!Path::new(&path).exists());
|
||||
|
||||
clean(conn);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
fn set_owner() {
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
@@ -403,6 +396,7 @@ pub(crate) mod tests {
|
||||
.any(|m| m.id == media.id));
|
||||
|
||||
clean(conn);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use diesel::{self, ExpressionMethods, JoinOnDsl, QueryDsl, RunQueryDsl};
|
||||
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
|
||||
|
||||
use comments::Comment;
|
||||
use follows::Follow;
|
||||
@@ -7,7 +7,7 @@ use likes::Like;
|
||||
use mentions::Mention;
|
||||
use posts::Post;
|
||||
use reshares::Reshare;
|
||||
use schema::{follows, notifications};
|
||||
use schema::notifications;
|
||||
use users::User;
|
||||
use {Connection, Error, Result};
|
||||
|
||||
@@ -64,16 +64,6 @@ impl Notification {
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn find_followed_by(conn: &Connection, user: &User) -> Result<Vec<Notification>> {
|
||||
notifications::table
|
||||
.inner_join(follows::table.on(notifications::object_id.eq(follows::id)))
|
||||
.filter(notifications::kind.eq(notification_kind::FOLLOW))
|
||||
.filter(follows::follower_id.eq(user.id))
|
||||
.select(notifications::all_columns)
|
||||
.load::<Notification>(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn count_for_user(conn: &Connection, user: &User) -> Result<i64> {
|
||||
notifications::table
|
||||
.filter(notifications::user_id.eq(user.id))
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
use chrono::{offset::Utc, Duration, NaiveDateTime};
|
||||
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
|
||||
use schema::password_reset_requests;
|
||||
use {Connection, Error, Result};
|
||||
|
||||
#[derive(Clone, Identifiable, Queryable)]
|
||||
pub struct PasswordResetRequest {
|
||||
pub id: i32,
|
||||
pub email: String,
|
||||
pub token: String,
|
||||
pub expiration_date: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[table_name = "password_reset_requests"]
|
||||
pub struct NewPasswordResetRequest {
|
||||
pub email: String,
|
||||
pub token: String,
|
||||
pub expiration_date: NaiveDateTime,
|
||||
}
|
||||
|
||||
const TOKEN_VALIDITY_HOURS: i64 = 2;
|
||||
|
||||
impl PasswordResetRequest {
|
||||
pub fn insert(conn: &Connection, email: &str) -> Result<String> {
|
||||
// first, delete other password reset tokens associated with this email:
|
||||
let existing_requests =
|
||||
password_reset_requests::table.filter(password_reset_requests::email.eq(email));
|
||||
diesel::delete(existing_requests).execute(conn)?;
|
||||
|
||||
// now, generate a random token, set the expiry date,
|
||||
// and insert it into the DB:
|
||||
let token = plume_common::utils::random_hex();
|
||||
let expiration_date = Utc::now()
|
||||
.naive_utc()
|
||||
.checked_add_signed(Duration::hours(TOKEN_VALIDITY_HOURS))
|
||||
.expect("could not calculate expiration date");
|
||||
let new_request = NewPasswordResetRequest {
|
||||
email: email.to_owned(),
|
||||
token: token.clone(),
|
||||
expiration_date,
|
||||
};
|
||||
diesel::insert_into(password_reset_requests::table)
|
||||
.values(new_request)
|
||||
.execute(conn)
|
||||
.map_err(Error::from)?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub fn find_by_token(conn: &Connection, token: &str) -> Result<Self> {
|
||||
let token = password_reset_requests::table
|
||||
.filter(password_reset_requests::token.eq(token))
|
||||
.first::<Self>(conn)
|
||||
.map_err(Error::from)?;
|
||||
|
||||
if token.expiration_date < Utc::now().naive_utc() {
|
||||
return Err(Error::Expired);
|
||||
}
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub fn find_and_delete_by_token(conn: &Connection, token: &str) -> Result<Self> {
|
||||
let request = Self::find_by_token(&conn, &token)?;
|
||||
|
||||
let filter =
|
||||
password_reset_requests::table.filter(password_reset_requests::id.eq(request.id));
|
||||
diesel::delete(filter).execute(conn)?;
|
||||
|
||||
Ok(request)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use diesel::Connection;
|
||||
use tests::db;
|
||||
use users::tests as user_tests;
|
||||
|
||||
#[test]
|
||||
fn test_insert_and_find_password_reset_request() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
user_tests::fill_database(&conn);
|
||||
let admin_email = "admin@example.com";
|
||||
|
||||
let token = PasswordResetRequest::insert(&conn, admin_email)
|
||||
.expect("couldn't insert new request");
|
||||
let request = PasswordResetRequest::find_by_token(&conn, &token)
|
||||
.expect("couldn't retrieve request");
|
||||
|
||||
assert!(&token.len() > &32);
|
||||
assert_eq!(&request.email, &admin_email);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_delete_previous_password_reset_request() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
user_tests::fill_database(&conn);
|
||||
let admin_email = "admin@example.com";
|
||||
|
||||
PasswordResetRequest::insert(&conn, &admin_email).expect("couldn't insert new request");
|
||||
PasswordResetRequest::insert(&conn, &admin_email)
|
||||
.expect("couldn't insert second request");
|
||||
|
||||
let count = password_reset_requests::table.count().get_result(&*conn);
|
||||
assert_eq!(Ok(1), count);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_password_reset_request_by_token_time() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
user_tests::fill_database(&conn);
|
||||
let admin_email = "admin@example.com";
|
||||
let token = "abcdef";
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
diesel::insert_into(password_reset_requests::table)
|
||||
.values((
|
||||
password_reset_requests::email.eq(&admin_email),
|
||||
password_reset_requests::token.eq(&token),
|
||||
password_reset_requests::expiration_date.eq(now),
|
||||
))
|
||||
.execute(&*conn)
|
||||
.expect("could not insert request");
|
||||
|
||||
match PasswordResetRequest::find_by_token(&conn, &token) {
|
||||
Err(Error::Expired) => (),
|
||||
_ => panic!("Received unexpected result finding expired token"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_and_delete_password_reset_request() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
user_tests::fill_database(&conn);
|
||||
let admin_email = "admin@example.com";
|
||||
|
||||
let token = PasswordResetRequest::insert(&conn, &admin_email)
|
||||
.expect("couldn't insert new request");
|
||||
PasswordResetRequest::find_and_delete_by_token(&conn, &token)
|
||||
.expect("couldn't find and delete request");
|
||||
|
||||
let count = password_reset_requests::table.count().get_result(&*conn);
|
||||
assert_eq!(Ok(0), count);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ use safe_string::SafeString;
|
||||
use schema::posts;
|
||||
use search::Searcher;
|
||||
use tags::*;
|
||||
use timeline::*;
|
||||
use users::User;
|
||||
use {ap_url, Connection, Error, PlumeRocket, Result, CONFIG};
|
||||
|
||||
@@ -183,6 +182,15 @@ impl Post {
|
||||
query.get_results::<Post>(conn).map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn get_recents(conn: &Connection, limit: i64) -> Result<Vec<Post>> {
|
||||
posts::table
|
||||
.order(posts::creation_date.desc())
|
||||
.filter(posts::published.eq(true))
|
||||
.limit(limit)
|
||||
.load::<Post>(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn get_recents_for_author(
|
||||
conn: &Connection,
|
||||
author: &User,
|
||||
@@ -238,6 +246,60 @@ impl Post {
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
/// Give a page of all the recent posts known to this instance (= federated timeline)
|
||||
pub fn get_recents_page(conn: &Connection, (min, max): (i32, i32)) -> Result<Vec<Post>> {
|
||||
posts::table
|
||||
.order(posts::creation_date.desc())
|
||||
.filter(posts::published.eq(true))
|
||||
.offset(min.into())
|
||||
.limit((max - min).into())
|
||||
.load::<Post>(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
/// Give a page of posts from a specific instance
|
||||
pub fn get_instance_page(
|
||||
conn: &Connection,
|
||||
instance_id: i32,
|
||||
(min, max): (i32, i32),
|
||||
) -> Result<Vec<Post>> {
|
||||
use schema::blogs;
|
||||
|
||||
let blog_ids = blogs::table
|
||||
.filter(blogs::instance_id.eq(instance_id))
|
||||
.select(blogs::id);
|
||||
|
||||
posts::table
|
||||
.order(posts::creation_date.desc())
|
||||
.filter(posts::published.eq(true))
|
||||
.filter(posts::blog_id.eq_any(blog_ids))
|
||||
.offset(min.into())
|
||||
.limit((max - min).into())
|
||||
.load::<Post>(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
/// Give a page of customized user feed, based on a list of followed users
|
||||
pub fn user_feed_page(
|
||||
conn: &Connection,
|
||||
followed: Vec<i32>,
|
||||
(min, max): (i32, i32),
|
||||
) -> Result<Vec<Post>> {
|
||||
use schema::post_authors;
|
||||
let post_ids = post_authors::table
|
||||
.filter(post_authors::author_id.eq_any(followed))
|
||||
.select(post_authors::post_id);
|
||||
|
||||
posts::table
|
||||
.order(posts::creation_date.desc())
|
||||
.filter(posts::published.eq(true))
|
||||
.filter(posts::id.eq_any(post_ids))
|
||||
.offset(min.into())
|
||||
.limit((max - min).into())
|
||||
.load::<Post>(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn drafts_by_author(conn: &Connection, author: &User) -> Result<Vec<Post>> {
|
||||
use schema::post_authors;
|
||||
|
||||
@@ -273,8 +335,11 @@ impl Post {
|
||||
use schema::blogs;
|
||||
blogs::table
|
||||
.filter(blogs::id.eq(self.blog_id))
|
||||
.first(conn)
|
||||
.map_err(Error::from)
|
||||
.limit(1)
|
||||
.load::<Blog>(conn)?
|
||||
.into_iter()
|
||||
.nth(0)
|
||||
.ok_or(Error::NotFound)
|
||||
}
|
||||
|
||||
pub fn count_likes(&self, conn: &Connection) -> Result<i64> {
|
||||
@@ -652,9 +717,6 @@ impl FromId<PlumeRocket> for Post {
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
Timeline::add_to_all_timelines(c, &post, Kind::Original)?;
|
||||
|
||||
Ok(post)
|
||||
}
|
||||
}
|
||||
@@ -866,6 +928,7 @@ mod tests {
|
||||
}
|
||||
_ => panic!("Unexpected result"),
|
||||
};
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ use plume_common::activity_pub::{
|
||||
};
|
||||
use posts::Post;
|
||||
use schema::reshares;
|
||||
use timeline::*;
|
||||
use users::User;
|
||||
use {Connection, Error, PlumeRocket, Result};
|
||||
|
||||
@@ -125,8 +124,6 @@ impl AsObject<User, Announce, &PlumeRocket> for Post {
|
||||
},
|
||||
)?;
|
||||
reshare.notify(conn)?;
|
||||
|
||||
Timeline::add_to_all_timelines(c, &self, Kind::Reshare(&actor))?;
|
||||
Ok(reshare)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,6 @@ lazy_static! {
|
||||
.add_tag_attributes("label", ["for"].iter())
|
||||
.add_tag_attributes("input", ["type", "checked"].iter())
|
||||
.add_allowed_classes("input", ["cw-checkbox"].iter())
|
||||
// Related to https://github.com/Plume-org/Plume/issues/637
|
||||
.add_allowed_classes("sup", ["footnote-reference", "footnote-definition-label"].iter())
|
||||
.add_allowed_classes("div", ["footnote-definition"].iter())
|
||||
.add_allowed_classes("span", ["cw-container", "cw-text"].iter())
|
||||
.attribute_filter(|elem, att, val| match (elem, att) {
|
||||
("input", "type") => Some("checkbox".into()),
|
||||
|
||||
@@ -47,7 +47,6 @@ table! {
|
||||
summary_html -> Text,
|
||||
icon_id -> Nullable<Int4>,
|
||||
banner_id -> Nullable<Int4>,
|
||||
theme -> Nullable<Varchar>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,26 +109,6 @@ table! {
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
list_elems (id) {
|
||||
id -> Int4,
|
||||
list_id -> Int4,
|
||||
user_id -> Nullable<Int4>,
|
||||
blog_id -> Nullable<Int4>,
|
||||
word -> Nullable<Varchar>,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
lists (id) {
|
||||
id -> Int4,
|
||||
name -> Varchar,
|
||||
user_id -> Nullable<Int4>,
|
||||
#[sql_name = "type"]
|
||||
type_ -> Int4,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
medias (id) {
|
||||
id -> Int4,
|
||||
@@ -162,15 +141,6 @@ table! {
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
password_reset_requests (id) {
|
||||
id -> Int4,
|
||||
email -> Varchar,
|
||||
token -> Varchar,
|
||||
expiration_date -> Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
post_authors (id) {
|
||||
id -> Int4,
|
||||
@@ -215,23 +185,6 @@ table! {
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
timeline (id) {
|
||||
id -> Int4,
|
||||
post_id -> Int4,
|
||||
timeline_id -> Int4,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
timeline_definition (id) {
|
||||
id -> Int4,
|
||||
user_id -> Nullable<Int4>,
|
||||
name -> Varchar,
|
||||
query -> Varchar,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
users (id) {
|
||||
id -> Int4,
|
||||
@@ -239,6 +192,7 @@ table! {
|
||||
display_name -> Varchar,
|
||||
outbox_url -> Varchar,
|
||||
inbox_url -> Varchar,
|
||||
is_admin -> Bool,
|
||||
summary -> Text,
|
||||
email -> Nullable<Text>,
|
||||
hashed_password -> Nullable<Text>,
|
||||
@@ -253,9 +207,6 @@ table! {
|
||||
last_fetched_date -> Timestamp,
|
||||
fqn -> Text,
|
||||
summary_html -> Text,
|
||||
role -> Int4,
|
||||
preferred_theme -> Nullable<Varchar>,
|
||||
hide_custom_css -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,10 +221,6 @@ joinable!(comments -> posts (post_id));
|
||||
joinable!(comments -> users (author_id));
|
||||
joinable!(likes -> posts (post_id));
|
||||
joinable!(likes -> users (user_id));
|
||||
joinable!(list_elems -> blogs (blog_id));
|
||||
joinable!(list_elems -> lists (list_id));
|
||||
joinable!(list_elems -> users (user_id));
|
||||
joinable!(lists -> users (user_id));
|
||||
joinable!(mentions -> comments (comment_id));
|
||||
joinable!(mentions -> posts (post_id));
|
||||
joinable!(mentions -> users (mentioned_id));
|
||||
@@ -285,9 +232,6 @@ joinable!(posts -> medias (cover_id));
|
||||
joinable!(reshares -> posts (post_id));
|
||||
joinable!(reshares -> users (user_id));
|
||||
joinable!(tags -> posts (post_id));
|
||||
joinable!(timeline -> posts (post_id));
|
||||
joinable!(timeline -> timeline_definition (timeline_id));
|
||||
joinable!(timeline_definition -> users (user_id));
|
||||
joinable!(users -> instances (instance_id));
|
||||
|
||||
allow_tables_to_appear_in_same_query!(
|
||||
@@ -300,17 +244,12 @@ allow_tables_to_appear_in_same_query!(
|
||||
follows,
|
||||
instances,
|
||||
likes,
|
||||
list_elems,
|
||||
lists,
|
||||
medias,
|
||||
mentions,
|
||||
notifications,
|
||||
password_reset_requests,
|
||||
post_authors,
|
||||
posts,
|
||||
reshares,
|
||||
tags,
|
||||
timeline,
|
||||
timeline_definition,
|
||||
users,
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ pub(crate) mod tests {
|
||||
use tests::db;
|
||||
|
||||
pub(crate) fn get_searcher() -> Searcher {
|
||||
let dir = temp_dir().join(&format!("plume-test-{}", random_hex()));
|
||||
let dir = temp_dir().join("plume-test");
|
||||
if dir.exists() {
|
||||
Searcher::open(&dir)
|
||||
} else {
|
||||
@@ -96,10 +96,11 @@ pub(crate) mod tests {
|
||||
|
||||
#[test]
|
||||
fn open() {
|
||||
let dir = temp_dir().join(format!("plume-test-{}", random_hex()));
|
||||
{
|
||||
Searcher::create(&dir).unwrap();
|
||||
}
|
||||
get_searcher()
|
||||
}; //make sure $tmp/plume-test-tantivy exist
|
||||
|
||||
let dir = temp_dir().join("plume-test");
|
||||
Searcher::open(&dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -174,6 +175,7 @@ pub(crate) mod tests {
|
||||
assert!(searcher
|
||||
.search_document(conn, Query::from_str(&newtitle).unwrap(), (0, 1))
|
||||
.is_empty());
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
@@ -148,10 +148,6 @@ impl Searcher {
|
||||
}
|
||||
|
||||
pub fn add_document(&self, conn: &Connection, post: &Post) -> Result<()> {
|
||||
if !post.published {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let schema = self.index.schema();
|
||||
|
||||
let post_id = schema.get_field("post_id").unwrap();
|
||||
|
||||
@@ -1,845 +0,0 @@
|
||||
use diesel::{self, BoolExpressionMethods, ExpressionMethods, QueryDsl, RunQueryDsl};
|
||||
|
||||
use lists::List;
|
||||
use posts::Post;
|
||||
use schema::{posts, timeline, timeline_definition};
|
||||
use std::ops::Deref;
|
||||
use {Connection, Error, PlumeRocket, Result};
|
||||
|
||||
pub(crate) mod query;
|
||||
|
||||
use self::query::{QueryError, TimelineQuery};
|
||||
|
||||
pub use self::query::Kind;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Queryable, Identifiable, AsChangeset)]
|
||||
#[table_name = "timeline_definition"]
|
||||
pub struct Timeline {
|
||||
pub id: i32,
|
||||
pub user_id: Option<i32>,
|
||||
pub name: String,
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Insertable)]
|
||||
#[table_name = "timeline_definition"]
|
||||
pub struct NewTimeline {
|
||||
user_id: Option<i32>,
|
||||
name: String,
|
||||
query: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Insertable)]
|
||||
#[table_name = "timeline"]
|
||||
struct TimelineEntry {
|
||||
pub post_id: i32,
|
||||
pub timeline_id: i32,
|
||||
}
|
||||
|
||||
impl Timeline {
|
||||
insert!(timeline_definition, NewTimeline);
|
||||
get!(timeline_definition);
|
||||
|
||||
pub fn find_for_user_by_name(
|
||||
conn: &Connection,
|
||||
user_id: Option<i32>,
|
||||
name: &str,
|
||||
) -> Result<Self> {
|
||||
if let Some(user_id) = user_id {
|
||||
timeline_definition::table
|
||||
.filter(timeline_definition::user_id.eq(user_id))
|
||||
.filter(timeline_definition::name.eq(name))
|
||||
.first(conn)
|
||||
.map_err(Error::from)
|
||||
} else {
|
||||
timeline_definition::table
|
||||
.filter(timeline_definition::user_id.is_null())
|
||||
.filter(timeline_definition::name.eq(name))
|
||||
.first(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_for_user(conn: &Connection, user_id: Option<i32>) -> Result<Vec<Self>> {
|
||||
if let Some(user_id) = user_id {
|
||||
timeline_definition::table
|
||||
.filter(timeline_definition::user_id.eq(user_id))
|
||||
.load::<Self>(conn)
|
||||
.map_err(Error::from)
|
||||
} else {
|
||||
timeline_definition::table
|
||||
.filter(timeline_definition::user_id.is_null())
|
||||
.load::<Self>(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// Same as `list_for_user`, but also includes instance timelines if `user_id` is `Some`.
|
||||
pub fn list_all_for_user(conn: &Connection, user_id: Option<i32>) -> Result<Vec<Self>> {
|
||||
if let Some(user_id) = user_id {
|
||||
timeline_definition::table
|
||||
.filter(
|
||||
timeline_definition::user_id
|
||||
.eq(user_id)
|
||||
.or(timeline_definition::user_id.is_null()),
|
||||
)
|
||||
.load::<Self>(conn)
|
||||
.map_err(Error::from)
|
||||
} else {
|
||||
timeline_definition::table
|
||||
.filter(timeline_definition::user_id.is_null())
|
||||
.load::<Self>(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_for_user(
|
||||
conn: &Connection,
|
||||
user_id: i32,
|
||||
name: String,
|
||||
query_string: String,
|
||||
) -> Result<Timeline> {
|
||||
{
|
||||
let query = TimelineQuery::parse(&query_string)?; // verify the query is valid
|
||||
if let Some(err) =
|
||||
query
|
||||
.list_used_lists()
|
||||
.into_iter()
|
||||
.find_map(|(name, kind)| {
|
||||
let list = List::find_for_user_by_name(conn, Some(user_id), &name)
|
||||
.map(|l| l.kind() == kind);
|
||||
match list {
|
||||
Ok(true) => None,
|
||||
Ok(false) => Some(Error::TimelineQuery(QueryError::RuntimeError(
|
||||
format!("list '{}' has the wrong type for this usage", name),
|
||||
))),
|
||||
Err(_) => Some(Error::TimelineQuery(QueryError::RuntimeError(
|
||||
format!("list '{}' was not found", name),
|
||||
))),
|
||||
}
|
||||
})
|
||||
{
|
||||
Err(err)?;
|
||||
}
|
||||
}
|
||||
Self::insert(
|
||||
conn,
|
||||
NewTimeline {
|
||||
user_id: Some(user_id),
|
||||
name,
|
||||
query: query_string,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn new_for_instance(
|
||||
conn: &Connection,
|
||||
name: String,
|
||||
query_string: String,
|
||||
) -> Result<Timeline> {
|
||||
{
|
||||
let query = TimelineQuery::parse(&query_string)?; // verify the query is valid
|
||||
if let Some(err) =
|
||||
query
|
||||
.list_used_lists()
|
||||
.into_iter()
|
||||
.find_map(|(name, kind)| {
|
||||
let list = List::find_for_user_by_name(conn, None, &name)
|
||||
.map(|l| l.kind() == kind);
|
||||
match list {
|
||||
Ok(true) => None,
|
||||
Ok(false) => Some(Error::TimelineQuery(QueryError::RuntimeError(
|
||||
format!("list '{}' has the wrong type for this usage", name),
|
||||
))),
|
||||
Err(_) => Some(Error::TimelineQuery(QueryError::RuntimeError(
|
||||
format!("list '{}' was not found", name),
|
||||
))),
|
||||
}
|
||||
})
|
||||
{
|
||||
Err(err)?;
|
||||
}
|
||||
}
|
||||
Self::insert(
|
||||
conn,
|
||||
NewTimeline {
|
||||
user_id: None,
|
||||
name,
|
||||
query: query_string,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update(&self, conn: &Connection) -> Result<Self> {
|
||||
diesel::update(self).set(self).execute(conn)?;
|
||||
let timeline = Self::get(conn, self.id)?;
|
||||
Ok(timeline)
|
||||
}
|
||||
|
||||
pub fn delete(&self, conn: &Connection) -> Result<()> {
|
||||
diesel::delete(self)
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn get_latest(&self, conn: &Connection, count: i32) -> Result<Vec<Post>> {
|
||||
self.get_page(conn, (0, count))
|
||||
}
|
||||
|
||||
pub fn get_page(&self, conn: &Connection, (min, max): (i32, i32)) -> Result<Vec<Post>> {
|
||||
timeline::table
|
||||
.filter(timeline::timeline_id.eq(self.id))
|
||||
.inner_join(posts::table)
|
||||
.order(posts::creation_date.desc())
|
||||
.offset(min.into())
|
||||
.limit((max - min).into())
|
||||
.select(posts::all_columns)
|
||||
.load::<Post>(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn count_posts(&self, conn: &Connection) -> Result<i64> {
|
||||
timeline::table
|
||||
.filter(timeline::timeline_id.eq(self.id))
|
||||
.inner_join(posts::table)
|
||||
.count()
|
||||
.get_result(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn add_to_all_timelines(rocket: &PlumeRocket, post: &Post, kind: Kind) -> Result<()> {
|
||||
let timelines = timeline_definition::table
|
||||
.load::<Self>(rocket.conn.deref())
|
||||
.map_err(Error::from)?;
|
||||
|
||||
for t in timelines {
|
||||
if t.matches(rocket, post, kind)? {
|
||||
t.add_post(&rocket.conn, post)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_post(&self, conn: &Connection, post: &Post) -> Result<()> {
|
||||
diesel::insert_into(timeline::table)
|
||||
.values(TimelineEntry {
|
||||
post_id: post.id,
|
||||
timeline_id: self.id,
|
||||
})
|
||||
.execute(conn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn matches(&self, rocket: &PlumeRocket, post: &Post, kind: Kind) -> Result<bool> {
|
||||
let query = TimelineQuery::parse(&self.query)?;
|
||||
query.matches(rocket, self, post, kind)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use blogs::tests as blogTests;
|
||||
use diesel::Connection;
|
||||
use follows::*;
|
||||
use lists::ListType;
|
||||
use post_authors::{NewPostAuthor, PostAuthor};
|
||||
use posts::NewPost;
|
||||
use safe_string::SafeString;
|
||||
use tags::Tag;
|
||||
use tests::{db, rockets};
|
||||
use users::tests as userTests;
|
||||
|
||||
#[test]
|
||||
fn test_timeline() {
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let users = userTests::fill_database(conn);
|
||||
|
||||
let mut tl1_u1 = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"my timeline".to_owned(),
|
||||
"all".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
List::new(conn, "languages I speak", Some(&users[1]), ListType::Prefix).unwrap();
|
||||
let tl2_u1 = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"another timeline".to_owned(),
|
||||
"followed".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
let tl1_u2 = Timeline::new_for_user(
|
||||
conn,
|
||||
users[1].id,
|
||||
"english posts".to_owned(),
|
||||
"lang in \"languages I speak\"".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
let tl1_instance = Timeline::new_for_instance(
|
||||
conn,
|
||||
"english posts".to_owned(),
|
||||
"license in [cc]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(tl1_u1, Timeline::get(conn, tl1_u1.id).unwrap());
|
||||
assert_eq!(
|
||||
tl2_u1,
|
||||
Timeline::find_for_user_by_name(conn, Some(users[0].id), "another timeline")
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
tl1_instance,
|
||||
Timeline::find_for_user_by_name(conn, None, "english posts").unwrap()
|
||||
);
|
||||
|
||||
let tl_u1 = Timeline::list_for_user(conn, Some(users[0].id)).unwrap();
|
||||
assert_eq!(3, tl_u1.len()); // it is not 2 because there is a "Your feed" tl created for each user automatically
|
||||
assert!(tl_u1.iter().fold(false, |res, tl| { res || *tl == tl1_u1 }));
|
||||
assert!(tl_u1.iter().fold(false, |res, tl| { res || *tl == tl2_u1 }));
|
||||
|
||||
let tl_instance = Timeline::list_for_user(conn, None).unwrap();
|
||||
assert_eq!(3, tl_instance.len()); // there are also the local and federated feed by default
|
||||
assert!(tl_instance
|
||||
.iter()
|
||||
.fold(false, |res, tl| { res || *tl == tl1_instance }));
|
||||
|
||||
tl1_u1.name = "My Super TL".to_owned();
|
||||
let new_tl1_u2 = tl1_u2.update(conn).unwrap();
|
||||
|
||||
let tl_u2 = Timeline::list_for_user(conn, Some(users[1].id)).unwrap();
|
||||
assert_eq!(2, tl_u2.len()); // same here
|
||||
assert!(tl_u2
|
||||
.iter()
|
||||
.fold(false, |res, tl| { res || *tl == new_tl1_u2 }));
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timeline_creation_error() {
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let users = userTests::fill_database(conn);
|
||||
|
||||
assert!(Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"my timeline".to_owned(),
|
||||
"invalid keyword".to_owned(),
|
||||
)
|
||||
.is_err());
|
||||
assert!(Timeline::new_for_instance(
|
||||
conn,
|
||||
"my timeline".to_owned(),
|
||||
"invalid keyword".to_owned(),
|
||||
)
|
||||
.is_err());
|
||||
|
||||
assert!(Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"my timeline".to_owned(),
|
||||
"author in non_existant_list".to_owned(),
|
||||
)
|
||||
.is_err());
|
||||
assert!(Timeline::new_for_instance(
|
||||
conn,
|
||||
"my timeline".to_owned(),
|
||||
"lang in dont-exist".to_owned(),
|
||||
)
|
||||
.is_err());
|
||||
|
||||
List::new(conn, "friends", Some(&users[0]), ListType::User).unwrap();
|
||||
List::new(conn, "idk", None, ListType::Blog).unwrap();
|
||||
|
||||
assert!(Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"my timeline".to_owned(),
|
||||
"blog in friends".to_owned(),
|
||||
)
|
||||
.is_err());
|
||||
assert!(Timeline::new_for_instance(
|
||||
conn,
|
||||
"my timeline".to_owned(),
|
||||
"not author in idk".to_owned(),
|
||||
)
|
||||
.is_err());
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_match() {
|
||||
let r = &rockets();
|
||||
let conn = &r.conn;
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, blogs) = blogTests::fill_database(conn);
|
||||
|
||||
let gnu_tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"GNU timeline".to_owned(),
|
||||
"license in [AGPL, LGPL, GPL]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let gnu_post = Post::insert(
|
||||
conn,
|
||||
NewPost {
|
||||
blog_id: blogs[0].id,
|
||||
slug: "slug".to_string(),
|
||||
title: "About Linux".to_string(),
|
||||
content: SafeString::new("you must say GNU/Linux, not Linux!!!"),
|
||||
published: true,
|
||||
license: "GPL".to_string(),
|
||||
ap_url: "".to_string(),
|
||||
creation_date: None,
|
||||
subtitle: "".to_string(),
|
||||
source: "you must say GNU/Linux, not Linux!!!".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(gnu_tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
|
||||
let non_free_post = Post::insert(
|
||||
conn,
|
||||
NewPost {
|
||||
blog_id: blogs[0].id,
|
||||
slug: "slug2".to_string(),
|
||||
title: "Private is bad".to_string(),
|
||||
content: SafeString::new("so is Microsoft"),
|
||||
published: true,
|
||||
license: "all right reserved".to_string(),
|
||||
ap_url: "".to_string(),
|
||||
creation_date: None,
|
||||
subtitle: "".to_string(),
|
||||
source: "so is Microsoft".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!gnu_tl.matches(r, &non_free_post, Kind::Original).unwrap());
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complex_match() {
|
||||
let r = &rockets();
|
||||
let conn = &r.conn;
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, blogs) = blogTests::fill_database(conn);
|
||||
Follow::insert(
|
||||
conn,
|
||||
NewFollow {
|
||||
follower_id: users[0].id,
|
||||
following_id: users[1].id,
|
||||
ap_url: String::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fav_blogs_list =
|
||||
List::new(conn, "fav_blogs", Some(&users[0]), ListType::Blog).unwrap();
|
||||
fav_blogs_list.add_blogs(conn, &[blogs[0].id]).unwrap();
|
||||
|
||||
let my_tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"My timeline".to_owned(),
|
||||
"blog in fav_blogs and not has_cover or local and followed exclude likes"
|
||||
.to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let post = Post::insert(
|
||||
conn,
|
||||
NewPost {
|
||||
blog_id: blogs[0].id,
|
||||
slug: "about-linux".to_string(),
|
||||
title: "About Linux".to_string(),
|
||||
content: SafeString::new("you must say GNU/Linux, not Linux!!!"),
|
||||
published: true,
|
||||
license: "GPL".to_string(),
|
||||
source: "you must say GNU/Linux, not Linux!!!".to_string(),
|
||||
ap_url: "".to_string(),
|
||||
creation_date: None,
|
||||
subtitle: "".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(my_tl.matches(r, &post, Kind::Original).unwrap()); // matches because of "blog in fav_blogs" (and there is no cover)
|
||||
|
||||
let post = Post::insert(
|
||||
conn,
|
||||
NewPost {
|
||||
blog_id: blogs[1].id,
|
||||
slug: "about-linux-2".to_string(),
|
||||
title: "About Linux (2)".to_string(),
|
||||
content: SafeString::new(
|
||||
"Actually, GNU+Linux, GNU×Linux, or GNU¿Linux are better.",
|
||||
),
|
||||
published: true,
|
||||
license: "GPL".to_string(),
|
||||
source: "Actually, GNU+Linux, GNU×Linux, or GNU¿Linux are better."
|
||||
.to_string(),
|
||||
ap_url: "".to_string(),
|
||||
creation_date: None,
|
||||
subtitle: "".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!my_tl.matches(r, &post, Kind::Like(&users[1])).unwrap());
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_to_all_timelines() {
|
||||
let r = &rockets();
|
||||
let conn = &r.conn;
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, blogs) = blogTests::fill_database(conn);
|
||||
|
||||
let gnu_tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"GNU timeline".to_owned(),
|
||||
"license in [AGPL, LGPL, GPL]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
let non_gnu_tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"Stallman disapproved timeline".to_owned(),
|
||||
"not license in [AGPL, LGPL, GPL]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let gnu_post = Post::insert(
|
||||
conn,
|
||||
NewPost {
|
||||
blog_id: blogs[0].id,
|
||||
slug: "slug".to_string(),
|
||||
title: "About Linux".to_string(),
|
||||
content: SafeString::new("you must say GNU/Linux, not Linux!!!"),
|
||||
published: true,
|
||||
license: "GPL".to_string(),
|
||||
ap_url: "".to_string(),
|
||||
creation_date: None,
|
||||
subtitle: "".to_string(),
|
||||
source: "you must say GNU/Linux, not Linux!!!".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let non_free_post = Post::insert(
|
||||
conn,
|
||||
NewPost {
|
||||
blog_id: blogs[0].id,
|
||||
slug: "slug2".to_string(),
|
||||
title: "Private is bad".to_string(),
|
||||
content: SafeString::new("so is Microsoft"),
|
||||
published: true,
|
||||
license: "all right reserved".to_string(),
|
||||
ap_url: "".to_string(),
|
||||
creation_date: None,
|
||||
subtitle: "".to_string(),
|
||||
source: "so is Microsoft".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
Timeline::add_to_all_timelines(r, &gnu_post, Kind::Original).unwrap();
|
||||
Timeline::add_to_all_timelines(r, &non_free_post, Kind::Original).unwrap();
|
||||
|
||||
let res = gnu_tl.get_latest(conn, 2).unwrap();
|
||||
assert_eq!(res.len(), 1);
|
||||
assert_eq!(res[0].id, gnu_post.id);
|
||||
let res = non_gnu_tl.get_latest(conn, 2).unwrap();
|
||||
assert_eq!(res.len(), 1);
|
||||
assert_eq!(res[0].id, non_free_post.id);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matches_lists_direct() {
|
||||
let r = &rockets();
|
||||
let conn = &r.conn;
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, blogs) = blogTests::fill_database(conn);
|
||||
|
||||
let gnu_post = Post::insert(
|
||||
conn,
|
||||
NewPost {
|
||||
blog_id: blogs[0].id,
|
||||
slug: "slug".to_string(),
|
||||
title: "About Linux".to_string(),
|
||||
content: SafeString::new("you must say GNU/Linux, not Linux!!!"),
|
||||
published: true,
|
||||
license: "GPL".to_string(),
|
||||
ap_url: "".to_string(),
|
||||
creation_date: None,
|
||||
subtitle: "".to_string(),
|
||||
source: "you must say GNU/Linux, not Linux!!!".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
gnu_post
|
||||
.update_tags(conn, vec![Tag::build_activity("free".to_owned()).unwrap()])
|
||||
.unwrap();
|
||||
PostAuthor::insert(
|
||||
conn,
|
||||
NewPostAuthor {
|
||||
post_id: gnu_post.id,
|
||||
author_id: blogs[0].list_authors(conn).unwrap()[0].id,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"blog timeline".to_owned(),
|
||||
format!("blog in [{}]", blogs[0].fqn),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"blog timeline".to_owned(),
|
||||
"blog in [no_one@nowhere]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"author timeline".to_owned(),
|
||||
format!(
|
||||
"author in [{}]",
|
||||
blogs[0].list_authors(conn).unwrap()[0].fqn
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"author timeline".to_owned(),
|
||||
format!("author in [{}]", users[2].fqn),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Reshare(&users[2])).unwrap());
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Like(&users[2])).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"author timeline".to_owned(),
|
||||
format!(
|
||||
"author in [{}] include likes exclude reshares",
|
||||
users[2].fqn
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Reshare(&users[2])).unwrap());
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Like(&users[2])).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"tag timeline".to_owned(),
|
||||
"tags in [free]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"tag timeline".to_owned(),
|
||||
"tags in [private]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"english timeline".to_owned(),
|
||||
"lang in [en]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"franco-italian timeline".to_owned(),
|
||||
"lang in [fr, it]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
#[test]
|
||||
fn test_matches_lists_saved() {
|
||||
let r = &rockets();
|
||||
let conn = &r.conn;
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, blogs) = blogTests::fill_database(conn);
|
||||
|
||||
let gnu_post = Post::insert(
|
||||
conn,
|
||||
NewPost {
|
||||
blog_id: blogs[0].id,
|
||||
slug: "slug".to_string(),
|
||||
title: "About Linux".to_string(),
|
||||
content: SafeString::new("you must say GNU/Linux, not Linux!!!"),
|
||||
published: true,
|
||||
license: "GPL".to_string(),
|
||||
ap_url: "".to_string(),
|
||||
creation_date: None,
|
||||
subtitle: "".to_string(),
|
||||
source: "you must say GNU/Linux, not Linux!!!".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
gnu_post.update_tags(conn, vec![Tag::build_activity("free".to_owned()).unwrap()]).unwrap();
|
||||
PostAuthor::insert(conn, NewPostAuthor {post_id: gnu_post.id, author_id: blogs[0].list_authors(conn).unwrap()[0].id}).unwrap();
|
||||
|
||||
unimplemented!();
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}*/
|
||||
|
||||
#[test]
|
||||
fn test_matches_keyword() {
|
||||
let r = &rockets();
|
||||
let conn = &r.conn;
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, blogs) = blogTests::fill_database(conn);
|
||||
|
||||
let gnu_post = Post::insert(
|
||||
conn,
|
||||
NewPost {
|
||||
blog_id: blogs[0].id,
|
||||
slug: "slug".to_string(),
|
||||
title: "About Linux".to_string(),
|
||||
content: SafeString::new("you must say GNU/Linux, not Linux!!!"),
|
||||
published: true,
|
||||
license: "GPL".to_string(),
|
||||
ap_url: "".to_string(),
|
||||
creation_date: None,
|
||||
subtitle: "Stallman is our god".to_string(),
|
||||
source: "you must say GNU/Linux, not Linux!!!".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"Linux title".to_owned(),
|
||||
"title contains Linux".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"Microsoft title".to_owned(),
|
||||
"title contains Microsoft".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"Linux subtitle".to_owned(),
|
||||
"subtitle contains Stallman".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"Microsoft subtitle".to_owned(),
|
||||
"subtitle contains Nadella".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"Linux content".to_owned(),
|
||||
"content contains Linux".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"Microsoft content".to_owned(),
|
||||
"subtitle contains Windows".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,877 +0,0 @@
|
||||
use blogs::Blog;
|
||||
use lists::{self, ListType};
|
||||
use plume_common::activity_pub::inbox::AsActor;
|
||||
use posts::Post;
|
||||
use tags::Tag;
|
||||
use users::User;
|
||||
use whatlang::{self, Lang};
|
||||
|
||||
use {PlumeRocket, Result};
|
||||
|
||||
use super::Timeline;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum QueryError {
|
||||
SyntaxError(usize, usize, String),
|
||||
UnexpectedEndOfQuery,
|
||||
RuntimeError(String),
|
||||
}
|
||||
|
||||
impl From<std::option::NoneError> for QueryError {
|
||||
fn from(_: std::option::NoneError) -> Self {
|
||||
QueryError::UnexpectedEndOfQuery
|
||||
}
|
||||
}
|
||||
|
||||
pub type QueryResult<T> = std::result::Result<T, QueryError>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Kind<'a> {
|
||||
Original,
|
||||
Reshare(&'a User),
|
||||
Like(&'a User),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum Token<'a> {
|
||||
LParent(usize),
|
||||
RParent(usize),
|
||||
LBracket(usize),
|
||||
RBracket(usize),
|
||||
Comma(usize),
|
||||
Word(usize, usize, &'a str),
|
||||
}
|
||||
|
||||
impl<'a> Token<'a> {
|
||||
fn get_text(&self) -> &'a str {
|
||||
match self {
|
||||
Token::Word(_, _, s) => s,
|
||||
Token::LParent(_) => "(",
|
||||
Token::RParent(_) => ")",
|
||||
Token::LBracket(_) => "[",
|
||||
Token::RBracket(_) => "]",
|
||||
Token::Comma(_) => ",",
|
||||
}
|
||||
}
|
||||
|
||||
fn get_pos(&self) -> (usize, usize) {
|
||||
match self {
|
||||
Token::Word(a, b, _) => (*a, *b),
|
||||
Token::LParent(a)
|
||||
| Token::RParent(a)
|
||||
| Token::LBracket(a)
|
||||
| Token::RBracket(a)
|
||||
| Token::Comma(a) => (*a, 1),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_error<T>(&self, token: Token) -> QueryResult<T> {
|
||||
let (b, e) = self.get_pos();
|
||||
let message = format!(
|
||||
"Syntax Error: Expected {}, got {}",
|
||||
token.to_string(),
|
||||
self.to_string()
|
||||
);
|
||||
Err(QueryError::SyntaxError(b, e, message))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> ToString for Token<'a> {
|
||||
fn to_string(&self) -> String {
|
||||
if let Token::Word(0, 0, v) = self {
|
||||
return v.to_string();
|
||||
}
|
||||
format!(
|
||||
"'{}'",
|
||||
match self {
|
||||
Token::Word(_, _, v) => v,
|
||||
Token::LParent(_) => "(",
|
||||
Token::RParent(_) => ")",
|
||||
Token::LBracket(_) => "[",
|
||||
Token::RBracket(_) => "]",
|
||||
Token::Comma(_) => ",",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! gen_tokenizer {
|
||||
( ($c:ident,$i:ident), $state:ident, $quote:ident; $([$char:tt, $variant:tt]),*) => {
|
||||
match $c {
|
||||
space if !*$quote && space.is_whitespace() => match $state.take() {
|
||||
Some(v) => vec![v],
|
||||
None => vec![],
|
||||
},
|
||||
$(
|
||||
$char if !*$quote => match $state.take() {
|
||||
Some(v) => vec![v, Token::$variant($i)],
|
||||
None => vec![Token::$variant($i)],
|
||||
},
|
||||
)*
|
||||
'"' => {
|
||||
*$quote = !*$quote;
|
||||
vec![]
|
||||
},
|
||||
_ => match $state.take() {
|
||||
Some(Token::Word(b, l, _)) => {
|
||||
*$state = Some(Token::Word(b, l+1, &""));
|
||||
vec![]
|
||||
},
|
||||
None => {
|
||||
*$state = Some(Token::Word($i,1,&""));
|
||||
vec![]
|
||||
},
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lex(stream: &str) -> Vec<Token> {
|
||||
stream
|
||||
.chars()
|
||||
.chain(" ".chars()) // force a last whitespace to empty scan's state
|
||||
.zip(0..)
|
||||
.scan((None, false), |(state, quote), (c, i)| {
|
||||
Some(gen_tokenizer!((c,i), state, quote;
|
||||
['(', LParent], [')', RParent],
|
||||
['[', LBracket], [']', RBracket],
|
||||
[',', Comma]))
|
||||
})
|
||||
.flatten()
|
||||
.map(|t| {
|
||||
if let Token::Word(b, e, _) = t {
|
||||
Token::Word(b, e, &stream[b..b + e])
|
||||
} else {
|
||||
t
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Private internals of TimelineQuery
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum TQ<'a> {
|
||||
Or(Vec<TQ<'a>>),
|
||||
And(Vec<TQ<'a>>),
|
||||
Arg(Arg<'a>, bool),
|
||||
}
|
||||
|
||||
impl<'a> TQ<'a> {
|
||||
fn matches(
|
||||
&self,
|
||||
rocket: &PlumeRocket,
|
||||
timeline: &Timeline,
|
||||
post: &Post,
|
||||
kind: Kind,
|
||||
) -> Result<bool> {
|
||||
match self {
|
||||
TQ::Or(inner) => inner.iter().try_fold(false, |s, e| {
|
||||
e.matches(rocket, timeline, post, kind).map(|r| s || r)
|
||||
}),
|
||||
TQ::And(inner) => inner.iter().try_fold(true, |s, e| {
|
||||
e.matches(rocket, timeline, post, kind).map(|r| s && r)
|
||||
}),
|
||||
TQ::Arg(inner, invert) => Ok(inner.matches(rocket, timeline, post, kind)? ^ invert),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_used_lists(&self) -> Vec<(String, ListType)> {
|
||||
match self {
|
||||
TQ::Or(inner) => inner.iter().flat_map(TQ::list_used_lists).collect(),
|
||||
TQ::And(inner) => inner.iter().flat_map(TQ::list_used_lists).collect(),
|
||||
TQ::Arg(Arg::In(typ, List::List(name)), _) => vec![(
|
||||
name.to_string(),
|
||||
match typ {
|
||||
WithList::Blog => ListType::Blog,
|
||||
WithList::Author { .. } => ListType::User,
|
||||
WithList::License => ListType::Word,
|
||||
WithList::Tags => ListType::Word,
|
||||
WithList::Lang => ListType::Prefix,
|
||||
},
|
||||
)],
|
||||
TQ::Arg(_, _) => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Arg<'a> {
|
||||
In(WithList, List<'a>),
|
||||
Contains(WithContains, &'a str),
|
||||
Boolean(Bool),
|
||||
}
|
||||
|
||||
impl<'a> Arg<'a> {
|
||||
pub fn matches(
|
||||
&self,
|
||||
rocket: &PlumeRocket,
|
||||
timeline: &Timeline,
|
||||
post: &Post,
|
||||
kind: Kind,
|
||||
) -> Result<bool> {
|
||||
match self {
|
||||
Arg::In(t, l) => t.matches(rocket, timeline, post, l, kind),
|
||||
Arg::Contains(t, v) => t.matches(post, v),
|
||||
Arg::Boolean(t) => t.matches(rocket, timeline, post, kind),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum WithList {
|
||||
Blog,
|
||||
Author { boosts: bool, likes: bool },
|
||||
License,
|
||||
Tags,
|
||||
Lang,
|
||||
}
|
||||
|
||||
impl WithList {
|
||||
pub fn matches(
|
||||
&self,
|
||||
rocket: &PlumeRocket,
|
||||
timeline: &Timeline,
|
||||
post: &Post,
|
||||
list: &List,
|
||||
kind: Kind,
|
||||
) -> Result<bool> {
|
||||
match list {
|
||||
List::List(name) => {
|
||||
let list =
|
||||
lists::List::find_for_user_by_name(&rocket.conn, timeline.user_id, &name)?;
|
||||
match (self, list.kind()) {
|
||||
(WithList::Blog, ListType::Blog) => {
|
||||
list.contains_blog(&rocket.conn, post.blog_id)
|
||||
}
|
||||
(WithList::Author { boosts, likes }, ListType::User) => match kind {
|
||||
Kind::Original => Ok(list
|
||||
.list_users(&rocket.conn)?
|
||||
.iter()
|
||||
.any(|a| post.is_author(&rocket.conn, a.id).unwrap_or(false))),
|
||||
Kind::Reshare(u) => {
|
||||
if *boosts {
|
||||
list.contains_user(&rocket.conn, u.id)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
Kind::Like(u) => {
|
||||
if *likes {
|
||||
list.contains_user(&rocket.conn, u.id)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
(WithList::License, ListType::Word) => {
|
||||
list.contains_word(&rocket.conn, &post.license)
|
||||
}
|
||||
(WithList::Tags, ListType::Word) => {
|
||||
let tags = Tag::for_post(&rocket.conn, post.id)?;
|
||||
Ok(list
|
||||
.list_words(&rocket.conn)?
|
||||
.iter()
|
||||
.any(|s| tags.iter().any(|t| s == &t.tag)))
|
||||
}
|
||||
(WithList::Lang, ListType::Prefix) => {
|
||||
let lang = whatlang::detect(post.content.get())
|
||||
.and_then(|i| {
|
||||
if i.is_reliable() {
|
||||
Some(i.lang())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(Lang::Eng)
|
||||
.name();
|
||||
list.contains_prefix(&rocket.conn, lang)
|
||||
}
|
||||
(_, _) => Err(QueryError::RuntimeError(format!(
|
||||
"The list '{}' is of the wrong type for this usage",
|
||||
name
|
||||
)))?,
|
||||
}
|
||||
}
|
||||
List::Array(list) => match self {
|
||||
WithList::Blog => Ok(list
|
||||
.iter()
|
||||
.filter_map(|b| Blog::find_by_fqn(rocket, b).ok())
|
||||
.any(|b| b.id == post.blog_id)),
|
||||
WithList::Author { boosts, likes } => match kind {
|
||||
Kind::Original => Ok(list
|
||||
.iter()
|
||||
.filter_map(|a| User::find_by_fqn(rocket, a).ok())
|
||||
.any(|a| post.is_author(&rocket.conn, a.id).unwrap_or(false))),
|
||||
Kind::Reshare(u) => {
|
||||
if *boosts {
|
||||
Ok(list.iter().any(|user| &u.fqn == user))
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
Kind::Like(u) => {
|
||||
if *likes {
|
||||
Ok(list.iter().any(|user| &u.fqn == user))
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
WithList::License => Ok(list.iter().any(|s| s == &post.license)),
|
||||
WithList::Tags => {
|
||||
let tags = Tag::for_post(&rocket.conn, post.id)?;
|
||||
Ok(list.iter().any(|s| tags.iter().any(|t| s == &t.tag)))
|
||||
}
|
||||
WithList::Lang => {
|
||||
let lang = whatlang::detect(post.content.get())
|
||||
.and_then(|i| {
|
||||
if i.is_reliable() {
|
||||
Some(i.lang())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(Lang::Eng)
|
||||
.name()
|
||||
.to_lowercase();
|
||||
Ok(list.iter().any(|s| lang.starts_with(&s.to_lowercase())))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum WithContains {
|
||||
Title,
|
||||
Subtitle,
|
||||
Content,
|
||||
}
|
||||
|
||||
impl WithContains {
|
||||
pub fn matches(&self, post: &Post, value: &str) -> Result<bool> {
|
||||
match self {
|
||||
WithContains::Title => Ok(post.title.contains(value)),
|
||||
WithContains::Subtitle => Ok(post.subtitle.contains(value)),
|
||||
WithContains::Content => Ok(post.content.contains(value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Bool {
|
||||
Followed { boosts: bool, likes: bool },
|
||||
HasCover,
|
||||
Local,
|
||||
All,
|
||||
}
|
||||
|
||||
impl Bool {
|
||||
pub fn matches(
|
||||
&self,
|
||||
rocket: &PlumeRocket,
|
||||
timeline: &Timeline,
|
||||
post: &Post,
|
||||
kind: Kind,
|
||||
) -> Result<bool> {
|
||||
match self {
|
||||
Bool::Followed { boosts, likes } => {
|
||||
if timeline.user_id.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
let user = timeline.user_id.unwrap();
|
||||
match kind {
|
||||
Kind::Original => post
|
||||
.get_authors(&rocket.conn)?
|
||||
.iter()
|
||||
.try_fold(false, |s, a| {
|
||||
a.is_followed_by(&rocket.conn, user).map(|r| s || r)
|
||||
}),
|
||||
Kind::Reshare(u) => {
|
||||
if *boosts {
|
||||
u.is_followed_by(&rocket.conn, user)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
Kind::Like(u) => {
|
||||
if *likes {
|
||||
u.is_followed_by(&rocket.conn, user)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Bool::HasCover => Ok(post.cover_id.is_some()),
|
||||
Bool::Local => Ok(post.get_blog(&rocket.conn)?.is_local()),
|
||||
Bool::All => Ok(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum List<'a> {
|
||||
List(&'a str),
|
||||
Array(Vec<&'a str>),
|
||||
}
|
||||
|
||||
fn parse_s<'a, 'b>(mut stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], TQ<'a>)> {
|
||||
let mut res = Vec::new();
|
||||
let (left, token) = parse_a(&stream)?;
|
||||
res.push(token);
|
||||
stream = left;
|
||||
while !stream.is_empty() {
|
||||
match stream[0] {
|
||||
Token::Word(_, _, and) if and == "or" => {}
|
||||
_ => break,
|
||||
}
|
||||
let (left, token) = parse_a(&stream[1..])?;
|
||||
res.push(token);
|
||||
stream = left;
|
||||
}
|
||||
|
||||
if res.len() == 1 {
|
||||
Ok((stream, res.remove(0)))
|
||||
} else {
|
||||
Ok((stream, TQ::Or(res)))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_a<'a, 'b>(mut stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], TQ<'a>)> {
|
||||
let mut res = Vec::new();
|
||||
let (left, token) = parse_b(&stream)?;
|
||||
res.push(token);
|
||||
stream = left;
|
||||
while !stream.is_empty() {
|
||||
match stream[0] {
|
||||
Token::Word(_, _, and) if and == "and" => {}
|
||||
_ => break,
|
||||
}
|
||||
let (left, token) = parse_b(&stream[1..])?;
|
||||
res.push(token);
|
||||
stream = left;
|
||||
}
|
||||
|
||||
if res.len() == 1 {
|
||||
Ok((stream, res.remove(0)))
|
||||
} else {
|
||||
Ok((stream, TQ::And(res)))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_b<'a, 'b>(stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], TQ<'a>)> {
|
||||
match stream.get(0) {
|
||||
Some(Token::LParent(_)) => {
|
||||
let (left, token) = parse_s(&stream[1..])?;
|
||||
match left.get(0) {
|
||||
Some(Token::RParent(_)) => Ok((&left[1..], token)),
|
||||
Some(t) => t.get_error(Token::RParent(0)),
|
||||
None => None?,
|
||||
}
|
||||
}
|
||||
_ => parse_c(stream),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_c<'a, 'b>(stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], TQ<'a>)> {
|
||||
match stream.get(0) {
|
||||
Some(Token::Word(_, _, not)) if not == &"not" => {
|
||||
let (left, token) = parse_d(&stream[1..])?;
|
||||
Ok((left, TQ::Arg(token, true)))
|
||||
}
|
||||
_ => {
|
||||
let (left, token) = parse_d(stream)?;
|
||||
Ok((left, TQ::Arg(token, false)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_d<'a, 'b>(mut stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], Arg<'a>)> {
|
||||
match stream.get(0).map(Token::get_text)? {
|
||||
s @ "blog" | s @ "author" | s @ "license" | s @ "tags" | s @ "lang" => {
|
||||
match stream.get(1)? {
|
||||
Token::Word(_, _, r#in) if r#in == &"in" => {
|
||||
let (mut left, list) = parse_l(&stream[2..])?;
|
||||
let kind = match s {
|
||||
"blog" => WithList::Blog,
|
||||
"author" => {
|
||||
let mut boosts = true;
|
||||
let mut likes = false;
|
||||
while let Some(Token::Word(s, e, clude)) = left.get(0) {
|
||||
if *clude != "include" && *clude != "exclude" {
|
||||
break;
|
||||
}
|
||||
match (*clude, left.get(1).map(Token::get_text)?) {
|
||||
("include", "reshares") | ("include", "reshare") => {
|
||||
boosts = true
|
||||
}
|
||||
("exclude", "reshares") | ("exclude", "reshare") => {
|
||||
boosts = false
|
||||
}
|
||||
("include", "likes") | ("include", "like") => likes = true,
|
||||
("exclude", "likes") | ("exclude", "like") => likes = false,
|
||||
(_, w) => {
|
||||
return Token::Word(*s, *e, w).get_error(Token::Word(
|
||||
0,
|
||||
0,
|
||||
"one of 'likes' or 'reshares'",
|
||||
))
|
||||
}
|
||||
}
|
||||
left = &left[2..];
|
||||
}
|
||||
WithList::Author { boosts, likes }
|
||||
}
|
||||
"license" => WithList::License,
|
||||
"tags" => WithList::Tags,
|
||||
"lang" => WithList::Lang,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
Ok((left, Arg::In(kind, list)))
|
||||
}
|
||||
t => t.get_error(Token::Word(0, 0, "'in'")),
|
||||
}
|
||||
}
|
||||
s @ "title" | s @ "subtitle" | s @ "content" => match (stream.get(1)?, stream.get(2)?) {
|
||||
(Token::Word(_, _, contains), Token::Word(_, _, w)) if contains == &"contains" => Ok((
|
||||
&stream[3..],
|
||||
Arg::Contains(
|
||||
match s {
|
||||
"title" => WithContains::Title,
|
||||
"subtitle" => WithContains::Subtitle,
|
||||
"content" => WithContains::Content,
|
||||
_ => unreachable!(),
|
||||
},
|
||||
w,
|
||||
),
|
||||
)),
|
||||
(Token::Word(_, _, contains), t) if contains == &"contains" => {
|
||||
t.get_error(Token::Word(0, 0, "any word"))
|
||||
}
|
||||
(t, _) => t.get_error(Token::Word(0, 0, "'contains'")),
|
||||
},
|
||||
s @ "followed" | s @ "has_cover" | s @ "local" | s @ "all" => match s {
|
||||
"followed" => {
|
||||
let mut boosts = true;
|
||||
let mut likes = false;
|
||||
while let Some(Token::Word(s, e, clude)) = stream.get(1) {
|
||||
if *clude != "include" && *clude != "exclude" {
|
||||
break;
|
||||
}
|
||||
match (*clude, stream.get(2).map(Token::get_text)?) {
|
||||
("include", "reshares") | ("include", "reshare") => boosts = true,
|
||||
("exclude", "reshares") | ("exclude", "reshare") => boosts = false,
|
||||
("include", "likes") | ("include", "like") => likes = true,
|
||||
("exclude", "likes") | ("exclude", "like") => likes = false,
|
||||
(_, w) => {
|
||||
return Token::Word(*s, *e, w).get_error(Token::Word(
|
||||
0,
|
||||
0,
|
||||
"one of 'likes' or 'boosts'",
|
||||
))
|
||||
}
|
||||
}
|
||||
stream = &stream[2..];
|
||||
}
|
||||
Ok((&stream[1..], Arg::Boolean(Bool::Followed { boosts, likes })))
|
||||
}
|
||||
"has_cover" => Ok((&stream[1..], Arg::Boolean(Bool::HasCover))),
|
||||
"local" => Ok((&stream[1..], Arg::Boolean(Bool::Local))),
|
||||
"all" => Ok((&stream[1..], Arg::Boolean(Bool::All))),
|
||||
_ => unreachable!(),
|
||||
},
|
||||
_ => stream.get(0)?.get_error(Token::Word(
|
||||
0,
|
||||
0,
|
||||
"one of 'blog', 'author', 'license', 'tags', 'lang', \
|
||||
'title', 'subtitle', 'content', 'followed', 'has_cover', 'local' or 'all'",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_l<'a, 'b>(stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], List<'a>)> {
|
||||
match stream.get(0)? {
|
||||
Token::LBracket(_) => {
|
||||
let (left, list) = parse_m(&stream[1..])?;
|
||||
match left.get(0)? {
|
||||
Token::RBracket(_) => Ok((&left[1..], List::Array(list))),
|
||||
t => t.get_error(Token::Word(0, 0, "one of ']' or ','")),
|
||||
}
|
||||
}
|
||||
Token::Word(_, _, list) => Ok((&stream[1..], List::List(list))),
|
||||
t => t.get_error(Token::Word(0, 0, "one of [list, of, words] or list_name")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_m<'a, 'b>(mut stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], Vec<&'a str>)> {
|
||||
let mut res: Vec<&str> = Vec::new();
|
||||
res.push(match stream.get(0)? {
|
||||
Token::Word(_, _, w) => w,
|
||||
t => return t.get_error(Token::Word(0, 0, "any word")),
|
||||
});
|
||||
stream = &stream[1..];
|
||||
while let Token::Comma(_) = stream[0] {
|
||||
res.push(match stream.get(1)? {
|
||||
Token::Word(_, _, w) => w,
|
||||
t => return t.get_error(Token::Word(0, 0, "any word")),
|
||||
});
|
||||
stream = &stream[2..];
|
||||
}
|
||||
|
||||
Ok((stream, res))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TimelineQuery<'a>(TQ<'a>);
|
||||
|
||||
impl<'a> TimelineQuery<'a> {
|
||||
pub fn parse(query: &'a str) -> QueryResult<Self> {
|
||||
parse_s(&lex(query))
|
||||
.and_then(|(left, res)| {
|
||||
if left.is_empty() {
|
||||
Ok(res)
|
||||
} else {
|
||||
left[0].get_error(Token::Word(0, 0, "on of 'or' or 'and'"))
|
||||
}
|
||||
})
|
||||
.map(TimelineQuery)
|
||||
}
|
||||
|
||||
pub fn matches(
|
||||
&self,
|
||||
rocket: &PlumeRocket,
|
||||
timeline: &Timeline,
|
||||
post: &Post,
|
||||
kind: Kind,
|
||||
) -> Result<bool> {
|
||||
self.0.matches(rocket, timeline, post, kind)
|
||||
}
|
||||
|
||||
pub fn list_used_lists(&self) -> Vec<(String, ListType)> {
|
||||
self.0.list_used_lists()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_lexer() {
|
||||
assert_eq!(
|
||||
lex("()[ ],two words \"something quoted with , and [\""),
|
||||
vec![
|
||||
Token::LParent(0),
|
||||
Token::RParent(1),
|
||||
Token::LBracket(2),
|
||||
Token::RBracket(4),
|
||||
Token::Comma(5),
|
||||
Token::Word(6, 3, "two"),
|
||||
Token::Word(10, 5, "words"),
|
||||
Token::Word(17, 29, "something quoted with , and ["),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser() {
|
||||
let q = TimelineQuery::parse(r#"lang in [fr, en] and (license in my_fav_lic or not followed) or title contains "Plume is amazing""#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
q.0,
|
||||
TQ::Or(vec![
|
||||
TQ::And(vec![
|
||||
TQ::Arg(
|
||||
Arg::In(WithList::Lang, List::Array(vec!["fr", "en"]),),
|
||||
false
|
||||
),
|
||||
TQ::Or(vec![
|
||||
TQ::Arg(Arg::In(WithList::License, List::List("my_fav_lic"),), false),
|
||||
TQ::Arg(
|
||||
Arg::Boolean(Bool::Followed {
|
||||
boosts: true,
|
||||
likes: false
|
||||
}),
|
||||
true
|
||||
),
|
||||
]),
|
||||
]),
|
||||
TQ::Arg(
|
||||
Arg::Contains(WithContains::Title, "Plume is amazing",),
|
||||
false
|
||||
),
|
||||
])
|
||||
);
|
||||
|
||||
let lists = TimelineQuery::parse(
|
||||
r#"blog in a or author in b include likes or license in c or tags in d or lang in e "#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
lists.0,
|
||||
TQ::Or(vec![
|
||||
TQ::Arg(Arg::In(WithList::Blog, List::List("a"),), false),
|
||||
TQ::Arg(
|
||||
Arg::In(
|
||||
WithList::Author {
|
||||
boosts: true,
|
||||
likes: true
|
||||
},
|
||||
List::List("b"),
|
||||
),
|
||||
false
|
||||
),
|
||||
TQ::Arg(Arg::In(WithList::License, List::List("c"),), false),
|
||||
TQ::Arg(Arg::In(WithList::Tags, List::List("d"),), false),
|
||||
TQ::Arg(Arg::In(WithList::Lang, List::List("e"),), false),
|
||||
])
|
||||
);
|
||||
|
||||
let contains = TimelineQuery::parse(
|
||||
r#"title contains a or subtitle contains b or content contains c"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
contains.0,
|
||||
TQ::Or(vec![
|
||||
TQ::Arg(Arg::Contains(WithContains::Title, "a"), false),
|
||||
TQ::Arg(Arg::Contains(WithContains::Subtitle, "b"), false),
|
||||
TQ::Arg(Arg::Contains(WithContains::Content, "c"), false),
|
||||
])
|
||||
);
|
||||
|
||||
let booleans = TimelineQuery::parse(
|
||||
r#"followed include like exclude reshares and has_cover and local and all"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
booleans.0,
|
||||
TQ::And(vec![
|
||||
TQ::Arg(
|
||||
Arg::Boolean(Bool::Followed {
|
||||
boosts: false,
|
||||
likes: true
|
||||
}),
|
||||
false
|
||||
),
|
||||
TQ::Arg(Arg::Boolean(Bool::HasCover), false),
|
||||
TQ::Arg(Arg::Boolean(Bool::Local), false),
|
||||
TQ::Arg(Arg::Boolean(Bool::All), false),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejection_parser() {
|
||||
let missing_and_or = TimelineQuery::parse(r#"followed or has_cover local"#).unwrap_err();
|
||||
assert_eq!(
|
||||
missing_and_or,
|
||||
QueryError::SyntaxError(
|
||||
22,
|
||||
5,
|
||||
"Syntax Error: Expected on of 'or' or 'and', got 'local'".to_owned()
|
||||
)
|
||||
);
|
||||
|
||||
let unbalanced_parent =
|
||||
TimelineQuery::parse(r#"followed and (has_cover or local"#).unwrap_err();
|
||||
assert_eq!(unbalanced_parent, QueryError::UnexpectedEndOfQuery);
|
||||
|
||||
let missing_and_or_in_par =
|
||||
TimelineQuery::parse(r#"(title contains "abc def" followed)"#).unwrap_err();
|
||||
assert_eq!(
|
||||
missing_and_or_in_par,
|
||||
QueryError::SyntaxError(
|
||||
26,
|
||||
8,
|
||||
"Syntax Error: Expected ')', got 'followed'".to_owned()
|
||||
)
|
||||
);
|
||||
|
||||
let expect_in = TimelineQuery::parse(r#"lang contains abc"#).unwrap_err();
|
||||
assert_eq!(
|
||||
expect_in,
|
||||
QueryError::SyntaxError(
|
||||
5,
|
||||
8,
|
||||
"Syntax Error: Expected 'in', got 'contains'".to_owned()
|
||||
)
|
||||
);
|
||||
|
||||
let expect_contains = TimelineQuery::parse(r#"title in abc"#).unwrap_err();
|
||||
assert_eq!(
|
||||
expect_contains,
|
||||
QueryError::SyntaxError(
|
||||
6,
|
||||
2,
|
||||
"Syntax Error: Expected 'contains', got 'in'".to_owned()
|
||||
)
|
||||
);
|
||||
|
||||
let expect_keyword = TimelineQuery::parse(r#"not_a_field contains something"#).unwrap_err();
|
||||
assert_eq!(expect_keyword, QueryError::SyntaxError(0, 11, "Syntax Error: Expected one of 'blog', \
|
||||
'author', 'license', 'tags', 'lang', 'title', 'subtitle', 'content', 'followed', 'has_cover', \
|
||||
'local' or 'all', got 'not_a_field'".to_owned()));
|
||||
|
||||
let expect_bracket_or_comma = TimelineQuery::parse(r#"lang in [en ["#).unwrap_err();
|
||||
assert_eq!(
|
||||
expect_bracket_or_comma,
|
||||
QueryError::SyntaxError(
|
||||
12,
|
||||
1,
|
||||
"Syntax Error: Expected one of ']' or ',', \
|
||||
got '['"
|
||||
.to_owned()
|
||||
)
|
||||
);
|
||||
|
||||
let expect_bracket = TimelineQuery::parse(r#"lang in )abc"#).unwrap_err();
|
||||
assert_eq!(
|
||||
expect_bracket,
|
||||
QueryError::SyntaxError(
|
||||
8,
|
||||
1,
|
||||
"Syntax Error: Expected one of [list, of, words] or list_name, \
|
||||
got ')'"
|
||||
.to_owned()
|
||||
)
|
||||
);
|
||||
|
||||
let expect_word = TimelineQuery::parse(r#"title contains ,"#).unwrap_err();
|
||||
assert_eq!(
|
||||
expect_word,
|
||||
QueryError::SyntaxError(15, 1, "Syntax Error: Expected any word, got ','".to_owned())
|
||||
);
|
||||
|
||||
let got_bracket = TimelineQuery::parse(r#"lang in []"#).unwrap_err();
|
||||
assert_eq!(
|
||||
got_bracket,
|
||||
QueryError::SyntaxError(9, 1, "Syntax Error: Expected any word, got ']'".to_owned())
|
||||
);
|
||||
|
||||
let got_par = TimelineQuery::parse(r#"lang in [a, ("#).unwrap_err();
|
||||
assert_eq!(
|
||||
got_par,
|
||||
QueryError::SyntaxError(12, 1, "Syntax Error: Expected any word, got '('".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_used_lists() {
|
||||
let q = TimelineQuery::parse(r#"lang in [fr, en] and blog in blogs or author in my_fav_authors or tags in hashtag and lang in spoken or license in copyleft"#)
|
||||
.unwrap();
|
||||
let used_lists = q.list_used_lists();
|
||||
assert_eq!(
|
||||
used_lists,
|
||||
vec![
|
||||
("blogs".to_owned(), ListType::Blog),
|
||||
("my_fav_authors".to_owned(), ListType::User),
|
||||
("hashtag".to_owned(), ListType::Word),
|
||||
("spoken".to_owned(), ListType::Prefix),
|
||||
("copyleft".to_owned(), ListType::Word),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
+138
-187
@@ -1,13 +1,13 @@
|
||||
use activitypub::{
|
||||
activity::Delete,
|
||||
actor::Person,
|
||||
collection::{OrderedCollection, OrderedCollectionPage},
|
||||
collection::OrderedCollection,
|
||||
object::{Image, Tombstone},
|
||||
Activity, CustomObject, Endpoint,
|
||||
};
|
||||
use bcrypt;
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use diesel::{self, BelongingToDsl, ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl};
|
||||
use diesel::{self, BelongingToDsl, ExpressionMethods, QueryDsl, RunQueryDsl, SaveChangesDsl};
|
||||
use openssl::{
|
||||
hash::MessageDigest,
|
||||
pkey::{PKey, Private},
|
||||
@@ -42,23 +42,15 @@ use db_conn::DbConn;
|
||||
use follows::Follow;
|
||||
use instance::*;
|
||||
use medias::Media;
|
||||
use notifications::Notification;
|
||||
use post_authors::PostAuthor;
|
||||
use posts::Post;
|
||||
use safe_string::SafeString;
|
||||
use schema::users;
|
||||
use search::Searcher;
|
||||
use timeline::Timeline;
|
||||
use {ap_url, Connection, Error, PlumeRocket, Result, ITEMS_PER_PAGE};
|
||||
use {ap_url, Connection, Error, PlumeRocket, Result};
|
||||
|
||||
pub type CustomPerson = CustomObject<ApSignature, Person>;
|
||||
|
||||
pub enum Role {
|
||||
Admin = 0,
|
||||
Moderator = 1,
|
||||
Normal = 2,
|
||||
}
|
||||
|
||||
#[derive(Queryable, Identifiable, Clone, Debug, AsChangeset)]
|
||||
pub struct User {
|
||||
pub id: i32,
|
||||
@@ -66,6 +58,7 @@ pub struct User {
|
||||
pub display_name: String,
|
||||
pub outbox_url: String,
|
||||
pub inbox_url: String,
|
||||
pub is_admin: bool,
|
||||
pub summary: String,
|
||||
pub email: Option<String>,
|
||||
pub hashed_password: Option<String>,
|
||||
@@ -80,12 +73,6 @@ pub struct User {
|
||||
pub last_fetched_date: NaiveDateTime,
|
||||
pub fqn: String,
|
||||
pub summary_html: SafeString,
|
||||
/// 0 = admin
|
||||
/// 1 = moderator
|
||||
/// anything else = normal user
|
||||
pub role: i32,
|
||||
pub preferred_theme: Option<String>,
|
||||
pub hide_custom_css: bool,
|
||||
}
|
||||
|
||||
#[derive(Default, Insertable)]
|
||||
@@ -95,6 +82,7 @@ pub struct NewUser {
|
||||
pub display_name: String,
|
||||
pub outbox_url: String,
|
||||
pub inbox_url: String,
|
||||
pub is_admin: bool,
|
||||
pub summary: String,
|
||||
pub email: Option<String>,
|
||||
pub hashed_password: Option<String>,
|
||||
@@ -106,28 +94,50 @@ pub struct NewUser {
|
||||
pub followers_endpoint: String,
|
||||
pub avatar_id: Option<i32>,
|
||||
pub summary_html: SafeString,
|
||||
pub role: i32,
|
||||
pub fqn: String,
|
||||
}
|
||||
|
||||
pub const AUTH_COOKIE: &str = "user_id";
|
||||
const USER_PREFIX: &str = "@";
|
||||
|
||||
impl User {
|
||||
insert!(users, NewUser);
|
||||
insert!(users, NewUser, |inserted, conn| {
|
||||
let instance = inserted.get_instance(conn)?;
|
||||
if inserted.outbox_url.is_empty() {
|
||||
inserted.outbox_url = instance.compute_box(USER_PREFIX, &inserted.username, "outbox");
|
||||
}
|
||||
|
||||
if inserted.inbox_url.is_empty() {
|
||||
inserted.inbox_url = instance.compute_box(USER_PREFIX, &inserted.username, "inbox");
|
||||
}
|
||||
|
||||
if inserted.ap_url.is_empty() {
|
||||
inserted.ap_url = instance.compute_box(USER_PREFIX, &inserted.username, "");
|
||||
}
|
||||
|
||||
if inserted.shared_inbox_url.is_none() {
|
||||
inserted.shared_inbox_url = Some(ap_url(&format!("{}/inbox", instance.public_domain)));
|
||||
}
|
||||
|
||||
if inserted.followers_endpoint.is_empty() {
|
||||
inserted.followers_endpoint =
|
||||
instance.compute_box(USER_PREFIX, &inserted.username, "followers");
|
||||
}
|
||||
|
||||
if inserted.fqn.is_empty() {
|
||||
if instance.local {
|
||||
inserted.fqn = inserted.username.clone();
|
||||
} else {
|
||||
inserted.fqn = format!("{}@{}", inserted.username, instance.public_domain);
|
||||
}
|
||||
}
|
||||
|
||||
inserted.save_changes(conn).map_err(Error::from)
|
||||
});
|
||||
get!(users);
|
||||
find_by!(users, find_by_email, email as &str);
|
||||
find_by!(users, find_by_name, username as &str, instance_id as i32);
|
||||
find_by!(users, find_by_ap_url, ap_url as &str);
|
||||
|
||||
pub fn is_moderator(&self) -> bool {
|
||||
self.role == Role::Admin as i32 || self.role == Role::Moderator as i32
|
||||
}
|
||||
|
||||
pub fn is_admin(&self) -> bool {
|
||||
self.role == Role::Admin as i32
|
||||
}
|
||||
|
||||
pub fn one_by_instance(conn: &Connection) -> Result<Vec<User>> {
|
||||
users::table
|
||||
.filter(users::instance_id.eq_any(users::table.select(users::instance_id).distinct()))
|
||||
@@ -166,10 +176,6 @@ impl User {
|
||||
}
|
||||
}
|
||||
|
||||
for notif in Notification::find_followed_by(conn, self)? {
|
||||
notif.delete(conn)?
|
||||
}
|
||||
|
||||
diesel::delete(self)
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
@@ -180,14 +186,46 @@ impl User {
|
||||
Instance::get(conn, self.instance_id)
|
||||
}
|
||||
|
||||
pub fn set_role(&self, conn: &Connection, new_role: Role) -> Result<()> {
|
||||
pub fn grant_admin_rights(&self, conn: &Connection) -> Result<()> {
|
||||
diesel::update(self)
|
||||
.set(users::role.eq(new_role as i32))
|
||||
.set(users::is_admin.eq(true))
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn revoke_admin_rights(&self, conn: &Connection) -> Result<()> {
|
||||
diesel::update(self)
|
||||
.set(users::is_admin.eq(false))
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn update(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
name: String,
|
||||
email: String,
|
||||
summary: String,
|
||||
) -> Result<User> {
|
||||
diesel::update(self)
|
||||
.set((
|
||||
users::display_name.eq(name),
|
||||
users::email.eq(email),
|
||||
users::summary_html.eq(utils::md_to_html(
|
||||
&summary,
|
||||
None,
|
||||
false,
|
||||
Some(Media::get_media_processor(conn, vec![self])),
|
||||
)
|
||||
.0),
|
||||
users::summary.eq(summary),
|
||||
))
|
||||
.execute(conn)?;
|
||||
User::get(conn, self.id)
|
||||
}
|
||||
|
||||
pub fn count_local(conn: &Connection) -> Result<i64> {
|
||||
users::table
|
||||
.filter(users::instance_id.eq(Instance::get_local()?.id))
|
||||
@@ -199,8 +237,10 @@ impl User {
|
||||
pub fn find_by_fqn(c: &PlumeRocket, fqn: &str) -> Result<User> {
|
||||
let from_db = users::table
|
||||
.filter(users::fqn.eq(fqn))
|
||||
.first(&*c.conn)
|
||||
.optional()?;
|
||||
.limit(1)
|
||||
.load::<User>(&*c.conn)?
|
||||
.into_iter()
|
||||
.next();
|
||||
if let Some(from_db) = from_db {
|
||||
Ok(from_db)
|
||||
} else {
|
||||
@@ -320,77 +360,16 @@ impl User {
|
||||
.load::<User>(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
pub fn outbox(&self, conn: &Connection) -> Result<ActivityStream<OrderedCollection>> {
|
||||
let mut coll = OrderedCollection::default();
|
||||
let first = &format!("{}?page=1", &self.outbox_url);
|
||||
let last = &format!(
|
||||
"{}?page={}",
|
||||
&self.outbox_url,
|
||||
self.get_activities_count(&conn) / i64::from(ITEMS_PER_PAGE) + 1
|
||||
);
|
||||
coll.collection_props.set_first_link(Id::new(first))?;
|
||||
coll.collection_props.set_last_link(Id::new(last))?;
|
||||
coll.collection_props
|
||||
.set_total_items_u64(self.get_activities_count(&conn) as u64)?;
|
||||
Ok(ActivityStream::new(coll))
|
||||
}
|
||||
pub fn outbox_page(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
(min, max): (i32, i32),
|
||||
) -> Result<ActivityStream<OrderedCollectionPage>> {
|
||||
let acts = self.get_activities_page(conn, (min, max))?;
|
||||
let n_acts = self.get_activities_count(&conn);
|
||||
let mut coll = OrderedCollectionPage::default();
|
||||
if n_acts - i64::from(min) >= i64::from(ITEMS_PER_PAGE) {
|
||||
coll.collection_page_props.set_next_link(Id::new(&format!(
|
||||
"{}?page={}",
|
||||
&self.outbox_url,
|
||||
min / ITEMS_PER_PAGE + 2
|
||||
)))?;
|
||||
}
|
||||
if min > 0 {
|
||||
coll.collection_page_props.set_prev_link(Id::new(&format!(
|
||||
"{}?page={}",
|
||||
&self.outbox_url,
|
||||
min / ITEMS_PER_PAGE
|
||||
)))?;
|
||||
}
|
||||
coll.collection_props.items = serde_json::to_value(acts)?;
|
||||
coll.collection_page_props
|
||||
.set_part_of_link(Id::new(&self.outbox_url))?;
|
||||
Ok(ActivityStream::new(coll))
|
||||
}
|
||||
fn fetch_outbox_page<T: Activity>(&self, url: &str) -> Result<(Vec<T>, Option<String>)> {
|
||||
let mut res = ClientBuilder::new()
|
||||
.connect_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
.build()?
|
||||
.get(url)
|
||||
.header(
|
||||
ACCEPT,
|
||||
HeaderValue::from_str(
|
||||
&ap_accept_header()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)?,
|
||||
)
|
||||
.send()?;
|
||||
let text = &res.text()?;
|
||||
let json: serde_json::Value = serde_json::from_str(text)?;
|
||||
let items = json["items"]
|
||||
.as_array()
|
||||
.unwrap_or(&vec![])
|
||||
.iter()
|
||||
.filter_map(|j| serde_json::from_value(j.clone()).ok())
|
||||
.collect::<Vec<T>>();
|
||||
|
||||
let next = match json.get("next") {
|
||||
Some(x) => Some(x.as_str().unwrap().to_owned()),
|
||||
None => None,
|
||||
};
|
||||
Ok((items, next))
|
||||
pub fn outbox(&self, conn: &Connection) -> Result<ActivityStream<OrderedCollection>> {
|
||||
let acts = self.get_activities(conn)?;
|
||||
let n_acts = acts.len();
|
||||
let mut coll = OrderedCollection::default();
|
||||
coll.collection_props.items = serde_json::to_value(acts)?;
|
||||
coll.collection_props.set_total_items_u64(n_acts as u64)?;
|
||||
Ok(ActivityStream::new(coll))
|
||||
}
|
||||
|
||||
pub fn fetch_outbox<T: Activity>(&self) -> Result<Vec<T>> {
|
||||
let mut res = ClientBuilder::new()
|
||||
.connect_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
@@ -408,32 +387,12 @@ impl User {
|
||||
.send()?;
|
||||
let text = &res.text()?;
|
||||
let json: serde_json::Value = serde_json::from_str(text)?;
|
||||
if let Some(first) = json.get("first") {
|
||||
let mut items: Vec<T> = Vec::new();
|
||||
let mut next = first.as_str().unwrap().to_owned();
|
||||
while let Ok((mut page, nxt)) = self.fetch_outbox_page(&next) {
|
||||
if page.is_empty() {
|
||||
break;
|
||||
}
|
||||
items.extend(page.drain(..));
|
||||
if let Some(n) = nxt {
|
||||
if n == next {
|
||||
break;
|
||||
}
|
||||
next = n;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(items)
|
||||
} else {
|
||||
Ok(json["items"]
|
||||
.as_array()
|
||||
.unwrap_or(&vec![])
|
||||
.iter()
|
||||
.filter_map(|j| serde_json::from_value(j.clone()).ok())
|
||||
.collect::<Vec<T>>())
|
||||
}
|
||||
Ok(json["items"]
|
||||
.as_array()
|
||||
.unwrap_or(&vec![])
|
||||
.iter()
|
||||
.filter_map(|j| serde_json::from_value(j.clone()).ok())
|
||||
.collect::<Vec<T>>())
|
||||
}
|
||||
|
||||
pub fn fetch_followers_ids(&self) -> Result<Vec<String>> {
|
||||
@@ -460,31 +419,14 @@ impl User {
|
||||
.filter_map(|j| serde_json::from_value(j.clone()).ok())
|
||||
.collect::<Vec<String>>())
|
||||
}
|
||||
fn get_activities_count(&self, conn: &Connection) -> i64 {
|
||||
use schema::post_authors;
|
||||
use schema::posts;
|
||||
let posts_by_self = PostAuthor::belonging_to(self).select(post_authors::post_id);
|
||||
posts::table
|
||||
.filter(posts::published.eq(true))
|
||||
.filter(posts::id.eq_any(posts_by_self))
|
||||
.count()
|
||||
.first(conn)
|
||||
.unwrap()
|
||||
}
|
||||
fn get_activities_page(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
(min, max): (i32, i32),
|
||||
) -> Result<Vec<serde_json::Value>> {
|
||||
|
||||
fn get_activities(&self, conn: &Connection) -> Result<Vec<serde_json::Value>> {
|
||||
use schema::post_authors;
|
||||
use schema::posts;
|
||||
let posts_by_self = PostAuthor::belonging_to(self).select(post_authors::post_id);
|
||||
let posts = posts::table
|
||||
.filter(posts::published.eq(true))
|
||||
.filter(posts::id.eq_any(posts_by_self))
|
||||
.order(posts::creation_date.desc())
|
||||
.offset(min.into())
|
||||
.limit((max - min).into())
|
||||
.load::<Post>(conn)?;
|
||||
Ok(posts
|
||||
.into_iter()
|
||||
@@ -726,7 +668,7 @@ impl User {
|
||||
pub fn avatar_url(&self, conn: &Connection) -> String {
|
||||
self.avatar_id
|
||||
.and_then(|id| Media::get(conn, id).and_then(|m| m.url()).ok())
|
||||
.unwrap_or_else(|| "/static/images/default-avatar.png".to_string())
|
||||
.unwrap_or_else(|| "/static/default-avatar.png".to_string())
|
||||
}
|
||||
|
||||
pub fn webfinger(&self, conn: &Connection) -> Result<Webfinger> {
|
||||
@@ -765,7 +707,7 @@ impl User {
|
||||
mime_type: None,
|
||||
href: None,
|
||||
template: Some(format!(
|
||||
"https://{}/remote_interact?{{uri}}",
|
||||
"{}/remote_interact?{{uri}}",
|
||||
self.get_instance(conn)?.public_domain
|
||||
)),
|
||||
},
|
||||
@@ -851,12 +793,6 @@ impl FromId<PlumeRocket> for User {
|
||||
return Err(Error::InvalidValue);
|
||||
}
|
||||
|
||||
let fqn = if instance.local {
|
||||
username.clone()
|
||||
} else {
|
||||
format!("{}@{}", username, instance.public_domain)
|
||||
};
|
||||
|
||||
let user = User::insert(
|
||||
&c.conn,
|
||||
NewUser {
|
||||
@@ -868,7 +804,7 @@ impl FromId<PlumeRocket> for User {
|
||||
username,
|
||||
outbox_url: acct.object.ap_actor_props.outbox_string()?,
|
||||
inbox_url: acct.object.ap_actor_props.inbox_string()?,
|
||||
role: 2,
|
||||
is_admin: false,
|
||||
summary: acct
|
||||
.object
|
||||
.object_props
|
||||
@@ -897,7 +833,6 @@ impl FromId<PlumeRocket> for User {
|
||||
.and_then(|e| e.shared_inbox_string())
|
||||
.ok(),
|
||||
followers_endpoint: acct.object.ap_actor_props.followers_string()?,
|
||||
fqn,
|
||||
avatar_id: None,
|
||||
},
|
||||
)?;
|
||||
@@ -985,41 +920,29 @@ impl NewUser {
|
||||
conn: &Connection,
|
||||
username: String,
|
||||
display_name: String,
|
||||
role: Role,
|
||||
is_admin: bool,
|
||||
summary: &str,
|
||||
email: String,
|
||||
password: String,
|
||||
) -> Result<User> {
|
||||
let (pub_key, priv_key) = gen_keypair();
|
||||
let instance = Instance::get_local()?;
|
||||
|
||||
let res = User::insert(
|
||||
User::insert(
|
||||
conn,
|
||||
NewUser {
|
||||
username: username.clone(),
|
||||
username,
|
||||
display_name,
|
||||
role: role as i32,
|
||||
is_admin,
|
||||
summary: summary.to_owned(),
|
||||
summary_html: SafeString::new(&utils::md_to_html(&summary, None, false, None).0),
|
||||
email: Some(email),
|
||||
hashed_password: Some(password),
|
||||
instance_id: instance.id,
|
||||
instance_id: Instance::get_local()?.id,
|
||||
ap_url: String::new(),
|
||||
public_key: String::from_utf8(pub_key).or(Err(Error::Signature))?,
|
||||
private_key: Some(String::from_utf8(priv_key).or(Err(Error::Signature))?),
|
||||
outbox_url: instance.compute_box(USER_PREFIX, &username, "outbox"),
|
||||
inbox_url: instance.compute_box(USER_PREFIX, &username, "inbox"),
|
||||
ap_url: instance.compute_box(USER_PREFIX, &username, ""),
|
||||
shared_inbox_url: Some(ap_url(&format!("{}/inbox", &instance.public_domain))),
|
||||
followers_endpoint: instance.compute_box(USER_PREFIX, &username, "followers"),
|
||||
fqn: username,
|
||||
avatar_id: None,
|
||||
..NewUser::default()
|
||||
},
|
||||
)?;
|
||||
|
||||
// create default timeline
|
||||
Timeline::new_for_user(conn, res.id, "My feed".into(), "followed".into())?;
|
||||
|
||||
Ok(res)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1038,7 +961,7 @@ pub(crate) mod tests {
|
||||
conn,
|
||||
"admin".to_owned(),
|
||||
"The admin".to_owned(),
|
||||
Role::Admin,
|
||||
true,
|
||||
"Hello there, I'm the admin",
|
||||
"admin@example.com".to_owned(),
|
||||
"invalid_admin_password".to_owned(),
|
||||
@@ -1048,7 +971,7 @@ pub(crate) mod tests {
|
||||
conn,
|
||||
"user".to_owned(),
|
||||
"Some user".to_owned(),
|
||||
Role::Normal,
|
||||
false,
|
||||
"Hello there, I'm no one",
|
||||
"user@example.com".to_owned(),
|
||||
"invalid_user_password".to_owned(),
|
||||
@@ -1058,7 +981,7 @@ pub(crate) mod tests {
|
||||
conn,
|
||||
"other".to_owned(),
|
||||
"Another user".to_owned(),
|
||||
Role::Normal,
|
||||
false,
|
||||
"Hello there, I'm someone else",
|
||||
"other@example.com".to_owned(),
|
||||
"invalid_other_password".to_owned(),
|
||||
@@ -1077,12 +1000,13 @@ pub(crate) mod tests {
|
||||
conn,
|
||||
"test".to_owned(),
|
||||
"test user".to_owned(),
|
||||
Role::Normal,
|
||||
false,
|
||||
"Hello I'm a test",
|
||||
"test@example.com".to_owned(),
|
||||
User::hash_pass("test_password").unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
test_user.id,
|
||||
User::find_by_name(conn, "test", Instance::get_local().unwrap().id)
|
||||
@@ -1110,6 +1034,7 @@ pub(crate) mod tests {
|
||||
.unwrap()
|
||||
.id
|
||||
);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -1123,6 +1048,7 @@ pub(crate) mod tests {
|
||||
assert!(User::get(conn, inserted[0].id).is_ok());
|
||||
inserted[0].delete(conn, &get_searcher()).unwrap();
|
||||
assert!(User::get(conn, inserted[0].id).is_err());
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -1139,12 +1065,34 @@ pub(crate) mod tests {
|
||||
local_inst
|
||||
.main_admin(conn)
|
||||
.unwrap()
|
||||
.set_role(conn, Role::Normal)
|
||||
.revoke_admin_rights(conn)
|
||||
.unwrap();
|
||||
i += 1;
|
||||
}
|
||||
inserted[0].set_role(conn, Role::Admin).unwrap();
|
||||
inserted[0].grant_admin_rights(conn).unwrap();
|
||||
assert_eq!(inserted[0].id, local_inst.main_admin(conn).unwrap().id);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update() {
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let inserted = fill_database(conn);
|
||||
let updated = inserted[0]
|
||||
.update(
|
||||
conn,
|
||||
"new name".to_owned(),
|
||||
"em@il".to_owned(),
|
||||
"<p>summary</p><script></script>".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.display_name, "new name");
|
||||
assert_eq!(updated.email.unwrap(), "em@il");
|
||||
assert_eq!(updated.summary_html.get(), "<p>summary</p>");
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -1158,7 +1106,7 @@ pub(crate) mod tests {
|
||||
conn,
|
||||
"test".to_owned(),
|
||||
"test user".to_owned(),
|
||||
Role::Normal,
|
||||
false,
|
||||
"Hello I'm a test",
|
||||
"test@example.com".to_owned(),
|
||||
User::hash_pass("test_password").unwrap(),
|
||||
@@ -1167,6 +1115,7 @@ pub(crate) mod tests {
|
||||
|
||||
assert!(test_user.auth("test_password"));
|
||||
assert!(!test_user.auth("other_password"));
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -1196,6 +1145,7 @@ pub(crate) mod tests {
|
||||
.len() as i64,
|
||||
User::count_local(conn).unwrap()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -1223,6 +1173,7 @@ pub(crate) mod tests {
|
||||
assert_eq!(user.avatar_url(conn), users[0].avatar_url(conn));
|
||||
assert_eq!(user.fqn, users[0].fqn);
|
||||
assert_eq!(user.summary_html, users[0].summary_html);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
+14
-13
@@ -3,55 +3,56 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:06\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 19:54\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: Afrikaans\n"
|
||||
"Language: af_ZA\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: af\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
msgid "Subtitle, or summary"
|
||||
# plume-front/src/editor.rs:147
|
||||
msgid "Subtitle or summary"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr ""
|
||||
|
||||
|
||||
+20
-18
@@ -3,55 +3,57 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:06\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 19:54\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: Arabic\n"
|
||||
"Language: ar_SA\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
|
||||
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 "
|
||||
"&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: ar\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr "فتح محرر النصوص الغني"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr "العنوان"
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
# plume-front/src/editor.rs:147
|
||||
#, fuzzy
|
||||
msgid "Subtitle, or summary"
|
||||
msgstr "العنوان الثانوي أو الملخص"
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr "اكتب مقالك هنا. ماركداون مُدَعَّم."
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr "يتبقا {} حرفا تقريبا"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr "الوسوم"
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr "الرخصة"
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr "الغلاف"
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr "هذه مسودة"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr "نشر كتابا"
|
||||
|
||||
|
||||
+22
-22
@@ -3,55 +3,55 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:06\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 18:15\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: Bulgarian\n"
|
||||
"Language: bg_BG\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: bg\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr "Отворете редактора с богат текст"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr "Заглавие"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
msgid "Subtitle, or summary"
|
||||
msgstr "Подзаглавие или резюме"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr "Напишете статията си тук. Поддържа се Markdown."
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr "Остават {} знака вляво"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr "Етикети"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr "Лиценз"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr "Основно изображение"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr "Това е проект"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr "Публикувай"
|
||||
|
||||
msgstr ""
|
||||
|
||||
+22
-22
@@ -3,55 +3,55 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:06\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 19:55\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: Catalan\n"
|
||||
"Language: ca_ES\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: ca\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr "Obre l’editor de text enriquit"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr "Títol"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
msgid "Subtitle, or summary"
|
||||
msgstr "Subtítol o resum"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr "Escriviu el vostre article ací. Podeu fer servir el Markdown."
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr "Queden uns {} caràcters"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr "Etiquetes"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr "Llicència"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr "Coberta"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr "Açò és un esborrany"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr "Publica"
|
||||
|
||||
msgstr ""
|
||||
|
||||
+15
-14
@@ -3,55 +3,56 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:06\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 18:15\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: Czech\n"
|
||||
"Language: cs_CZ\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: cs\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr "Otevřít editor formátovaného textu"
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr "Nadpis"
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
# plume-front/src/editor.rs:147
|
||||
#, fuzzy
|
||||
msgid "Subtitle, or summary"
|
||||
msgstr "Podnadpis, nebo shrnutí"
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr "Sem napište svůj článek. Markdown je podporován."
|
||||
msgstr "Napište sem svůj článek. Markdown je podporován."
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr "Zbývá kolem {} znaků"
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr "Tagy"
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr "Licence"
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr "Titulka"
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr "Tohle je koncept"
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr "Zveřejnit"
|
||||
|
||||
|
||||
+14
-13
@@ -3,55 +3,56 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:06\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 18:15\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: Danish\n"
|
||||
"Language: da_DK\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: da\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
msgid "Subtitle, or summary"
|
||||
# plume-front/src/editor.rs:147
|
||||
msgid "Subtitle or summary"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr ""
|
||||
|
||||
|
||||
+18
-17
@@ -3,55 +3,56 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:07\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 18:14\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: de\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr " Rich Text Editor (RTE) öffnen"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr "Titel"
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
# plume-front/src/editor.rs:147
|
||||
#, fuzzy
|
||||
msgid "Subtitle, or summary"
|
||||
msgstr "Untertitel oder Zusammenfassung"
|
||||
msgstr "Untertitel oder Übersicht"
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr "Schreiben deinen Artikel hier. Markdown wird unterstützt."
|
||||
msgstr "Schreibe deinen Artikel hier. Markdown ist unterstützt."
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr "Ungefähr {} Zeichen übrig"
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr "Schlagwörter"
|
||||
msgstr "Tags"
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr "Lizenz"
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr "Einband"
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr "Dies ist ein Entwurf"
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr "Veröffentlichen"
|
||||
|
||||
|
||||
+14
-13
@@ -3,55 +3,56 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:07\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 18:14\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: Greek\n"
|
||||
"Language: el_GR\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: el\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
msgid "Subtitle, or summary"
|
||||
# plume-front/src/editor.rs:147
|
||||
msgid "Subtitle or summary"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr ""
|
||||
|
||||
|
||||
+12
-12
@@ -3,23 +3,24 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:07\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 18:15\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: en\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
@@ -27,31 +28,30 @@ msgstr ""
|
||||
msgid "Subtitle, or summary"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr ""
|
||||
|
||||
|
||||
+15
-14
@@ -3,55 +3,56 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:07\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 18:15\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: Esperanto\n"
|
||||
"Language: eo_UY\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: eo\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr "Malfermi la riĉan redaktilon"
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr "Titolo"
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
# plume-front/src/editor.rs:147
|
||||
#, fuzzy
|
||||
msgid "Subtitle, or summary"
|
||||
msgstr ""
|
||||
msgstr "Subtitolo aŭ resumo"
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr "Verku vian artikolon ĉi tie. Markdown estas subtenita."
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr "Proksimume {} signoj restantaj"
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr "Etikedoj"
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr "Permesilo"
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr "Kovro"
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr "Malfinias"
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr "Eldoni"
|
||||
|
||||
|
||||
+16
-15
@@ -3,55 +3,56 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:07\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 18:15\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: Spanish\n"
|
||||
"Language: es_ES\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: es-ES\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr "Abrir el editor de texto enriquecido"
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr "Título"
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
# plume-front/src/editor.rs:147
|
||||
#, fuzzy
|
||||
msgid "Subtitle, or summary"
|
||||
msgstr ""
|
||||
msgstr "Subtítulo o resumen"
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr "Escriba su artículo aquí. Puede utilizar Markdown."
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr "Quedan unos {} caracteres"
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr "Etiquetas"
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr "Licencia"
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr "Cubierta"
|
||||
msgstr "Cobertura"
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr "Esto es un borrador"
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr "Publicar"
|
||||
|
||||
|
||||
+23
-22
@@ -3,55 +3,56 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:07\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 19:55\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: Persian\n"
|
||||
"Language: fa_IR\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: fa\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr "باز کردن ویرایشگر غنی"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr "عنوان"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
msgid "Subtitle, or summary"
|
||||
msgstr "زیرعنوان، یا چکیده"
|
||||
# plume-front/src/editor.rs:147
|
||||
msgid "Subtitle or summary"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr "مقاله را اینجا بنویسید. از مارکداون پشتیبانی میشود."
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr "نزدیک به {} نویسه باقی مانده است"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr "برچسبها"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr "پروانه"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr "این، یک پیشنویس است"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr "انتشار"
|
||||
msgstr ""
|
||||
|
||||
|
||||
+24
-23
@@ -3,55 +3,56 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:07\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 18:14\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: Finnish\n"
|
||||
"Language: fi_FI\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: fi\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr "Avaa edistynyt tekstieditori"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr "Otsikko"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
msgid "Subtitle, or summary"
|
||||
msgstr "Alaotsikko tai tiivistelmä"
|
||||
# plume-front/src/editor.rs:147
|
||||
msgid "Subtitle or summary"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr "Kirjoita artikkelisi tähän. Markdown -kuvauskieli on tuettu."
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr "%{count} merkkiä jäljellä"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr "Tagit"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr "Lisenssi"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr "Kansi"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr "Tämä on luonnos"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr "Julkaise"
|
||||
msgstr ""
|
||||
|
||||
|
||||
+14
-13
@@ -3,55 +3,56 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:07\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 18:14\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: fr\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr "Ouvrir l'éditeur de texte avancé"
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr "Titre"
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
# plume-front/src/editor.rs:147
|
||||
#, fuzzy
|
||||
msgid "Subtitle, or summary"
|
||||
msgstr "Sous-titre ou résumé"
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr "Écrivez votre article ici. Vous pouvez utiliser du Markdown."
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr "Environ {} caractères restant"
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr "Étiquettes"
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr "Licence"
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr "Illustration"
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr "Ceci est un brouillon"
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr "Publier"
|
||||
|
||||
|
||||
+15
-14
@@ -3,55 +3,56 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:07\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 18:14\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: Galician\n"
|
||||
"Language: gl_ES\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: gl\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr "Abra o editor de texto enriquecido"
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr "Título"
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
# plume-front/src/editor.rs:147
|
||||
#, fuzzy
|
||||
msgid "Subtitle, or summary"
|
||||
msgstr "Subtítulo, ou resumo"
|
||||
msgstr "Subtítulo ou resumo"
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr "Escriba aquí o seu artigo: pode utilizar Markdown."
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr "Dispón de {} caracteres restantes"
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr "Etiquetas"
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr "Licenza"
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr "Portada"
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr "Este é un borrador"
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr "Publicar"
|
||||
|
||||
|
||||
+14
-13
@@ -3,55 +3,56 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:07\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 18:14\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: Hebrew\n"
|
||||
"Language: he_IL\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3;\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: he\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
msgid "Subtitle, or summary"
|
||||
# plume-front/src/editor.rs:147
|
||||
msgid "Subtitle or summary"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr ""
|
||||
|
||||
|
||||
+15
-14
@@ -3,55 +3,56 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:07\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 18:14\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: Hindi\n"
|
||||
"Language: hi_IN\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: hi\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr "शीर्षक"
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
# plume-front/src/editor.rs:147
|
||||
#, fuzzy
|
||||
msgid "Subtitle, or summary"
|
||||
msgstr ""
|
||||
msgstr "उपशीर्षक या सारांश"
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr "अपना आर्टिकल या लेख यहाँ लिखें. Markdown उपलब्ध है."
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr "लगभग {} अक्षर बाकी हैं"
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr "टैग्स"
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr "लाइसेंस"
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr "पब्लिश करें"
|
||||
|
||||
|
||||
+17
-15
@@ -3,55 +3,57 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:06\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 18:15\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: Croatian\n"
|
||||
"Language: hr_HR\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
|
||||
"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: hr\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr "Naslov"
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
# plume-front/src/editor.rs:147
|
||||
#, fuzzy
|
||||
msgid "Subtitle, or summary"
|
||||
msgstr ""
|
||||
msgstr "Podnaslov ili sažetak"
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr "Tagovi"
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr "Licenca"
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr "Objavi"
|
||||
|
||||
|
||||
+14
-13
@@ -3,55 +3,56 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:07\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 18:15\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: Hungarian\n"
|
||||
"Language: hu_HU\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: hu\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
msgid "Subtitle, or summary"
|
||||
# plume-front/src/editor.rs:147
|
||||
msgid "Subtitle or summary"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr ""
|
||||
|
||||
|
||||
+15
-14
@@ -3,55 +3,56 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:07\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 18:15\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: Italian\n"
|
||||
"Language: it_IT\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: it\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr "Apri il compositore di testo avanzato"
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr "Titolo"
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
# plume-front/src/editor.rs:147
|
||||
#, fuzzy
|
||||
msgid "Subtitle, or summary"
|
||||
msgstr "Sottotitolo, o sommario"
|
||||
msgstr "Sottotitolo o sommario"
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr "Scrivi qui il tuo articolo. È supportato il Markdown."
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr "Circa {} caratteri rimasti"
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr "Etichette"
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr "Licenza"
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr "Copertina"
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr "Questa è una bozza"
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr "Pubblica"
|
||||
|
||||
|
||||
+15
-14
@@ -3,55 +3,56 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:07\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 18:15\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: Japanese\n"
|
||||
"Language: ja_JP\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: ja\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr "リッチテキストエディターを開く"
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr "タイトル"
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
# plume-front/src/editor.rs:147
|
||||
#, fuzzy
|
||||
msgid "Subtitle, or summary"
|
||||
msgstr "サブタイトル、または概要"
|
||||
msgstr "サブタイトルまたは概要"
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr "投稿をここに書きます。Markdown がサポートされています。"
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr "残り約 {} 文字"
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr "タグ"
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr "ライセンス"
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr "カバー"
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr "これは下書きです"
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr "公開"
|
||||
|
||||
|
||||
+14
-13
@@ -3,55 +3,56 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:07\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 18:15\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: Korean\n"
|
||||
"Language: ko_KR\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: ko\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
msgid "Subtitle, or summary"
|
||||
# plume-front/src/editor.rs:147
|
||||
msgid "Subtitle or summary"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr ""
|
||||
|
||||
|
||||
+14
-13
@@ -3,55 +3,56 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:06\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 18:15\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: nl\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
msgid "Subtitle, or summary"
|
||||
# plume-front/src/editor.rs:147
|
||||
msgid "Subtitle or summary"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr ""
|
||||
|
||||
|
||||
+14
-13
@@ -3,55 +3,56 @@ msgstr ""
|
||||
"Project-Id-Version: plume\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
|
||||
"PO-Revision-Date: 2019-12-16 21:07\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"PO-Revision-Date: 2019-04-17 19:55\n"
|
||||
"Last-Translator: Ana Gelez (AnaGelez)\n"
|
||||
"Language-Team: Norwegian\n"
|
||||
"Language: no_NO\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: crowdin.com\n"
|
||||
"X-Crowdin-Project: plume\n"
|
||||
"X-Crowdin-Language: no\n"
|
||||
"X-Crowdin-File: /master/po/plume-front/plume-front.pot\n"
|
||||
|
||||
# plume-front/src/editor.rs:115
|
||||
# plume-front/src/editor.rs:114
|
||||
msgid "Open the rich text editor"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:145
|
||||
# plume-front/src/editor.rs:143
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:149
|
||||
msgid "Subtitle, or summary"
|
||||
# plume-front/src/editor.rs:147
|
||||
msgid "Subtitle or summary"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:156
|
||||
# plume-front/src/editor.rs:154
|
||||
msgid "Write your article here. Markdown is supported."
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:167
|
||||
# plume-front/src/editor.rs:165
|
||||
msgid "Around {} characters left"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:243
|
||||
# plume-front/src/editor.rs:228
|
||||
msgid "Tags"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:244
|
||||
# plume-front/src/editor.rs:229
|
||||
msgid "License"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:247
|
||||
# plume-front/src/editor.rs:232
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:267
|
||||
# plume-front/src/editor.rs:252
|
||||
msgid "This is a draft"
|
||||
msgstr ""
|
||||
|
||||
# plume-front/src/editor.rs:274
|
||||
# plume-front/src/editor.rs:259
|
||||
msgid "Publish"
|
||||
msgstr ""
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user