Add support for bag descriptions, validate bags and recipes, and complete the UI for creating bags and recipes

Styling to come
This commit is contained in:
Jeremy Ruston 2024-01-24 22:24:24 +00:00
parent 0b9749f3a4
commit b0a67300cc
6 changed files with 254 additions and 109 deletions

View File

@ -1,107 +1,169 @@
title: MultiWikiServer Administration
\procedure createBag(name)
\procedure completion-createBag()
\import [subfilter{$:/core/config/GlobalImportFilter}]
<$action-log msg="In completion-createBag"/>
<$action-log/>
\end completion-createBag
<$action-sendmessage
$message="tm-http-request"
url=`/wiki/$(name)$/bags/$(name)$`
method="PUT"
oncompletion=<<completion-createBag>>
/>
\procedure createBag(name,description)
\procedure completion-createBag()
\import [subfilter{$:/core/config/GlobalImportFilter}]
<$action-log msg="In completion-createBag"/>
<$action-log/>
\end completion-createBag
<$action-sendmessage
$message="tm-http-request"
url=`/wiki/$(name)$/bags/$(name)$`
method="PUT"
body=`{"description":"${ [<description>encodeuricomponent[]] }$"}`
oncompletion=<<completion-createBag>>
/>
\end createBag
\procedure createBagButton(name)
<$button class="">
<$transclude $variable="createBag" name={{$:/state/NewBagName}}/>
{{$:/core/images/new-button}}
</$button><span class="tc-btn-text"><$text text="Create a new bag:"/></span><$edit-text tiddler="$:/state/NewBagName" tag="input"/>
\whitespace trim
<form class="mws-form">
<div class="mws-form-heading">
<$text text="Create a new bag"/>
</div>
<div class="mws-form-fields">
<div class="mws-form-field">
<label class="mws-form-field-description">
Bag name
</label>
<$edit-text tiddler="$:/state/NewBagName" tag="input" placeholder="(bag name)" class="mws-form-field-input"/>
</div>
<div class="mws-form-field">
<label class="mws-form-field-description">
Bag description
</label>
<$edit-text tiddler="$:/state/NewBagDescription" tag="input" placeholder="(description)" class="mws-form-field-input"/>
</div>
</div>
<div class="mws-form-buttons">
<$button class="mws-form-button">
<$transclude
$variable="createBag"
name={{$:/state/NewBagName}}
description={{$:/state/NewBagDescription}}
/>
Create Bag
</$button>
</div>
</form>
\end createBagButton
\procedure createRecipe(name)
\procedure completion-createRecipe()
\import [subfilter{$:/core/config/GlobalImportFilter}]
<$action-log msg="In completion-createRecipe"/>
<$action-log/>
\end completion-createRecipe
<$action-sendmessage
$message="tm-http-request"
url=`/wiki/$(name)$/recipes/$(name)$`
method="PUT"
oncompletion=<<completion-createRecipe>>
/>
\procedure createRecipe(name,bag_names,description)
\procedure completion-createRecipe()
\import [subfilter{$:/core/config/GlobalImportFilter}]
<$action-log msg="In completion-createRecipe"/>
<$action-log/>
\end completion-createRecipe
\procedure emptyArray() []
\function createRecipeJson()
[<bag_names>enlist-input[]] :reduce[<accumulator>!match[]else<emptyArray>jsonset<index>,<currentTiddler>]
\end createRecipeJson
<$action-log message="Sending" body=<<createRecipeJson>>/>
<$action-sendmessage
$message="tm-http-request"
url=`/wiki/$(name)$/recipes/$(name)$`
method="PUT"
body=`{"bag_names":${ [<createRecipeJson>] }$,"description":"${ [<description>encodeuricomponent[]] }$"}`
oncompletion=<<completion-createRecipe>>
/>
\end createRecipe
\procedure createRecipeButton(name)
<$button class="">
<$transclude $variable="createRecipe" name={{$:/state/NewRecipeName}}/>
{{$:/core/images/new-button}}
</$button><span class="tc-btn-text"><$text text="Create a new recipe:"/></span><$edit-text tiddler="$:/state/NewRecipeName" tag="input"/>
\procedure createRecipeButton()
\whitespace trim
<form class="mws-form">
<div class="mws-form-heading">
<$text text="Create a new recipe"/>
</div>
<div class="mws-form-fields">
<div class="mws-form-field">
<label class="mws-form-field-description">
Recipe name
</label>
<$edit-text tiddler="$:/state/NewRecipeName" tag="input" placeholder="(recipe name)" class="mws-form-field-input"/>
</div>
<div class="mws-form-field">
<label class="mws-form-field-description">
Bag names
</label>
<$edit-text tiddler="$:/state/NewRecipeBagNames" tag="input" placeholder="(space separated list of bags)"/>
</div>
<div class="mws-form-field">
<label class="mws-form-field-description">
Recipe description
</label>
<$edit-text tiddler="$:/state/NewRecipeDescription" tag="input" placeholder="(description)" class="mws-form-field-input"/>
</div>
</div>
<div class="mws-form-buttons">
<$button class="mws-form-button">
<$transclude
$variable="createRecipe"
name={{$:/state/NewRecipeName}}
bag_names={{$:/state/NewRecipeBagNames}}
description={{$:/state/NewRecipeDescription}}
/>
Create Recipe
</$button>
</div>
</form>
\end createRecipeButton
<!-- Expects currentTiddler to be the title of a bag entity state tiddler -->
\procedure bagPill(element-tag:"span",is-topmost:"no")
\whitespace trim
<$genesis $type=<<element-tag>> class={{{ mws-bag-pill [<is-topmost>match[yes]then[mws-bag-pill-topmost]] +[join[ ]] }}}>
<a class="mws-bag-pill-link" href=`/wiki/${ [{!!bag-name}encodeuricomponent[]] }$/bags/${ [{!!bag-name}encodeuricomponent[]] }$` rel="noopener noreferrer" target="_blank">
<$image
source=`/wiki/${ [{!!bag-name}encodeuricomponent[]] }$/bags/${ [{!!bag-name}encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico`
class="mws-favicon-small"
>
\whitespace trim
<$genesis $type=<<element-tag>> class={{{ mws-bag-pill [<is-topmost>match[yes]then[mws-bag-pill-topmost]] +[join[ ]] }}}>
<a class="mws-bag-pill-link" href=`/wiki/${ [{!!bag-name}encodeuricomponent[]] }$/bags/${ [{!!bag-name}encodeuricomponent[]] }$` rel="noopener noreferrer" target="_blank">
<$image
source="$:/plugins/multiwikiserver/images/missing-favicon.png"
source=`/wiki/${ [{!!bag-name}encodeuricomponent[]] }$/bags/${ [{!!bag-name}encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico`
class="mws-favicon-small"
/>
</$image>
<span class="mws-bag-pill-label">
<$text text={{!!bag-name}}/>
</span>
</a>
</$genesis>
>
<$image
source="$:/plugins/multiwikiserver/images/missing-favicon.png"
class="mws-favicon-small"
/>
</$image>
<span class="mws-bag-pill-label">
<$text text={{!!bag-name}}/>
</span>
</a>
</$genesis>
\end
<!-- Expects currentTiddler to be the title of a recipe entity state tiddler -->
\procedure wikiCard()
\whitespace trim
<a class="mws-wiki-card" href=`/wiki/${ [{!!recipe-name}encodeuricomponent[]] }$` rel="noopener noreferrer" target="_blank">
<div class="mws-wiki-card-image">
<$image
source=`/wiki/${ [{!!recipe-name}encodeuricomponent[]] }$/recipes/${ [{!!recipe-name}encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico`
class="mws-favicon"
>
\whitespace trim
<a class="mws-wiki-card" href=`/wiki/${ [{!!recipe-name}encodeuricomponent[]] }$` rel="noopener noreferrer" target="_blank">
<div class="mws-wiki-card-image">
<$image
source="$:/plugins/multiwikiserver/images/missing-favicon.png"
source=`/wiki/${ [{!!recipe-name}encodeuricomponent[]] }$/recipes/${ [{!!recipe-name}encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico`
class="mws-favicon"
/>
</$image>
</div>
<div class="mws-wiki-card-content">
<div class="mws-wiki-card-header">
<$text text={{!!recipe-name}}/>
>
<$image
source="$:/plugins/multiwikiserver/images/missing-favicon.png"
class="mws-favicon"
/>
</$image>
</div>
<div class="mws-wiki-card-meta">
<%if [list<currentTiddler>] %>
<ol class="mws-horizontal-list">
<$list filter="[list<currentTiddler>]" counter="counter">
<$transclude $variable="bagPill" is-topmost={{{ [<counter-last>match[yes]] }}} element-tag="li"/>
</$list>
</ol>
<%else%>
(no bags defined)
<%endif%>
<div class="mws-wiki-card-content">
<div class="mws-wiki-card-header">
<$text text={{!!recipe-name}}/>
</div>
<div class="mws-wiki-card-meta">
<%if [list<currentTiddler>] %>
<ol class="mws-horizontal-list">
<$list filter="[list<currentTiddler>]" counter="counter">
<$transclude $variable="bagPill" is-topmost={{{ [<counter-last>match[yes]] }}} element-tag="li"/>
</$list>
</ol>
<%else%>
(no bags defined)
<%endif%>
</div>
<div class="mws-wiki-card-description">
<$text text={{!!text}}/>
</div>
</div>
<div class="mws-wiki-card-description">
<$text text={{!!text}}/>
</div>
</div>
</a>
</a>
\end
<div class="mws-admin-container">
@ -127,6 +189,7 @@ title: MultiWikiServer Administration
<$list filter="[prefix[$:/state/MultiWikiServer/bags/]]">
<li>
<<bagPill>>
<$text text={{!!text}}/>
</li>
</$list>
</ul>

