mirror of
https://github.com/zoldar/jenot.git
synced 2026-01-05 07:02:55 +00:00
Implement very rudimentary sync capability
This commit is contained in:
parent
3991491527
commit
001d7f8296
15 changed files with 473 additions and 320 deletions
|
|
@ -5,7 +5,7 @@ defmodule Jenot.Account do
|
||||||
|
|
||||||
@primary_key {:id, :binary_id, autogenerate: true}
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
schema "accounts" do
|
schema "accounts" do
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime_usec)
|
||||||
end
|
end
|
||||||
|
|
||||||
def new() do
|
def new() do
|
||||||
|
|
|
||||||
|
|
@ -9,22 +9,32 @@ defmodule Jenot.Note do
|
||||||
field(:type, Ecto.Enum, values: [:note, :tasklist], default: :note)
|
field(:type, Ecto.Enum, values: [:note, :tasklist], default: :note)
|
||||||
field(:title, :string, default: "")
|
field(:title, :string, default: "")
|
||||||
field(:content, :string)
|
field(:content, :string)
|
||||||
|
field(:deleted_at, :utc_datetime_usec)
|
||||||
|
|
||||||
belongs_to(:account, Jenot.Account, type: :binary_id)
|
belongs_to(:account, Jenot.Account, type: :binary_id)
|
||||||
|
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime_usec)
|
||||||
end
|
end
|
||||||
|
|
||||||
def new(account, params) do
|
def new(account, params) do
|
||||||
%__MODULE__{}
|
%__MODULE__{}
|
||||||
|> cast(params, [:internal_id, :type, :title, :content])
|
|> cast(params, [
|
||||||
|
:internal_id,
|
||||||
|
:type,
|
||||||
|
:title,
|
||||||
|
:content,
|
||||||
|
:deleted_at,
|
||||||
|
:inserted_at,
|
||||||
|
:updated_at
|
||||||
|
])
|
||||||
|> put_change(:id, Ecto.UUID.generate())
|
|> put_change(:id, Ecto.UUID.generate())
|
||||||
|> validate_required([:internal_id, :type])
|
|> validate_required([:internal_id, :type, :inserted_at, :updated_at])
|
||||||
|> put_assoc(:account, account)
|
|> put_assoc(:account, account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update(note, params) do
|
def update(note, params) do
|
||||||
note
|
note
|
||||||
|> cast(params, [:type, :title, :content])
|
|> cast(params, [:type, :title, :content, :deleted_at, :updated_at])
|
||||||
|
|> validate_required([:internal_id, :type, :updated_at])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,15 @@ defmodule Jenot.Notes do
|
||||||
def latest_change(account) do
|
def latest_change(account) do
|
||||||
Note
|
Note
|
||||||
|> where(account_id: ^account.id)
|
|> where(account_id: ^account.id)
|
||||||
|> select([a], max(a.updated_at))
|
|> where([n], is_nil(n.deleted_at))
|
||||||
|
|> select([n], max(n.updated_at))
|
||||||
|> Repo.one()
|
|> Repo.one()
|
||||||
end
|
end
|
||||||
|
|
||||||
def all(account, since \\ nil) do
|
def all(account, since \\ nil, include_deleted? \\ false) do
|
||||||
Note
|
Note
|
||||||
|> where(account_id: ^account.id)
|
|> where(account_id: ^account.id)
|
||||||
|
|> maybe_include_deleted(include_deleted?)
|
||||||
|> maybe_filter_since(since)
|
|> maybe_filter_since(since)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
@ -33,7 +35,11 @@ defmodule Jenot.Notes do
|
||||||
end
|
end
|
||||||
|
|
||||||
def note_by_internal_id(account, internal_id) do
|
def note_by_internal_id(account, internal_id) do
|
||||||
note = Repo.get_by(Note, account_id: account.id, internal_id: internal_id)
|
note =
|
||||||
|
Note
|
||||||
|
|> where(account_id: ^account.id)
|
||||||
|
|> where(internal_id: ^internal_id)
|
||||||
|
|> Repo.one()
|
||||||
|
|
||||||
if note do
|
if note do
|
||||||
{:ok, note}
|
{:ok, note}
|
||||||
|
|
@ -58,7 +64,7 @@ defmodule Jenot.Notes do
|
||||||
def delete(account, internal_id) do
|
def delete(account, internal_id) do
|
||||||
Note
|
Note
|
||||||
|> where(account_id: ^account.id, internal_id: ^internal_id)
|
|> where(account_id: ^account.id, internal_id: ^internal_id)
|
||||||
|> Repo.delete_all()
|
|> Repo.update_all(set: [deleted_at: DateTime.utc_now(), updated_at: DateTime.utc_now()])
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
@ -79,14 +85,20 @@ defmodule Jenot.Notes do
|
||||||
|> Map.delete("id")
|
|> Map.delete("id")
|
||||||
|> Map.delete(:id)
|
|> Map.delete(:id)
|
||||||
|> Map.put("internal_id", internal_id)
|
|> Map.put("internal_id", internal_id)
|
||||||
|
|> deserialize_timestamp(:deleted, :deleted_at)
|
||||||
|
|> deserialize_timestamp(:created, :inserted_at)
|
||||||
|
|> deserialize_timestamp(:updated, :updated_at)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp serialize_note(note) do
|
defp serialize_note(note) do
|
||||||
note =
|
note =
|
||||||
note
|
note
|
||||||
|> Map.take([:title, :type, :content, :inserted_at, :updated_at])
|
|> Map.take([:title, :type, :content, :inserted_at, :updated_at, :deleted_at])
|
||||||
|> Map.put(:id, note.internal_id)
|
|> Map.put(:id, note.internal_id)
|
||||||
|
|> serialize_timestamp(:deleted_at, :deleted)
|
||||||
|
|> serialize_timestamp(:inserted_at, :created)
|
||||||
|
|> serialize_timestamp(:updated_at, :updated)
|
||||||
|
|
||||||
if note.type == :tasklist and not is_nil(note.content) do
|
if note.type == :tasklist and not is_nil(note.content) do
|
||||||
%{note | content: Jason.decode!(note.content)}
|
%{note | content: Jason.decode!(note.content)}
|
||||||
|
|
@ -95,9 +107,47 @@ defmodule Jenot.Notes do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp deserialize_timestamp(params, src_key, dst_key) do
|
||||||
|
str_src_key = to_string(src_key)
|
||||||
|
|
||||||
|
value =
|
||||||
|
case Map.get(params, str_src_key, Map.get(params, src_key)) do
|
||||||
|
nil -> nil
|
||||||
|
unix_time -> DateTime.from_unix!(unix_time, :millisecond)
|
||||||
|
end
|
||||||
|
|
||||||
|
params
|
||||||
|
|> Map.delete(src_key)
|
||||||
|
|> Map.delete(str_src_key)
|
||||||
|
|> Map.put(to_string(dst_key), value)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp serialize_timestamp(data, src_key, dst_key) do
|
||||||
|
value =
|
||||||
|
case data[src_key] do
|
||||||
|
nil -> nil
|
||||||
|
dt -> DateTime.to_unix(dt, :millisecond)
|
||||||
|
end
|
||||||
|
|
||||||
|
data
|
||||||
|
|> Map.delete(src_key)
|
||||||
|
|> Map.put(dst_key, value)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_include_deleted(q, true), do: q
|
||||||
|
|
||||||
|
defp maybe_include_deleted(q, false) do
|
||||||
|
where(q, [n], is_nil(n.deleted_at))
|
||||||
|
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, unix_time) do
|
||||||
|
datetime =
|
||||||
|
unix_time
|
||||||
|
|> String.to_integer()
|
||||||
|
|> DateTime.from_unix!(:millisecond)
|
||||||
|
|
||||||
where(query, [n], n.updated_at > ^datetime)
|
where(query, [n], n.updated_at > ^datetime)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -106,11 +156,59 @@ defmodule Jenot.Notes do
|
||||||
type = Ecto.Changeset.get_field(changeset, :type) || :note
|
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) || ""
|
||||||
|
deleted_at = Ecto.Changeset.get_field(changeset, :deleted_at)
|
||||||
|
updated_at = Ecto.Changeset.get_field(changeset, :updated_at)
|
||||||
|
|
||||||
|
conflict_query =
|
||||||
|
from(n in Note,
|
||||||
|
update: [
|
||||||
|
set: [
|
||||||
|
type:
|
||||||
|
fragment(
|
||||||
|
"CASE WHEN ? > ? THEN ? ELSE ? END",
|
||||||
|
^updated_at,
|
||||||
|
n.updated_at,
|
||||||
|
^type,
|
||||||
|
n.type
|
||||||
|
),
|
||||||
|
title:
|
||||||
|
fragment(
|
||||||
|
"CASE WHEN ? > ? THEN ? ELSE ? END",
|
||||||
|
^updated_at,
|
||||||
|
n.updated_at,
|
||||||
|
^title,
|
||||||
|
n.title
|
||||||
|
),
|
||||||
|
content:
|
||||||
|
fragment(
|
||||||
|
"CASE WHEN ? > ? THEN ? ELSE ? END",
|
||||||
|
^updated_at,
|
||||||
|
n.updated_at,
|
||||||
|
^content,
|
||||||
|
n.content
|
||||||
|
),
|
||||||
|
deleted_at:
|
||||||
|
fragment(
|
||||||
|
"CASE WHEN ? > ? THEN ? ELSE ? END",
|
||||||
|
^updated_at,
|
||||||
|
n.updated_at,
|
||||||
|
^deleted_at,
|
||||||
|
n.deleted_at
|
||||||
|
),
|
||||||
|
updated_at:
|
||||||
|
fragment(
|
||||||
|
"CASE WHEN ? > ? THEN ? ELSE ? END",
|
||||||
|
^updated_at,
|
||||||
|
n.updated_at,
|
||||||
|
^updated_at,
|
||||||
|
n.updated_at
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
Repo.insert(changeset,
|
Repo.insert(changeset,
|
||||||
on_conflict: [
|
on_conflict: conflict_query,
|
||||||
set: [type: type, title: title, content: content, updated_at: DateTime.utc_now()]
|
|
||||||
],
|
|
||||||
conflict_target: conflict_target,
|
conflict_target: conflict_target,
|
||||||
returning: true
|
returning: true
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ defmodule Jenot.Reminder do
|
||||||
field(:day_of_week, :integer)
|
field(:day_of_week, :integer)
|
||||||
field(:repeat_period, Ecto.Enum, values: [:day, :week, :month, :year])
|
field(:repeat_period, Ecto.Enum, values: [:day, :week, :month, :year])
|
||||||
field(:repeat_count, :integer)
|
field(:repeat_count, :integer)
|
||||||
|
field(:deleted_at, :utc_datetime_usec)
|
||||||
|
|
||||||
belongs_to(:note, Jenot.Note)
|
belongs_to(:note, Jenot.Note)
|
||||||
|
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime_usec)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,6 @@ defmodule Jenot.Subscription do
|
||||||
|
|
||||||
belongs_to(:account, Jenot.Account)
|
belongs_to(:account, Jenot.Account)
|
||||||
|
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime_usec)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ defmodule Jenot.Web do
|
||||||
|
|
||||||
notes =
|
notes =
|
||||||
account
|
account
|
||||||
|> Notes.all(conn.params["since"])
|
|> Notes.all(conn.params["since"], conn.params["deleted"] == "true")
|
||||||
|> Enum.map(&Notes.serialize/1)
|
|> Enum.map(&Notes.serialize/1)
|
||||||
|
|
||||||
send_resp(conn, 200, Jason.encode!(notes))
|
send_resp(conn, 200, Jason.encode!(notes))
|
||||||
|
|
@ -108,7 +108,7 @@ defmodule Jenot.Web do
|
||||||
|
|
||||||
:ok = Notes.delete(account, internal_id)
|
:ok = Notes.delete(account, internal_id)
|
||||||
|
|
||||||
send_resp(conn, 204, "")
|
send_resp(conn, 200, Jason.encode!(%{deleted: true}))
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/push/public-key" do
|
get "/api/push/public-key" do
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ defmodule Jenot.Repo.Migrations.InitialSchema do
|
||||||
add :type, :text, 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 :deleted_at, :datetime_usec
|
||||||
|
|
||||||
add :account_id, references(:accounts, on_delete: :delete_all), null: false
|
add :account_id, references(:accounts, on_delete: :delete_all), null: false
|
||||||
|
|
||||||
|
|
@ -49,6 +50,8 @@ defmodule Jenot.Repo.Migrations.InitialSchema do
|
||||||
add :repeat_period, :text
|
add :repeat_period, :text
|
||||||
add :repeat_count, :integer
|
add :repeat_count, :integer
|
||||||
|
|
||||||
|
add :deleted_at, :datetime_usec
|
||||||
|
|
||||||
add :note_id, references(:note, on_delete: :delete_all), null: false
|
add :note_id, references(:note, on_delete: :delete_all), null: false
|
||||||
|
|
||||||
timestamps(type: :datetime_usec)
|
timestamps(type: :datetime_usec)
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
const putInCache = async (request, response) => {
|
|
||||||
const cache = await caches.open("v1");
|
|
||||||
await cache.put(request, response);
|
|
||||||
};
|
|
||||||
|
|
||||||
const cacheFirst = async ({ request, fallbackUrl }) => {
|
|
||||||
const responseFromCache = await caches.match(request);
|
|
||||||
if (responseFromCache) {
|
|
||||||
return responseFromCache;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const responseFromNetwork = await fetch(request);
|
|
||||||
// Cloning is needed because a response can only be consumed once.
|
|
||||||
putInCache(request, responseFromNetwork.clone());
|
|
||||||
return responseFromNetwork;
|
|
||||||
} catch (error) {
|
|
||||||
const fallbackResponse = await caches.match(fallbackUrl);
|
|
||||||
if (fallbackResponse) {
|
|
||||||
return fallbackResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response("Network error happened", {
|
|
||||||
status: 408,
|
|
||||||
headers: { "Content-Type": "text/plain" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
export class DBNoteStore extends EventTarget {
|
|
||||||
constructor(dbName, storeName) {
|
|
||||||
super();
|
|
||||||
this.dbName = dbName;
|
|
||||||
this.storeName = storeName;
|
|
||||||
this.db = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async all() {
|
|
||||||
const that = this;
|
|
||||||
return this.#connect().then(
|
|
||||||
(db) =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
db
|
|
||||||
.transaction([that.storeName], "readonly")
|
|
||||||
.objectStore(that.storeName)
|
|
||||||
.getAll().onsuccess = (data) => resolve(data.target.result);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(id) {
|
|
||||||
const that = this;
|
|
||||||
let result;
|
|
||||||
|
|
||||||
return this.#connect().then(
|
|
||||||
(db) =>
|
|
||||||
new Promise(
|
|
||||||
(resolve, reject) =>
|
|
||||||
(db
|
|
||||||
.transaction([that.storeName], "readonly")
|
|
||||||
.objectStore(that.storeName)
|
|
||||||
.get(id).onsuccess = (data) => resolve(data.target.result)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async add(note) {
|
|
||||||
const that = this;
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
const entry = {
|
|
||||||
id: "id_" + now,
|
|
||||||
type: note.type,
|
|
||||||
content: note.content,
|
|
||||||
created: now,
|
|
||||||
updated: now,
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.#connect().then(
|
|
||||||
(db) =>
|
|
||||||
new Promise(
|
|
||||||
(resolve, reject) =>
|
|
||||||
(db
|
|
||||||
.transaction([that.storeName], "readwrite")
|
|
||||||
.objectStore(that.storeName)
|
|
||||||
.add(entry).onsuccess = () => resolve(null)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async reset() {
|
|
||||||
const that = this;
|
|
||||||
|
|
||||||
return this.#connect().then(
|
|
||||||
(db) =>
|
|
||||||
new Promise(
|
|
||||||
(resolve, reject) =>
|
|
||||||
(db
|
|
||||||
.transaction([that.storeName], "readwrite")
|
|
||||||
.objectStore(that.storeName)
|
|
||||||
.clear().onsuccess = () => resolve(null)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove({ id }) {
|
|
||||||
const that = this;
|
|
||||||
|
|
||||||
return this.#connect().then(
|
|
||||||
(db) =>
|
|
||||||
new Promise(
|
|
||||||
(resolve, reject) =>
|
|
||||||
(db
|
|
||||||
.transaction([that.storeName], "readwrite")
|
|
||||||
.objectStore(that.storeName)
|
|
||||||
.delete(id).onsuccess = () => resolve(null)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(note) {
|
|
||||||
const that = this;
|
|
||||||
|
|
||||||
note.updated = Date.now();
|
|
||||||
|
|
||||||
return this.#connect().then(
|
|
||||||
(db) =>
|
|
||||||
new Promise(
|
|
||||||
(resolve, reject) =>
|
|
||||||
(db
|
|
||||||
.transaction([that.storeName], "readwrite")
|
|
||||||
.objectStore(that.storeName)
|
|
||||||
.put(note).onsuccess = () => resolve(null)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveStorage() {
|
|
||||||
this.dispatchEvent(new CustomEvent("save"));
|
|
||||||
}
|
|
||||||
|
|
||||||
async #connect() {
|
|
||||||
if (!this.db) {
|
|
||||||
this.db = await this.#dbConnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.db;
|
|
||||||
}
|
|
||||||
|
|
||||||
#dbConnect() {
|
|
||||||
const that = this;
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const request = indexedDB.open(this.dbName, 1);
|
|
||||||
|
|
||||||
request.onsuccess = (e) => {
|
|
||||||
resolve(e.target.result);
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onerror = (e) => {
|
|
||||||
console.error(`indexedDB error: ${e.target.errorCode}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onupgradeneeded = (e) => {
|
|
||||||
const db = e.target.result;
|
|
||||||
db.createObjectStore(that.storeName, {
|
|
||||||
keyPath: "id",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import "./service-worker.js";
|
import "./service-worker-init.js";
|
||||||
import { renderText } from "./dom.js";
|
import { renderText } from "./dom.js";
|
||||||
import { NoteStore } from "./store.js";
|
import { LocalNoteStore } from "./local-store.js";
|
||||||
import { DBNoteStore } from "./db-store.js";
|
import { SyncedNoteStore } from "./synced-store.js";
|
||||||
import { WebNoteStore } from "./web-store.js";
|
|
||||||
import {
|
import {
|
||||||
authorizeNotifications,
|
authorizeNotifications,
|
||||||
notificationsEnabled,
|
notificationsEnabled,
|
||||||
|
|
@ -29,8 +28,16 @@ notificationsTestButton.addEventListener("click", () => {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
const Notes = urlParams.has("localStorage")
|
const Notes = urlParams.has("localStorage")
|
||||||
? new NoteStore("jenot-app")
|
? new LocalNoteStore("jenot-app")
|
||||||
: new WebNoteStore("jenot-app", "notes");
|
: new SyncedNoteStore("jenot-app", "notes", "/");
|
||||||
|
|
||||||
|
const sync = async () => {
|
||||||
|
await Notes.sync();
|
||||||
|
Notes.saveStorage();
|
||||||
|
};
|
||||||
|
|
||||||
|
sync();
|
||||||
|
setInterval(sync, 5000);
|
||||||
|
|
||||||
const newNote = document.querySelector("#new-note");
|
const newNote = document.querySelector("#new-note");
|
||||||
const editNote = document.querySelector("#edit-note");
|
const editNote = document.querySelector("#edit-note");
|
||||||
|
|
@ -94,7 +101,6 @@ 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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export class NoteStore extends EventTarget {
|
export class LocalNoteStore extends EventTarget {
|
||||||
localStorageKey;
|
localStorageKey;
|
||||||
notes = [];
|
notes = [];
|
||||||
|
|
||||||
|
|
@ -44,10 +44,6 @@ export class NoteStore extends EventTarget {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.notes = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
remove({ id }) {
|
remove({ id }) {
|
||||||
this.notes = this.notes.filter((note) => note.id !== id);
|
this.notes = this.notes.filter((note) => note.id !== id);
|
||||||
}
|
}
|
||||||
|
|
@ -57,6 +53,8 @@ export class NoteStore extends EventTarget {
|
||||||
this.notes = this.notes.map((n) => (n.id === note.id ? note : n));
|
this.notes = this.notes.map((n) => (n.id === note.id ? note : n));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sync() {}
|
||||||
|
|
||||||
saveStorage() {
|
saveStorage() {
|
||||||
window.localStorage.setItem(
|
window.localStorage.setItem(
|
||||||
this.localStorageKey + "_notes",
|
this.localStorageKey + "_notes",
|
||||||
24
priv/static/js/service-worker-init.js
Normal file
24
priv/static/js/service-worker-init.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
const registerServiceWorker = async () => {
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.register(
|
||||||
|
"/js/service-worker.js",
|
||||||
|
{
|
||||||
|
type: "module",
|
||||||
|
scope: "/",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (registration.installing) {
|
||||||
|
console.log("Service worker installing");
|
||||||
|
} else if (registration.waiting) {
|
||||||
|
console.log("Service worker installed");
|
||||||
|
} else if (registration.active) {
|
||||||
|
console.log("Service worker active");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Registration failed with ${error}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
registerServiceWorker();
|
||||||
|
|
@ -1,23 +1,46 @@
|
||||||
const registerServiceWorker = async () => {
|
// caching
|
||||||
if ("serviceWorker" in navigator) {
|
|
||||||
try {
|
const putInCache = async (request, response) => {
|
||||||
const registration = await navigator.serviceWorker.register(
|
const cache = await caches.open("v1");
|
||||||
"/js/caching-worker.js",
|
await cache.put(request, response);
|
||||||
{
|
};
|
||||||
scope: "/",
|
|
||||||
},
|
const cacheFirst = async ({ request, fallbackUrl }) => {
|
||||||
);
|
const responseFromCache = await caches.match(request);
|
||||||
if (registration.installing) {
|
if (responseFromCache) {
|
||||||
console.log("Service worker installing");
|
return responseFromCache;
|
||||||
} else if (registration.waiting) {
|
}
|
||||||
console.log("Service worker installed");
|
|
||||||
} else if (registration.active) {
|
try {
|
||||||
console.log("Service worker active");
|
const responseFromNetwork = await fetch(request);
|
||||||
}
|
// Cloning is needed because a response can only be consumed once.
|
||||||
} catch (error) {
|
putInCache(request, responseFromNetwork.clone());
|
||||||
console.error(`Registration failed with ${error}`);
|
return responseFromNetwork;
|
||||||
|
} catch (error) {
|
||||||
|
const fallbackResponse = await caches.match(fallbackUrl);
|
||||||
|
if (fallbackResponse) {
|
||||||
|
return fallbackResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return new Response("Network error happened", {
|
||||||
|
status: 408,
|
||||||
|
headers: { "Content-Type": "text/plain" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
registerServiceWorker();
|
self.addEventListener("fetch", (event) => {
|
||||||
|
// We don't cache API requests
|
||||||
|
if (!event.request.url?.pathname) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.request.url.pathname.startsWith("/api")) {
|
||||||
|
event.respondWith(
|
||||||
|
cacheFirst({
|
||||||
|
request: event.request,
|
||||||
|
fallbackUrl: "/index.html",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
261
priv/static/js/synced-store.js
Normal file
261
priv/static/js/synced-store.js
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
class WebNoteStore {
|
||||||
|
constructor(endpoint) {
|
||||||
|
this.endpoint = endpoint || "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
async all(lastSync, includeDeleted) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (lastSync > 0) {
|
||||||
|
params.append("since", lastSync);
|
||||||
|
}
|
||||||
|
if (includeDeleted) {
|
||||||
|
params.append("deleted", "true");
|
||||||
|
}
|
||||||
|
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
||||||
|
return this.#request(`${this.endpoint}api/notes${suffix}`, {}, () => []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id) {
|
||||||
|
return this.#request(`${this.endpoint}api/notes/${id}`, {}, () => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async add(note) {
|
||||||
|
return this.#request(
|
||||||
|
`${this.endpoint}api/notes`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(note),
|
||||||
|
},
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(note) {
|
||||||
|
return this.#request(
|
||||||
|
`${this.endpoint}api/notes/${note.id}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(note),
|
||||||
|
},
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SyncedNoteStore extends EventTarget {
|
||||||
|
constructor(dbName, storeName, endpoint) {
|
||||||
|
super();
|
||||||
|
this.dbName = dbName;
|
||||||
|
this.storeName = storeName;
|
||||||
|
this.db = null;
|
||||||
|
this.webStore = endpoint && new WebNoteStore(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
async all(since, includeDeleted) {
|
||||||
|
const that = this;
|
||||||
|
return this.#connect().then(
|
||||||
|
(db) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
db
|
||||||
|
.transaction([that.storeName], "readonly")
|
||||||
|
.objectStore(that.storeName)
|
||||||
|
.getAll().onsuccess = (data) => {
|
||||||
|
const results = data.target.result.filter(
|
||||||
|
(n) => (includeDeleted || !n.deleted) && n.id !== "meta",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (since > 0) {
|
||||||
|
return resolve(results.filter((n) => n.updated > since));
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(results);
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id) {
|
||||||
|
const that = this;
|
||||||
|
let result;
|
||||||
|
|
||||||
|
return this.#connect().then(
|
||||||
|
(db) =>
|
||||||
|
new Promise(
|
||||||
|
(resolve, reject) =>
|
||||||
|
(db
|
||||||
|
.transaction([that.storeName], "readonly")
|
||||||
|
.objectStore(that.storeName)
|
||||||
|
.get(id).onsuccess = (data) => resolve(data.target.result)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMeta() {
|
||||||
|
return this.get("meta").then((meta) => meta || { lastSync: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
async setMeta(meta) {
|
||||||
|
meta.id = "meta";
|
||||||
|
this.update(meta, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async add(note) {
|
||||||
|
const that = this;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
id: "id_" + now,
|
||||||
|
type: note.type,
|
||||||
|
content: note.content,
|
||||||
|
created: now,
|
||||||
|
updated: now,
|
||||||
|
deleted: null,
|
||||||
|
};
|
||||||
|
return this.#connect()
|
||||||
|
.then(
|
||||||
|
(db) =>
|
||||||
|
new Promise(
|
||||||
|
(resolve, reject) =>
|
||||||
|
(db
|
||||||
|
.transaction([that.storeName], "readwrite")
|
||||||
|
.objectStore(that.storeName)
|
||||||
|
.add(entry).onsuccess = () => resolve(null)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
(async () => this.webStore?.add(entry))();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(note) {
|
||||||
|
const that = this;
|
||||||
|
|
||||||
|
note.deleted = Date.now();
|
||||||
|
|
||||||
|
return this.update(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(note, skipNetwork) {
|
||||||
|
const that = this;
|
||||||
|
|
||||||
|
if (!skipNetwork) {
|
||||||
|
note.updated = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.#connect()
|
||||||
|
.then(
|
||||||
|
(db) =>
|
||||||
|
new Promise(
|
||||||
|
(resolve, reject) =>
|
||||||
|
(db
|
||||||
|
.transaction([that.storeName], "readwrite")
|
||||||
|
.objectStore(that.storeName)
|
||||||
|
.put(note).onsuccess = () => resolve(null)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
(async () => (skipNetwork ? null : this.webStore?.update(note)))();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sync() {
|
||||||
|
const that = this;
|
||||||
|
const meta = await this.getMeta();
|
||||||
|
const lastSync = meta?.lastSync;
|
||||||
|
|
||||||
|
this.all(lastSync, true)
|
||||||
|
.then((notes) => {
|
||||||
|
notes.forEach(async (n) => await that.webStore.add(n));
|
||||||
|
})
|
||||||
|
.then(() => that.webStore.all(lastSync, true))
|
||||||
|
.then((notes) => {
|
||||||
|
notes.forEach(async (n) => await that.update(n, true));
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
meta.lastSync = Date.now();
|
||||||
|
|
||||||
|
that.setMeta(meta);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveStorage() {
|
||||||
|
this.dispatchEvent(new CustomEvent("save"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async #connect() {
|
||||||
|
if (!this.db) {
|
||||||
|
this.db = await this.#dbConnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.db;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dbConnect() {
|
||||||
|
const that = this;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(this.dbName, 1);
|
||||||
|
|
||||||
|
request.onsuccess = (e) => {
|
||||||
|
resolve(e.target.result);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = (e) => {
|
||||||
|
console.error(`indexedDB error: ${e.target.errorCode}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onupgradeneeded = (e) => {
|
||||||
|
const db = e.target.result;
|
||||||
|
db.createObjectStore(that.storeName, {
|
||||||
|
keyPath: "id",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#request(url, opts, emptyValue) {
|
||||||
|
new Promise((resolve) => {
|
||||||
|
return resolve(this.#runRequest(url, opts, () => emptyValue));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async #runRequest(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
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