API CRUD Implementation
authorAdam Dullage <redacted>
Sat, 7 Aug 2021 17:41:01 +0000 (18:41 +0100)
committerAdam Dullage <redacted>
Sat, 7 Aug 2021 17:41:01 +0000 (18:41 +0100)
README.md
flatnotes/flatnotes.py
flatnotes/helpers.py [new file with mode: 0644]
flatnotes/main.py

index bb720772b5eedf0248310dbe990f4bdf8874ac84..12d3e43f656b0d63a23286b60d14741c8050daa0 100644 (file)
--- a/README.md
+++ b/README.md
@@ -8,34 +8,40 @@ For a long time now I've written and stored all of my personal notes in a simple
 
 Still, I was unable to find an editor that ticked all the boxes:
 
-- Support for flat folder storage
-- Cross platform (web based)
-- Mobile friendly
-- Clean, simple interface
-- Good markdown support for:
-  - Tables
-  - Code Blocks (Syntax Highlighting)
-  - Task Lists
-- Full text searching
-- Support for embedding images (and storing them)
-- Support for uploading file attachments
-- Password authentication
-- The ability to share notes as public read-only URLs
+* Support for flat folder storage
+* Cross platform (web based)
+* Mobile friendly
+* Clean, simple interface
+* Good markdown support for:
+  * Tables
+  * Code Blocks (Syntax Highlighting)
+  * Task Lists
+* Full text searching
+* Support for embedding images (and storing them)
+* Support for uploading file attachments
+* Password authentication
+* The ability to share notes as public read-only URLs
 
 This is what flatnotes aims to achieve.
 
 ## To Do
-- [X] Proof of Concept 1
-  - [X] Notes List
-  - [X] Full Text Searching
-- [ ] Proof of Concept 2
-  - [ ] View Note Content
-  - [ ] Edit Note Content
-- [ ] Proof of Concept 3
-  - [ ] Image Embedding
-  - [ ] Attachment Upload
-- [ ] Proof of Concept 5
-  - [ ] Password Authentication
-  - [ ] Public URL Sharing
-- [ ] First Release
-  - [ ] Clean & Responsive UI
+
+* [x] Proof of Concept - Stage 1
+  * [x] Notes List
+  * [x] Full Text Searching
+* [ ] Proof of Concept - Stage 2
+  * [x] View Note Content
+  * [ ] Edit Note Content
+  * [ ] Create New Note
+  * [ ] Docker Deployment
+  * [ ] Password Authentication
+* [ ] Proof of Concept - Stage 3
+  * [ ] Image Embedding
+  * [ ] Attachment Upload
+* [ ] Proof of Concept - Stage 4
+  * [ ] Public URL Sharing
+* [ ] First Release
+  * [ ] Clean & Responsive UI
+  * [ ] Ability to Delete a Note
+  * [ ] Ability to Rename a Note
+  * [ ] Error Handling
index 8d29e4ba85aaab71f26a128707b3a47809e69d3e..02091b73fe6bdfd39c9a260989c0cbb085773c7b 100644 (file)
@@ -7,6 +7,7 @@ from typing import List, Tuple
 import whoosh
 from whoosh import writing
 from whoosh.fields import ID, STORED, TEXT, SchemaClass
+from whoosh.index import Index
 from whoosh.qparser import MultifieldParser
 from whoosh.searching import Hit
 
@@ -19,16 +20,20 @@ class IndexSchema(SchemaClass):
 
 
 class Note:
-    def __init__(self, filepath: str) -> None:
-        self.filepath = filepath
+    def __init__(self, filepath: str, new: bool = False) -> None:
+        if new and os.path.exists(filepath):
+            raise FileExistsError
+        elif new:
+            open(filepath, "w").close()
+        self._filepath = filepath
 
     @property
-    def dirpath(self):
-        return os.path.split(self.filepath)[0]
+    def filepath(self):
+        return self._filepath
 
     @property
-    def filename(self):
-        return os.path.split(self.filepath)[1]
+    def dirpath(self):
+        return os.path.split(self._filepath)[0]
 
     @property
     def title(self):
@@ -36,13 +41,34 @@ class Note:
 
     @property
     def last_modified(self):
-        return os.path.getmtime(self.filepath)
+        return os.path.getmtime(self._filepath)
+
+    # Editable Properties
+    @property
+    def filename(self):
+        return os.path.split(self._filepath)[1]
+
+    @filename.setter
+    def filename(self, new_filename):
+        new_filepath = os.path.join(self.dirpath, new_filename)
+        os.rename(self._filepath, new_filepath)
+        self._filepath = new_filepath
 
     @property
     def content(self):
-        with open(self.filepath, "r") as f:
+        with open(self._filepath, "r") as f:
             return f.read()
 
+    @content.setter
+    def content(self, new_content):
+        if not os.path.exists(self._filepath):
+            raise FileNotFoundError
+        with open(self._filepath, "w") as f:
+            f.write(new_content)
+
+    def delete(self):
+        os.remove(self._filepath)
+
 
 class NoteHit(Note):
     def __init__(self, hit: Hit) -> None:
@@ -70,7 +96,7 @@ class Flatnotes(object):
     def index_dirpath(self):
         return os.path.join(self.notes_dirpath, ".flatnotes")
 
-    def _load_index(self) -> None:
+    def _load_index(self) -> Index:
         """Load the note index or create new if not exists."""
         if not os.path.exists(self.index_dirpath):
             os.mkdir(self.index_dirpath)
