Compare commits

...

74 commits

Author SHA1 Message Date
c3e02a0d1a feat: moving from old name conventions. 2024-12-25 00:43:55 +05:00
Magnus Hovland Hoff
b034dcf61a Update bart dependency, which depended on a yanked crate version. 2023-06-21 21:59:17 +02:00
Magnus Hovland Hoff
03227d6aa9 Remove some redundant "extern crate" statements 2022-04-03 22:21:04 +02:00
Magnus Hovland Hoff
0521f0ce5a Upgrade quote 2022-04-03 22:16:45 +02:00
Magnus Hovland Hoff
25dcb85c24 Warning seems to have disappeared with updated diesel dependency 2022-04-03 22:10:29 +02:00
Magnus Hovland Hoff
bf34e2923b Addressed many cargo clippy complaints 2022-04-03 22:09:36 +02:00
Magnus Hovland Hoff
95a73b9471 cargo clippy --fix 2022-04-03 14:29:35 +02:00
Magnus Hovland Hoff
9320d14d89 Manually fix remaining compile warnings. 2022-04-03 14:19:56 +02:00
Magnus Hovland Hoff
e0f52cd031 Upgrade diesel dependencies.
When switching to edition=2021, the build fails with a linking error to -lsqlite3.
Upgrading libsqlite3-sys unfortunately does not fix this.
2022-04-03 14:01:45 +02:00
Magnus Hovland Hoff
203a701517 Upgrade lazy_static to get rid of compile warning 2022-04-03 13:49:15 +02:00
Magnus Hovland Hoff
9f80ced3ec cargo fmt 2022-04-03 13:47:43 +02:00
Magnus Hovland Hoff
26fe2b64da Update edition, cargo fix and cargo fix --edition 2022-04-03 13:45:50 +02:00
Magnus Hovland Hoff
3f71040aec Disable auto-deploy.
I would like to keep continuous builds available, but the auto-deploy has triggered
GitHub releases for every build, which is very confusing documentation. Also, Travis
and other CI services, do not seem to want to offer a free tier any longer. Considering
switching to running continuous build on my own hardware somehow.
2022-04-03 13:35:10 +02:00
Magnus Hovland Hoff
f675896054 Update API key for Github deploy 2021-05-23 21:17:42 +02:00
Magnus Hovland Hoff
3b16598444 cargo update
Fix broken build
2021-05-01 20:18:41 +02:00
Magnus Hovland Hoff
fef213f9f3 cargo update
This fixes build breakage caused by rotten dependency
2020-05-25 21:37:24 +02:00
Magnus Hovland Hoff
f96cc8dac5 Silence warnings caused by Diesel on Rust >=1.29, also in build.rs 2018-10-27 15:30:33 +02:00
Magnus Hovland Hoff
a20117a42c Silence warnings caused by Diesel on Rust >=1.29.
I expect this to be fixed in a future release of Diesel
2018-10-27 11:42:09 +02:00
Magnus Hovland Hoff
58a859b014 Completely disable setting focus.
setSelectionRange causes Safari to set focus to the element. Trying to get rid of distracting focus changes that pops up an on-screen keyboard
2018-10-08 07:53:41 +02:00
Magnus Hovland Hoff
318273a75d Improve placeholder styling.
Apply placeholder styling to title editor as well
2018-10-08 07:49:14 +02:00
Magnus Hovland Hoff
0a64a274ac Bugfix. Anonymous edits would have a stray period. 2018-10-07 13:54:09 +02:00
Magnus Hovland Hoff
ccbabb86f8 Make theme optional when updating articles.
This makes sense from an API design perspective
2018-10-05 13:07:19 +02:00
Magnus Hovland Hoff
09c68c5993 Select random theme for new articles server side.
This supports user agents with disabled javascript (noscript)
2018-10-05 11:56:53 +02:00
Magnus Hovland Hoff
7373af0417 Select random theme for new articles client side 2018-10-05 11:19:47 +02:00
Magnus Hovland Hoff
65ad262bd8 Bugfix.
Cancel button is always disabled and shown conditionally
2018-10-05 11:03:41 +02:00
Magnus Hovland Hoff
cffcc93b15 Add tests for automatically slugged links 2018-10-03 22:35:47 +02:00
Magnus Hovland Hoff
575c18f915 Support automatically slugged links 2018-10-03 18:24:55 +02:00
Magnus Hovland Hoff
6d1c9967aa Correctly mark unused variable 2018-10-03 18:12:27 +02:00
Magnus Hovland Hoff
748459483e Show author for diffs that show only one changeset 2018-10-02 08:44:06 +02:00
Magnus Hovland Hoff
01ceda8015 Use correct number for generating pagination link 2018-10-01 22:58:40 +02:00
Magnus Hoff
16384c9f83 Inconsequential change to trigger CI pipeline 2018-10-01 10:03:24 +02:00
Magnus Hovland Hoff
d5410f2a22 Revert focusing the body text editor when entering edit mode.
This behaves badly in multiple mobile browsers, causing confusion. Perhaps it is better to disable this feature altogether
2018-10-01 09:02:55 +02:00
Magnus Hovland Hoff
17c23da9bf Better scroll handling when toggling editing.
It will now retain the scroll position relative to the full document height when entering and leaving editing mode. This places the viewport closer to the contents you were watching. This is likely less disorienting
2018-10-01 09:01:27 +02:00
Magnus Hovland Hoff
619ba14b3f Fix editor controls layout in narrow layout 2018-10-01 08:36:44 +02:00
Magnus Hoff
58283a601c Also disable form elements via CSS pointer-events when not in edit mode.
This lets us visually transition the controls without accidentally enabling interaction
2018-09-30 22:33:40 +02:00
Magnus Hoff
c6dd37ed9e Centralize handling of form elements state handling.
This makes it easier to maintain state changes. Seems to handle all states well now
2018-09-30 22:30:52 +02:00
Magnus Hoff
85014d2789 Include theme in response when creating new article.
Bug caused by duplication of code.
2018-09-30 22:29:37 +02:00
Magnus Hovland Hoff
a81a568ee2 Forward the edit-state when redirecting to renamed articles.
This improves usability with noscript
2018-09-24 23:01:01 +02:00
Magnus Hovland Hoff
e92c9695be Fix editing of new articles with noscript.
This also generalizes the code. Neat!
2018-09-24 22:55:10 +02:00
Magnus Hovland Hoff
0439ca0d8e Avoid storing build cache in travis,
it costs more than we gain
2018-09-24 18:30:17 +02:00
Magnus Hovland Hoff
62378007b1 Reset theme when editing is canceled 2018-09-24 18:21:50 +02:00
Magnus Hoff
3bbe5840ee Implement theme picker UI 2018-09-24 08:43:36 +02:00
Magnus Hovland Hoff
baaab6ebc8 Store theme explicitly in database. Propagate theme both ways between db and frontend 2018-09-23 22:38:18 +02:00
Magnus Hovland Hoff
fe0011e757 Allow build.rs to figure out the correct database schema 2018-09-23 21:39:54 +02:00
Magnus Hovland Hoff
828490df3b Update serde_urlencoded dependency for bugfix 2018-09-23 21:39:09 +02:00
Magnus Hovland Hoff
c1fcc80cf0 Minor fix for making Theme insertable with Diesel 2018-09-23 21:38:17 +02:00
Magnus Hovland Hoff
8f1e95bdde Add theme to ArticleRevisionStub, RebaseResult and related.
This paves the way for explicitly storing the theme in the database
2018-09-22 23:16:58 +02:00
Magnus Hovland Hoff
c82228f019 Propagate theme in ArticleRevisions from the state struct 2018-09-21 08:57:35 +02:00
Magnus Hovland Hoff
b777a92a48 Expose theme_from_str_hash to SQL.
To be used in a db migration for storing the previously implicit value
2018-09-20 23:17:25 +02:00
Magnus Hovland Hoff
ca1e072d9b Add more tests for using Theme with the database 2018-09-20 08:37:27 +02:00
Magnus Hovland Hoff
df066c611d Add support for using Theme with the database 2018-09-19 22:56:12 +02:00
Magnus Hovland Hoff
f961699f0f Rename function to avoid confusion 2018-09-19 08:20:43 +02:00
Magnus Hovland Hoff
6118f14bb0 Explicitly set theme for layout.
Refactoring in anticipation of letting the user choose theme
2018-09-18 23:11:25 +02:00
Magnus Hovland Hoff
c1dcb1de64 Make responsibility for converting a theme to a css class to the theme module 2018-09-18 19:43:33 +02:00
Magnus Hovland Hoff
d4e8277f2a Dependency for previous commit 2018-09-18 19:21:36 +02:00
Magnus Hovland Hoff
a65e85f242 Refactor Theme handling to a new module.
Fundamentals for communicating about themes with the database and over http
2018-09-18 07:56:58 +02:00
Magnus Hovland Hoff
ecf4c1e98e Disable save hotkey when it is not appropriate 2018-09-16 22:31:57 +02:00
Magnus Hovland Hoff
534dffdfe3 Show disabled cancel button instead of removing it.
This fixes two problems caused by the removal of the button: Broken layout and broken JS
2018-09-16 22:25:28 +02:00
Magnus Hovland Hoff
999253a778 Place text caret at end of text to reduce visual distraction caused by stuff moving around 2018-09-16 12:16:03 +02:00
Magnus Hovland Hoff
a00cdf6394 Position editor controls with units that are affected by the scroll bar 2018-09-16 12:10:02 +02:00
Magnus Hovland Hoff
8d86e8937a Capitalize placeholder in search input.
I think I like this better
2018-09-16 12:06:20 +02:00
Magnus Hovland Hoff
830f641167 Fine-tune margin 2018-09-13 08:48:09 +02:00
Magnus Hoff
d6e1015197 Reset style for Safari to make transition between view and edit more seamless 2018-09-11 19:53:14 +02:00
Magnus Hoff
9c67333b87 Wider top margin for better visual balance 2018-09-11 19:19:22 +02:00
Magnus Hovland Hoff
7b1a0256e1 Add rainbow bar to test-themes 2018-08-31 21:30:46 +02:00
Magnus Hovland Hoff
94db59c44c Add hotkey for saving 2018-08-22 08:14:25 +02:00
Magnus Hovland Hoff
42e7857fcd Revert "Attempt to improve overscroll in Apple browsers"
This reverts commit 0847cb5c4d.
2018-08-21 21:57:40 +02:00
Magnus Hovland Hoff
0847cb5c4d Attempt to improve overscroll in Apple browsers 2018-08-21 19:06:53 +02:00
Magnus Hovland Hoff
b8da0ff753 Fix breakpoint size for editor controls 2018-08-21 19:02:38 +02:00
Magnus Hovland Hoff
096da6ef38 Iterate on editor controls design 2018-08-21 18:40:44 +02:00
Magnus Hoff
c94bf91fc2 Iterate on editor controls styling 2018-08-20 23:25:03 +02:00
Magnus Hoff
b93c79c479 Update styling of .notice 2018-08-20 22:47:52 +02:00
Magnus Hoff
b8a4368219 Improve contrast for placeholder text in search box 2018-08-20 22:32:04 +02:00
Magnus Hoff
8500075357 Update styling of hr 2018-08-20 22:31:35 +02:00
52 changed files with 3548 additions and 2085 deletions

View file

@ -19,21 +19,6 @@ script:
- strip -s target/x86_64-unknown-linux-musl/release/sausagewiki
- XZ_OPT=-9 tar Jcf sausagewiki.tar.xz -C target/x86_64-unknown-linux-musl/release/ sausagewiki
deploy:
provider: releases
api_key:
secure: NmM+uk4ijbv5wFF3O7w9KLTrGYbe1mxWAzJDR8cD9rimgORWNQKlHOZtthAQxVgtvmhKAMkzwglgQSX3p0w4yGK5oaV3oO1aA21dzlf0BXL7/BOxgYSTjV+x8O1uIu57ERnf4k2WATCpLSx4r4LpfxMgjdEtIl6LDAnV/zX+HKu7pZAzXvmkS22m5CJbEY4M6DpCAIfpP99dolnyU7h5/AR1njMmzSqGB/naVe5O2j0sBveInsjC+4gPSh9QT/VHZBxbOctcy+kSzwN4iDktkFdYIGe9Z2sDjXsiI39ihXntyOHXA2iVpdkgpIGeLIYBOo+DobgMdS45CzZQ2y9zLnwXwODCgrh8qexxnRpC8RG7uKuVe50R6v4HDPgkjwCJoHicxaEUDiPIsg5qCxEfMYd5qUt21OwEwBN9N8K/RZD0fmgKLE5lQiyxubufeSB4wjpWrXct2M46t25qPFobbZ0kzLCXtZHtKk1mkkk+EWv8UOhRvJ8ih0Fb9ivSOrN6YA1/eRd9/SRntkJriMYmfAW50W3DnyFnPHqdV+x+jHJgcB+DnaDvQnPamk93ZDF/UyUDjVuPJFd0BAFxoRUy6HGaF/yajH4r9g3EdlfSu2IrGDo4vIA9qawBYpHyaSGvYwdCDx4/oUPIAf8sLBS01WOaDJgcmmFey7A/OqSEt6Q=
file: sausagewiki.tar.xz
skip_cleanup: true
on:
repo: maghoff/sausagewiki
branch: master
rust: stable
cache: cargo
before_cache:
- chmod -R a+r $HOME/.cargo
branches:
except:
- "/^untagged-/"

1565
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -4,31 +4,32 @@ description = "A wiki engine"
license = "GPL-3.0"
name = "sausagewiki"
version = "0.1.0-dev"
edition = "2018"
[build-dependencies]
quote = "0.3.10"
quote = "1.0.17"
walkdir = "1"
[build-dependencies.diesel]
default-features = false
features = ["sqlite", "chrono"]
version = "1.3.0"
version = "1.4.8"
[build-dependencies.diesel_migrations]
default-features = false
features = ["sqlite"]
version = "1.3.0"
version = "1.4.0"
[dependencies]
bart = "0.1.4"
bart_derive = "0.1.4"
bart = "0.1.6"
bart_derive = "0.1.6"
chrono = "0.4"
clap = "2.31"
diff = "0.1"
futures = "0.1"
futures-cpupool = "0.1"
hyper = "0.11"
lazy_static = "0.2"
lazy_static = "1.4.0"
maplit = "1"
percent-encoding = "1.0"
r2d2 = "0.8"
@ -38,12 +39,14 @@ seahash = "3.0.5"
serde = "1.0.0"
serde_derive = "1.0.0"
serde_json = "1.0"
serde_urlencoded = "0.5"
serde_urlencoded = "0.5.3"
slug = "0.1"
titlecase = "0.10"
tokio-io = "0.1"
tokio-proto = "0.1"
tokio-service = "0.1"
serde_plain = "0.3.0"
rand = "0.5.5"
[dependencies.codegen]
path = "libs/codegen"
@ -51,21 +54,21 @@ path = "libs/codegen"
[dependencies.diesel]
default-features = false
features = ["sqlite", "chrono"]
version = "1.3.0"
version = "1.4.8"
[dependencies.diesel_infer_schema]
default-features = false
features = ["sqlite"]
version = "1.3.0"
version = "1.4.0"
[dependencies.diesel_migrations]
default-features = false
features = ["sqlite"]
version = "1.3.0"
version = "1.4.0"
[dependencies.libsqlite3-sys]
features = ["bundled"]
version = "0.9.1"
version = "<0.23.0"
[dependencies.num]
default-features = false
@ -76,7 +79,7 @@ default-features = false
git = "https://github.com/maghoff/pulldown-cmark.git"
[dev-dependencies]
indoc = "0.2"
indoc = "1.0.4"
matches = "0.1"
[features]

View file

