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"
|
# 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}"]
|
||||||
]
|
]
|
||||||
|
|
|
||||||
1
TODO.md
1
TODO.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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