diff --git a/flatnotes/helpers.py b/flatnotes/helpers.py
new file mode 100644 (file)
index 0000000..9964cc8
--- /dev/null
@@ -0,0 +1,19 @@
+import os
+from pydantic import BaseModel
+
+
+def is_path_safe(filename: str) -> bool:
+    """Return False if the declared filename contains path
+    information e.g. '../note.md' or 'folder/note.md'."""
+    return os.path.split(filename)[0] == ""
+
+
+def camel_case(snake_case_str: str) -> str:
+    """Return the declared snake_case string in camelCase."""
+    parts = [part for part in snake_case_str.split("_") if part != ""]
+    return parts[0] + "".join(part.title() for part in parts[1:])
+
+
+class CamelCaseBaseModel(BaseModel):
+    class Config:
+        alias_generator = camel_case
index d1e6ccca7ce4b5638946b16d5c23f7e3f8d5e0bf..58267e16d0779dcfe3bd1d0986a61462ac1250fc 100644 (file)
@@ -1,11 +1,13 @@
 import logging
 import os
+from typing import Dict, List, Optional
 
 from fastapi import FastAPI
-from fastapi.responses import RedirectResponse
+from fastapi.responses import JSONResponse, RedirectResponse
 from fastapi.staticfiles import StaticFiles
 
-from flatnotes import Flatnotes
+from flatnotes import Flatnotes, Note, NoteHit
+from helpers import CamelCaseBaseModel, is_path_safe
 
 logging.basicConfig(
     format="%(asctime)s [%(levelname)s]: %(message)s",
@@ -19,30 +21,101 @@ app = FastAPI()
 flatnotes = Flatnotes(os.environ["FLATNOTES_PATH"])
 
 
+class NoteModel(CamelCaseBaseModel):
+    filename: str
+    last_modified: int
+    content: Optional[str]
+
+    @classmethod
+    def dump(cls, note: Note, include_content: bool = True) -> Dict:
+        return {
+            "filename": note.filename,
+            "lastModified": note.last_modified,
+            "content": note.content if include_content else None,
+        }
+
+
+class NoteHitModel(CamelCaseBaseModel):
+    filename: str
+    last_modified: int
+    title_highlights: Optional[str]
+    content_highlights: Optional[str]
+
+    @classmethod
+    def dump(self, note_hit: NoteHit) -> Dict:
+        return {
+            "filename": note_hit.filename,
+            "lastModified": note_hit.last_modified,
+            "titleHighlights": note_hit.title_highlights,
+            "contentHighlights": note_hit.content_highlights,
+        }
+
+
 @app.get("/")
 async def root():
     return RedirectResponse("/index.html")
 
 
-@app.get("/api/notes")
-async def notes():
+@app.get("/api/notes", response_model=List[NoteModel])
+async def get_notes(include_content: bool = False):
+    """Get all notes."""
     return [
-        {"filename": note.filename, "lastModified": note.last_modified}
+        NoteModel.dump(note, include_content=include_content)
         for note in flatnotes.get_notes()
     ]
 
 
-@app.get("/api/search")
+@app.post("/api/notes", response_model=NoteModel)
+async def post_note(filename: str, content: str):
+    """Create a new note."""
+    if not is_path_safe(filename):
+        return JSONResponse(status_code=404)  # TODO: Different code
+    note = Note(os.path.join(flatnotes.notes_dirpath, filename), new=True)
+    note.content = content
+    # TODO: Handle file exists
+    return NoteModel.dump(note, include_content=True)
+
+
+@app.get("/api/notes/{filename}", response_model=NoteModel)
+async def get_note(filename: str, include_content: bool = True):
+    """Get a specific note."""
+    if not is_path_safe(filename):
+        return JSONResponse(status_code=404)
+    note = Note(os.path.join(flatnotes.notes_dirpath, filename))
+    try:
+        return NoteModel.dump(note, include_content=include_content)
+    except FileNotFoundError:
+        return JSONResponse(status_code=404)
+
+
+@app.patch("/api/notes/{filename}", response_model=NoteModel)
+async def patch_note(
+    filename: str, new_filename: str = None, new_content: str = None
+):
+    if not is_path_safe(filename):
+            return JSONResponse(status_code=404)
+    note = Note(
+        os.path.join(flatnotes.notes_dirpath, filename)
+    )  # TODO: Stop repeating this
+    if new_filename is not None:
+        note.filename = new_filename
+    if new_content is not None:
+        note.content = new_content
+    return NoteModel.dump(note, include_content=True)
+
+
+@app.delete("/api/notes/{filename}")
+async def delete_note(filename: str):
+    if not is_path_safe(filename):
+            return JSONResponse(status_code=404)
+    note = Note(os.path.join(flatnotes.notes_dirpath, filename))
+    note.delete()
+
+
+@app.get("/api/search", response_model=List[NoteHitModel])
 async def search(term: str):
-    return [
-        {
-            "filename": hit.filename,
-            "lastModified": hit.last_modified,
-            "titleHighlights": hit.title_highlights,
-            "contentHighlights": hit.content_highlights,
-        }
-        for hit in flatnotes.search(term)
-    ]
+    """Perform a full text search for a note."""
+    return [NoteHitModel.dump(note_hit) for note_hit in flatnotes.search(term)]
 
 
 app.mount("/", StaticFiles(directory="flatnotes/dist"), name="dist")
git clone https://git.99rst.org/PROJECT