Implement server-backed notes store

This commit is contained in:
Adrian Gruntkowski 2024-11-22 14:09:24 +01:00
parent 08e145f00a
commit e8dc258fbe
11 changed files with 178 additions and 27 deletions

View file

@ -1,6 +1,6 @@
# Used by "mix format" # Used by "mix format"
[ [
import_deps: [:plug], import_deps: [:plug, :ecto],
subdirectories: ["priv/repo/migrations"], subdirectories: ["priv/repo/migrations"],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
] ]

View file

@ -1,5 +1,6 @@
Only immediate next things to do are listed here, without any far-fetching plans. Only immediate next things to do are listed here, without any far-fetching plans.
- Implement syncing data with server
- Implement reminders - Implement reminders
- Implement masonry layout - Implement masonry layout
- Implement color coding - Implement color coding

View file

@ -6,7 +6,8 @@ defmodule Jenot.Note do
@primary_key {:id, :binary_id, autogenerate: true} @primary_key {:id, :binary_id, autogenerate: true}
schema "notes" do schema "notes" do
field(:internal_id, :string) field(:internal_id, :string)
field(:title, :string) field(:type, Ecto.Enum, values: [:note, :tasklist], default: :note)
field(:title, :string, default: "")
field(:content, :string) field(:content, :string)
belongs_to(:account, Jenot.Account, type: :binary_id) belongs_to(:account, Jenot.Account, type: :binary_id)
@ -16,14 +17,14 @@ defmodule Jenot.Note do
def new(account, params) do def new(account, params) do
%__MODULE__{} %__MODULE__{}
|> cast(params, [:internal_id, :title, :content]) |> cast(params, [:internal_id, :type, :title, :content])
|> put_change(:id, Ecto.UUID.generate()) |> put_change(:id, Ecto.UUID.generate())
|> validate_required([:internal_id]) |> validate_required([:internal_id, :type])
|> put_assoc(:account, account) |> put_assoc(:account, account)
end end
def update(note, params) do def update(note, params) do
note note
|> cast(params, [:title, :content]) |> cast(params, [:type, :title, :content])
end end
end end

View file

@ -5,7 +5,7 @@ defmodule Jenot.Notes do
alias Jenot.Repo alias Jenot.Repo
def serialize(note) do def serialize(note) do
Map.take(note, [:internal_id, :title, :content, :inserted_at, :updated_at]) serialize_note(note)
end end
def latest_change(account) do def latest_change(account) do
@ -23,6 +23,7 @@ defmodule Jenot.Notes do
end end
def add(account, params) do def add(account, params) do
params = deserialize_params(params)
changeset = Note.new(account, params) changeset = Note.new(account, params)
case upsert(changeset, target: [:account_id, :internal_id]) do case upsert(changeset, target: [:account_id, :internal_id]) do
@ -42,6 +43,8 @@ defmodule Jenot.Notes do
end end
def update(account, internal_id, params) do def update(account, internal_id, params) do
params = deserialize_params(params)
with {:ok, note} <- note_by_internal_id(account, internal_id) do with {:ok, note} <- note_by_internal_id(account, internal_id) do
changeset = Note.update(note, params) changeset = Note.update(note, params)
@ -60,6 +63,38 @@ defmodule Jenot.Notes do
:ok :ok
end 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, nil), do: query
defp maybe_filter_since(query, datetime) do defp maybe_filter_since(query, datetime) do
@ -68,11 +103,14 @@ defmodule Jenot.Notes do
defp upsert(changeset, opts) do defp upsert(changeset, opts) do
conflict_target = Keyword.fetch!(opts, :target) conflict_target = Keyword.fetch!(opts, :target)
type = Ecto.Changeset.get_field(changeset, :type) || :note
title = Ecto.Changeset.get_field(changeset, :title) || "" title = Ecto.Changeset.get_field(changeset, :title) || ""
content = Ecto.Changeset.get_field(changeset, :content) || "" content = Ecto.Changeset.get_field(changeset, :content) || ""
Repo.insert(changeset, 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, conflict_target: conflict_target,
returning: true returning: true
) )

View file

@ -63,6 +63,19 @@ defmodule Jenot.Web do
send_resp(conn, 200, Jason.encode!(notes)) send_resp(conn, 200, Jason.encode!(notes))
end 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 post "/api/notes" do
account = Accounts.get() account = Accounts.get()

View file

