mirror of
https://github.com/zoldar/jenot.git
synced 2026-01-03 14:32:54 +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}
|
||||
schema "accounts" do
|
||||
timestamps(type: :utc_datetime)
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
def new() do
|
||||
|
|
|
|||
|
|
@ -9,22 +9,32 @@ defmodule Jenot.Note do
|
|||
field(:type, Ecto.Enum, values: [:note, :tasklist], default: :note)
|
||||
field(:title, :string, default: "")
|
||||
field(:content, :string)
|
||||
field(:deleted_at, :utc_datetime_usec)
|
||||
|
||||
belongs_to(:account, Jenot.Account, type: :binary_id)
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
def new(account, params) do
|
||||
%__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())
|
||||
|> validate_required([:internal_id, :type])
|
||||
|> validate_required([:internal_id, :type, :inserted_at, :updated_at])
|
||||
|> put_assoc(:account, account)
|
||||
end
|
||||
|
||||
def update(note, params) do
|
||||
note
|
||||
|> cast(params, [:type, :title, :content])
|
||||
|> cast(params, [:type, :title, :content, :deleted_at, :updated_at])
|
||||
|> validate_required([:internal_id, :type, :updated_at])
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -11,13 +11,15 @@ defmodule Jenot.Notes do
|
|||
def latest_change(account) do
|
||||
Note
|
||||
|> 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()
|
||||
end
|
||||
|
||||
def all(account, since \\ nil) do
|
||||
def all(account, since \\ nil, include_deleted? \\ false) do
|
||||
Note
|
||||
|> where(account_id: ^account.id)
|
||||
|> maybe_include_deleted(include_deleted?)
|
||||
|> maybe_filter_since(since)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
|
@ -33,7 +35,11 @@ defmodule Jenot.Notes do
|
|||
end
|
||||
|
||||
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
|
||||
{:ok, note}
|
||||
|
|
@ -58,7 +64,7 @@ defmodule Jenot.Notes do
|
|||
def delete(account, internal_id) do
|
||||
Note
|
||||
|> 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
|
||||
end
|
||||
|
|
@ -79,14 +85,20 @@ defmodule Jenot.Notes do
|
|||
|> Map.delete("id")
|
||||
|> Map.delete(:id)
|
||||
|> Map.put("internal_id", internal_id)
|
||||
|> deserialize_timestamp(:deleted, :deleted_at)
|
||||
|> deserialize_timestamp(:created, :inserted_at)
|
||||
|> deserialize_timestamp(:updated, :updated_at)
|
||||
end
|
||||
end
|
||||
|
||||
defp serialize_note(note) do
|
||||
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)
|
||||
|> 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
|
||||
%{note | content: Jason.decode!(note.content)}
|
||||
|
|
@ -95,9 +107,47 @@ defmodule Jenot.Notes do
|
|||
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, 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)
|
||||
end
|
||||
|
||||
|
|
@ -106,11 +156,59 @@ defmodule Jenot.Notes do
|
|||
type = Ecto.Changeset.get_field(changeset, :type) || :note
|
||||
title = Ecto.Changeset.get_field(changeset, :title) || ""
|
||||
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,
|
||||
on_conflict: [
|
||||
set: [type: type, title: title, content: content, updated_at: DateTime.utc_now()]
|
||||
],
|
||||
on_conflict: conflict_query,
|
||||
conflict_target: conflict_target,
|
||||
returning: true
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ defmodule Jenot.Reminder do
|
|||
field(:day_of_week, :integer)
|
||||
field(:repeat_period, Ecto.Enum, values: [:day, :week, :month, :year])
|
||||
field(:repeat_count, :integer)
|
||||
field(:deleted_at, :utc_datetime_usec)
|
||||
|
||||
belongs_to(:note, Jenot.Note)
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -11,6 +11,6 @@ defmodule Jenot.Subscription do
|
|||
|
||||
belongs_to(:account, Jenot.Account)
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ defmodule Jenot.Web do
|
|||
|
||||
notes =
|
||||
account
|
||||
|> Notes.all(conn.params["since"])
|
||||
|> Notes.all(conn.params["since"], conn.params["deleted"] == "true")
|
||||
|> Enum.map(&Notes.serialize/1)
|
||||
|
||||
send_resp(conn, 200, Jason.encode!(notes))
|
||||
|
|
@ -108,7 +108,7 @@ defmodule Jenot.Web do
|
|||
|
||||
:ok = Notes.delete(account, internal_id)
|
||||
|
||||
send_resp(conn, 204, "")
|
||||
send_resp(conn, 200, Jason.encode!(%{deleted: true}))
|
||||
end
|
||||
|
||||
get "/api/push/public-key" do
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ defmodule Jenot.Repo.Migrations.InitialSchema do
|
|||
add :type, :text, null: false
|
||||
add :title, :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
|
||||
|
||||
|
|
@ -49,6 +50,8 @@ defmodule Jenot.Repo.Migrations.InitialSchema do
|
|||
add :repeat_period, :text
|
||||
add :repeat_count, :integer
|
||||
|
||||
add :deleted_at, :datetime_usec
|
||||
|
||||
add :note_id, references(:note, on_delete: :delete_all), null: false
|
||||
|
||||
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 { NoteStore } from "./store.js";
|
||||
import { DBNoteStore } from "./db-store.js";
|
||||
import { WebNoteStore } from "./web-store.js";
|
||||
import { LocalNoteStore } from "./local-store.js";
|
||||
import { SyncedNoteStore } from "./synced-store.js";
|
||||
import {
|
||||
authorizeNotifications,
|
||||
notificationsEnabled,
|
||||
|
|
@ -29,8 +28,16 @@ notificationsTestButton.addEventListener("click", () => {
|
|||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
const Notes = urlParams.has("localStorage")
|
||||
? new NoteStore("jenot-app")
|
||||
: new WebNoteStore("jenot-app", "notes");
|
||||
? new LocalNoteStore("jenot-app")
|
||||
: new SyncedNoteStore("jenot-app", "notes", "/");
|
||||
|
||||
const sync = async () => {
|
||||
await Notes.sync();
|
||||
Notes.saveStorage();
|
||||
};
|
||||
|
||||
sync();
|
||||
setInterval(sync, 5000);
|
||||
|
||||
const newNote = document.querySelector("#new-note");
|
||||
const editNote = document.querySelector("#edit-note");
|
||||
|
|
@ -94,7 +101,6 @@ 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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export class NoteStore extends EventTarget {
|
||||
export class LocalNoteStore extends EventTarget {
|
||||
localStorageKey;
|
||||
notes = [];
|
||||
|
||||
|
|
@ -44,10 +44,6 @@ export class NoteStore extends EventTarget {
|
|||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.notes = [];
|
||||
}
|
||||
|
||||
remove({ 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));
|
||||
}
|
||||
|
||||
sync() {}
|
||||
|
||||
saveStorage() {
|
||||
window.localStorage.setItem(
|
||||
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 () => {
|
||||
if ("serviceWorker" in navigator) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register(
|
||||
"/js/caching-worker.js",
|
||||
{
|
||||
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}`);
|
||||
// caching
|
||||
|
||||
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" },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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