View File

@ -42,16 +42,16 @@ exports.startup = function() {
databasePath: databasePath
});
// Create docs bag and recipe
$tw.sqlTiddlerStore.createBag("docs");
$tw.sqlTiddlerStore.createBag("docs","TiddlyWiki Documentation from https://tiddlywiki.com/");
$tw.sqlTiddlerStore.createRecipe("docs",["docs"],"TiddlyWiki Documentation from https://tiddlywiki.com/");
$tw.sqlTiddlerStore.saveTiddlersFromPath(path.resolve($tw.boot.corePath,$tw.config.editionsPath,"tw5.com/tiddlers"),"docs");
$tw.sqlTiddlerStore.createBag("dev-docs");
$tw.sqlTiddlerStore.createBag("dev-docs","TiddlyWiki Developer Documentation from https://tiddlywiki.com/dev/");
$tw.sqlTiddlerStore.createRecipe("dev-docs",["dev-docs"],"TiddlyWiki Developer Documentation from https://tiddlywiki.com/dev/");
$tw.sqlTiddlerStore.saveTiddlersFromPath(path.resolve($tw.boot.corePath,$tw.config.editionsPath,"dev/tiddlers"),"dev-docs");
// Create bags and recipes
$tw.sqlTiddlerStore.createBag("bag-alpha");
$tw.sqlTiddlerStore.createBag("bag-beta");
$tw.sqlTiddlerStore.createBag("bag-gamma");
$tw.sqlTiddlerStore.createBag("bag-alpha","A test bag");
$tw.sqlTiddlerStore.createBag("bag-beta","Another test bag");
$tw.sqlTiddlerStore.createBag("bag-gamma","A further test bag");
$tw.sqlTiddlerStore.createRecipe("recipe-rho",["bag-alpha","bag-beta"],"First wiki");
$tw.sqlTiddlerStore.createRecipe("recipe-sigma",["bag-alpha","bag-gamma"],"Second Wiki");
$tw.sqlTiddlerStore.createRecipe("recipe-tau",["bag-alpha"],"Third Wiki");

