1
1
mirror of https://github.com/aelve/guide.git synced 2024-12-23 04:42:24 +03:00

Implement merging for traits

This commit is contained in:
Artyom 2016-06-19 00:52:19 +03:00
parent 57afee3b64
commit ccb8693a97
8 changed files with 599 additions and 17 deletions

View File

@ -34,6 +34,7 @@ executable guide
Config
Types
Utils
Merge
Cache
Markdown
JS
@ -63,8 +64,8 @@ executable guide
, friendly-time == 0.4.*
, hashable
, http-types
, iproute == 1.7.*
, ilist
, iproute == 1.7.*
, lucid >= 2.9.5 && < 3
, megaparsec == 4.4.*
, microlens-platform >= 0.2.3
@ -72,15 +73,18 @@ executable guide
, mtl >= 2.1.1
, neat-interpolation == 0.3.*
, network
, patches-vector
, path-pieces
, random >= 1.1
, safecopy
, shortcut-links >= 0.4.2
, split
, stm-containers == 0.2.10.*
, template-haskell
, text-all == 0.3.*
, time >= 1.5
, transformers
, vector
, wai
, wai-middleware-metrics
, wai-middleware-static

View File

@ -43,6 +43,7 @@ allJSFunctions = JS . T.unlines . map fromJS $ [
autosizeTextarea,
expandHash,
expandItemNotes,
showDiffPopup,
-- Creating parts of interface
makeTraitEditor,
makeItemNotesEditor,
@ -289,6 +290,73 @@ expandItemNotes =
switchSection("#item-notes-"+itemId, "expanded");
|]
showDiffPopup :: JSFunction a => a
showDiffPopup =
makeJSFunction "showDiffPopup" ["ours", "modified", "merged", "send"]
[text|
dialog = $("<div>", {
"class" : "diff-popup"
})[0];
choices = $("<div>", {
"class" : "diff-choices"
})[0];
// our version
choiceOurs = $("<div>", {
"class" : "var-a" })[0];
textOurs = $("<div>", {
"class" : "text",
"text" : ours })[0];
headerOurs = $("<strong>", {
"text" : "Your version" })[0];
buttonOurs = $("<button>", {
"text" : "Submit this version, disregard changes on the server" })[0];
$(buttonOurs).click(function() {
send(ours); });
$(choiceOurs).append(headerOurs, textOurs, buttonOurs);
// modified version
choiceMod = $("<div>", {
"class" : "var-b" })[0];
textMod = $("<div>", {
"class" : "text",
"text" : modified })[0];
headerMod = $("<strong>", {
"text" : "Version on the server" })[0];
buttonMod = $("<button>", {
"text" : "Accept this version, disregard my changes" })[0];
$(buttonMod).click(function() {
send(modified); });
$(choiceMod).append(headerMod, textMod, buttonMod);
// building merged
choiceMerged = $("<div>", {
"class" : "var-merged" })[0];
areaMerged = $("<textarea>", {
"autocomplete" : "off",
"text" : merged })[0];
headerMerged = $("<strong>", {
"text" : "Merged version (edit if needed)" })[0];
buttonMerged = $("<button>", {
"text" : "Submit the merged version" })[0];
$(buttonMerged).click(function () {
send(areaMerged.value); });
$(choiceMerged).append(headerMerged, areaMerged, buttonMerged);
$(choices).append(choiceOurs, choiceMod, choiceMerged);
$(dialog).append(choices);
$.magnificPopup.open({
modal: true,
items: {
src: dialog,
type: 'inline' }
});
autosizeTextarea(areaMerged);
|]
{- Note [dynamic interface]
~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -305,7 +373,8 @@ See <https://github.com/aelve/guide/issues/24>.
makeTraitEditor :: JSFunction a => a
makeTraitEditor =
makeJSFunction "makeTraitEditor"
["traitNode", "sectionNode", "textareaUid", "content", "itemId", "traitId"]
["traitNode", "sectionNode", "textareaUid",
"content", "itemId", "traitId"]
[text|
$(sectionNode).html("");
area = $("<textarea>", {
@ -316,7 +385,7 @@ makeTraitEditor =
"text" : content })[0];
area.onkeydown = function (event) {
if (event.keyCode == 13) {
submitTrait(traitNode, itemId, traitId, area.value);
submitTrait(traitNode, itemId, traitId, content, area.value);
return false; } };
br = $("<br>")[0];
a = $("<a>", {
@ -479,16 +548,28 @@ addCon =
submitTrait :: JSFunction a => a
submitTrait =
makeJSFunction "submitTrait" ["node", "itemId", "traitId", "s"]
makeJSFunction "submitTrait"
["node", "itemId", "traitId", "original", "ours"]
[text|
$.post("/haskell/set/item/"+itemId+"/trait/"+traitId, {content: s})
.done(function (data) {
$.post({
url: "/haskell/set/item/"+itemId+"/trait/"+traitId,
data: {
original : original,
content : ours },
success: function (data) {
$.magnificPopup.close();
$(node).replaceWith(data);
switchSection(node, "editable");
// Switching has to be done here and not in 'Main.renderTrait'
// because $.post is asynchronous and will be done *after*
// switchSection has worked.
switchSection(node, "editable"); },
statusCode: {
409: function (xhr, st, err) {
modified = xhr.responseJSON["modified"];
merged = xhr.responseJSON["merged"];
showDiffPopup(ours, modified, merged, function (x) {
submitTrait(node, itemId, traitId, modified, x) }); } }
});
// Switching has to be done here and not in 'Main.renderTrait'
// because $.post is asynchronous and will be done *after*
// switchSection has worked.
|]
submitItemInfo :: JSFunction a => a

View File

@ -65,6 +65,7 @@ import JS (JS(..), allJSFunctions)
import Utils
import Markdown
import Cache
import Merge
{- Note [acid-state]
@ -80,11 +81,11 @@ This application doesn't use a database instead, it uses acid-state. Acid-st
* The data is kept in-memory, but all changes are logged to the disk (which lets us recover the state in case of a crash by reapplying the changes) and you can't access the state directly. When the application exits, it creates a snapshot of the state (called checkpoint) and writes it to the disk. Additionally, a checkpoint is created every hour (grep for createCheckpoint).
* acid-state has a nasty surprise when the state hasn't changed, 'createCheckpoint' appends it to the previous checkpoint. When state doesn't change for a long time, it means that checkpoints can grow to 100 MB or more. So, we employ a dirty bit and use createCheckpoint' instead of createCheckpoint (which doesn't create the checkpoint if the dirty bit isn't set).
* acid-state has a nasty feature when the state hasn't changed, 'createCheckpoint' appends it to the previous checkpoint. When state doesn't change for a long time, it means that checkpoints can grow to 100 MB or more. So, we employ a dirty bit and use createCheckpoint' instead of createCheckpoint. The former only creates the checkpoint if the dirty bit is set, which is good.
* When any type is changed, we have to write a migration function that would read the old version of the type and turn it into the new version. It's enough to keep just one old version (and even that isn't needed after the migration happened and a new checkpoint has been created). For examples, look at instance Migrate in Types.hs. Also, all types involved in acid-state (whether migrate-able or not) have to have a SafeCopy instance, which is generated by 'deriveSafeCopySimple'.
* There are actually ways to access the state directly (GetGlobalState and SetGlobalState), but the latter should only be used when doing something one-off (like migrating all IDs to a different ID scheme, or whatever).
* There are actually ways to access the state directly (GetGlobalState and SetGlobalState), but the latter should only be used when doing something one-off (e.g. if you need to migrate all IDs to a different ID scheme).
-}
@ -92,7 +93,8 @@ This application doesn't use a database instead, it uses acid-state. Acid-st
-- creating checkpoints, etc).
type DB = AcidState GlobalState
-- | Update something in the database.
-- | Update something in the database. Don't forget to 'invalidateCache' when
-- you update something that is cached.
dbUpdate :: (MonadIO m, HasSpock m, SpockState m ~ ServerState,
EventState event ~ GlobalState, UpdateEvent event)
=> event -> m (EventResult event)
@ -481,11 +483,20 @@ setMethods = Spock.subcomponent "set" $ do
lucidIO $ renderItemNotes category item
-- Trait
Spock.post (itemVar <//> traitVar) $ \itemId traitId -> do
original <- param' "original"
content' <- param' "content"
invalidateCache' (CacheItemTraits itemId)
(edit, trait) <- dbUpdate (SetTraitContent itemId traitId content')
addEdit edit
lucidIO $ renderTrait itemId trait
modified <- view (content.mdText) <$> dbQuery (GetTrait itemId traitId)
if modified == original
then do
invalidateCache' (CacheItemTraits itemId)
(edit, trait) <- dbUpdate (SetTraitContent itemId traitId content')
addEdit edit
lucidIO $ renderTrait itemId trait
else do
setStatus HTTP.status409
json $ M.fromList [
("modified" :: Text, modified),
("merged" :: Text, merge original content' modified)]
addMethods :: SpockM () () ServerState ()
addMethods = Spock.subcomponent "add" $ do

83
src/Merge.hs Normal file
View File

@ -0,0 +1,83 @@
{-# LANGUAGE
OverloadedStrings,
NoImplicitPrelude
#-}
module Merge
(
merge,
)
where
-- TODO: get rid of “-- General” everywhere (and in Emacs snippet too)
-- General
import BasePrelude
-- Lenses
import Lens.Micro.Platform hiding ((&))
-- Text
import qualified Data.Text.All as T
import Data.Text.All (Text)
import Data.List.Split
-- Vector
import qualified Data.Vector as V
-- Diffing
import qualified Data.Patch as PV
-- | An implementation of a 3-way diff and merge.
merge
:: Text -- ^ Original text
-> Text -- ^ Variant A (preferred)
-> Text -- ^ Variant B
-> Text -- ^ Merged text
merge orig a b = T.concat . V.toList $ PV.apply (pa <> pb') orig'
where
(orig', a', b') = (orig, a, b) & each %~
V.fromList . consolidate . map T.toStrict . break' . T.toString
pa = PV.diff orig' a'
pb = PV.diff orig' b'
(_, pb') = PV.transformWith PV.ours pa pb
-- | Break a string into words, spaces, and special characters.
break' :: String -> [String]
break' = split . dropInitBlank . dropFinalBlank . dropInnerBlanks . whenElt $
\c -> not (isAlphaNum c) && c /= '\''
-- | Consolidate some of the things into tokens (like links, consecutive
-- spaces, and Markdown elements).
consolidate :: [Text] -> [Text]
-- spaces
consolidate s@(" ":_) =
let (l, r) = span (== " ") s
in T.concat l : consolidate r
-- breaks between paragraphs
consolidate s@("\n":_) =
let (l, r) = span (== "\n") s
in T.concat l : consolidate r
-- code block markers
consolidate s@("~":_) =
let (l, r) = span (== "~") s
in if length l >= 3 then T.concat l : consolidate r else l ++ consolidate r
consolidate s@("`":_) =
let (l, r) = span (== "`") s
in if length l >= 3 then T.concat l : consolidate r else l ++ consolidate r
-- hrules
consolidate s@("-":_) =
let (l, r) = span (== "-") s
in if length l >= 3 then T.concat l : consolidate r else l ++ consolidate r
-- ellipses
consolidate (".":".":".":xs) = "..." : consolidate xs
-- links
consolidate s@("http":":":"/":"/":_) =
let (l, r) = span (\x -> x /= ")" && not (isSpace (T.head x))) s
in T.concat l : consolidate r
consolidate s@("https":":":"/":"/":_) =
let (l, r) = span (\x -> x /= ")" && not (isSpace (T.head x))) s
in T.concat l : consolidate r
consolidate ("(":"@":"hk":")":xs) = "(" : "@hk" : ")" : consolidate xs
-- the rest
consolidate (x:xs) = x : consolidate xs
consolidate [] = []

View File

@ -558,6 +558,9 @@ wrapPage pageTitle page = doctypehtml_ $ do
return false; };
|]
includeJS "/jquery.js"
-- for modal dialogs
includeJS "/magnific-popup.js"
includeCSS "/magnific-popup.css"
-- See Note [autosize]
includeJS "/autosize.js"
onPageLoad (JS "autosize($('textarea'));")

View File

@ -22,6 +22,8 @@ h1 {
/* Other CSS */
/* TODO: move }s to new lines */
#footer {
display: flex;
flex-flow: row wrap;
@ -189,3 +191,46 @@ textarea.fullwidth {
.notes-toc {
background-color: rgba(10, 10, 10, 0.1);
padding: 1px 0; }
.diff-popup {
background: white;
padding: 20px 30px;
text-align: left;
margin: 40px auto;
position: relative;
}
.diff-choices {
display: flex;
flex-flow: row wrap;
justify-content: space-between;
margin: 0px -10px;
}
.diff-choices > * {
flex: 1;
margin-top: 10px;
margin: 7px 5px;
min-width: 400px;
margin-top: 10px;
}
.diff-choices > * > .text {
border: 1px solid gray;
padding: 5px 10px;
margin: 5px 0px;
font-size: 80%;
word-wrap: break-word;
}
.diff-choices > .var-a > .text {
background-color: #fdd;
}
.diff-choices > .var-b > .text {
background-color: #cfc;
}
.diff-choices > .var-merged > textarea {
margin: 5px 0px;
width: 100%;
}

351
static/magnific-popup.css Normal file
View File

@ -0,0 +1,351 @@
/* Magnific Popup CSS */
.mfp-bg {
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1042;
overflow: hidden;
position: fixed;
background: #0b0b0b;
opacity: 0.8; }
.mfp-wrap {
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1043;
position: fixed;
outline: none !important;
-webkit-backface-visibility: hidden; }
.mfp-container {
text-align: center;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
padding: 0 8px;
box-sizing: border-box; }
.mfp-container:before {
content: '';
display: inline-block;
height: 100%;
vertical-align: middle; }
.mfp-align-top .mfp-container:before {
display: none; }
.mfp-content {
position: relative;
display: inline-block;
vertical-align: middle;
margin: 0 auto;
text-align: left;
z-index: 1045; }
.mfp-inline-holder .mfp-content,
.mfp-ajax-holder .mfp-content {
width: 100%;
cursor: auto; }
.mfp-ajax-cur {
cursor: progress; }
.mfp-zoom-out-cur, .mfp-zoom-out-cur .mfp-image-holder .mfp-close {
cursor: -moz-zoom-out;
cursor: -webkit-zoom-out;
cursor: zoom-out; }
.mfp-zoom {
cursor: pointer;
cursor: -webkit-zoom-in;
cursor: -moz-zoom-in;
cursor: zoom-in; }
.mfp-auto-cursor .mfp-content {
cursor: auto; }
.mfp-close,
.mfp-arrow,
.mfp-preloader,
.mfp-counter {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none; }
.mfp-loading.mfp-figure {
display: none; }
.mfp-hide {
display: none !important; }
.mfp-preloader {
color: #CCC;
position: absolute;
top: 50%;
width: auto;
text-align: center;
margin-top: -0.8em;
left: 8px;
right: 8px;
z-index: 1044; }
.mfp-preloader a {
color: #CCC; }
.mfp-preloader a:hover {
color: #FFF; }
.mfp-s-ready .mfp-preloader {
display: none; }
.mfp-s-error .mfp-content {
display: none; }
button.mfp-close,
button.mfp-arrow {
overflow: visible;
cursor: pointer;
background: transparent;
border: 0;
-webkit-appearance: none;
display: block;
outline: none;
padding: 0;
z-index: 1046;
box-shadow: none;
touch-action: manipulation; }
button::-moz-focus-inner {
padding: 0;
border: 0; }
.mfp-close {
width: 44px;
height: 44px;
line-height: 44px;
position: absolute;
right: 0;
top: 0;
text-decoration: none;
text-align: center;
opacity: 0.65;
padding: 0 0 18px 10px;
color: #FFF;
font-style: normal;
font-size: 28px;
font-family: Arial, Baskerville, monospace; }
.mfp-close:hover,
.mfp-close:focus {
opacity: 1; }
.mfp-close:active {
top: 1px; }
.mfp-close-btn-in .mfp-close {
color: #333; }
.mfp-image-holder .mfp-close,
.mfp-iframe-holder .mfp-close {
color: #FFF;
right: -6px;
text-align: right;
padding-right: 6px;
width: 100%; }
.mfp-counter {
position: absolute;
top: 0;
right: 0;
color: #CCC;
font-size: 12px;
line-height: 18px;
white-space: nowrap; }
.mfp-arrow {
position: absolute;
opacity: 0.65;
margin: 0;
top: 50%;
margin-top: -55px;
padding: 0;
width: 90px;
height: 110px;
-webkit-tap-highlight-color: transparent; }
.mfp-arrow:active {
margin-top: -54px; }
.mfp-arrow:hover,
.mfp-arrow:focus {
opacity: 1; }
.mfp-arrow:before,
.mfp-arrow:after {
content: '';
display: block;
width: 0;
height: 0;
position: absolute;
left: 0;
top: 0;
margin-top: 35px;
margin-left: 35px;
border: medium inset transparent; }
.mfp-arrow:after {
border-top-width: 13px;
border-bottom-width: 13px;
top: 8px; }
.mfp-arrow:before {
border-top-width: 21px;
border-bottom-width: 21px;
opacity: 0.7; }
.mfp-arrow-left {
left: 0; }
.mfp-arrow-left:after {
border-right: 17px solid #FFF;
margin-left: 31px; }
.mfp-arrow-left:before {
margin-left: 25px;
border-right: 27px solid #3F3F3F; }
.mfp-arrow-right {
right: 0; }
.mfp-arrow-right:after {
border-left: 17px solid #FFF;
margin-left: 39px; }
.mfp-arrow-right:before {
border-left: 27px solid #3F3F3F; }
.mfp-iframe-holder {
padding-top: 40px;
padding-bottom: 40px; }
.mfp-iframe-holder .mfp-content {
line-height: 0;
width: 100%;
max-width: 900px; }
.mfp-iframe-holder .mfp-close {
top: -40px; }
.mfp-iframe-scaler {
width: 100%;
height: 0;
overflow: hidden;
padding-top: 56.25%; }
.mfp-iframe-scaler iframe {
position: absolute;
display: block;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.6);
background: #000; }
/* Main image in popup */
img.mfp-img {
width: auto;
max-width: 100%;
height: auto;
display: block;
line-height: 0;
box-sizing: border-box;
padding: 40px 0 40px;
margin: 0 auto; }
/* The shadow behind the image */
.mfp-figure {
line-height: 0; }
.mfp-figure:after {
content: '';
position: absolute;
left: 0;
top: 40px;
bottom: 40px;
display: block;
right: 0;
width: auto;
height: auto;
z-index: -1;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.6);
background: #444; }
.mfp-figure small {
color: #BDBDBD;
display: block;
font-size: 12px;
line-height: 14px; }
.mfp-figure figure {
margin: 0; }
.mfp-bottom-bar {
margin-top: -36px;
position: absolute;
top: 100%;
left: 0;
width: 100%;
cursor: auto; }
.mfp-title {
text-align: left;
line-height: 18px;
color: #F3F3F3;
word-wrap: break-word;
padding-right: 36px; }
.mfp-image-holder .mfp-content {
max-width: 100%; }
.mfp-gallery .mfp-image-holder .mfp-figure {
cursor: pointer; }
@media screen and (max-width: 800px) and (orientation: landscape), screen and (max-height: 300px) {
/**
* Remove all paddings around the image on small screen
*/
.mfp-img-mobile .mfp-image-holder {
padding-left: 0;
padding-right: 0; }
.mfp-img-mobile img.mfp-img {
padding: 0; }
.mfp-img-mobile .mfp-figure:after {
top: 0;
bottom: 0; }
.mfp-img-mobile .mfp-figure small {
display: inline;
margin-left: 5px; }
.mfp-img-mobile .mfp-bottom-bar {
background: rgba(0, 0, 0, 0.6);
bottom: 0;
margin: 0;
top: auto;
padding: 3px 5px;
position: fixed;
box-sizing: border-box; }
.mfp-img-mobile .mfp-bottom-bar:empty {
padding: 0; }
.mfp-img-mobile .mfp-counter {
right: 5px;
top: 3px; }
.mfp-img-mobile .mfp-close {
top: 0;
right: 0;
width: 35px;
height: 35px;
line-height: 35px;
background: rgba(0, 0, 0, 0.6);
position: fixed;
text-align: center;
padding: 0; } }
@media all and (max-width: 900px) {
.mfp-arrow {
-webkit-transform: scale(0.75);
transform: scale(0.75); }
.mfp-arrow-left {
-webkit-transform-origin: 0;
transform-origin: 0; }
.mfp-arrow-right {
-webkit-transform-origin: 100%;
transform-origin: 100%; }
.mfp-container {
padding-left: 6px;
padding-right: 6px; } }

4
static/magnific-popup.js Normal file

File diff suppressed because one or more lines are too long