mirror of
https://github.com/zoldar/jenot.git
synced 2026-01-03 06:22:55 +00:00
Implement server-backed notes store
This commit is contained in:
parent
08e145f00a
commit
e8dc258fbe
11 changed files with 178 additions and 27 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# Used by "mix format"
|
||||
[
|
||||
import_deps: [:plug],
|
||||
import_deps: [:plug, :ecto],
|
||||
subdirectories: ["priv/repo/migrations"],
|
||||
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||
]
|
||||
|
|
|
|||
1
TODO.md
1
TODO.md
|
|
@ -1,5 +1,6 @@
|
|||
Only immediate next things to do are listed here, without any far-fetching plans.
|
||||
|
||||
- Implement syncing data with server
|
||||
- Implement reminders
|
||||
- Implement masonry layout
|
||||
- Implement color coding
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ defmodule Jenot.Note do
|
|||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
schema "notes" do
|
||||
field(:internal_id, :string)
|
||||
field(:title, :string)
|
||||
field(:type, Ecto.Enum, values: [:note, :tasklist], default: :note)
|
||||
field(:title, :string, default: "")
|
||||
field(:content, :string)
|
||||
|
||||
belongs_to(:account, Jenot.Account, type: :binary_id)
|
||||
|
|
@ -16,14 +17,14 @@ defmodule Jenot.Note do
|
|||
|
||||
def new(account, params) do
|
||||
%__MODULE__{}
|
||||
|> cast(params, [:internal_id, :title, :content])
|
||||
|> cast(params, [:internal_id, :type, :title, :content])
|
||||
|> put_change(:id, Ecto.UUID.generate())
|
||||
|> validate_required([:internal_id])
|
||||
|> validate_required([:internal_id, :type])
|
||||
|> put_assoc(:account, account)
|
||||
end
|
||||
|
||||
def update(note, params) do
|
||||
note
|
||||
|> cast(params, [:title, :content])
|
||||
|> cast(params, [:type, :title, :content])
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ defmodule Jenot.Notes do
|
|||
alias Jenot.Repo
|
||||
|
||||
def serialize(note) do
|
||||
Map.take(note, [:internal_id, :title, :content, :inserted_at, :updated_at])
|
||||
serialize_note(note)
|
||||
end
|
||||
|
||||
def latest_change(account) do
|
||||
|
|
@ -23,6 +23,7 @@ defmodule Jenot.Notes do
|
|||
end
|
||||
|
||||
def add(account, params) do
|
||||
params = deserialize_params(params)
|
||||
changeset = Note.new(account, params)
|
||||
|
||||
case upsert(changeset, target: [:account_id, :internal_id]) do
|
||||
|
|
@ -42,6 +43,8 @@ defmodule Jenot.Notes do
|
|||
end
|
||||
|
||||
def update(account, internal_id, params) do
|
||||
params = deserialize_params(params)
|
||||
|
||||
with {:ok, note} <- note_by_internal_id(account, internal_id) do
|
||||
changeset = Note.update(note, params)
|
||||
|
||||
|
|
@ -60,6 +63,38 @@ defmodule Jenot.Notes do
|
|||
:ok
|
||||
end
|
||||
|
||||
defp deserialize_params(params) do
|
||||
params =
|
||||
case Map.get(params, "content", Map.get(params, :content)) do
|
||||
data when is_list(data) -> Map.put(params, "content", Jason.encode!(data))
|
||||
_ -> params
|
||||
end
|
||||
|
||||
case Map.get(params, "id", Map.get(params, :id)) do
|
||||
nil ->
|
||||
params
|
||||
|
||||
internal_id ->
|
||||
params
|
||||
|> Map.delete("id")
|
||||
|> Map.delete(:id)
|
||||
|> Map.put("internal_id", internal_id)
|
||||
end
|
||||
end
|
||||
|
||||
defp serialize_note(note) do
|
||||
note =
|
||||
note
|
||||
|> Map.take([:title, :type, :content, :inserted_at, :updated_at])
|
||||
|> Map.put(:id, note.internal_id)
|
||||
|
||||
if note.type == :tasklist and not is_nil(note.content) do
|
||||
%{note | content: Jason.decode!(note.content)}
|
||||
else
|
||||
note
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_filter_since(query, nil), do: query
|
||||
|
||||
defp maybe_filter_since(query, datetime) do
|
||||
|
|
@ -68,11 +103,14 @@ defmodule Jenot.Notes do
|
|||
|
||||
defp upsert(changeset, opts) do
|
||||
conflict_target = Keyword.fetch!(opts, :target)
|
||||
type = Ecto.Changeset.get_field(changeset, :type) || :note
|
||||
title = Ecto.Changeset.get_field(changeset, :title) || ""
|
||||
content = Ecto.Changeset.get_field(changeset, :content) || ""
|
||||
|
||||
Repo.insert(changeset,
|
||||
on_conflict: [set: [title: title, content: content, updated_at: DateTime.utc_now()]],
|
||||
on_conflict: [
|
||||
set: [type: type, title: title, content: content, updated_at: DateTime.utc_now()]
|
||||
],
|
||||
conflict_target: conflict_target,
|
||||
returning: true
|
||||
)
|
||||
|
|
|
|||
|
|
@ -63,6 +63,19 @@ defmodule Jenot.Web do
|
|||
send_resp(conn, 200, Jason.encode!(notes))
|
||||
end
|
||||
|
||||
get "/api/notes/:internal_id" do
|
||||
account = Accounts.get()
|
||||
|
||||
case Notes.note_by_internal_id(account, internal_id) do
|
||||
{:ok, note} ->
|
||||
note = Notes.serialize(note)
|
||||
send_resp(conn, 200, Jason.encode!(note))
|
||||
|
||||
{:error, :note_not_found} ->
|
||||
send_resp(conn, 404, Jason.encode!(%{error: "Note not found"}))
|
||||
end
|
||||
end
|
||||
|
||||
post "/api/notes" do
|
||||
account = Accounts.get()
|
||||
|
||||
|
|
|
|||
|
|
@ -11,11 +11,12 @@ defmodule Jenot.Repo.Migrations.InitialSchema do
|
|||
create table(:notes, primary_key: false) do
|
||||
add :id, :uuid, null: false, primary_key: true
|
||||
add :internal_id, :text, null: false
|
||||
add :account_id, references(:accounts, on_delete: :delete_all), null: false
|
||||
|
||||
add :type, :text, null: false
|
||||
add :title, :text, null: false, default: ""
|
||||
add :content, :text, null: false, default: ""
|
||||
|
||||
add :account_id, references(:accounts, on_delete: :delete_all), null: false
|
||||
|
||||
timestamps(type: :datetime_usec)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
const putInCache = async (request, response) => {
|
||||
const requestUrl = URL.parse(request.url);
|
||||
|
||||
// We don't cache API requests
|
||||
if (!requestUrl.pathname.startsWith('/api/')) {
|
||||
const cache = await caches.open("v1");
|
||||
await cache.put(request, response);
|
||||
}
|
||||
};
|
||||
|
||||
const cacheFirst = async ({ request, fallbackUrl }) => {
|
||||
|
|
@ -33,10 +28,15 @@ const cacheFirst = async ({ request, fallbackUrl }) => {
|
|||
};
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const requestUrl = URL.parse(event.request.url);
|
||||
|
||||
// We don't cache API requests
|
||||
if (!requestUrl.pathname.startsWith("/api")) {
|
||||
event.respondWith(
|
||||
cacheFirst({
|
||||
request: event.request,
|
||||
fallbackUrl: "/index.html",
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,12 +37,14 @@ export class DBNoteStore extends EventTarget {
|
|||
|
||||
async add(note) {
|
||||
const that = this;
|
||||
const now = Date.now();
|
||||
|
||||
const entry = {
|
||||
id: "id_" + Date.now(),
|
||||
id: "id_" + now,
|
||||
type: note.type,
|
||||
content: note.content,
|
||||
created: new Date(),
|
||||
created: now,
|
||||
updated: now,
|
||||
};
|
||||
|
||||
return this.#connect().then(
|
||||
|
|
@ -90,6 +92,8 @@ export class DBNoteStore extends EventTarget {
|
|||
async update(note) {
|
||||
const that = this;
|
||||
|
||||
note.updated = Date.now();
|
||||
|
||||
return this.#connect().then(
|
||||
(db) =>
|
||||
new Promise(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import "./service-worker.js";
|
|||
import { renderText } from "./dom.js";
|
||||
import { NoteStore } from "./store.js";
|
||||
import { DBNoteStore } from "./db-store.js";
|
||||
import { WebNoteStore } from "./web-store.js";
|
||||
import {
|
||||
authorizeNotifications,
|
||||
notificationsEnabled,
|
||||
|
|
@ -29,7 +30,7 @@ const urlParams = new URLSearchParams(window.location.search);
|
|||
|
||||
const Notes = urlParams.has("localStorage")
|
||||
? new NoteStore("jenot-app")
|
||||
: new DBNoteStore("jenot-app", "notes");
|
||||
: new WebNoteStore("jenot-app", "notes");
|
||||
|
||||
const newNote = document.querySelector("#new-note");
|
||||
const editNote = document.querySelector("#edit-note");
|
||||
|
|
@ -93,6 +94,7 @@ async function render() {
|
|||
newNote.classList.add("hidden");
|
||||
editNote.classList.remove("hidden");
|
||||
const note = await Notes.get(container.id);
|
||||
console.log("edited note", note);
|
||||
editNote.load(note);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export class NoteStore extends EventTarget {
|
|||
- type - either `note` or `tasklist`
|
||||
- content - note's content
|
||||
- created - timestamp
|
||||
- updated - timestamp
|
||||
*/
|
||||
|
||||
constructor(localStorageKey) {
|
||||
|
|
@ -32,11 +33,14 @@ export class NoteStore extends EventTarget {
|
|||
get = (id) => this.notes.find((note) => note.id === id);
|
||||
|
||||
add(note) {
|
||||
const now = Date.now();
|
||||
|
||||
this.notes.unshift({
|
||||
id: "id_" + Date.now(),
|
||||
id: "id_" + now,
|
||||
type: note.type,
|
||||
content: note.content,
|
||||
created: new Date(),
|
||||
created: now,
|
||||
updated: now,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -49,6 +53,7 @@ export class NoteStore extends EventTarget {
|
|||
}
|
||||
|
||||
update(note) {
|
||||
note.updated = Date.now();
|
||||
this.notes = this.notes.map((n) => (n.id === note.id ? note : n));
|
||||
}
|
||||
|
||||
|
|
|
|||
86
priv/static/js/web-store.js
Normal file
86
priv/static/js/web-store.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
export class WebNoteStore extends EventTarget {
|
||||
constructor(dbName, storeName, endpoint) {
|
||||
super();
|
||||
this.dbName = dbName;
|
||||
this.storeName = storeName;
|
||||
this.db = null;
|
||||
this.endpoint = endpoint || "/";
|
||||
}
|
||||
|
||||
async all() {
|
||||
return this.#request(`${this.endpoint}api/notes`, {}, () => []);
|
||||
}
|
||||
|
||||
async get(id) {
|
||||
console.log("get", id);
|
||||
return this.#request(`${this.endpoint}api/notes/${id}`, {}, () => null);
|
||||
}
|
||||
|
||||
async add(note) {
|
||||
const now = Date.now();
|
||||
|
||||
const entry = {
|
||||
id: "id_" + now,
|
||||
type: note.type,
|
||||
content: note.content,
|
||||
};
|
||||
|
||||
return this.#request(
|
||||
`${this.endpoint}api/notes`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(entry),
|
||||
},
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
|
||||
async reset() {
|
||||
// NOOP for now
|
||||
return null;
|
||||
}
|
||||
|
||||
async remove({ id }) {
|
||||
return this.#request(
|
||||
`${this.endpoint}api/notes/${id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
|
||||
async update(note) {
|
||||
return this.#request(
|
||||
`${this.endpoint}api/notes/${note.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(note),
|
||||
},
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
|
||||
saveStorage() {
|
||||
this.dispatchEvent(new CustomEvent("save"));
|
||||
}
|
||||
|
||||
async #request(url, opts, errorCallback) {
|
||||
opts.headers = {
|
||||
...(opts.headers || {}),
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, opts);
|
||||
if (!response.ok) {
|
||||
console.error("Request failed", response);
|
||||
return errorCallback(response);
|
||||
}
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
console.error("Request error", error);
|
||||
return errorCallback(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue