From: Adam Dullage Date: Sat, 7 Aug 2021 17:41:01 +0000 (+0100) Subject: API CRUD Implementation X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;h=69631954f4fe4ca3b4a81db633c097c8492b9087;p=flatnotes.git API CRUD Implementation --- diff --git a/README.md b/README.md index bb72077..12d3e43 100644 --- 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 diff --git a/flatnotes/flatnotes.py b/flatnotes/flatnotes.py index 8d29e4b..02091b7 100644 --- a/flatnotes/flatnotes.py +++ b/flatnotes/flatnotes.py @@ -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 index 0000000..9964cc8 --- /dev/null +++ b/flatnotes/helpers.py @@ -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 diff --git a/flatnotes/main.py b/flatnotes/main.py index d1e6ccc..58267e1 100644 --- a/flatnotes/main.py +++ b/flatnotes/main.py @@ -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")