diff --git a/src/ttfrog/app.py b/src/ttfrog/app.py
index 0b7fbaf..47d2fda 100644
--- a/src/ttfrog/app.py
+++ b/src/ttfrog/app.py
@@ -153,45 +153,42 @@ API_URI=/_/v1/
if uri.startswith(self.config.VIEW_URI):
uri = uri.replace(self.config.VIEW_URI, "", 1)
- parent_uri = ''
- search_uri = '/'
- page_name = '/'
+ parent_uri = None
+ search_uri = uri
+ page_name = uri
if "/" in uri:
(parent_uri, page_name) = uri.rsplit("/", 1)
if parent_uri == 'Page':
- parent_uri = '/'
+ parent_uri = None
search_uri = page_name
else:
search_uri = uri
- elif uri:
- parent_uri = "/"
- search_uri = uri
- page_name = uri
- self.log.debug(f"Searching for page in {table = } with {search_uri = }; its parent is {parent_uri=}")
+ # self.log.debug(f"Searching for page in {table = } with {search_uri = }; its parent is {parent_uri=}")
# self.log.debug("\n".join([f"{p.doc_id}: {p.uri}" for p in table.all()]))
page = table.get(where("uri") == search_uri, recurse=False)
if not page:
# load the parent to check for write permissions
- self.log.debug(f"Page at {search_uri} does not exist, looking for parent at {parent_uri=}")
- parent_table = table if "/" in parent_uri else self.db.Page
+ # self.log.debug(f"Page at {search_uri} does not exist, looking for parent at {parent_uri=}")
+ parent_table = table if parent_uri and "/" in parent_uri else self.db.Page
parent = None
try:
- self.log.debug(f"Loading parent with {parent_uri}")
+ # self.log.debug(f"Loading parent with {parent_uri}")
parent = self.get_page(user, parent_table.name, uri=parent_uri)
except Exception as e:
self.log.debug(f"Error loading parent: {e}")
if not parent:
- raise MalformedRequestError("Page does not exist and neither does its parent.")
+ raise MalformedRequestError(f"Page with uri '{search_uri}' does not exist and neither does its parent.")
if not self.authorize(user, parent, schema.Permissions.WRITE):
raise UnauthorizedError(f"User {user.doc_id} does not have permission to create under {parent_uri}.")
- page = getattr(schema, table.name)(
- name=page_name, body=f"# {page_name}\nThis page does not exist", parent=parent
- )
- self.log.debug(f"Returning {page.doc_id}: {page.uri}")
+
+ obj = getattr(schema, table.name)
+ page = obj(name=page_name, body=obj.default.format(name=page_name), parent=parent)
+
+ # self.log.debug(f"Returning {page.doc_id}: {page.uri}")
return page
def get_page(self, user, table_name, doc_id=None, uri=None):
@@ -216,11 +213,8 @@ API_URI=/_/v1/
page = table.get(doc_id=doc_id)
if not page:
raise RecordNotFoundError(f"No record with {doc_id=} was found.")
- elif uri:
- page = self._get_or_create_page_by_uri(user, table, uri)
- else:
- self.log.error("No doc_id or uri.")
- raise MalformedRequestError("Either a doc_id or a uri must be specified.")
+
+ page = self._get_or_create_page_by_uri(user, table, uri)
if not self.authorize(user, page, schema.Permissions.READ):
self.log.error(f"No permission for {user.name} on {page}")
diff --git a/src/ttfrog/bootstrap.py b/src/ttfrog/bootstrap.py
index eb6631a..731cc88 100644
--- a/src/ttfrog/bootstrap.py
+++ b/src/ttfrog/bootstrap.py
@@ -40,7 +40,7 @@ def bootstrap():
app.check_state()
# create the top-level pages
- root = app.db.save(schema.Page(name=app.config.VIEW_URI, body=b"This is the home page"))
+ root = app.db.save(schema.Page(name=app.config.VIEW_URI, body=b"This is the home page", uri=""))
users = root.add_member(schema.Page(name="User", body=b"# Users\nusers go here."))
groups = root.add_member(schema.Page(name="Group", body=b"# Groups\ngroups go here."))
diff --git a/src/ttfrog/forms.py b/src/ttfrog/forms.py
index 62e492a..c925eea 100644
--- a/src/ttfrog/forms.py
+++ b/src/ttfrog/forms.py
@@ -1,5 +1,6 @@
from dataclasses import dataclass, field
from functools import cached_property
+import logging
from flask import g
from grung.types import BackReference, Collection, Pointer, Record, Timestamp
@@ -9,6 +10,9 @@ from ttfrog import schema
READ_ONLY_FIELD_TYPES = [Collection, Pointer, BackReference, Timestamp]
+logger = logging.getLogger(__name__)
+
+
@dataclass
class Form:
"""
@@ -29,8 +33,9 @@ class Form:
# filter out fields that cannot be set by the user
if key in self.read_only:
continue
-
- self.record[key] = value
+ if self.record[key] != value:
+ logger.debug(f"Updating {self.record.__class__.__name__}[{self.record.doc_id}] {key}={value}")
+ self.record[key] = value
self.record.author = g.user
return self.record
diff --git a/src/ttfrog/schema.py b/src/ttfrog/schema.py
index 969a68b..93b89f1 100644
--- a/src/ttfrog/schema.py
+++ b/src/ttfrog/schema.py
@@ -3,6 +3,7 @@ from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from typing import List
+from textwrap import dedent
from grung.types import (
BackReference,
@@ -29,6 +30,17 @@ class Page(Record):
"""
A page in the wiki. Just about everything in the databse is either a Page or a subclass of a Page.
"""
+ default = dedent("""
+# {name}
+
+*Overview of this page*
+
+
+## Section 1
+
+*Organize your text into logically separted sections.*
+
+ """)
@classmethod
def fields(cls):
@@ -37,16 +49,27 @@ class Page(Record):
*super().fields(),
Field("uri", unique=True), # The URI for the page, relative to the app's VIEW_URI
Field("name"), # The portion of the URI after the last /
- TextFilePointer("body", extension='.md'), # The main content blob of the page
Collection("members", Page), # The pages that exist below this page's URI
- BackReference("parent", value_type=Page), # The page that exists above this page's URI
Pointer("author", value_type=User), # The last user to touch the page.
DateTime("created"), # When the page was created
Timestamp("last_modified"), # The last time the page was modified.
Dict("acl"),
+ TextFilePointer("body", extension='.md', default=Page.default), # The main content blob of the page
]
+ # fmt: on
- # fmt: on
+ def parent(self, db):
+ if self.uri == "":
+ return None
+
+ parent_uri = ""
+ if "/" in self.uri:
+ parent_uri = self.uri.rsplit("/", 1)[0]
+ print(f"Checking for parent at {parent_uri=}")
+ for table_name in db.tables():
+ page = db.table(table_name).get(where('uri') == parent_uri, recurse=False)
+ if page:
+ return page
def before_insert(self, db):
"""
@@ -62,25 +85,15 @@ class Page(Record):
if not self.doc_id and self.created < now:
self.created = now
- self.uri = (self.parent.uri + "/" if self.parent and self.parent.uri != "/" else "") + self.name
-
- def after_insert(self, db):
- """
- After saving this record, ensure that any page in the members collection is updated with the
- correct URI. This ensures that if a page is moved from one collection to another, the URI is updated.
- """
- super().after_insert(db)
- if not hasattr(self, "members"):
- return
- for child in self.members:
- obj = BackReference.dereference(child, db)
- obj.uri = f"{self.uri}/{obj.name}"
- child = db.save(obj)
-
def add_member(self, child: Record):
from ttfrog import app
app.check_state()
+ prefix = (self.uri + "/") if self.uri else ""
+ new_uri = f"{prefix}{child.name}"
+ if child.uri != new_uri:
+ app.log.debug(f"Moving {child._metadata.table}[{child.doc_id}] from {child.uri} to {new_uri}")
+ child.uri = new_uri
self.members = list(set(self.members + [app.db.save(child)]))
app.db.save(self)
return self.get_child(child)
@@ -126,7 +139,7 @@ class Page(Record):
return group_grants
if hasattr(obj, "parent"):
- return find_acl(obj.parent)
+ return find_acl(obj.parent(app.db))
return {"": ""}
return find_acl(self)
@@ -171,6 +184,21 @@ class User(Entity):
def check_credentials(self, username: str, password: str) -> bool:
return username == self.name and self._metadata.fields["password"].compare(password, self.password)
+ def after_insert(self, db):
+ """
+ After saving this record, ensure that any page in the members collection is updated with the
+ correct URI. This ensures that if a page is moved from one collection to another, the URI is updated.
+ """
+ super().after_insert(db)
+ for name, _field in self._metadata.fields.items():
+ _field.after_insert(db, self)
+ if not hasattr(self, "members"):
+ return
+ for child in self.members:
+ obj = BackReference.dereference(child, db, recurse=False)
+ obj.uri = f"{self.uri}/{obj.name}"
+ child = db.save(obj)
+
class Group(Entity):
"""
@@ -186,3 +214,18 @@ class NPC(Page):
"""
An NPC, editable as a wiki page.
"""
+ default = dedent("""
+# {name}
+*[Ancestry] [Class]*
+
+| AC | HP | STR | DEX | CON | INT | WIS | CHA
+|----|----|-----|-----|-----|-----|-----|------
+| 10 | 10 | +0 | +0 | +0 | +0 | +0 | +0
+
+**{name} (they/they)** [description]
+
+* Personality: **[keywords]**
+* Flaw: **[flaw]**
+* Goal: **[goal]**
+
+""")
diff --git a/src/ttfrog/themes/default/page.html b/src/ttfrog/themes/default/page.html
index 118b296..db4dae9 100644
--- a/src/ttfrog/themes/default/page.html
+++ b/src/ttfrog/themes/default/page.html
@@ -18,7 +18,7 @@
{% if user.can_write(page) %}
-
+
{% else %}
diff --git a/src/ttfrog/themes/default/static/editor/editor.js b/src/ttfrog/themes/default/static/editor/editor.js
index 21b3d1f..62a3516 100644
--- a/src/ttfrog/themes/default/static/editor/editor.js
+++ b/src/ttfrog/themes/default/static/editor/editor.js
@@ -5,8 +5,26 @@ var contents = null;
var pageContent = null;
var saveButton = null;
var editorUI = null;
+var VIEW_URI = null;;
APIv1 = {
+ get: function(doc_id, callback) {
+ (async () => {
+ const raw = await fetch('/_/v1/get/' + doc_id, {
+ method: 'GET',
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json'
+ }
+ });
+ const res = await raw.json();
+ if (res['code'] != 200) {
+ console.error("APIv1 error: ", res)
+ }
+ callback(res);
+ })();
+ },
+
put: function(data, callback) {
(async () => {
const raw = await fetch('/_/v1/put/' + window.location.pathname, {
@@ -26,6 +44,26 @@ APIv1 = {
callback(res);
})();
},
+
+ search: function(space, query, callback) {
+ (async () => {
+ const raw = await fetch('/_/v1/search/' + space, {
+ method: 'POST',
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ 'body': query
+ }),
+ });
+ const res = await raw.json();
+ if (res['code'] != 200) {
+ console.error("APIv1 error: ", res)
+ }
+ callback(res);
+ })();
+ },
};
isReadOnly = function() {
@@ -108,8 +146,54 @@ toggleButton = function() {
handleContentChange = function() {
}
-initialize = function() {
- return new toastui.Editor({
+autoComplete = function(search_string, matches, callback) {
+ id = `_ac_${search_string}`;
+ el = document.getElementById(id);
+ var addEl = false;
+ if (!el) {
+ el = document.createElement("ul");
+ el.id = id;
+ addEl = true;
+ }
+ el.className = 'autocomplete';
+ el.innerHTML = "";
+ el.addEventListener("keyup", function(e) {
+ // do navigation / selection
+ });
+
+ matches.forEach(match => {
+ var text = match.uri;
+ for (pos = 0; pos < match.uri.length - search_string.length; pos++) {
+ var substr = match.name.substring(pos, search_string.length);
+ if (substr.toLowerCase() == search_string.toLowerCase()) {
+ text = match.name.substring(0, pos) + "" + substr + "" + match.name.substr(pos + substr.length);
+ break;
+ }
+ }
+ var option = document.createElement("li");
+ option.innerHTML = text;
+ option.addEventListener("click", function(e) {
+ // do selection
+ });
+ el.appendChild(option);
+ });
+ if (addEl) {
+ var selection = window.getSelection();
+ if (selection.rangeCount > 0) {
+ var range = selection.getRangeAt(0);
+ range.insertNode(el);
+ }
+ }
+}
+
+
+initialize = function(base_uri) {
+
+ const macro_rule = /@(\S{3,})/;
+
+ VIEW_URI = base_uri;
+
+ const ed = new toastui.Editor({
el: editor,
initialEditType: 'wysiwyg',
initialValue: "",
@@ -129,6 +213,23 @@ initialize = function() {
{ el: toggleButton(), tooltip: 'Toggle Edit Mode' }
],
],
+ widgetRules: [ ],
+
+ /*
+ {
+ rule: macro_rule, toDOM(text) {
+ const matched = text.match(macro_rule);
+ const search_string = matched[1];
+
+ var replacement = "";
+
+ });
+ console.log(replacement);
+ return replacement;
+ },
+ },
+ ],
+ */
events: {
'loadUI': function(e) {
editorUI = e;
@@ -147,4 +248,48 @@ initialize = function() {
'change': handleContentChange,
}
});
+
+ var searchPos = null;
+ ed.on('keyup', (editorType, ev) => {
+ const [start, end] = editorUI.getSelection();
+ console.log(start, end);
+ if (ev.key === '@') {
+ searchPos = start;
+ console.log(`Setting search position to ${searchPos}`);
+ return;
+ }
+ if (searchPos === null) {
+ return;
+ }
+
+ var range = window.getSelection().getRangeAt(0);
+ range.selectNodeContents(editor);
+ range.setStart(editor, 0);
+ range.setEnd(editor, end);
+ var search_string = range.toString();
+ console.log(search_string);
+
+
+
+ /*
+ APIv1.search("", search_string, (res) => {
+ if (res.code == 404) {
+ return;
+ }
+ const matches = res.response;
+ if (matches.length == 1) {
+ replacement = document.createElement('span');
+ replacement.innerHTML = `${matches[0].name}`;
+ return;
+ }
+
+ autoComplete(search_string, matches, (selection) => {
+ console.log(`Selected ${selection}`);
+ document.remove(options.id);
+ });
+ };
+ */
+ });
+
+ return ed;
};
diff --git a/src/ttfrog/web.py b/src/ttfrog/web.py
index 9f3a32c..e01592a 100644
--- a/src/ttfrog/web.py
+++ b/src/ttfrog/web.py
@@ -4,6 +4,8 @@ from flask import Response, g, jsonify, redirect, render_template, request, sess
from ttfrog import app, forms, schema
from ttfrog.exceptions import MalformedRequestError, RecordNotFoundError, UnauthorizedError
+from tinydb import where
+import re
def get_page(
@@ -42,7 +44,7 @@ def rendered(page: schema.Record, template: str = "page.html"):
if not page:
return Response("Page not found", status=404)
- root = page if page.uri == app.config.VIEW_URI else get_page(app.config.VIEW_URI)[0]
+ root = page if page.uri == "" else get_page("")[0]
return render_template(template, page=page, app=app, breadcrumbs=breadcrumbs(), root=root, user=g.user, g=g)
@@ -62,7 +64,7 @@ def before_request():
g.messages = []
if not request.path.startswith("/static"):
user_id = session.get("user_id", 1)
- g.user = app.db.User.get(doc_id=user_id)
+ g.user = app.db.User.get(doc_id=user_id, recurse=False)
session["user_id"] = user_id
session["user"] = dict(g.user.serialize())
@@ -77,7 +79,7 @@ def add_header(r):
@app.web.route(app.config.VIEW_URI)
def index():
- page, error = get_page(app.config.VIEW_URI)
+ page, error = get_page("")
if error:
g.messages.append(str(error))
return rendered(page)
@@ -127,12 +129,39 @@ def put(table, path):
params = json.loads(request.data.decode())["body"]
save_data = getattr(forms, table)(page, params).prepare()
-
- doc = app.db.save(save_data) if page.doc_id else page.parent.add_member(save_data)
+ app.log.debug("Saving form data...")
+ doc = app.db.save(save_data)
+ app.log.debug(f"Saved {dict(doc)}")
+ if not page.doc_id:
+ print(f"Adding {doc.doc_id} to {page.parent.members}")
+ page.parent.members = list(set(page.parent.members + [doc]))
+ app.db.save(page.parent)
app.log.debug(f"Saved: {dict(doc)=}")
return api_response(response=dict(doc))
+@app.web.route(f"{app.config.API_URI}/search/", methods=["POST"])
+@app.web.route(f"{app.config.API_URI}/search/", methods=["POST"], defaults={"space": None})
+def search(space):
+
+ spaces = app.db.tables()
+ if space:
+ spaces = [space.lower().capitalize()]
+
+ query = json.loads(request.data.decode())["body"]
+ app.log.debug(f"Searching for records matching query {query}")
+ matches = []
+ for space in spaces:
+ for page in app.db.table(space).search(where('name').matches(query, re.IGNORECASE), recurse=False):
+ if app.authorize(g.user, page, schema.Permissions.READ):
+ app.log.debug(f"Adding search result {dict(page)}")
+ matches.append(dict(page))
+ return api_response(
+ response=matches,
+ error=None if matches else RecordNotFoundError(f"No records matching '{query}'")
+ )
+
+
@app.web.route(f"{app.config.API_URI}/get//", methods=["GET"])
def get(table, doc_id):
app.log.debug(f"API: getting {table}({doc_id})")