from fastapi.responses import JSONResponse
-file_exists_response = JSONResponse(
- content={"message": "The specified filename already exists."},
+title_exists_response = JSONResponse(
+ content={"message": "The specified title already exists."},
status_code=409,
)
-invalid_filename_response = JSONResponse(
- content={
- "message": "The specified filename contains invalid characters."
- },
+invalid_title_response = JSONResponse(
+ content={"message": "The specified title contains invalid characters."},
status_code=400,
)
-file_not_found_response = JSONResponse(
- content={"message": "The specified file cannot be found."}, status_code=404
+note_not_found_response = JSONResponse(
+ content={"message": "The specified note cannot be found."}, status_code=404
)
from whoosh.qparser import MultifieldParser
from whoosh.searching import Hit
+from helpers import strip_ext
-class InvalidFilenameError(Exception):
- def __init__(self, message="The specified filename is invalid"):
+MARKDOWN_EXT = ".md"
+
+
+class InvalidTitleError(Exception):
+ def __init__(self, message="The specified title is invalid"):
self.message = message
super().__init__(self.message)
class Note:
def __init__(
- self, flatnotes: "Flatnotes", filename: str, new: bool = False
+ self, flatnotes: "Flatnotes", title: str, new: bool = False
) -> None:
- if not self._is_valid_filename(filename):
- raise InvalidFilenameError
self._flatnotes = flatnotes
- self._filename = filename
+ self._title = title.strip()
+ if not self._is_valid_title(self._title):
+ raise InvalidTitleError
if new and os.path.exists(self.filepath):
raise FileExistsError
elif new:
@property
def filepath(self):
- return os.path.join(self._flatnotes.dir, self._filename)
+ return os.path.join(self._flatnotes.dir, self.filename)
@property
- def title(self):
- return os.path.splitext(self._filename)[0]
+ def filename(self):
+ return self._title + MARKDOWN_EXT
@property
def last_modified(self):
# Editable Properties
@property
- def filename(self):
- return self._filename
-
- @filename.setter
- def filename(self, new_filename):
- if not self._is_valid_filename(new_filename):
- raise InvalidFilenameError
- new_filepath = os.path.join(self._flatnotes.dir, new_filename)
+ def title(self):
+ return self._title
+
+ @title.setter
+ def title(self, new_title):
+ if not self._is_valid_title(new_title):
+ raise InvalidTitleError
+ new_filepath = os.path.join(
+ self._flatnotes.dir, new_title + MARKDOWN_EXT
+ )
os.rename(self.filepath, new_filepath)
- self._filename = new_filename
+ self._title = new_title
@property
def content(self):
os.remove(self.filepath)
# Functions
- def _is_valid_filename(self, filename: str) -> bool:
- r"""Return False if the declared filename contains any of the following
+ def _is_valid_title(self, title: str) -> bool:
+ r"""Return False if the declared title contains any of the following
characters: <>:"/\|?*"""
invalid_chars = r'<>:"/\|?*'
- return not any(
- invalid_char in filename for invalid_char in invalid_chars
- )
+ return not any(invalid_char in title for invalid_char in invalid_chars)
class NoteHit(Note):
def __init__(self, flatnotes: "Flatnotes", hit: Hit) -> None:
- super().__init__(flatnotes, hit["filename"])
+ super().__init__(flatnotes, strip_ext(hit["filename"]))
self.title_highlights = hit.highlights("title", text=self.title)
self.content_highlights = hit.highlights(
"content",
self, writer: writing.IndexWriter, note: Note
) -> None:
"""Add a Note object to the index using the given writer. If the
- filepath already exists in the index an update will be performed instead."""
+ filename already exists in the index an update will be performed
+ instead."""
writer.update_document(
filename=note.filename,
last_modified=note.last_modified,
)
def get_notes(self) -> List[Note]:
- """Return a list containing a Note object for every file in the notes directory."""
+ """Return a list containing a Note object for every file in the notes
+ directory."""
return [
- Note(self, os.path.split(filepath)[1])
- for filepath in glob.glob(os.path.join(self.dir, "*.md"))
+ Note(self, strip_ext(os.path.split(filepath)[1]))
+ for filepath in glob.glob(
+ os.path.join(self.dir, "*" + MARKDOWN_EXT)
+ )
]
def update_index(self, clean: bool = False) -> None:
os.path.getmtime(idx_filepath) != idx_note["last_modified"]
):
logging.info(f"'{idx_filename}' updated")
- self._add_note_to_index(writer, Note(self, idx_filename))
+ self._add_note_to_index(
+ writer, Note(self, strip_ext(idx_filename))
+ )
indexed.add(idx_filename)
# Ignore already indexed
else:
self.last_index_update = datetime.now()
def update_index_debounced(self, clean: bool = False) -> None:
- """Run update_index() but only if it hasn't been run in the last 10 seconds."""
+ """Run update_index() but only if it hasn't been run in the last 10
+ seconds."""
if (
self.last_index_update is None
or (datetime.now() - self.last_index_update).seconds > 10
+import os
+
from pydantic import BaseModel
+def strip_ext(filename):
+ return os.path.splitext(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 != ""]
validate_token,
)
from error_responses import (
- file_exists_response,
- file_not_found_response,
- invalid_filename_response,
+ title_exists_response,
+ note_not_found_response,
+ invalid_title_response,
)
from fastapi import Depends, FastAPI, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from models import LoginModel, NoteHitModel, NoteModel, NotePatchModel
-from flatnotes import Flatnotes, InvalidFilenameError, Note
+from flatnotes import Flatnotes, InvalidTitleError, Note
logging.basicConfig(
format="%(asctime)s [%(levelname)s]: %(message)s",
@app.get("/")
@app.get("/login")
@app.get("/search")
-@app.get("/note/{filename}")
-async def root(filename: str = ""):
+@app.get("/note/{title}")
+async def root(title: str = ""):
with open("flatnotes/dist/index.html", "r", encoding="utf-8") as f:
html = f.read()
return HTMLResponse(content=html)
async def get_notes(
start: int = 0,
limit: int = None,
- sort: Literal["filename", "lastModified"] = "filename",
+ sort: Literal["title", "lastModified"] = "title",
order: Literal["asc", "desc"] = "asc",
include_content: bool = False,
_: str = Depends(validate_token),
notes.sort(
key=lambda note: note.last_modified
if sort == "lastModified"
- else note.filename,
+ else note.title,
reverse=order == "desc",
)
return [
async def post_note(data: NoteModel, _: str = Depends(validate_token)):
"""Create a new note."""
try:
- note = Note(flatnotes, data.filename, new=True)
+ note = Note(flatnotes, data.title, new=True)
note.content = data.content
return NoteModel.dump(note, include_content=True)
- except InvalidFilenameError:
- return invalid_filename_response
+ except InvalidTitleError:
+ return invalid_title_response
except FileExistsError:
- return file_exists_response
+ return title_exists_response
-@app.get("/api/notes/{filename}", response_model=NoteModel)
+@app.get("/api/notes/{title}", response_model=NoteModel)
async def get_note(
- filename: str,
+ title: str,
include_content: bool = True,
_: str = Depends(validate_token),
):
"""Get a specific note."""
try:
- note = Note(flatnotes, filename)
+ note = Note(flatnotes, title)
return NoteModel.dump(note, include_content=include_content)
- except InvalidFilenameError:
- return invalid_filename_response
+ except InvalidTitleError:
+ return invalid_title_response
except FileNotFoundError:
- return file_not_found_response
+ return note_not_found_response
-@app.patch("/api/notes/{filename}", response_model=NoteModel)
+@app.patch("/api/notes/{title}", response_model=NoteModel)
async def patch_note(
- filename: str, new_data: NotePatchModel, _: str = Depends(validate_token)
+ title: str, new_data: NotePatchModel, _: str = Depends(validate_token)
):
try:
- note = Note(flatnotes, filename)
- if new_data.new_filename is not None:
- note.filename = new_data.new_filename
+ note = Note(flatnotes, title)
+ if new_data.new_title is not None:
+ note.title = new_data.new_title
if new_data.new_content is not None:
note.content = new_data.new_content
return NoteModel.dump(note, include_content=True)
- except InvalidFilenameError:
- return invalid_filename_response
+ except InvalidTitleError:
+ return invalid_title_response
except FileExistsError:
- return file_exists_response
+ return title_exists_response
except FileNotFoundError:
- return file_not_found_response
+ return note_not_found_response
-@app.delete("/api/notes/{filename}")
-async def delete_note(filename: str, _: str = Depends(validate_token)):
+@app.delete("/api/notes/{title}")
+async def delete_note(title: str, _: str = Depends(validate_token)):
try:
- note = Note(flatnotes, filename)
+ note = Note(flatnotes, title)
note.delete()
- except InvalidFilenameError:
- return invalid_filename_response
+ except InvalidTitleError:
+ return invalid_title_response
except FileNotFoundError:
- return file_not_found_response
+ return note_not_found_response
@app.get("/api/search", response_model=List[NoteHitModel])
class NoteModel(CamelCaseBaseModel):
- filename: str
+ title: str
last_modified: Optional[int]
content: Optional[str]
@classmethod
def dump(cls, note: Note, include_content: bool = True) -> Dict:
return {
- "filename": note.filename,
+ "title": note.title,
"lastModified": note.last_modified,
"content": note.content if include_content else None,
}
class NotePatchModel(CamelCaseBaseModel):
- new_filename: Optional[str]
+ new_title: Optional[str]
new_content: Optional[str]
class NoteHitModel(CamelCaseBaseModel):
- filename: str
+ title: 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,
+ "title": note_hit.title,
"lastModified": note_hit.last_modified,
"titleHighlights": note_hit.title_highlights,
"contentHighlights": note_hit.content_highlights,
import * as constants from "./constants";
class Note {
- constructor(filename, lastModified, content) {
- this.filename = filename;
+ constructor(title, lastModified, content) {
+ this.title = title;
this.lastModified = lastModified;
this.content = content;
}
- get title() {
- if (this.filename) {
- return this.filename.substring(0, this.filename.lastIndexOf("."));
- } else {
- return null;
- }
- }
-
- get ext() {
- return this.filename.substring(this.filename.lastIndexOf(".") + 1);
- }
-
get href() {
return `/${constants.basePaths.note}/${this.title}`;
}
}
class SearchResult extends Note {
- constructor(filename, lastModified, titleHighlights, contentHighlights) {
- super(filename, lastModified);
+ constructor(title, lastModified, titleHighlights, contentHighlights) {
+ super(title, lastModified);
this.titleHighlights = titleHighlights;
this.contentHighlights = contentHighlights;
}
response.data.forEach(function(result) {
parent.searchResults.push(
new SearchResult(
- result.filename,
+ result.title,
result.lastModified,
result.titleHighlights,
result.contentHighlights
},
getContentForEditor: function() {
- let draftContent = localStorage.getItem(this.currentNote.filename);
+ let draftContent = localStorage.getItem(this.currentNote.title);
if (draftContent) {
if (confirm("Do you want to resume the saved draft?")) {
return draftContent;
} else {
- localStorage.removeItem(this.currentNote.filename);
+ localStorage.removeItem(this.currentNote.title);
}
}
return this.currentNote.content;
},
- loadNote: function(filename) {
+ loadNote: function(title) {
let parent = this;
this.noteLoadFailed = false;
api
- .get(`/api/notes/${filename}.${constants.markdownExt}`)
+ .get(`/api/notes/${title}`)
.then(function(response) {
parent.currentNote = new Note(
- response.data.filename,
+ response.data.title,
response.data.lastModified,
response.data.content
);
// To Edit Mode
if (this.editMode == false) {
this.titleInput = this.currentNote.title;
- let draftContent = localStorage.getItem(this.currentNote.filename);
+ let draftContent = localStorage.getItem(this.currentNote.title);
if (draftContent) {
this.$bvModal
parent.initialContent = draftContent;
} else {
parent.initialContent = parent.currentNote.content;
- localStorage.removeItem(parent.currentNote.filename);
+ localStorage.removeItem(parent.currentNote.title);
}
parent.editMode = !parent.editMode;
});
},
saveDraft: function() {
- localStorage.setItem(this.currentNote.filename, this.getEditorContent());
+ localStorage.setItem(this.currentNote.title, this.getEditorContent());
},
- existingFilenameToast: function() {
+ existingTitleToast: function() {
this.$bvToast.toast(
"A note with this title already exists. Please try again with a new title.",
{
if (this.currentNote.lastModified == null) {
api
.post(`/api/notes`, {
- filename: `${this.titleInput}.${constants.markdownExt}`,
+ title: this.titleInput,
content: newContent,
})
.then(this.saveNoteResponseHandler)
typeof error.response !== "undefined" &&
error.response.status == 409
) {
- parent.existingFilenameToast();
+ parent.existingTitleToast();
} else {
parent.unhandledServerErrorToast();
}
this.titleInput != this.currentNote.title
) {
api
- .patch(`/api/notes/${this.currentNote.filename}`, {
- newFilename: `${this.titleInput}.${this.currentNote.ext}`,
+ .patch(`/api/notes/${this.currentNote.title}`, {
+ newTitle: this.titleInput,
newContent: newContent,
})
.then(this.saveNoteResponseHandler)
typeof error.response !== "undefined" &&
error.response.status == 409
) {
- parent.existingFilenameToast();
+ parent.existingTitleToast();
} else {
parent.unhandledServerErrorToast();
}
},
saveNoteResponseHandler: function(response) {
- localStorage.removeItem(this.currentNote.filename);
+ localStorage.removeItem(this.currentNote.title);
this.currentNote = new Note(
- response.data.filename,
+ response.data.title,
response.data.lastModified,
response.data.content
);
},
cancelNote: function() {
- localStorage.removeItem(this.currentNote.filename);
+ localStorage.removeItem(this.currentNote.title);
if (this.currentNote.lastModified == null) {
// Cancelling a new note
this.currentNote = null;
.then(function(response) {
if (response == true) {
api
- .delete(`/api/notes/${parent.currentNote.filename}`)
+ .delete(`/api/notes/${parent.currentNote.title}`)
.then(function() {
parent.navigate("/");
parent.$bvToast.toast("Note deleted ✓", {
<!-- Search Results Loaded -->
<div v-else>
- <div
- v-for="result in searchResults"
- :key="result.filename"
- class="mb-5"
- >
+ <div v-for="result in searchResults" :key="result.title" class="mb-5">
<p class="h5 text-center clickable-link">
<a
v-html="result.titleHighlightsOrTitle"
<!-- Loading -->
<div v-if="notes == null">
- <loading-indicator :failed="loadingFailed"/>
+ <loading-indicator :failed="loadingFailed" />
</div>
<!-- No Notes -->
<p
v-for="note in notes"
class="text-center clickable-link mb-2"
- :key="note.filename"
+ :key="note.title"
>
<a :href="note.href" @click.prevent="openNote(note.href, $event)">{{
note.title
},
methods: {
- getNotes: function (limit = null, sort = "filename", order = "asc") {
+ getNotes: function (limit = null, sort = "title", order = "asc") {
let parent = this;
api
.get("/api/notes", {
.then(function (response) {
parent.notes = [];
response.data.forEach(function (note) {
- parent.notes.push(new Note(note.filename, note.lastModified));
+ parent.notes.push(new Note(note.title, note.lastModified));
});
})
.catch(function (error) {
import codeSyntaxHighlight from "@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight-all.js";
-export const markdownExt = "md";
-
// Base Paths
export const basePaths = { login: "login", note: "note", search: "search" };