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
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
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):
@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:
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)
--- /dev/null
+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
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",
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")