View File

@ -21,12 +21,21 @@ exports.path = /^\/wiki\/([^\/]+)\/bags\/([^\/]+)$/;
exports.handler = function(request,response,state) {
// Get the parameters
var bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
bag_name_2 = $tw.utils.decodeURIComponentSafe(state.params[1]);
if(bag_name === bag_name_2) {
$tw.sqlTiddlerStore.createBag(bag_name);
state.sendResponse(204,{
"Content-Type": "text/plain"
});
bag_name_2 = $tw.utils.decodeURIComponentSafe(state.params[1]),
data = $tw.utils.parseJSONSafe(state.data);
if(bag_name === bag_name_2 && data) {
const result = $tw.sqlTiddlerStore.createBag(bag_name,data.description);
if(!result) {
state.sendResponse(204,{
"Content-Type": "text/plain"
});
} else {
state.sendResponse(400,{
"Content-Type": "text/plain"
},
result.message,
"utf8");
}
} else {
response.writeHead(404);
response.end();

View File

@ -21,12 +21,22 @@ exports.path = /^\/wiki\/([^\/]+)\/recipes\/([^\/]+)$/;
exports.handler = function(request,response,state) {
// Get the parameters
var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
recipe_name_2 = $tw.utils.decodeURIComponentSafe(state.params[1]);
if(recipe_name === recipe_name_2) {
$tw.sqlTiddlerStore.createRecipe(recipe_name);
state.sendResponse(204,{
"Content-Type": "text/plain"
});
recipe_name_2 = $tw.utils.decodeURIComponentSafe(state.params[1]),
data = $tw.utils.parseJSONSafe(state.data);
if(recipe_name === recipe_name_2 && data) {
const result = $tw.sqlTiddlerStore.createRecipe(recipe_name,data.bag_names,data.description);
console.log(`create recipe route handler for ${recipe_name} with ${JSON.stringify(data)} got result ${JSON.stringify(result)}`)
if(!result) {
state.sendResponse(204,{
"Content-Type": "text/plain"
});
} else {
state.sendResponse(400,{
"Content-Type": "text/plain"
},
result.message,
"utf8");
}
} else {
response.writeHead(404);
response.end();

View File

@ -6,6 +6,7 @@ module-type: library
Low level SQL functions to store and retrieve tiddlers in a SQLite database.
This class is intended to encapsulate all the SQL queries used to access the database.
Validation is for the most part left to the caller
\*/
@ -58,7 +59,8 @@ SqlTiddlerDatabase.prototype.createTables = function() {
CREATE TABLE IF NOT EXISTS bags (
bag_id INTEGER PRIMARY KEY AUTOINCREMENT,
bag_name TEXT UNIQUE,
accesscontrol TEXT
accesscontrol TEXT,
description TEXT
)
`,`
-- Recipes have names...
@ -115,28 +117,30 @@ SqlTiddlerDatabase.prototype.logTables = function() {
SqlTiddlerDatabase.prototype.listBags = function() {
const rows = this.runStatementGetAll(`
SELECT bag_name, accesscontrol
SELECT bag_name, accesscontrol, description
FROM bags
ORDER BY bag_name
`);
return rows;
};
SqlTiddlerDatabase.prototype.createBag = function(bagname) {
SqlTiddlerDatabase.prototype.createBag = function(bagname,description) {
// Run the queries
this.runStatement(`
INSERT OR IGNORE INTO bags (bag_name, accesscontrol)
VALUES ($bag_name, '')
INSERT OR IGNORE INTO bags (bag_name, accesscontrol, description)
VALUES ($bag_name, '', '')
`,{
bag_name: bagname
});
this.runStatement(`
UPDATE bags
SET accesscontrol = $accesscontrol
SET accesscontrol = $accesscontrol,
description = $description
WHERE bag_name = $bag_name
`,{
bag_name: bagname,
accesscontrol: "[some access control stuff]"
accesscontrol: "[some access control stuff]",
description: description
});
};

View File

@ -7,6 +7,7 @@ Higher level functions to perform basic tiddler operations with a sqlite3 databa
This class is largely a wrapper for the sql-tiddler-database.js class, adding the following functionality:
* Validating requests (eg bag and recipe name constraints)
* Synchronising bag and recipe names to the admin wiki
* Handling _canonical_uri tiddlers
@ -34,6 +35,43 @@ function SqlTiddlerStore(options) {
this.updateAdminWiki();
}
/*
Returns null if a bag/recipe name is valid, or a string error message if not
*/
SqlTiddlerStore.prototype.validateItemName = function(name) {
if(typeof name !== "string") {
return "Not a valid string";
}
if(name.length > 256) {
return "Too long";
}
if(!(/^[^\s\u00A0\x00-\x1F\x7F~`!@#$%^&*()+={}\[\];:\'\"<>.,\/\\\?]+$/g.test(name))) {
return "Invalid character(s)";
}
return null;
};
/*
Returns null if the argument is an array of valid bag/recipe names, or a string error message if not
*/
SqlTiddlerStore.prototype.validateItemNames = function(names) {
if(!$tw.utils.isArray(names)) {
return "Not a valid array";
}
var errors = [];
for(const name of names) {
const result = this.validateItemName(name);
if(result) {
errors.push(result);
}
}
if(errors.length === 0) {
return null;
} else {
return errors.join("\n");
}
};
SqlTiddlerStore.prototype.close = function() {
this.sqlTiddlerDatabase.close();
this.sqlTiddlerDatabase = undefined;
@ -49,7 +87,7 @@ SqlTiddlerStore.prototype.updateAdminWiki = function() {
this.saveEntityStateTiddler({
title: "bags/" + bagInfo.bag_name,
"bag-name": bagInfo.bag_name,
text: ""
text: bagInfo.description
});
}
// Update recipes
@ -112,22 +150,42 @@ SqlTiddlerStore.prototype.listBags = function() {
return this.sqlTiddlerDatabase.listBags();
};
SqlTiddlerStore.prototype.createBag = function(bagname) {
this.sqlTiddlerDatabase.createBag(bagname);
SqlTiddlerStore.prototype.createBag = function(bagname,description) {
console.log(`create bag method for ${bagname} with ${description}`)
console.log(`validation results are ${this.validateItemName(bagname)}`)
const validationBagName = this.validateItemName(bagname);
if(validationBagName) {
return {message: validationBagName};
}
this.sqlTiddlerDatabase.createBag(bagname,description);
this.saveEntityStateTiddler({
title: "bags/" + bagname,
"bag-name": bagname,
text: ""
text: description
});
return null;
};
SqlTiddlerStore.prototype.listRecipes = function() {
return this.sqlTiddlerDatabase.listRecipes();
};
/*
Returns null on success, or {message:} on error
*/
SqlTiddlerStore.prototype.createRecipe = function(recipename,bagnames,description) {
console.log(`create recipe method for ${recipename} with ${JSON.stringify(bagnames)}`)
console.log(`validation results are ${this.validateItemName(recipename)} and ${this.validateItemNames(bagnames)}`)
bagnames = bagnames || [];
description = description || "";
const validationRecipeName = this.validateItemName(recipename);
if(validationRecipeName) {
return {message: validationRecipeName};
}
const validationBagNames = this.validateItemNames(bagnames);
if(validationBagNames) {
return {message: validationBagNames};
}
this.sqlTiddlerDatabase.createRecipe(recipename,bagnames,description);
this.saveEntityStateTiddler({
title: "recipes/" + recipename,
@ -137,6 +195,7 @@ SqlTiddlerStore.prototype.createRecipe = function(recipename,bagnames,descriptio
return this.entityStateTiddlerPrefix + "bags/" + bag_name;
}))
});
return null;
};
/*