@ -11,11 +11,12 @@ defmodule Jenot.Repo.Migrations.InitialSchema do
create table(:notes, primary_key: false) do create table(:notes, primary_key: false) do
add :id, :uuid, null: false, primary_key: true add :id, :uuid, null: false, primary_key: true
add :internal_id, :text, null: false 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 :title, :text, null: false, default: ""
add :content, :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) timestamps(type: :datetime_usec)
end end

View file

@ -1,11 +1,6 @@
const putInCache = async (request, response) => { const putInCache = async (request, response) => {
const requestUrl = URL.parse(request.url); const cache = await caches.open("v1");
await cache.put(request, response);
// 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 }) => { const cacheFirst = async ({ request, fallbackUrl }) => {
@ -33,10 +28,15 @@ const cacheFirst = async ({ request, fallbackUrl }) => {
}; };
self.addEventListener("fetch", (event) => { self.addEventListener("fetch", (event) => {
event.respondWith( const requestUrl = URL.parse(event.request.url);
cacheFirst({
request: event.request, // We don't cache API requests
fallbackUrl: "/index.html", if (!requestUrl.pathname.startsWith("/api")) {
}), event.respondWith(
); cacheFirst({
request: event.request,
fallbackUrl: "/index.html",
}),
);
}
}); });

View file

@ -37,12 +37,14 @@ export class DBNoteStore extends EventTarget {
async add(note) { async add(note) {
const that = this; const that = this;
const now = Date.now();
const entry = { const entry = {
id: "id_" + Date.now(), id: "id_" + now,
type: note.type, type: note.type,
content: note.content, content: note.content,
created: new Date(), created: now,
updated: now,
}; };
return this.#connect().then( return this.#connect().then(
@ -90,6 +92,8 @@ export class DBNoteStore extends EventTarget {
async update(note) { async update(note) {
const that = this; const that = this;
note.updated = Date.now();
return this.#connect().then( return this.#connect().then(
(db) => (db) =>
new Promise( new Promise(

View file

@ -2,6 +2,7 @@ import "./service-worker.js";
import { renderText } from "./dom.js"; import { renderText } from "./dom.js";
import { NoteStore } from "./store.js"; import { NoteStore } from "./store.js";
import { DBNoteStore } from "./db-store.js"; import { DBNoteStore } from "./db-store.js";
import { WebNoteStore } from "./web-store.js";
import { import {
authorizeNotifications, authorizeNotifications,
notificationsEnabled, notificationsEnabled,
@ -29,7 +30,7 @@ const urlParams = new URLSearchParams(window.location.search);
const Notes = urlParams.has("localStorage") const Notes = urlParams.has("localStorage")
? new NoteStore("jenot-app") ? new NoteStore("jenot-app")
: new DBNoteStore("jenot-app", "notes"); : new WebNoteStore("jenot-app", "notes");
const newNote = document.querySelector("#new-note"); const newNote = document.querySelector("#new-note");
const editNote = document.querySelector("#edit-note"); const editNote = document.querySelector("#edit-note");
@ -93,6 +94,7 @@ async function render() {
newNote.classList.add("hidden"); newNote.classList.add("hidden");
editNote.classList.remove("hidden"); editNote.classList.remove("hidden");
const note = await Notes.get(container.id); const note = await Notes.get(container.id);
console.log("edited note", note);
editNote.load(note); editNote.load(note);
}); });
}); });

View file

@ -9,6 +9,7 @@ export class NoteStore extends EventTarget {
- type - either `note` or `tasklist` - type - either `note` or `tasklist`
- content - note's content - content - note's content
- created - timestamp - created - timestamp
- updated - timestamp
*/ */
constructor(localStorageKey) { constructor(localStorageKey) {
@ -32,11 +33,14 @@ export class NoteStore extends EventTarget {
get = (id) => this.notes.find((note) => note.id === id); get = (id) => this.notes.find((note) => note.id === id);
add(note) { add(note) {
const now = Date.now();
this.notes.unshift({ this.notes.unshift({
id: "id_" + Date.now(), id: "id_" + now,
type: note.type, type: note.type,
content: note.content, content: note.content,
created: new Date(), created: now,
updated: now,
}); });
} }
@ -49,6 +53,7 @@ export class NoteStore extends EventTarget {
} }
update(note) { update(note) {
note.updated = Date.now();
this.notes = this.notes.map((n) => (n.id === note.id ? note : n)); this.notes = this.notes.map((n) => (n.id === note.id ? note : n));
} }

View 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);
}
}
}