From e8dc258fbe17b525167e426604a31aa8af0c3c6c Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Fri, 22 Nov 2024 14:09:24 +0100 Subject: [PATCH] Implement server-backed notes store --- .formatter.exs | 2 +- TODO.md | 1 + lib/jenot/note.ex | 9 +- lib/jenot/notes.ex | 42 ++++++++- lib/jenot/web.ex | 13 +++ .../20241117180924_initial_schema.exs | 5 +- priv/static/js/caching-worker.js | 26 +++--- priv/static/js/db-store.js | 8 +- priv/static/js/jenot.js | 4 +- priv/static/js/store.js | 9 +- priv/static/js/web-store.js | 86 +++++++++++++++++++ 11 files changed, 178 insertions(+), 27 deletions(-) create mode 100644 priv/static/js/web-store.js diff --git a/.formatter.exs b/.formatter.exs index 662c239..62aca5e 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -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}"] ] diff --git a/TODO.md b/TODO.md index 8e73f6c..f9d2ce8 100644 --- a/TODO.md +++ b/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 diff --git a/lib/jenot/note.ex b/lib/jenot/note.ex index 4884a62..c382ea9 100644 --- a/lib/jenot/note.ex +++ b/lib/jenot/note.ex @@ -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 diff --git a/lib/jenot/notes.ex b/lib/jenot/notes.ex index e2d535e..71a6afe 100644 --- a/lib/jenot/notes.ex +++ b/lib/jenot/notes.ex @@ -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 ) diff --git a/lib/jenot/web.ex b/lib/jenot/web.ex index cace960..c0aa724 100644 --- a/lib/jenot/web.ex +++ b/lib/jenot/web.ex @@ -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() diff --git a/priv/repo/migrations/20241117180924_initial_schema.exs b/priv/repo/migrations/20241117180924_initial_schema.exs index a58aa7c..c826eff 100644 --- a/priv/repo/migrations/20241117180924_initial_schema.exs +++ b/priv/repo/migrations/20241117180924_initial_schema.exs @@ -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 diff --git a/priv/static/js/caching-worker.js b/priv/static/js/caching-worker.js index cdc1fb1..89486c3 100644 --- a/priv/static/js/caching-worker.js +++ b/priv/static/js/caching-worker.js @@ -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 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) => { - event.respondWith( - cacheFirst({ - request: event.request, - fallbackUrl: "/index.html", - }), - ); + 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", + }), + ); + } }); diff --git a/priv/static/js/db-store.js b/priv/static/js/db-store.js index 7fdeda6..96cba29 100644 --- a/priv/static/js/db-store.js +++ b/priv/static/js/db-store.js @@ -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( diff --git a/priv/static/js/jenot.js b/priv/static/js/jenot.js index 6a93e48..0b7218b 100644 --- a/priv/static/js/jenot.js +++ b/priv/static/js/jenot.js @@ -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); }); }); diff --git a/priv/static/js/store.js b/priv/static/js/store.js index 8fb440f..b473aff 100644 --- a/priv/static/js/store.js +++ b/priv/static/js/store.js @@ -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)); } diff --git a/priv/static/js/web-store.js b/priv/static/js/web-store.js new file mode 100644 index 0000000..7423a4c --- /dev/null +++ b/priv/static/js/web-store.js @@ -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); + } + } +}