feat(bookmarks): location identity, origin provenance, KOReader-style labels

- Drop UNIQUE(media_item_id,user_id,title): titles are display labels
  shared verbatim across clients; same-title bookmarks on different pages
  now coexist instead of 500ing (deleting over a tombstone no longer
  blocks future creates with that title)
- Add origin_source column recording the creating client (android/web/
  koreader), set once at insert, exposed in API responses
- Web reader auto-title mirrors KOReader's 'in <chapter>' convention,
  falling back to 'Bookmark'; adds bookmark rename in the drawer
This commit is contained in:
2026-09-09 08:17:43 -04:00
parent f7c4dfe2e8
commit 7b1c809ae3
9 changed files with 115 additions and 17 deletions
+43 -1
View File
@@ -472,6 +472,8 @@ document.addEventListener("alpine:init", () => {
positionLabel: string;
cfi: string;
page: number | null;
renameOpen?: boolean;
renameText?: string;
}[],
tocItems: [] as any[],
mediaItemId: "" as string,
@@ -2079,6 +2081,14 @@ document.addEventListener("alpine:init", () => {
const page = this.isFixedLayout
? (this.renderer?.index ?? 0) + 1
: 0;
// Auto-label mirrors KOReader's convention ("in <chapter title>")
// so every client names unnamed bookmarks identically; falls back
// to a plain "Bookmark" for fixed-layout or TOC-less books.
const chapter = this.lastRelocateDetail?.tocItem?.label as
| string
| undefined;
const title =
!this.isFixedLayout && chapter ? `in ${chapter}` : "Bookmark";
try {
const resp = await fetch(
@@ -2090,7 +2100,7 @@ document.addEventListener("alpine:init", () => {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: `Bookmark at ${this.progressText || "current position"}`,
title,
position: this.isFixedLayout
? `page:${page}`
: cfi
@@ -2130,6 +2140,38 @@ document.addEventListener("alpine:init", () => {
/* ignore bookmark errors for now */
}
},
startBookmarkRename(bookmark: (typeof this.bookmarkItems)[number]) {
bookmark.renameOpen = true;
bookmark.renameText = bookmark.title;
},
async renameBookmark(bookmark: (typeof this.bookmarkItems)[number]) {
const token = getToken();
const title = (bookmark.renameText ?? "").trim();
if (!token || !this.mediaItemId || !title) return;
try {
const resp = await fetch(
`/api/media-items/${this.mediaItemId}/bookmarks/${bookmark.id}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
notes: "",
position: bookmark.positionLabel || "",
}),
},
);
if (resp.ok) {
bookmark.title = title;
}
bookmark.renameOpen = false;
} catch (_e) {
bookmark.renameOpen = false;
}
},
chapterNumberForProgress(): number {
const tocItem = this.lastRelocateDetail?.tocItem;
if (!tocItem?.label) return 0;