@ -1,3 +1,5 @@
"use strict";
function autosizeTextarea(textarea, shadow) {
shadow.style.width = textarea.clientWidth + "px";
shadow.value = textarea.value;
@ -6,16 +8,17 @@ function autosizeTextarea(textarea, shadow) {
function queryArgsFromForm(form) {
const items = [];
for (const {name, value} of form.elements) {
for (const {name, value, type, checked} of form.elements) {
if (!name) continue;
if (type === "radio" && !checked) continue;
items.push(encodeURIComponent(name) + '=' + encodeURIComponent(value));
}
return items.join('&');
}
function isEdited(form) {
for (const {name, value, defaultValue} of form.elements) {
if (name && (value !== defaultValue)) return true;
for (const {name, value, defaultValue, checked, defaultChecked} of form.elements) {
if (name && ((value !== defaultValue) || (checked !== defaultChecked))) return true;
}
return false;
}
@ -56,8 +59,15 @@ function confirmDiscard() {
return popup(instantiate("confirm-discard"));
}
let hasBeenOpen = false;
const state = {
hasBeenOpen: false,
saving: false,
editing: function () { return document.querySelector(".container").classList.contains('edit'); },
hasCancelUrl: function () { return document.querySelector("a.button-cancel").getAttribute('href') !== ""; }
};
function openEditor() {
const bodyElement = document.querySelector("body");
const container = document.querySelector(".container");
const rendered = container.querySelector(".rendered");
const editor = container.querySelector(".editor");
@ -65,31 +75,61 @@ function openEditor() {
const shadow = editor.querySelector('textarea.shadow-control');
const form = document.getElementById('article-editor');
const cancel = form.querySelector('.cancel');
const cancelButton = form.querySelector('button.button-cancel');
const cancelInteractionGroup = form.querySelector(".cancel-interaction-group");
const footer = document.querySelector("footer");
const lastUpdated = footer.querySelector(".last-updated");
textarea.style.height = rendered.clientHeight + "px";
retainScrollRatio(() => {
container.classList.add('edit');
autosizeTextarea(textarea, shadow);
});
updateFormEnabledState();
textarea.focus();
if (hasBeenOpen) return;
hasBeenOpen = true;
if (state.hasBeenOpen) return;
state.hasBeenOpen = true;
textarea.addEventListener('input', () => autosizeTextarea(textarea, shadow));
window.addEventListener('resize', () => autosizeTextarea(textarea, shadow));
form.addEventListener("submit", function (ev) {
ev.preventDefault();
ev.stopPropagation();
function updateFormEnabledState() {
const baseEnabled = !state.saving && state.editing();
const enabled = {
cancel: baseEnabled && state.hasCancelUrl(),
};
cancelInteractionGroup.classList.remove(!enabled.cancel ? "interaction-group--root--enabled" : "interaction-group--root--disabled");
cancelInteractionGroup.classList.add(enabled.cancel ? "interaction-group--root--enabled" : "interaction-group--root--disabled");
for (const el of form.elements) {
el.disabled = !baseEnabled;
}
cancelButton.disabled = true;
// TODO: edit-link in footer?
}
function retainScrollRatio(innerFunction) {
const scrollElement = document.body.parentElement;
const savedScrollRatio = scrollElement.scrollTop / (scrollElement.scrollHeight - scrollElement.clientHeight);
innerFunction();
scrollElement.scrollTop = (scrollElement.scrollHeight - scrollElement.clientHeight) * savedScrollRatio;
}
function closeEditor() {
retainScrollRatio(() => container.classList.remove('edit'));
document.activeElement && document.activeElement.blur();
}
function doSave() {
state.saving = true;
updateFormEnabledState();
const body = queryArgsFromForm(form);
textarea.disabled = true;
// TODO Disable other interaction as well: title editor, cancel and OK buttons
fetch(
form.getAttribute("action"),
@ -109,7 +149,8 @@ function openEditor() {
if (probablyLoginRedirect) {
return loginDialog(response.url)
.then(() => {
textarea.disabled = false;
state.saving = false;
updateFormEnabledState();
});
}
@ -117,8 +158,10 @@ function openEditor() {
return response.json()
.then(result => {
// Update url-bar, page title and footer
window.history.replaceState(null, result.title, result.slug == "" ? "." : result.slug);
// Update url-bar, page title, footer and cancel link
const url = result.slug == "" ? "." : result.slug;
window.history.replaceState(null, result.title, url);
cancel.setAttribute("href", url);
document.querySelector("title").textContent = result.title;
lastUpdated.innerHTML = result.last_updated;
lastUpdated.classList.remove("missing");
@ -129,17 +172,22 @@ function openEditor() {
form.elements.title.value = result.title;
shadow.value = textarea.value = result.body;
form.querySelector(`.theme-picker--option[value=${JSON.stringify(result.theme)}]`).checked = true;
bodyElement.className = `theme-${result.theme}`;
// Update form:
form.elements.base_revision.value = result.revision;
for (const element of form.elements) {
element.defaultValue = element.value;
element.defaultChecked = element.checked;
}
if (!result.conflict) {
container.classList.remove('edit');
closeEditor();
}
textarea.disabled = false;
state.saving = false;
updateFormEnabledState();
autosizeTextarea(textarea, shadow);
if (result.conflict) {
@ -149,23 +197,37 @@ function openEditor() {
}
});
}).catch(err => {
textarea.disabled = false;
state.saving = false;
updateFormEnabledState();
console.error(err);
return alertAsync(err.toString());
});
}
function doCancel() {
Promise.resolve(!isEdited(form) || confirmDiscard())
.then(doReset => {
if (doReset) {
closeEditor();
updateFormEnabledState();
form.reset();
let selectedTheme = form.querySelector(`.theme-picker--option[checked]`).value;
bodyElement.className = `theme-${selectedTheme}`;
}
});
}
form.addEventListener("submit", function (ev) {
ev.preventDefault();
ev.stopPropagation();
doSave();
});
cancel.addEventListener('click', function (ev) {
ev.preventDefault();
ev.stopPropagation();
Promise.resolve(!isEdited(form) || confirmDiscard())
.then(doReset => {
if (doReset) {
container.classList.remove('edit');
form.reset();
}
});
doCancel();
});
window.addEventListener("beforeunload", function (ev) {
@ -174,8 +236,43 @@ function openEditor() {
return ev.returnValue = "Discard changes?";
}
});
document.addEventListener("keypress", function (ev) {
const accel = ev.ctrlKey || ev.metaKey; // Imprecise, but works cross platform
if (ev.key === "Enter" && accel) {
if (!state.editing()) return;
ev.stopPropagation();
ev.preventDefault();
doSave();
}
});
const themeOptions = form.querySelectorAll(".theme-picker--option");
for (let themeOption of themeOptions) {
themeOption.addEventListener("click", function (ev) {
bodyElement.className = `theme-${ev.target.value}`;
});
}
}
function initializeTheme() {
const form = document.getElementById('article-editor');
let preSelectedTheme = form.querySelector(`.theme-picker--option[checked]`);
if (preSelectedTheme) return;
let themes = form.querySelectorAll(`.theme-picker--option`);
let randomThemeId = (Math.random() * themes.length) | 0;
let theme = themes[randomThemeId];
theme.defaultChecked = theme.checked = true;
document.querySelector("body").className = `theme-${theme.value}`;
}
initializeTheme();
document
.getElementById("openEditor")
.addEventListener("click", function (ev) {

View file

@ -2,6 +2,10 @@
display: none;
}
input {
margin: 0; /* reset for Safari */
}
html {
font-family: "Apple Garamond", "Baskerville",
"Times New Roman", "Droid Serif", "Times",
@ -54,15 +58,16 @@ h1+*, h2+*, h3+*, h4+*, h5+*, h6+* {
article>hr {
border: none;
border-top: 1px solid black;
max-width: 400px;
width: 70%;
border-top: 6px solid var(--theme-main);
width: 40px;
margin: 20px auto;
}
.notice {
background: lightyellow;
padding: 16px 48px;
background: var(--theme-main);
color: var(--theme-text);
padding: 1px 24px;
font-size: 18px;
line-height: 32px;
@ -71,6 +76,9 @@ article>hr {
width: 100%;
margin: 30px auto;
}
.notice a {
color: var(--theme-link);
}
.hero {
background: var(--theme-main);
@ -301,29 +309,146 @@ h1>input {
bottom: 0;
left: 0;
box-sizing: border-box;
text-align: right;
box-shadow: 0px 5px 20px rgba(0,0,0, 0.2);
background: var(--theme-main);
background: white;
color: var(--theme-text);
padding: 10px 20px;
padding: 10px 10px;
transform: translate(0, 65px);
transition: transform 100ms;
transition-timing-function: linear;
pointer-events: none;
}
.edit .editor-controls {
transform: translate(0, 0);
transition-timing-function: cubic-bezier(.17,.84,.44,1);
pointer-events: unset;
}
@media (min-width: 630px) {
.editor-controls {
position: fixed;
left: calc(50vw + 320px);
.theme-picker {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
}
.theme-picker--option {
/* reset */
-webkit-appearance: none;
-moz-appearance: none;
-o-appearance: none;
-ms-appearance: none;
appearance: none;
border: none;
border-radius: 0;
margin: 0;
padding: 0;
height: 20px;
background: var(--theme-main);
color: var(--theme-text);
flex-grow: 1;
position: relative;
}
.theme-picker--option:checked::after {
content: " ";
display: block;
background: white;
border-radius: 5px;
width: 10px;
height: 10px;
position: absolute;
top: calc(50% - 5px);
left: calc(50% - 5px);
}
.button {
border-radius: 2px;
display: inline-block;
width: 120px;
top: calc(50vh - 40px);
height: 80px;
text-align: center;
border: none;
cursor: pointer;
font-family: -apple-system, BlinkMacSystemFont,
"Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans",
"Droid Sans", "Helvetica Neue", sans-serif;
font-size: 16px;
line-height: 20px;
padding: 10px 0px;
margin-left: 10px;
}
.button[disabled] {
opacity: 0.5;
cursor: default;
}
.button:hover {
text-decoration: none;
}
.button:not([disabled]):hover, .button:not([disabled]):active {
background: var(--button-alt);
}
.button-cancel {
background: white;
color: var(--theme-main);
--button-alt: #f0f0f0;
}
.button-default {
background: var(--theme-main);
color: var(--theme-text);
--button-alt: var(--theme-input);
}
.cancel-interaction-group {
display: inline;
}
.interaction-group--root--enabled .interaction-group--disabled {
display: none;
}
.interaction-group--root--disabled .interaction-group--enabled {
display: none;
}
@media (min-width: 960px) {
/* min-width is calculated like this:
body-width = width of the main text column
controls-width = width of .editor-controls element, including drop-shadow
min-width = body-width + 2*controls-width = 600 + 2 * 180 = 960
*/
.editor-controls {
border-radius: 2px;
position: fixed;
left: calc(50% + 320px);
width: 140px;
top: calc(50% - 55px);
height: 110px;
padding: 10px;
transform: translate(20px, 0);
opacity: 0;
@ -337,6 +462,11 @@ h1>input {
transition-timing-function: cubic-bezier(.17,.84,.44,1);
opacity: 1;
}
.button {
margin: 0;
margin-bottom: 10px;
}
}
article ul.search-results {
@ -371,7 +501,7 @@ article ul.search-results {
.search {
text-align: center;
margin-top: 30px;
margin-top: 45px;
position: relative;
}
@ -402,9 +532,9 @@ input[type="search"] {
text-overflow: ellipsis;
}
input[type="search"]::placeholder {
input[type="search"]::placeholder, .hero input::placeholder {
color: var(--theme-text);
opacity: 0.5;
opacity: 0.6;
}
.search .live-results {
@ -586,6 +716,10 @@ input[type="search"]::placeholder {
margin: 0 auto;
}
article>hr {
border-color: black;
}
h1 {
font-size: 22pt;
line-height: 33pt;

View file

@ -16,9 +16,18 @@
.proto {
display: none;
}
#bar {
display: flex;
width: 100%;
}
#bar>div {
height: 30px;
flex-grow: 1;
}
</style>
</head>
<body>
<div id="bar"></div>
<div class="proto">
<div class="themed">The <span class="link">quick</span> brown <span class="link">dog</span> jumps over the lazy log <span class="theme-name"></span></div>
<div class="themed"><input type=search placeholder=placeholder> <input type=search value="Bacon"></div>
@ -33,6 +42,13 @@
block.querySelector(".theme-name").textContent = theme;
body.appendChild(block);
}
const bar = document.querySelector("#bar");
for (theme of themes) {
const block = document.createElement("div");
block.className = `theme-${theme} themed`;
bar.appendChild(block);
}
</script>
</body>
</html>

View file

@ -1,10 +1,9 @@
#[macro_use] extern crate quote;
#[macro_use] extern crate diesel;
extern crate diesel_migrations;
extern crate walkdir;
#[macro_use]
extern crate diesel;
use diesel::Connection;
use diesel::prelude::*;
use diesel::Connection;
use quote::quote;
use std::env;
use std::fs::File;
use std::io::prelude::*;
@ -15,31 +14,40 @@ use walkdir::WalkDir;
mod sqlfunc {
use diesel::sql_types::Text;
sql_function!(fn markdown_to_fts(text: Text) -> Text);
sql_function!(fn theme_from_str_hash(text: Text) -> Text);
}
fn main() {
let out_dir = env::var("OUT_DIR").expect("cargo must set OUT_DIR");
let db_path = Path::new(&out_dir).join("build.db");
let db_path = db_path.to_str().expect("Will only work for Unicode-representable paths");
let db_path = db_path
.to_str()
.expect("Will only work for Unicode-representable paths");
let _ignore_failure = std::fs::remove_file(db_path);
let connection = SqliteConnection::establish(db_path)
.expect(&format!("Error esablishing a database connection to {}", db_path));
.unwrap_or_else(|_| panic!("Error esablishing a database connection to {}", db_path));
// Integer is a dummy placeholder. Compiling fails when passing ().
diesel::expression::sql_literal::sql::<(diesel::sql_types::Integer)>("PRAGMA foreign_keys = ON")
diesel::expression::sql_literal::sql::<diesel::sql_types::Integer>("PRAGMA foreign_keys = ON")
.execute(&connection)
.expect("Should be able to enable foreign keys");
sqlfunc::markdown_to_fts::register_impl(&connection, |_: String| -> String { unreachable!() }).unwrap();
sqlfunc::markdown_to_fts::register_impl(&connection, |_: String| -> String { unreachable!() })
.unwrap();
sqlfunc::theme_from_str_hash::register_impl(&connection, |_: String| -> String {
unreachable!()
})
.unwrap();
diesel_migrations::run_pending_migrations(&connection).unwrap();
let infer_schema_path = Path::new(&out_dir).join("infer_schema.rs");
let mut file = File::create(infer_schema_path).expect("Unable to open file for writing");
file.write_all(quote! {
file.write_all(
quote! {
mod __diesel_infer_schema_articles {
infer_table_from_schema!(#db_path, "articles");
}
@ -49,18 +57,21 @@ fn main() {
infer_table_from_schema!(#db_path, "article_revisions");
}
pub use self::__diesel_infer_schema_article_revisions::*;
}.as_str().as_bytes()).expect("Unable to write to file");
}
.to_string()
.as_bytes(),
)
.expect("Unable to write to file");
for entry in WalkDir::new("migrations").into_iter().filter_map(|e| e.ok()) {
for entry in WalkDir::new("migrations")
.into_iter()
.filter_map(|e| e.ok())
{
println!("cargo:rerun-if-changed={}", entry.path().display());
}
// For build_config.rs
for env_var in &[
"CONTINUOUS_INTEGRATION",
"TRAVIS_BRANCH",
"TRAVIS_COMMIT",
] {
for env_var in &["CONTINUOUS_INTEGRATION", "TRAVIS_BRANCH", "TRAVIS_COMMIT"] {
println!("cargo:rerun-if-env-changed={}", env_var);
}
}

View file

@ -1,11 +1,13 @@
#![recursion_limit="128"]
#![recursion_limit = "128"]
#[macro_use] extern crate quote;
#[macro_use] extern crate serde_derive;
#[macro_use]
extern crate quote;
#[macro_use]
extern crate serde_derive;
extern crate base64;
extern crate proc_macro;
extern crate serde_json;
extern crate serde;
extern crate serde_json;
extern crate sha2;
extern crate syn;

View file

@ -2,13 +2,10 @@ use std::fs::File;
use proc_macro::TokenStream;
use quote;
use serde_json;
use serde::de::IgnoredAny;
use serde_json;
const SOURCES: &[&str] = &[
"src/licenses/license-hound.json",
"src/licenses/other.json",
];
const SOURCES: &[&str] = &["src/licenses/license-hound.json", "src/licenses/other.json"];
#[derive(Debug, Copy, Clone, Deserialize)]
pub enum LicenseId {
@ -22,7 +19,7 @@ impl LicenseId {
fn include_notice(&self) -> bool {
use self::LicenseId::*;
match self {
&Mpl2 => false,
Mpl2 => false,
_ => true,
}
}
@ -32,10 +29,10 @@ impl quote::ToTokens for LicenseId {
fn to_tokens(&self, tokens: &mut quote::Tokens) {
use self::LicenseId::*;
tokens.append(match self {
&Bsd3Clause => "Bsd3Clause",
&Mit => "Mit",
&Mpl2 => "Mpl2",
&Ofl11 => "Ofl11",
Bsd3Clause => "Bsd3Clause",
Mit => "Mit",
Mpl2 => "Mpl2",
Ofl11 => "Ofl11",
});
}
}
@ -56,12 +53,16 @@ struct LicenseReport {
impl quote::ToTokens for LicenseReport {
fn to_tokens(&self, tokens: &mut quote::Tokens) {
let c: &LicenseDescription = self.conclusion.as_ref().unwrap();
let (name, link, copyright, license) =
(&self.package_name, &c.link, &c.copyright_notice, &c.chosen_license);
let (name, link, copyright, license) = (
&self.package_name,
&c.link,
&c.copyright_notice,
&c.chosen_license,
);
let link = match link {
&Some(ref link) => quote! { Some(#link) },
&None => quote! { None },
let link = match *link {
Some(ref link) => quote! { Some(#link) },
None => quote! { None },
};
let copyright = match license.include_notice() {
@ -85,7 +86,10 @@ pub fn licenses(_input: TokenStream) -> TokenStream {
.iter()
.map(|x| -> Vec<LicenseReport> { serde_json::from_reader(File::open(x).unwrap()).unwrap() })
.map(|x| x.into_iter().filter(|x| x.conclusion.is_ok()))
.fold(vec![], |mut a, b| { a.extend(b); a });
.fold(vec![], |mut a, b| {
a.extend(b);
a
});
license_infos.sort_unstable_by_key(|x| x.package_name.to_lowercase());

View file

@ -10,30 +10,29 @@ fn user_crate_root() -> PathBuf {
std::env::current_dir().expect("Unable to get current directory")
}
fn find_attr<'a>(attrs: &'a Vec<syn::Attribute>, name: &str) -> Option<&'a str> {
attrs.iter()
fn find_attr<'a>(attrs: &'a [syn::Attribute], name: &str) -> Option<&'a str> {
attrs
.iter()
.find(|&x| x.name() == name)
.and_then(|ref attr| match &attr.value {
&syn::MetaItem::NameValue(_, syn::Lit::Str(ref template, _)) => Some(template),
_ => None
.and_then(|attr| match attr.value {
syn::MetaItem::NameValue(_, syn::Lit::Str(ref template, _)) => Some(template),
_ => None,
})
.map(|x| x.as_ref())
}
fn buf_file<P: AsRef<Path>>(filename: P) -> Vec<u8> {
let mut f = File::open(filename)
.expect("Unable to open file for reading");
let mut f = File::open(filename).expect("Unable to open file for reading");
let mut buf = Vec::new();
f.read_to_end(&mut buf)
.expect("Unable to read file");
f.read_to_end(&mut buf).expect("Unable to read file");
buf
}
fn calculate_checksum<P: AsRef<Path>>(filename: P) -> String {
use base64::*;
use sha2::{Sha256, Digest};
use sha2::{Digest, Sha256};
encode_config(&Sha256::digest(&buf_file(filename)), URL_SAFE)
}
@ -42,23 +41,24 @@ pub fn static_resource(input: TokenStream) -> TokenStream {
let s = input.to_string();
let ast = syn::parse_macro_input(&s).unwrap();
let filename = find_attr(&ast.attrs, "filename")
.expect("The `filename` attribute must be specified");
let filename =
find_attr(&ast.attrs, "filename").expect("The `filename` attribute must be specified");
let abs_filename = user_crate_root().join(filename);
let abs_filename = abs_filename.to_str().expect("Absolute file path must be valid Unicode");
let abs_filename = abs_filename
.to_str()
.expect("Absolute file path must be valid Unicode");
let checksum = calculate_checksum(&abs_filename);
let path: &Path = filename.as_ref();
let resource_name =
format!("{}-{}.{}",
let resource_name = format!(
"{}-{}.{}",
path.file_stem().unwrap().to_str().unwrap(),
checksum,
path.extension().unwrap().to_str().unwrap()
);
let mime = find_attr(&ast.attrs, "mime")
.expect("The `mime` attribute must be specified");
let mime = find_attr(&ast.attrs, "mime").expect("The `mime` attribute must be specified");
let name = &ast.ident;
let (impl_generics, ty_generics, where_clause) = ast.generics.split_for_impl();

View file

@ -0,0 +1,7 @@
ALTER TABLE article_revisions ADD COLUMN theme TEXT NOT NULL CHECK (theme IN (
'red', 'pink', 'purple', 'deep-purple', 'indigo', 'blue', 'light-blue',
'cyan', 'teal', 'green', 'light-green', 'lime', 'yellow', 'amber',
'orange', 'deep-orange', 'brown', 'gray', 'blue-gray'
)) DEFAULT 'red';
UPDATE article_revisions SET theme=theme_from_str_hash(title);

View file

@ -72,3 +72,4 @@ Command line arguments
Sausagewiki will create an SQLite database file with the filename given in the
`DATABASE` parameter and open an HTTP server bound to the configured address,
`<address>:<port>`.

View file

@ -1,8 +1,8 @@
#[cfg(not(feature="dynamic-assets"))]
#[cfg(not(feature = "dynamic-assets"))]
mod static_assets {
use std::collections::HashMap;
use crate::web::{Resource, ResponseFuture};
use futures::Future;
use web::{Resource, ResponseFuture};
use std::collections::HashMap;
// The CSS should be built to a single CSS file at compile time
#[derive(StaticResource)]
@ -32,8 +32,8 @@ mod static_assets {
// #[mime = "application/font-woff"]
// pub struct AmaticFont;
type BoxResource = Box<Resource + Sync + Send>;
type ResourceFn = Box<Fn() -> BoxResource + Sync + Send>;
type BoxResource = Box<dyn Resource + Sync + Send>;
type ResourceFn = Box<dyn Fn() -> BoxResource + Sync + Send>;
lazy_static! {
pub static ref ASSETS_MAP: HashMap<&'static str, ResourceFn> = hashmap!{
// The CSS should be built to a single CSS file at compile time
@ -52,31 +52,39 @@ mod static_assets {
}
}
#[cfg(not(feature="dynamic-assets"))]
#[cfg(not(feature = "dynamic-assets"))]
pub use self::static_assets::*;
#[cfg(feature="dynamic-assets")]
#[cfg(feature = "dynamic-assets")]
mod dynamic_assets {
pub struct ThemesCss;
impl ThemesCss {
pub fn resource_name() -> &'static str { "themes.css" }
pub fn resource_name() -> &'static str {
"themes.css"
}
}
pub struct StyleCss;
impl StyleCss {
pub fn resource_name() -> &'static str { "style.css" }
pub fn resource_name() -> &'static str {
"style.css"
}
}
pub struct ScriptJs;
impl ScriptJs {
pub fn resource_name() -> &'static str { "script.js" }
pub fn resource_name() -> &'static str {
"script.js"
}
}
pub struct SearchJs;
impl SearchJs {
pub fn resource_name() -> &'static str { "search.js" }
pub fn resource_name() -> &'static str {
"search.js"
}
}
}
#[cfg(feature="dynamic-assets")]
#[cfg(feature = "dynamic-assets")]
pub use self::dynamic_assets::*;

View file

@ -7,12 +7,12 @@ pub const PROJECT_NAME: &str = env!("CARGO_PKG_NAME");
const SOFT_HYPHEN: &str = "\u{00AD}";
#[cfg(all(not(debug_assertions), feature="dynamic-assets"))]
#[cfg(all(not(debug_assertions), feature = "dynamic-assets"))]
compile_error!("dynamic-assets must not be used for production");
lazy_static! {
pub static ref VERSION: String = || -> String {
let mut components = Vec::<String>::new();
let mut components = vec![];
#[cfg(debug_assertions)]
components.push("debug".into());
@ -20,10 +20,10 @@ lazy_static! {
#[cfg(test)]
components.push("test".into());
#[cfg(feature="dynamic-assets")]
#[cfg(feature = "dynamic-assets")]
components.push("dynamic-assets".into());
if let None = option_env!("CONTINUOUS_INTEGRATION") {
if option_env!("CONTINUOUS_INTEGRATION").is_none() {
components.push("local-build".into());
}
@ -32,26 +32,22 @@ lazy_static! {
}
if let Some(commit) = option_env!("TRAVIS_COMMIT") {
components.push(format!("commit:{}",
components.push(format!(
"commit:{}",
commit
.as_bytes()
.chunks(4)
.map(|x|
String::from_utf8(x.to_owned())
.unwrap_or_else(|_| String::new())
)
.map(|x| String::from_utf8(x.to_owned()).unwrap_or_else(|_| String::new()))
.collect::<Vec<_>>()
.join(SOFT_HYPHEN)
));
}
if components.len() > 0 {
if !components.is_empty() {
format!("{} ({})", env!("CARGO_PKG_VERSION"), components.join(" "))
} else {
env!("CARGO_PKG_VERSION").to_string()
}
}();
pub static ref HTTP_SERVER: String =
format!("{}/{}", PROJECT_NAME, VERSION.as_str());
pub static ref HTTP_SERVER: String = format!("{}/{}", PROJECT_NAME, VERSION.as_str());
}

View file

@ -1,10 +1,11 @@
use diesel::prelude::*;
use diesel::expression::sql_literal::sql;
use diesel::prelude::*;
use diesel::sql_types::*;
use r2d2::{CustomizeConnection, Pool};
use r2d2_diesel::{self, ConnectionManager};
use rendering;
use crate::rendering;
use crate::theme;
embed_migrations!();
@ -12,27 +13,35 @@ embed_migrations!();
struct SqliteInitializer;
#[allow(dead_code)]
mod sqlfunc {
pub mod sqlfunc {
use diesel::sql_types::Text;
sql_function!(fn markdown_to_fts(text: Text) -> Text);
sql_function!(fn theme_from_str_hash(text: Text) -> Text);
}
impl CustomizeConnection<SqliteConnection, r2d2_diesel::Error> for SqliteInitializer {
fn on_acquire(&self, conn: &mut SqliteConnection) -> Result<(), r2d2_diesel::Error> {
sql::<(Integer)>("PRAGMA foreign_keys = ON")
sql::<Integer>("PRAGMA foreign_keys = ON")
.execute(conn)
.map_err(|x| r2d2_diesel::Error::QueryError(x))?;
.map_err(r2d2_diesel::Error::QueryError)?;
sqlfunc::markdown_to_fts::register_impl(
conn,
|text: String| rendering::render_markdown_for_fts(&text)
).map_err(|x| r2d2_diesel::Error::QueryError(x))?;
sqlfunc::markdown_to_fts::register_impl(conn, |text: String| {
rendering::render_markdown_for_fts(&text)
})
.map_err(r2d2_diesel::Error::QueryError)?;
sqlfunc::theme_from_str_hash::register_impl(conn, |title: String| {
theme::theme_from_str_hash(&title)
})
.map_err(r2d2_diesel::Error::QueryError)?;
Ok(())
}
}
pub fn create_pool<S: Into<String>>(connection_string: S) -> Result<Pool<ConnectionManager<SqliteConnection>>, Box<::std::error::Error>> {
pub fn create_pool<S: Into<String>>(
connection_string: S,
) -> Result<Pool<ConnectionManager<SqliteConnection>>, Box<dyn (::std::error::Error)>> {
let manager = ConnectionManager::<SqliteConnection>::new(connection_string);
let pool = Pool::builder()
.connection_customizer(Box::new(SqliteInitializer {}))
@ -53,3 +62,48 @@ pub fn test_connection() -> SqliteConnection {
conn
}
#[cfg(test)]
mod test {
use super::*;
use diesel::sql_query;
#[test]
fn markdown_to_fts() {
let conn = test_connection();
#[derive(QueryableByName, PartialEq, Eq, Debug)]
struct Row {
#[sql_type = "Text"]
text: String,
}
let res = sql_query("SELECT markdown_to_fts('[link](url)') as text")
.load::<Row>(&conn)
.unwrap();
let expected = rendering::render_markdown_for_fts("[link](url)");
assert_eq!(expected, res[0].text);
}
#[test]
fn theme_from_str_hash() {
let conn = test_connection();
#[derive(QueryableByName, PartialEq, Eq, Debug)]
struct Row {
#[sql_type = "Text"]
theme: theme::Theme,
}
let res = sql_query("SELECT theme_from_str_hash('Bartefjes') as theme")
.load::<Row>(&conn)
.unwrap();
let expected = theme::theme_from_str_hash("Bartefjes");
assert_eq!(expected, res[0].theme);
}
}

View file

@ -1,32 +1,33 @@
#![recursion_limit="128"] // for diesel's infer_schema!
#![allow(clippy::into_iter_on_ref)]
#![allow(clippy::vec_init_then_push)]
#![recursion_limit = "128"]
// for diesel's infer_schema!
#[cfg(test)] #[macro_use] extern crate matches;
#[cfg(test)] #[macro_use] extern crate indoc;
#[macro_use] extern crate bart_derive;
#[macro_use] extern crate codegen;
#[macro_use] #[allow(deprecated)] extern crate diesel_infer_schema;
#[macro_use] extern crate diesel_migrations;
#[macro_use] extern crate diesel;
#[macro_use] extern crate hyper;
#[macro_use] extern crate lazy_static;
#[macro_use] extern crate maplit;
#[macro_use] extern crate serde_derive;
extern crate chrono;
extern crate diff;
extern crate futures_cpupool;
extern crate futures;
extern crate percent_encoding;
extern crate pulldown_cmark;
extern crate r2d2_diesel;
extern crate r2d2;
extern crate seahash;
extern crate serde_json;
extern crate serde_urlencoded;
extern crate serde;
extern crate slug;
extern crate titlecase;
#[cfg(test)]
#[macro_use]
extern crate matches;
#[macro_use]
extern crate bart_derive;
#[macro_use]
extern crate codegen;
#[macro_use]
#[allow(clippy::useless_attribute)]
#[allow(deprecated)]
extern crate diesel_infer_schema;
#[macro_use]
extern crate diesel_migrations;
#[macro_use]
extern crate diesel;
#[macro_use]
extern crate hyper;
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate maplit;
#[macro_use]
extern crate serde_derive;
#[macro_use]
extern crate serde_plain;
use std::net::{IpAddr, SocketAddr};
@ -41,22 +42,26 @@ mod resources;
mod schema;
mod site;
mod state;
mod theme;
mod web;
mod wiki_lookup;
pub fn main(db_file: String, bind_host: IpAddr, bind_port: u16, trust_identity: bool) -> Result<(), Box<std::error::Error>> {
pub fn main(
db_file: String,
bind_host: IpAddr,
bind_port: u16,
trust_identity: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let db_pool = db::create_pool(db_file)?;
let cpu_pool = futures_cpupool::CpuPool::new_num_cpus();
let state = state::State::new(db_pool, cpu_pool);
let lookup = wiki_lookup::WikiLookup::new(state, trust_identity);
let server =
hyper::server::Http::new()
.bind(
&SocketAddr::new(bind_host, bind_port),
move || Ok(site::Site::new(lookup.clone(), trust_identity))
)?;
let server = hyper::server::Http::new()
.bind(&SocketAddr::new(bind_host, bind_port), move || {
Ok(site::Site::new(lookup.clone(), trust_identity))
})?;
println!("Listening on http://{}", server.local_addr().unwrap());

View file

@ -1,11 +1,10 @@
#[macro_use] extern crate lazy_static;
extern crate clap;
extern crate sausagewiki;
#[macro_use]
extern crate lazy_static;
use std::net::IpAddr;
mod build_config;
use build_config::*;
use crate::build_config::*;
const DATABASE: &str = "DATABASE";
const TRUST_IDENTITY: &str = "trust-identity";
@ -18,52 +17,61 @@ fn args<'a>() -> clap::ArgMatches<'a> {
App::new(PROJECT_NAME)
.version(VERSION.as_str())
.about(env!("CARGO_PKG_DESCRIPTION"))
.arg(Arg::with_name(DATABASE)
.arg(
Arg::with_name(DATABASE)
.help("Sets the database file to use")
.required(true))
.arg(Arg::with_name(PORT)
.required(true),
)
.arg(
Arg::with_name(PORT)
.help("Sets the listening port")
.short("p")
.long(PORT)
.default_value("8080")
.validator(|x| match x.parse::<u16>() {
Ok(_) => Ok(()),
Err(_) => Err("Must be an integer in the range [0, 65535]".into())
Err(_) => Err("Must be an integer in the range [0, 65535]".into()),
})
.takes_value(true))
.arg(Arg::with_name(ADDRESS)
.takes_value(true),
)
.arg(
Arg::with_name(ADDRESS)
.help("Sets the IP address to bind to")
.short("a")
.long(ADDRESS)
.default_value("127.0.0.1")
.validator(|x| match x.parse::<IpAddr>() {
Ok(_) => Ok(()),
Err(_) => Err("Must be a valid IP address".into())
Err(_) => Err("Must be a valid IP address".into()),
})
.takes_value(true))
.arg(Arg::with_name(TRUST_IDENTITY)
.help("Trust the value in the X-Identity header to be an \
.takes_value(true),
)
.arg(
Arg::with_name(TRUST_IDENTITY)
.help(
"Trust the value in the X-Identity header to be an \
authenticated username. This only makes sense when Sausagewiki \
runs behind a reverse proxy which sets this header.")
.long(TRUST_IDENTITY))
runs behind a reverse proxy which sets this header.",
)
.long(TRUST_IDENTITY),
)
.get_matches()
}
fn main() -> Result<(), Box<std::error::Error>> {
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = args();
const CLAP: &str = "Guaranteed by clap";
const VALIDATOR: &str = "Guaranteed by clap validator";
let db_file = args.value_of(DATABASE).expect(CLAP).to_owned();
let bind_host = args.value_of(ADDRESS).expect(CLAP).parse().expect(VALIDATOR);
let bind_host = args
.value_of(ADDRESS)
.expect(CLAP)
.parse()
.expect(VALIDATOR);
let bind_port = args.value_of(PORT).expect(CLAP).parse().expect(VALIDATOR);
let trust_identity = args.is_present(TRUST_IDENTITY);
sausagewiki::main(
db_file,
bind_host,
bind_port,
trust_identity,
)
sausagewiki::main(db_file, bind_host, bind_port, trust_identity)
}

View file

@ -1,8 +1,7 @@
use std::fmt::Debug;
use diff;
#[derive(Debug, PartialEq)]
pub struct Chunk<'a, Item: 'a + Debug + PartialEq + Copy>(
pub &'a [diff::Result<Item>],
pub &'a [diff::Result<Item>]
pub &'a [diff::Result<Item>],
);

View file

@ -1,13 +1,12 @@
use std::fmt::Debug;
use diff;
use diff::Result::*;
use super::chunk::Chunk;
pub struct ChunkIterator<'a, Item>
where
Item: 'a + Debug + PartialEq
Item: 'a + Debug + PartialEq,
{
left: &'a [diff::Result<Item>],
right: &'a [diff::Result<Item>],
@ -15,16 +14,19 @@ where
impl<'a, Item> ChunkIterator<'a, Item>
where
Item: 'a + Debug + PartialEq + Eq
Item: 'a + Debug + PartialEq + Eq,
{
pub fn new(left: &'a [diff::Result<Item>], right: &'a [diff::Result<Item>]) -> ChunkIterator<'a, Item> {
pub fn new(
left: &'a [diff::Result<Item>],
right: &'a [diff::Result<Item>],
) -> ChunkIterator<'a, Item> {
ChunkIterator { left, right }
}
}
impl<'a, Item> Iterator for ChunkIterator<'a, Item>
where
Item: 'a + Debug + PartialEq + Copy
Item: 'a + Debug + PartialEq + Copy,
{
type Item = Chunk<'a, Item>;
@ -46,18 +48,18 @@ where
match (self.left.get(li), self.right.get(ri)) {
(Some(&Right(_)), _) => {
li += 1;
},
}
(_, Some(&Right(_))) => {
ri += 1;
},
}
(Some(&Left(_)), Some(_)) => {
li += 1;
ri += 1;
},
}
(Some(_), Some(&Left(_))) => {
li += 1;
ri += 1;
},
}
(Some(&Both(..)), Some(&Both(..))) => {
let chunk = Chunk(&self.left[..li], &self.right[..ri]);
self.left = &self.left[li..];
@ -65,7 +67,7 @@ where
return Some(chunk);
}
_ => {
if self.left.len() > 0 || self.right.len() > 0 {
if !self.left.is_empty() || !self.right.is_empty() {
let chunk = Chunk(self.left, self.right);
self.left = &self.left[self.left.len()..];
self.right = &self.right[self.right.len()..];
@ -81,7 +83,6 @@ where
#[cfg(test)]
mod test {
use super::*;
use diff;
#[test]
fn simple_case() {
@ -94,13 +95,16 @@ mod test {
let chunks = ChunkIterator::new(&oa, &ob).collect::<Vec<_>>();
assert_eq!(vec![
Chunk(&oa[0.. 3], &ob[0.. 3]),
Chunk(&oa[3.. 6], &ob[3.. 3]),
Chunk(&oa[6.. 9], &ob[3.. 6]),
Chunk(&oa[9.. 9], &ob[6.. 9]),
assert_eq!(
vec![
Chunk(&oa[0..3], &ob[0..3]),
Chunk(&oa[3..6], &ob[3..3]),
Chunk(&oa[6..9], &ob[3..6]),
Chunk(&oa[9..9], &ob[6..9]),
Chunk(&oa[9..12], &ob[9..12]),
], chunks);
],
chunks
);
}
#[test]
@ -113,11 +117,14 @@ mod test {
let ob = diff::chars(o, b);
let chunks = ChunkIterator::new(&oa, &ob).collect::<Vec<_>>();
assert_eq!(vec![
Chunk(&oa[0.. 3], &ob[0.. 3]),
Chunk(&oa[3.. 9], &ob[3.. 9]),
assert_eq!(
vec![
Chunk(&oa[0..3], &ob[0..3]),
Chunk(&oa[3..9], &ob[3..9]),
Chunk(&oa[9..12], &ob[9..12]),
], chunks);
],
chunks
);
}
#[test]
@ -130,10 +137,10 @@ mod test {
let ob = diff::chars(o, b);
let chunks = ChunkIterator::new(&oa, &ob).collect::<Vec<_>>();
assert_eq!(vec![
Chunk(&oa[0..9], &ob[0.. 9]),
Chunk(&oa[9..9], &ob[9..12]),
], chunks);
assert_eq!(
vec![Chunk(&oa[0..9], &ob[0..9]), Chunk(&oa[9..9], &ob[9..12]),],
chunks
);
}
#[test]
@ -146,10 +153,10 @@ mod test {
let ob = diff::chars(o, b);
let chunks = ChunkIterator::new(&oa, &ob).collect::<Vec<_>>();
assert_eq!(vec![
Chunk(&oa[0..6], &ob[0.. 6]),
Chunk(&oa[6..9], &ob[6..12]),
], chunks);
assert_eq!(
vec![Chunk(&oa[0..6], &ob[0..6]), Chunk(&oa[6..9], &ob[6..12]),],
chunks
);
}
#[test]
@ -162,8 +169,6 @@ mod test {
let ob = diff::chars(o, b);
let chunks = ChunkIterator::new(&oa, &ob).collect::<Vec<_>>();
assert_eq!(vec![
Chunk(&oa[0..6], &ob[0..6]),
], chunks);
assert_eq!(vec![Chunk(&oa[0..6], &ob[0..6]),], chunks);
}
}

View file

@ -1,14 +1,12 @@
mod chunk_iterator;
mod chunk;
mod chunk_iterator;
mod output;
use std::fmt::Debug;
use diff;
use self::chunk_iterator::ChunkIterator;
use self::output::*;
use self::output::Output::Resolved;
use self::output::*;
pub use self::output::Output;
@ -19,12 +17,12 @@ pub enum MergeResult<Item: Debug + PartialEq> {
}
impl<'a> MergeResult<&'a str> {
pub fn to_strings(self) -> MergeResult<String> {
pub fn into_strings(self) -> MergeResult<String> {
match self {
MergeResult::Clean(x) => MergeResult::Clean(x),
MergeResult::Conflicted(x) => MergeResult::Conflicted(
x.into_iter().map(Output::to_strings).collect()
)
MergeResult::Conflicted(x) => {
MergeResult::Conflicted(x.into_iter().map(Output::into_strings).collect())
}
}
}
}
@ -33,8 +31,8 @@ impl MergeResult<String> {
pub fn flatten(self) -> String {
match self {
MergeResult::Clean(x) => x,
MergeResult::Conflicted(x) => {
x.into_iter()
MergeResult::Conflicted(x) => x
.into_iter()
.flat_map(|out| match out {
Output::Conflict(a, _o, b) => {
let mut x: Vec<String> = vec![];
@ -44,12 +42,10 @@ impl MergeResult<String> {
x.extend(b.into_iter().map(|x| format!("{}\n", x)));
x.push(">>>>>>> Conflict ends here\n".into());
x
},
Output::Resolved(x) =>
x.into_iter().map(|x| format!("{}\n", x)).collect(),
})
.collect()
}
Output::Resolved(x) => x.into_iter().map(|x| format!("{}\n", x)).collect(),
})
.collect(),
}
}
}
@ -58,8 +54,8 @@ impl MergeResult<char> {
pub fn flatten(self) -> String {
match self {
MergeResult::Clean(x) => x,
MergeResult::Conflicted(x) => {
x.into_iter()
MergeResult::Conflicted(x) => x
.into_iter()
.flat_map(|out| match out {
Output::Conflict(a, _o, b) => {
let mut x: Vec<char> = vec![];
@ -69,11 +65,10 @@ impl MergeResult<char> {
x.extend(b);
x.push('>');
x
},
}
Output::Resolved(x) => x,
})
.collect()
}
.collect(),
}
}
}
@ -85,7 +80,7 @@ pub fn merge_lines<'a>(a: &'a str, o: &'a str, b: &'a str) -> MergeResult<&'a st
let chunks = ChunkIterator::new(&oa, &ob);
let hunks: Vec<_> = chunks.map(resolve).collect();
let clean = hunks.iter().all(|x| match x { &Resolved(..) => true, _ => false });
let clean = hunks.iter().all(|x| matches!(x, Resolved(..)));
if clean {
MergeResult::Clean(
@ -93,10 +88,10 @@ pub fn merge_lines<'a>(a: &'a str, o: &'a str, b: &'a str) -> MergeResult<&'a st
.into_iter()
.flat_map(|x| match x {
Resolved(y) => y.into_iter(),
_ => unreachable!()
_ => unreachable!(),
})
.collect::<Vec<_>>()
.join("\n")
.join("\n"),
)
} else {
MergeResult::Conflicted(hunks)
@ -110,7 +105,7 @@ pub fn merge_chars<'a>(a: &'a str, o: &'a str, b: &'a str) -> MergeResult<char>
let chunks = ChunkIterator::new(&oa, &ob);
let hunks: Vec<_> = chunks.map(resolve).collect();
let clean = hunks.iter().all(|x| match x { &Resolved(..) => true, _ => false });
let clean = hunks.iter().all(|x| matches!(x, Resolved(..)));
if clean {
MergeResult::Clean(
@ -118,9 +113,9 @@ pub fn merge_chars<'a>(a: &'a str, o: &'a str, b: &'a str) -> MergeResult<char>
.into_iter()
.flat_map(|x| match x {
Resolved(y) => y.into_iter(),
_ => unreachable!()
_ => unreachable!(),
})
.collect()
.collect(),
)
} else {
MergeResult::Conflicted(hunks)
@ -129,11 +124,11 @@ pub fn merge_chars<'a>(a: &'a str, o: &'a str, b: &'a str) -> MergeResult<char>
#[cfg(test)]
mod test {
use diff;
use indoc::indoc;
use super::*;
use super::output::*;
use super::output::Output::*;
use super::output::*;
use super::*;
#[test]
fn simple_case() {
@ -145,106 +140,141 @@ mod test {
chunks.map(resolve).collect()
}
assert_eq!(vec![
assert_eq!(
vec![
Resolved("aaa".chars().collect()),
Resolved("xxx".chars().collect()),
Resolved("bbb".chars().collect()),
Resolved("yyy".chars().collect()),
Resolved("ccc".chars().collect()),
], merge_chars(
"aaaxxxbbbccc",
"aaabbbccc",
"aaabbbyyyccc",
));
],
merge_chars("aaaxxxbbbccc", "aaabbbccc", "aaabbbyyyccc",)
);
}
#[test]
fn clean_case() {
assert_eq!(MergeResult::Clean(indoc!("
assert_eq!(
MergeResult::Clean(
indoc!(
"
aaa
xxx
bbb
yyy
ccc
").into()), merge_lines(
indoc!("
"
)
.into()
),
merge_lines(
indoc!(
"
aaa
xxx
bbb
ccc
"),
indoc!("
"
),
indoc!(
"
aaa
bbb
ccc
"),
indoc!("
"
),
indoc!(
"
aaa
bbb
yyy
ccc
"),
));
"
),
)
);
}
#[test]
fn clean_case_chars() {
assert_eq!(MergeResult::Clean("Title".into()), merge_chars(
"Titlle",
"titlle",
"title",
));
assert_eq!(
MergeResult::Clean("Title".into()),
merge_chars("Titlle", "titlle", "title",)
);
}
#[test]
fn false_conflict() {
assert_eq!(MergeResult::Clean(indoc!("
assert_eq!(
MergeResult::Clean(
indoc!(
"
aaa
xxx
ccc
").into()), merge_lines(
indoc!("
"
)
.into()
),
merge_lines(
indoc!(
"
aaa
xxx
ccc
"),
indoc!("
"
),
indoc!(
"
aaa
bbb
ccc
"),
indoc!("
"
),
indoc!(
"
aaa
xxx
ccc
"),
));
"
),
)
);
}
#[test]
fn true_conflict() {
assert_eq!(MergeResult::Conflicted(vec![
assert_eq!(
MergeResult::Conflicted(vec![
Resolved(vec!["aaa"]),
Conflict(vec!["xxx"], vec![], vec!["yyy"]),
Resolved(vec!["bbb", "ccc", ""]),
]), merge_lines(
indoc!("
]),
merge_lines(
indoc!(
"
aaa
xxx
bbb
ccc
"),
indoc!("
"
),
indoc!(
"
aaa
bbb
ccc
"),
indoc!("
"
),
indoc!(
"
aaa
yyy
bbb
ccc
"),
));
"
),
)
);
}
}

View file

@ -1,6 +1,5 @@
use std::fmt::Debug;
use diff;
use diff::Result::*;
use super::chunk::Chunk;
@ -12,7 +11,7 @@ pub enum Output<Item: Debug + PartialEq> {
}
impl<'a> Output<&'a str> {
pub fn to_strings(self) -> Output<String> {
pub fn into_strings(self) -> Output<String> {
match self {
Output::Resolved(x) => Output::Resolved(x.into_iter().map(str::to_string).collect()),
Output::Conflict(a, o, b) => Output::Conflict(
@ -27,10 +26,10 @@ impl<'a> Output<&'a str> {
fn choose_left<Item: Copy>(operations: &[diff::Result<Item>]) -> Vec<Item> {
operations
.iter()
.filter_map(|x| match x {
&Both(y, _) => Some(y),
&Left(y) => Some(y),
&Right(_) => None,
.filter_map(|x| match *x {
Both(y, _) => Some(y),
Left(y) => Some(y),
Right(_) => None,
})
.collect()
}
@ -38,21 +37,16 @@ fn choose_left<Item: Copy>(operations: &[diff::Result<Item>]) -> Vec<Item> {
fn choose_right<Item: Copy>(operations: &[diff::Result<Item>]) -> Vec<Item> {
operations
.iter()
.filter_map(|x| match x {
&Both(_, y) => Some(y),
&Left(_) => None,
&Right(y) => Some(y),
.filter_map(|x| match *x {
Both(_, y) => Some(y),
Left(_) => None,
Right(y) => Some(y),
})
.collect()
}
fn no_change<Item>(operations: &[diff::Result<Item>]) -> bool {
operations
.iter()
.all(|x| match x {
&Both(..) => true,
_ => false,
})
operations.iter().all(|x| matches!(x, Both(..)))
}
pub fn resolve<'a, Item: 'a + Debug + PartialEq + Copy>(chunk: Chunk<'a, Item>) -> Output<Item> {
@ -69,92 +63,51 @@ pub fn resolve<'a, Item: 'a + Debug + PartialEq + Copy>(chunk: Chunk<'a, Item>)
return Output::Resolved(choose_right(chunk.0));
}
return Output::Conflict(
Output::Conflict(
choose_right(chunk.0),
choose_left(chunk.0),
choose_right(chunk.1),
);
)
}
#[cfg(test)]
mod test {
use diff::Result::*;
use super::*;
#[test]
fn empty() {
assert_eq!(
Output::Resolved(vec![]),
resolve::<i32>(Chunk(&[], &[]))
);
assert_eq!(Output::Resolved(vec![]), resolve::<i32>(Chunk(&[], &[])));
}
#[test]
fn same() {
assert_eq!(
Output::Resolved(vec![
1
]),
resolve::<i32>(Chunk(
&[Both(1, 1)],
&[Both(1, 1)]
))
Output::Resolved(vec![1]),
resolve::<i32>(Chunk(&[Both(1, 1)], &[Both(1, 1)]))
);
}
#[test]
fn only_left() {
assert_eq!(
Output::Resolved(vec![
2
]),
resolve::<i32>(Chunk(
&[
Left(1),
Right(2)
],
&[]
))
Output::Resolved(vec![2]),
resolve::<i32>(Chunk(&[Left(1), Right(2)], &[]))
);
}
#[test]
fn false_conflict() {
assert_eq!(
Output::Resolved(vec![
2
]),
resolve::<i32>(Chunk(
&[
Left(1),
Right(2)
],
&[
Left(1),
Right(2)
],
))
Output::Resolved(vec![2]),
resolve::<i32>(Chunk(&[Left(1), Right(2)], &[Left(1), Right(2)],))
);
}
#[test]
fn real_conflict() {
assert_eq!(
Output::Conflict(
vec![2],
vec![1],
vec![3],
),
resolve::<i32>(Chunk(
&[
Left(1),
Right(2)
],
&[
Left(1),
Right(3)
],
))
Output::Conflict(vec![2], vec![1], vec![3],),
resolve::<i32>(Chunk(&[Left(1), Right(2)], &[Left(1), Right(3)],))
);
}
}

View file

@ -1,4 +1,4 @@
use chrono;
use crate::theme::Theme;
fn slug_link(slug: &str) -> &str {
if slug.is_empty() {
@ -23,10 +23,14 @@ pub struct ArticleRevision {
pub latest: bool,
pub author: Option<String>,
pub theme: Theme,
}
impl ArticleRevision {
pub fn link(&self) -> &str { slug_link(&self.slug) }
pub fn link(&self) -> &str {
slug_link(&self.slug)
}
}
#[derive(Debug, PartialEq, Queryable)]
@ -43,10 +47,14 @@ pub struct ArticleRevisionStub {
pub latest: bool,
pub author: Option<String>,
pub theme: Theme,
}
impl ArticleRevisionStub {
pub fn link(&self) -> &str { slug_link(&self.slug) }
pub fn link(&self) -> &str {
slug_link(&self.slug)
}
}
use diesel::sql_types::Text;
@ -63,5 +71,7 @@ pub struct SearchResult {
}
impl SearchResult {
pub fn link(&self) -> &str { slug_link(&self.slug) }
pub fn link(&self) -> &str {
slug_link(&self.slug)
}
}

View file

@ -1,9 +1,18 @@
use pulldown_cmark::{Parser, Tag, html, OPTION_ENABLE_TABLES, OPTION_DISABLE_HTML};
use pulldown_cmark::Event::{Text, End};
use pulldown_cmark::Event::{End, Text};
use pulldown_cmark::{html, Parser, Tag, OPTION_DISABLE_HTML, OPTION_ENABLE_TABLES};
use slug::slugify;
fn slugify_link(text: &str, title: &str) -> Option<(String, String)> {
Some((slugify(text), title.to_owned()))
}
fn parser(src: &str) -> Parser {
let opts = OPTION_ENABLE_TABLES | OPTION_DISABLE_HTML;
Parser::new_with_broken_link_callback(src, opts, Some(&slugify_link))
}
pub fn render_markdown(src: &str) -> String {
let opts = OPTION_ENABLE_TABLES | OPTION_DISABLE_HTML;
let p = Parser::new_ext(src, opts);
let p = parser(src);
let mut buf = String::new();
html::push_html(&mut buf, p);
buf
@ -14,22 +23,43 @@ fn is_html_special(c: char) -> bool {
}
pub fn render_markdown_for_fts(src: &str) -> String {
let opts = OPTION_ENABLE_TABLES | OPTION_DISABLE_HTML;
let p = Parser::new_ext(src, opts);
let p = parser(src);
let mut buf = String::new();
for event in p {
match event {
Text(text) =>
buf.push_str(&text.replace(is_html_special, " ")),
// As far as I understand this is a basic
// sanitizing to prevent HTML from
// appearing in page.
Text(text) => buf.push_str(&text.replace(is_html_special, " ")),
// Footnote links maybe?
End(Tag::Link(uri, _title)) => {
buf.push_str(" (");
buf.push_str(&uri.replace(is_html_special, " "));
buf.push_str(") ");
}
_ => buf.push_str(" "),
_ => buf.push(' '),
}
}
buf
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn slug_link() {
let actual = render_markdown("[Slug link]");
let expected = "<p><a href=\"slug-link\" title=\"Slug link\">Slug link</a></p>\n";
assert_eq!(actual, expected);
}
#[test]
fn footnote_links() {
let actual = render_markdown("[Link]\n\n[Link]: target");
let expected = "<p><a href=\"target\">Link</a></p>\n";
assert_eq!(actual, expected);
}
}

View file

@ -1,12 +1,12 @@
use futures::{self, Future};
use hyper;
use hyper::header::ContentType;
use hyper::server::*;
use build_config;
use mimes::*;
use site::system_page;
use web::{Resource, ResponseFuture};
use crate::build_config;
use crate::mimes::*;
use crate::site::system_page;
use crate::web::{Resource, ResponseFuture};
#[derive(Licenses)]
pub struct AboutResource;
@ -28,20 +28,20 @@ impl License {
fn link(&self) -> &'static str {
use self::License::*;
match self {
&Bsd3Clause => "bsd-3-clause",
&Mit => "mit",
&Mpl2 => "mpl2",
&Ofl11 => "sil-ofl-1.1",
Bsd3Clause => "bsd-3-clause",
Mit => "mit",
Mpl2 => "mpl2",
Ofl11 => "sil-ofl-1.1",
}
}
fn name(&self) -> &'static str {
use self::License::*;
match self {
&Bsd3Clause => "BSD-3-Clause",
&Mit => "MIT",
&Mpl2 => "MPL2",
&Ofl11 => "OFL-1.1",
Bsd3Clause => "BSD-3-Clause",
Mit => "MIT",
Mpl2 => "MPL2",
Ofl11 => "OFL-1.1",
}
}
}
@ -54,13 +54,15 @@ struct LicenseInfo {
}
#[derive(BartDisplay)]
#[template="templates/about.html"]
#[template = "templates/about.html"]
struct Template<'a> {
deps: &'a [LicenseInfo]
deps: &'a [LicenseInfo],
}
impl<'a> Template<'a> {
fn version(&self) -> &str { &build_config::VERSION }
fn version(&self) -> &str {
&build_config::VERSION
}
}
impl Resource for AboutResource {
@ -70,24 +72,27 @@ impl Resource for AboutResource {
}
fn head(&self) -> ResponseFuture {
Box::new(futures::finished(Response::new()
Box::new(futures::finished(
Response::new()
.with_status(hyper::StatusCode::Ok)
.with_header(ContentType(TEXT_HTML.clone()))
.with_header(ContentType(TEXT_HTML.clone())),
))
}
fn get(self: Box<Self>) -> ResponseFuture {
let head = self.head();
Box::new(head
.and_then(move |head| {
Ok(head.with_body(system_page(
Box::new(head.and_then(move |head| {
Ok(head.with_body(
system_page(
None, // Hmm, should perhaps accept `base` as argument
"About Sausagewiki",
Template {
deps: &*LICENSE_INFOS
deps: *LICENSE_INFOS,
},
).to_string()))
)
.to_string(),
))
}))
}
}

View file

@ -1,22 +1,26 @@
use chrono::{TimeZone, DateTime, Local};
use chrono::{DateTime, Local, TimeZone};
use futures::{self, Future};
use hyper;
use hyper::header::{ContentType, Location};
use hyper::server::*;
use serde_json;
use serde_urlencoded;
use assets::ScriptJs;
use mimes::*;
use rendering::render_markdown;
use site::Layout;
use state::{State, UpdateResult, RebaseConflict};
use web::{Resource, ResponseFuture};
use crate::assets::ScriptJs;
use crate::mimes::*;
use crate::rendering::render_markdown;
use crate::site::Layout;
use crate::state::{RebaseConflict, State, UpdateResult};
use crate::theme::{self, Theme};
use crate::web::{Resource, ResponseFuture};
use super::changes_resource::QueryParameters;
struct SelectableTheme {
theme: Theme,
selected: bool,
}
#[derive(BartDisplay)]
#[template="templates/article.html"]
#[template = "templates/article.html"]
struct Template<'a> {
revision: i32,
last_updated: Option<&'a str>,
@ -26,6 +30,7 @@ struct Template<'a> {
title: &'a str,
raw: &'a str,
rendered: String,
themes: &'a [SelectableTheme],
}
impl<'a> Template<'a> {
@ -39,6 +44,7 @@ struct UpdateArticle {
base_revision: i32,
title: String,
body: String,
theme: Option<Theme>,
}
pub struct ArticleResource {
@ -50,7 +56,12 @@ pub struct ArticleResource {
impl ArticleResource {
pub fn new(state: State, article_id: i32, revision: i32, edit: bool) -> Self {
Self { state, article_id, revision, edit }
Self {
state,
article_id,
revision,
edit,
}
}
}
@ -70,12 +81,23 @@ pub fn last_updated(article_id: i32, created: &DateTime<Local>, author: Option<&
Template {
created: &created.to_rfc2822(),
article_history: &format!("_changes{}", QueryParameters::default().article_id(Some(article_id)).into_link()),
article_history: &format!(
"_changes{}",
QueryParameters::default()
.article_id(Some(article_id))
.into_link()
),
author: author.map(|author| Author {
author: &author,
history: format!("_changes{}", QueryParameters::default().author(Some(author.to_owned())).into_link()),
author,
history: format!(
"_changes{}",
QueryParameters::default()
.author(Some(author.to_owned()))
.into_link()
),
}),
}.to_string()
}
.to_string()
}
impl Resource for ArticleResource {
@ -85,37 +107,49 @@ impl Resource for ArticleResource {
}
fn head(&self) -> ResponseFuture {
Box::new(futures::finished(Response::new()
Box::new(futures::finished(
Response::new()
.with_status(hyper::StatusCode::Ok)
.with_header(ContentType(TEXT_HTML.clone()))
.with_header(ContentType(TEXT_HTML.clone())),
))
}
fn get(self: Box<Self>) -> ResponseFuture {
let data = self.state.get_article_revision(self.article_id, self.revision)
let data = self
.state
.get_article_revision(self.article_id, self.revision)
.map(|x| x.expect("Data model guarantees that this exists"));
let head = self.head();
Box::new(data.join(head)
.and_then(move |(data, head)| {
Ok(head
.with_body(Layout {
Box::new(data.join(head).and_then(move |(data, head)| {
Ok(head.with_body(
Layout {
base: None, // Hmm, should perhaps accept `base` as argument
title: &data.title,
theme: data.theme,
body: &Template {
revision: data.revision,
last_updated: Some(&last_updated(
data.article_id,
&Local.from_utc_datetime(&data.created),
data.author.as_ref().map(|x| &**x)
data.author.as_deref(),
)),
edit: self.edit,
cancel_url: Some(data.link()),
title: &data.title,
raw: &data.body,
rendered: render_markdown(&data.body),
themes: &theme::THEMES
.iter()
.map(|&x| SelectableTheme {
theme: x,
selected: x == data.theme,
})
.collect::<Vec<_>>(),
},
}.to_string()))
}
.to_string(),
))
}))
}
@ -125,7 +159,7 @@ impl Resource for ArticleResource {
use futures::Stream;
#[derive(BartDisplay)]
#[template="templates/article_contents.html"]
#[template = "templates/article_contents.html"]
struct Template<'a> {
title: &'a str,
rendered: String,
@ -138,69 +172,84 @@ impl Resource for ArticleResource {
revision: i32,
title: &'a str,
body: &'a str,
theme: Theme,
rendered: &'a str,
last_updated: &'a str,
}
Box::new(body
.concat2()
Box::new(
body.concat2()
.map_err(Into::into)
.and_then(|body| {
serde_urlencoded::from_bytes(&body)
.map_err(Into::into)
})
.and_then(|body| serde_urlencoded::from_bytes(&body).map_err(Into::into))
.and_then(move |update: UpdateArticle| {
self.state.update_article(self.article_id, update.base_revision, update.title, update.body, identity)
self.state.update_article(
self.article_id,
update.base_revision,
update.title,
update.body,
identity,
update.theme,
)
})
.and_then(|updated| match updated {
UpdateResult::Success(updated) =>
Ok(Response::new()
UpdateResult::Success(updated) => Ok(Response::new()
.with_status(hyper::StatusCode::Ok)
.with_header(ContentType(APPLICATION_JSON.clone()))
.with_body(serde_json::to_string(&PutResponse {
.with_body(
serde_json::to_string(&PutResponse {
conflict: false,
slug: &updated.slug,
revision: updated.revision,
title: &updated.title,
body: &updated.body,
theme: updated.theme,
rendered: &Template {
title: &updated.title,
rendered: render_markdown(&updated.body),
}.to_string(),
}
.to_string(),
last_updated: &last_updated(
updated.article_id,
&Local.from_utc_datetime(&updated.created),
updated.author.as_ref().map(|x| &**x)
),
}).expect("Should never fail"))
updated.author.as_deref(),
),
})
.expect("Should never fail"),
)),
UpdateResult::RebaseConflict(RebaseConflict {
base_article, title, body
base_article,
title,
body,
theme,
}) => {
let title = title.flatten();
let body = body.flatten();
Ok(Response::new()
.with_status(hyper::StatusCode::Ok)
.with_header(ContentType(APPLICATION_JSON.clone()))
.with_body(serde_json::to_string(&PutResponse {
.with_body(
serde_json::to_string(&PutResponse {
conflict: true,
slug: &base_article.slug,
revision: base_article.revision,
title: &title,
body: &body,
theme,
rendered: &Template {
title: &title,
rendered: render_markdown(&body),
}.to_string(),
}
.to_string(),
last_updated: &last_updated(
base_article.article_id,
&Local.from_utc_datetime(&base_article.created),
base_article.author.as_ref().map(|x| &**x)
base_article.author.as_deref(),
),
}).expect("Should never fail"))
)
}
})
.expect("Should never fail"),
))
}
}),
)
}
@ -209,53 +258,67 @@ impl Resource for ArticleResource {
use futures::Stream;
Box::new(body
.concat2()
Box::new(
body.concat2()
.map_err(Into::into)
.and_then(|body| {
serde_urlencoded::from_bytes(&body)
.map_err(Into::into)
})
.and_then(|body| serde_urlencoded::from_bytes(&body).map_err(Into::into))
.and_then(move |update: UpdateArticle| {
self.state.update_article(self.article_id, update.base_revision, update.title, update.body, identity)
self.state.update_article(
self.article_id,
update.base_revision,
update.title,
update.body,
identity,
update.theme,
)
})
.and_then(|updated| {
match updated {
.and_then(|updated| match updated {
UpdateResult::Success(updated) => Ok(Response::new()
.with_status(hyper::StatusCode::SeeOther)
.with_header(ContentType(TEXT_PLAIN.clone()))
.with_header(Location::new(updated.link().to_owned()))
.with_body("See other")
),
.with_body("See other")),
UpdateResult::RebaseConflict(RebaseConflict {
base_article, title, body
base_article,
title,
body,
theme,
}) => {
let title = title.flatten();
let body = body.flatten();
Ok(Response::new()
.with_status(hyper::StatusCode::Ok)
.with_header(ContentType(TEXT_HTML.clone()))
.with_body(Layout {
.with_body(
Layout {
base: None,
title: &title,
theme,
body: &Template {
revision: base_article.revision,
last_updated: Some(&last_updated(
base_article.article_id,
&Local.from_utc_datetime(&base_article.created),
base_article.author.as_ref().map(|x| &**x)
base_article.author.as_deref(),
)),
edit: true,
cancel_url: Some(base_article.link()),
title: &title,
raw: &body,
rendered: render_markdown(&body),
},
}.to_string())
)
}
}
themes: &theme::THEMES
.iter()
.map(|&x| SelectableTheme {
theme: x,
selected: x == theme,
})
.collect::<Vec<_>>(),
},
}
.to_string(),
))
}
}),
)
}
}

View file

@ -1,14 +1,14 @@
use chrono::{TimeZone, DateTime, Local};
use chrono::{DateTime, Local, TimeZone};
use futures::{self, Future};
use hyper;
use hyper::header::ContentType;
use hyper::server::*;
use mimes::*;
use models;
use rendering::render_markdown;
use site::system_page;
use web::{Resource, ResponseFuture};
use crate::mimes::*;
use crate::models;
use crate::rendering::render_markdown;
use crate::site::system_page;
use crate::web::{Resource, ResponseFuture};
use super::changes_resource::QueryParameters;
use super::diff_resource;
@ -24,7 +24,12 @@ impl ArticleRevisionResource {
}
}
pub fn timestamp_and_author(sequence_number: i32, article_id: i32, created: &DateTime<Local>, author: Option<&str>) -> String {
pub fn timestamp_and_author(
sequence_number: i32,
article_id: i32,
created: &DateTime<Local>,
author: Option<&str>,
) -> String {
struct Author<'a> {
author: &'a str,
history: String,
@ -42,15 +47,17 @@ pub fn timestamp_and_author(sequence_number: i32, article_id: i32, created: &Dat
Template {
created: &created.to_rfc2822(),
article_history: &format!("_changes{}",
article_history: &format!(
"_changes{}",
QueryParameters::default()
.pagination(pagination)
.article_id(Some(article_id))
.into_link()
),
author: author.map(|author| Author {
author: &author,
history: format!("_changes{}",
author,
history: format!(
"_changes{}",
QueryParameters::default()
.pagination(pagination)
.article_id(Some(article_id))
@ -58,7 +65,8 @@ pub fn timestamp_and_author(sequence_number: i32, article_id: i32, created: &Dat
.into_link()
),
}),
}.to_string()
}
.to_string()
}
impl Resource for ArticleRevisionResource {
@ -68,15 +76,16 @@ impl Resource for ArticleRevisionResource {
}
fn head(&self) -> ResponseFuture {
Box::new(futures::finished(Response::new()
Box::new(futures::finished(
Response::new()
.with_status(hyper::StatusCode::Ok)
.with_header(ContentType(TEXT_HTML.clone()))
.with_header(ContentType(TEXT_HTML.clone())),
))
}
fn get(self: Box<Self>) -> ResponseFuture {
#[derive(BartDisplay)]
#[template="templates/article_revision.html"]
#[template = "templates/article_revision.html"]
struct Template<'a> {
link_current: &'a str,
timestamp_and_author: &'a str,
@ -87,9 +96,9 @@ impl Resource for ArticleRevisionResource {
let head = self.head();
let data = self.data;
Box::new(head
.and_then(move |head|
Ok(head.with_body(system_page(
Box::new(head.and_then(move |head| {
Ok(head.with_body(
system_page(
Some("../../"), // Hmm, should perhaps accept `base` as argument
&data.title,
&Template {
@ -98,11 +107,11 @@ impl Resource for ArticleRevisionResource {
data.sequence_number,
data.article_id,
&Local.from_utc_datetime(&data.created),
data.author.as_ref().map(|x| &**x)
data.author.as_deref(),
),
diff_link:
if data.revision > 1 {
Some(format!("_diff/{}?{}",
diff_link: if data.revision > 1 {
Some(format!(
"_diff/{}?{}",
data.article_id,
diff_resource::QueryParameters::new(
data.revision as u32 - 1,
@ -114,7 +123,9 @@ impl Resource for ArticleRevisionResource {
},
rendered: render_markdown(&data.body),
},
).to_string()))
)
.to_string(),
))
}))
}
}

View file

@ -1,16 +1,14 @@
use diesel;
use futures::{self, Future};
use futures::future::{done, finished};
use hyper;
use futures::{self, Future};
use hyper::header::ContentType;
use hyper::server::*;
use serde_urlencoded;
use mimes::*;
use schema::article_revisions;
use site::system_page;
use state::State;
use web::{Resource, ResponseFuture};
use crate::mimes::*;
use crate::schema::article_revisions;
use crate::site::system_page;
use crate::state::State;
use crate::web::{Resource, ResponseFuture};
use super::diff_resource;
use super::pagination::Pagination;
@ -18,7 +16,7 @@ use super::TemporaryRedirectResource;
const DEFAULT_LIMIT: i32 = 30;
type BoxResource = Box<Resource + Sync + Send>;
type BoxResource = Box<dyn Resource + Sync + Send>;
#[derive(Clone)]
pub struct ChangesLookup {
@ -40,8 +38,16 @@ pub struct QueryParameters {
impl QueryParameters {
pub fn pagination(self, pagination: Pagination<i32>) -> Self {
Self {
after: if let Pagination::After(x) = pagination { Some(x) } else { None },
before: if let Pagination::Before(x) = pagination { Some(x) } else { None },
after: if let Pagination::After(x) = pagination {
Some(x)
} else {
None
},
before: if let Pagination::Before(x) = pagination {
Some(x)
} else {
None
},
..self
}
}
@ -56,14 +62,18 @@ impl QueryParameters {
pub fn limit(self, limit: i32) -> Self {
Self {
limit: if limit != DEFAULT_LIMIT { Some(limit) } else { None },
limit: if limit != DEFAULT_LIMIT {
Some(limit)
} else {
None
},
..self
}
}
pub fn into_link(self) -> String {
let args = serde_urlencoded::to_string(self).expect("Serializing to String cannot fail");
if args.len() > 0 {
if !args.is_empty() {
format!("?{}", args)
} else {
"_changes".to_owned()
@ -71,14 +81,12 @@ impl QueryParameters {
}
}
fn apply_query_config<'a>(
mut query: article_revisions::BoxedQuery<'a, diesel::sqlite::Sqlite>,
fn apply_query_config(
mut query: article_revisions::BoxedQuery<diesel::sqlite::Sqlite>,
article_id: Option<i32>,
author: Option<String>,
limit: i32,
)
-> article_revisions::BoxedQuery<'a, diesel::sqlite::Sqlite>
{
) -> article_revisions::BoxedQuery<diesel::sqlite::Sqlite> {
use diesel::prelude::*;
if let Some(article_id) = article_id {
@ -94,10 +102,16 @@ fn apply_query_config<'a>(
impl ChangesLookup {
pub fn new(state: State, show_authors: bool) -> ChangesLookup {
Self { state, show_authors }
Self {
state,
show_authors,
}
}
pub fn lookup(&self, query: Option<&str>) -> Box<Future<Item=Option<BoxResource>, Error=::web::Error>> {
pub fn lookup(
&self,
query: Option<&str>,
) -> Box<dyn Future<Item = Option<BoxResource>, Error = crate::web::Error>> {
use super::pagination;
let state = self.state.clone();
@ -111,31 +125,34 @@ impl ChangesLookup {
let limit = match params.limit {
None => Ok(DEFAULT_LIMIT),
Some(x) if 1 <= x && x <= 100 => Ok(x),
Some(x) if (1..=100).contains(&x) => Ok(x),
_ => Err("`limit` argument must be in range [1, 100]"),
}?;
Ok((pagination, params.article_id, params.author, limit))
})())
.and_then(move |(pagination, article_id, author, limit)| match pagination {
.and_then(move |(pagination, article_id, author, limit)| {
match pagination {
Pagination::After(x) => {
let author2 = author.clone();
Box::new(state.query_article_revision_stubs(move |query| {
Box::new(
state
.query_article_revision_stubs(move |query| {
use diesel::prelude::*;
apply_query_config(query, article_id, author2, limit)
.filter(article_revisions::sequence_number.gt(x))
.order(article_revisions::sequence_number.asc())
}).and_then(move |mut data| {
})
.and_then(move |mut data| {
let extra_element = if data.len() > limit as usize {
data.pop()
} else {
None
};
let args =
QueryParameters {
let args = QueryParameters {
after: None,
before: None,
article_id,
@ -146,19 +163,42 @@ impl ChangesLookup {
Ok(Some(match extra_element {
Some(x) => Box::new(TemporaryRedirectResource::new(
args
.pagination(Pagination::Before(x.sequence_number))
.into_link()
)) as BoxResource,
args.pagination(Pagination::Before(x.sequence_number))
.into_link(),
))
as BoxResource,
None => Box::new(TemporaryRedirectResource::new(
args.into_link()
)) as BoxResource,
args.into_link(),
))
as BoxResource,
}))
})) as Box<Future<Item=Option<BoxResource>, Error=::web::Error>>
},
Pagination::Before(x) => Box::new(finished(Some(Box::new(ChangesResource::new(state, show_authors, Some(x), article_id, author, limit)) as BoxResource))),
Pagination::None => Box::new(finished(Some(Box::new(ChangesResource::new(state, show_authors, None, article_id, author, limit)) as BoxResource))),
})
}),
)
as Box<
dyn Future<Item = Option<BoxResource>, Error = crate::web::Error>,
>
}
Pagination::Before(x) => {
Box::new(finished(Some(Box::new(ChangesResource::new(
state,
show_authors,
Some(x),
article_id,
author,
limit,
)) as BoxResource)))
}
Pagination::None => Box::new(finished(Some(Box::new(ChangesResource::new(
state,
show_authors,
None,
article_id,
author,
limit,
))
as BoxResource))),
}
}),
)
}
}
@ -173,8 +213,22 @@ pub struct ChangesResource {
}
impl ChangesResource {
pub fn new(state: State, show_authors: bool, before: Option<i32>, article_id: Option<i32>, author: Option<String>, limit: i32) -> Self {
Self { state, show_authors, before, article_id, author, limit }
pub fn new(
state: State,
show_authors: bool,
before: Option<i32>,
article_id: Option<i32>,
author: Option<String>,
limit: i32,
) -> Self {
Self {
state,
show_authors,
before,
article_id,
author,
limit,
}
}
fn query_args(&self) -> QueryParameters {
@ -196,14 +250,15 @@ impl Resource for ChangesResource {
}
fn head(&self) -> ResponseFuture {
Box::new(futures::finished(Response::new()
Box::new(futures::finished(
Response::new()
.with_status(hyper::StatusCode::Ok)
.with_header(ContentType(TEXT_HTML.clone()))
.with_header(ContentType(TEXT_HTML.clone())),
))
}
fn get(self: Box<Self>) -> ResponseFuture {
use chrono::{TimeZone, Local};
use chrono::{Local, TimeZone};
struct Row<'a> {
resource: &'a ChangesResource,
@ -224,7 +279,8 @@ impl Resource for ChangesResource {
impl<'a> Row<'a> {
fn author_link(&self) -> String {
self.resource.query_args()
self.resource
.query_args()
.pagination(Pagination::After(self.sequence_number))
.author(self.author.clone())
.into_link()
@ -237,7 +293,7 @@ impl Resource for ChangesResource {
}
#[derive(BartDisplay)]
#[template="templates/changes.html"]
#[template = "templates/changes.html"]
struct Template<'a> {
resource: &'a ChangesResource,
@ -251,7 +307,7 @@ impl Resource for ChangesResource {
fn subject_clause(&self) -> String {
match self.resource.article_id {
Some(x) => format!(" <a href=\"_by_id/{}\">this article</a>", x),
None => format!(" the wiki"),
None => " the wiki".to_string(),
}
}
@ -260,24 +316,25 @@ impl Resource for ChangesResource {
}
fn all_articles_link(&self) -> Option<String> {
self.resource.article_id.map(|_| {
self.resource.query_args()
.article_id(None)
.into_link()
})
self.resource
.article_id
.map(|_| self.resource.query_args().article_id(None).into_link())
}
fn all_authors_link(&self) -> Option<String> {
self.resource.author.as_ref().map(|_| {
self.resource.query_args()
.author(None)
.into_link()
})
self.resource
.author
.as_ref()
.map(|_| self.resource.query_args().author(None).into_link())
}
}
let (before, article_id, author, limit) =
(self.before.clone(), self.article_id.clone(), self.author.clone(), self.limit);
let (before, article_id, author, limit) = (
self.before,
self.article_id,
self.author.clone(),
self.limit,
);
let data = self.state.query_article_revision_stubs(move |query| {
use diesel::prelude::*;
@ -292,10 +349,7 @@ impl Resource for ChangesResource {
let head = self.head();
Box::new(data.join(head)
.and_then(move |(mut data, head)| {
use std::iter::Iterator;
Box::new(data.join(head).and_then(move |(mut data, head)| {
let extra_element = if data.len() > self.limit as usize {
data.pop()
} else {
@ -305,29 +359,41 @@ impl Resource for ChangesResource {
let (newer, older) = match self.before {
Some(x) => (
Some(NavLinks {
more: self.query_args().pagination(Pagination::After(x-1)).into_link(),
more: self
.query_args()
.pagination(Pagination::After(x - 1))
.into_link(),
end: self.query_args().pagination(Pagination::None).into_link(),
}),
extra_element.map(|_| NavLinks {
more: self.query_args()
more: self
.query_args()
.pagination(Pagination::Before(data.last().unwrap().sequence_number))
.into_link(),
end: self.query_args().pagination(Pagination::After(0)).into_link(),
})
end: self
.query_args()
.pagination(Pagination::After(0))
.into_link(),
}),
),
None => (
None,
extra_element.map(|_| NavLinks {
more: self.query_args()
more: self
.query_args()
.pagination(Pagination::Before(data.last().unwrap().sequence_number))
.into_link(),
end: self.query_args().pagination(Pagination::After(0)).into_link(),
end: self
.query_args()
.pagination(Pagination::After(0))
.into_link(),
}),
),
};
let changes = &data.into_iter().map(|x| {
Row {
let changes = &data
.into_iter()
.map(|x| Row {
resource: &self,
sequence_number: x.sequence_number,
article_id: x.article_id,
@ -337,9 +403,9 @@ impl Resource for ChangesResource {
_slug: x.slug,
title: x.title,
_latest: x.latest,
diff_link:
if x.revision > 1 {
Some(format!("_diff/{}?{}",
diff_link: if x.revision > 1 {
Some(format!(
"_diff/{}?{}",
x.article_id,
diff_resource::QueryParameters::new(
x.revision as u32 - 1,
@ -349,10 +415,11 @@ impl Resource for ChangesResource {
} else {
None
},
}
}).collect::<Vec<_>>();
})
.collect::<Vec<_>>();
Ok(head.with_body(system_page(
Ok(head.with_body(
system_page(
None, // Hmm, should perhaps accept `base` as argument
"Changes",
Template {
@ -360,9 +427,11 @@ impl Resource for ChangesResource {
show_authors: self.show_authors,
newer,
older,
changes
}
).to_string()))
changes,
},
)
.to_string(),
))
}))
}
}

View file

@ -1,23 +1,22 @@
use std::fmt;
use diff;
use futures::{self, Future};
use futures::future::done;
use hyper;
use futures::{self, Future};
use hyper::header::ContentType;
use hyper::server::*;
use serde_urlencoded;
use mimes::*;
use models::ArticleRevision;
use site::Layout;
use state::State;
use web::{Resource, ResponseFuture};
use crate::mimes::*;
use crate::models::ArticleRevision;
use crate::site::Layout;
use crate::state::State;
use crate::theme;
use crate::web::{Resource, ResponseFuture};
use super::changes_resource;
use super::pagination::Pagination;
type BoxResource = Box<Resource + Sync + Send>;
type BoxResource = Box<dyn Resource + Sync + Send>;
#[derive(Clone)]
pub struct DiffLookup {
@ -47,25 +46,28 @@ impl DiffLookup {
Self { state }
}
pub fn lookup(&self, article_id: u32, query: Option<&str>) -> Box<Future<Item=Option<BoxResource>, Error=::web::Error>> {
pub fn lookup(
&self,
article_id: u32,
query: Option<&str>,
) -> Box<dyn Future<Item = Option<BoxResource>, Error = crate::web::Error>> {
let state = self.state.clone();
Box::new(done(
serde_urlencoded::from_str(query.unwrap_or(""))
.map_err(Into::into)
).and_then(move |params: QueryParameters| {
Box::new(
done(serde_urlencoded::from_str(query.unwrap_or("")).map_err(Into::into))
.and_then(move |params: QueryParameters| {
let from = state.get_article_revision(article_id as i32, params.from as i32);
let to = state.get_article_revision(article_id as i32, params.to as i32);
from.join(to)
}).and_then(move |(from, to)| {
match (from, to) {
(Some(from), Some(to)) =>
Ok(Some(Box::new(DiffResource::new(from, to)) as BoxResource)),
_ =>
Ok(None),
})
.and_then(move |(from, to)| match (from, to) {
(Some(from), Some(to)) => {
Ok(Some(Box::new(DiffResource::new(from, to)) as BoxResource))
}
}))
_ => Ok(None),
}),
)
}
}
@ -88,9 +90,10 @@ impl Resource for DiffResource {
}
fn head(&self) -> ResponseFuture {
Box::new(futures::finished(Response::new()
Box::new(futures::finished(
Response::new()
.with_status(hyper::StatusCode::Ok)
.with_header(ContentType(TEXT_HTML.clone()))
.with_header(ContentType(TEXT_HTML.clone())),
))
}
@ -100,6 +103,8 @@ impl Resource for DiffResource {
struct Template<'a> {
consecutive: bool,
article_id: u32,
author: Option<&'a str>,
author_link: &'a str,
article_history_link: &'a str,
from_link: &'a str,
to_link: &'a str,
@ -116,41 +121,88 @@ impl Resource for DiffResource {
let head = self.head();
Box::new(head
.and_then(move |head| {
Ok(head
.with_body(Layout {
base: Some("../"), // Hmm, should perhaps accept `base` as argument
title: "Difference",
body: &Template {
consecutive: self.to.revision - self.from.revision == 1,
article_id: self.from.article_id as u32,
article_history_link: &format!("_changes{}",
Box::new(head.and_then(move |head| {
let consecutive = self.to.revision - self.from.revision == 1;
let author = match consecutive {
true => self.to.author.as_deref(),
false => None,
};
let author_link = &format!(
"_changes{}",
changes_resource::QueryParameters::default()
.author(author.map(|x| x.to_owned()))
.pagination(Pagination::After(self.from.sequence_number))
.into_link()
);
let article_history_link = &format!(
"_changes{}",
changes_resource::QueryParameters::default()
.article_id(Some(self.from.article_id))
.pagination(Pagination::After(self.from.revision))
.pagination(Pagination::After(self.from.sequence_number))
.into_link()
),
from_link: &format!("_revisions/{}/{}", self.from.article_id, self.from.revision),
to_link: &format!("_revisions/{}/{}", self.to.article_id, self.to.revision),
title: &diff::chars(&self.from.title, &self.to.title)
);
let title = &diff::chars(&self.from.title, &self.to.title)
.into_iter()
.map(|x| match x {
diff::Result::Left(x) => Diff { removed: Some(x), ..Default::default() },
diff::Result::Both(x, _) => Diff { same: Some(x), ..Default::default() },
diff::Result::Right(x) => Diff { added: Some(x), ..Default::default() },
})
.collect::<Vec<_>>(),
lines: &diff::lines(&self.from.body, &self.to.body)
.into_iter()
.map(|x| match x {
diff::Result::Left(x) => Diff { removed: Some(x), ..Default::default() },
diff::Result::Both(x, _) => Diff { same: Some(x), ..Default::default() },
diff::Result::Right(x) => Diff { added: Some(x), ..Default::default() },
})
.collect::<Vec<_>>()
diff::Result::Left(x) => Diff {
removed: Some(x),
..Default::default()
},
}.to_string()))
diff::Result::Both(x, _) => Diff {
same: Some(x),
..Default::default()
},
diff::Result::Right(x) => Diff {
added: Some(x),
..Default::default()
},
})
.collect::<Vec<_>>();
let lines = &diff::lines(&self.from.body, &self.to.body)
.into_iter()
.map(|x| match x {
diff::Result::Left(x) => Diff {
removed: Some(x),
..Default::default()
},
diff::Result::Both(x, _) => Diff {
same: Some(x),
..Default::default()
},
diff::Result::Right(x) => Diff {
added: Some(x),
..Default::default()
},
})
.collect::<Vec<_>>();
Ok(head.with_body(
Layout {
base: Some("../"), // Hmm, should perhaps accept `base` as argument
title: "Difference",
theme: theme::theme_from_str_hash("Difference"),
body: &Template {
consecutive,
article_id: self.from.article_id as u32,
author,
author_link,
article_history_link,
from_link: &format!(
"_revisions/{}/{}",
self.from.article_id, self.from.revision
),
to_link: &format!("_revisions/{}/{}", self.to.article_id, self.to.revision),
title,
lines,
},
}
.to_string(),
))
}))
}
}

View file

@ -1,11 +1,11 @@
use futures::{self, Future};
use hyper;
use hyper::header::ContentType;
use hyper::server::*;
use mimes::*;
use site::system_page;
use web::{Resource, ResponseFuture};
use crate::mimes::*;
use crate::site::system_page;
use crate::web::{Resource, ResponseFuture};
pub struct HtmlResource {
base: Option<&'static str>,
@ -15,7 +15,11 @@ pub struct HtmlResource {
impl HtmlResource {
pub fn new(base: Option<&'static str>, title: &'static str, html_body: &'static str) -> Self {
HtmlResource { base, title, html_body }
HtmlResource {
base,
title,
html_body,
}
}
}
@ -26,22 +30,18 @@ impl Resource for HtmlResource {
}
fn head(&self) -> ResponseFuture {
Box::new(futures::finished(Response::new()
Box::new(futures::finished(
Response::new()
.with_status(hyper::StatusCode::Ok)
.with_header(ContentType(TEXT_HTML.clone()))
.with_header(ContentType(TEXT_HTML.clone())),
))
}
fn get(self: Box<Self>) -> ResponseFuture {
let head = self.head();
Box::new(head
.and_then(move |head| {
Ok(head.with_body(system_page(
self.base,
self.title,
self.html_body
).to_string()))
Box::new(head.and_then(move |head| {
Ok(head.with_body(system_page(self.base, self.title, self.html_body).to_string()))
}))
}
}

View file

@ -1,8 +1,8 @@
pub mod pagination;
mod about_resource;
mod article_revision_resource;
mod article_resource;
mod article_revision_resource;
mod changes_resource;
mod diff_resource;
mod html_resource;
@ -13,8 +13,8 @@ mod sitemap_resource;
mod temporary_redirect_resource;
pub use self::about_resource::AboutResource;
pub use self::article_revision_resource::ArticleRevisionResource;
pub use self::article_resource::ArticleResource;
pub use self::article_revision_resource::ArticleRevisionResource;
pub use self::changes_resource::{ChangesLookup, ChangesResource};
pub use self::diff_resource::{DiffLookup, DiffResource};
pub use self::html_resource::HtmlResource;

View file

@ -1,16 +1,15 @@
use futures::{self, Future};
use hyper;
use hyper::header::{ContentType, Location};
use hyper::server::*;
use serde_json;
use serde_urlencoded;
use assets::ScriptJs;
use mimes::*;
use rendering::render_markdown;
use site::Layout;
use state::State;
use web::{Resource, ResponseFuture};
use crate::assets::ScriptJs;
use crate::mimes::*;
use crate::rendering::render_markdown;
use crate::site::Layout;
use crate::state::State;
use crate::theme::{self, Theme};
use crate::web::{Resource, ResponseFuture};
const NEW: &str = "NEW";
@ -27,6 +26,7 @@ fn title_from_slug(slug: &str) -> String {
pub struct NewArticleResource {
state: State,
slug: Option<String>,
edit: bool,
}
#[derive(Deserialize)]
@ -34,11 +34,12 @@ struct CreateArticle {
base_revision: String,
title: String,
body: String,
theme: Option<Theme>,
}
impl NewArticleResource {
pub fn new(state: State, slug: Option<String>) -> Self {
Self { state, slug }
pub fn new(state: State, slug: Option<String>, edit: bool) -> Self {
Self { state, slug, edit }
}
}
@ -49,15 +50,22 @@ impl Resource for NewArticleResource {
}
fn head(&self) -> ResponseFuture {
Box::new(futures::finished(Response::new()
Box::new(futures::finished(
Response::new()
.with_status(hyper::StatusCode::NotFound)
.with_header(ContentType(TEXT_HTML.clone()))
.with_header(ContentType(TEXT_HTML.clone())),
))
}
fn get(self: Box<Self>) -> ResponseFuture {
// TODO Remove duplication with article_resource.rs:
struct SelectableTheme {
theme: Theme,
selected: bool,
}
#[derive(BartDisplay)]
#[template="templates/article.html"]
#[template = "templates/article.html"]
struct Template<'a> {
revision: &'a str,
last_updated: Option<&'a str>,
@ -67,6 +75,7 @@ impl Resource for NewArticleResource {
title: &'a str,
raw: &'a str,
rendered: &'a str,
themes: &'a [SelectableTheme],
}
impl<'a> Template<'a> {
fn script_js(&self) -> &'static str {
@ -74,29 +83,36 @@ impl Resource for NewArticleResource {
}
}
let title = self.slug.as_ref()
let title = self
.slug
.as_ref()
.map_or("".to_owned(), |x| title_from_slug(x));
Box::new(self.head()
.and_then(move |head| {
Ok(head
.with_body(Layout {
Box::new(self.head().and_then(move |head| {
Ok(head.with_body(
Layout {
base: None, // Hmm, should perhaps accept `base` as argument
title: &title,
theme: theme::Theme::Gray,
body: &Template {
revision: NEW,
last_updated: None,
// Implicitly start in edit-mode when no slug is given. This
// currently directly corresponds to the /_new endpoint
edit: self.slug.is_none(),
cancel_url: self.slug.as_ref().map(|x| &**x),
edit: self.edit,
cancel_url: self.slug.as_deref(),
title: &title,
raw: "",
rendered: EMPTY_ARTICLE_MESSAGE,
themes: &theme::THEMES
.iter()
.map(|&x| SelectableTheme {
theme: x,
selected: false,
})
.collect::<Vec<_>>(),
},
}.to_string()))
}
.to_string(),
))
}))
}
@ -104,11 +120,11 @@ impl Resource for NewArticleResource {
// TODO Check incoming Content-Type
// TODO Refactor? Reduce duplication with ArticleResource::put?
use chrono::{TimeZone, Local};
use chrono::{Local, TimeZone};
use futures::Stream;
#[derive(BartDisplay)]
#[template="templates/article_contents.html"]
#[template = "templates/article_contents.html"]
struct Template<'a> {
title: &'a str,
rendered: String,
@ -121,45 +137,56 @@ impl Resource for NewArticleResource {
revision: i32,
title: &'a str,
body: &'a str,
theme: Theme,
rendered: &'a str,
last_updated: &'a str,
}
Box::new(body
.concat2()
Box::new(
body.concat2()
.map_err(Into::into)
.and_then(|body| {
serde_urlencoded::from_bytes(&body)
.map_err(Into::into)
})
.and_then(|body| serde_urlencoded::from_bytes(&body).map_err(Into::into))
.and_then(move |arg: CreateArticle| {
if arg.base_revision != NEW {
unimplemented!("Version update conflict");
}
self.state.create_article(self.slug.clone(), arg.title, arg.body, identity)
let theme = arg.theme.unwrap_or_else(theme::random);
self.state.create_article(
self.slug.clone(),
arg.title,
arg.body,
identity,
theme,
)
})
.and_then(|updated| {
futures::finished(Response::new()
futures::finished(
Response::new()
.with_status(hyper::StatusCode::Ok)
.with_header(ContentType(APPLICATION_JSON.clone()))
.with_body(serde_json::to_string(&PutResponse {
.with_body(
serde_json::to_string(&PutResponse {
slug: &updated.slug,
article_id: updated.article_id,
revision: updated.revision,
title: &updated.title,
body: &updated.body,
theme: updated.theme,
rendered: &Template {
title: &updated.title,
rendered: render_markdown(&updated.body),
}.to_string(),
}
.to_string(),
last_updated: &super::article_resource::last_updated(
updated.article_id,
&Local.from_utc_datetime(&updated.created),
updated.author.as_ref().map(|x| &**x)
updated.author.as_deref(),
),
}).expect("Should never fail"))
)
})
.expect("Should never fail"),
),
)
}),
)
}
@ -169,27 +196,32 @@ impl Resource for NewArticleResource {
use futures::Stream;
Box::new(body
.concat2()
Box::new(
body.concat2()
.map_err(Into::into)
.and_then(|body| {
serde_urlencoded::from_bytes(&body)
.map_err(Into::into)
})
.and_then(|body| serde_urlencoded::from_bytes(&body).map_err(Into::into))
.and_then(move |arg: CreateArticle| {
if arg.base_revision != NEW {
unimplemented!("Version update conflict");
}
self.state.create_article(self.slug.clone(), arg.title, arg.body, identity)
let theme = arg.theme.unwrap_or_else(theme::random);
self.state.create_article(
self.slug.clone(),
arg.title,
arg.body,
identity,
theme,
)
})
.and_then(|updated| {
futures::finished(Response::new()
futures::finished(
Response::new()
.with_status(hyper::StatusCode::SeeOther)
.with_header(ContentType(TEXT_PLAIN.clone()))
.with_header(Location::new(updated.link().to_owned()))
.with_body("See other")
.with_body("See other"),
)
})
}),
)
}
}

View file

@ -8,15 +8,11 @@ pub struct Error;
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
write!(f, "{}", (self as &error::Error).description())
write!(f, "`after` and `before` are mutually exclusive")
}
}
impl error::Error for Error {
fn description(&self) -> &str {
"`after` and `before` are mutually exclusive"
}
}
impl error::Error for Error {}
#[derive(Deserialize)]
struct PaginationStruct<T> {
@ -37,16 +33,16 @@ impl<T> PaginationStruct<T> {
(Some(x), None) => Ok(Pagination::After(x)),
(None, Some(x)) => Ok(Pagination::Before(x)),
(None, None) => Ok(Pagination::None),
_ => Err(Error)
_ => Err(Error),
}
}
}
pub fn _from_str<'a, T: serde::Deserialize<'a>>(s: &'a str) -> Result<Pagination<T>, Error> {
let pagination: PaginationStruct<T> = serde_urlencoded::from_str(s).map_err(|_| Error)?; // TODO Proper error reporting
Ok(pagination.into_enum()?)
pagination.into_enum()
}
pub fn from_fields<T>(after: Option<T>, before: Option<T>) -> Result<Pagination<T>, Error> {
Ok(PaginationStruct { after, before }.into_enum()?)
PaginationStruct { after, before }.into_enum()
}

View file

@ -1,9 +1,9 @@
use futures::Future;
use hyper::header::{ContentType, ContentLength, CacheControl, CacheDirective};
use hyper::header::{CacheControl, CacheDirective, ContentLength, ContentType};
use hyper::server::*;
use hyper::StatusCode;
use web::{Resource, ResponseFuture};
use crate::web::{Resource, ResponseFuture};
#[allow(unused)]
pub struct ReadOnlyResource {
@ -18,21 +18,21 @@ impl Resource for ReadOnlyResource {
}
fn head(&self) -> ResponseFuture {
Box::new(::futures::finished(Response::new()
Box::new(::futures::finished(
Response::new()
.with_status(StatusCode::Ok)
.with_header(ContentType(self.content_type.clone()))
.with_header(CacheControl(vec![
CacheDirective::MustRevalidate,
CacheDirective::NoStore,
]))
])),
))
}
fn get(self: Box<Self>) -> ResponseFuture {
Box::new(self.head().map(move |head|
head
.with_header(ContentLength(self.body.len() as u64))
Box::new(self.head().map(move |head| {
head.with_header(ContentLength(self.body.len() as u64))
.with_body(self.body.clone())
))
}))
}
}

View file

@ -1,20 +1,18 @@
use futures::{self, Future};
use hyper;
use hyper::header::{Accept, ContentType};
use hyper::server::*;
use serde_json;
use serde_urlencoded;
use mimes::*;
use models::SearchResult;
use site::system_page;
use state::State;
use web::{Resource, ResponseFuture};
use crate::mimes::*;
use crate::models::SearchResult;
use crate::site::system_page;
use crate::state::State;
use crate::web::{Resource, ResponseFuture};
const DEFAULT_LIMIT: u32 = 10;
const DEFAULT_SNIPPET_SIZE: u32 = 30;
type BoxResource = Box<Resource + Sync + Send>;
type BoxResource = Box<dyn Resource + Sync + Send>;
#[derive(Serialize, Deserialize, Default)]
pub struct QueryParameters {
@ -34,21 +32,29 @@ impl QueryParameters {
pub fn limit(self, limit: u32) -> Self {
Self {
limit: if limit != DEFAULT_LIMIT { Some(limit) } else { None },
limit: if limit != DEFAULT_LIMIT {
Some(limit)
} else {
None
},
..self
}
}
pub fn snippet_size(self, snippet_size: u32) -> Self {
Self {
snippet_size: if snippet_size != DEFAULT_SNIPPET_SIZE { Some(snippet_size) } else { None },
snippet_size: if snippet_size != DEFAULT_SNIPPET_SIZE {
Some(snippet_size)
} else {
None
},
..self
}
}
pub fn into_link(self) -> String {
let args = serde_urlencoded::to_string(self).expect("Serializing to String cannot fail");
if args.len() > 0 {
if !args.is_empty() {
format!("_search?{}", args)
} else {
"_search".to_owned()
@ -66,18 +72,16 @@ impl SearchLookup {
Self { state }
}
pub fn lookup(&self, query: Option<&str>) -> Result<Option<BoxResource>, ::web::Error> {
pub fn lookup(&self, query: Option<&str>) -> Result<Option<BoxResource>, crate::web::Error> {
let args: QueryParameters = serde_urlencoded::from_str(query.unwrap_or(""))?;
Ok(Some(Box::new(
SearchResource::new(
Ok(Some(Box::new(SearchResource::new(
self.state.clone(),
args.q,
args.limit.unwrap_or(DEFAULT_LIMIT),
args.offset.unwrap_or(0),
args.snippet_size.unwrap_or(DEFAULT_SNIPPET_SIZE),
)
)))
))))
}
}
@ -98,8 +102,21 @@ pub enum ResponseType {
}
impl SearchResource {
pub fn new(state: State, query: Option<String>, limit: u32, offset: u32, snippet_size: u32) -> Self {
Self { state, response_type: ResponseType::Html, query, limit, offset, snippet_size }
pub fn new(
state: State,
query: Option<String>,
limit: u32,
offset: u32,
snippet_size: u32,
) -> Self {
Self {
state,
response_type: ResponseType::Html,
query,
limit,
offset,
snippet_size,
}
}
fn query_args(&self) -> QueryParameters {
@ -126,21 +143,24 @@ impl Resource for SearchResource {
self.response_type = match accept.first() {
Some(&QualityItem { item: ref mime, .. })
if mime.type_() == mime::APPLICATION && mime.subtype() == mime::JSON
=> ResponseType::Json,
if mime.type_() == mime::APPLICATION && mime.subtype() == mime::JSON =>
{
ResponseType::Json
}
_ => ResponseType::Html,
};
}
fn head(&self) -> ResponseFuture {
let content_type = match &self.response_type {
&ResponseType::Json => ContentType(APPLICATION_JSON.clone()),
&ResponseType::Html => ContentType(TEXT_HTML.clone()),
let content_type = match self.response_type {
ResponseType::Json => ContentType(APPLICATION_JSON.clone()),
ResponseType::Html => ContentType(TEXT_HTML.clone()),
};
Box::new(futures::finished(Response::new()
Box::new(futures::finished(
Response::new()
.with_status(hyper::StatusCode::Ok)
.with_header(content_type)
.with_header(content_type),
))
}
@ -154,7 +174,7 @@ impl Resource for SearchResource {
}
#[derive(BartDisplay)]
#[template="templates/search.html"]
#[template = "templates/search.html"]
struct Template<'a> {
query: &'a str,
hits: &'a [(usize, &'a SearchResult)],
@ -163,17 +183,26 @@ impl Resource for SearchResource {
}
// TODO: Show a search "front page" when no query is given:
let query = self.query.as_ref().map(|x| x.clone()).unwrap_or("".to_owned());
let query = self
.query
.as_ref()
.cloned()
.unwrap_or_else(|| "".to_owned());
let data = self.state.search_query(query, (self.limit + 1) as i32, self.offset as i32, self.snippet_size as i32);
let data = self.state.search_query(
query,
(self.limit + 1) as i32,
self.offset as i32,
self.snippet_size as i32,
);
let head = self.head();
Box::new(data.join(head)
.and_then(move |(mut data, head)| {
Box::new(data.join(head).and_then(move |(mut data, head)| {
let prev = if self.offset > 0 {
Some(self.query_args()
Some(
self.query_args()
.offset(self.offset.saturating_sub(self.limit))
.into_link()
.into_link(),
)
} else {
None
@ -181,35 +210,38 @@ impl Resource for SearchResource {
let next = if data.len() > self.limit as usize {
data.pop();
Some(self.query_args()
Some(
self.query_args()
.offset(self.offset + self.limit)
.into_link()
.into_link(),
)
} else {
None
};
match &self.response_type {
&ResponseType::Json => Ok(head
.with_body(serde_json::to_string(&JsonResponse {
query: self.query.as_ref().map(|x| &**x).unwrap_or(""),
match self.response_type {
ResponseType::Json => Ok(head.with_body(
serde_json::to_string(&JsonResponse {
query: self.query.as_deref().unwrap_or(""),
hits: &data,
prev,
next,
}).expect("Should never fail"))
),
&ResponseType::Html => Ok(head.with_body(system_page(
})
.expect("Should never fail"),
)),
ResponseType::Html => Ok(head.with_body(
system_page(
None, // Hmm, should perhaps accept `base` as argument
"Search",
&Template {
query: self.query.as_ref().map(|x| &**x).unwrap_or(""),
hits: &data.iter()
.enumerate()
.collect::<Vec<_>>(),
query: self.query.as_deref().unwrap_or(""),
hits: &data.iter().enumerate().collect::<Vec<_>>(),
prev,
next,
},
).to_string())),
)
.to_string(),
)),
}
}))
}

View file

@ -1,13 +1,13 @@
use futures::{self, Future};
use hyper;
use hyper::header::ContentType;
use hyper::server::*;
use mimes::*;
use models::ArticleRevisionStub;
use site::system_page;
use state::State;
use web::{Resource, ResponseFuture};
use crate::mimes::*;
use crate::models::ArticleRevisionStub;
use crate::site::system_page;
use crate::state::State;
use crate::web::{Resource, ResponseFuture};
pub struct SitemapResource {
state: State,
@ -26,15 +26,16 @@ impl Resource for SitemapResource {
}
fn head(&self) -> ResponseFuture {
Box::new(futures::finished(Response::new()
Box::new(futures::finished(
Response::new()
.with_status(hyper::StatusCode::Ok)
.with_header(ContentType(TEXT_HTML.clone()))
.with_header(ContentType(TEXT_HTML.clone())),
))
}
fn get(self: Box<Self>) -> ResponseFuture {
#[derive(BartDisplay)]
#[template="templates/sitemap.html"]
#[template = "templates/sitemap.html"]
struct Template<'a> {
articles: &'a [ArticleRevisionStub],
}
@ -42,15 +43,17 @@ impl Resource for SitemapResource {
let data = self.state.get_latest_article_revision_stubs();
let head = self.head();
Box::new(data.join(head)
.and_then(move |(articles, head)| {
Ok(head.with_body(system_page(
Box::new(data.join(head).and_then(move |(articles, head)| {
Ok(head.with_body(
system_page(
None, // Hmm, should perhaps accept `base` as argument
"Sitemap",
Template {
articles: &articles,
},
).to_string()))
)
.to_string(),
))
}))
}
}

View file

@ -1,9 +1,9 @@
use futures::{self, Future};
use hyper;
use hyper::header::Location;
use hyper::server::*;
use web::{Resource, ResponseFuture};
use crate::web::{Resource, ResponseFuture};
pub struct TemporaryRedirectResource {
location: String,
@ -14,14 +14,17 @@ impl TemporaryRedirectResource {
Self { location }
}
pub fn from_slug<S: AsRef<str>>(slug: S) -> Self {
Self {
location:
if slug.as_ref().is_empty() {
pub fn from_slug<S: AsRef<str>>(slug: S, edit: bool) -> Self {
let base = if slug.as_ref().is_empty() {
"."
} else {
slug.as_ref()
}.to_owned()
};
let tail = if edit { "?edit" } else { "" };
Self {
location: format!("{}{}", base, tail),
}
}
}
@ -33,18 +36,18 @@ impl Resource for TemporaryRedirectResource {
}
fn head(&self) -> ResponseFuture {
Box::new(futures::finished(Response::new()
Box::new(futures::finished(
Response::new()
.with_status(hyper::StatusCode::TemporaryRedirect)
.with_header(Location::new(self.location.clone()))
.with_header(Location::new(self.location.clone())),
))
}
fn get(self: Box<Self>) -> ResponseFuture {
Box::new(self.head()
.and_then(move |head| {
Ok(head
.with_body(format!("Moved to {}", self.location)))
}))
Box::new(
self.head()
.and_then(move |head| Ok(head.with_body(format!("Moved to {}", self.location)))),
)
}
fn put(self: Box<Self>, _body: hyper::Body, _identity: Option<String>) -> ResponseFuture {

View file

@ -4,24 +4,20 @@
use std::fmt;
use futures::{self, Future};
use hyper::header::{Accept, ContentType, Server};
use hyper::mime;
use hyper::server::*;
use hyper;
use assets::{ThemesCss, StyleCss, SearchJs};
use build_config;
use web::Lookup;
use wiki_lookup::WikiLookup;
const THEMES: [&str; 19] = ["red", "pink", "purple", "deep-purple", "indigo",
"blue", "light-blue", "cyan", "teal", "green", "light-green", "lime",
"yellow", "amber", "orange", "deep-orange", "brown", "gray", "blue-gray"];
use crate::assets::{SearchJs, StyleCss, ThemesCss};
use crate::build_config;
use crate::theme;
use crate::web::Lookup;
use crate::wiki_lookup::WikiLookup;
lazy_static! {
static ref TEXT_HTML: mime::Mime = "text/html;charset=utf-8".parse().unwrap();
static ref SERVER: Server =
Server::new(build_config::HTTP_SERVER.as_str());
static ref SERVER: Server = Server::new(build_config::HTTP_SERVER.as_str());
}
header! { (XIdentity, "X-Identity") => [String] }
@ -31,39 +27,48 @@ header! { (XIdentity, "X-Identity") => [String] }
pub struct Layout<'a, T: 'a + fmt::Display> {
pub base: Option<&'a str>,
pub title: &'a str,
pub theme: theme::Theme,
pub body: T,
}
impl<'a, T: 'a + fmt::Display> Layout<'a, T> {
pub fn theme(&self) -> &str {
let hash = ::seahash::hash(self.title.as_bytes()) as usize;
let choice = hash % THEMES.len();
THEMES[choice]
pub fn themes_css(&self) -> &str {
ThemesCss::resource_name()
}
pub fn style_css(&self) -> &str {
StyleCss::resource_name()
}
pub fn search_js(&self) -> &str {
SearchJs::resource_name()
}
pub fn themes_css(&self) -> &str { ThemesCss::resource_name() }
pub fn style_css(&self) -> &str { StyleCss::resource_name() }
pub fn search_js(&self) -> &str { SearchJs::resource_name() }
pub fn project_name(&self) -> &str { build_config::PROJECT_NAME }
pub fn version(&self) -> &str { build_config::VERSION.as_str() }
pub fn project_name(&self) -> &str {
build_config::PROJECT_NAME
}
pub fn version(&self) -> &str {
build_config::VERSION.as_str()
}
}
#[derive(BartDisplay)]
#[template="templates/system_page_layout.html"]
#[template = "templates/system_page_layout.html"]
pub struct SystemPageLayout<'a, T: 'a + fmt::Display> {
title: &'a str,
html_body: T,
}
pub fn system_page<'a, T>(base: Option<&'a str>, title: &'a str, body: T)
-> Layout<'a, SystemPageLayout<'a, T>>
pub fn system_page<'a, T>(
base: Option<&'a str>,
title: &'a str,
body: T,
) -> Layout<'a, SystemPageLayout<'a, T>>
where
T: 'a + fmt::Display
T: 'a + fmt::Display,
{
Layout {
base,
title,
theme: theme::theme_from_str_hash(title),
body: SystemPageLayout {
title,
html_body: body,
@ -86,41 +91,39 @@ pub struct Site {
impl Site {
pub fn new(root: WikiLookup, trust_identity: bool) -> Site {
Site { root, trust_identity }
Site {
root,
trust_identity,
}
}
fn not_found(base: Option<&str>) -> Response {
Response::new()
.with_header(ContentType(TEXT_HTML.clone()))
.with_body(system_page(
base,
"Not found",
NotFound,
).to_string())
.with_body(system_page(base, "Not found", NotFound).to_string())
.with_status(hyper::StatusCode::NotFound)
}
fn internal_server_error(base: Option<&str>, err: Box<::std::error::Error + Send + Sync>) -> Response {
fn internal_server_error(
base: Option<&str>,
err: Box<dyn ::std::error::Error + Send + Sync>,
) -> Response {
eprintln!("Internal Server Error:\n{:#?}", err);
Response::new()
.with_header(ContentType(TEXT_HTML.clone()))
.with_body(system_page(
base,
"Internal server error",
InternalServerError,
).to_string())
.with_body(system_page(base, "Internal server error", InternalServerError).to_string())
.with_status(hyper::StatusCode::InternalServerError)
}
}
fn root_base_from_request_uri(path: &str) -> Option<String> {
assert!(path.starts_with("/"));
assert!(path.starts_with('/'));
let slashes = path[1..].matches('/').count();
match slashes {
0 => None,
n => Some(::std::iter::repeat("../").take(n).collect())
n => Some("../".repeat(n)),
}
}
@ -128,7 +131,7 @@ impl Service for Site {
type Request = Request;
type Response = Response;
type Error = hyper::Error;
type Future = Box<futures::Future<Item = Response, Error = Self::Error>>;
type Future = Box<dyn futures::Future<Item = Response, Error = Self::Error>>;
fn call(&self, req: Request) -> Self::Future {
let (method, uri, _http_version, headers, body) = req.deconstruct();
@ -140,12 +143,14 @@ impl Service for Site {
false => None,
};
let accept_header = headers.get().map(|x: &Accept| x.clone()).unwrap_or(Accept(vec![]));
let accept_header = headers.get().cloned().unwrap_or_else(|| Accept(vec![]));
let base = root_base_from_request_uri(uri.path());
let base2 = base.clone(); // Bah, stupid clone
Box::new(self.root.lookup(uri.path(), uri.query())
Box::new(
self.root
.lookup(uri.path(), uri.query())
.and_then(move |resource| match resource {
Some(mut resource) => {
use hyper::Method::*;
@ -156,13 +161,13 @@ impl Service for Site {
Get => resource.get(),
Put => resource.put(body, identity),
Post => resource.post(body, identity),
_ => Box::new(futures::finished(resource.method_not_allowed()))
_ => Box::new(futures::finished(resource.method_not_allowed())),
}
},
None => Box::new(futures::finished(Self::not_found(base.as_ref().map(|x| &**x))))
}
None => Box::new(futures::finished(Self::not_found(base.as_deref()))),
})
.or_else(move |err| Ok(Self::internal_server_error(base2.as_ref().map(|x| &**x), err)))
.map(|response| response.with_header(SERVER.clone()))
.or_else(move |err| Ok(Self::internal_server_error(base2.as_deref(), err)))
.map(|response| response.with_header(SERVER.clone())),
)
}
}

View file

@ -1,15 +1,13 @@
use std;
use diesel;
use diesel::sqlite::SqliteConnection;
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use futures_cpupool::{self, CpuFuture};
use r2d2::Pool;
use r2d2_diesel::ConnectionManager;
use merge;
use models;
use schema::*;
use crate::merge;
use crate::models;
use crate::schema::*;
use crate::theme::Theme;
#[derive(Clone)]
pub struct State {
@ -17,19 +15,16 @@ pub struct State {
cpu_pool: futures_cpupool::CpuPool,
}
pub type Error = Box<std::error::Error + Send + Sync>;
pub type Error = Box<dyn std::error::Error + Send + Sync>;
pub enum SlugLookup {
Miss,
Hit {
article_id: i32,
revision: i32,
},
Hit { article_id: i32, revision: i32 },
Redirect(String),
}
#[derive(Insertable)]
#[table_name="article_revisions"]
#[table_name = "article_revisions"]
struct NewRevision<'a> {
article_id: i32,
revision: i32,
@ -38,6 +33,7 @@ struct NewRevision<'a> {
body: &'a str,
author: Option<&'a str>,
latest: bool,
theme: Theme,
}
#[derive(Debug, PartialEq)]
@ -45,11 +41,16 @@ pub struct RebaseConflict {
pub base_article: models::ArticleRevisionStub,
pub title: merge::MergeResult<char>,
pub body: merge::MergeResult<String>,
pub theme: Theme,
}
#[derive(Debug, PartialEq)]
enum RebaseResult {
Clean { title: String, body: String },
Clean {
title: String,
body: String,
theme: Theme,
},
Conflict(RebaseConflict),
}
@ -58,11 +59,17 @@ pub enum UpdateResult {
RebaseConflict(RebaseConflict),
}
fn decide_slug(conn: &SqliteConnection, article_id: i32, prev_title: &str, title: &str, prev_slug: Option<&str>) -> Result<String, Error> {
fn decide_slug(
conn: &SqliteConnection,
article_id: i32,
prev_title: &str,
title: &str,
prev_slug: Option<&str>,
) -> Result<String, Error> {
let base_slug = ::slug::slugify(title);
if let Some(prev_slug) = prev_slug {
if prev_slug == "" {
if prev_slug.is_empty() {
// Never give a non-empty slug to the front page
return Ok(String::new());
}
@ -76,9 +83,11 @@ fn decide_slug(conn: &SqliteConnection, article_id: i32, prev_title: &str, title
}
}
let base_slug = if base_slug.is_empty() { "article" } else { &base_slug };
use schema::article_revisions;
let base_slug = if base_slug.is_empty() {
"article"
} else {
&base_slug
};
let mut slug = base_slug.to_owned();
let mut disambiguator = 1;
@ -89,7 +98,8 @@ fn decide_slug(conn: &SqliteConnection, article_id: i32, prev_title: &str, title
.filter(article_revisions::slug.eq(&slug))
.filter(article_revisions::latest.eq(true))
.count()
.first::<i64>(conn)? != 0;
.first::<i64>(conn)?
!= 0;
if !slug_in_use {
break Ok(slug);
@ -110,8 +120,6 @@ impl<'a> SyncState<'a> {
}
pub fn get_article_slug(&self, article_id: i32) -> Result<Option<String>, Error> {
use schema::article_revisions;
Ok(article_revisions::table
.filter(article_revisions::article_id.eq(article_id))
.filter(article_revisions::latest.eq(true))
@ -120,9 +128,11 @@ impl<'a> SyncState<'a> {
.optional()?)
}
pub fn get_article_revision(&self, article_id: i32, revision: i32) -> Result<Option<models::ArticleRevision>, Error> {
use schema::article_revisions;
pub fn get_article_revision(
&self,
article_id: i32,
revision: i32,
) -> Result<Option<models::ArticleRevision>, Error> {
Ok(article_revisions::table
.filter(article_revisions::article_id.eq(article_id))
.filter(article_revisions::revision.eq(revision))
@ -130,14 +140,17 @@ impl<'a> SyncState<'a> {
.optional()?)
}
pub fn query_article_revision_stubs<F>(&self, f: F) -> Result<Vec<models::ArticleRevisionStub>, Error>
pub fn query_article_revision_stubs<F>(
&self,
f: F,
) -> Result<Vec<models::ArticleRevisionStub>, Error>
where
F: 'static + Send + Sync,
for <'x> F:
FnOnce(article_revisions::BoxedQuery<'x, diesel::sqlite::Sqlite>) ->
for<'x> F: FnOnce(
article_revisions::BoxedQuery<'x, diesel::sqlite::Sqlite>,
) -> article_revisions::BoxedQuery<'x, diesel::sqlite::Sqlite>,
{
use schema::article_revisions::dsl::*;
use crate::schema::article_revisions::dsl::*;
Ok(f(article_revisions.into_boxed())
.select((
@ -149,20 +162,24 @@ impl<'a> SyncState<'a> {
title,
latest,
author,
theme,
))
.load(self.db_connection)?
)
.load(self.db_connection)?)
}
fn get_article_revision_stub(&self, article_id: i32, revision: i32) -> Result<Option<models::ArticleRevisionStub>, Error> {
use schema::article_revisions;
Ok(self.query_article_revision_stubs(move |query| {
fn get_article_revision_stub(
&self,
article_id: i32,
revision: i32,
) -> Result<Option<models::ArticleRevisionStub>, Error> {
Ok(self
.query_article_revision_stubs(move |query| {
query
.filter(article_revisions::article_id.eq(article_id))
.filter(article_revisions::revision.eq(revision))
.limit(1)
})?.pop())
})?
.pop())
}
pub fn lookup_slug(&self, slug: String) -> Result<SlugLookup, Error> {
@ -174,9 +191,8 @@ impl<'a> SyncState<'a> {
}
self.db_connection.transaction(|| {
use schema::article_revisions;
Ok(match article_revisions::table
Ok(
match article_revisions::table
.filter(article_revisions::slug.eq(slug))
.order(article_revisions::sequence_number.desc())
.select((
@ -197,100 +213,154 @@ impl<'a> SyncState<'a> {
.filter(article_revisions::latest.eq(true))
.filter(article_revisions::article_id.eq(stub.article_id))
.select(article_revisions::slug)
.first::<String>(self.db_connection)?
.first::<String>(self.db_connection)?,
),
},
)
})
})
}
fn rebase_update(&self, article_id: i32, target_base_revision: i32, existing_base_revision: i32, title: String, body: String)
-> Result<RebaseResult, Error>
{
fn rebase_update(
&self,
article_id: i32,
target_base_revision: i32,
existing_base_revision: i32,
title: String,
body: String,
theme: Theme,
) -> Result<RebaseResult, Error> {
let mut title_a = title;
let mut body_a = body;
let mut theme_a = theme;
// TODO: Improve this implementation.
// Weakness: If the range of revisions is big, _one_ request from the
// client can cause _many_ database requests, cheaply causing lots
// of work for the server. Possible attack vector.
// Weakness: When the range is larger than just one iteration, the
// same title and body are retrieved from the database multiple
// times. Unnecessary extra copies.
for revision in existing_base_revision..target_base_revision {
let mut stored = article_revisions::table
.filter(article_revisions::article_id.eq(article_id))
.filter(article_revisions::revision.ge(revision))
.filter(article_revisions::revision.le(revision+1))
.filter(article_revisions::revision.le(revision + 1))
.order(article_revisions::revision.asc())
.select((
article_revisions::title,
article_revisions::body,
article_revisions::theme,
))
.load::<(String, String)>(self.db_connection)?;
.load::<(String, String, Theme)>(self.db_connection)?;
let (title_b, body_b) = stored.pop().expect("Application layer guarantee");
let (title_o, body_o) = stored.pop().expect("Application layer guarantee");
let (title_b, body_b, theme_b) = stored.pop().expect("Application layer guarantee");
let (title_o, body_o, theme_o) = stored.pop().expect("Application layer guarantee");
use merge::MergeResult::*;
use crate::merge::MergeResult::*;
fn merge_themes(a: Theme, o: Theme, b: Theme) -> Theme {
// Last change wins
if a != o {
a
} else {
b
}
}
let update = {
let title_merge = merge::merge_chars(&title_a, &title_o, &title_b);
let body_merge = merge::merge_lines(&body_a, &body_o, &body_b);
let theme = merge_themes(theme_a, theme_o, theme_b);
match (title_merge, body_merge) {
(Clean(title), Clean(body)) => (title, body),
(Clean(title), Clean(body)) => (title, body, theme),
(title_merge, body_merge) => {
return Ok(RebaseResult::Conflict(RebaseConflict {
base_article: self.get_article_revision_stub(article_id, revision+1)?.expect("Application layer guarantee"),
base_article: self
.get_article_revision_stub(article_id, revision + 1)?
.expect("Application layer guarantee"),
title: title_merge,
body: body_merge.to_strings(),
body: body_merge.into_strings(),
theme,
}));
},
}
}
};
title_a = update.0;
body_a = update.1;
theme_a = update.2;
}
Ok(RebaseResult::Clean { title: title_a, body: body_a })
Ok(RebaseResult::Clean {
title: title_a,
body: body_a,
theme: theme_a,
})
}
pub fn update_article(&self, article_id: i32, base_revision: i32, title: String, body: String, author: Option<String>)
-> Result<UpdateResult, Error>
{
pub fn update_article(
&self,
article_id: i32,
base_revision: i32,
title: String,
body: String,
author: Option<String>,
theme: Option<Theme>,
) -> Result<UpdateResult, Error> {
if title.is_empty() {
Err("title cannot be empty")?;
return Err("title cannot be empty".into());
}
self.db_connection.transaction(|| {
use schema::article_revisions;
let (latest_revision, prev_title, prev_slug) = article_revisions::table
let (latest_revision, prev_title, prev_slug, prev_theme) = article_revisions::table
.filter(article_revisions::article_id.eq(article_id))
.order(article_revisions::revision.desc())
.select((
article_revisions::revision,
article_revisions::title,
article_revisions::slug,
article_revisions::theme,
))
.first::<(i32, String, String)>(self.db_connection)?;
.first::<(i32, String, String, Theme)>(self.db_connection)?;
// TODO: If this is an historic edit repeated, just respond OK
// This scheme would make POST idempotent.
if base_revision > latest_revision {
Err("This edit is based on a future version of the article")?;
return Err("This edit is based on a future version of the article".into());
}
let rebase_result = self.rebase_update(article_id, latest_revision, base_revision, title, body)?;
let theme = theme.unwrap_or(prev_theme);
let rebase_result = self.rebase_update(
article_id,
latest_revision,
base_revision,
title,
body,
theme,
)?;
let (title, body) = match rebase_result {
RebaseResult::Clean { title, body } => (title, body),
let (title, body, theme) = match rebase_result {
RebaseResult::Clean { title, body, theme } => (title, body, theme),
RebaseResult::Conflict(x) => return Ok(UpdateResult::RebaseConflict(x)),
};
let new_revision = latest_revision + 1;
let slug = decide_slug(self.db_connection, article_id, &prev_title, &title, Some(&prev_slug))?;
let slug = decide_slug(
self.db_connection,
article_id,
&prev_title,
&title,
Some(&prev_slug),
)?;
diesel::update(
article_revisions::table
.filter(article_revisions::article_id.eq(article_id))
.filter(article_revisions::revision.eq(latest_revision))
.filter(article_revisions::revision.eq(latest_revision)),
)
.set(article_revisions::latest.eq(false))
.execute(self.db_connection)?;
@ -302,44 +372,58 @@ impl<'a> SyncState<'a> {
slug: &slug,
title: &title,
body: &body,
author: author.as_ref().map(|x| &**x),
author: author.as_deref(),
latest: true,
theme,
})
.execute(self.db_connection)?;
Ok(UpdateResult::Success(article_revisions::table
Ok(UpdateResult::Success(
article_revisions::table
.filter(article_revisions::article_id.eq(article_id))
.filter(article_revisions::revision.eq(new_revision))
.first::<models::ArticleRevision>(self.db_connection)?
.first::<models::ArticleRevision>(self.db_connection)?,
))
})
}
pub fn create_article(&self, target_slug: Option<String>, title: String, body: String, author: Option<String>)
-> Result<models::ArticleRevision, Error>
{
pub fn create_article(
&self,
target_slug: Option<String>,
title: String,
body: String,
author: Option<String>,
theme: Theme,
) -> Result<models::ArticleRevision, Error> {
if title.is_empty() {
Err("title cannot be empty")?;
return Err("title cannot be empty".into());
}
self.db_connection.transaction(|| {
#[derive(Insertable)]
#[table_name="articles"]
#[table_name = "articles"]
struct NewArticle {
id: Option<i32>
id: Option<i32>,
}
let article_id = {
use diesel::expression::sql_literal::sql;
// Diesel and SQLite are a bit in disagreement for how this should look:
sql::<(diesel::sql_types::Integer)>("INSERT INTO articles VALUES (null)")
sql::<diesel::sql_types::Integer>("INSERT INTO articles VALUES (null)")
.execute(self.db_connection)?;
sql::<(diesel::sql_types::Integer)>("SELECT LAST_INSERT_ROWID()")
sql::<diesel::sql_types::Integer>("SELECT LAST_INSERT_ROWID()")
.load::<i32>(self.db_connection)?
.pop().expect("Statement must evaluate to an integer")
.pop()
.expect("Statement must evaluate to an integer")
};
let slug = decide_slug(self.db_connection, article_id, "", &title, target_slug.as_ref().map(|x| &**x))?;
let slug = decide_slug(
self.db_connection,
article_id,
"",
&title,
target_slug.as_deref(),
)?;
let new_revision = 1;
@ -350,20 +434,26 @@ impl<'a> SyncState<'a> {
slug: &slug,
title: &title,
body: &body,
author: author.as_ref().map(|x| &**x),
author: author.as_deref(),
latest: true,
theme,
})
.execute(self.db_connection)?;
Ok(article_revisions::table
.filter(article_revisions::article_id.eq(article_id))
.filter(article_revisions::revision.eq(new_revision))
.first::<models::ArticleRevision>(self.db_connection)?
)
.first::<models::ArticleRevision>(self.db_connection)?)
})
}
pub fn search_query(&self, query_string: String, limit: i32, offset: i32, snippet_size: i32) -> Result<Vec<models::SearchResult>, Error> {
pub fn search_query(
&self,
query_string: String,
limit: i32,
offset: i32,
snippet_size: i32,
) -> Result<Vec<models::SearchResult>, Error> {
use diesel::sql_query;
use diesel::sql_types::{Integer, Text};
@ -401,7 +491,10 @@ impl<'a> SyncState<'a> {
}
impl State {
pub fn new(connection_pool: Pool<ConnectionManager<SqliteConnection>>, cpu_pool: futures_cpupool::CpuPool) -> State {
pub fn new(
connection_pool: Pool<ConnectionManager<SqliteConnection>>,
cpu_pool: futures_cpupool::CpuPool,
) -> State {
State {
connection_pool,
cpu_pool,
@ -411,7 +504,7 @@ impl State {
fn execute<F, T>(&self, f: F) -> CpuFuture<T, Error>
where
F: 'static + Sync + Send,
for <'a> F: FnOnce(SyncState<'a>) -> Result<T, Error>,
for<'a> F: FnOnce(SyncState<'a>) -> Result<T, Error>,
T: 'static + Send,
{
let connection_pool = self.connection_pool.clone();
@ -427,21 +520,30 @@ impl State {
self.execute(move |state| state.get_article_slug(article_id))
}
pub fn get_article_revision(&self, article_id: i32, revision: i32) -> CpuFuture<Option<models::ArticleRevision>, Error> {
pub fn get_article_revision(
&self,
article_id: i32,
revision: i32,
) -> CpuFuture<Option<models::ArticleRevision>, Error> {
self.execute(move |state| state.get_article_revision(article_id, revision))
}
pub fn query_article_revision_stubs<F>(&self, f: F) -> CpuFuture<Vec<models::ArticleRevisionStub>, Error>
pub fn query_article_revision_stubs<F>(
&self,
f: F,
) -> CpuFuture<Vec<models::ArticleRevisionStub>, Error>
where
F: 'static + Send + Sync,
for <'a> F:
FnOnce(article_revisions::BoxedQuery<'a, diesel::sqlite::Sqlite>) ->
for<'a> F: FnOnce(
article_revisions::BoxedQuery<'a, diesel::sqlite::Sqlite>,
) -> article_revisions::BoxedQuery<'a, diesel::sqlite::Sqlite>,
{
self.execute(move |state| state.query_article_revision_stubs(f))
}
pub fn get_latest_article_revision_stubs(&self) -> CpuFuture<Vec<models::ArticleRevisionStub>, Error> {
pub fn get_latest_article_revision_stubs(
&self,
) -> CpuFuture<Vec<models::ArticleRevisionStub>, Error> {
self.query_article_revision_stubs(|query| {
query
.filter(article_revisions::latest.eq(true))
@ -453,19 +555,38 @@ impl State {
self.execute(move |state| state.lookup_slug(slug))
}
pub fn update_article(&self, article_id: i32, base_revision: i32, title: String, body: String, author: Option<String>)
-> CpuFuture<UpdateResult, Error>
{
self.execute(move |state| state.update_article(article_id, base_revision, title, body, author))
pub fn update_article(
&self,
article_id: i32,
base_revision: i32,
title: String,
body: String,
author: Option<String>,
theme: Option<Theme>,
) -> CpuFuture<UpdateResult, Error> {
self.execute(move |state| {
state.update_article(article_id, base_revision, title, body, author, theme)
})
}
pub fn create_article(&self, target_slug: Option<String>, title: String, body: String, author: Option<String>)
-> CpuFuture<models::ArticleRevision, Error>
{
self.execute(move |state| state.create_article(target_slug, title, body, author))
pub fn create_article(
&self,
target_slug: Option<String>,
title: String,
body: String,
author: Option<String>,
theme: Theme,
) -> CpuFuture<models::ArticleRevision, Error> {
self.execute(move |state| state.create_article(target_slug, title, body, author, theme))
}
pub fn search_query(&self, query_string: String, limit: i32, offset: i32, snippet_size: i32) -> CpuFuture<Vec<models::SearchResult>, Error> {
pub fn search_query(
&self,
query_string: String,
limit: i32,
offset: i32,
snippet_size: i32,
) -> CpuFuture<Vec<models::SearchResult>, Error> {
self.execute(move |state| state.search_query(query_string, limit, offset, snippet_size))
}
}
@ -473,13 +594,13 @@ impl State {
#[cfg(test)]
mod test {
use super::*;
use db;
use crate::db;
impl UpdateResult {
pub fn unwrap(self) -> models::ArticleRevision {
match self {
UpdateResult::Success(x) => x,
_ => panic!("Expected success")
_ => panic!("Expected success"),
}
}
}
@ -488,7 +609,7 @@ mod test {
($state:ident) => {
let db = db::test_connection();
let $state = SyncState::new(&db);
}
};
}
#[test]
@ -500,16 +621,27 @@ mod test {
#[test]
fn create_article() {
init!(state);
let article_revision = state.create_article(None, "Title".into(), "Body".into(), None).unwrap();
let article_revision = state
.create_article(None, "Title".into(), "Body".into(), None, Theme::Cyan)
.unwrap();
assert_eq!("title", article_revision.slug);
assert_eq!(true, article_revision.latest);
assert!(article_revision.latest);
assert_eq!(Theme::Cyan, article_revision.theme);
}
#[test]
fn create_article_when_empty_slug_then_empty_slug() {
// Front page gets to keep its empty slug
init!(state);
let article_revision = state.create_article(Some("".into()), "Title".into(), "Body".into(), None).unwrap();
let article_revision = state
.create_article(
Some("".into()),
"Title".into(),
"Body".into(),
None,
Theme::Cyan,
)
.unwrap();
assert_eq!("", article_revision.slug);
}
@ -517,9 +649,21 @@ mod test {
fn update_article() {
init!(state);
let article = state.create_article(None, "Title".into(), "Body".into(), None).unwrap();
let article = state
.create_article(None, "Title".into(), "Body".into(), None, Theme::Cyan)
.unwrap();
let new_revision = state.update_article(article.article_id, article.revision, article.title.clone(), "New body".into(), None).unwrap().unwrap();
let new_revision = state
.update_article(
article.article_id,
article.revision,
article.title.clone(),
"New body".into(),
None,
Some(Theme::BlueGray),
)
.unwrap()
.unwrap();
assert_eq!(article.article_id, new_revision.article_id);
@ -532,46 +676,135 @@ mod test {
assert_eq!(article.slug, new_revision.slug);
assert_eq!("New body", new_revision.body);
assert_eq!(Theme::BlueGray, new_revision.theme);
}
#[test]
fn update_article_when_sequential_edits_then_last_wins() {
init!(state);
let article = state.create_article(None, "Title".into(), "Body".into(), None).unwrap();
let article = state
.create_article(None, "Title".into(), "Body".into(), None, Theme::Cyan)
.unwrap();
let first_edit = state.update_article(article.article_id, article.revision, article.title.clone(), "New body".into(), None).unwrap().unwrap();
let second_edit = state.update_article(article.article_id, first_edit.revision, article.title.clone(), "Newer body".into(), None).unwrap().unwrap();
let first_edit = state
.update_article(
article.article_id,
article.revision,
article.title.clone(),
"New body".into(),
None,
Some(Theme::Blue),
)
.unwrap()
.unwrap();
let second_edit = state
.update_article(
article.article_id,
first_edit.revision,
article.title,
"Newer body".into(),
None,
Some(Theme::Amber),
)
.unwrap()
.unwrap();
assert_eq!("Newer body", second_edit.body);
assert_eq!(Theme::Amber, second_edit.theme);
}
#[test]
fn update_article_when_edit_conflict_then_merge() {
init!(state);
let article = state.create_article(None, "Title".into(), "a\nb\nc\n".into(), None).unwrap();
let article = state
.create_article(None, "Title".into(), "a\nb\nc\n".into(), None, Theme::Cyan)
.unwrap();
let first_edit = state.update_article(article.article_id, article.revision, article.title.clone(), "a\nx\nb\nc\n".into(), None).unwrap().unwrap();
let second_edit = state.update_article(article.article_id, article.revision, article.title.clone(), "a\nb\ny\nc\n".into(), None).unwrap().unwrap();
let first_edit = state
.update_article(
article.article_id,
article.revision,
article.title.clone(),
"a\nx\nb\nc\n".into(),
None,
Some(Theme::Blue),
)
.unwrap()
.unwrap();
let second_edit = state
.update_article(
article.article_id,
article.revision,
article.title.clone(),
"a\nb\ny\nc\n".into(),
None,
Some(Theme::Amber),
)
.unwrap()
.unwrap();
assert!(article.revision < first_edit.revision);
assert!(first_edit.revision < second_edit.revision);
assert_eq!("a\nx\nb\ny\nc\n", second_edit.body);
assert_eq!(Theme::Amber, second_edit.theme);
}
#[test]
fn update_article_when_edit_conflict_then_rebase_over_multiple_revisions() {
init!(state);
let article = state.create_article(None, "Title".into(), "a\nb\nc\n".into(), None).unwrap();
let article = state
.create_article(None, "Title".into(), "a\nb\nc\n".into(), None, Theme::Cyan)
.unwrap();
let edit = state.update_article(article.article_id, article.revision, article.title.clone(), "a\nx1\nb\nc\n".into(), None).unwrap().unwrap();
let edit = state.update_article(article.article_id, edit.revision, article.title.clone(), "a\nx1\nx2\nb\nc\n".into(), None).unwrap().unwrap();
let edit = state.update_article(article.article_id, edit.revision, article.title.clone(), "a\nx1\nx2\nx3\nb\nc\n".into(), None).unwrap().unwrap();
let edit = state
.update_article(
article.article_id,
article.revision,
article.title.clone(),
"a\nx1\nb\nc\n".into(),
None,
Some(article.theme),
)
.unwrap()
.unwrap();
let edit = state
.update_article(
article.article_id,
edit.revision,
article.title.clone(),
"a\nx1\nx2\nb\nc\n".into(),
None,
Some(article.theme),
)
.unwrap()
.unwrap();
let edit = state
.update_article(
article.article_id,
edit.revision,
article.title.clone(),
"a\nx1\nx2\nx3\nb\nc\n".into(),
None,
Some(article.theme),
)
.unwrap()
.unwrap();
let rebase_edit = state.update_article(article.article_id, article.revision, article.title.clone(), "a\nb\ny\nc\n".into(), None).unwrap().unwrap();
let rebase_edit = state
.update_article(
article.article_id,
article.revision,
article.title.clone(),
"a\nb\ny\nc\n".into(),
None,
Some(article.theme),
)
.unwrap()
.unwrap();
assert!(article.revision < edit.revision);
assert!(edit.revision < rebase_edit.revision);
@ -583,10 +816,32 @@ mod test {
fn update_article_when_title_edit_conflict_then_merge_title() {
init!(state);
let article = state.create_article(None, "titlle".into(), "".into(), None).unwrap();
let article = state
.create_article(None, "titlle".into(), "".into(), None, Theme::Cyan)
.unwrap();
let first_edit = state.update_article(article.article_id, article.revision, "Titlle".into(), article.body.clone(), None).unwrap().unwrap();
let second_edit = state.update_article(article.article_id, article.revision, "title".into(), article.body.clone(), None).unwrap().unwrap();
let first_edit = state
.update_article(
article.article_id,
article.revision,
"Titlle".into(),
article.body.clone(),
None,
Some(article.theme),
)
.unwrap()
.unwrap();
let second_edit = state
.update_article(
article.article_id,
article.revision,
"title".into(),
article.body.clone(),
None,
Some(article.theme),
)
.unwrap()
.unwrap();
assert!(article.revision < first_edit.revision);
assert!(first_edit.revision < second_edit.revision);
@ -598,20 +853,110 @@ mod test {
fn update_article_when_merge_conflict() {
init!(state);
let article = state.create_article(None, "Title".into(), "a".into(), None).unwrap();
let article = state
.create_article(None, "Title".into(), "a".into(), None, Theme::Cyan)
.unwrap();
let first_edit = state.update_article(article.article_id, article.revision, article.title.clone(), "b".into(), None).unwrap().unwrap();
let conflict_edit = state.update_article(article.article_id, article.revision, article.title.clone(), "c".into(), None).unwrap();
let first_edit = state
.update_article(
article.article_id,
article.revision,
article.title.clone(),
"b".into(),
None,
Some(Theme::Blue),
)
.unwrap()
.unwrap();
let conflict_edit = state
.update_article(
article.article_id,
article.revision,
article.title.clone(),
"c".into(),
None,
Some(Theme::Amber),
)
.unwrap();
match conflict_edit {
UpdateResult::Success(..) => panic!("Expected conflict"),
UpdateResult::RebaseConflict(RebaseConflict { base_article, title, body }) => {
UpdateResult::RebaseConflict(RebaseConflict {
base_article,
title,
body,
theme,
}) => {
assert_eq!(first_edit.revision, base_article.revision);
assert_eq!(title, merge::MergeResult::Clean(article.title.clone()));
assert_eq!(body, merge::MergeResult::Conflicted(vec![
merge::Output::Conflict(vec!["c"], vec!["a"], vec!["b"]),
]).to_strings());
assert_eq!(title, merge::MergeResult::Clean(article.title));
assert_eq!(
body,
merge::MergeResult::Conflicted(vec![merge::Output::Conflict(
vec!["c"],
vec!["a"],
vec!["b"]
),])
.into_strings()
);
assert_eq!(Theme::Amber, theme);
}
};
}
#[test]
fn update_article_when_theme_conflict_then_ignore_unchanged() {
init!(state);
let article = state
.create_article(None, "Title".into(), "a\nb\nc\n".into(), None, Theme::Cyan)
.unwrap();
let _first_edit = state
.update_article(
article.article_id,
article.revision,
article.title.clone(),
"a\nx\nb\nc\n".into(),
None,
Some(Theme::Blue),
)
.unwrap()
.unwrap();
let second_edit = state
.update_article(
article.article_id,
article.revision,
article.title,
"a\nb\ny\nc\n".into(),
None,
Some(Theme::Cyan),
)
.unwrap()
.unwrap();
assert_eq!(Theme::Blue, second_edit.theme);
}
#[test]
fn update_article_with_no_given_theme_then_theme_unchanged() {
init!(state);
let article = state
.create_article(None, "Title".into(), "a\nb\nc\n".into(), None, Theme::Cyan)
.unwrap();
let edit = state
.update_article(
article.article_id,
article.revision,
article.title,
article.body,
None,
None,
)
.unwrap()
.unwrap();
assert_eq!(Theme::Cyan, edit.theme);
}
}

232
src/theme.rs Normal file
View file

@ -0,0 +1,232 @@
use std::io::Write;
use diesel::backend::Backend;
use diesel::deserialize::{self, FromSql};
use diesel::serialize::{self, Output, ToSql};
use diesel::sql_types::Text;
use diesel::sqlite::Sqlite;
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)] // Serde
#[serde(rename_all = "kebab-case")]
#[derive(AsExpression, FromSqlRow)] // Diesel
#[sql_type = "Text"]
pub enum Theme {
Red,
Pink,
Purple,
DeepPurple,
Indigo,
Blue,
LightBlue,
Cyan,
Teal,
Green,
LightGreen,
Lime,
Yellow,
Amber,
Orange,
DeepOrange,
Brown,
Gray,
BlueGray,
}
use self::Theme::*;
forward_display_to_serde!(Theme);
forward_from_str_to_serde!(Theme);
pub const THEMES: [Theme; 19] = [
Red, Pink, Purple, DeepPurple, Indigo, Blue, LightBlue, Cyan, Teal, Green, LightGreen, Lime,
Yellow, Amber, Orange, DeepOrange, Brown, Gray, BlueGray,
];
pub fn theme_from_str_hash(x: &str) -> Theme {
let hash = seahash::hash(x.as_bytes()) as usize;
let choice = hash % THEMES.len();
THEMES[choice]
}
pub fn random() -> Theme {
use rand::Rng;
*rand::thread_rng()
.choose(&THEMES)
.expect("Could only fail for an empty slice")
}
impl ToSql<Text, Sqlite> for Theme {
fn to_sql<W: Write>(&self, out: &mut Output<W, Sqlite>) -> serialize::Result {
ToSql::<Text, Sqlite>::to_sql(&self.to_string(), out)
}
}
impl FromSql<Text, Sqlite> for Theme {
fn from_sql(value: Option<&<Sqlite as Backend>::RawValue>) -> deserialize::Result<Self> {
// See Diesel's documentation on how to implement FromSql for Sqlite,
// especially with regards to the unsafe conversion below.
// http://docs.diesel.rs/diesel/deserialize/trait.FromSql.html
let text_ptr = <*const str as FromSql<Text, Sqlite>>::from_sql(value)?;
let text = unsafe { &*text_ptr };
text.parse().map_err(Into::into)
}
}
pub struct CssClass(Theme);
impl Theme {
pub fn css_class(self) -> CssClass {
CssClass(self)
}
}
use std::fmt::{self, Display, Formatter};
impl Display for CssClass {
fn fmt(&self, fmt: &mut Formatter) -> fmt::Result {
write!(fmt, "theme-{}", self.0)
}
}
#[cfg(test)]
mod test {
use std::error::Error;
use diesel::prelude::*;
use diesel::sql_query;
use diesel::sql_types::Text;
use super::*;
#[test]
fn basic_serialize() {
assert_eq!(serde_plain::to_string(&Theme::Red).unwrap(), "red");
}
#[test]
fn serialize_kebab_case() {
assert_eq!(
serde_plain::to_string(&Theme::LightGreen).unwrap(),
"light-green"
);
}
#[test]
fn serialize_json() {
#[derive(Serialize)]
struct Test {
x: Theme,
}
assert_eq!(
serde_json::to_string(&Test { x: Theme::Red }).unwrap(),
"{\"x\":\"red\"}"
);
}
#[test]
fn deserialize_json() {
#[derive(Deserialize, Debug, PartialEq, Eq)]
struct Test {
x: Theme,
}
assert_eq!(
serde_json::from_str::<Test>("{\"x\":\"red\"}").unwrap(),
Test { x: Theme::Red }
);
}
#[test]
fn serialize_urlencoded() {
#[derive(Serialize)]
struct Test {
x: Theme,
}
assert_eq!(
serde_urlencoded::to_string(&Test { x: Theme::Red }).unwrap(),
"x=red"
);
}
#[test]
fn deserialize_urlencoded() {
#[derive(Deserialize, Debug, PartialEq, Eq)]
struct Test {
x: Theme,
}
assert_eq!(
serde_urlencoded::from_str::<Test>("x=red").unwrap(),
Test { x: Theme::Red }
);
}
#[test]
fn basic_display() {
assert_eq!(&Theme::Red.to_string(), "red");
}
#[test]
fn display_kebab_case() {
assert_eq!(&Theme::LightGreen.to_string(), "light-green");
}
#[test]
fn basic_from_str() {
let indigo: Theme = "indigo".parse().unwrap();
assert_eq!(indigo, Theme::Indigo);
}
#[test]
fn to_number() {
assert_eq!(Theme::Red as i32, 0);
assert_eq!(Theme::LightGreen as i32, 10);
assert_eq!(Theme::BlueGray as i32, 18);
}
#[test]
fn from_str_hash() {
assert_eq!(theme_from_str_hash("Bartefjes"), Theme::Orange);
}
#[test]
fn css_class_display() {
assert_eq!(&Theme::Red.css_class().to_string(), "theme-red");
}
#[test]
fn basic_db_roundtrip() -> Result<(), Box<dyn Error>> {
let conn = SqliteConnection::establish(":memory:")?;
#[derive(QueryableByName, PartialEq, Eq, Debug)]
struct Row {
#[sql_type = "Text"]
theme: Theme,
}
let res = sql_query("SELECT ? as theme")
.bind::<Text, _>(DeepPurple)
.load::<Row>(&conn)?;
assert_eq!(&[Row { theme: DeepPurple }], res.as_slice());
Ok(())
}
#[test]
fn db_invalid_value_gives_error() -> Result<(), Box<dyn Error>> {
let conn = SqliteConnection::establish(":memory:")?;
#[derive(QueryableByName, PartialEq, Eq, Debug)]
struct Row {
#[sql_type = "Text"]
theme: Theme,
}
let res = sql_query("SELECT 'green' as theme").load::<Row>(&conn);
assert!(res.is_ok());
let res = sql_query("SELECT 'blueish-yellow' as theme").load::<Row>(&conn);
assert!(res.is_err());
Ok(())
}
}

View file

@ -1,9 +1,7 @@
use futures;
pub trait Lookup {
type Resource;
type Error;
type Future: futures::Future<Item=Option<Self::Resource>, Error=Self::Error>;
type Future: futures::Future<Item = Option<Self::Resource>, Error = Self::Error>;
fn lookup(&self, path: &str, query: Option<&str>) -> Self::Future;
}

View file

@ -1,5 +1,5 @@
mod resource;
mod lookup;
mod resource;
pub use self::resource::*;
pub use self::lookup::*;
pub use self::resource::*;

View file

@ -1,15 +1,13 @@
use futures;
use futures::{Future, Stream};
use hyper::{self, header, mime, server};
use hyper::server::Response;
use std;
use hyper::{self, header, mime, server};
lazy_static! {
static ref TEXT_PLAIN: mime::Mime = "text/plain;charset=utf-8".parse().unwrap();
}
pub type Error = Box<std::error::Error + Send + Sync>;
pub type ResponseFuture = Box<futures::Future<Item = server::Response, Error = Error>>;
pub type Error = Box<dyn std::error::Error + Send + Sync>;
pub type ResponseFuture = Box<dyn futures::Future<Item = server::Response, Error = Error>>;
pub trait Resource {
fn allow(&self) -> Vec<hyper::Method>;
@ -23,22 +21,24 @@ pub trait Resource {
}
fn put(self: Box<Self>, body: hyper::Body, _identity: Option<String>) -> ResponseFuture
where Self: 'static
where
Self: 'static,
{
Box::new(body
.fold((), |_, _| -> Result<(), hyper::Error> { Ok(()) })
Box::new(
body.fold((), |_, _| -> Result<(), hyper::Error> { Ok(()) })
.map_err(Into::into)
.and_then(move |_| futures::finished(self.method_not_allowed()))
.and_then(move |_| futures::finished(self.method_not_allowed())),
)
}
fn post(self: Box<Self>, body: hyper::Body, _identity: Option<String>) -> ResponseFuture
where Self: 'static
where
Self: 'static,
{
Box::new(body
.fold((), |_, _| -> Result<(), hyper::Error> { Ok(()) })
Box::new(
body.fold((), |_, _| -> Result<(), hyper::Error> { Ok(()) })
.map_err(Into::into)
.and_then(move |_| futures::finished(self.method_not_allowed()))
.and_then(move |_| futures::finished(self.method_not_allowed())),
)
}

View file

@ -2,23 +2,23 @@ use std::borrow::Cow;
use std::collections::HashMap;
use std::str::Utf8Error;
use futures::{Future, finished, failed, done};
use futures::future::FutureResult;
use futures::{done, failed, finished, Future};
use percent_encoding::percent_decode;
use slug::slugify;
use resources::*;
use state::State;
use web::{Lookup, Resource};
use crate::resources::*;
use crate::state::State;
use crate::web::{Lookup, Resource};
#[allow(unused)]
use assets::*;
use crate::assets::*;
type BoxResource = Box<Resource + Sync + Send>;
type ResourceFn = Box<Fn() -> BoxResource + Sync + Send>;
type BoxResource = Box<dyn Resource + Sync + Send>;
type ResourceFn = Box<dyn Fn() -> BoxResource + Sync + Send>;
lazy_static! {
static ref LICENSES_MAP: HashMap<&'static str, ResourceFn> = hashmap!{
static ref LICENSES_MAP: HashMap<&'static str, ResourceFn> = hashmap! {
"bsd-3-clause" => Box::new(|| Box::new(
HtmlResource::new(Some("../"), "The 3-Clause BSD License", include_str!("licenses/bsd-3-clause.html"))
) as BoxResource) as ResourceFn,
@ -54,9 +54,10 @@ fn split_one(path: &str) -> Result<(Cow<str>, Option<&str>), Utf8Error> {
Ok((head, tail))
}
fn map_lookup(map: &HashMap<&str, ResourceFn>, path: &str) ->
FutureResult<Option<BoxResource>, Box<::std::error::Error + Send + Sync>>
{
fn map_lookup(
map: &HashMap<&str, ResourceFn>,
path: &str,
) -> FutureResult<Option<BoxResource>, Box<dyn ::std::error::Error + Send + Sync>> {
let (head, tail) = match split_one(path) {
Ok(x) => x,
Err(x) => return failed(x.into()),
@ -73,13 +74,14 @@ fn map_lookup(map: &HashMap<&str, ResourceFn>, path: &str) ->
}
#[allow(unused)]
fn fs_lookup(root: &str, path: &str) ->
FutureResult<Option<BoxResource>, Box<::std::error::Error + Send + Sync>>
{
fn fs_lookup(
root: &str,
path: &str,
) -> FutureResult<Option<BoxResource>, Box<dyn ::std::error::Error + Send + Sync>> {
use std::fs::File;
use std::io::prelude::*;
let extension = path.rsplitn(2, ".").next();
let extension = path.rsplit_once('.').map(|x| x.1);
let content_type = match extension {
Some("html") => "text/html",
@ -87,17 +89,17 @@ fn fs_lookup(root: &str, path: &str) ->
Some("js") => "application/javascript",
Some("woff") => "application/font-woff",
_ => "application/binary",
}.parse().unwrap();
}
.parse()
.unwrap();
let mut filename = root.to_string();
filename.push_str(path);
let mut f = File::open(&filename)
.unwrap_or_else(|_| panic!(format!("Not found: {}", filename)));
let mut f = File::open(&filename).unwrap_or_else(|_| panic!("Not found: {}", filename));
let mut body = Vec::new();
f.read_to_end(&mut body)
.expect("Unable to read file");
f.read_to_end(&mut body).expect("Unable to read file");
finished(Some(Box::new(ReadOnlyResource { content_type, body })))
}
@ -108,7 +110,12 @@ impl WikiLookup {
let diff_lookup = DiffLookup::new(state.clone());
let search_lookup = SearchLookup::new(state.clone());
WikiLookup { state, changes_lookup, diff_lookup, search_lookup }
WikiLookup {
state,
changes_lookup,
diff_lookup,
search_lookup,
}
}
fn revisions_lookup(&self, path: &str, _query: Option<&str>) -> <Self as Lookup>::Future {
@ -126,12 +133,12 @@ impl WikiLookup {
};
Box::new(
self.state.get_article_revision(article_id, revision)
.and_then(|article_revision|
Ok(article_revision.map(move |x| Box::new(
ArticleRevisionResource::new(x)
) as BoxResource))
)
self.state
.get_article_revision(article_id, revision)
.and_then(|article_revision| {
Ok(article_revision
.map(move |x| Box::new(ArticleRevisionResource::new(x)) as BoxResource))
}),
)
}
@ -148,14 +155,11 @@ impl WikiLookup {
Err(_) => return Box::new(finished(None)),
};
Box::new(
self.state.get_article_slug(article_id)
.and_then(|slug|
Ok(slug.map(|slug| Box::new(
TemporaryRedirectResource::new(format!("../{}", slug))
) as BoxResource))
)
)
Box::new(self.state.get_article_slug(article_id).and_then(|slug| {
Ok(slug.map(|slug| {
Box::new(TemporaryRedirectResource::new(format!("../{}", slug))) as BoxResource
}))
}))
}
fn diff_lookup_f(&self, path: &str, query: Option<&str>) -> <Self as Lookup>::Future {
@ -181,30 +185,30 @@ impl WikiLookup {
};
match (head.as_ref(), tail) {
("_about", None) =>
Box::new(finished(Some(Box::new(AboutResource::new()) as BoxResource))),
("_about", Some(license)) =>
Box::new(map_lookup(&LICENSES_MAP, license)),
#[cfg(feature="dynamic-assets")]
("_assets", Some(asset)) =>
Box::new(fs_lookup(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/"), asset)),
#[cfg(not(feature="dynamic-assets"))]
("_assets", Some(asset)) =>
Box::new(map_lookup(&ASSETS_MAP, asset)),
("_by_id", Some(tail)) =>
self.by_id_lookup(tail, query),
("_changes", None) =>
Box::new(self.changes_lookup.lookup(query)),
("_diff", Some(tail)) =>
self.diff_lookup_f(tail, query),
("_new", None) =>
Box::new(finished(Some(Box::new(NewArticleResource::new(self.state.clone(), None)) as BoxResource))),
("_revisions", Some(tail)) =>
self.revisions_lookup(tail, query),
("_search", None) =>
Box::new(done(self.search_lookup.lookup(query))),
("_sitemap", None) =>
Box::new(finished(Some(Box::new(SitemapResource::new(self.state.clone())) as BoxResource))),
("_about", None) => Box::new(finished(Some(
Box::new(AboutResource::new()) as BoxResource
))),
("_about", Some(license)) => Box::new(map_lookup(&LICENSES_MAP, license)),
#[cfg(feature = "dynamic-assets")]
("_assets", Some(asset)) => Box::new(fs_lookup(
concat!(env!("CARGO_MANIFEST_DIR"), "/assets/"),
asset,
)),
#[cfg(not(feature = "dynamic-assets"))]
("_assets", Some(asset)) => Box::new(map_lookup(&ASSETS_MAP, asset)),
("_by_id", Some(tail)) => self.by_id_lookup(tail, query),
("_changes", None) => Box::new(self.changes_lookup.lookup(query)),
("_diff", Some(tail)) => self.diff_lookup_f(tail, query),
("_new", None) => Box::new(finished(Some(Box::new(NewArticleResource::new(
self.state.clone(),
None,
true,
)) as BoxResource))),
("_revisions", Some(tail)) => self.revisions_lookup(tail, query),
("_search", None) => Box::new(done(self.search_lookup.lookup(query))),
("_sitemap", None) => Box::new(finished(Some(Box::new(SitemapResource::new(
self.state.clone(),
)) as BoxResource))),
_ => Box::new(finished(None)),
}
}
@ -220,42 +224,49 @@ impl WikiLookup {
return Box::new(finished(None));
}
let edit = query == Some("edit");
// Normalize all user-generated slugs:
let slugified_slug = slugify(&slug);
if slugified_slug != slug {
return Box::new(finished(Some(
Box::new(TemporaryRedirectResource::from_slug(slugified_slug)) as BoxResource
Box::new(TemporaryRedirectResource::from_slug(slugified_slug, edit)) as BoxResource,
)));
}
let state = self.state.clone();
let edit = query == Some("edit");
let slug = slug.into_owned();
use state::SlugLookup;
Box::new(self.state.lookup_slug(slug.clone())
.and_then(move |x| Ok(Some(match x {
SlugLookup::Miss =>
Box::new(NewArticleResource::new(state, Some(slug))) as BoxResource,
SlugLookup::Hit { article_id, revision } =>
Box::new(ArticleResource::new(state, article_id, revision, edit)) as BoxResource,
SlugLookup::Redirect(slug) =>
Box::new(TemporaryRedirectResource::from_slug(slug)) as BoxResource,
})))
)
use crate::state::SlugLookup;
Box::new(self.state.lookup_slug(slug.clone()).and_then(move |x| {
Ok(Some(match x {
SlugLookup::Miss => {
Box::new(NewArticleResource::new(state, Some(slug), edit)) as BoxResource
}
SlugLookup::Hit {
article_id,
revision,
} => {
Box::new(ArticleResource::new(state, article_id, revision, edit)) as BoxResource
}
SlugLookup::Redirect(slug) => {
Box::new(TemporaryRedirectResource::from_slug(slug, edit)) as BoxResource
}
}))
}))
}
}
impl Lookup for WikiLookup {
type Resource = BoxResource;
type Error = Box<::std::error::Error + Send + Sync>;
type Future = Box<Future<Item = Option<Self::Resource>, Error = Self::Error>>;
type Error = Box<dyn ::std::error::Error + Send + Sync>;
type Future = Box<dyn Future<Item = Option<Self::Resource>, Error = Self::Error>>;
fn lookup(&self, path: &str, query: Option<&str>) -> Self::Future {
assert!(path.starts_with("/"));
assert!(path.starts_with('/'));
let path = &path[1..];
if path.starts_with("_") {
if path.starts_with('_') {
self.reserved_lookup(path, query)
} else {
self.article_lookup(path, query)

View file

@ -5,7 +5,7 @@
{{>article_contents.html}}
</div>
<form id="article-editor" action="" method="POST">
<form autocomplete="off" id="article-editor" action="" method="POST">
<div class="editor">
<div class="hero">
@ -14,6 +14,12 @@
</header>
</div>
<div class="theme-picker">
{{#themes}}
<input autocomplete="off" type="radio" name="theme" value="{{.theme}}"{{#.selected?}} checked{{/.selected}} class="theme-picker--option {{.theme.css_class()}} themed">
{{/themes}}
</div>
<article>
<p>
<input autocomplete=off type=hidden name=base_revision value="{{revision}}">
@ -25,10 +31,20 @@
</div>
<div class="editor-controls">
{{#cancel_url}}
<a class="cancel" href="{{.}}">Cancel</a>
{{/cancel_url}}
<button type=submit>Save</button>
{{#edit?}}
<div class="cancel-interaction-group {{#cancel_url}}interaction-group--root--enabled{{/cancel_url}}{{^cancel_url}}interaction-group--root--disabled{{/cancel_url}}">
<a class="interaction-group--enabled button button-cancel cancel" href="{{#cancel_url}}{{.}}{{/cancel_url}}">Cancel</a>
<button class="interaction-group--disabled button button-cancel" disabled>Cancel</a>
</div>
<button class="button button-default" type=submit {{^edit?}}disabled{{/edit}}>Save</button>
{{/edit}}
{{^edit?}}
<div class="cancel-interaction-group interaction-group--root--disabled">
<a class="interaction-group--enabled button button-cancel cancel" href="{{#cancel_url}}{{.}}{{/cancel_url}}">Cancel</a>
<button class="interaction-group--disabled button button-cancel" disabled>Cancel</a>
</div>
<button class="button button-default" type=submit disabled>Save</button>
{{/edit}}
</div>
</form>

View file

@ -10,6 +10,7 @@
<p>
You are viewing the difference between two {{#consecutive?}}consecutive{{/consecutive}}
revisions of <a href="_by_id/{{article_id}}">this article</a>.
{{#author}}This changeset was authored by <a href="{{..author_link}}">{{.}}</a>.{{/author}}
</p>
<p>

View file

@ -9,7 +9,7 @@
<link href="_assets/{{style_css()}}" rel="stylesheet">
<meta name="generator" content="{{project_name()}} {{version()}}" />
</head>
<body class="theme-{{theme()}}">
<body class="{{theme.css_class()}}">
{{>search_input.html}}
{{{body}}}
</body>

View file

@ -1,7 +1,7 @@
<div class="search-container">
<form class="search keyboard-focus-control" action=_search method=GET>
<div class="search-widget-container">
<input data-focusindex="0" type=search name=q placeholder=search autocomplete=off>
<input data-focusindex="0" type=search name=q placeholder=Search autocomplete=off>
<ul class="live-results search-results">
</ul>
</div>