Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aea6a4e2c | ||
|
|
b10bf3e8c7 | ||
|
|
8c3273a0fc | ||
|
|
aec226af1a | ||
|
|
1c1f5cf1bb | ||
|
|
9e4cc4efd0 | ||
|
|
7b0b2793a6 | ||
|
|
077afaca56 | ||
|
|
388b62b277 | ||
|
|
5da97b2e5d | ||
|
|
45e3a256e2 | ||
|
|
64ef691d64 | ||
|
|
a4dbe79d0b | ||
|
|
1002cbca93 | ||
|
|
65f6bdf075 | ||
|
|
4faa217822 | ||
|
|
5cdfe5a8dd | ||
|
|
1cd8557b58 | ||
|
|
b4c956aed4 | ||
|
|
7caa46c2da | ||
|
|
5329bf35a9 | ||
|
|
80e5d5b0d1 | ||
|
|
b3a429d7e1 | ||
|
|
d8179bb066 | ||
|
|
960305dfa9 | ||
|
|
16bbeec5a3 | ||
|
|
39f9c39360 | ||
|
|
011b6d14e4 | ||
|
|
d4661e7f04 | ||
|
|
27a93ba2ea | ||
|
|
7afad5dcde | ||
|
|
929a862e74 | ||
|
|
0e55dafb1e | ||
|
|
cc8084a713 | ||
|
|
3ab294bf81 | ||
|
|
e944f11415 | ||
|
|
708598687e | ||
|
|
aac7c72900 | ||
|
|
fe5ab9e5f8 | ||
|
|
249b1dfe93 | ||
|
|
17216e9cc7 | ||
|
|
841db91a29 | ||
|
|
1eb9c92d6a | ||
|
|
1fa8ee3a59 | ||
|
|
72d167005f | ||
|
|
bf2c2825ac | ||
|
|
cd119a74da | ||
|
|
14445a7c3f | ||
|
|
e6aceae0da | ||
|
|
44b98f3fc3 | ||
|
|
61681aac23 | ||
|
|
7a9a66fcc5 | ||
|
|
2df3ecbf36 | ||
|
|
9da193e718 | ||
|
|
7f64b92b9d | ||
|
|
b9645752f5 | ||
|
|
9c8337d0a8 | ||
|
|
da1f689263 | ||
|
|
298330a3a8 | ||
|
|
0614795ca2 | ||
|
|
6aa958c78f | ||
|
|
3514b4dc1c | ||
|
|
ae87c0cd6a | ||
|
|
b6f507b9e5 | ||
|
|
ce3ae31ced | ||
|
|
75c1d9bb95 | ||
|
|
905218dd4b | ||
|
|
94dad5e089 | ||
|
|
2366faccce | ||
|
|
e40530824e | ||
|
|
5a6c361c11 | ||
|
|
7b1c809ae3 | ||
|
|
f7c4dfe2e8 | ||
|
|
b1eda696f1 | ||
|
|
27b3dcb69f | ||
|
|
5c5593644d | ||
|
|
9e516b96cc | ||
|
|
44735554f7 | ||
|
|
311049379d | ||
|
|
f65db5ab4f | ||
|
|
7ddcdd0756 | ||
|
|
b927ed9988 | ||
|
|
e2953c4a01 |
@@ -20,6 +20,9 @@ logs/
|
||||
|
||||
# Dependencies (install fresh in container)
|
||||
node_modules/
|
||||
# Local-only lockfile: a stale one pins old foliate-js commits into builds
|
||||
# (npm reuses name@version without re-resolving the git pin)
|
||||
package-lock.json
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
||||
@@ -35,5 +35,9 @@ DBPASS=your-secure-database-password-here
|
||||
# BOOKHOARD_CONVERSION_TOOL=/usr/bin/ebook-convert
|
||||
# Conversion Cache TTL: Override default 24h
|
||||
# BOOKHOARD_CONVERSION_CACHE_TTL=48h
|
||||
# Archive Retention: days to keep archived items (files missing from disk for
|
||||
# 2+ scans) before they are purged, deleting their reading history with them.
|
||||
# Set 0 to keep archived items until purged manually on the library admin page.
|
||||
# ARCHIVE_RETENTION_DAYS=90
|
||||
# System timezone (fallback for server-side time operations, defaults to UTC)
|
||||
# TZ=America/New_York
|
||||
@@ -56,6 +56,9 @@ jobs:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
# Bake the version into the binary (Admin → Settings → About).
|
||||
build-args: |
|
||||
APP_VERSION=${{ env.TAG }}
|
||||
# Publishes both the exact version (e.g. v0.2.0) and the movable "latest" tag.
|
||||
# Deployments default to "latest" via ${IMAGE_TAG:-latest} in docker-compose.yml;
|
||||
# pin or roll back by setting IMAGE_TAG in .env.
|
||||
|
||||
+23
-4
@@ -15,7 +15,10 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||
|
||||
# Copy package files and install npm dependencies (cached unless package.json changes)
|
||||
COPY package*.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
# npm cache mount renamed from /root/.npm: the old volume held a stale
|
||||
# foliate-js tarball (name@version reuse) that --no-cache and builder prune
|
||||
# could not evict; a new mount id guarantees a clean fetch.
|
||||
RUN --mount=type=cache,target=/root/.npm-cache2 \
|
||||
npm install
|
||||
|
||||
# Copy Go mod files (cached unless go.mod changes)
|
||||
@@ -34,22 +37,38 @@ RUN cd templates && templ generate
|
||||
RUN npm run build:css:prod
|
||||
RUN npm run build:ts
|
||||
|
||||
# App version baked into the binary (shown in Admin → Settings → About).
|
||||
# Declared after `COPY . .` so a version bump only invalidates this layer.
|
||||
# The release workflow passes the git tag, e.g. APP_VERSION=v1.5.
|
||||
ARG APP_VERSION=dev
|
||||
|
||||
# Build Go binary (cached unless Go files or generated code changes)
|
||||
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo -o main ./cmd/server
|
||||
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo \
|
||||
-ldflags "-X bookhoard/internal/version.Version=${APP_VERSION}" \
|
||||
-o main ./cmd/server
|
||||
|
||||
# Materialize the Go module cache into an image layer so the test-runner
|
||||
# stage can restore it. GOMODCACHE is /go/pkg/mod; cache mounts elsewhere are
|
||||
# ignored by the go tool, and without this copy `go test` in the tests
|
||||
# container re-downloads every dependency on each invocation.
|
||||
RUN cp -r /go/pkg/mod /app/go-module-cache
|
||||
|
||||
# Test runner stage - includes Go runtime and test dependencies
|
||||
# This stage is ONLY used for running tests, never deployed to production
|
||||
FROM golang:1.26-alpine AS test-runner
|
||||
|
||||
RUN apk --no-cache add ca-certificates curl
|
||||
RUN apk --no-cache add ca-certificates curl poppler-utils
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy everything from builder (includes compiled binary, source code, and dependencies)
|
||||
COPY --from=builder /app ./
|
||||
|
||||
# Restore the materialized Go module cache so `go test` works offline.
|
||||
RUN mkdir -p /go/pkg/mod && cp -r /app/go-module-cache/. /go/pkg/mod/ && rm -rf /app/go-module-cache
|
||||
|
||||
# Install kepubify for conversion tests
|
||||
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
|
||||
&& chmod +x /usr/bin/kepubify
|
||||
@@ -65,7 +84,7 @@ CMD ["go", "test", "./cmd/server/tests", "-v", "-timeout", "5m", "-parallel=1",
|
||||
# Final stage
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk --no-cache add ca-certificates curl
|
||||
RUN apk --no-cache add ca-certificates curl poppler-utils
|
||||
|
||||
# Install kepubify for EPUB→KEPUB conversion
|
||||
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
|
||||
|
||||
@@ -1,232 +1,661 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
“This License” refers to version 3 of the GNU General Public License.
|
||||
|
||||
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||
|
||||
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
|
||||
|
||||
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
|
||||
|
||||
A “covered work” means either the unmodified Program or a work based on the Program.
|
||||
|
||||
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
||||
|
||||
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
||||
|
||||
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||
|
||||
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
||||
|
||||
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||
|
||||
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
||||
|
||||
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||
|
||||
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
||||
|
||||
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
||||
|
||||
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||
|
||||
bookhoard
|
||||
Copyright (C) 2026 john-okeefe
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
bookhoard Copyright (C) 2026 john-okeefe
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 📚 Bookhoard
|
||||
# <img src="web/static/favicon.svg" width="32" alt="Bookhoard logo"> Bookhoard
|
||||
|
||||
A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and Tailwind CSS featuring **universal cross-device sync**, beautiful dark themes, and comprehensive media management.
|
||||
|
||||
@@ -111,6 +111,7 @@ The first user to register automatically becomes an admin.
|
||||
### For Developers
|
||||
|
||||
- **[docs/developer/api/api-reference.md](docs/developer/api/api-reference.md)** - Complete API documentation
|
||||
- **[docs/developer/android-app.md](docs/developer/android-app.md)** - Android app design & roadmap
|
||||
- **[docs/contributing/development.md](docs/contributing/development.md)** - Development workflow
|
||||
|
||||
---
|
||||
@@ -122,7 +123,7 @@ The first user to register automatically becomes an admin.
|
||||
| **Web Browser** | ✅ | ✅ | Full support |
|
||||
| **KOReader** | ✅ | ✅ | Runs on Kindle, Kobo, PocketBook hardware |
|
||||
| **Kobo Devices** | 🚧 | 🚧 | Native Kobo sync coming soon (use KOReader on Kobo today) |
|
||||
| **Mobile Apps** | 🚧 | 🚧 | Android/iOS apps coming later |
|
||||
| **Mobile Apps** | 🚧 | 🚧 | Native Android app in design ([docs](docs/developer/android-app.md)); iOS later |
|
||||
|
||||
---
|
||||
|
||||
@@ -155,7 +156,7 @@ bruno run
|
||||
## 📊 Project Status
|
||||
|
||||
**Version**: 1.0
|
||||
**License**: GPL-3.0
|
||||
**License**: AGPL-3.0
|
||||
**Status**: Production-ready ✅
|
||||
|
||||
---
|
||||
@@ -168,7 +169,7 @@ We welcome contributions! Please see [docs/developer/development.md](docs/develo
|
||||
|
||||
## 📄 License
|
||||
|
||||
GPL-3.0 - See [LICENSE](LICENSE) file for details.
|
||||
AGPL-3.0 - See [LICENSE](LICENSE) file for details.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
info:
|
||||
name: Rescan Media Item
|
||||
type: http
|
||||
seq: 9
|
||||
|
||||
http:
|
||||
method: POST
|
||||
url: "{{base_url}}/api/media-items/{{media_item_id}}/rescan"
|
||||
headers:
|
||||
- name: ""
|
||||
value: application/json
|
||||
auth: inherit
|
||||
|
||||
runtime:
|
||||
scripts:
|
||||
- type: tests
|
||||
code: |-
|
||||
test_rescan_media_item_success(status, headers, body) {
|
||||
if (status !== 200) {
|
||||
throw new Error("Expected status 200, got " + status);
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
|
||||
docs: |-
|
||||
## Rescan Media Item
|
||||
|
||||
Re-extracts metadata and cover art for a single media item from the file on disk.
|
||||
|
||||
**Method:** POST
|
||||
|
||||
**Endpoint:** /api/media-items/{id}/rescan
|
||||
@@ -121,6 +121,9 @@ func main() {
|
||||
|
||||
// Create library service
|
||||
libraryService := services.NewLibraryService(queries)
|
||||
// The progress service verifies submitted anchors against the book
|
||||
// itself — it needs to resolve library-relative file paths.
|
||||
progressService.SetMediaPathResolver(libraryService)
|
||||
|
||||
// Sync Go AllowedExtensions into DB so API clients see correct extensions
|
||||
libraryService.SyncAllowedExtensions(context.Background())
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Cross-library SHA move detection + archived-twin merge integration tests.
|
||||
// These cover the scenario where identical book content moves between
|
||||
// libraries of the same type: the row must be repointed (history follows)
|
||||
// instead of duplicated, deliberate copies stay independent, type rules are
|
||||
// enforced, and archived twins can be merged into their active copy.
|
||||
|
||||
const (
|
||||
moveTestAdminEmail = "testuser@tests.bookhoard.internal"
|
||||
moveTestFileName = "Test Book.epub"
|
||||
)
|
||||
|
||||
// createEbooksTestLibrary creates a library of the given type via the API and
|
||||
// swaps its default folder for a fresh temp directory the test controls.
|
||||
func createTypedTestLibrary(t *testing.T, setup *TestServerSetup, name, libType string) (pgtype.UUID, string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
libBody, _ := json.Marshal(map[string]interface{}{"name": name, "type": libType})
|
||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/libraries", bytes.NewBuffer(libBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, "library creation should succeed")
|
||||
|
||||
var libResp map[string]interface{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&libResp))
|
||||
libID, err := uuid.Parse(libResp["id"].(string))
|
||||
require.NoError(t, err)
|
||||
pgLib := pgtype.UUID{Bytes: libID, Valid: true}
|
||||
|
||||
folders, err := setup.DB.GetLibraryFolders(ctx, pgLib)
|
||||
require.NoError(t, err)
|
||||
for _, f := range folders {
|
||||
_, err = setup.DB.DeleteLibraryFolder(ctx, database.DeleteLibraryFolderParams{
|
||||
LibraryID: pgLib,
|
||||
FolderPath: f.FolderPath,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
_, err = setup.DB.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
|
||||
LibraryID: pgLib,
|
||||
FolderPath: tmpDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return pgLib, tmpDir
|
||||
}
|
||||
|
||||
// deleteLibraries removes libraries (cascading to media items, progress,
|
||||
// annotations, and processing issues) so reruns never see leftovers from a
|
||||
// previous run — stale rows with identical content hashes would otherwise be
|
||||
// picked up as move candidates. Call via defer AFTER the setup.Close defer so
|
||||
// it executes while the pool is still open (defers run LIFO).
|
||||
func deleteLibraries(t *testing.T, setup *TestServerSetup, libs ...pgtype.UUID) {
|
||||
t.Helper()
|
||||
for _, lib := range libs {
|
||||
_ = setup.DB.DeleteLibrary(context.Background(), lib)
|
||||
}
|
||||
}
|
||||
|
||||
// scanTestFolder runs a synchronous scan over one folder.
|
||||
func scanTestFolder(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, folder string) {
|
||||
t.Helper()
|
||||
scanner := services.NewMediaScanner(setup.DB)
|
||||
defer func() { _ = scanner.Close() }()
|
||||
scanner.SetAdminID(adminID)
|
||||
require.NoError(t, scanner.SetFolders([]string{folder}, false))
|
||||
require.NoError(t, scanner.ScanFolders(context.Background()))
|
||||
}
|
||||
|
||||
// waitForCond polls cond until true or the timeout elapses.
|
||||
func waitForCond(t *testing.T, timeout time.Duration, msg string, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("condition not met within %v: %s", timeout, msg)
|
||||
}
|
||||
|
||||
func moveTestAdminID(t *testing.T, setup *TestServerSetup) (pgtype.UUID, pgtype.UUID) {
|
||||
t.Helper()
|
||||
admin, err := setup.DB.GetUserByEmail(context.Background(), moveTestAdminEmail)
|
||||
require.NoError(t, err)
|
||||
pgAdmin := pgtype.UUID{Bytes: admin.ID.Bytes, Valid: true}
|
||||
return pgAdmin, pgAdmin
|
||||
}
|
||||
|
||||
// writeTestEbook places an identical dummy epub under folder/Author/.
|
||||
func writeTestEbook(t *testing.T, folder string, content []byte) string {
|
||||
t.Helper()
|
||||
authorDir := filepath.Join(folder, "Author")
|
||||
require.NoError(t, os.MkdirAll(authorDir, 0755))
|
||||
bookPath := filepath.Join(authorDir, moveTestFileName)
|
||||
require.NoError(t, os.WriteFile(bookPath, content, 0644))
|
||||
return bookPath
|
||||
}
|
||||
|
||||
// TestCrossLibraryMovePreservesHistory is the core scenario: a book moves
|
||||
// from library A to library B (same type). The existing row must be repointed
|
||||
// to library B keeping its ID so progress and annotations survive, instead of
|
||||
// creating a duplicate while the old row goes missing.
|
||||
func TestCrossLibraryMovePreservesHistory(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
defer func() { _ = setup.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
adminID, adminPgID := moveTestAdminID(t, setup)
|
||||
libA, folderA := createTypedTestLibrary(t, setup, "Move Src Library", "ebooks")
|
||||
libB, folderB := createTypedTestLibrary(t, setup, "Move Dst Library", "ebooks")
|
||||
defer deleteLibraries(t, setup, libA, libB)
|
||||
|
||||
content := []byte("cross library move test epub content")
|
||||
writeTestEbook(t, folderA, content)
|
||||
|
||||
scanTestFolder(t, setup, adminID, folderA)
|
||||
|
||||
var original database.ListMediaItemsByLibraryRow
|
||||
waitForCond(t, 5*time.Second, "book should be imported into library A", func() bool {
|
||||
books, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
if err != nil || len(books) != 1 {
|
||||
return false
|
||||
}
|
||||
original = books[0]
|
||||
return true
|
||||
})
|
||||
|
||||
// Give the book real user data at its original location.
|
||||
_, err := setup.DB.UpdateReadingProgress(ctx, database.UpdateReadingProgressParams{
|
||||
MediaItemID: original.ID,
|
||||
UserID: adminPgID,
|
||||
CurrentPage: pgtype.Int4{Int32: 12, Valid: true},
|
||||
TotalPages: pgtype.Int4{Int32: 300, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DB.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{
|
||||
MediaItemID: original.ID,
|
||||
UserID: adminPgID,
|
||||
SelectionText: "a line worth keeping",
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Move the file: same bytes, new library folder, old copy deleted.
|
||||
writeTestEbook(t, folderB, content)
|
||||
require.NoError(t, os.Remove(filepath.Join(folderA, "Author", moveTestFileName)))
|
||||
|
||||
scanTestFolder(t, setup, adminID, folderB)
|
||||
|
||||
var moved database.ListMediaItemsByLibraryRow
|
||||
waitForCond(t, 5*time.Second, "book should be repointed into library B", func() bool {
|
||||
books, err := setup.DB.ListMediaItemsByLibrary(ctx, libB)
|
||||
if err != nil || len(books) != 1 {
|
||||
return false
|
||||
}
|
||||
moved = books[0]
|
||||
return true
|
||||
})
|
||||
|
||||
assert.Equal(t, original.ID, moved.ID, "row ID must be preserved so history follows the book")
|
||||
assert.Equal(t, libB, moved.LibraryID, "row must now belong to library B")
|
||||
assert.Equal(t, filepath.ToSlash(filepath.Join("Author", moveTestFileName)), moved.FilePath, "path must be repointed relative to library B")
|
||||
assert.False(t, moved.ArchivedAt.Valid, "moved row must not be archived")
|
||||
assert.Equal(t, int32(0), moved.MissingScanCount, "moved row must not be marked missing")
|
||||
assert.True(t, moved.LibraryTypeName.Valid && moved.LibraryTypeName.String == "ebooks", "library_type_name must stay truthful after library change")
|
||||
|
||||
// History followed the row.
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{MediaItemID: original.ID, UserID: adminPgID})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32(12), progress.CurrentPage.Int32, "reading progress must survive the move")
|
||||
|
||||
highlights, err := setup.DB.GetMediaHighlights(ctx, database.GetMediaHighlightsParams{MediaItemID: original.ID, UserID: adminPgID})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, highlights, 1, "annotation must survive the move")
|
||||
|
||||
// Library A no longer holds the row: no duplicate, no archived ghost.
|
||||
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, booksA, "library A must not keep a duplicate row after the move")
|
||||
}
|
||||
|
||||
// TestCrossLibraryCopyStaysIndependent verifies that placing identical
|
||||
// content in two libraries on purpose creates two independent rows: the
|
||||
// second import is NOT blocked, and neither row is repointed or archived.
|
||||
func TestCrossLibraryCopyStaysIndependent(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
defer func() { _ = setup.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
adminID, adminPgID := moveTestAdminID(t, setup)
|
||||
libA, folderA := createTypedTestLibrary(t, setup, "Copy Lib A", "ebooks")
|
||||
libB, folderB := createTypedTestLibrary(t, setup, "Copy Lib B", "ebooks")
|
||||
defer deleteLibraries(t, setup, libA, libB)
|
||||
|
||||
content := []byte("deliberate duplicate content")
|
||||
writeTestEbook(t, folderA, content)
|
||||
writeTestEbook(t, folderB, content)
|
||||
|
||||
scanTestFolder(t, setup, adminID, folderA)
|
||||
scanTestFolder(t, setup, adminID, folderB)
|
||||
|
||||
var itemA, itemB database.ListMediaItemsByLibraryRow
|
||||
waitForCond(t, 5*time.Second, "both libraries should hold their own copy", func() bool {
|
||||
booksA, errA := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
booksB, errB := setup.DB.ListMediaItemsByLibrary(ctx, libB)
|
||||
if errA != nil || errB != nil || len(booksA) != 1 || len(booksB) != 1 {
|
||||
return false
|
||||
}
|
||||
itemA, itemB = booksA[0], booksB[0]
|
||||
return true
|
||||
})
|
||||
|
||||
assert.NotEqual(t, itemA.ID, itemB.ID, "deliberate copies must stay independent rows")
|
||||
assert.False(t, itemA.ArchivedAt.Valid && itemB.ArchivedAt.Valid, "no copy should be archived")
|
||||
|
||||
// History on one copy does not leak into the other.
|
||||
_, err := setup.DB.UpdateReadingProgress(ctx, database.UpdateReadingProgressParams{
|
||||
MediaItemID: itemA.ID,
|
||||
UserID: adminPgID,
|
||||
CurrentPage: pgtype.Int4{Int32: 7, Valid: true},
|
||||
TotalPages: pgtype.Int4{Int32: 100, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{MediaItemID: itemB.ID, UserID: adminPgID})
|
||||
assert.Error(t, err, "copy B must not inherit copy A's progress")
|
||||
}
|
||||
|
||||
// TestCrossLibraryMoveRejectedForTypeMismatch verifies the type guard: the
|
||||
// cross-library lookup only matches same-type libraries, so a reflowable EPUB
|
||||
// registered in a manga library whose file moves to ANOTHER manga library is
|
||||
// detected as a move but must be rejected by the type rules (manga libraries
|
||||
// only accept fixed-layout/image content). The move is rejected, a processing
|
||||
// issue is recorded, and the file stays untracked rather than being
|
||||
// force-imported.
|
||||
func TestCrossLibraryMoveRejectedForTypeMismatch(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
defer func() { _ = setup.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
adminID, _ := moveTestAdminID(t, setup)
|
||||
libA, folderA := createTypedTestLibrary(t, setup, "Type Src Manga", "manga")
|
||||
libM, folderM := createTypedTestLibrary(t, setup, "Type Dst Manga", "manga")
|
||||
defer deleteLibraries(t, setup, libA, libM)
|
||||
|
||||
content := []byte("reflowable epub that manga libraries must not adopt via move")
|
||||
writeTestEbook(t, folderA, content)
|
||||
scanTestFolder(t, setup, adminID, folderA)
|
||||
|
||||
var original database.ListMediaItemsByLibraryRow
|
||||
waitForCond(t, 5*time.Second, "book should be imported into the first manga library", func() bool {
|
||||
books, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
if err != nil || len(books) != 1 {
|
||||
return false
|
||||
}
|
||||
original = books[0]
|
||||
return true
|
||||
})
|
||||
require.Equal(t, "reflowable", original.FormatGroup, "dummy epub must scan as reflowable for this test")
|
||||
|
||||
// Move into the second manga library: same hash, same type, but the
|
||||
// reflowable format violates manga library rules.
|
||||
writeTestEbook(t, folderM, content)
|
||||
require.NoError(t, os.Remove(filepath.Join(folderA, "Author", moveTestFileName)))
|
||||
|
||||
scanTestFolder(t, setup, adminID, folderM)
|
||||
|
||||
// The second manga library must not adopt the item.
|
||||
booksM, err := setup.DB.ListMediaItemsByLibrary(ctx, libM)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, booksM, "reflowable epub must not be repointed between manga libraries")
|
||||
|
||||
// The original row is untouched in the first manga library.
|
||||
after, err := setup.DB.GetMediaItem(ctx, original.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, libA, after.LibraryID, "original row must stay in its library")
|
||||
|
||||
// A processing issue explains the rejection.
|
||||
waitForCond(t, 5*time.Second, "processing issue should be recorded", func() bool {
|
||||
issues, err := setup.DB.ListProcessingIssuesByLibrary(ctx, libM)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, issue := range issues {
|
||||
if issue.IssueType == "library_type_mismatch" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
// TestListHiddenMediaItemsMatchesActiveTwin verifies the archived-page query:
|
||||
// an archived row with an active same-hash twin reports it in match_* columns;
|
||||
// an archived row without a twin reports no match.
|
||||
func TestListHiddenMediaItemsMatchesActiveTwin(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
defer func() { _ = setup.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
adminID, _ := moveTestAdminID(t, setup)
|
||||
libA, folderA := createTypedTestLibrary(t, setup, "Twin Lib A", "ebooks")
|
||||
libB, folderB := createTypedTestLibrary(t, setup, "Twin Lib B", "ebooks")
|
||||
libC, folderC := createTypedTestLibrary(t, setup, "Twin Lib C", "ebooks")
|
||||
defer deleteLibraries(t, setup, libA, libB, libC)
|
||||
|
||||
twinContent := []byte("twin content shared by two libraries")
|
||||
writeTestEbook(t, folderA, twinContent)
|
||||
writeTestEbook(t, folderB, twinContent)
|
||||
lonelyContent := []byte("unique content with no twin anywhere")
|
||||
writeTestEbook(t, folderC, lonelyContent)
|
||||
|
||||
scanTestFolder(t, setup, adminID, folderA)
|
||||
scanTestFolder(t, setup, adminID, folderB)
|
||||
scanTestFolder(t, setup, adminID, folderC)
|
||||
|
||||
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, booksA, 1)
|
||||
twin := booksA[0]
|
||||
|
||||
booksC, err := setup.DB.ListMediaItemsByLibrary(ctx, libC)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, booksC, 1)
|
||||
lonely := booksC[0]
|
||||
|
||||
// Archive both: the A row now has an active twin in B, the C row has none.
|
||||
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, twin.ID))
|
||||
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, lonely.ID))
|
||||
|
||||
rows, err := setup.DB.ListHiddenMediaItems(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
foundTwin, foundLonely := false, false
|
||||
for _, row := range rows {
|
||||
if row.ID == twin.ID {
|
||||
foundTwin = true
|
||||
assert.True(t, row.MatchID.Valid, "twin row must report its active match")
|
||||
if row.MatchID.Valid {
|
||||
assert.Equal(t, findTwinTargetID(t, setup, libB), uuid.UUID(row.MatchID.Bytes).String())
|
||||
}
|
||||
assert.Equal(t, "Test Book", row.MatchTitle)
|
||||
}
|
||||
if row.ID == lonely.ID {
|
||||
foundLonely = true
|
||||
assert.False(t, row.MatchID.Valid, "row without a twin must report no match")
|
||||
}
|
||||
}
|
||||
require.True(t, foundTwin, "twin row should be listed")
|
||||
require.True(t, foundLonely, "lonely row should be listed")
|
||||
}
|
||||
|
||||
// findTwinTargetID returns the single active item in the given library.
|
||||
func findTwinTargetID(t *testing.T, setup *TestServerSetup, libID pgtype.UUID) string {
|
||||
t.Helper()
|
||||
books, err := setup.DB.ListMediaItemsByLibrary(context.Background(), libID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, books, 1)
|
||||
return uuid.UUID(books[0].ID.Bytes).String()
|
||||
}
|
||||
|
||||
// mergeRequest issues POST /api/media-items/{source}/merge.
|
||||
func mergeRequest(t *testing.T, setup *TestServerSetup, token, sourceID string, body map[string]interface{}) *http.Response {
|
||||
t.Helper()
|
||||
payload, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items/"+sourceID+"/merge", bytes.NewBuffer(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
return resp
|
||||
}
|
||||
|
||||
// archiveAndIsolate imports one book into the library and archives it,
|
||||
// simulating a file that was copied elsewhere then deleted from here.
|
||||
func archiveAndIsolate(t *testing.T, setup *TestServerSetup, adminID, adminPgID pgtype.UUID, libID pgtype.UUID, folder, content string) database.ListMediaItemsByLibraryRow {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
writeTestEbook(t, folder, []byte(content))
|
||||
scanTestFolder(t, setup, adminID, folder)
|
||||
|
||||
books, err := setup.DB.ListMediaItemsByLibrary(ctx, libID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, books, 1)
|
||||
item := books[0]
|
||||
|
||||
// Reading history that must survive the merge.
|
||||
_, err = setup.DB.UpdateReadingProgress(ctx, database.UpdateReadingProgressParams{
|
||||
MediaItemID: item.ID,
|
||||
UserID: adminPgID,
|
||||
CurrentPage: pgtype.Int4{Int32: 42, Valid: true},
|
||||
TotalPages: pgtype.Int4{Int32: 200, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DB.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{
|
||||
MediaItemID: item.ID,
|
||||
UserID: adminPgID,
|
||||
SelectionText: "annotation on the old copy",
|
||||
Color: pgtype.Text{String: "#00ff00", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, item.ID))
|
||||
return item
|
||||
}
|
||||
|
||||
// TestMergeArchivedItemIntoActiveTwin covers the copy-then-delete-later
|
||||
// workflow: the active copy exists in another library, the archived twin
|
||||
// holds the real reading history. Merging moves progress + annotations onto
|
||||
// the active row and removes the archived row.
|
||||
func TestMergeArchivedItemIntoActiveTwin(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
defer func() { _ = setup.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
adminID, adminPgID := moveTestAdminID(t, setup)
|
||||
libOld, folderOld := createTypedTestLibrary(t, setup, "Merge Old Lib", "ebooks")
|
||||
libNew, folderNew := createTypedTestLibrary(t, setup, "Merge New Lib", "ebooks")
|
||||
defer deleteLibraries(t, setup, libOld, libNew)
|
||||
|
||||
content := "merge workflow epub content"
|
||||
|
||||
// Old library: book + history, then archived (file "deleted").
|
||||
oldItem := archiveAndIsolate(t, setup, adminID, adminPgID, libOld, folderOld, content)
|
||||
|
||||
// New library: the surviving active copy, no history of its own.
|
||||
writeTestEbook(t, folderNew, []byte(content))
|
||||
scanTestFolder(t, setup, adminID, folderNew)
|
||||
booksNew, err := setup.DB.ListMediaItemsByLibrary(ctx, libNew)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, booksNew, 1)
|
||||
active := booksNew[0]
|
||||
|
||||
oldID := uuid.UUID(oldItem.ID.Bytes).String()
|
||||
activeID := uuid.UUID(active.ID.Bytes).String()
|
||||
resp := mergeRequest(t, setup, setup.Token, oldID, map[string]interface{}{"target_id": activeID})
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "merge should succeed")
|
||||
|
||||
var mergeResp map[string]interface{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&mergeResp))
|
||||
|
||||
// Archived row is gone.
|
||||
_, err = setup.DB.GetMediaItem(ctx, oldItem.ID)
|
||||
assert.Error(t, err, "archived row must be removed after merge")
|
||||
|
||||
// History landed on the active row.
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{MediaItemID: active.ID, UserID: adminPgID})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32(42), progress.CurrentPage.Int32, "progress must merge into the active copy")
|
||||
|
||||
highlights, err := setup.DB.GetMediaHighlights(ctx, database.GetMediaHighlightsParams{MediaItemID: active.ID, UserID: adminPgID})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, highlights, 1, "annotations must merge into the active copy")
|
||||
|
||||
booksOld, err := setup.DB.ListMediaItemsByLibrary(ctx, libOld)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, booksOld, "old library must be empty after merge")
|
||||
}
|
||||
|
||||
func TestMergeArchivedItemRejections(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
defer func() { _ = setup.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
adminID, adminPgID := moveTestAdminID(t, setup)
|
||||
libA, folderA := createTypedTestLibrary(t, setup, "Reject Lib A", "ebooks")
|
||||
libB, folderB := createTypedTestLibrary(t, setup, "Reject Lib B", "ebooks")
|
||||
defer deleteLibraries(t, setup, libA, libB)
|
||||
|
||||
t.Run("requires admin", func(t *testing.T) {
|
||||
writeTestEbook(t, folderA, []byte("admin check content"))
|
||||
scanTestFolder(t, setup, adminID, folderA)
|
||||
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, booksA, 1)
|
||||
itemID := uuid.UUID(booksA[0].ID.Bytes).String()
|
||||
|
||||
resp := mergeRequest(t, setup, setup.RegularToken, itemID, map[string]interface{}{"target_id": itemID})
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("rejects active source", func(t *testing.T) {
|
||||
writeTestEbook(t, folderA, []byte("active source content"))
|
||||
scanTestFolder(t, setup, adminID, folderA)
|
||||
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, booksA, 1)
|
||||
sourceID := uuid.UUID(booksA[0].ID.Bytes).String()
|
||||
|
||||
writeTestEbook(t, folderB, []byte("active source content"))
|
||||
scanTestFolder(t, setup, adminID, folderB)
|
||||
targetID := findTwinTargetID(t, setup, libB)
|
||||
|
||||
resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{"target_id": targetID})
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "source is not archived/missing")
|
||||
})
|
||||
|
||||
t.Run("rejects hash mismatch", func(t *testing.T) {
|
||||
writeTestEbook(t, folderA, []byte("hash mismatch content A"))
|
||||
scanTestFolder(t, setup, adminID, folderA)
|
||||
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, booksA, 1)
|
||||
source := booksA[0]
|
||||
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, source.ID))
|
||||
sourceID := uuid.UUID(source.ID.Bytes).String()
|
||||
|
||||
writeTestEbook(t, folderB, []byte("hash mismatch content B - different bytes"))
|
||||
scanTestFolder(t, setup, adminID, folderB)
|
||||
targetID := findTwinTargetID(t, setup, libB)
|
||||
|
||||
resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{"target_id": targetID})
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "different content must not merge")
|
||||
|
||||
// Both rows still exist.
|
||||
_, err = setup.DB.GetMediaItem(ctx, source.ID)
|
||||
assert.NoError(t, err, "source must survive a rejected merge")
|
||||
})
|
||||
|
||||
t.Run("requires target_id", func(t *testing.T) {
|
||||
writeTestEbook(t, folderA, []byte("missing target content"))
|
||||
scanTestFolder(t, setup, adminID, folderA)
|
||||
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, booksA, 1)
|
||||
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, booksA[0].ID))
|
||||
sourceID := uuid.UUID(booksA[0].ID.Bytes).String()
|
||||
|
||||
resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{})
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("rejects self merge", func(t *testing.T) {
|
||||
writeTestEbook(t, folderA, []byte("self merge content"))
|
||||
scanTestFolder(t, setup, adminID, folderA)
|
||||
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, booksA, 1)
|
||||
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, booksA[0].ID))
|
||||
sourceID := uuid.UUID(booksA[0].ID.Bytes).String()
|
||||
|
||||
resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{"target_id": sourceID})
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("rejects archived target", func(t *testing.T) {
|
||||
content := []byte("archived target content")
|
||||
writeTestEbook(t, folderA, content)
|
||||
scanTestFolder(t, setup, adminID, folderA)
|
||||
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, booksA, 1)
|
||||
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, booksA[0].ID))
|
||||
sourceID := uuid.UUID(booksA[0].ID.Bytes).String()
|
||||
|
||||
writeTestEbook(t, folderB, content)
|
||||
scanTestFolder(t, setup, adminID, folderB)
|
||||
booksB, err := setup.DB.ListMediaItemsByLibrary(ctx, libB)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, booksB, 1)
|
||||
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, booksB[0].ID))
|
||||
targetID := uuid.UUID(booksB[0].ID.Bytes).String()
|
||||
|
||||
resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{"target_id": targetID})
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "target must be active")
|
||||
})
|
||||
|
||||
_ = adminPgID
|
||||
}
|
||||
|
||||
// TestLibraryTypeNameTriggerOnLibraryChange proves the schema trigger keeps
|
||||
// media_items.library_type_name in sync when a row is repointed to a library
|
||||
// of a different type (the INSERT-only trigger previously left it stale).
|
||||
func TestLibraryTypeNameTriggerOnLibraryChange(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
defer func() { _ = setup.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
adminID, _ := moveTestAdminID(t, setup)
|
||||
libE, folderE := createTypedTestLibrary(t, setup, "Trigger Ebooks", "ebooks")
|
||||
libC, _ := createTypedTestLibrary(t, setup, "Trigger Comics", "comics")
|
||||
defer deleteLibraries(t, setup, libE, libC)
|
||||
|
||||
writeTestEbook(t, folderE, []byte("trigger check content"))
|
||||
scanTestFolder(t, setup, adminID, folderE)
|
||||
|
||||
books, err := setup.DB.ListMediaItemsByLibrary(ctx, libE)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, books, 1)
|
||||
item := books[0]
|
||||
require.True(t, item.LibraryTypeName.Valid && item.LibraryTypeName.String == "ebooks")
|
||||
|
||||
require.NoError(t, setup.DB.MoveMediaItemToLibrary(ctx, database.MoveMediaItemToLibraryParams{
|
||||
ID: item.ID,
|
||||
LibraryID: libC,
|
||||
FilePath: "Author/Test Book.epub",
|
||||
FileSize: pgtype.Int8{Int64: 27, Valid: true},
|
||||
}))
|
||||
|
||||
after, err := setup.DB.GetMediaItem(ctx, item.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, libC, after.LibraryID)
|
||||
assert.True(t, after.LibraryTypeName.Valid && after.LibraryTypeName.String == "comics",
|
||||
"trigger must refresh library_type_name when library_id changes")
|
||||
}
|
||||
@@ -252,6 +252,88 @@ func TestListPendingRegistrations(t *testing.T) {
|
||||
assert.NotNil(t, pending, "Pending registrations should not be nil")
|
||||
}
|
||||
|
||||
// Regression test for the "approve returns 500 after app reinstall" bug:
|
||||
// an app reinstall that preserves data (e.g. Android Studio installDebug
|
||||
// over an existing install) re-registers with the SAME device_identifier,
|
||||
// and the blind INSERT in ApproveDevice hit the UNIQUE(device_identifier)
|
||||
// constraint. Re-approval must be idempotent: same row (id unchanged),
|
||||
// rotated token, old token invalidated.
|
||||
func TestApproveDeviceReapprovalRotatesToken(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
userID := getTestUserID(t, setup.DB)
|
||||
|
||||
identifier := fmt.Sprintf("repro-reinstall-%s", uuid.New().String())
|
||||
|
||||
registerAndApprove := func() string {
|
||||
regRequest := map[string]interface{}{
|
||||
"device_name": "Reinstall Device",
|
||||
"device_type": "mobile",
|
||||
"device_identifier": identifier,
|
||||
}
|
||||
regBody, _ := json.Marshal(regRequest)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/devices/register", bytes.NewReader(regBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusCreated, rec.Code, "Should initiate registration")
|
||||
|
||||
var regResponse map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), ®Response)
|
||||
registrationID, ok := regResponse["registration_id"].(string)
|
||||
require.True(t, ok, "Should have registration_id")
|
||||
|
||||
req = httptest.NewRequest("GET", fmt.Sprintf("/api/devices/approve/%s", registrationID), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
rec = httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code,
|
||||
"Approve must succeed even when the identifier already exists (was the reinstall 500)")
|
||||
|
||||
// The status response is single-use and carries the credentials.
|
||||
statusBody, _ := json.Marshal(map[string]string{"registration_id": registrationID})
|
||||
req = httptest.NewRequest("POST", "/api/devices/register/status", bytes.NewReader(statusBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var statusResponse map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &statusResponse)
|
||||
require.Equal(t, "approved", statusResponse["status"])
|
||||
token, ok := statusResponse["auth_token"].(string)
|
||||
require.True(t, ok, "Should have auth_token")
|
||||
require.NotEmpty(t, token)
|
||||
return token
|
||||
}
|
||||
|
||||
token1 := registerAndApprove()
|
||||
token2 := registerAndApprove()
|
||||
|
||||
assert.NotEqual(t, token1, token2, "Re-approval must rotate the auth token (fresh install = fresh credentials)")
|
||||
|
||||
devices, err := setup.DB.ListDevicesByUser(context.Background(),
|
||||
pgtype.UUID{Bytes: [16]byte(userID), Valid: true})
|
||||
require.NoError(t, err)
|
||||
rows := 0
|
||||
for _, d := range devices {
|
||||
if d.DeviceIdentifier == identifier {
|
||||
rows++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, rows, "Re-approval must reuse the existing devices row, not duplicate it")
|
||||
|
||||
// The rotated-in token must be the live one.
|
||||
var live *database.Devices
|
||||
for i := range devices {
|
||||
if devices[i].DeviceIdentifier == identifier {
|
||||
live = &devices[i]
|
||||
}
|
||||
}
|
||||
require.NotNil(t, live)
|
||||
assert.Equal(t, token2, live.AuthToken, "The devices row must carry the newly rotated token")
|
||||
}
|
||||
|
||||
func TestApproveDeviceRegistration(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -435,3 +436,115 @@ func TestOPDSSearchAcrossLibraries(t *testing.T) {
|
||||
t.Logf("❌ FAILED - OPDS returned %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOPDSLibraryFolders covers the library-folder navigation feed: the root
|
||||
// catalog is a navigation feed with an "All Books" entry plus one folder per
|
||||
// visible library, the scoped catalog only lists that library's books, and
|
||||
// the library-scoped search parameter restricts results to that library.
|
||||
// TestOPDSLibraryFolders covers the library-folder navigation feed: the root
|
||||
// catalog is a navigation feed with an "All Books" entry plus one folder per
|
||||
// visible library, the scoped catalog only lists that library's books, and
|
||||
// the library-scoped search parameter restricts results to that library.
|
||||
// NOTE: library names deliberately avoid the word "test" — setupTestServer's
|
||||
// setup-time cleanup deletes every library whose name contains it (and
|
||||
// setupDeviceTest re-runs that cleanup mid-test).
|
||||
func TestOPDSLibraryFolders(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
// Two libraries, one distinct book each.
|
||||
lib1Resp := createLibrary(t, client, setup, "OPDS Folder Lib Alpha")
|
||||
lib2Resp := createLibrary(t, client, setup, "OPDS Folder Lib Beta")
|
||||
lib1ID := lib1Resp["id"].(string)
|
||||
lib2ID := lib2Resp["id"].(string)
|
||||
// Real directories: the folder API validates that the path exists on the
|
||||
// host running the server (container path /app/uploads won't exist when
|
||||
// tests run on the host).
|
||||
addFolderToLibrary(t, setup, lib1ID, t.TempDir())
|
||||
addFolderToLibrary(t, setup, lib2ID, t.TempDir())
|
||||
createTestMediaItemIDInLibrary(t, client, setup, lib1ID, "OPDS Folder Book 1")
|
||||
createTestMediaItemIDInLibrary(t, client, setup, lib2ID, "OPDS Folder Book 2")
|
||||
|
||||
deviceSetup := setupDeviceTest(t)
|
||||
device := deviceSetup.CreateDevice(t, "OPDS Folders Device", "koreader", "opds-folders-"+uuid.NewString())
|
||||
base := setup.Server.URL + "/opds/devices/" + device.ID.String()
|
||||
|
||||
// Explicit cleanup: these names don't contain "test", so the harness
|
||||
// cleanup skips them; remove them ourselves. Registered as a defer AFTER
|
||||
// the setup.Close defer so LIFO ordering runs it while the pool is open.
|
||||
defer deleteLibraries(t, setup,
|
||||
pgtype.UUID{Bytes: uuid.MustParse(lib1ID), Valid: true},
|
||||
pgtype.UUID{Bytes: uuid.MustParse(lib2ID), Valid: true})
|
||||
|
||||
getFeed := func(t *testing.T, url string) (int, string, string) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+device.AuthToken)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
return resp.StatusCode, contentType, string(bodyBytes)
|
||||
}
|
||||
|
||||
t.Run("root catalog is a navigation feed with All Books and library folders", func(t *testing.T) {
|
||||
status, contentType, body := getFeed(t, base+"/catalog")
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, contentType, "kind=navigation")
|
||||
assert.Contains(t, body, "All Books")
|
||||
assert.Contains(t, body, "OPDS Folder Lib Alpha")
|
||||
assert.Contains(t, body, "OPDS Folder Lib Beta")
|
||||
assert.Contains(t, body, "/library/"+lib1ID+"/catalog")
|
||||
assert.Contains(t, body, "/library/"+lib2ID+"/catalog")
|
||||
// The nav feed itself must not inline book entries.
|
||||
assert.NotContains(t, body, "OPDS Folder Book 1")
|
||||
})
|
||||
|
||||
t.Run("all=1 keeps the flat cross-library catalog", func(t *testing.T) {
|
||||
status, contentType, body := getFeed(t, base+"/catalog?all=1")
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, contentType, "kind=acquisition")
|
||||
assert.Contains(t, body, "OPDS Folder Book 1")
|
||||
assert.Contains(t, body, "OPDS Folder Book 2")
|
||||
})
|
||||
|
||||
t.Run("scoped catalog lists only its own library's books", func(t *testing.T) {
|
||||
status, contentType, body := getFeed(t, base+"/library/"+lib1ID+"/catalog")
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, contentType, "kind=acquisition")
|
||||
assert.Contains(t, body, "OPDS Folder Book 1")
|
||||
assert.NotContains(t, body, "OPDS Folder Book 2")
|
||||
// Scoped search discovery: template pins the library.
|
||||
assert.Contains(t, body, "library_id="+lib1ID)
|
||||
})
|
||||
|
||||
t.Run("scoped catalog rejects libraries outside device visibility", func(t *testing.T) {
|
||||
status, _, _ := getFeed(t, base+"/library/11111111-2222-3333-4444-555555555555/catalog")
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
})
|
||||
|
||||
t.Run("scoped search restricts results to the library", func(t *testing.T) {
|
||||
status, _, body := getFeed(t, base+"/search?q=OPDS+Folder&library_id="+lib2ID)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, body, "OPDS Folder Book 2")
|
||||
assert.NotContains(t, body, "OPDS Folder Book 1")
|
||||
})
|
||||
|
||||
t.Run("scoped search rejects inaccessible library", func(t *testing.T) {
|
||||
status, _, _ := getFeed(t, base+"/search?q=OPDS&library_id=11111111-2222-3333-4444-555555555555")
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
})
|
||||
|
||||
t.Run("global search labels entries with their library", func(t *testing.T) {
|
||||
status, _, body := getFeed(t, base+"/search?q=OPDS+Folder")
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, body, "OPDS Folder Book 1")
|
||||
assert.Contains(t, body, "OPDS Folder Book 2")
|
||||
assert.Contains(t, body, "OPDS Folder Lib Alpha")
|
||||
assert.Contains(t, body, "OPDS Folder Lib Beta")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"bookhoard/internal/config"
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSchemaInitializationIsIdempotent replays the application's production
|
||||
// schema initializer twice against the same database. schema.sql runs on
|
||||
// EVERY app startup, so it must apply cleanly to a database where a previous
|
||||
// startup already applied it. A regression here crash-loops the production
|
||||
// container on its second boot (e.g. SQLSTATE 2BP01: DROP FUNCTION refused
|
||||
// while a trigger created by the previous run still references it).
|
||||
//
|
||||
// Uses a dedicated pool: production Initialize holds the advisory lock on one
|
||||
// connection while executing the schema on another, but the shared test pool
|
||||
// is capped at MaxConns=1 and would deadlock.
|
||||
func TestSchemaInitializationIsIdempotent(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping schema re-initialization in -short mode")
|
||||
}
|
||||
|
||||
// Bring up the standard test environment (seeds users, starts server).
|
||||
setup := setupTestServer(t)
|
||||
defer func() { _ = setup.Close() }()
|
||||
|
||||
// Dedicated pool with default connection limits for the initializer.
|
||||
poolCfg, err := pgxpool.ParseConfig(config.LoadConfig().DatabaseURL())
|
||||
require.NoError(t, err, "failed to parse database URL")
|
||||
pool, err := pgxpool.NewWithConfig(context.Background(), poolCfg)
|
||||
require.NoError(t, err, "failed to connect for schema initialization test")
|
||||
defer func() { pool.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// First boot: applies the schema to the (possibly fresh) database.
|
||||
if err := database.Initialize(ctx, pool); err != nil {
|
||||
t.Fatalf("first schema initialization failed: %v", err)
|
||||
}
|
||||
|
||||
// Second boot against the now-initialized database: this is exactly
|
||||
// where non-idempotent statements (bare CREATE TRIGGER, DROP FUNCTION
|
||||
// with dependent objects, etc.) blow up.
|
||||
if err := database.Initialize(ctx, pool); err != nil {
|
||||
t.Fatalf("second schema initialization failed: %v — schema.sql must stay idempotent across restarts", err)
|
||||
}
|
||||
}
|
||||
@@ -544,6 +544,10 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
AnnotationService: annotationService,
|
||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||
LoginTracker: loginAttemptTracker,
|
||||
// RegisterRoutes dereferences Settings for the auth rate limit;
|
||||
// the main binary wires database.NewSettingsRegistry(queries) —
|
||||
// the test harness must too or route registration nil-panics.
|
||||
Settings: database.NewSettingsRegistry(queries),
|
||||
}
|
||||
|
||||
router.RegisterRoutes(routerConfig)
|
||||
@@ -588,9 +592,27 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
}
|
||||
}
|
||||
|
||||
// Create fresh admin test user
|
||||
// The admin test user doubles as the shared dev admin and is
|
||||
// deliberately PRESERVED by the cleanup above — reuse it when it
|
||||
// already exists instead of re-creating (a blind INSERT hits
|
||||
// users_email_key on every run after the first).
|
||||
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
|
||||
adminUser, err := queries.CreateUser(ctx, database.CreateUserParams{
|
||||
var adminUUID uuid.UUID
|
||||
if existing, gerr := queries.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal"); gerr == nil {
|
||||
adminUUID, err = uuid.FromBytes(existing.ID.Bytes[:])
|
||||
require.NoError(t, err, "Failed to parse admin user UUID")
|
||||
// Reset the password to the known test constant: other tests (e.g.
|
||||
// password-change tests) mutate the shared admin's password, which
|
||||
// would otherwise lock every later test out with login 401s until
|
||||
// the database is wiped.
|
||||
if updErr := queries.UpdatePassword(ctx, database.UpdatePasswordParams{
|
||||
ID: existing.ID,
|
||||
PasswordHash: passwordHash,
|
||||
}); updErr != nil {
|
||||
require.NoError(t, updErr, "Failed to reset shared admin test password")
|
||||
}
|
||||
} else {
|
||||
adminUser, cerr := queries.CreateUser(ctx, database.CreateUserParams{
|
||||
Email: "testuser@tests.bookhoard.internal",
|
||||
Username: "testuser",
|
||||
PasswordHash: passwordHash,
|
||||
@@ -598,11 +620,13 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
LastName: pgtype.Text{String: "User", Valid: true},
|
||||
Role: "admin",
|
||||
})
|
||||
require.NoError(t, err, "Failed to create admin test user")
|
||||
|
||||
adminUUID, err := uuid.FromBytes(adminUser.ID.Bytes[:])
|
||||
require.NoError(t, cerr, "Failed to create admin test user")
|
||||
adminUUID, err = uuid.FromBytes(adminUser.ID.Bytes[:])
|
||||
require.NoError(t, err, "Failed to parse admin user UUID")
|
||||
// Default collections are created exactly once per user; the
|
||||
// preserved dev admin already has its set from the first run.
|
||||
createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||
}
|
||||
|
||||
// Create fresh regular test user
|
||||
regularUser, err := queries.CreateUser(ctx, database.CreateUserParams{
|
||||
|
||||
@@ -207,6 +207,19 @@ CREATE TABLE IF NOT EXISTS media_items (
|
||||
-- Summary (distinct from description - may merge with Calibre description)
|
||||
summary TEXT, -- Summary from ComicInfo.xml (may be merged with description from Calibre)
|
||||
|
||||
-- Per-field user-override tracking. Column names listed here (e.g.
|
||||
-- 'description', 'tags', 'cover_image_path') are user-customized and MUST
|
||||
-- NOT be overwritten by library scans or per-book rescans; only the
|
||||
-- reset-to-scanned-defaults action clears them.
|
||||
metadata_overrides TEXT[] NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Archive lifecycle: a file missing from disk for two consecutive scans
|
||||
-- gets archived_at set (hidden from all browsing, reading history kept).
|
||||
-- If the file returns, the row is un-archived. Archived rows are purged
|
||||
-- after ARCHIVE_RETENTION_DAYS (0 = manual purge only).
|
||||
missing_scan_count INT NOT NULL DEFAULT 0,
|
||||
archived_at TIMESTAMPTZ,
|
||||
|
||||
-- Chapter metadata for reader navigation and progress tracking
|
||||
-- Caches detected chapter structure to avoid re-parsing files
|
||||
-- Populated by ReaderService.DetectChapters() on first read
|
||||
@@ -232,12 +245,25 @@ CREATE INDEX IF NOT EXISTS idx_media_items_contributors_gin ON media_items USING
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[];
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
|
||||
|
||||
-- Per-field user-override tracking (idempotent backfill for existing
|
||||
-- installs; the column is also declared in the media_items table
|
||||
-- definition above for fresh databases)
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS metadata_overrides TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
-- Archive lifecycle (idempotent backfill for existing installs)
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS missing_scan_count INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_archived ON media_items (archived_at) WHERE archived_at IS NOT NULL;
|
||||
|
||||
-- Create GIN indexes for fast search field searches
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_tags_search ON media_items USING GIN (tags_search);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_contributors_search ON media_items USING GIN (contributors_search);
|
||||
|
||||
-- Drop existing trigger if exists
|
||||
-- Drop existing triggers if exists. Both must be dropped BEFORE the function:
|
||||
-- dropping a function that triggers still reference fails (SQLSTATE 2BP01),
|
||||
-- which would abort the schema transaction on every startup after the first.
|
||||
DROP TRIGGER IF EXISTS set_library_type_name_on_insert ON media_items;
|
||||
DROP TRIGGER IF EXISTS set_library_type_name_on_library_change ON media_items;
|
||||
-- Drop existing function if exists
|
||||
DROP FUNCTION IF EXISTS set_library_type_name();
|
||||
|
||||
@@ -256,6 +282,14 @@ CREATE TRIGGER set_library_type_name_on_insert
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_library_type_name();
|
||||
|
||||
-- Keep library_type_name truthful when an item is repointed to another
|
||||
-- library (cross-library move detection reuses the same function; the row
|
||||
-- keeps its ID while its library changes).
|
||||
CREATE TRIGGER set_library_type_name_on_library_change
|
||||
BEFORE UPDATE OF library_id ON media_items
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_library_type_name();
|
||||
|
||||
-- Add GIN indexes for pg_trgm fuzzy search performance
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_author_trgm
|
||||
ON media_items USING GIN (author gin_trgm_ops);
|
||||
@@ -335,8 +369,8 @@ CREATE TABLE IF NOT EXISTS media_highlights (
|
||||
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
selection_text TEXT NOT NULL,
|
||||
start_position VARCHAR(100), -- position (page:offset or CFI) where highlight starts
|
||||
end_position VARCHAR(100), -- position (page:offset or CFI) where highlight ends
|
||||
start_position TEXT, -- position (page:offset, CFI, or locator JSON) where highlight starts
|
||||
end_position TEXT, -- position (page:offset, CFI, or locator JSON) where highlight ends
|
||||
color VARCHAR(7) DEFAULT '#ffff00', -- hex color code for highlight
|
||||
note_id UUID REFERENCES media_notes(id) ON DELETE SET NULL, -- optional associated note
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
@@ -1344,8 +1378,10 @@ CREATE TABLE IF NOT EXISTS media_bookmarks (
|
||||
title VARCHAR(255) NOT NULL,
|
||||
position VARCHAR(100), -- 'pdf:page:45', 'comic:page:12', 'chapter:3' for consistency
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(media_item_id, user_id, title)
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
-- No UNIQUE(media_item_id, user_id, title): bookmarks are identified by
|
||||
-- their location (dedup_key), titles are display labels shared verbatim
|
||||
-- across clients (KOReader auto-labels repeat within a chapter).
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id);
|
||||
@@ -1364,6 +1400,13 @@ ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS note_text TEXT;
|
||||
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- Widen position columns for existing databases: the API handlers
|
||||
-- validate up to 1000 characters (full Readium locators, KOReader CRE
|
||||
-- xpointers) but VARCHAR(100) rejected anything longer at the database
|
||||
-- layer. VARCHAR -> TEXT is a metadata-only change, safe to re-run.
|
||||
ALTER TABLE media_highlights ALTER COLUMN start_position TYPE TEXT;
|
||||
ALTER TABLE media_highlights ALTER COLUMN end_position TYPE TEXT;
|
||||
|
||||
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
||||
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
||||
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
||||
@@ -1379,6 +1422,14 @@ ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS epubcfi_location TEXT;
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS chapter_reference INTEGER;
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
-- Provenance: the client that CREATED the bookmark (unlike
|
||||
-- last_modified_source, which tracks the last writer). Set once at insert.
|
||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS origin_source VARCHAR(30);
|
||||
-- Identity is the dedup_key (location), not the title; drop the legacy
|
||||
-- unique-title constraint so same-title bookmarks on different pages can
|
||||
-- coexist (re-creating over a tombstone with a changed position also
|
||||
-- relied on this). Catalog-only change, safe to re-run.
|
||||
ALTER TABLE media_bookmarks DROP CONSTRAINT IF EXISTS media_bookmarks_media_item_id_user_id_title_key;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_highlights_dedup
|
||||
ON media_highlights (user_id, media_item_id, dedup_key)
|
||||
|
||||
@@ -61,6 +61,12 @@ services:
|
||||
BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
|
||||
BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}
|
||||
|
||||
# Library Maintenance
|
||||
# Days an archived item (file missing from disk for 2+ scans) is kept,
|
||||
# with its reading history, before library scans purge it for good.
|
||||
# Set 0 to keep archived items until purged manually on the library admin page.
|
||||
ARCHIVE_RETENTION_DAYS: ${ARCHIVE_RETENTION_DAYS:-90}
|
||||
|
||||
# System timezone (fallback for server-side time operations)
|
||||
TZ: ${TZ:-UTC}
|
||||
ports:
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# Android App — Design & Roadmap
|
||||
|
||||
This document describes the planned native Android client for Bookhoard: a thin, offline-first reading app that treats the Bookhoard server as its backend. The relationship is the same as the audiobookshelf app to an audiobookshelf server, or the Kindle app to Kindle cloud — the server owns the library, sync, and conflict resolution; the app is a dedicated, mobile-first reading frontend with its own UI, designed independently of the web interface.
|
||||
|
||||
**Status**: Planning / pre-development
|
||||
**Companion repo**: `bookhoard-app` (separate repository, AGPL-3.0)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Product Vision
|
||||
|
||||
- An **amazing ereader** first and foremost — rendering polish, latency, and reading UX are the product
|
||||
- **Mobile-first UI** designed from scratch for phones; not a wrapper around the web app
|
||||
- **Thin client**: the server remains authoritative for all sync, book matching, and conflict resolution
|
||||
- **v1 formats**: EPUB (ebooks) and CBZ (comics/manga); PDF comes nearly free via the reader toolkit
|
||||
- **Android first**. iOS is a real roadmap item but unscheduled — likely contributor-driven
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Tech Stack
|
||||
|
||||
| Concern | Choice |
|
||||
| -------------- | ------------------------------------------------- |
|
||||
| Language | Kotlin |
|
||||
| UI | Jetpack Compose + Material 3 |
|
||||
| Reader engine | [Readium Kotlin toolkit](https://github.com/readium/kotlin-toolkit) |
|
||||
| Local database | Room |
|
||||
| Networking | OkHttp / Retrofit + WebSocket |
|
||||
| Background | WorkManager |
|
||||
| Images | Coil |
|
||||
| Settings | DataStore |
|
||||
|
||||
### Why native Android
|
||||
|
||||
- The quality bar is the Kindle app. Page-turn latency, text layout fidelity, PDF rendering (`PdfRenderer`), and comic/manga image pipelines are platform-level strengths — and they are the *hard* parts in a WebView, not the easy parts.
|
||||
- Android-first removes the "share one codebase across two platforms simultaneously" constraint that motivates hybrid stacks.
|
||||
- Solo, AI-assisted development compresses the cost of native (code volume), while native's failure modes (well-documented platform APIs) are far easier to debug — alone or with AI — than cross-framework bridge/plugin bugs.
|
||||
- The target audience is the self-hosted community, best reached via GitHub Releases and F-Droid rather than app-store optimization.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- **Capacitor / WebView shell** (the audiobookshelf-app model): excellent when a self-contained SPA already exists; a poor fit here. Bookhoard's web UI is server-rendered HTMX and cannot be packaged, comics rendering in a WebView caps the polish target, and deep offline support fights the shell.
|
||||
- **Flutter**: strong middle ground, but no Readium port and a weaker EPUB/PDF plugin ecosystem than the native toolkits.
|
||||
- **Kotlin Multiplatform**: only pays off with a committed near-term iOS effort. Revisit if iOS becomes active; until then it would constrain v1 for a hypothetical.
|
||||
|
||||
---
|
||||
|
||||
## 🏗 Architecture
|
||||
|
||||
Thin, offline-first client. The server API is the contract (see [API Reference](api/api-reference.md) and [WebSocket API](websocket-api.md)).
|
||||
|
||||
### Module layout
|
||||
|
||||
```
|
||||
:app Compose UI, navigation, dependency injection
|
||||
:core:domain Pure Kotlin — models, sync logic, use cases (no Android deps)
|
||||
:core:data Room, Retrofit/OkHttp, downloads and file storage
|
||||
:feature:reader Readium navigator integration and reading UI
|
||||
```
|
||||
|
||||
Keeping `:core:domain` free of Android dependencies preserves optionality: a future iOS client, a KMP extraction, or a desktop client can reuse or port the domain logic without touching the UI.
|
||||
|
||||
### Offline-first sync flow
|
||||
|
||||
1. UI writes go to the local Room mirror **first** (never blocked on network)
|
||||
2. A WorkManager queue replays changes to the existing REST endpoints (`/api/progress`, `/api/media-items/:id/notes`, `/highlights`, etc.)
|
||||
3. Conflicts are resolved by the server's existing mechanisms — the client never invents its own merge logic
|
||||
4. While online, a WebSocket connection receives realtime updates pushed by other devices (web reader, KOReader)
|
||||
5. Books are downloaded to app storage for fully offline reading, with storage management UI
|
||||
|
||||
### Authentication & device identity
|
||||
|
||||
- **Primary auth: username/password login** via the existing endpoints (`POST /api/auth/login` + refresh). The app is a full user client — browse, collections, ratings, and annotation management all live behind the user JWT, which device tokens cannot reach
|
||||
- After login, the app registers itself as a **device** (`device_type: mobile`) and **self-approves** its registration using its own JWT — approval only requires a logged-in user. The phone then appears on the Devices page with sync attribution, per-device settings, and individually revocable access, with no QR ceremony
|
||||
- Netflix-style QR pairing as a zero-typing sign-in option: post-v1 (see below)
|
||||
|
||||
---
|
||||
|
||||
## 📖 Reader Engine
|
||||
|
||||
[Readium](https://github.com/readium/kotlin-toolkit) provides EPUB, PDF, and CBZ through one publication model and navigator — production-hardened by real reading apps. This avoids building and maintaining three renderers.
|
||||
|
||||
Planned reading features:
|
||||
|
||||
- Custom fonts (including user-loaded), adjustable margins and line height
|
||||
- Themes including OLED true-black for battery
|
||||
- Paginated and scroll modes; gesture and volume-key page turns
|
||||
- Keep-screen-awake while reading
|
||||
- Highlights, notes, and bookmarks synced via existing APIs (including deleted-annotation restore)
|
||||
- Resume to exact position using EPUB CFI, consistent with universal sync
|
||||
|
||||
### Comics & manga UX
|
||||
|
||||
- RTL reading direction and double-page spreads with correct cover/single-page handling
|
||||
- Per-book reading-mode overrides (a manga library can default to RTL)
|
||||
- Zoom and pan; aggressive preloading of adjacent pages
|
||||
- Webtoon / continuous vertical mode: post-v1
|
||||
|
||||
### Reader settings parity with the web reader
|
||||
|
||||
The web reader (`web/src/reader/`) is the reference implementation for reading ergonomics — its font selection, reading themes, and highlight system are considered well-designed; only its desktop-oriented presentation is being replaced on mobile. The Android reader should reuse the same settings model (stored in the `reader_settings` table and synced via the settings endpoint) rather than inventing a parallel one:
|
||||
|
||||
- **Fonts**: the same roster of variable fonts, self-hosted under `/static/fonts/` — Literata (default), Crimson Pro, Source Serif 4, EB Garamond, Libertinus Serif, Noto Serif, Charis SIL, IBM Plex Serif (`FONT_MAP` in `web/src/reader/reader.ts`)
|
||||
- **Typography**: `font_size` (default 18), `line_height` (1.6), `margin_width`, `double_page_spread`
|
||||
- **Themes**: `chrome_theme` (default `tokyo-night`) for app chrome vs `reading_theme`/`reading_mode` for the page surface, plus the fx stack (`fx_brightness`, `fx_contrast`, `fx_invert`)
|
||||
- **Navigation**: `tap_zones_enabled` + `tap_zone_size`, `reading_direction`, `progress_mode`
|
||||
- **Highlights**: per-annotation color (default `#ffd54f`), matching the web palette
|
||||
|
||||
Settings chosen on one device should follow the user everywhere — mobile changes write back through the same sync.
|
||||
|
||||
---
|
||||
|
||||
## 🍎 iOS Posture
|
||||
|
||||
iOS is a real roadmap item but not near-term. The strategy is **not** to pre-pay for it with KMP or a cross-platform framework. Instead:
|
||||
|
||||
- The documented REST/WebSocket API is the sharing mechanism — a future iOS client is a *new client over the same contract*, never a rewrite of shared logic
|
||||
- A contributor-driven Swift/SwiftUI client is welcome; the server needs no changes to support it
|
||||
|
||||
---
|
||||
|
||||
## 📦 Distribution & Licensing
|
||||
|
||||
- **License**: AGPL-3.0, matching the Bookhoard server
|
||||
- **Channels**: GitHub Releases and F-Droid; Play Store optional later
|
||||
|
||||
---
|
||||
|
||||
## 🚧 Milestones
|
||||
|
||||
1. **Scaffold** — app shell, auth + QR device pairing, library browsing, book downloads
|
||||
2. **EPUB reading** — Readium integration, CFI progress sync, offline-first reading
|
||||
3. **Annotations** — highlights/notes/bookmarks sync with offline queue
|
||||
4. **Comics** — CBZ navigator with manga modes (RTL, spreads, zoom)
|
||||
5. **Polish** — OLED themes, gestures, background sync, storage management
|
||||
|
||||
---
|
||||
|
||||
## 🔭 Post-v1 Ideas
|
||||
|
||||
### QR pairing sign-in (Netflix-style)
|
||||
|
||||
"Add device" on the web (while logged in) displays a QR code; a fresh app install scans it and is **fully signed in** — no server URL, no password, nothing typed on the phone.
|
||||
|
||||
- **QR is a full login**: the claim endpoint returns JWT + refresh token (plus the device token for sync identity)
|
||||
- **Typed-code fallback** (GitHub/Netflix device-flow style: app displays a short code, user enters it on the web) for phones with broken cameras or no camera
|
||||
- **KOReader keeps its existing flow unchanged** — no typed-code pairing there; it is already as convenient as it can be
|
||||
- **Use the configured `BASE_URL`, never a detected LAN IP** — if the server is published at `https://public.domain`, pairing must work identically from outside the LAN
|
||||
- Requires small server additions: `pair`/`claim` endpoints backed by single-use pairing sessions with a short TTL (in-memory like `pendingRegistrations`)
|
||||
|
||||
### Other ideas
|
||||
|
||||
- Webtoon / continuous vertical reading mode
|
||||
- Home-screen widgets and app shortcuts ("continue reading")
|
||||
- Text-to-speech
|
||||
- OPDS feed consumption from other servers
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **[API Reference](api/api-reference.md)** - Complete REST API
|
||||
- **[WebSocket API](websocket-api.md)** - Real-time sync events
|
||||
- **[Sync Guide](../user/sync-guide.md)** - How universal sync works
|
||||
- **[Devices API](api/devices/)** - Device registration and approval
|
||||
+40
-108
@@ -394,6 +394,11 @@ Authorization: Bearer <token>
|
||||
|
||||
## Reading Progress
|
||||
|
||||
Full field reference: [api/progress/](api/progress/) — and read the
|
||||
[Position Contract](api/progress/position-contract.md) (verification,
|
||||
healing, the OPF spine numbering hazard, offset currencies) before
|
||||
writing a client.
|
||||
|
||||
### Get Reading Progress
|
||||
|
||||
```http
|
||||
@@ -405,57 +410,56 @@ Authorization: Bearer <token>
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"media_item_id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"current_page": 45,
|
||||
"total_pages": 200,
|
||||
"percentage": 0.225,
|
||||
"character_offset": 15432,
|
||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
||||
"chapter": 3,
|
||||
"chapter_progress": 0.5,
|
||||
"last_read_at": "2026-01-31T10:00:00Z",
|
||||
"percentage": 0.045,
|
||||
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/58/1:598)",
|
||||
"context_text": "from day to day, but there was none in which ...",
|
||||
"character_offset": 16375,
|
||||
"current_page": null,
|
||||
"total_pages": null,
|
||||
"chapter": null,
|
||||
"chapter_progress": null,
|
||||
"format_group": "reflowable",
|
||||
"viewport_y": 0.12,
|
||||
"zoom_level": 1.0
|
||||
"total_characters": 592216,
|
||||
"chapter_count": 1,
|
||||
"last_read_at": "2026-09-26T14:52:34Z",
|
||||
"last_sync_source": "koreader",
|
||||
"css_selector": "body>div:nth-child(4)>p:nth-child(29)",
|
||||
"anchor_href": "1984.xhtml",
|
||||
"char_offset": 598
|
||||
}
|
||||
```
|
||||
|
||||
`css_selector`, `anchor_href` and `char_offset` are server-derived
|
||||
restore handles, served for convertible reflowable books with a
|
||||
resolvable anchor. `char_offset` is UTF-16 code units within the anchor
|
||||
block's text; `character_offset` is a book-wide rune count. Resolve the
|
||||
document by `anchor_href`, never by the CFI's spine step (OPF numbering
|
||||
includes `linear="no"` items).
|
||||
|
||||
### Update Reading Progress
|
||||
|
||||
```http
|
||||
PUT /api/media-items/{media_id}/progress
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"source": "web",
|
||||
"location": {
|
||||
"percentage": 0.45678,
|
||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
||||
"character": 15432,
|
||||
"chapter": 3,
|
||||
"page": 89,
|
||||
"total_pages": 200
|
||||
},
|
||||
"device_metadata": {
|
||||
"device_type": "web",
|
||||
"user_agent": "Mozilla/5.0..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (200):
|
||||
|
||||
```json
|
||||
{
|
||||
"sync_status": "success",
|
||||
"progress_updated": true,
|
||||
"devices_notified": ["device-1", "device-2"],
|
||||
"broadcast": true
|
||||
"percentage": 0.0415,
|
||||
"context_text": "was at war with one of these Powers it was generally a",
|
||||
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/62/1:456)"
|
||||
}
|
||||
```
|
||||
|
||||
All fields are optional. The server verifies the submission against the
|
||||
book and heals it on mismatch; the response is the stored row after
|
||||
verification. A `percentage` below 0.005 is ignored while stored
|
||||
progress exceeds 0.01 (first-page anti-clobber).
|
||||
|
||||
### Delete Reading Progress
|
||||
|
||||
```http
|
||||
@@ -1297,82 +1301,10 @@ Authorization: Bearer <device_token>
|
||||
|
||||
## Universal Progress
|
||||
|
||||
### Get Universal Progress
|
||||
|
||||
```http
|
||||
GET /api/progress/{book_uuid}
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Response** (200):
|
||||
|
||||
```json
|
||||
{
|
||||
"book_id": "book-uuid",
|
||||
"format_group": "reflowable",
|
||||
"universal_progress": 0.45678,
|
||||
"location_references": {
|
||||
"percentage": 0.45678,
|
||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
||||
"character": 15432,
|
||||
"chapter": 3,
|
||||
"chapter_progress": 0.234,
|
||||
"viewport_y": 0.12
|
||||
},
|
||||
"device_progress": {
|
||||
"koreader": {
|
||||
"percentage": 0.45678,
|
||||
"last_sync": "2026-01-30T20:00:00Z"
|
||||
},
|
||||
"kobo": {
|
||||
"percentage": 45.6,
|
||||
"last_sync": "2026-01-30T19:55:00Z"
|
||||
},
|
||||
"web": {
|
||||
"display_page": 89,
|
||||
"total_pages": 200,
|
||||
"last_sync": "2026-01-30T20:05:00Z"
|
||||
}
|
||||
},
|
||||
"annotations": {
|
||||
"highlights": [...],
|
||||
"notes": [...],
|
||||
"bookmarks": [...]
|
||||
},
|
||||
"conflicts": [
|
||||
{
|
||||
"id": "conflict-uuid",
|
||||
"type": "progress",
|
||||
"resolved": false,
|
||||
"sources": ["koreader", "kobo"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Update Universal Progress
|
||||
|
||||
```http
|
||||
POST /api/progress/{book_uuid}
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"source": "web|koreader|kobo|mobile",
|
||||
"location": {
|
||||
"percentage": 0.45678,
|
||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
||||
"character": 15432,
|
||||
"chapter": 3,
|
||||
"page": 89,
|
||||
"total_pages": 200
|
||||
},
|
||||
"device_metadata": {
|
||||
"device_type": "web",
|
||||
"user_agent": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
Universal progress is the media-item progress — one row per (user,
|
||||
media item) shared by every device. See [Reading Progress](#reading-progress)
|
||||
and the [Position Contract](api/progress/position-contract.md). There
|
||||
are no separate `/api/progress/:id` GET/POST endpoints.
|
||||
|
||||
## Conflicts
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ See [Media Item Operations](media-items/)
|
||||
- GET /api/media-items/:id - Get media item details
|
||||
- POST /api/media-items/bulk-delete - Bulk delete media items
|
||||
- POST /api/media-items/bulk-update - Bulk update media items (tags/contributors with normalization)
|
||||
- GET /api/media-items/:uuid/download - Download media item file
|
||||
- GET /uploads/library-{library_id}/{file_path} - Download book file / cover (JWT; see [Download Media Item](media-items/download_media_item.md))
|
||||
- POST /api/media-items/:id/rating - Create rating
|
||||
- GET /api/media-items/:id/rating - Get rating
|
||||
- PUT /api/media-items/:id/rating - Update rating
|
||||
@@ -117,10 +117,14 @@ See [Media Item Operations](media-items/)
|
||||
|
||||
## Reading Progress
|
||||
|
||||
See [Progress Tracking](progress/)
|
||||
See [Progress Tracking](progress/) — in particular the
|
||||
[Position Contract](progress/position-contract.md) (verification,
|
||||
healing, the OPF spine numbering hazard, and offset currencies) before
|
||||
writing a client.
|
||||
|
||||
- GET /api/progress/:id - Get universal progress
|
||||
- POST /api/progress/:id - Update universal progress
|
||||
- GET /api/media-items/:id/progress - Get reading progress + restore handles
|
||||
- PUT /api/media-items/:id/progress - Submit reading progress (verified/healed server-side)
|
||||
- DELETE /api/media-items/:id/progress - Delete reading progress
|
||||
- GET /api/progress/:id/history - Get progress history
|
||||
|
||||
## Notes & Highlights
|
||||
|
||||
@@ -26,7 +26,7 @@ Authenticate with email and password.
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"refresh_token": "d4f5g6h7...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 604800,
|
||||
@@ -41,6 +41,8 @@ Authenticate with email and password.
|
||||
}
|
||||
```
|
||||
|
||||
Note: the access token field is `access_token` (not `token`). Nullable profile fields (`first_name`, `last_name`) may be empty strings.
|
||||
|
||||
**Set-Cookie Header**:
|
||||
|
||||
```
|
||||
|
||||
@@ -24,7 +24,9 @@ Check device registration status or get device details.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "pending|approved|expired",
|
||||
"status": "pending|approved",
|
||||
"message": "awaiting user approval",
|
||||
"expires_in": 123,
|
||||
"auth_token": "device-bearer-token...",
|
||||
"device_id": "uuid",
|
||||
"sync_endpoints": {
|
||||
@@ -35,24 +37,16 @@ Check device registration status or get device details.
|
||||
}
|
||||
```
|
||||
|
||||
## Response (200 OK) - Device Details
|
||||
`status` is `pending` or `approved`. While pending, the response includes `message` and `expires_in` (seconds remaining). Once approved, the response includes `auth_token`, `device_id`, and `sync_endpoints`; `auth_token` fields are empty when pending.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"device_name": "My Kobo Clara",
|
||||
"device_type": "kobo",
|
||||
"last_sync": "2026-01-31T10:00:00Z",
|
||||
"last_seen": "2026-01-31T10:05:00Z",
|
||||
"sync_enabled": true,
|
||||
"auto_sync": true,
|
||||
"sync_frequency_minutes": 5
|
||||
}
|
||||
```
|
||||
**The approved response is single-use**: the registration is deleted from the pending map once returned, so store the `auth_token` immediately. A repeat status check for the same `registration_id` returns 404.
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | --------------------------------------------- |
|
||||
| 401 | Invalid or expired token (for device details) |
|
||||
| 404 | Device or registration not found |
|
||||
| ---- | -------------------------------------------------- |
|
||||
| 400 | Invalid or missing `registration_id` |
|
||||
| 404 | Registration not found (unknown or already issued) |
|
||||
| 410 | Registration expired (`{"error": "registration expired"}`) |
|
||||
|
||||
Note: expiration is signaled by HTTP 410 Gone, not a `"status": "expired"` value. Pending registrations are held in server memory, so a server restart also invalidates them (subsequent checks return 404).
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
# Get Metadata
|
||||
|
||||
Get metadata for a book from KOReader device.
|
||||
Get a book's stored progress and annotations for a KOReader device —
|
||||
the pull half of the device sync. The reference client calls this after
|
||||
linking a book via [Resolve Book](resolve_book.md) and navigates to the
|
||||
returned position.
|
||||
|
||||
**Endpoint**: `GET /api/sync/koreader/metadata/:uuid`
|
||||
**Auth**: Required (Device authentication)
|
||||
**Auth**: Required (Device authentication — `Authorization: Bearer {device_token}`)
|
||||
|
||||
## Path Parameters
|
||||
|
||||
@@ -11,41 +14,55 @@ Get metadata for a book from KOReader device.
|
||||
| --------- | ------------- | -------- | ----------- |
|
||||
| uuid | string (UUID) | Yes | Book UUID |
|
||||
|
||||
## Device Authentication
|
||||
|
||||
This endpoint requires device authentication (not user JWT). Devices authenticate using their device credentials.
|
||||
|
||||
## Request Headers
|
||||
|
||||
| Header | Type | Required | Description |
|
||||
| ------------ | ------ | -------- | ------------------------- |
|
||||
| X-Device-ID | string | Yes | Device UUID |
|
||||
| X-Device-Key | string | Yes | Device authentication key |
|
||||
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
GET /api/sync/koreader/metadata/550e8400-e29b-41d4-a716-446655440000
|
||||
X-Device-ID: 550e8400-e29b-41d4-a716-446655440000
|
||||
X-Device-Key: device-auth-key
|
||||
GET /api/sync/koreader/metadata/774641f9-317b-4087-8e04-53bb4392ae56
|
||||
Authorization: Bearer {device_token}
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "Book Title",
|
||||
"authors": ["Author Name"],
|
||||
"path": "/path/to/book.epub",
|
||||
"file_size": 1234567,
|
||||
"modified_at": "2026-02-08T10:00:00Z"
|
||||
"uuid": "774641f9-317b-4087-8e04-53bb4392ae56",
|
||||
"title": "1984",
|
||||
"author": "George Orwell",
|
||||
"progress": {
|
||||
"percentage": 0.045,
|
||||
"koreader_xpointer": "/body/DocFragment[1]/body/p[29]/text().598",
|
||||
"chapter": null,
|
||||
"chapter_progress": null,
|
||||
"page": null,
|
||||
"total_pages": null
|
||||
},
|
||||
"annotations": {
|
||||
"highlights": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Progress Object
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| `percentage` | float | Stored position as a book fraction. |
|
||||
| `koreader_xpointer` | string | The stored canonical position converted back to a CRE xpointer (UTF-16 `text().N` offset). **The device should navigate to this.** Reflowable books only. |
|
||||
| `epubcfi` | string | The stored canonical CFI, when the conversion to a CRE xpointer is unavailable. Fallback after `koreader_xpointer`. |
|
||||
| `character` | int | Book-wide rune offset (internal currency). |
|
||||
| `chapter`, `chapter_progress` | int, float | Chapter position when known. |
|
||||
| `page`, `total_pages` | int | Fixed-layout page position — the canonical locator for image-based books (CFI/xpointer are omitted for them). |
|
||||
|
||||
`progress` is `null` when the book has no stored progress.
|
||||
|
||||
The `annotations` object carries device-format highlights/bookmarks/notes
|
||||
synced from other clients; its presence depends on annotation sync being
|
||||
enabled.
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ---------------------------- |
|
||||
| ---- | ----------- |
|
||||
| 400 | Invalid book UUID |
|
||||
| 401 | Device authentication failed |
|
||||
| 404 | Book or device not found |
|
||||
| 404 | Book not found |
|
||||
|
||||
@@ -1,63 +1,87 @@
|
||||
# Sync Progress
|
||||
|
||||
Sync reading progress from KOReader device.
|
||||
Push reading progress from a KOReader device.
|
||||
|
||||
**Endpoint**: `POST /api/sync/koreader/progress`
|
||||
**Auth**: Required (Device authentication)
|
||||
**Auth**: Required (Device authentication — `Authorization: Bearer {device_token}`)
|
||||
|
||||
## Device Authentication
|
||||
|
||||
This endpoint requires device authentication (not user JWT). Devices authenticate using their device credentials.
|
||||
This is the device-native tier of the [Position
|
||||
Contract](../progress/position-contract.md): the KOReader payload carries
|
||||
a CRE xpointer and the server converts it to the canonical standard CFI,
|
||||
verifies it against the submitted `context_text`, and heals it on
|
||||
mismatch — exactly like every other client.
|
||||
|
||||
## Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --------- | ------------- | -------- | ------------------------- |
|
||||
| device_id | string (UUID) | Yes | Device UUID |
|
||||
| progress | array | Yes | Array of progress objects |
|
||||
| --------- | ------ | -------- | ----------- |
|
||||
| `books` | array | Yes | One book object (the reference client sends a single-element array). |
|
||||
| `sync_mode`| string | No | `immediate` (default) or `manual`. |
|
||||
|
||||
### Progress Object
|
||||
### Book Object
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ----------- | ------- | -------- | ---------------------------------- |
|
||||
| book | string | Yes | Book identifier (filename or UUID) |
|
||||
| percent | float | Yes | Progress percentage (0-100) |
|
||||
| page | integer | No | Current page number |
|
||||
| total_pages | integer | No | Total pages in document |
|
||||
| date_read | string | No | ISO 8601 timestamp of last read |
|
||||
| updated_at | string | Yes | ISO 8601 timestamp |
|
||||
| ----- | ---- | -------- | ----------- |
|
||||
| `uuid` | string (UUID) | No | Bookhoard UUID, once the device has linked the book via [Resolve Book](resolve_book.md). |
|
||||
| `sha256` | string | Yes | File content hash (64 hex chars) — the primary book identity. |
|
||||
| `title` | string | No | Document title. |
|
||||
| `authors` | array | No | Author names. |
|
||||
| `percentage` | float | Yes | Position as a fraction of the book (0..1). |
|
||||
| `context_text` | string | No | Up to 100 whitespace-normalized chars from the current position — enables the server's verification/healing. Strongly recommended. |
|
||||
| `page` | int | No | Current page (fixed-layout books). |
|
||||
| `total_pages` | int | No | Page count (fixed-layout books). |
|
||||
| `epubcfi` | string | Reflowable only | **A CRE xpointer** (`/body/DocFragment[N]/body/...`), not a CFI — the field name is historical. Fixed-layout books must omit it and carry their position in `page`/`total_pages`. |
|
||||
| `file_path` | string | No | Device-local file path (informational). |
|
||||
| `device_info` | object | No | `{ koreader_version, device_model }`. |
|
||||
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
POST /api/sync/koreader/progress
|
||||
Authorization: Bearer {device_token}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"progress": [
|
||||
"books": [
|
||||
{
|
||||
"book": "book.epub",
|
||||
"percent": 75.5,
|
||||
"page": 150,
|
||||
"total_pages": 200,
|
||||
"date_read": "2026-02-08T10:00:00Z",
|
||||
"updated_at": "2026-02-08T10:00:00Z"
|
||||
"uuid": "774641f9-317b-4087-8e04-53bb4392ae56",
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"title": "1984",
|
||||
"authors": ["George Orwell"],
|
||||
"percentage": 0.045,
|
||||
"context_text": "from day to day, but there was none in which Goldstein was not the principal figure. He was the prim",
|
||||
"page": 14,
|
||||
"total_pages": 311,
|
||||
"epubcfi": "/body/DocFragment[1]/body/p[29]/text().598",
|
||||
"device_info": {
|
||||
"koreader_version": "v2026.07.1",
|
||||
"device_model": "emulator"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"sync_mode": "immediate"
|
||||
}
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
## Response (202 Accepted)
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Progress synced successfully",
|
||||
"synced_count": 1
|
||||
"sync_status": "ok",
|
||||
"books_synced": 1,
|
||||
"timestamp": "2026-09-26T21:25:09Z"
|
||||
}
|
||||
```
|
||||
|
||||
Per-book results and any detected sync conflicts are carried in
|
||||
`book_results` and `conflicts` when present.
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ---------------------------- |
|
||||
| ---- | ----------- |
|
||||
| 400 | Invalid request format |
|
||||
| 401 | Device authentication failed |
|
||||
| 400 | Invalid request data |
|
||||
| 404 | Device not found |
|
||||
| 500 | Database error |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Retrieve all libraries visible to the current user.
|
||||
|
||||
**Endpoint**: `GET /api/libraries/visible`
|
||||
**Endpoint**: `GET /api/libraries/visibility`
|
||||
**Auth**: Required
|
||||
|
||||
## Request Headers
|
||||
@@ -14,26 +14,33 @@ Retrieve all libraries visible to the current user.
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
GET /api/libraries/visible
|
||||
GET /api/libraries/visibility
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
A top-level JSON **array** of library rows:
|
||||
|
||||
```json
|
||||
{
|
||||
"libraries": [
|
||||
[
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "My Ebooks",
|
||||
"description": "Ebook collection",
|
||||
"library_type_id": "uuid",
|
||||
"created_by_admin_id": "uuid",
|
||||
"created_at": "2026-01-31T10:00:00Z",
|
||||
"updated_at": "2026-01-31T10:00:00Z",
|
||||
"type_name": "ebooks",
|
||||
"type_description": "Ebook libraries",
|
||||
"is_visible": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Nullable columns (`description`, `type_description`) serialize as `null` when unset. Timestamps are RFC 3339.
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
|
||||
Download a media item file (EPUB, PDF, etc.) from the Bookhoard server.
|
||||
|
||||
**Endpoint**: `GET /api/media-items/:uuid/download`
|
||||
**Auth**: None (public endpoint for Kobo devices)
|
||||
**Content-Type**: Binary file download
|
||||
Book files are served by the authenticated file route, the same one the web reader uses. Build the URL from the media item's `library_id` and relative `file_path` (both returned by the media item list/get endpoints):
|
||||
|
||||
**Endpoint**: `GET /uploads/library-{library_id}/{file_path}`
|
||||
**Auth**: Required (JWT - Bearer header or session cookie)
|
||||
|
||||
The `file_path` segments are URL-escaped individually; slashes are preserved. `cover_image_path` uses the same route.
|
||||
|
||||
## Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------ | -------- | --------------- |
|
||||
| uuid | string | Yes | Media item UUID |
|
||||
| ----------- | ------ | -------- | ------------------------------------ |
|
||||
| library_id | string | Yes | Library UUID (the item's library) |
|
||||
| file_path | string | Yes | The item's relative `file_path` |
|
||||
|
||||
## Response
|
||||
|
||||
@@ -18,25 +22,27 @@ Download a media item file (EPUB, PDF, etc.) from the Bookhoard server.
|
||||
|
||||
**Response Headers**:
|
||||
|
||||
- `Content-Type`: `application/epub+zip`, `application/pdf`, or appropriate MIME type
|
||||
- `Content-Disposition`: `attachment; filename="filename.epub"`
|
||||
- `Content-Type`: MIME type by file extension (`application/epub+zip`, `application/pdf`, …; `application/octet-stream` fallback)
|
||||
- `Cache-Control`: `public, max-age=86400`
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | --------------------------------- |
|
||||
| 404 | Media item not found |
|
||||
| 500 | Server error during file download |
|
||||
| ---- | --------------------------- |
|
||||
| 400 | Invalid library ID or path |
|
||||
| 401 | Missing/invalid token |
|
||||
| 404 | File not found on disk |
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
curl -O http://localhost:8765/api/media-items/550e8400-e29b-41d4-a716-446655440000/download
|
||||
curl -O -H "Authorization: Bearer $TOKEN" \
|
||||
"http://localhost:8765/uploads/library/550e8400-.../books/1984.epub"
|
||||
```
|
||||
|
||||
(URL shape: `/uploads/library-{uuid}/{escaped-relative-path}`.)
|
||||
|
||||
## Notes
|
||||
|
||||
- **Public endpoint**: No authentication required for Kobo device downloads
|
||||
- **File format**: Returns the original file format (EPUB, PDF, etc.)
|
||||
- **Kobo integration**: Designed for direct downloads from Kobo e-readers
|
||||
- **Cover images**: Use `/api/media-items/:uuid/cover` for cover images
|
||||
- **Do not rely on `GET /api/media-items/:id/download`** — it appears in older docs but is **not registered**; `MediaHandler.DownloadBook` exists as dead code. Use the file route above.
|
||||
- OPDS-capable devices may alternatively use the device-authenticated `GET /opds/devices/{deviceId}/download/{bookId}`, which supports on-the-fly format conversion (epub, kepub, pdf, cbz).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# List Media Items
|
||||
|
||||
Retrieve a paginated list of media items from a library.
|
||||
Retrieve a paginated list of media items, scoped to a library or across all libraries.
|
||||
|
||||
**Endpoint**: `GET /api/media-items`
|
||||
**Auth**: Required
|
||||
@@ -8,10 +8,15 @@ Retrieve a paginated list of media items from a library.
|
||||
## Query Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| ---------- | ------- | -------- | ----------------------------------------------- |
|
||||
| library_id | string | Yes | Library UUID |
|
||||
| limit | integer | No | Number of items to return (max 100, default 20) |
|
||||
| offset | integer | No | Number of items to skip |
|
||||
| ---------- | ------ | -------- | ------------------------------------------------------ |
|
||||
| library_id | string | No | Library UUID. If omitted, items from all libraries are returned |
|
||||
| limit | int | No | Items to return (default 50, max 1000) |
|
||||
| offset | int | No | Items to skip (must be >= 0) |
|
||||
| sort | string | No | Sort expression, default `created_at DESC` |
|
||||
|
||||
### Allowed sort expressions
|
||||
|
||||
`created_at`, `title`, `author`, `series`, `date_published`, `copyright_year`, `page_count`, `genre` — each with ` ASC` or ` DESC` (e.g. `title ASC`). Any other value silently falls back to `created_at DESC`.
|
||||
|
||||
## Request Headers
|
||||
|
||||
@@ -22,46 +27,121 @@ Retrieve a paginated list of media items from a library.
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
GET /api/media-items?library_id=uuid&limit=20&offset=0
|
||||
GET /api/media-items?library_id=uuid&limit=20&offset=0&sort=title%20ASC
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
The response body is `{"data": [...]}` in both modes. The item shape differs by mode.
|
||||
|
||||
**No total is returned** — page until fewer items than `limit` come back.
|
||||
|
||||
### With `library_id` — full database rows
|
||||
|
||||
Nullable columns serialize as `null`.
|
||||
|
||||
```json
|
||||
{
|
||||
"media_items": [
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"library_id": "uuid",
|
||||
"title": "Book Title",
|
||||
"author": "Author Name",
|
||||
"isbn": "978-...",
|
||||
"description": "Book description",
|
||||
"file_path": "/path/to/book.epub",
|
||||
"file_path": "relative/path/book.epub",
|
||||
"file_size": 1024000,
|
||||
"mime_type": "application/epub+zip",
|
||||
"cover_image_path": "/path/to/cover.jpg",
|
||||
"cover_image_path": "relative/path/cover.jpg",
|
||||
"series": "Series Name",
|
||||
"series_number": 1,
|
||||
"tags": ["sci-fi", "space opera"],
|
||||
"tags_search": ["sci fi", "space opera"],
|
||||
"contributors": ["Author Name", "ACME CORP."],
|
||||
"contributors_search": ["author name", "acme corp"],
|
||||
"tags": ["sci-fi"],
|
||||
"asin": null,
|
||||
"date_published": "2023-06-01",
|
||||
"publisher": null,
|
||||
"contributors": ["Author Name"],
|
||||
"language": "en",
|
||||
"edition": null,
|
||||
"page_count": 350,
|
||||
"genre": "Science Fiction",
|
||||
"copyright_year": 2023,
|
||||
"created_at": "2026-01-31T10:00:00Z"
|
||||
"goodreads_id": null,
|
||||
"openlibrary_id": null,
|
||||
"google_books_id": null,
|
||||
"added_by_admin_id": "uuid",
|
||||
"created_at": "2026-01-31T10:00:00Z",
|
||||
"imported_at": "2026-01-31T10:00:00Z",
|
||||
"updated_at": "2026-01-31T10:00:00Z",
|
||||
"format_group": "epub",
|
||||
"format_mimetype": "application/epub+zip",
|
||||
"is_reflowable": true,
|
||||
"has_fixed_layout": false,
|
||||
"total_characters": 480000,
|
||||
"chapter_count": 24
|
||||
}
|
||||
],
|
||||
"total": 100
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Note: in this mode `file_path` and `cover_image_path` are the raw relative storage paths, not URLs.
|
||||
|
||||
### Without `library_id` — curated items with resolved URLs
|
||||
|
||||
Across all libraries; file and cover paths are resolved to fetchable URL paths (`/uploads/...` or library-scoped paths):
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"library_id": "uuid",
|
||||
"title": "Book Title",
|
||||
"author": "Author Name",
|
||||
"isbn": "978-...",
|
||||
"description": "Book description",
|
||||
"file_path": "/api/libraries/<uuid>/files/...",
|
||||
"file_size": 1024000,
|
||||
"mime_type": "application/epub+zip",
|
||||
"cover_image_path": "/api/libraries/<uuid>/files/.../cover.jpg",
|
||||
"series": "Series Name",
|
||||
"series_number": 1,
|
||||
"tags": ["sci-fi"],
|
||||
"asin": null,
|
||||
"date_published": "2023-06-01",
|
||||
"publisher": null,
|
||||
"contributors": ["Author Name"],
|
||||
"language": "en",
|
||||
"edition": null,
|
||||
"page_count": 350,
|
||||
"genre": "Science Fiction",
|
||||
"created_at": "2026-01-31T10:00:00Z",
|
||||
"updated_at": "2026-01-31T10:00:00Z",
|
||||
"format_group": "epub",
|
||||
"manga_type": null,
|
||||
"reading_direction": null,
|
||||
"series_count": null,
|
||||
"volume": null,
|
||||
"imprint": null,
|
||||
"age_rating": null,
|
||||
"web_url": null,
|
||||
"metadata_notes": null,
|
||||
"community_rating": null,
|
||||
"story_arc": null,
|
||||
"is_black_and_white": false,
|
||||
"alternate_info": null,
|
||||
"scan_information": null,
|
||||
"summary": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------------------------------------- |
|
||||
| 400 | Invalid query parameters |
|
||||
| ---- | ------------------------------------------ |
|
||||
| 400 | Invalid `library_id`, `offset` < 0 |
|
||||
| 401 | Invalid or expired token |
|
||||
| 403 | User does not have access to this library |
|
||||
| 500 | Query failure (returned as `{"error": …}`) |
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Delete Media Item Progress
|
||||
|
||||
Delete the stored reading progress for a media item.
|
||||
|
||||
**Endpoint**: `DELETE /api/media-items/:id/progress`
|
||||
**Auth**: Required (Bearer token)
|
||||
|
||||
## Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------------- | -------- | ---------------- |
|
||||
| id | string (UUID) | Yes | Media item UUID |
|
||||
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
DELETE /api/media-items/774641f9-317b-4087-8e04-53bb4392ae56/progress
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "reading progress deleted"
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 400 | Invalid media item id |
|
||||
| 401 | Invalid or expired token |
|
||||
| 500 | Database error |
|
||||
@@ -1,36 +0,0 @@
|
||||
# Delete Reading Progress
|
||||
|
||||
Delete reading progress for a media item.
|
||||
|
||||
**Endpoint**: `DELETE /api/media-items/{media_id}/progress`
|
||||
**Auth**: Required
|
||||
|
||||
## Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------ | -------- | --------------- |
|
||||
| media_id | string | Yes | Media item UUID |
|
||||
|
||||
## Request Headers
|
||||
|
||||
| Header | Type | Required | Description |
|
||||
| ------------- | ------ | -------- | ------------ |
|
||||
| Authorization | string | Yes | Bearer token |
|
||||
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
DELETE /api/media-items/uuid/progress
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
## Response (204 No Content)
|
||||
|
||||
Progress deleted successfully.
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ------------------------ |
|
||||
| 401 | Invalid or expired token |
|
||||
| 404 | Media item not found |
|
||||
@@ -0,0 +1,98 @@
|
||||
# Get Media Item Progress
|
||||
|
||||
Get the stored reading progress for a media item, plus the
|
||||
server-derived restore handles for reflowable books.
|
||||
|
||||
**Endpoint**: `GET /api/media-items/:id/progress`
|
||||
**Auth**: Required (Bearer token)
|
||||
|
||||
See [Position Contract](position-contract.md) for the semantics of every
|
||||
field — what is verified, what the currencies are, and how clients
|
||||
should restore.
|
||||
|
||||
## Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------------- | -------- | --------------- |
|
||||
| id | string (UUID) | Yes | Media item UUID |
|
||||
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
GET /api/media-items/774641f9-317b-4087-8e04-53bb4392ae56/progress
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
|
||||
```
|
||||
|
||||
## Response (200 OK) — no progress stored
|
||||
|
||||
When the item has no progress row, an empty fixed-layout-style stub is
|
||||
returned (not 404):
|
||||
|
||||
```json
|
||||
{
|
||||
"current_page": 0,
|
||||
"total_pages": null
|
||||
}
|
||||
```
|
||||
|
||||
## Response (200 OK) — progress stored
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "e8239659-0ed6-42ae-a946-8440d7655b42",
|
||||
"media_item_id": "774641f9-317b-4087-8e04-53bb4392ae56",
|
||||
"user_id": "1b64992e-3408-4d84-9e03-e2dc4950e1dd",
|
||||
"current_page": null,
|
||||
"total_pages": null,
|
||||
"last_read_at": "2026-09-26T14:52:34.806446Z",
|
||||
"percentage": 0.045,
|
||||
"character_offset": 16375,
|
||||
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/58/1:598)",
|
||||
"chapter": null,
|
||||
"chapter_progress": null,
|
||||
"format_group": "reflowable",
|
||||
"total_characters": 592216,
|
||||
"chapter_count": 1,
|
||||
"last_sync_device": "web",
|
||||
"last_sync_source": "koreader",
|
||||
"last_sync_timestamp": "2026-09-26T14:52:34.806446Z",
|
||||
"css_selector": "body>div:nth-child(4)>p:nth-child(29)",
|
||||
"anchor_href": "1984.xhtml",
|
||||
"char_offset": 598,
|
||||
"context_text": "from day to day, but there was none in which Goldstein was not the principal figure. He was the prim"
|
||||
}
|
||||
```
|
||||
|
||||
### Field Reference
|
||||
|
||||
Base fields (always present when a row exists):
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| `percentage` | float | Position as a fraction of the whole book (0..1). |
|
||||
| `epubcfi` | string | The stored canonical standard CFI. **Spine steps index the OPF spine as written, including `linear="no"` items — do not resolve them against a readium reading order.** See the [Position Contract](position-contract.md#spine-numbering-hazard--read-this-before-parsing-a-stored-cfi). |
|
||||
| `context_text` | string | The stored verification context (≤100 whitespace-normalized chars from the anchor). |
|
||||
| `character_offset` | int | Book-wide rune offset. Internal currency — consistent with `total_characters`. |
|
||||
| `current_page`, `total_pages` | int | Fixed-layout page position (null for reflowable). |
|
||||
| `chapter`, `chapter_progress` | int, float | Chapter index and within-chapter fraction, when known. |
|
||||
| `format_group` | string | `reflowable`, `fixed_layout`, `comic_archive`, … Gates the restore handles. |
|
||||
| `total_characters`, `chapter_count` | int | Book metrics, for client-side fraction math. |
|
||||
| `last_sync_device`, `last_sync_source`, `last_sync_timestamp` | — | Which client last wrote the row. |
|
||||
|
||||
Restore handles (conditional — served only for convertible reflowable
|
||||
books whose stored anchor re-resolves at GET time):
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| `anchor_href` | string | The spine document containing the anchor (e.g. `1984.xhtml`). **Resolve your resource by this**, never by the CFI's spine step. |
|
||||
| `css_selector` | string | Body-relative chain to the anchor's block element. |
|
||||
| `char_offset` | int | Anchor offset within the block's concatenated text, in UTF-16 code units. |
|
||||
| `epubcfi` | string | Re-served (possibly healed) canonical CFI — present whenever re-verification produced one. |
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 400 | Invalid media item id |
|
||||
| 401 | Invalid or expired token |
|
||||
| 500 | Database error |
|
||||
@@ -1,52 +0,0 @@
|
||||
# Get Reading Progress
|
||||
|
||||
Retrieve reading progress for a specific media item.
|
||||
|
||||
**Endpoint**: `GET /api/media-items/{media_id}/progress`
|
||||
**Auth**: Required
|
||||
|
||||
## Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------ | -------- | --------------- |
|
||||
| media_id | string | Yes | Media item UUID |
|
||||
|
||||
## Request Headers
|
||||
|
||||
| Header | Type | Required | Description |
|
||||
| ------------- | ------ | -------- | ------------ |
|
||||
| Authorization | string | Yes | Bearer token |
|
||||
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
GET /api/media-items/uuid/progress
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"media_item_id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"current_page": 45,
|
||||
"total_pages": 200,
|
||||
"percentage": 0.225,
|
||||
"character_offset": 15432,
|
||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
||||
"chapter": 3,
|
||||
"chapter_progress": 0.5,
|
||||
"last_read_at": "2026-01-31T10:00:00Z",
|
||||
"format_group": "reflowable",
|
||||
"viewport_y": 0.12,
|
||||
"zoom_level": 1.0
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ------------------------ |
|
||||
| 401 | Invalid or expired token |
|
||||
| 404 | Media item not found |
|
||||
@@ -1,50 +0,0 @@
|
||||
# Get Universal Progress
|
||||
|
||||
Get universal (device-agnostic) reading progress for a media item.
|
||||
|
||||
**Endpoint**: `GET /api/progress/:id`
|
||||
**Auth**: Required
|
||||
|
||||
## Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------------- | -------- | --------------- |
|
||||
| id | string (UUID) | Yes | Media item UUID |
|
||||
|
||||
## Request Headers
|
||||
|
||||
| Header | Type | Required | Description |
|
||||
| ------------- | ------ | -------- | ------------ |
|
||||
| Authorization | string | Yes | Bearer token |
|
||||
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
GET /api/progress/550e8400-e29b-41d4-a716-446655440000
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"media_item_id": "uuid",
|
||||
"percentage": 75.5,
|
||||
"position": 1234,
|
||||
"page": 150,
|
||||
"total_pages": 200,
|
||||
"finished": false,
|
||||
"updated_at": "2026-02-08T10:00:00Z",
|
||||
"device": {
|
||||
"id": "device-uuid",
|
||||
"name": "My Kobo"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ------------------------ |
|
||||
| 401 | Invalid or expired token |
|
||||
| 404 | Media item not found |
|
||||
@@ -0,0 +1,135 @@
|
||||
# Position Contract
|
||||
|
||||
How reading positions are represented, submitted, verified, and restored
|
||||
across clients. This is the contract every client speaks — readium-based
|
||||
apps, web (foliate), KOReader, and Kobo devices alike.
|
||||
|
||||
**The server is the position authority.** Every submission is
|
||||
independently verified against the book itself before it is stored; a
|
||||
client whose locator math is wrong cannot poison the stored position.
|
||||
In exchange, the server hands back handles clients can apply directly,
|
||||
so they never have to parse or trust CFIs themselves.
|
||||
|
||||
## The three-tier submission
|
||||
|
||||
Clients submit what their renderer can reliably observe. All three tiers
|
||||
are accepted on `PUT /api/media-items/:id/progress` (and on the KOReader
|
||||
device endpoints):
|
||||
|
||||
| Tier | Field | Currency | Notes |
|
||||
| ------ | -------------- | ----------------------------------------------------- | ----- |
|
||||
| 1 | `percentage` | Float 0..1 of the whole book | Universal. The only field every client can supply. |
|
||||
| 2 | `context_text` | Up to 100 chars, whitespace-normalized, starting at the anchor | The verification anchor. The server extracts the text at the submitted structural anchor and compares. |
|
||||
| 3 | `epubcfi` | Standard wrapped CFI, terminals in UTF-16 code units | Optional structural anchor. KOReader submits a CRE xpointer and the server converts it. |
|
||||
|
||||
Tier 2 is what makes the system self-correcting: percentages alone
|
||||
cannot distinguish "the reader's locator is right" from "the reader
|
||||
silently reported wherever it is currently scrolled".
|
||||
|
||||
## Verification and healing (ingest side)
|
||||
|
||||
On every progress save for a convertible reflowable book that carries a
|
||||
`context_text`, the server:
|
||||
|
||||
1. Parses the submitted `epubcfi` (if any) and resolves it against the
|
||||
book's own XHTML.
|
||||
2. Extracts the text at the resolved anchor and cross-checks it with the
|
||||
submitted `context_text`.
|
||||
3. On a mismatch — or an unresolvable anchor, or no anchor at all —
|
||||
heals the position by text search, using the submitted `percentage`
|
||||
to disambiguate repeated phrases. The healed CFI and a recomputed
|
||||
percentage are stored in place of the submitted ones.
|
||||
4. Refreshes the book-wide `character_offset` column from the verified
|
||||
anchor on every verified save, so it never goes stale behind the
|
||||
anchor.
|
||||
|
||||
If verification fails outright (book file unreadable, context not found
|
||||
and percentage cannot disambiguate), the error is logged and the
|
||||
submission is stored as-is — verification never rejects a save, it only
|
||||
corrects.
|
||||
|
||||
## The restore handles
|
||||
|
||||
`GET /api/media-items/:id/progress` serves, alongside the raw stored
|
||||
fields, the server-derived handles for reflowable books with a
|
||||
resolvable anchor:
|
||||
|
||||
| Field | Currency | Meaning |
|
||||
| ------------- | ----------------------- | ------- |
|
||||
| `anchor_href` | — | The spine document the anchor lives in (e.g. `1984.xhtml`). **This is how a client finds the right resource.** |
|
||||
| `css_selector`| — | Body-relative `tag:nth-child(k)` chain of the anchor's block element (e.g. `body>div:nth-child(4)>p:nth-child(29)`). |
|
||||
| `char_offset` | UTF-16 code units | Offset of the anchor within the concatenated text of that block. |
|
||||
| `epubcfi` | UTF-16 terminals | The stored canonical CFI — re-served healed if the GET-time re-verification improved it. |
|
||||
| `context_text`| — | The stored verification context (≤100 normalized chars from the anchor). |
|
||||
|
||||
**Recommended restore sequence for a client:**
|
||||
|
||||
1. Open the book and jump to the stored `percentage` (coarse floor).
|
||||
2. Resolve `anchor_href` against your own spine (an ends-with match on
|
||||
document hrefs) and open that document if you are not already there.
|
||||
3. Query `css_selector` in that document, walk its text nodes counting
|
||||
UTF-16 units to `char_offset`, and scroll that position into view.
|
||||
4. If the anchor cannot be measured (renderer-specific laziness), the
|
||||
percentage floor stands.
|
||||
|
||||
## SPINE NUMBERING HAZARD — read this before parsing a stored CFI
|
||||
|
||||
**`epubcfi` spine steps index the OPF spine AS WRITTEN, including
|
||||
`linear="no"` items.** Several rendering engines (notably readium)
|
||||
number their reading order EXCLUDING `linear="no"` items. When a book's
|
||||
cover (or any other item) is `linear="no"`, the two numberings differ by
|
||||
a constant offset from that item onward — a client resolving a stored
|
||||
CFI's spine step against its own numbering lands in the WRONG DOCUMENT.
|
||||
|
||||
This is not hypothetical: "1984" epubs commonly have a `linear="no"`
|
||||
cover, which makes readium spine 0 = OPF spine 1. The server heals such
|
||||
numbering mismatches at ingest (that is what the context check is for),
|
||||
but the durable rule for client authors is:
|
||||
|
||||
> **Never resolve a stored CFI's spine step yourself.** Resolve the
|
||||
> document by `anchor_href`, then land with `css_selector` +
|
||||
> `char_offset`. Treat the CFI as opaque server currency.
|
||||
|
||||
## Offset currencies
|
||||
|
||||
Two counting systems are in play, and they are deliberately kept apart:
|
||||
|
||||
| Quantity | Currency | Why |
|
||||
| -------- | -------- | --- |
|
||||
| CFI terminal offsets (`…/1:456`) | UTF-16 code units | The EPUB CFI spec, and what every client observes (JavaScript `.length`). |
|
||||
| `char_offset` (block-relative handle) | UTF-16 code units | Same reason — clients walk DOM text with JS semantics. |
|
||||
| KOReader CRE `text().N` offsets | UTF-16 code units | crengine is UCS-16 internally. |
|
||||
| `character_offset` (book-wide column) | Unicode runes | Internal, consistent with `total_characters` and the percentage derivations. |
|
||||
|
||||
For all-BMP text the two currencies are identical. They diverge on
|
||||
astral-plane characters (emoji, rare CJK ideographs): one rune, two
|
||||
UTF-16 units. The server converts at every wire boundary; internal
|
||||
arithmetic never crosses.
|
||||
|
||||
## `context_text` rules
|
||||
|
||||
- Starts at the anchor position (it may begin mid-word).
|
||||
- Whitespace-normalized (all runs of whitespace collapse to single
|
||||
spaces).
|
||||
- At most 100 characters.
|
||||
- Comparison is containment-based (client and server suffixes of the
|
||||
same block verify in either direction); contexts shorter than 12
|
||||
chars never match.
|
||||
|
||||
## Engine-specific notes
|
||||
|
||||
- **readium-based clients** (the Android app): submit
|
||||
percentage + `context_text` + a CFI generated from the laid-out
|
||||
WebView. Their locally-generated CFI spine steps use readium
|
||||
numbering — the server heals the difference; nothing to do.
|
||||
- **Web (foliate)**: submit all three tiers; foliate CFIs are the same
|
||||
currency the server stores.
|
||||
- **KOReader**: submits a CRE xpointer in the `epubcfi` field of the
|
||||
device payload (historical field name; it is a CRE xpointer, not a
|
||||
CFI). The server converts CRE → canonical at ingest and canonical →
|
||||
CRE on pull (`koreader_xpointer` in the metadata response).
|
||||
- **Kobo**: submits kepub CFI locators, converted server-side the same
|
||||
way.
|
||||
- **Fixed-layout content** (PDF/CBZ): the page index is the canonical
|
||||
locator; CFI/xpointer are meaningless and neither submitted nor
|
||||
served.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Update Media Item Progress
|
||||
|
||||
Submit reading progress for a media item. This is the position-authority
|
||||
ingest point: the server independently verifies the submission against
|
||||
the book and heals it when the client's projection is wrong.
|
||||
|
||||
**Endpoint**: `PUT /api/media-items/:id/progress`
|
||||
**Auth**: Required (Bearer token)
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
See the [Position Contract](position-contract.md) for the three-tier
|
||||
submission model, the verification/healing semantics, and the offset
|
||||
currencies.
|
||||
|
||||
## Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------------- | -------- | ---------------- |
|
||||
| id | string (UUID) | Yes | Media item UUID |
|
||||
|
||||
## Request Body
|
||||
|
||||
All fields are optional; submit what your renderer can observe.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| `percentage` | float | Position as a fraction of the whole book (0..1). Tier 1 — the universal field. |
|
||||
| `context_text` | string | Up to 100 whitespace-normalized chars starting at the anchor. Tier 2 — enables verification and healing. |
|
||||
| `epubcfi` | string | Standard wrapped CFI (UTF-16 terminals). Tier 3 — the structural anchor. Note: the server re-derives the stored canonical CFI; a client's own spine numbering is healed if it disagrees with the OPF spine. |
|
||||
| `character_offset` | int | Book-wide rune offset. Accepted but recomputed server-side from the verified anchor on every verified save. |
|
||||
| `current_page`, `total_pages` | int | Fixed-layout position. For fixed-layout formats the page index is the canonical locator. |
|
||||
| `chapter`, `chapter_progress` | int, float | Chapter index and within-chapter fraction. |
|
||||
| `reading_mode` | string | `paged` or `scrolled` (fixed-layout reader state). |
|
||||
| `zoom_level`, `scroll_position_x`, `scroll_position_y` | float | Fixed-layout viewport state. |
|
||||
|
||||
### Example Request (reflowable, all three tiers)
|
||||
|
||||
```http
|
||||
PUT /api/media-items/774641f9-317b-4087-8e04-53bb4392ae56/progress
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"percentage": 0.0415,
|
||||
"context_text": "was at war with one of these Powers it was generally at peace with the other. But what was strange w",
|
||||
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/62/1:456)"
|
||||
}
|
||||
```
|
||||
|
||||
### Example Request (fixed-layout)
|
||||
|
||||
```json
|
||||
{
|
||||
"percentage": 0.15,
|
||||
"current_page": 30,
|
||||
"total_pages": 194,
|
||||
"reading_mode": "paged"
|
||||
}
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- The submission is verified against the book's XHTML when
|
||||
`context_text` is present and the book is convertible reflowable: the
|
||||
CFI is resolved, the text at the anchor is compared with
|
||||
`context_text`, and mismatches heal by text search (percentage
|
||||
disambiguates repeats). See [Position Contract](position-contract.md).
|
||||
- **Anti-clobber guard**: a submission with `percentage < 0.005` is
|
||||
ignored with `{"status": "ignored"}` when the stored row already holds
|
||||
a percentage above `0.01` — re-opening a book at its first page does
|
||||
not wipe real progress.
|
||||
- The response is the stored row after verification, including the
|
||||
healed `epubcfi` and the refreshed `character_offset` when
|
||||
verification ran.
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
The saved progress row (same shape as
|
||||
[GET](get_media_progress.md), minus the GET-time handles). The
|
||||
`epubcfi` and `percentage` in the response are the server-verified
|
||||
values, which may differ from the submitted ones when healing occurred.
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 400 | Invalid request data |
|
||||
| 401 | Invalid or expired token |
|
||||
| 500 | Database error |
|
||||
@@ -1,68 +0,0 @@
|
||||
# Update Reading Progress
|
||||
|
||||
Update reading progress for a media item. This will sync across all devices via WebSocket.
|
||||
|
||||
**Endpoint**: `PUT /api/media-items/{media_id}/progress`
|
||||
**Auth**: Required
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
## Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------ | -------- | --------------- |
|
||||
| media_id | string | Yes | Media item UUID |
|
||||
|
||||
## Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --------------------------- | ------- | -------- | ------------------------------------------------- |
|
||||
| source | string | Yes | Progress source (e.g., "web", "koreader", "kobo") |
|
||||
| location | object | Yes | Location information |
|
||||
| location.percentage | float | No | Progress percentage (0-1) |
|
||||
| location.epubcfi | string | No | EPUB CFI location |
|
||||
| location.character | integer | No | Character offset |
|
||||
| location.chapter | integer | No | Chapter number |
|
||||
| location.page | integer | No | Current page |
|
||||
| location.total_pages | integer | No | Total pages |
|
||||
| device_metadata | object | No | Device metadata |
|
||||
| device_metadata.device_type | string | No | Device type |
|
||||
| device_metadata.user_agent | string | No | User agent string |
|
||||
|
||||
### Example Request
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "web",
|
||||
"location": {
|
||||
"percentage": 0.45678,
|
||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
||||
"character": 15432,
|
||||
"chapter": 3,
|
||||
"page": 89,
|
||||
"total_pages": 200
|
||||
},
|
||||
"device_metadata": {
|
||||
"device_type": "web",
|
||||
"user_agent": "Mozilla/5.0..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"sync_status": "success",
|
||||
"progress_updated": true,
|
||||
"devices_notified": ["device-1", "device-2"],
|
||||
"broadcast": true
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ------------------------ |
|
||||
| 400 | Invalid location data |
|
||||
| 401 | Invalid or expired token |
|
||||
| 404 | Media item not found |
|
||||
@@ -1,56 +0,0 @@
|
||||
# Update Universal Progress
|
||||
|
||||
Update universal reading progress for a media item.
|
||||
|
||||
**Endpoint**: `POST /api/progress/:id`
|
||||
**Auth**: Required
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
## Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------------- | -------- | --------------- |
|
||||
| id | string (UUID) | Yes | Media item UUID |
|
||||
|
||||
## Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ---------- | ------------- | -------- | ------------------------------------------- |
|
||||
| percentage | float | No | Progress percentage (0-100) |
|
||||
| position | integer | No | Current position in bytes |
|
||||
| page | integer | No | Current page number |
|
||||
| finished | boolean | No | Whether the book is finished |
|
||||
| device_id | string (UUID) | No | Device UUID (optional, for tracking source) |
|
||||
|
||||
### Example Request
|
||||
|
||||
```json
|
||||
{
|
||||
"percentage": 75.5,
|
||||
"position": 1234,
|
||||
"page": 150,
|
||||
"finished": false,
|
||||
"device_id": "device-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"media_item_id": "uuid",
|
||||
"percentage": 75.5,
|
||||
"position": 1234,
|
||||
"page": 150,
|
||||
"finished": false,
|
||||
"updated_at": "2026-02-08T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ------------------------ |
|
||||
| 400 | Invalid request data |
|
||||
| 401 | Invalid or expired token |
|
||||
| 404 | Media item not found |
|
||||
@@ -77,6 +77,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
| **Deploy Bookhoard** | [Operations Portal → Troubleshooting](operations/troubleshooting.md) |
|
||||
| **Contribute code** | [Contributing Portal → Development Guide](contributing/development.md) |
|
||||
| **Understand sync** | [User Portal → Sync Guide](user/sync-guide.md) |
|
||||
| **Read in the browser** | [User Portal → Web Reader Guide](user/reader-guide.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -90,6 +91,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
| ...set up KOReader? | [KOReader Setup Guide](user/devices/koreader-setup.md) |
|
||||
| ...use a Kobo? | [Kobo Setup Guide](user/devices/kobo-setup.md) - native sync coming soon; KOReader works today |
|
||||
| ...understand sync? | [Sync Guide](user/sync-guide.md) |
|
||||
| ...read in the browser? | [Web Reader Guide](user/reader-guide.md) - reading, highlights, notes |
|
||||
| ...resolve conflicts? | [Sync Guide](user/sync-guide.md) - Managing Conflicts |
|
||||
| ...troubleshoot deployment? | [Troubleshooting Guide](operations/troubleshooting.md) |
|
||||
| ...use the API? | [API Reference](developer/api-reference.md) |
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Web Reader Guide
|
||||
|
||||
Bookhoard's built-in web reader works in any modern browser — desktop or
|
||||
mobile — and keeps your position, highlights, bookmarks, and notes in sync
|
||||
with the Android app and KOReader.
|
||||
|
||||
## Opening a Book
|
||||
|
||||
From your library or book page, click **Read**. The reader opens in an
|
||||
immersive view with the book's cover, title, and chapter in the top bar
|
||||
(EPUB) or page number (PDF/comics).
|
||||
|
||||
## Navigation
|
||||
|
||||
### On a phone or tablet (touch)
|
||||
|
||||
| Gesture | Action |
|
||||
|---|---|
|
||||
| **Swipe** left / right | Turn the page |
|
||||
| **Tap** once | Toggle the reader menu (headers, settings, drawers) |
|
||||
| **Tap** in a corner zone | With the menu open: jump back / forward by chapter |
|
||||
|
||||
Paging is swipe-only by design: taps are reserved for the menu so a page
|
||||
turn can never fire while you're trying to select text.
|
||||
|
||||
### On desktop
|
||||
|
||||
- Scroll wheel, trackpad, arrow keys, or **Page Up / Page Down** to page
|
||||
- Click the progress bar (bottom, with the menu open) to jump
|
||||
- Contents drawer for the table of contents; Pages grid for PDF/comics
|
||||
|
||||
## Text Selection & Highlights
|
||||
|
||||
### Selecting text (touch)
|
||||
|
||||
1. **Press and hold** a word (~half a second) until it highlights
|
||||
2. **Drag** to extend the selection word by word — it stays on the
|
||||
visible page
|
||||
3. **Drag the round handles** (EPUB) to fine-tune the start and end
|
||||
points
|
||||
4. Lift your finger and pause — the highlight menu appears **below** the
|
||||
selection, underneath the browser's own copy menu
|
||||
|
||||
The browser's address bar hides while a selection is active to give the
|
||||
page room; it returns as soon as the selection is cleared.
|
||||
|
||||
### Creating a highlight or note
|
||||
|
||||
With the selection menu open:
|
||||
|
||||
- **Color dots** — tap one to highlight immediately in that color
|
||||
- **Pencil** — opens a note; write it and tap **Highlight with note**
|
||||
to save the highlight and its note together
|
||||
- **Copy** — copies the selected text (works even over plain HTTP on
|
||||
your LAN)
|
||||
- While the note editor is open, you can still change the color — the
|
||||
popover stays put until you save or cancel
|
||||
|
||||
### Editing or removing a highlight
|
||||
|
||||
Tap anywhere on a painted highlight to reopen it in the menu. From there
|
||||
you can change its color, edit the note (pencil → **Save note**), or
|
||||
delete it (trash icon). Tapping anywhere else closes the menu.
|
||||
|
||||
### PDFs and comics
|
||||
|
||||
Text selection works the same way on PDFs. Highlights are stored as
|
||||
page-anchored rectangles and sync to the Android app. Comics/manga
|
||||
libraries disable annotation (bookmarks still work).
|
||||
|
||||
## Notes & Bookmarks
|
||||
|
||||
The **annotations drawer** (right side, from the open menu) lists your
|
||||
highlights, standalone notes, and bookmarks for the book — tap any entry
|
||||
to jump to it.
|
||||
|
||||
## Where Reading Position Is Stored
|
||||
|
||||
Position, highlights, bookmarks, and notes sync automatically through the
|
||||
server (see the [Sync Guide](sync-guide.md)). Close the tab whenever you
|
||||
like — the reader resumes exactly where you left off on any device.
|
||||
@@ -588,4 +588,4 @@ A: Yes, HTTPS/TLS 1.3 for all sync traffic.
|
||||
|
||||
**Last Updated**: August 2026
|
||||
**Version**: 1.0
|
||||
**License**: GPL-3.0
|
||||
**License**: AGPL-3.0
|
||||
|
||||
@@ -36,6 +36,14 @@ Learn how to configure your e-reader devices to sync with Bookhoard:
|
||||
- Troubleshooting
|
||||
- Best practices
|
||||
|
||||
## 📖 Reading
|
||||
|
||||
- **[Web Reader Guide](reader-guide.md)** - Reading and annotating in the browser
|
||||
- Touch navigation (swipe to page, tap for menu)
|
||||
- Selecting text: long-press, drag, handles
|
||||
- Highlights, notes, copy, and editing
|
||||
- PDFs and comics
|
||||
|
||||
## 🎨 Frontend Guide
|
||||
|
||||
**[Frontend Guide](frontend-guide.md)** - Learn how to use the Bookhoard web interface
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
github.com/ArcadiaLin/go-epub v0.1.1 h1:13roe62tarrZ1Y1QTxE+Bzd/NKlChhRzegLVmU5Hgws=
|
||||
github.com/ArcadiaLin/go-epub v0.1.1/go.mod h1:GY09AG6jnEbsYytkw6VeICLOt25F1GQDGXjYS7cxNhU=
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
|
||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
||||
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
|
||||
@@ -12,8 +14,13 @@ github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU
|
||||
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
|
||||
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
|
||||
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
|
||||
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -22,6 +29,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8Yc
|
||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
@@ -72,16 +81,23 @@ github.com/labstack/echo/v5 v5.1.0 h1:MvIRydoN+p9cx/zq8Lff6YXqUW2ZaEsOMISzEGSMrB
|
||||
github.com/labstack/echo/v5 v5.1.0/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
|
||||
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
|
||||
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
|
||||
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
||||
github.com/pdfcpu/pdfcpu v0.11.1 h1:htHBSkGH5jMKWC6e0sihBFbcKZ8vG1M67c8/dJxhjas=
|
||||
github.com/pdfcpu/pdfcpu v0.11.1/go.mod h1:pP3aGga7pRvwFWAm9WwFvo+V68DfANi9kxSQYioNYcw=
|
||||
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
|
||||
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -91,6 +107,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po=
|
||||
github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
@@ -98,6 +116,8 @@ github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -118,16 +138,22 @@ golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -84,3 +84,14 @@ func getEnvInt(key string, defaultValue int) int {
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// ArchiveRetentionDays returns how many days a media item stays archived
|
||||
// (file missing from disk for two consecutive scans) before library scans
|
||||
// purge it for good. Reading progress, notes, and highlights survive the
|
||||
// archive window and are restored if the file returns; the purge deletes
|
||||
// them along with the row.
|
||||
// Configure via ARCHIVE_RETENTION_DAYS (default 90); 0 keeps archived items
|
||||
// until an admin purges them manually from the library admin page.
|
||||
func ArchiveRetentionDays() int {
|
||||
return getEnvInt("ARCHIVE_RETENTION_DAYS", 90)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package database
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package database
|
||||
|
||||
@@ -185,6 +185,7 @@ type MediaBookmarks struct {
|
||||
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
|
||||
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||
OriginSource pgtype.Text `db:"origin_source" json:"origin_source"`
|
||||
}
|
||||
|
||||
type MediaHighlights struct {
|
||||
@@ -305,6 +306,9 @@ type MediaItems struct {
|
||||
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
||||
// Summary from ComicInfo.xml (may be merged with description from Calibre)
|
||||
Summary pgtype.Text `db:"summary" json:"summary"`
|
||||
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
|
||||
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
|
||||
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
|
||||
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
||||
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
||||
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package database
|
||||
|
||||
@@ -20,6 +20,9 @@ type Querier interface {
|
||||
AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error)
|
||||
// Library Folders queries
|
||||
AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error)
|
||||
// Second consecutive missing scan: hide the item from all browsing while
|
||||
// preserving reading history in case the file returns.
|
||||
ArchiveMediaItem(ctx context.Context, id pgtype.UUID) error
|
||||
// Bulk update format group for all media items
|
||||
BulkUpdateFormatGroups(ctx context.Context) error
|
||||
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
|
||||
@@ -30,7 +33,14 @@ type Querier interface {
|
||||
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
|
||||
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
|
||||
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
|
||||
// File is back on disk (by path or content hash): restore visibility and
|
||||
// reset the missing-scan counter. No-op for items that were never archived.
|
||||
ClearMediaItemArchive(ctx context.Context, id pgtype.UUID) error
|
||||
// Reset an item to scanned defaults: clears per-field user overrides so the
|
||||
// next rescan can freely overwrite user-customized metadata.
|
||||
ClearMediaItemMetadataOverrides(ctx context.Context, id pgtype.UUID) error
|
||||
CountAdmins(ctx context.Context) (int64, error)
|
||||
CountArchivedMediaItems(ctx context.Context) (int64, error)
|
||||
// Count unlinked books for a device
|
||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||
@@ -249,6 +259,12 @@ type Querier interface {
|
||||
GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error)
|
||||
// Per-item user-data counts, used when choosing which duplicate copy to keep
|
||||
GetMediaItemUsageCounts(ctx context.Context, mediaItemID pgtype.UUID) (GetMediaItemUsageCountsRow, error)
|
||||
// Cross-library move detection: identical content registered in a different
|
||||
// library of the same type (ebooks stay with ebooks, comics with comics).
|
||||
// Candidates may be active copies (deliberate duplicates - leave alone) or
|
||||
// hidden/archived rows whose file vanished (move candidates - the scanner
|
||||
// verifies disk state before repointing).
|
||||
GetMediaItemsBySHA256AndSameLibraryType(ctx context.Context, arg GetMediaItemsBySHA256AndSameLibraryTypeParams) ([]GetMediaItemsBySHA256AndSameLibraryTypeRow, error)
|
||||
GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error)
|
||||
// ============================================
|
||||
// ANNOTATION SYNC QUERIES (notes)
|
||||
@@ -337,9 +353,17 @@ type Querier interface {
|
||||
ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error)
|
||||
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
|
||||
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
|
||||
// Items hidden from libraries: either missing from disk (waiting on the
|
||||
// second scan that archives them) or already archived and pending purge.
|
||||
// Feeds the admin archived-items page. match_* columns identify an active
|
||||
// item with identical content (same SHA-256) so the UI can offer merging the
|
||||
// archived row's reading data into its active twin instead of waiting for a
|
||||
// file that will never return.
|
||||
ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaItemsRow, error)
|
||||
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
||||
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
|
||||
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
|
||||
ListMediaItemsByLibraryIncludingArchived(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryIncludingArchivedRow, error)
|
||||
// List all media items sharing a SHA-256 hash within a library (hash conflict group)
|
||||
ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg ListMediaItemsBySHA256AndLibraryParams) ([]MediaItems, error)
|
||||
// List media items that have no stored SHA-256 (imported before hashing existed)
|
||||
@@ -353,6 +377,22 @@ type Querier interface {
|
||||
// List unresolved unlinked books with pagination
|
||||
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
||||
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
||||
// First consecutive scan that cannot find the file on disk.
|
||||
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
|
||||
// Content reappeared at a new location while the old path vanished: treat as
|
||||
// a move. Repoints the row (and refreshes file-level fields) so reading
|
||||
// history follows the book; archive/missing state is cleared separately.
|
||||
MoveMediaItemFilePath(ctx context.Context, arg MoveMediaItemFilePathParams) error
|
||||
// Cross-library move: identical content reappeared in a different library of
|
||||
// the same type while the old file vanished. Repoints the row so reading
|
||||
// history, annotations and collections follow the book; archive/missing
|
||||
// state is cleared. library_type_name is refreshed by trigger.
|
||||
MoveMediaItemToLibrary(ctx context.Context, arg MoveMediaItemToLibraryParams) error
|
||||
// Manual bulk purge from the library admin page.
|
||||
PurgeAllArchivedMediaItems(ctx context.Context) ([]PurgeAllArchivedMediaItemsRow, error)
|
||||
// Retention sweep at library-scan time: hard-delete archived items older
|
||||
// than the cutoff. Cascades remove reading history with the row.
|
||||
PurgeExpiredArchivedMediaItems(ctx context.Context, archivedAt pgtype.Timestamptz) ([]PurgeExpiredArchivedMediaItemsRow, error)
|
||||
PurgeExpiredBookmarkTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||
PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||
PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -141,7 +141,9 @@ ORDER BY l.created_at ASC;
|
||||
SELECT l.id, COUNT(mi.id) as media_count
|
||||
FROM libraries l
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||
LEFT JOIN media_items mi ON mi.library_id = l.id
|
||||
LEFT JOIN media_items mi ON mi.library_id = l.id AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.missing_scan_count = 0
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
GROUP BY l.id;
|
||||
|
||||
@@ -160,6 +162,8 @@ SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
WHERE mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: ListMediaItemsByLibrary :many
|
||||
@@ -167,6 +171,16 @@ SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
WHERE mi.library_id = $1
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY mi.created_at DESC;
|
||||
|
||||
-- name: ListMediaItemsByLibraryIncludingArchived :many
|
||||
SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
WHERE mi.library_id = $1
|
||||
ORDER BY mi.created_at DESC;
|
||||
|
||||
@@ -175,7 +189,9 @@ SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
WHERE mi.library_id = sqlc.narg('library_id')
|
||||
WHERE mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title
|
||||
@@ -282,10 +298,104 @@ UPDATE media_items SET
|
||||
alternate_info = $35,
|
||||
scan_information = $36,
|
||||
summary = $37,
|
||||
metadata_overrides = $38,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: ClearMediaItemMetadataOverrides :exec
|
||||
-- Reset an item to scanned defaults: clears per-field user overrides so the
|
||||
-- next rescan can freely overwrite user-customized metadata.
|
||||
UPDATE media_items SET metadata_overrides = '{}', updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: MarkMediaItemMissing :exec
|
||||
-- First consecutive scan that cannot find the file on disk.
|
||||
UPDATE media_items SET missing_scan_count = missing_scan_count + 1, updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: MoveMediaItemFilePath :exec
|
||||
-- Content reappeared at a new location while the old path vanished: treat as
|
||||
-- a move. Repoints the row (and refreshes file-level fields) so reading
|
||||
-- history follows the book; archive/missing state is cleared separately.
|
||||
UPDATE media_items SET file_path = $2, file_size = $3, missing_scan_count = 0,
|
||||
archived_at = NULL, updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: ArchiveMediaItem :exec
|
||||
-- Second consecutive missing scan: hide the item from all browsing while
|
||||
-- preserving reading history in case the file returns.
|
||||
UPDATE media_items SET archived_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: MoveMediaItemToLibrary :exec
|
||||
-- Cross-library move: identical content reappeared in a different library of
|
||||
-- the same type while the old file vanished. Repoints the row so reading
|
||||
-- history, annotations and collections follow the book; archive/missing
|
||||
-- state is cleared. library_type_name is refreshed by trigger.
|
||||
UPDATE media_items
|
||||
SET library_id = $2,
|
||||
file_path = $3,
|
||||
file_size = $4,
|
||||
archived_at = NULL,
|
||||
missing_scan_count = 0,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: ClearMediaItemArchive :exec
|
||||
-- File is back on disk (by path or content hash): restore visibility and
|
||||
-- reset the missing-scan counter. No-op for items that were never archived.
|
||||
UPDATE media_items SET archived_at = NULL, missing_scan_count = 0, updated_at = NOW()
|
||||
WHERE id = $1 AND (archived_at IS NOT NULL OR missing_scan_count > 0);
|
||||
|
||||
-- name: PurgeExpiredArchivedMediaItems :many
|
||||
-- Retention sweep at library-scan time: hard-delete archived items older
|
||||
-- than the cutoff. Cascades remove reading history with the row.
|
||||
DELETE FROM media_items
|
||||
WHERE archived_at IS NOT NULL AND archived_at < $1
|
||||
RETURNING id, title;
|
||||
|
||||
-- name: PurgeAllArchivedMediaItems :many
|
||||
-- Manual bulk purge from the library admin page.
|
||||
DELETE FROM media_items
|
||||
WHERE archived_at IS NOT NULL
|
||||
RETURNING id, title;
|
||||
|
||||
-- name: CountArchivedMediaItems :one
|
||||
SELECT COUNT(*) FROM media_items WHERE archived_at IS NOT NULL;
|
||||
|
||||
-- name: ListHiddenMediaItems :many
|
||||
-- Items hidden from libraries: either missing from disk (waiting on the
|
||||
-- second scan that archives them) or already archived and pending purge.
|
||||
-- Feeds the admin archived-items page. match_* columns identify an active
|
||||
-- item with identical content (same SHA-256) so the UI can offer merging the
|
||||
-- archived row's reading data into its active twin instead of waiting for a
|
||||
-- file that will never return.
|
||||
SELECT mi.*, l.name as library_name, lt.name as library_type_name,
|
||||
twin.id AS match_id, COALESCE(twin.title, '') AS match_title,
|
||||
twin_library.name AS match_library_name
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT t.id, t.title, t.library_id
|
||||
FROM media_items t
|
||||
JOIN libraries tl ON t.library_id = tl.id
|
||||
JOIN library_types tlt ON tl.library_type_id = tlt.id
|
||||
WHERE t.file_sha256 = mi.file_sha256
|
||||
AND t.id <> mi.id
|
||||
AND t.archived_at IS NULL
|
||||
AND t.missing_scan_count = 0
|
||||
ORDER BY (tlt.id = lt.id) DESC,
|
||||
(tl.id = l.id) DESC,
|
||||
(SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = t.id) DESC,
|
||||
t.created_at ASC
|
||||
LIMIT 1
|
||||
) twin ON true
|
||||
LEFT JOIN libraries twin_library ON twin_library.id = twin.library_id
|
||||
WHERE mi.archived_at IS NOT NULL OR mi.missing_scan_count > 0
|
||||
ORDER BY mi.archived_at NULLS LAST, mi.updated_at DESC;
|
||||
|
||||
-- name: DeleteMediaItem :exec
|
||||
DELETE FROM media_items WHERE id = $1;
|
||||
|
||||
@@ -434,6 +544,8 @@ JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND (
|
||||
mi.title ILIKE sqlc.narg('search_pattern') OR
|
||||
@@ -506,6 +618,8 @@ JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL
|
||||
OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
-- Fuzzy author filter
|
||||
@@ -639,6 +753,8 @@ FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3
|
||||
AND mi.author IS NOT NULL
|
||||
@@ -656,6 +772,8 @@ FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, '')) > 0.3
|
||||
AND mi.genre IS NOT NULL
|
||||
@@ -690,6 +808,8 @@ FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3
|
||||
AND mi.series IS NOT NULL
|
||||
@@ -707,6 +827,8 @@ FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, '')) > 0.3
|
||||
AND mi.language IS NOT NULL
|
||||
@@ -898,9 +1020,9 @@ INSERT INTO media_bookmarks (
|
||||
cfi_position, title, position, notes,
|
||||
percentage_location, epubcfi_location, chapter_reference,
|
||||
dedup_key, last_modified_at, last_modified_source,
|
||||
device_sync_data
|
||||
device_sync_data, origin_source
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
|
||||
) RETURNING *;
|
||||
|
||||
-- name: UpdateMediaBookmarkForSync :one
|
||||
@@ -1811,6 +1933,24 @@ SELECT * FROM media_items WHERE file_sha256 = $1;
|
||||
-- name: GetMediaItemBySHA256AndLibrary :one
|
||||
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2;
|
||||
|
||||
-- name: GetMediaItemsBySHA256AndSameLibraryType :many
|
||||
-- Cross-library move detection: identical content registered in a different
|
||||
-- library of the same type (ebooks stay with ebooks, comics with comics).
|
||||
-- Candidates may be active copies (deliberate duplicates - leave alone) or
|
||||
-- hidden/archived rows whose file vanished (move candidates - the scanner
|
||||
-- verifies disk state before repointing).
|
||||
SELECT mi.*, lt.name AS library_type_name
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
JOIN libraries target_lib ON target_lib.id = sqlc.arg('library_id')
|
||||
WHERE mi.file_sha256 = sqlc.arg('file_sha256')
|
||||
AND mi.library_id <> sqlc.arg('library_id')
|
||||
AND lt.id = target_lib.library_type_id
|
||||
ORDER BY (mi.archived_at IS NOT NULL) DESC,
|
||||
mi.missing_scan_count DESC,
|
||||
mi.created_at ASC;
|
||||
|
||||
-- List all media items sharing a SHA-256 hash within a library (hash conflict group)
|
||||
-- name: ListMediaItemsBySHA256AndLibrary :many
|
||||
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path;
|
||||
@@ -2056,6 +2196,8 @@ SELECT ci.*, mi.title, mi.author, mi.cover_image_path, mi.library_id
|
||||
FROM collection_items ci
|
||||
JOIN media_items mi ON ci.media_item_id = mi.id
|
||||
WHERE ci.collection_id = $1
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY ci.added_at DESC;
|
||||
|
||||
-- Get collections for book
|
||||
@@ -2362,12 +2504,16 @@ INNER JOIN (
|
||||
ORDER BY media_item_id, last_read_at DESC
|
||||
) rp ON rp.media_item_id = mi.id
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY rp.last_read_at DESC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: GetRecentlyAddedItems :many
|
||||
SELECT mi.* FROM media_items mi
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
@@ -2381,12 +2527,16 @@ INNER JOIN (
|
||||
ORDER BY media_item_id, last_read_at DESC
|
||||
) rp ON rp.media_item_id = mi.id
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY rp.last_read_at DESC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: GetNotStartedItems :many
|
||||
SELECT mi.* FROM media_items mi
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM reading_progress rp
|
||||
WHERE rp.media_item_id = mi.id
|
||||
@@ -2401,12 +2551,16 @@ SELECT mi.*, ci.excluded FROM media_items mi
|
||||
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
||||
WHERE ci.collection_id = sqlc.narg('collection_id')
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY ci.added_at DESC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: GetLibraryItems :many
|
||||
SELECT mi.* FROM media_items mi
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY mi.created_at DESC;
|
||||
|
||||
-- name: GetDistinctSeries :many
|
||||
@@ -2454,7 +2608,9 @@ next_books AS (
|
||||
usp.last_read_at
|
||||
FROM media_items mi
|
||||
JOIN user_series_progress usp ON mi.series = usp.series
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
WHERE mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND (mi.series_number > usp.max_read_number OR usp.max_read_number IS NULL)
|
||||
ORDER BY mi.series, mi.series_number ASC NULLS LAST
|
||||
)
|
||||
|
||||
@@ -77,6 +77,12 @@ func parseTableNames() ([]string, error) {
|
||||
tables := make(map[string]bool)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
// Skip SQL comments: a doc line like "-- ... declared in CREATE
|
||||
// TABLE above" would otherwise register a phantom table and fail
|
||||
// startup verification.
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "--") {
|
||||
continue
|
||||
}
|
||||
matches := pattern.FindStringSubmatch(line)
|
||||
if len(matches) > 1 {
|
||||
tableName := matches[1]
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParseTableNamesIgnoresComments guards against phantom tables parsed out
|
||||
// of SQL comments (e.g. "-- ... declared in CREATE TABLE above" once
|
||||
// registered a table named "above" and crashed startup verification).
|
||||
func TestParseTableNamesIgnoresComments(t *testing.T) {
|
||||
tables, err := parseTableNames()
|
||||
if err != nil {
|
||||
t.Fatalf("parseTableNames() error: %v", err)
|
||||
}
|
||||
if len(tables) == 0 {
|
||||
t.Fatal("parseTableNames() returned no tables")
|
||||
}
|
||||
|
||||
for _, name := range tables {
|
||||
// Every parsed table must correspond to a real CREATE TABLE statement
|
||||
// at the start of a (non-comment) line.
|
||||
stmt := regexp.MustCompile(`(?m)^CREATE TABLE (?:IF NOT EXISTS )?(?:\w+\.)?` + regexp.QuoteMeta(name) + `\s`)
|
||||
if !stmt.MatchString(SchemaFile) {
|
||||
t.Errorf("parseTableNames() returned phantom table %q with no matching CREATE TABLE statement", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
-12
@@ -6,11 +6,16 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/skip2/go-qrcode"
|
||||
@@ -106,7 +111,14 @@ type PendingRegistration struct {
|
||||
SyncEndpoints map[string]string
|
||||
}
|
||||
|
||||
var pendingRegistrations = make(map[string]*PendingRegistration)
|
||||
// pendingRegistrations holds in-flight (unapproved) device registrations.
|
||||
// HTTP handlers touch it from multiple goroutines — every access must hold
|
||||
// pendingMu (Go maps are not safe for concurrent use; a racing write is a
|
||||
// runtime fatal, not an error).
|
||||
var (
|
||||
pendingRegistrations = make(map[string]*PendingRegistration)
|
||||
pendingMu sync.Mutex
|
||||
)
|
||||
|
||||
func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error {
|
||||
req := DeviceRegistrationRequest{}
|
||||
@@ -130,7 +142,9 @@ func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error {
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
pendingMu.Lock()
|
||||
pendingRegistrations[registrationID] = registration
|
||||
pendingMu.Unlock()
|
||||
|
||||
authURL := fmt.Sprintf("%s/devices/approve/%s", h.cfg.BaseURL, registrationID)
|
||||
|
||||
@@ -167,26 +181,34 @@ func (h *DeviceHandler) CheckRegistrationStatus(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"})
|
||||
}
|
||||
|
||||
pendingMu.Lock()
|
||||
registration, exists := pendingRegistrations[req.RegistrationID]
|
||||
if !exists {
|
||||
pendingMu.Unlock()
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
||||
}
|
||||
|
||||
if time.Now().After(registration.ExpiresAt) {
|
||||
delete(pendingRegistrations, req.RegistrationID)
|
||||
pendingMu.Unlock()
|
||||
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
||||
}
|
||||
|
||||
if registration.Approved {
|
||||
delete(pendingRegistrations, req.RegistrationID)
|
||||
|
||||
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
||||
// Snapshot under the lock: the approver wrote these fields, and
|
||||
// the row is gone from the map — no other reader/writer remains.
|
||||
resp := DeviceAuthStatusResponse{
|
||||
Status: "approved",
|
||||
AuthToken: registration.AuthToken,
|
||||
DeviceID: registration.DeviceID,
|
||||
SyncEndpoints: registration.SyncEndpoints,
|
||||
})
|
||||
}
|
||||
pendingMu.Unlock()
|
||||
|
||||
return c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
pendingMu.Unlock()
|
||||
|
||||
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
||||
Status: "pending",
|
||||
@@ -552,17 +574,21 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
||||
|
||||
registrationID := c.Param("registration_id")
|
||||
|
||||
pendingMu.Lock()
|
||||
registration, exists := pendingRegistrations[registrationID]
|
||||
if !exists {
|
||||
pendingMu.Unlock()
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
||||
}
|
||||
|
||||
if time.Now().After(registration.ExpiresAt) {
|
||||
delete(pendingRegistrations, registrationID)
|
||||
pendingMu.Unlock()
|
||||
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
||||
}
|
||||
|
||||
if registration.Approved {
|
||||
pendingMu.Unlock()
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"message": "device already approved",
|
||||
"device_name": registration.DeviceName,
|
||||
@@ -570,6 +596,7 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
||||
"approved": true,
|
||||
})
|
||||
}
|
||||
pendingMu.Unlock()
|
||||
|
||||
authToken, err := generateDeviceToken()
|
||||
if err != nil {
|
||||
@@ -577,23 +604,87 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
||||
}
|
||||
|
||||
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
||||
syncEnabled := pgtype.Bool{Bool: true, Valid: true}
|
||||
autoSync := pgtype.Bool{Bool: true, Valid: true}
|
||||
syncFreq := pgtype.Int4{Int32: 5, Valid: true}
|
||||
|
||||
device, err := h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
|
||||
// Re-approval: app reinstalls that preserve data (e.g. Android Studio
|
||||
// installDebug over an existing install) re-register with the SAME
|
||||
// device_identifier, but the devices row from the previous install
|
||||
// still exists — device_identifier is UNIQUE, so a blind INSERT fails
|
||||
// with a unique violation (the historical "approve returns 500 after
|
||||
// reinstall" bug). Look the device up first and rotate its token
|
||||
// instead; a fresh install MUST get fresh credentials, so the old
|
||||
// token is invalidated either way.
|
||||
rotateExisting := func(existing database.Devices) (database.Devices, bool, error) {
|
||||
// The identifier is globally unique; a row owned by another user
|
||||
// means cross-account identifier reuse — refuse it.
|
||||
if !existing.UserID.Valid || existing.UserID.Bytes != pgUserID.Bytes {
|
||||
return existing, false, nil
|
||||
}
|
||||
updated, err := h.db.UpdateDeviceAuthToken(c.Request().Context(),
|
||||
database.UpdateDeviceAuthTokenParams{
|
||||
ID: pgtype.UUID{Bytes: existing.ID.Bytes, Valid: true},
|
||||
AuthToken: authToken,
|
||||
})
|
||||
return updated, true, err
|
||||
}
|
||||
|
||||
existing, err := h.db.GetDeviceByIdentifier(c.Request().Context(),
|
||||
registration.DeviceIdentifier)
|
||||
var device database.Devices
|
||||
switch {
|
||||
case err == nil:
|
||||
// Known device: rotate the token, keep the row (id unchanged —
|
||||
// highlights/bookmarks/progress anchored to it stay valid).
|
||||
var ok bool
|
||||
device, ok, err = rotateExisting(existing)
|
||||
if err != nil {
|
||||
slog.Error("device re-approval failed",
|
||||
"registration_id", registrationID, "error", err)
|
||||
return c.JSON(http.StatusInternalServerError,
|
||||
map[string]string{"error": "failed to approve device"})
|
||||
}
|
||||
if !ok {
|
||||
return c.JSON(http.StatusConflict,
|
||||
map[string]string{"error": "device identifier already registered to another user"})
|
||||
}
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
device, err = h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
|
||||
UserID: pgUserID,
|
||||
DeviceName: registration.DeviceName,
|
||||
DeviceType: registration.DeviceType,
|
||||
DeviceIdentifier: registration.DeviceIdentifier,
|
||||
AuthToken: authToken,
|
||||
SyncEnabled: syncEnabled,
|
||||
AutoSync: autoSync,
|
||||
SyncFrequencyMinutes: syncFreq,
|
||||
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||||
DeviceMetadata: []byte("{}"),
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create device"})
|
||||
// Concurrent approve racing us onto the unique index: fall
|
||||
// through to the rotate path for the winner's row.
|
||||
var pgErr *pgconn.PgError
|
||||
if asErr := errors.As(err, &pgErr); asErr && pgErr.Code == "23505" {
|
||||
if winner, lerr := h.db.GetDeviceByIdentifier(
|
||||
c.Request().Context(), registration.DeviceIdentifier); lerr == nil {
|
||||
var ok bool
|
||||
device, ok, err = rotateExisting(winner)
|
||||
if err == nil && !ok {
|
||||
return c.JSON(http.StatusConflict,
|
||||
map[string]string{"error": "device identifier already registered to another user"})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("device creation failed",
|
||||
"registration_id", registrationID, "error", err)
|
||||
return c.JSON(http.StatusInternalServerError,
|
||||
map[string]string{"error": "failed to create device"})
|
||||
}
|
||||
default:
|
||||
slog.Error("device lookup failed",
|
||||
"registration_id", registrationID, "error", err)
|
||||
return c.JSON(http.StatusInternalServerError,
|
||||
map[string]string{"error": "failed to look up device"})
|
||||
}
|
||||
|
||||
syncEndpoints := map[string]string{}
|
||||
@@ -607,11 +698,16 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
||||
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
|
||||
}
|
||||
|
||||
// Publish the credentials under the map lock: the status poller
|
||||
// snapshots these fields only after Approved flips, so the token can
|
||||
// never be read half-written.
|
||||
pendingMu.Lock()
|
||||
registration.UserID = userUUID
|
||||
registration.Approved = true
|
||||
registration.AuthToken = authToken
|
||||
registration.DeviceID = device.ID.Bytes
|
||||
registration.SyncEndpoints = syncEndpoints
|
||||
pendingMu.Unlock()
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"message": "device approved successfully",
|
||||
@@ -625,12 +721,15 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
||||
func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
|
||||
registrationID := c.Param("registration_id")
|
||||
|
||||
pendingMu.Lock()
|
||||
_, exists := pendingRegistrations[registrationID]
|
||||
if !exists {
|
||||
pendingMu.Unlock()
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
||||
}
|
||||
|
||||
delete(pendingRegistrations, registrationID)
|
||||
pendingMu.Unlock()
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{
|
||||
"message": "device registration rejected",
|
||||
@@ -639,6 +738,7 @@ func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
|
||||
|
||||
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
|
||||
registrations := []map[string]interface{}{}
|
||||
pendingMu.Lock()
|
||||
for _, reg := range pendingRegistrations {
|
||||
if reg.UserID == (uuid.UUID{}) {
|
||||
registrations = append(registrations, map[string]interface{}{
|
||||
@@ -652,6 +752,7 @@ func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[stri
|
||||
})
|
||||
}
|
||||
}
|
||||
pendingMu.Unlock()
|
||||
|
||||
return registrations, nil
|
||||
}
|
||||
@@ -664,6 +765,7 @@ func (h *DeviceHandler) ListPendingRegistrations(c *echo.Context) error {
|
||||
}
|
||||
|
||||
registrations := []map[string]interface{}{}
|
||||
pendingMu.Lock()
|
||||
for _, reg := range pendingRegistrations {
|
||||
if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) {
|
||||
registrations = append(registrations, map[string]interface{}{
|
||||
@@ -677,6 +779,7 @@ func (h *DeviceHandler) ListPendingRegistrations(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
}
|
||||
pendingMu.Unlock()
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"registrations": registrations,
|
||||
|
||||
+168
-105
@@ -50,33 +50,97 @@ func (h *KOReaderHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||
h.annotationSvc = svc
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaItemID pgtype.UUID, pos0, pos1, contextText string) (string, string) {
|
||||
if pos0 == "" || h.libraryService == nil {
|
||||
return "", ""
|
||||
}
|
||||
// annotationEpub carries the per-book context every locator conversion
|
||||
// needs: the media item (format gating) and the resolved EPUB path. It is
|
||||
// resolved once per request so all annotations in a push share one
|
||||
// converter-cache entry instead of re-resolving (and re-parsing the book)
|
||||
// per annotation.
|
||||
type annotationEpub struct {
|
||||
mediaItem *database.MediaItems
|
||||
epubPath string
|
||||
}
|
||||
|
||||
func (ec annotationEpub) convertible() bool {
|
||||
return ec.mediaItem != nil && ec.epubPath != ""
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) loadAnnotationEpub(ctx context.Context, mediaItemID pgtype.UUID) annotationEpub {
|
||||
var ec annotationEpub
|
||||
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
|
||||
if err != nil {
|
||||
return ec
|
||||
}
|
||||
ec.mediaItem = &mediaItem
|
||||
if h.libraryService != nil {
|
||||
if epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath); err == nil && epubPath != "" {
|
||||
ec.epubPath = epubPath
|
||||
}
|
||||
}
|
||||
return ec
|
||||
}
|
||||
|
||||
// convertHighlightPositions resolves a device annotation's pos0/pos1
|
||||
// locators to canonical CFIs through the shared facade. contextText is the
|
||||
// selection's own text — a quote of the document, so the converter can
|
||||
// verify structural landings against it and, when it must search, anchor a
|
||||
// range end that spans block boundaries. percentage anchors the
|
||||
// last-resort fallback. Only structural/exact landings are returned: a
|
||||
// low-confidence conversion yields "" so an echo preserves the row's
|
||||
// existing web CFIs (applyLWW coalesces empty) instead of clobbering them
|
||||
// with a guess, and a new device highlight paints nowhere rather than in
|
||||
// the wrong place.
|
||||
func (h *KOReaderHandler) convertHighlightPositions(ec annotationEpub, pos0, pos1, contextText string, percentage float64) (string, string) {
|
||||
if pos0 == "" || !ec.convertible() {
|
||||
return "", ""
|
||||
}
|
||||
epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath)
|
||||
if err != nil || epubPath == "" {
|
||||
return "", ""
|
||||
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, percentage, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
|
||||
startCFI := webUsableCFI(startLoc)
|
||||
endCFI := ""
|
||||
// A text-search start matched the selection text itself: its extent
|
||||
// is the selection's true end, even across blocks.
|
||||
if startCFI != "" && startLoc.EndCFI != "" {
|
||||
endCFI = startLoc.EndCFI
|
||||
}
|
||||
// The annotation's own text is the ideal anchor for the converter's
|
||||
// text-search path: clients (thin, underpowered) send only raw
|
||||
// locators, the server resolves them against the actual book.
|
||||
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, 0, contextText, mediaItem.FormatGroup, epubPath, "")
|
||||
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, 0, "", mediaItem.FormatGroup, epubPath, "")
|
||||
endCFI := endLoc.CFI
|
||||
// The end conversion carries no context text, so unless it resolved
|
||||
// exactly it degenerates to a percentage fallback anchored at the
|
||||
// document start — useless as a range end. When the START resolved
|
||||
// exactly, derive the end from it: same node, character offset
|
||||
// advanced by the selection's UTF-16 length (the CFI offset unit).
|
||||
if endLoc.Precision != "exact" && startLoc.Precision == "exact" && contextText != "" {
|
||||
endCFI = extendCFIByLength(startLoc.CFI, contextText)
|
||||
if endCFI == "" && pos1 != "" {
|
||||
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, percentage, "", ec.mediaItem.FormatGroup, ec.epubPath, "")
|
||||
endCFI = webUsableCFI(endLoc)
|
||||
}
|
||||
return startLoc.CFI, endCFI
|
||||
// The end conversion carries no context text; when neither resolved
|
||||
// confidently, derive the end from the start advanced by the
|
||||
// selection's UTF-16 length (the CFI offset unit). Multi-node
|
||||
// selections produce an out-of-range offset — harmless: resolution
|
||||
// clamps or fails, and consumers fall back to the start.
|
||||
if endCFI == "" && startCFI != "" && contextText != "" {
|
||||
endCFI = extendCFIByLength(startCFI, contextText)
|
||||
}
|
||||
return startCFI, endCFI
|
||||
}
|
||||
|
||||
// convertBookmarkPosition resolves a device bookmark's locator to the
|
||||
// canonical CFI for the web reader. Structural-only by design: bookmark
|
||||
// text is a display label ("in <chapter>" auto-fill or a user note), never
|
||||
// book text, so no context is supplied and only a structural/exact landing
|
||||
// is trusted — lower rungs would store a confident-looking guess the web
|
||||
// drawer would present as a real destination.
|
||||
func (h *KOReaderHandler) convertBookmarkPosition(ec annotationEpub, position string, percentage float64) string {
|
||||
if position == "" || !ec.convertible() || !wsync.IsCREXPointer(position) {
|
||||
return ""
|
||||
}
|
||||
loc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, position, percentage, "", ec.mediaItem.FormatGroup, ec.epubPath, "")
|
||||
return webUsableCFI(loc)
|
||||
}
|
||||
|
||||
// webUsableCFI keeps only high-confidence conversions: the web reader
|
||||
// navigates bookmarks by CFI, so href/percentage/fallback results are
|
||||
// discarded instead of stored as dead links.
|
||||
func webUsableCFI(loc wsync.CanonicalLocator) string {
|
||||
if loc.Precision != "structural" && loc.Precision != "exact" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(loc.CFI, "epubcfi(") || !strings.HasSuffix(loc.CFI, ")") {
|
||||
return ""
|
||||
}
|
||||
return loc.CFI
|
||||
}
|
||||
|
||||
// extendCFIByLength advances a point CFI's trailing character offset by the
|
||||
@@ -127,22 +191,19 @@ func (h *KOReaderHandler) existingHighlightColor(ctx context.Context, mediaItemI
|
||||
// deriveAnnotationPercentage computes a percentage for device-pushed
|
||||
// annotations when the client didn't send one (thin clients skip their own
|
||||
// per-annotation page lookups; arithmetic is only free on paging documents).
|
||||
func (h *KOReaderHandler) deriveAnnotationPercentage(ctx context.Context, mediaItemID pgtype.UUID, pos0 string, page int) float64 {
|
||||
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
|
||||
if err != nil {
|
||||
func (h *KOReaderHandler) deriveAnnotationPercentage(ec annotationEpub, pos0 string, page int) float64 {
|
||||
if ec.mediaItem == nil {
|
||||
return 0
|
||||
}
|
||||
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup)
|
||||
formatGroup := wsync.FormatGroup(ec.mediaItem.FormatGroup)
|
||||
if formatGroup == wsync.FormatGroupFixedLayout || formatGroup == wsync.FormatGroupComicArchive {
|
||||
if page > 0 && mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 {
|
||||
return float64(page) / float64(mediaItem.PageCount.Int32)
|
||||
if page > 0 && ec.mediaItem.PageCount.Valid && ec.mediaItem.PageCount.Int32 > 0 {
|
||||
return float64(page) / float64(ec.mediaItem.PageCount.Int32)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if wsync.IsCREXPointer(pos0) && h.libraryService != nil {
|
||||
if epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath); err == nil && epubPath != "" {
|
||||
return wsync.NewCFIConverter(epubPath).SectionPercentage(pos0)
|
||||
}
|
||||
if wsync.IsCREXPointer(pos0) && ec.epubPath != "" {
|
||||
return wsync.SectionPercentageCached(ec.epubPath, pos0)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -614,21 +675,23 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
||||
if h.annotationSvc == nil {
|
||||
return
|
||||
}
|
||||
ec := h.loadAnnotationEpub(ctx, mediaItemID)
|
||||
|
||||
for _, hl := range book.Highlights {
|
||||
startPos := hl.Pos0
|
||||
endPos := hl.Pos1
|
||||
// The highlight's own text anchors the conversion exactly.
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos, hl.Text)
|
||||
|
||||
pctStart := 0.0
|
||||
if hl.Percentage != nil {
|
||||
pctStart = *hl.Percentage
|
||||
}
|
||||
if pctStart == 0 {
|
||||
pctStart = h.deriveAnnotationPercentage(ctx, mediaItemID, startPos, int(hl.Page))
|
||||
pctStart = h.deriveAnnotationPercentage(ec, startPos, int(hl.Page))
|
||||
}
|
||||
|
||||
// The highlight's own text anchors the conversion exactly.
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ec, startPos, endPos, hl.Text, pctStart)
|
||||
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": hl.Datetime,
|
||||
"pos0": hl.Pos0,
|
||||
@@ -676,16 +739,17 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
||||
for _, note := range book.Notes {
|
||||
startPos := note.Pos0
|
||||
endPos := note.Pos1
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos, note.Text)
|
||||
|
||||
pctStart := 0.0
|
||||
if note.Percentage != nil {
|
||||
pctStart = *note.Percentage
|
||||
}
|
||||
if pctStart == 0 {
|
||||
pctStart = h.deriveAnnotationPercentage(ctx, mediaItemID, startPos, int(note.Page))
|
||||
pctStart = h.deriveAnnotationPercentage(ec, startPos, int(note.Page))
|
||||
}
|
||||
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ec, startPos, endPos, note.Text, pctStart)
|
||||
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": note.Datetime,
|
||||
"pos0": note.Pos0,
|
||||
@@ -723,6 +787,19 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
||||
position = fmt.Sprintf("page:%d", bookmark.Page)
|
||||
}
|
||||
|
||||
pctLoc := 0.0
|
||||
if bookmark.Percentage != nil {
|
||||
pctLoc = *bookmark.Percentage
|
||||
}
|
||||
if pctLoc == 0 {
|
||||
pctLoc = h.deriveAnnotationPercentage(ec, position, int(bookmark.Page))
|
||||
}
|
||||
|
||||
// Device-native xpointer → canonical CFI so the web drawer can
|
||||
// navigate KOReader-created bookmarks (page-only positions have no
|
||||
// convertible locator; the drawer's page fallback covers those).
|
||||
cfiPosition := h.convertBookmarkPosition(ec, position, pctLoc)
|
||||
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": bookmark.Datetime,
|
||||
"pos0": bookmark.Pos0,
|
||||
@@ -739,8 +816,11 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
||||
UserID: userID,
|
||||
Title: bookmark.Text,
|
||||
Position: position,
|
||||
CFIPosition: cfiPosition,
|
||||
PercentageLoc: pctLoc,
|
||||
ChapterNumber: int32(bookmark.Chapter),
|
||||
Source: "koreader",
|
||||
OriginSource: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
DedupKey: dedupKey,
|
||||
})
|
||||
@@ -783,29 +863,11 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
||||
if h.progressSvc != nil {
|
||||
epubcfi := book.Epubcfi
|
||||
if epubcfi != nil && wsync.IsCREXPointer(*epubcfi) {
|
||||
log.Printf("Bookhoard: CRE→CFI attempting conversion for %s", *epubcfi)
|
||||
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
|
||||
if err != nil {
|
||||
log.Printf("Bookhoard: CRE→CFI failed to get media item: %v", err)
|
||||
} else if mediaItem.FormatGroup == string(wsync.FormatGroupFixedLayout) ||
|
||||
mediaItem.FormatGroup == string(wsync.FormatGroupComicArchive) {
|
||||
// Image-based fixed content (fixed-layout comic EPUBs, PDF,
|
||||
// comic archives) has no extractable text, so CRE→CFI conversion
|
||||
// cannot succeed. The page index (page/total_pages) is the
|
||||
// canonical locator. Keep the incoming xpointer for device-native
|
||||
// restore; the web reader restores by page.
|
||||
log.Printf("Bookhoard: CRE→CFI skipped for %s format", mediaItem.FormatGroup)
|
||||
} else if h.libraryService == nil {
|
||||
log.Printf("Bookhoard: CRE→CFI libraryService is nil, skipping conversion")
|
||||
} else {
|
||||
epubPath, resolveErr := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath)
|
||||
if resolveErr != nil {
|
||||
log.Printf("Bookhoard: CRE→CFI failed to resolve media path: %v", resolveErr)
|
||||
} else if epubPath == "" {
|
||||
log.Printf("Bookhoard: CRE→CFI resolved empty epub path for %s", mediaItem.FilePath)
|
||||
} else {
|
||||
log.Printf("Bookhoard: CRE→CFI resolved epub path: %s", epubPath)
|
||||
converter := wsync.NewCFIConverter(epubPath)
|
||||
// Same facade every annotation uses: cached converter,
|
||||
// structural-first resolution, guarded fallbacks. The facade
|
||||
// passes non-reflowable formats through untouched.
|
||||
ec := h.loadAnnotationEpub(ctx, mediaItemID)
|
||||
if ec.convertible() {
|
||||
pct := 0.0
|
||||
if book.Percentage >= 0 {
|
||||
pct = book.Percentage
|
||||
@@ -814,22 +876,27 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
||||
if book.ContextText != nil {
|
||||
contextText = *book.ContextText
|
||||
}
|
||||
result, convErr := converter.ConvertCREToStandard(*epubcfi, pct, contextText)
|
||||
if convErr != nil {
|
||||
log.Printf("Bookhoard: CRE→CFI conversion error: %v", convErr)
|
||||
} else if result != nil {
|
||||
if result.EPUBCFI != "" {
|
||||
convertedCFI := result.EPUBCFI
|
||||
epubcfi = &convertedCFI
|
||||
log.Printf("Bookhoard: CRE→CFI converted to epubcfi: %s", convertedCFI)
|
||||
} else if result.Href != "" {
|
||||
convertedHref := result.Href
|
||||
epubcfi = &convertedHref
|
||||
log.Printf("Bookhoard: CRE→CFI converted to href: %s", convertedHref)
|
||||
} else {
|
||||
log.Printf("Bookhoard: CRE→CFI conversion: %s precision for %s", result.Precision, *epubcfi)
|
||||
}
|
||||
}
|
||||
loc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, *epubcfi, pct, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
|
||||
switch {
|
||||
case strings.HasPrefix(loc.CFI, "epubcfi(") &&
|
||||
(loc.Precision == "structural" || loc.Precision == "exact"):
|
||||
converted := loc.CFI
|
||||
epubcfi = &converted
|
||||
log.Printf("Bookhoard: CRE→CFI converted progress (%s) to %s", loc.Precision, converted)
|
||||
case loc.CFI != "" && loc.CFI != *epubcfi &&
|
||||
(loc.Precision == "element" || loc.Precision == "section"):
|
||||
// Fragment-ID positions resolve to a section href: keep
|
||||
// serving it (legacy behavior).
|
||||
converted := loc.CFI
|
||||
epubcfi = &converted
|
||||
log.Printf("Bookhoard: CRE→CFI converted progress (%s) to href %s", loc.Precision, converted)
|
||||
default:
|
||||
// Low-confidence (percentage/fallback): store no
|
||||
// canonical locator — the row's percentage restores
|
||||
// approximately instead of a confidently-wrong CFI,
|
||||
// and the device keeps its own native position.
|
||||
log.Printf("Bookhoard: CRE→CFI conversion low-confidence (%s) for %s; storing percentage only", loc.Precision, *epubcfi)
|
||||
epubcfi = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1168,40 +1235,20 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) convertCFIToXPointer(c *echo.Context, mediaItem database.MediaItems, progress database.GetUniversalProgressRow, progressData *KOReaderProgressData) {
|
||||
if h.libraryService == nil {
|
||||
log.Printf("Bookhoard: CFI→CRE libraryService is nil, skipping reverse conversion")
|
||||
return
|
||||
}
|
||||
|
||||
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
||||
if err != nil {
|
||||
log.Printf("Bookhoard: CFI→CRE failed to resolve media path: %v", err)
|
||||
return
|
||||
}
|
||||
if epubPath == "" {
|
||||
log.Printf("Bookhoard: CFI→CRE resolved empty epub path for %s", mediaItem.FilePath)
|
||||
return
|
||||
}
|
||||
|
||||
converter := wsync.NewCFIConverter(epubPath)
|
||||
contextText := ""
|
||||
if progress.ContextText.Valid {
|
||||
contextText = progress.ContextText.String
|
||||
}
|
||||
pct := progress.Percentage.Float64
|
||||
|
||||
result, err := converter.ConvertStandardToCRE(progress.Epubcfi.String, pct, contextText)
|
||||
if err != nil {
|
||||
log.Printf("Bookhoard: CFI→CRE conversion error: %v", err)
|
||||
return
|
||||
}
|
||||
if result != nil && result.XPointer != "" {
|
||||
progressData.KoreaderXPointer = &result.XPointer
|
||||
log.Printf("Bookhoard: CFI→CRE converted to XPointer: %s", result.XPointer)
|
||||
// Same facade path annotations use on serve: structural resolution
|
||||
// first, guarded text search only as fallback, cached converter. The
|
||||
// stored percentage anchors the reverse fallback ladder.
|
||||
if xp := h.reverseConvertCFI(c, mediaItem, progress.Epubcfi.String, contextText, progress.Percentage.Float64); xp != "" {
|
||||
progressData.KoreaderXPointer = &xp
|
||||
log.Printf("Bookhoard: CFI→CRE converted to XPointer: %s", xp)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string, contextText string) string {
|
||||
func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string, contextText string, percentage float64) string {
|
||||
if h.libraryService == nil || epubcfi == "" {
|
||||
return ""
|
||||
}
|
||||
@@ -1209,7 +1256,7 @@ func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.
|
||||
if err != nil || epubPath == "" {
|
||||
return ""
|
||||
}
|
||||
loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, 0, contextText, mediaItem.FormatGroup, epubPath, "")
|
||||
loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, percentage, contextText, mediaItem.FormatGroup, epubPath, "")
|
||||
if loc.Position != "" && loc.Position != epubcfi {
|
||||
return loc.Position
|
||||
}
|
||||
@@ -1316,7 +1363,7 @@ func (h *KOReaderHandler) koreaderPos0(c *echo.Context, mediaItem database.Media
|
||||
cfi = strings.TrimPrefix(startPosition, "cfi:")
|
||||
}
|
||||
if cfi != "" && wsync.IsStandardEPUBCFI(cfi) {
|
||||
if converted := h.reverseConvertCFI(c, mediaItem, cfi, contextText); converted != "" {
|
||||
if converted := h.reverseConvertCFI(c, mediaItem, cfi, contextText, 0); converted != "" {
|
||||
return converted
|
||||
}
|
||||
// Conversion failed; fall through so numeric positions still work.
|
||||
@@ -1482,6 +1529,17 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
||||
}
|
||||
|
||||
if h.annotationSvc != nil {
|
||||
ec := h.loadAnnotationEpub(ctx, mediaItemID)
|
||||
|
||||
pctLoc := 0.0
|
||||
if bookmark.Percentage != nil {
|
||||
pctLoc = *bookmark.Percentage
|
||||
}
|
||||
if pctLoc == 0 {
|
||||
pctLoc = h.deriveAnnotationPercentage(ec, position, int(bookmark.Page))
|
||||
}
|
||||
cfiPosition := h.convertBookmarkPosition(ec, position, pctLoc)
|
||||
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": bookmark.Datetime,
|
||||
"pos0": bookmark.Pos0,
|
||||
@@ -1493,8 +1551,11 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
||||
UserID: pgUserID,
|
||||
Title: bookmark.Text,
|
||||
Position: position,
|
||||
CFIPosition: cfiPosition,
|
||||
PercentageLoc: pctLoc,
|
||||
ChapterNumber: int32(bookmark.Chapter),
|
||||
Source: "koreader",
|
||||
OriginSource: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
@@ -1588,13 +1649,15 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
||||
}
|
||||
|
||||
if h.annotationSvc != nil {
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1, highlight.Text)
|
||||
ec := h.loadAnnotationEpub(ctx, mediaItemID)
|
||||
|
||||
pctStart := 0.0
|
||||
if highlight.Percentage != nil {
|
||||
pctStart = *highlight.Percentage
|
||||
}
|
||||
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ec, highlight.Pos0, highlight.Pos1, highlight.Text, pctStart)
|
||||
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": highlight.Datetime,
|
||||
"pos0": highlight.Pos0,
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
wsync "bookhoard/internal/sync"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -50,3 +52,53 @@ func TestKOReaderProgressRequest_DeletedAnnotationsOmitted(t *testing.T) {
|
||||
assert.Empty(t, req.Books[0].DeletedHighlights)
|
||||
assert.Empty(t, req.Books[0].DeletedBookmarks)
|
||||
}
|
||||
|
||||
// The web drawer navigates bookmarks by CFI, so only high-confidence
|
||||
// conversions may be stored: href/percentage/fallback results would
|
||||
// become dead links. This is the sole gate for KOReader→web bookmark
|
||||
// positions (bookmark text is a label, never book text, so the
|
||||
// conversion runs structural-only with empty context).
|
||||
func TestWebUsableCFI(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
loc wsync.CanonicalLocator
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "structural landing stored",
|
||||
loc: wsync.CanonicalLocator{CFI: "epubcfi(/6/52!/4/28/2/1:0)", Precision: "structural", Percentage: 0.52},
|
||||
expected: "epubcfi(/6/52!/4/28/2/1:0)",
|
||||
},
|
||||
{
|
||||
name: "exact text-search landing stored",
|
||||
loc: wsync.CanonicalLocator{CFI: "epubcfi(/6/52!/4/28/2/1:0)", Precision: "exact", Percentage: 0.52},
|
||||
expected: "epubcfi(/6/52!/4/28/2/1:0)",
|
||||
},
|
||||
{
|
||||
name: "percentage guess discarded",
|
||||
loc: wsync.CanonicalLocator{CFI: "epubcfi(/6/52!/4/2/1:0)", Precision: "percentage", Percentage: 0.52},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "fallback passthrough (raw xpointer) discarded",
|
||||
loc: wsync.CanonicalLocator{CFI: "/body/DocFragment[2]/body/p[3]", Precision: "fallback", Percentage: 0.52},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "section href discarded (not a CFI)",
|
||||
loc: wsync.CanonicalLocator{CFI: "ch10.xhtml", Precision: "section", Percentage: 0.52},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "structural but non-CFI value discarded",
|
||||
loc: wsync.CanonicalLocator{CFI: "ch10.xhtml#h1", Precision: "structural", Percentage: 0.52},
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, webUsableCFI(tc.loc))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+305
-62
@@ -11,7 +11,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -153,6 +152,9 @@ type CreateMediaBookmarkRequest struct {
|
||||
ChapterNumber int32 `json:"chapter_number"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
ChapterReference int32 `json:"chapter_reference"`
|
||||
// Origin labels the creating client for display ("android", "web").
|
||||
// Optional: defaults to "web" for browser callers.
|
||||
Origin string `json:"origin" validate:"omitempty,max=30"`
|
||||
}
|
||||
|
||||
// UpdateMediaBookmarkRequest represents the request for updating a media bookmark
|
||||
@@ -191,55 +193,6 @@ func (mh *MediaHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||
mh.annotationSvc = svc
|
||||
}
|
||||
|
||||
func (h *MediaHandler) DownloadBook(c *echo.Context) error {
|
||||
bookUUID, err := uuid.Parse(c.Param("uuid"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book UUID"})
|
||||
}
|
||||
|
||||
pgBookUUID := pgtype.UUID{Bytes: bookUUID, Valid: true}
|
||||
|
||||
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgBookUUID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
||||
}
|
||||
|
||||
// Resolve relative path to absolute filesystem path
|
||||
fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found on disk"})
|
||||
}
|
||||
|
||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found on disk"})
|
||||
}
|
||||
|
||||
file, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to open book file"})
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
mimeType := mediaItem.MimeType.String
|
||||
if !mediaItem.MimeType.Valid || mimeType == "" {
|
||||
mimeType = mime.TypeByExtension(filepath.Ext(mediaItem.FilePath))
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Content-Type", mimeType)
|
||||
c.Response().Header().Set("Content-Disposition", "attachment; filename=\""+filepath.Base(mediaItem.FilePath)+"\"")
|
||||
|
||||
if mediaItem.FileSize.Valid && mediaItem.FileSize.Int64 > 0 {
|
||||
c.Response().Header().Set("Content-Length", strconv.FormatInt(mediaItem.FileSize.Int64, 10))
|
||||
}
|
||||
|
||||
_, err = io.Copy(c.Response(), file)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to stream file"})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteSearch performs search and returns results with count
|
||||
// Public wrapper for shared search logic used by both JSON and HTML endpoints
|
||||
func (h *MediaHandler) ExecuteSearch(ctx context.Context, params services.SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
|
||||
@@ -619,24 +572,33 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
|
||||
Summary: existingMedia.Summary,
|
||||
}
|
||||
|
||||
// Bulk edits are user customizations: keep existing overrides and mark
|
||||
// each applied update as overridden so scans preserve it.
|
||||
overrides := existingMedia.MetadataOverrides
|
||||
if update.Updates.Title != nil {
|
||||
updateParams.Title = *update.Updates.Title
|
||||
overrides = utils.MergeOverrides(overrides, utils.OverrideTitle)
|
||||
}
|
||||
if update.Updates.Author != nil {
|
||||
updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true}
|
||||
overrides = utils.MergeOverrides(overrides, utils.OverrideAuthor)
|
||||
}
|
||||
if update.Updates.Genre != nil {
|
||||
updateParams.Genre = pgtype.Text{String: *update.Updates.Genre, Valid: true}
|
||||
overrides = utils.MergeOverrides(overrides, utils.OverrideGenre)
|
||||
}
|
||||
if update.Updates.Language != nil {
|
||||
updateParams.Language = pgtype.Text{String: *update.Updates.Language, Valid: true}
|
||||
overrides = utils.MergeOverrides(overrides, utils.OverrideLanguage)
|
||||
}
|
||||
if len(update.Updates.Tags) > 0 {
|
||||
normalizedTags := utils.NormalizeTags(update.Updates.Tags)
|
||||
updateParams.Tags = normalizedTags
|
||||
tagsSearch := utils.NormalizeTagsSearch(update.Updates.Tags)
|
||||
updateParams.TagsSearch = tagsSearch
|
||||
overrides = utils.MergeOverrides(overrides, utils.OverrideTags)
|
||||
}
|
||||
updateParams.MetadataOverrides = overrides
|
||||
|
||||
_, err = h.db.UpdateMediaItem(c.Request().Context(), updateParams)
|
||||
if err != nil {
|
||||
@@ -991,6 +953,37 @@ func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
|
||||
"last_sync_timestamp": progress.LastSyncTimestamp,
|
||||
}
|
||||
|
||||
// Position authority: re-verify the stored anchor and derive the
|
||||
// client-facing handles (cssSelector + anchor document href) server-
|
||||
// side, so clients scroll to an address instead of parsing CFIs.
|
||||
if progress.Epubcfi.Valid && wsync.IsConvertibleFormat(progress.FormatGroup) && mh.libraryService != nil {
|
||||
contextText := ""
|
||||
if progress.ContextText.Valid {
|
||||
contextText = progress.ContextText.String
|
||||
}
|
||||
pct := 0.0
|
||||
if progress.Percentage.Valid {
|
||||
pct = progress.Percentage.Float64
|
||||
}
|
||||
if mediaItem, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err == nil {
|
||||
if path, perr := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath); perr == nil && path != "" {
|
||||
if finalCFI, sel, href, charOff, _, _, _, verr := wsync.VerifyProgressAnchor(path, progress.Epubcfi.String, contextText, pct); verr == nil && sel != "" {
|
||||
resp["css_selector"] = sel
|
||||
resp["anchor_href"] = href
|
||||
if charOff != nil {
|
||||
resp["char_offset"] = *charOff
|
||||
}
|
||||
if finalCFI != "" {
|
||||
resp["epubcfi"] = finalCFI
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if progress.ContextText.Valid {
|
||||
resp["context_text"] = progress.ContextText.String
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
@@ -1287,6 +1280,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
||||
}
|
||||
|
||||
coverPath := existing.CoverImagePath.String
|
||||
coverUploaded := false
|
||||
|
||||
if req.CoverAction == "remove" {
|
||||
coverPath = ""
|
||||
@@ -1298,6 +1292,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save cover image"})
|
||||
}
|
||||
coverPath = savedPath
|
||||
coverUploaded = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1306,7 +1301,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
||||
alternateInfoBytes = []byte(req.AlternateInfo)
|
||||
}
|
||||
|
||||
item, err := mh.db.UpdateMediaItem(c.Request().Context(), database.UpdateMediaItemParams{
|
||||
params := database.UpdateMediaItemParams{
|
||||
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
Title: req.Title,
|
||||
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
||||
@@ -1344,7 +1339,17 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
||||
AlternateInfo: alternateInfoBytes,
|
||||
ScanInformation: pgtype.Text{String: req.ScanInformation, Valid: req.ScanInformation != ""},
|
||||
Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""},
|
||||
})
|
||||
}
|
||||
|
||||
// Fields the user actually changed become overrides so future scans keep
|
||||
// the custom values. An explicit cover upload/removal always overrides.
|
||||
overrides := utils.DetectMetadataOverrides(params, existing)
|
||||
if req.CoverAction == "remove" || coverUploaded {
|
||||
overrides = utils.MergeOverrides(overrides, utils.OverrideCoverImagePath)
|
||||
}
|
||||
params.MetadataOverrides = overrides
|
||||
|
||||
item, err := mh.db.UpdateMediaItem(c.Request().Context(), params)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
@@ -1356,6 +1361,168 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// UnarchiveMediaItem handles POST /api/media-items/:id/unarchive (admin
|
||||
// only). Manually restores a hidden/archived item to library visibility
|
||||
// without waiting for its file to return. If the file is still gone, the
|
||||
// next scan hides it again.
|
||||
func (mh *MediaHandler) UnarchiveMediaItem(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
|
||||
if user.Role != "admin" {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
||||
}
|
||||
|
||||
mediaID := c.Param("id")
|
||||
mediaUUID, err := uuid.Parse(mediaID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||
}
|
||||
|
||||
if err := mh.db.ClearMediaItemArchive(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// MergeArchivedMediaItem handles POST /api/media-items/:id/merge (admin
|
||||
// only). Merges an archived/missing item's reading data (progress,
|
||||
// annotations, highlights, notes, bookmarks, ratings, collections, kobo
|
||||
// shelves) into an active item with identical content (same SHA-256), then
|
||||
// deletes the archived row. Covers the copy-then-delete-later workflow: the
|
||||
// active copy exists, the archived twin holds the real reading history.
|
||||
// Per-user collisions keep the active copy's data, mirroring hash-conflict
|
||||
// resolution.
|
||||
func (mh *MediaHandler) MergeArchivedMediaItem(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
|
||||
if user.Role != "admin" {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
||||
}
|
||||
|
||||
sourceUUID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||
}
|
||||
|
||||
var req struct {
|
||||
TargetID string `json:"target_id"`
|
||||
}
|
||||
if err := c.Bind(&req); err != nil || req.TargetID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "target_id is required"})
|
||||
}
|
||||
targetUUID, err := uuid.Parse(req.TargetID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid target_id"})
|
||||
}
|
||||
if sourceUUID == targetUUID {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot merge an item into itself"})
|
||||
}
|
||||
|
||||
ctx := c.Request().Context()
|
||||
source, err := mh.db.GetMediaItem(ctx, pgtype.UUID{Bytes: sourceUUID, Valid: true})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "archived item not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
if !source.ArchivedAt.Valid && source.MissingScanCount == 0 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "source item is not archived or missing"})
|
||||
}
|
||||
|
||||
target, err := mh.db.GetMediaItem(ctx, pgtype.UUID{Bytes: targetUUID, Valid: true})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "target item not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
if target.ArchivedAt.Valid || target.MissingScanCount > 0 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "target item is not active"})
|
||||
}
|
||||
|
||||
if !source.FileSha256.Valid || source.FileSha256.String == "" ||
|
||||
source.FileSha256.String != target.FileSha256.String {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "items do not share the same content hash"})
|
||||
}
|
||||
|
||||
if err := mh.db.ReparentMediaItemChildren(ctx, database.ReparentMediaItemChildrenParams{
|
||||
Column1: pgtype.UUID{Bytes: targetUUID, Valid: true},
|
||||
Column2: pgtype.UUID{Bytes: sourceUUID, Valid: true},
|
||||
}); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to merge reading data: " + err.Error()})
|
||||
}
|
||||
if err := mh.db.DeleteMediaItem(ctx, pgtype.UUID{Bytes: sourceUUID, Valid: true}); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to remove archived item: " + err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{
|
||||
"message": "Merged reading progress, annotations, and collections into the active item",
|
||||
})
|
||||
}
|
||||
|
||||
// PurgeArchivedMediaItems handles POST /api/media-items/purge-archived
|
||||
// (admin only). Hard-deletes every archived item (files missing from disk for
|
||||
// 2+ scans) together with its reading history. The archive retention window
|
||||
// eventually does the same automatically; this is the manual bulk escape hatch.
|
||||
func (mh *MediaHandler) PurgeArchivedMediaItems(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
|
||||
if user.Role != "admin" {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
||||
}
|
||||
|
||||
purged, err := mh.db.PurgeAllArchivedMediaItems(c.Request().Context())
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"purged": len(purged),
|
||||
})
|
||||
}
|
||||
|
||||
// RescanMediaItem handles POST /api/media-items/:id/rescan (admin only)
|
||||
func (mh *MediaHandler) RescanMediaItem(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
|
||||
if user.Role != "admin" {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
||||
}
|
||||
|
||||
mediaID := c.Param("id")
|
||||
mediaUUID, err := uuid.Parse(mediaID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||
}
|
||||
|
||||
if _, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
scanner := services.NewMediaScanner(mh.db)
|
||||
defer scanner.Close()
|
||||
|
||||
// reset_overrides=true discards user customizations first, returning the
|
||||
// item to pure scanned defaults (the "Reset to Scanned" action).
|
||||
resetOverrides := c.QueryParam("reset_overrides") == "true"
|
||||
|
||||
if err := scanner.RescanMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}, resetOverrides); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// DeleteMediaItem handles DELETE /api/media-items/:id (admin only)
|
||||
func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
@@ -1703,6 +1870,11 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
|
||||
ChapterReference: req.ChapterReference,
|
||||
Source: "web",
|
||||
ModifiedAt: time.Now(),
|
||||
// The PUT targets this exact row (from the URL): identity must
|
||||
// not be re-derived from content — a device echo has usually
|
||||
// rewritten the stored CFI to point shape, so the computed key
|
||||
// would miss and mint a duplicate beside the edited row.
|
||||
HighlightID: pgtype.UUID{Bytes: highlightUUID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -1800,6 +1972,10 @@ func (mh *MediaHandler) CreateMediaBookmark(c *echo.Context) error {
|
||||
// The sync-aware path (dedup + LWW + tombstones) is preferred; fall back
|
||||
// to the plain query when the service isn't wired (e.g. some tests).
|
||||
if mh.annotationSvc != nil {
|
||||
origin := req.Origin
|
||||
if origin == "" {
|
||||
origin = "web"
|
||||
}
|
||||
result, err := mh.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
@@ -1812,6 +1988,7 @@ func (mh *MediaHandler) CreateMediaBookmark(c *echo.Context) error {
|
||||
PercentageLoc: req.Percentage,
|
||||
ChapterReference: req.ChapterReference,
|
||||
Source: "web",
|
||||
OriginSource: origin,
|
||||
ModifiedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -2073,20 +2250,30 @@ func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UU
|
||||
return mh.libraryService.ResolveMediaPath(ctx, libraryID, relativePath)
|
||||
}
|
||||
|
||||
// ServeFile serves files (covers or books) via /uploads/library-{id}/path
|
||||
// Requires JWT authentication
|
||||
// ServeFile serves stored library files (covers and books).
|
||||
//
|
||||
// Two URL forms funnel into this handler:
|
||||
//
|
||||
// /uploads/library-{libraryID}/{relativePath} (covers, reader files)
|
||||
// /api/media-items/{mediaItemID}/download (explicit book download)
|
||||
//
|
||||
// Both require JWT authentication and that the authenticated user can see
|
||||
// the library owning the file - library visibility is the permission gate.
|
||||
func (mh *MediaHandler) ServeFile(c *echo.Context) error {
|
||||
// URL format: /uploads/library-{libraryID}/{relativePath}
|
||||
// Get library ID directly from route parameter
|
||||
var libraryUUID pgtype.UUID
|
||||
var relativePath string
|
||||
|
||||
rawPath := c.Param("*")
|
||||
if rawPath != "" {
|
||||
// Path form: /uploads/library-{libraryID}/{relativePath}
|
||||
libraryIDStr := c.Param("id")
|
||||
libraryUUID, err := uuid.Parse(libraryIDStr)
|
||||
parsed, err := uuid.Parse(libraryIDStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library ID"})
|
||||
}
|
||||
libraryUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
|
||||
// Get remaining path from URL
|
||||
rawPath := c.Param("*")
|
||||
relativePath, err := url.QueryUnescape(rawPath)
|
||||
relativePath, err = url.QueryUnescape(rawPath)
|
||||
if err != nil {
|
||||
relativePath = rawPath
|
||||
}
|
||||
@@ -2094,9 +2281,55 @@ func (mh *MediaHandler) ServeFile(c *echo.Context) error {
|
||||
if relativePath == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid path"})
|
||||
}
|
||||
} else {
|
||||
// Item form: /api/media-items/{mediaItemID}/download
|
||||
itemUUID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item ID"})
|
||||
}
|
||||
|
||||
mediaItem, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: itemUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
||||
}
|
||||
|
||||
if !mediaItem.LibraryID.Valid || mediaItem.FilePath == "" {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found"})
|
||||
}
|
||||
|
||||
libraryUUID = mediaItem.LibraryID
|
||||
relativePath = mediaItem.FilePath
|
||||
|
||||
// Explicit download endpoint: suggest saving instead of inline display.
|
||||
filename := strings.Map(func(r rune) rune {
|
||||
if r == '"' || r == '\\' || r == '/' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, filepath.Base(relativePath))
|
||||
c.Response().Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
||||
}
|
||||
|
||||
// Library visibility gate: the library is what grants permission to
|
||||
// see and download media.
|
||||
user := c.Get("user").(database.Users)
|
||||
visibleLibraries, err := mh.libraryService.GetUserVisibleLibraries(c.Request().Context(), user.ID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"})
|
||||
}
|
||||
libraryVisible := false
|
||||
for _, lib := range visibleLibraries {
|
||||
if lib.ID.Valid && lib.ID.Bytes == libraryUUID.Bytes {
|
||||
libraryVisible = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !libraryVisible {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
||||
}
|
||||
|
||||
// Resolve using service
|
||||
fullPath, err := mh.getFullFilePath(c.Request().Context(), pgtype.UUID{Bytes: libraryUUID, Valid: true}, relativePath)
|
||||
fullPath, err := mh.getFullFilePath(c.Request().Context(), libraryUUID, relativePath)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
||||
}
|
||||
@@ -2169,7 +2402,17 @@ func (mh *MediaHandler) saveCoverImage(c echo.Context, mediaUUID uuid.UUID, file
|
||||
return "", fmt.Errorf("media item has no file path")
|
||||
}
|
||||
|
||||
coverRelPath := relativeFilePath + ".cover.jpg"
|
||||
// Custom covers live at a dedicated sidecar path (distinct from the
|
||||
// scanner-generated {file}.cover.jpg) so scans can never overwrite a
|
||||
// user-uploaded cover and the metadata_overrides set can protect it.
|
||||
ext := ".jpg"
|
||||
switch contentType {
|
||||
case "image/png":
|
||||
ext = ".png"
|
||||
case "image/webp":
|
||||
ext = ".webp"
|
||||
}
|
||||
coverRelPath := relativeFilePath + ".custom_cover" + ext
|
||||
|
||||
coverFullPath, err := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, coverRelPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,10 @@ import "bookhoard/internal/database"
|
||||
type MediaDetail struct {
|
||||
database.MediaItems // Embedded - ALL book fields available
|
||||
|
||||
// Absolute on-disk location (library folder + relative path), resolved by
|
||||
// the page handler so the UI can show where the file lives.
|
||||
FileLocation string `json:"file_location"`
|
||||
|
||||
// User-specific data
|
||||
Rating *database.MediaRatings `json:"rating,omitempty"`
|
||||
Collections []database.Collections `json:"collections"`
|
||||
|
||||
+288
-11
@@ -90,6 +90,9 @@ func appendToken(url, token string) string {
|
||||
// catalogMediaType is the OPDS media type for an acquisition catalog feed.
|
||||
const catalogMediaType = "application/atom+xml;profile=opds-catalog;kind=acquisition"
|
||||
|
||||
// navFeedMediaType is the OPDS media type for a navigation catalog feed.
|
||||
const navFeedMediaType = "application/atom+xml;profile=opds-catalog;kind=navigation"
|
||||
|
||||
// addCatalogPaginationLinks adds OPDS pagination links (self, start, first,
|
||||
// previous, next, last) and OpenSearch paging metadata (totalResults,
|
||||
// itemsPerPage, startIndex) to a feed based on the current page position.
|
||||
@@ -172,8 +175,109 @@ func formatLabelFromPath(path string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// GetDeviceCatalog returns the OPDS catalog feed for a device
|
||||
// GetDeviceCatalog serves the OPDS root feed for a device.
|
||||
//
|
||||
// By default it returns a navigation feed: an "All Books" entry (the flat
|
||||
// cross-library catalog) followed by one folder entry per visible library.
|
||||
// OPDS clients render navigation entries as folders, so each library's
|
||||
// books — and its library-scoped search — live under their own node instead
|
||||
// of interleaving duplicate copies of the same book in one flat list.
|
||||
// Passing ?all=1 serves the flat cross-library acquisition feed directly,
|
||||
// preserving the pre-folders behavior.
|
||||
func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
if c.QueryParam("all") == "1" {
|
||||
return h.serveAllBooksCatalog(c)
|
||||
}
|
||||
|
||||
deviceID := c.Param("deviceId")
|
||||
|
||||
// Get base URLs
|
||||
_, opdsBaseURL, err := h.getBaseURLs(c)
|
||||
if err != nil {
|
||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get config"))
|
||||
}
|
||||
|
||||
// Parse device ID
|
||||
deviceUUID, err := uuid.Parse(deviceID)
|
||||
if err != nil {
|
||||
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid device ID"))
|
||||
}
|
||||
|
||||
// Verify device exists
|
||||
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.XML(http.StatusNotFound, opds.NewErrorFeed("Device not found"))
|
||||
}
|
||||
|
||||
// Get user's visible libraries
|
||||
userID := device.UserID.Bytes
|
||||
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
||||
if err != nil {
|
||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||
}
|
||||
|
||||
token := h.getAuthToken(c)
|
||||
|
||||
feed := opds.NewFeed(
|
||||
fmt.Sprintf("urn:uuid:%s", deviceID),
|
||||
"Bookhoard Library",
|
||||
)
|
||||
|
||||
// OpenSearch discovery: the root search stays global (all libraries).
|
||||
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID), token)
|
||||
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
|
||||
|
||||
updated := time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
|
||||
// "All Books" first: the flat cross-library catalog (?all=1).
|
||||
allBooks := opds.NewEntry(
|
||||
fmt.Sprintf("urn:uuid:%s-all-books", deviceID),
|
||||
"All Books",
|
||||
"",
|
||||
updated,
|
||||
)
|
||||
allBooks.AddLink(appendToken(fmt.Sprintf("%s/devices/%s/catalog?all=1", opdsBaseURL, deviceID), token), catalogMediaType, "subsection")
|
||||
allBooks.SetSummary("Browse every book across all your libraries")
|
||||
feed.AddEntry(allBooks)
|
||||
|
||||
// One folder per visible library, with live book counts.
|
||||
counts := make(map[pgtype.UUID]int64)
|
||||
if countRows, cerr := h.db.GetVisibleLibraryMediaCounts(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}); cerr == nil {
|
||||
for _, row := range countRows {
|
||||
counts[row.ID] = row.MediaCount
|
||||
}
|
||||
}
|
||||
|
||||
for _, lib := range libraries {
|
||||
libUUID := uuid.UUID(lib.ID.Bytes).String()
|
||||
title := lib.Name
|
||||
if n, ok := counts[lib.ID]; ok {
|
||||
title = fmt.Sprintf("%s (%d)", lib.Name, n)
|
||||
}
|
||||
entry := opds.NewEntry(
|
||||
fmt.Sprintf("urn:uuid:%s-library-%s", deviceID, libUUID),
|
||||
title,
|
||||
"",
|
||||
updated,
|
||||
)
|
||||
entry.AddLink(appendToken(fmt.Sprintf("%s/devices/%s/library/%s/catalog", opdsBaseURL, deviceID, libUUID), token), catalogMediaType, "subsection")
|
||||
entry.SetSummary("Browse books in the " + lib.Name + " library")
|
||||
feed.AddEntry(entry)
|
||||
}
|
||||
|
||||
// Generate XML
|
||||
xmlString, err := feed.GenerateXMLString()
|
||||
if err != nil {
|
||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed"))
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Content-Type", navFeedMediaType)
|
||||
return c.String(http.StatusOK, xmlString)
|
||||
}
|
||||
|
||||
// serveAllBooksCatalog is the original flat cross-library acquisition feed:
|
||||
// every book from every visible library, paginated as one list.
|
||||
func (h *OPDSHandler) serveAllBooksCatalog(c *echo.Context) error {
|
||||
deviceID := c.Param("deviceId")
|
||||
|
||||
page := c.QueryParam("page")
|
||||
@@ -216,8 +320,6 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
|
||||
// Get user's visible libraries
|
||||
userID := device.UserID.Bytes
|
||||
userUUID := uuid.UUID(userID)
|
||||
|
||||
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
||||
if err != nil {
|
||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||
@@ -276,7 +378,23 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
|
||||
|
||||
// Add entries
|
||||
for _, item := range allItems {
|
||||
h.addAcquisitionEntries(c.Request().Context(), feed, device, deviceID, allItems, baseURL, opdsBaseURL, token)
|
||||
|
||||
// Generate XML
|
||||
xmlString, err := feed.GenerateXMLString()
|
||||
if err != nil {
|
||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed"))
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Content-Type", catalogMediaType)
|
||||
return c.String(http.StatusOK, xmlString)
|
||||
}
|
||||
|
||||
// addAcquisitionEntries fills an acquisition feed with book entries for the
|
||||
// given items. Shared body of the all-books and per-library catalogs.
|
||||
func (h *OPDSHandler) addAcquisitionEntries(ctx context.Context, feed *opds.Feed, device database.Devices, deviceID string, items []database.ListMediaItemsByLibraryRow, baseURL, opdsBaseURL, token string) {
|
||||
userUUID := uuid.UUID(device.UserID.Bytes)
|
||||
for _, item := range items {
|
||||
bookUUID := uuid.UUID(item.ID.Bytes).String()
|
||||
title := item.Title
|
||||
author := ""
|
||||
@@ -323,7 +441,7 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
}
|
||||
|
||||
// Add collections as categories
|
||||
collections, err := h.db.GetCollectionsForBook(c.Request().Context(), pgtype.UUID{Bytes: item.ID.Bytes, Valid: true})
|
||||
collections, err := h.db.GetCollectionsForBook(ctx, pgtype.UUID{Bytes: item.ID.Bytes, Valid: true})
|
||||
if err == nil {
|
||||
collectionScheme := fmt.Sprintf("%s/collections", baseURL)
|
||||
for _, col := range collections {
|
||||
@@ -335,6 +453,125 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
|
||||
feed.AddEntry(entry)
|
||||
}
|
||||
}
|
||||
|
||||
// GetLibraryCatalog serves an acquisition feed scoped to one of the device
|
||||
// owner's visible libraries. Its search link is library-scoped: OPDS clients
|
||||
// only substitute {searchTerms}, so opening search from inside this folder
|
||||
// searches within this library only.
|
||||
func (h *OPDSHandler) GetLibraryCatalog(c *echo.Context) error {
|
||||
deviceID := c.Param("deviceId")
|
||||
libraryID := c.Param("libraryId")
|
||||
|
||||
page := c.QueryParam("page")
|
||||
perPage := c.QueryParam("per_page")
|
||||
includeFormat := c.QueryParam("include_format")
|
||||
|
||||
// Parse pagination parameters
|
||||
pageNum := 1
|
||||
if page != "" {
|
||||
if num, err := strconv.Atoi(page); err == nil && num > 0 {
|
||||
pageNum = num
|
||||
}
|
||||
}
|
||||
|
||||
perPageNum := h.opdsDefaultPageSize()
|
||||
maxPerPage := h.opdsMaxPageSize()
|
||||
if perPage != "" {
|
||||
if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= maxPerPage {
|
||||
perPageNum = num
|
||||
}
|
||||
}
|
||||
|
||||
// Get base URLs
|
||||
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
|
||||
if err != nil {
|
||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get config"))
|
||||
}
|
||||
|
||||
// Parse IDs
|
||||
deviceUUID, err := uuid.Parse(deviceID)
|
||||
if err != nil {
|
||||
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid device ID"))
|
||||
}
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid library ID"))
|
||||
}
|
||||
|
||||
// Verify device exists
|
||||
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.XML(http.StatusNotFound, opds.NewErrorFeed("Device not found"))
|
||||
}
|
||||
|
||||
// The library must be visible to the device owner.
|
||||
userID := device.UserID.Bytes
|
||||
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
||||
if err != nil {
|
||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||
}
|
||||
var library *database.GetUserVisibleLibrariesRow
|
||||
for i := range libraries {
|
||||
if libraries[i].ID.Bytes == libUUID {
|
||||
library = &libraries[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if library == nil {
|
||||
return c.XML(http.StatusForbidden, opds.NewErrorFeed("Library not accessible"))
|
||||
}
|
||||
|
||||
// Get media items
|
||||
items, err := h.db.ListMediaItemsByLibrary(c.Request().Context(), library.ID)
|
||||
if err != nil {
|
||||
items = []database.ListMediaItemsByLibraryRow{}
|
||||
}
|
||||
|
||||
// Filter by format if specified
|
||||
if includeFormat != "" {
|
||||
filteredItems := []database.ListMediaItemsByLibraryRow{}
|
||||
for _, item := range items {
|
||||
if strings.EqualFold(item.FormatGroup, includeFormat) {
|
||||
filteredItems = append(filteredItems, item)
|
||||
}
|
||||
}
|
||||
items = filteredItems
|
||||
}
|
||||
|
||||
// Pagination
|
||||
totalItems := len(items)
|
||||
startIdx := (pageNum - 1) * perPageNum
|
||||
endIdx := startIdx + perPageNum
|
||||
|
||||
if startIdx >= totalItems {
|
||||
items = []database.ListMediaItemsByLibraryRow{}
|
||||
} else if endIdx > totalItems {
|
||||
items = items[startIdx:]
|
||||
} else {
|
||||
items = items[startIdx:endIdx]
|
||||
}
|
||||
|
||||
// Create OPDS feed
|
||||
feed := opds.NewFeed(
|
||||
fmt.Sprintf("urn:uuid:%s-library-%s", deviceID, libUUID),
|
||||
library.Name,
|
||||
)
|
||||
|
||||
token := h.getAuthToken(c)
|
||||
catalogBase := fmt.Sprintf("%s/devices/%s/library/%s/catalog", opdsBaseURL, deviceID, libUUID)
|
||||
addCatalogPaginationLinks(feed, catalogBase, pageNum, perPageNum, totalItems, token)
|
||||
|
||||
// Library-scoped search discovery: the template pins this library.
|
||||
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}&library_id=%s", opdsBaseURL, deviceID, libUUID), token)
|
||||
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
|
||||
|
||||
// Up-link back to the root navigation feed.
|
||||
rootURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
|
||||
feed.AddLink(rootURL, navFeedMediaType, "up")
|
||||
|
||||
// Add entries
|
||||
h.addAcquisitionEntries(c.Request().Context(), feed, device, deviceID, items, baseURL, opdsBaseURL, token)
|
||||
|
||||
// Generate XML
|
||||
xmlString, err := feed.GenerateXMLString()
|
||||
@@ -342,7 +579,7 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed"))
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Content-Type", "application/atom+xml;profile=opds-catalog;kind=acquisition")
|
||||
c.Response().Header().Set("Content-Type", catalogMediaType)
|
||||
return c.String(http.StatusOK, xmlString)
|
||||
}
|
||||
|
||||
@@ -377,20 +614,50 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||
|
||||
// Get user's visible libraries
|
||||
userID := device.UserID.Bytes
|
||||
_, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
||||
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
||||
if err != nil {
|
||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||
}
|
||||
|
||||
// Optional library scope: library folder feeds advertise their own search
|
||||
// link with this parameter, so opening search from inside a library
|
||||
// folder searches within that library only.
|
||||
var scopedLibraryID pgtype.UUID
|
||||
scopedLibraryName := ""
|
||||
if libParam := c.QueryParam("library_id"); libParam != "" {
|
||||
parsed, perr := uuid.Parse(libParam)
|
||||
if perr != nil {
|
||||
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid library ID"))
|
||||
}
|
||||
for i := range libraries {
|
||||
if libraries[i].ID.Bytes == parsed {
|
||||
scopedLibraryID = libraries[i].ID
|
||||
scopedLibraryName = libraries[i].Name
|
||||
break
|
||||
}
|
||||
}
|
||||
if !scopedLibraryID.Valid {
|
||||
return c.XML(http.StatusForbidden, opds.NewErrorFeed("Library not accessible"))
|
||||
}
|
||||
}
|
||||
|
||||
token := h.getAuthToken(c)
|
||||
|
||||
// No query: serve the OpenSearch description document so clients can learn
|
||||
// the search template (contains the {searchTerms} placeholder).
|
||||
// the search URL template (contains the {searchTerms} placeholder).
|
||||
if query == "" {
|
||||
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID), token)
|
||||
template := fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID)
|
||||
shortName := "Bookhoard"
|
||||
description := "Search the Bookhoard library"
|
||||
if scopedLibraryID.Valid {
|
||||
template += "&library_id=" + scopedLibraryID.String()
|
||||
shortName = "Bookhoard: " + scopedLibraryName
|
||||
description = "Search the " + scopedLibraryName + " library"
|
||||
}
|
||||
searchURL := appendToken(template, token)
|
||||
desc := opds.NewSearchDescription(
|
||||
"Bookhoard",
|
||||
"Search the Bookhoard library",
|
||||
shortName,
|
||||
description,
|
||||
searchURL,
|
||||
)
|
||||
xmlString, err := desc.GenerateXMLString()
|
||||
@@ -404,6 +671,7 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||
// Search media items
|
||||
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
LibraryID: scopedLibraryID,
|
||||
SearchPattern: pgtype.Text{String: "%" + query + "%", Valid: true},
|
||||
Offset: pgtype.Int4{Int32: 0, Valid: true},
|
||||
Limit: pgtype.Int4{Int32: 1000, Valid: true},
|
||||
@@ -451,6 +719,15 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||
entry.SetSummary(item.Description.String)
|
||||
}
|
||||
|
||||
// Global (unscoped) search: label which library each copy lives in so
|
||||
// duplicate copies of the same book are distinguishable.
|
||||
if !scopedLibraryID.Valid && item.LibraryName != "" {
|
||||
entry.AddCategory(baseURL+"/libraries", item.LibraryName)
|
||||
if !item.Description.Valid || item.Description.String == "" {
|
||||
entry.SetSummary("Library: " + item.LibraryName)
|
||||
}
|
||||
}
|
||||
|
||||
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
|
||||
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ func (h *ReaderHandler) ShowReader(c *echo.Context) error {
|
||||
|
||||
// Determine if panel detection should be enabled
|
||||
enablePanelDetection := shouldEnablePanelDetection(libraryWithType.TypeName, mediaItem.FormatGroup)
|
||||
treatAsComic := ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup)
|
||||
|
||||
// Get reading progress
|
||||
var progress database.ReadingProgress
|
||||
@@ -133,6 +134,7 @@ func (h *ReaderHandler) ShowReader(c *echo.Context) error {
|
||||
"progress": progress,
|
||||
"bookmarks": bookmarks,
|
||||
"enable_panel_detection": enablePanelDetection,
|
||||
"treat_as_comic": treatAsComic,
|
||||
"format_group": mediaItem.FormatGroup,
|
||||
"manga_type": mediaItem.MangaType,
|
||||
"reading_direction": mediaItem.ReadingDirection,
|
||||
@@ -639,6 +641,25 @@ func (h *ReaderHandler) ParseEbook(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// ShouldTreatAsComic reports whether a fixed-layout item that lives in a
|
||||
// comics or manga library should get comic reader treatment: bookmarks
|
||||
// only — no text-selection highlights, no annotations, no in-book search.
|
||||
// Comic archives already behave this way; this flag extends it to fixed-
|
||||
// layout PDFs shelved in comic libraries (scanned or official manga PDFs).
|
||||
func ShouldTreatAsComic(libraryType string, formatGroup string) bool {
|
||||
comicLibraries := map[string]bool{
|
||||
"manga": true,
|
||||
"comics": true,
|
||||
}
|
||||
|
||||
fixedFormats := map[string]bool{
|
||||
"fixed_layout": true,
|
||||
"comic_archive": true,
|
||||
}
|
||||
|
||||
return comicLibraries[libraryType] && fixedFormats[formatGroup]
|
||||
}
|
||||
|
||||
// shouldEnablePanelDetection determines if panel detection should be enabled
|
||||
// based on library type and format group
|
||||
func shouldEnablePanelDetection(libraryType string, formatGroup string) bool {
|
||||
|
||||
@@ -7,13 +7,17 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/config"
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/utils"
|
||||
"bookhoard/internal/version"
|
||||
"bookhoard/templates"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -293,6 +297,18 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
libraryID := libRes.LibraryID
|
||||
libData := libRes.Libraries
|
||||
|
||||
// Steam Deck behavior: an offline library (all folders missing, e.g.
|
||||
// an unmounted external drive) shows an empty shelf with a notice
|
||||
// instead of querying items. Nothing is marked or purged.
|
||||
if !libRes.IsAll {
|
||||
for _, l := range libData {
|
||||
if l.ID == libraryID && l.Offline {
|
||||
errorMsg = l.Name + " is currently unavailable - its storage is not connected. Your books are safe and will return when the drive is reattached."
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch saved filters for SSR
|
||||
userUUID, _ := uuid.Parse(user.ID)
|
||||
var savedFilters []database.SavedFilters
|
||||
@@ -834,6 +850,59 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
}))
|
||||
|
||||
frontendProtected.GET("/admin/archived", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
|
||||
rows, err := cfg.Queries.ListHiddenMediaItems(c.Request().Context())
|
||||
if err != nil {
|
||||
log.Printf("ListHiddenMediaItems failed: %v", err)
|
||||
rows = []database.ListHiddenMediaItemsRow{}
|
||||
}
|
||||
|
||||
retentionDays := config.ArchiveRetentionDays()
|
||||
items := make([]templates.ArchivedItem, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
itemUUID, _ := uuid.FromBytes(row.ID.Bytes[0:16])
|
||||
item := templates.ArchivedItem{
|
||||
ID: itemUUID.String(),
|
||||
Title: row.Title,
|
||||
Author: getText(row.Author),
|
||||
LibraryName: row.LibraryName,
|
||||
FilePath: row.FilePath,
|
||||
MissingScans: row.MissingScanCount,
|
||||
}
|
||||
if row.MatchID.Valid {
|
||||
item.MatchID = uuid.UUID(row.MatchID.Bytes).String()
|
||||
item.MatchTitle = row.MatchTitle
|
||||
item.MatchLibraryName = getText(row.MatchLibraryName)
|
||||
}
|
||||
if row.ArchivedAt.Valid {
|
||||
archived := row.ArchivedAt.Time
|
||||
item.ArchivedAt = &archived
|
||||
switch {
|
||||
case retentionDays <= 0:
|
||||
item.Status = "Archived - kept until manually purged (retention disabled)"
|
||||
default:
|
||||
purge := archived.AddDate(0, 0, retentionDays)
|
||||
item.PurgeAt = &purge
|
||||
item.Status = "Archived - permanently deleted " + purge.Format("Jan 2, 2006")
|
||||
}
|
||||
} else {
|
||||
item.Status = fmt.Sprintf("Missing from disk - archives on the next scan (%d/2)", row.MissingScanCount)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := templates.AdminArchived(user, items, retentionDays).Render(c.Request().Context(), &buf); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
}))
|
||||
|
||||
frontendProtected.GET("/admin/library", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
@@ -882,7 +951,8 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.AdminLibrary(user, libData, userData).Render(c.Request().Context(), &buf)
|
||||
archivedCount, _ := cfg.Queries.CountArchivedMediaItems(c.Request().Context())
|
||||
err = templates.AdminLibrary(user, libData, userData, archivedCount).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1267,6 +1337,23 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
mediaItem.CoverImagePath = pgtype.Text{String: resolvedPath, Valid: true}
|
||||
}
|
||||
|
||||
// Resolve the on-disk location (library folder + relative path) so the
|
||||
// detail page can show where the file lives, even for sparse metadata.
|
||||
// Admin-only: everyday users never receive the absolute path.
|
||||
fileLocation := ""
|
||||
if user.Role == "admin" {
|
||||
fileLocation = mediaItem.FilePath
|
||||
if folders, ferr := cfg.Queries.GetLibraryFolders(c.Request().Context(), mediaItem.LibraryID); ferr == nil {
|
||||
for _, folder := range folders {
|
||||
candidate := filepath.Join(folder.FolderPath, mediaItem.FilePath)
|
||||
if _, serr := os.Stat(candidate); serr == nil {
|
||||
fileLocation = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch rating
|
||||
var rating *database.MediaRatings
|
||||
userRating, err := cfg.Queries.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{
|
||||
@@ -1330,6 +1417,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
// Assemble response (no field duplication!)
|
||||
detail := handlers.MediaDetail{
|
||||
MediaItems: mediaItem, // Embedded - ALL fields available
|
||||
FileLocation: fileLocation,
|
||||
Rating: rating,
|
||||
Collections: collections,
|
||||
ReadingProgress: progress,
|
||||
@@ -1368,6 +1456,7 @@ func (cfg *Config) GetHealth(c *echo.Context) error {
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"status": "healthy",
|
||||
"version": version.Version,
|
||||
"database": "connected",
|
||||
"scan": map[string]interface{}{
|
||||
"scan_in_progress": cfg.Worker.HasActiveScans(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
@@ -132,12 +133,30 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
|
||||
res.Libraries = make([]templates.LibraryData, len(libraries))
|
||||
for i, lib := range libraries {
|
||||
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||||
// Steam Deck SD-card behavior: a library whose folders are all
|
||||
// missing (unmounted drive) renders offline - hidden contents, no
|
||||
// marking, no purging - and returns when the storage does.
|
||||
offline := false
|
||||
folders, folderErr := cfg.Queries.GetLibraryFolders(c.Request().Context(), lib.ID)
|
||||
if folderErr != nil || len(folders) == 0 {
|
||||
offline = true
|
||||
} else {
|
||||
anyFolderExists := false
|
||||
for _, folder := range folders {
|
||||
if _, statErr := os.Stat(folder.FolderPath); statErr == nil {
|
||||
anyFolderExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
offline = !anyFolderExists
|
||||
}
|
||||
res.Libraries[i] = templates.LibraryData{
|
||||
ID: libUUID.String(),
|
||||
Name: lib.Name,
|
||||
Description: getText(lib.Description),
|
||||
TypeName: lib.TypeName,
|
||||
MediaCount: countMap[libUUID.String()],
|
||||
Offline: offline,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ func registerMediaRoutes(cfg *Config) {
|
||||
// Media item routes (all authenticated users)
|
||||
protected.GET("/media-items", cfg.MediaHandler.ListMediaItems)
|
||||
protected.GET("/media-items/:id", cfg.MediaHandler.GetMediaItem)
|
||||
// Book download endpoint - same ServeFile flow as /uploads/library-:id/*
|
||||
// (JWT + library-visibility gated), addressed by media item ID.
|
||||
protected.GET("/media-items/:id/download", cfg.MediaHandler.ServeFile)
|
||||
|
||||
// Media rating routes (all authenticated users)
|
||||
protected.POST("/media-items/:id/rating", cfg.MediaHandler.CreateMediaRating)
|
||||
@@ -57,7 +60,11 @@ func registerMediaRoutes(cfg *Config) {
|
||||
// Admin-only media routes
|
||||
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
|
||||
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
|
||||
admin.POST("/media-items/:id/rescan", cfg.MediaHandler.RescanMediaItem)
|
||||
admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem)
|
||||
admin.POST("/media-items/purge-archived", cfg.MediaHandler.PurgeArchivedMediaItems)
|
||||
admin.POST("/media-items/:id/unarchive", cfg.MediaHandler.UnarchiveMediaItem)
|
||||
admin.POST("/media-items/:id/merge", cfg.MediaHandler.MergeArchivedMediaItem)
|
||||
|
||||
// Shelf management (protected)
|
||||
protected.POST("/devices/:id/shelves", cfg.MediaHandler.AddToShelf)
|
||||
|
||||
@@ -19,6 +19,7 @@ func registerOPDSRoutes(cfg *Config) {
|
||||
opds := e.Group("/opds/devices")
|
||||
opds.Use(cfg.DeviceAuthMiddleware.Authenticate)
|
||||
opds.GET("/:deviceId/catalog", cfg.OPDSHandler.GetDeviceCatalog)
|
||||
opds.GET("/:deviceId/library/:libraryId/catalog", cfg.OPDSHandler.GetLibraryCatalog)
|
||||
opds.GET("/:deviceId/search", cfg.OPDSHandler.SearchDeviceCatalog)
|
||||
opds.GET("/:deviceId/nav", cfg.OPDSHandler.GetDeviceNavigation)
|
||||
opds.GET("/:deviceId/download/:bookId", cfg.OPDSHandler.DownloadBook)
|
||||
|
||||
+13
-68
@@ -1,18 +1,15 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/sync"
|
||||
"bookhoard/internal/utils"
|
||||
"bookhoard/templates"
|
||||
"bytes"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
@@ -69,21 +66,17 @@ func registerReaderRoutes(cfg *Config) {
|
||||
if !visible {
|
||||
return renderErrorPage(c, "Access denied", "access_denied")
|
||||
}
|
||||
// Get reading progress
|
||||
var progress database.ReadingProgress
|
||||
progress, err = cfg.Queries.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
|
||||
UserID: uuidToPGType(userUUID),
|
||||
})
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
progress = database.ReadingProgress{}
|
||||
// Library type decides comic reader treatment: fixed-layout items
|
||||
// (e.g. manga PDFs) shelved in comics/manga libraries are paged
|
||||
// through like comics — bookmarks only, no highlights or search.
|
||||
libraryWithType, err := cfg.Queries.GetLibraryWithType(c.Request().Context(), mediaItem.LibraryID)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Failed to get library info", "library_error")
|
||||
}
|
||||
// Get bookmarks
|
||||
bookmarks, _ := cfg.Queries.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
|
||||
UserID: uuidToPGType(userUUID),
|
||||
})
|
||||
// Convert to template types
|
||||
// Convert to template types. Reading state is deliberately NOT
|
||||
// fetched or embedded: the reader pulls position, bookmarks, and
|
||||
// annotations from the APIs at open time so the page can never
|
||||
// carry (nor write back) a stale snapshot.
|
||||
mediaUUID, _ := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
|
||||
libUUID, _ := uuid.FromBytes(mediaItem.LibraryID.Bytes[0:16])
|
||||
metadata := templates.ReaderMetadata{
|
||||
@@ -103,59 +96,11 @@ func registerReaderRoutes(cfg *Config) {
|
||||
FileURL: utils.ResolveMediaURL(mediaItem.LibraryID, pgtype.Text{String: mediaItem.FilePath, Valid: true}),
|
||||
TotalCharacters: mediaItem.TotalCharacters.Int64,
|
||||
EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
||||
TreatAsComic: handlers.ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup),
|
||||
}
|
||||
// Progress conversion (inline)
|
||||
progressUUID, _ := uuid.FromBytes(progress.ID.Bytes[0:16])
|
||||
progressMediaUUID, _ := uuid.FromBytes(progress.MediaItemID.Bytes[0:16])
|
||||
progressUserUUID, _ := uuid.FromBytes(progress.UserID.Bytes[0:16])
|
||||
templateProgress := templates.ReadingProgress{
|
||||
ID: progressUUID.String(),
|
||||
MediaItemID: progressMediaUUID.String(),
|
||||
UserID: progressUserUUID.String(),
|
||||
CurrentPage: int(progress.CurrentPage.Int32),
|
||||
TotalPages: int(progress.TotalPages.Int32),
|
||||
Percentage: progress.Percentage.Float64 * 100,
|
||||
EpubCfi: textToString(progress.Epubcfi),
|
||||
LastReadAt: progress.LastReadAt.Time,
|
||||
Chapter: int(progress.Chapter.Int32),
|
||||
ChapterProgress: progress.ChapterProgress.Float64 * 100,
|
||||
FormatGroup: mediaItem.FormatGroup,
|
||||
}
|
||||
// Bookmarks conversion (inline, with loop)
|
||||
templateBookmarks := make([]templates.Bookmark, len(bookmarks))
|
||||
for i, b := range bookmarks {
|
||||
bookmarkUUID, _ := uuid.FromBytes(b.ID.Bytes[0:16])
|
||||
bookmarkMediaUUID, _ := uuid.FromBytes(b.MediaItemID.Bytes[0:16])
|
||||
bookmarkUserUUID, _ := uuid.FromBytes(b.UserID.Bytes[0:16])
|
||||
|
||||
var pageNumber *int
|
||||
if b.PageNumber.Valid {
|
||||
val := int(b.PageNumber.Int32)
|
||||
pageNumber = &val
|
||||
}
|
||||
|
||||
var chapterNumber *int
|
||||
if b.ChapterNumber.Valid {
|
||||
val := int(b.ChapterNumber.Int32)
|
||||
chapterNumber = &val
|
||||
}
|
||||
|
||||
templateBookmarks[i] = templates.Bookmark{
|
||||
ID: bookmarkUUID.String(),
|
||||
MediaItemID: bookmarkMediaUUID.String(),
|
||||
UserID: bookmarkUserUUID.String(),
|
||||
PageNumber: pageNumber,
|
||||
ChapterNumber: chapterNumber,
|
||||
CfiPosition: textToString(b.CfiPosition),
|
||||
Title: b.Title,
|
||||
Position: textToString(b.Position),
|
||||
Notes: textToString(b.Notes),
|
||||
CreatedAt: b.CreatedAt.Time,
|
||||
}
|
||||
}
|
||||
// 8. Render template
|
||||
// Render template
|
||||
var buf bytes.Buffer
|
||||
err = templates.Reader(user, metadata, templateProgress, templateBookmarks).Render(c.Request().Context(), &buf)
|
||||
err = templates.Reader(user, metadata).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error rendering reader", "render_error")
|
||||
}
|
||||
|
||||
+839
-227
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,410 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// createTestJPEGBytes returns the bytes of a minimal valid JPEG.
|
||||
func createTestJPEGBytes() string {
|
||||
var buf bytes.Buffer
|
||||
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
|
||||
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
||||
return ""
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// createPragmaticStyleEPUB builds an EPUB modeled on Pragmatic Bookshelf
|
||||
// output: the dc namespace declared on the <metadata> element (not on
|
||||
// <package>), a scheme-less ISBN identifier, an OPF-declared cover, and a
|
||||
// deliberately malformed chapter body. The malformed chapter is the
|
||||
// regression trigger: the previous go-epub-based extractor failed the whole
|
||||
// book when any chapter was unparseable and wrote blank metadata.
|
||||
func createPragmaticStyleEPUB(epubPath string) error {
|
||||
file, err := os.Create(epubPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
zipWriter := zip.NewWriter(file)
|
||||
defer zipWriter.Close()
|
||||
|
||||
mimetypeW, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: "mimetype",
|
||||
Method: zip.Store,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mimetypeW.Write([]byte("application/epub+zip"))
|
||||
|
||||
files := map[string]string{
|
||||
"META-INF/container.xml": `<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`,
|
||||
// dc namespace declared on <metadata>; identifiers carry no scheme attr
|
||||
"OEBPS/content.opf": `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="PubID">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:language>en</dc:language>
|
||||
<dc:title>A Common-Sense Guide</dc:title>
|
||||
<dc:creator>Jay Wengrow</dc:creator>
|
||||
<dc:publisher>The Pragmatic Bookshelf, LLC</dc:publisher>
|
||||
<dc:description>Content that makes you a better programmer.</dc:description>
|
||||
<dc:subject>Programming</dc:subject>
|
||||
<dc:identifier id="PubID">978-1-68050-722-8</dc:identifier>
|
||||
<meta name="cover" content="cover-image"/>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="cover-image" href="images/cover.jpg" media-type="image/jpeg"/>
|
||||
<item id="ch1" href="ch1.xhtml" media-type="application/xhtml+xml"/>
|
||||
</manifest>
|
||||
<spine><itemref idref="ch1"/></spine>
|
||||
</package>`,
|
||||
// Malformed on purpose: unclosed tags
|
||||
"OEBPS/ch1.xhtml": `<html><body><p>unclosed paragraph`,
|
||||
"OEBPS/images/cover.jpg": createTestJPEGBytes(),
|
||||
}
|
||||
|
||||
for name, content := range files {
|
||||
w, err := zipWriter.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write([]byte(content)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return zipWriter.Close()
|
||||
}
|
||||
|
||||
// TestExtractEPUBMetadataBrokenChapter guards the regression where one
|
||||
// unparseable chapter made the extractor return nothing at all: metadata must
|
||||
// come from the OPF regardless of chapter-body damage.
|
||||
func TestExtractEPUBMetadataBrokenChapter(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
epubPath := filepath.Join(tmpDir, "book.epub")
|
||||
if err := createPragmaticStyleEPUB(epubPath); err != nil {
|
||||
t.Fatalf("failed to create test EPUB: %v", err)
|
||||
}
|
||||
|
||||
s := NewMediaScanner(nil)
|
||||
metadata, err := s.extractEPUBMetadata(epubPath)
|
||||
if err != nil {
|
||||
t.Fatalf("extractEPUBMetadata() error: %v", err)
|
||||
}
|
||||
if metadata.Title != "A Common-Sense Guide" {
|
||||
t.Errorf("Title = %q, want %q", metadata.Title, "A Common-Sense Guide")
|
||||
}
|
||||
if metadata.Author != "Jay Wengrow" {
|
||||
t.Errorf("Author = %q, want %q", metadata.Author, "Jay Wengrow")
|
||||
}
|
||||
if metadata.Publisher != "The Pragmatic Bookshelf, LLC" {
|
||||
t.Errorf("Publisher = %q, want %q", metadata.Publisher, "The Pragmatic Bookshelf, LLC")
|
||||
}
|
||||
if metadata.Description == "" {
|
||||
t.Error("Description missing")
|
||||
}
|
||||
if metadata.Language != "en" {
|
||||
t.Errorf("Language = %q, want %q", metadata.Language, "en")
|
||||
}
|
||||
// Scheme-less identifier that normalizes to a valid ISBN must be picked up
|
||||
if metadata.ISBN == "" {
|
||||
t.Error("ISBN missing (scheme-less dc:identifier fallback failed)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOPFContentCalibreSeries(t *testing.T) {
|
||||
opf := `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="id">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
|
||||
<dc:title>Test Book</dc:title>
|
||||
<dc:creator>Some Author</dc:creator>
|
||||
<dc:date>2020-03-15</dc:date>
|
||||
<dc:subject>Fiction</dc:subject>
|
||||
<dc:subject>Classic</dc:subject>
|
||||
<dc:identifier opf:scheme="ISBN">978-3-16-148410-0</dc:identifier>
|
||||
<meta name="calibre:series" content="Great Series"/>
|
||||
<meta name="calibre:series_index" content="2.5"/>
|
||||
</metadata>
|
||||
</package>`
|
||||
metadata, err := parseOPFContent([]byte(opf))
|
||||
if err != nil {
|
||||
t.Fatalf("parseOPFContent() error: %v", err)
|
||||
}
|
||||
if metadata.Series != "Great Series" || metadata.SeriesNumber != 2 {
|
||||
t.Errorf("Series = %q/%d, want Great Series/2", metadata.Series, metadata.SeriesNumber)
|
||||
}
|
||||
if metadata.ISBN == "" {
|
||||
t.Error("schemed ISBN not extracted")
|
||||
}
|
||||
wantDate := time.Date(2020, 3, 15, 0, 0, 0, 0, time.UTC)
|
||||
if !metadata.PublishDate.Equal(wantDate) {
|
||||
t.Errorf("PublishDate = %v, want %v", metadata.PublishDate, wantDate)
|
||||
}
|
||||
if len(metadata.Tags) != 2 {
|
||||
t.Errorf("Tags = %v, want 2 subjects", metadata.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAudiobookshelfSidecar(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
validate func(t *testing.T, m *MediaMetadata)
|
||||
}{
|
||||
{
|
||||
name: "full sidecar",
|
||||
json: `{
|
||||
"title": "An Book",
|
||||
"authors": ["Author One", "Author Two"],
|
||||
"series": [{"series": "The Series", "sequence": "4.5"}],
|
||||
"genres": ["Fantasy"],
|
||||
"tags": ["tag1"],
|
||||
"publishedYear": 2019,
|
||||
"publisher": "ACME Books",
|
||||
"description": "A very good book.",
|
||||
"isbn": "978-3-16-148410-0",
|
||||
"asin": "B08XYZ",
|
||||
"language": "en"
|
||||
}`,
|
||||
validate: func(t *testing.T, m *MediaMetadata) {
|
||||
if m.Title != "An Book" || m.Author != "Author One" {
|
||||
t.Errorf("Title/Author = %q/%q", m.Title, m.Author)
|
||||
}
|
||||
if m.Series != "The Series" || m.SeriesNumber != 4 {
|
||||
t.Errorf("Series = %q/%d, want The Series/4", m.Series, m.SeriesNumber)
|
||||
}
|
||||
if len(m.Tags) != 2 {
|
||||
t.Errorf("Tags = %v, want genres+tags merged", m.Tags)
|
||||
}
|
||||
if m.PublishDate.Year() != 2019 {
|
||||
t.Errorf("PublishDate year = %d, want 2019", m.PublishDate.Year())
|
||||
}
|
||||
if m.Publisher != "ACME Books" || m.Description != "A very good book." {
|
||||
t.Errorf("Publisher/Description = %q/%q", m.Publisher, m.Description)
|
||||
}
|
||||
if m.ISBN == "" || m.ASIN != "B08XYZ" || m.Language != "en" {
|
||||
t.Errorf("ISBN/ASIN/Language = %q/%q/%q", m.ISBN, m.ASIN, m.Language)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sparse sidecar (real-world Audiobookshelf export)",
|
||||
json: `{"title": "Sparse (1234)", "authors": ["X"], "tags": [], "description": null}`,
|
||||
validate: func(t *testing.T, m *MediaMetadata) {
|
||||
if m.Title != "Sparse (1234)" || m.Author != "X" {
|
||||
t.Errorf("Title/Author = %q/%q", m.Title, m.Author)
|
||||
}
|
||||
if m.Description != "" || m.Tags != nil {
|
||||
t.Error("null/empty sidecar fields must stay unset")
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(tt.json), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := extractAudiobookshelfSidecar(filepath.Join(dir, "book.epub"))
|
||||
if m == nil {
|
||||
t.Fatal("extractAudiobookshelfSidecar() = nil, want metadata")
|
||||
}
|
||||
tt.validate(t, m)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("no sidecar returns nil", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if m := extractAudiobookshelfSidecar(filepath.Join(dir, "book.epub")); m != nil {
|
||||
t.Errorf("extractAudiobookshelfSidecar() = %v, want nil", m)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// createTestPDFWithImage builds a minimal one-page PDF embedding a JPEG
|
||||
// XObject, so pdfcpu-based cover extraction has an image to find.
|
||||
func createTestPDFWithImage() []byte {
|
||||
jpegData := []byte(createTestJPEGBytes())
|
||||
content := "q 100 0 0 100 0 0 cm /Im0 Do Q"
|
||||
|
||||
var buf bytes.Buffer
|
||||
offsets := []int{0} // object numbers are 1-based
|
||||
buf.WriteString("%PDF-1.4\n")
|
||||
|
||||
writeObj := func(n int, body func(w *bytes.Buffer)) {
|
||||
offsets = append(offsets, buf.Len())
|
||||
fmt.Fprintf(&buf, "%d 0 obj\n", n)
|
||||
body(&buf)
|
||||
buf.WriteString("endobj\n")
|
||||
}
|
||||
|
||||
writeObj(1, func(w *bytes.Buffer) { w.WriteString("<< /Type /Catalog /Pages 2 0 R >>\n") })
|
||||
writeObj(2, func(w *bytes.Buffer) { w.WriteString("<< /Type /Pages /Kids [3 0 R] /Count 1 >>\n") })
|
||||
writeObj(3, func(w *bytes.Buffer) {
|
||||
w.WriteString("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>\n")
|
||||
})
|
||||
writeObj(4, func(w *bytes.Buffer) {
|
||||
fmt.Fprintf(w, "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length %d >>\nstream\n", len(jpegData))
|
||||
w.Write(jpegData)
|
||||
w.WriteString("\nendstream\n")
|
||||
})
|
||||
writeObj(5, func(w *bytes.Buffer) {
|
||||
fmt.Fprintf(w, "<< /Length %d >>\nstream\n%s\nendstream\n", len(content), content)
|
||||
})
|
||||
writeObj(6, func(w *bytes.Buffer) {
|
||||
// Info dictionary deliberately richer than the sidecars in gap-fill
|
||||
// tests: gap-fill must use these, never overwrite with them.
|
||||
w.WriteString("<< /Title (Embedded Title) /Author (Embedded Author) /Subject (Embedded Subject) /Producer (Embedded Producer) /Keywords (embedded-kw) >>\n")
|
||||
})
|
||||
|
||||
xrefStart := buf.Len()
|
||||
fmt.Fprintf(&buf, "xref\n0 %d\n0000000000 65535 f \n", len(offsets))
|
||||
for _, off := range offsets[1:] {
|
||||
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
|
||||
}
|
||||
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R /Info 6 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets), xrefStart)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// TestSidecarCoverFallback guards the regression where a metadata.json
|
||||
// sidecar (no cover.jpg next to the book) made extractMetadata return an
|
||||
// empty CoverPath for PDFs - mergeMetadata has no PDF/EPUB cover logic, so a
|
||||
// full rescan wiped cover_image_path for sidecar-managed books.
|
||||
func TestSidecarCoverFallback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pdfPath := filepath.Join(dir, "book.pdf")
|
||||
if err := os.WriteFile(pdfPath, createTestPDFWithImage(), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(`{"title":"Sidecar Book","authors":["A"]}`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := NewMediaScanner(nil)
|
||||
s.folders = []string{dir} // SetFolders requires a live DB; tests only need path relativization
|
||||
metadata, err := s.extractMetadata(pdfPath)
|
||||
if err != nil {
|
||||
t.Fatalf("extractMetadata() error: %v", err)
|
||||
}
|
||||
if metadata.Title != "Sidecar Book" {
|
||||
t.Errorf("Title = %q, want sidecar title", metadata.Title)
|
||||
}
|
||||
if metadata.CoverPath == "" {
|
||||
t.Fatal("CoverPath empty - sidecar branch skipped embedded cover extraction")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, metadata.CoverPath)); err != nil {
|
||||
t.Errorf("extracted cover not on disk: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeMetadataEPUBGapFill verifies that a sparse sidecar (metadata.opf
|
||||
// with only a title) gets its blanks filled from the book's own embedded OPF
|
||||
// while sidecar-set fields are never overwritten.
|
||||
func TestMergeMetadataEPUBGapFill(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
epubPath := filepath.Join(dir, "gappy.epub")
|
||||
f, err := os.Create(epubPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zipWriter := zip.NewWriter(f)
|
||||
mimetype, _ := zipWriter.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
|
||||
mimetype.Write([]byte("application/epub+zip"))
|
||||
epubFiles := map[string]string{
|
||||
"META-INF/container.xml": `<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`,
|
||||
"OEBPS/content.opf": `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="u">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>Embedded Title</dc:title>
|
||||
<dc:creator>Embedded Author</dc:creator>
|
||||
<dc:description>Embedded description from the book.</dc:description>
|
||||
<dc:publisher>Embedded Publisher</dc:publisher>
|
||||
<dc:language>en</dc:language>
|
||||
<dc:date>2021-06-01</dc:date>
|
||||
</metadata>
|
||||
<manifest/>
|
||||
<spine/>
|
||||
</package>`,
|
||||
}
|
||||
for name, content := range epubFiles {
|
||||
w, err := zipWriter.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Write([]byte(content))
|
||||
}
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
sparse := &MediaMetadata{Title: "Sidecar Title", Author: "Sidecar Author"}
|
||||
s := NewMediaScanner(nil)
|
||||
merged, err := s.mergeMetadata(epubPath, sparse)
|
||||
if err != nil {
|
||||
t.Fatalf("mergeMetadata() error: %v", err)
|
||||
}
|
||||
if merged.Title != "Sidecar Title" || merged.Author != "Sidecar Author" {
|
||||
t.Errorf("sidecar values overwritten: title=%q author=%q", merged.Title, merged.Author)
|
||||
}
|
||||
if merged.Description != "Embedded description from the book." {
|
||||
t.Errorf("Description = %q, want embedded fill", merged.Description)
|
||||
}
|
||||
if merged.Publisher != "Embedded Publisher" {
|
||||
t.Errorf("Publisher = %q, want embedded fill", merged.Publisher)
|
||||
}
|
||||
if merged.Language != "en" {
|
||||
t.Errorf("Language = %q, want embedded fill", merged.Language)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeMetadataPDFGapFill verifies the same for PDFs: a sparse sidecar
|
||||
// keeps its title while author/description/publisher/tags/pagecount fill in
|
||||
// from the embedded Info dictionary.
|
||||
func TestMergeMetadataPDFGapFill(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pdfPath := filepath.Join(dir, "gappy.pdf")
|
||||
if err := os.WriteFile(pdfPath, createTestPDFWithImage(), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sparse := &MediaMetadata{Title: "Kept Title"}
|
||||
s := NewMediaScanner(nil)
|
||||
merged, err := s.mergeMetadata(pdfPath, sparse)
|
||||
if err != nil {
|
||||
t.Fatalf("mergeMetadata() error: %v", err)
|
||||
}
|
||||
|
||||
checks := []struct {
|
||||
name string
|
||||
got string
|
||||
want string
|
||||
}{
|
||||
{"Title (sidecar wins)", merged.Title, "Kept Title"},
|
||||
{"Author", merged.Author, "Embedded Author"},
|
||||
{"Description", merged.Description, "Embedded Subject"},
|
||||
{"Publisher", merged.Publisher, "Embedded Producer"},
|
||||
}
|
||||
for _, c := range checks {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
|
||||
}
|
||||
}
|
||||
if len(merged.Tags) == 0 {
|
||||
t.Error("Tags empty, want keywords from Info dict")
|
||||
}
|
||||
if merged.PageCount != 1 {
|
||||
t.Errorf("PageCount = %d, want 1 from Info dict", merged.PageCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func mustUUID(t *testing.T, raw string) pgtype.UUID {
|
||||
t.Helper()
|
||||
parsed, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("invalid test uuid %q: %v", raw, err)
|
||||
}
|
||||
return pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
|
||||
// alwaysExists simulates every candidate's file still being on disk:
|
||||
// deliberate copies across libraries must never be repointed.
|
||||
func TestSelectMoveCandidateAllFilesExist(t *testing.T) {
|
||||
candidates := []moveCandidate{
|
||||
{ID: mustUUID(t, "00000000-0000-0000-0000-000000000001"), FilePath: "a/book.epub"},
|
||||
{ID: mustUUID(t, "00000000-0000-0000-0000-000000000002"), FilePath: "b/book.epub"},
|
||||
}
|
||||
|
||||
if _, ok := selectMoveCandidate(candidates, func(moveCandidate) bool { return true }); ok {
|
||||
t.Fatal("expected no move candidate when every file still exists")
|
||||
}
|
||||
}
|
||||
|
||||
// Input arrives pre-ordered (archived first, then most missing scans, then
|
||||
// oldest), so the first missing candidate must win.
|
||||
func TestSelectMoveCandidatePicksFirstMissing(t *testing.T) {
|
||||
onDisk := moveCandidate{ID: mustUUID(t, "00000000-0000-0000-0000-000000000001"), FilePath: "a/book.epub"}
|
||||
gone := moveCandidate{ID: mustUUID(t, "00000000-0000-0000-0000-000000000002"), FilePath: "b/book.epub"}
|
||||
candidates := []moveCandidate{onDisk, gone}
|
||||
|
||||
got, ok := selectMoveCandidate(candidates, func(c moveCandidate) bool {
|
||||
return c.ID == onDisk.ID
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected a move candidate")
|
||||
}
|
||||
if got.ID != gone.ID {
|
||||
t.Fatalf("expected candidate %v, got %v", gone.ID, got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMoveCandidateOnlyMissingQualifies(t *testing.T) {
|
||||
gone := moveCandidate{ID: mustUUID(t, "00000000-0000-0000-0000-000000000001"), FilePath: "old/book.epub"}
|
||||
candidates := []moveCandidate{gone}
|
||||
|
||||
got, ok := selectMoveCandidate(candidates, func(moveCandidate) bool { return false })
|
||||
if !ok {
|
||||
t.Fatal("expected the missing candidate to qualify")
|
||||
}
|
||||
if got.ID != gone.ID {
|
||||
t.Fatalf("expected candidate %v, got %v", gone.ID, got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMoveCandidateEmptyList(t *testing.T) {
|
||||
if _, ok := selectMoveCandidate(nil, func(moveCandidate) bool { return false }); ok {
|
||||
t.Fatal("expected no candidate for an empty list")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Calibre-modeled OPF parsing. The scanner previously scraped OPF content
|
||||
// with attribute-order-sensitive regexes; real books serialize attributes in
|
||||
// any order (e.g. Pragmatic/Pattinson EPUBs put id before properties and
|
||||
// content before name), which silently defeated cover detection. Everything
|
||||
// here is parsed with encoding/xml so attribute order and namespace prefix
|
||||
// choices are irrelevant.
|
||||
|
||||
type opfDCValue struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type opfIdentifier struct {
|
||||
Scheme string `xml:"http://www.idpf.org/2007/opf scheme,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type opfMeta struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Name string `xml:"name,attr"`
|
||||
Content string `xml:"content,attr"`
|
||||
Property string `xml:"property,attr"`
|
||||
Refines string `xml:"refines,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type opfItem struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Href string `xml:"href,attr"`
|
||||
MediaType string `xml:"media-type,attr"`
|
||||
Properties string `xml:"properties,attr"`
|
||||
}
|
||||
|
||||
// opfDocument is a structured view of an OPF package document.
|
||||
type opfDocument struct {
|
||||
Metadata struct {
|
||||
Titles []opfDCValue `xml:"http://purl.org/dc/elements/1.1/ title"`
|
||||
Creators []string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
||||
Subjects []string `xml:"http://purl.org/dc/elements/1.1/ subject"`
|
||||
Descriptions []string `xml:"http://purl.org/dc/elements/1.1/ description"`
|
||||
Publishers []string `xml:"http://purl.org/dc/elements/1.1/ publisher"`
|
||||
Dates []string `xml:"http://purl.org/dc/elements/1.1/ date"`
|
||||
Languages []string `xml:"http://purl.org/dc/elements/1.1/ language"`
|
||||
Identifiers []opfIdentifier `xml:"http://purl.org/dc/elements/1.1/ identifier"`
|
||||
Contributors []string `xml:"http://purl.org/dc/elements/1.1/ contributor"`
|
||||
Metas []opfMeta `xml:"meta"`
|
||||
} `xml:"metadata"`
|
||||
Manifest struct {
|
||||
Items []opfItem `xml:"item"`
|
||||
} `xml:"manifest"`
|
||||
Spine struct {
|
||||
PageProgressionDirection string `xml:"page-progression-direction,attr"`
|
||||
Itemrefs []struct {
|
||||
IDRef string `xml:"idref,attr"`
|
||||
} `xml:"itemref"`
|
||||
} `xml:"spine"`
|
||||
Guide struct {
|
||||
References []struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Href string `xml:"href,attr"`
|
||||
} `xml:"reference"`
|
||||
} `xml:"guide"`
|
||||
}
|
||||
|
||||
func parseOPFXML(content []byte) (*opfDocument, error) {
|
||||
var doc opfDocument
|
||||
if err := xml.Unmarshal(content, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
// refinesFor maps an element id to its EPUB3 refining metas
|
||||
// (those whose refines attribute starts with '#').
|
||||
func (d *opfDocument) refinesFor(id string) []opfMeta {
|
||||
var out []opfMeta
|
||||
if id == "" {
|
||||
return out
|
||||
}
|
||||
for _, m := range d.Metadata.Metas {
|
||||
if strings.HasPrefix(m.Refines, "#") && m.Refines[1:] == id {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// refinesProperty returns the value of the first refining meta carrying the
|
||||
// given property (e.g. "title-type", "collection-type", "group-position").
|
||||
func refinesProperty(metas []opfMeta, property string) (string, bool) {
|
||||
for _, m := range metas {
|
||||
if strings.EqualFold(m.Property, property) {
|
||||
if v := strings.TrimSpace(m.Value); v != "" {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// selectTitle ports Calibre's read_title: prefer the dc:title refined as
|
||||
// title-type "main"; fall back to the first non-empty title. A distinct
|
||||
// subtitle (title-type containing "subtitle"/"sub-title") is joined onto the
|
||||
// main title with ": ", exactly as Calibre stores it.
|
||||
func (d *opfDocument) selectTitle() string {
|
||||
var first, main, subtitle string
|
||||
for _, t := range d.Metadata.Titles {
|
||||
v := strings.TrimSpace(t.Value)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if first == "" {
|
||||
first = v
|
||||
}
|
||||
tt, ok := refinesProperty(d.refinesFor(t.ID), "title-type")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(tt) {
|
||||
case "main":
|
||||
if main == "" {
|
||||
main = v
|
||||
}
|
||||
default:
|
||||
l := strings.ToLower(tt)
|
||||
if strings.Contains(l, "subtitle") || strings.Contains(l, "sub-title") {
|
||||
if subtitle == "" {
|
||||
subtitle = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
title := main
|
||||
if title == "" {
|
||||
title = first
|
||||
}
|
||||
if subtitle != "" && subtitle != title {
|
||||
title = title + ": " + subtitle
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
// readSeries ports Calibre's read_series: EPUB3 belongs-to-collection (with a
|
||||
// collection-type=series refine and group-position index) first, then the
|
||||
// classic calibre:series / calibre:series_index metas.
|
||||
func (d *opfDocument) readSeries() (series string, index float64) {
|
||||
for _, m := range d.Metadata.Metas {
|
||||
if !strings.EqualFold(m.Property, "belongs-to-collection") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(m.Value)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
refines := d.refinesFor(m.ID)
|
||||
if ct, ok := refinesProperty(refines, "collection-type"); !ok || !strings.EqualFold(ct, "series") {
|
||||
continue
|
||||
}
|
||||
if gp, ok := refinesProperty(refines, "group-position"); ok {
|
||||
if v, err := strconv.ParseFloat(strings.TrimSpace(gp), 64); err == nil {
|
||||
index = v
|
||||
}
|
||||
}
|
||||
return name, index
|
||||
}
|
||||
for _, m := range d.Metadata.Metas {
|
||||
switch m.Name {
|
||||
case "calibre:series":
|
||||
series = m.Content
|
||||
case "calibre:series_index":
|
||||
if v, err := strconv.ParseFloat(strings.TrimSpace(m.Content), 64); err == nil {
|
||||
index = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return series, index
|
||||
}
|
||||
|
||||
// pageProgressionDirection returns the OPF spine's reading direction as
|
||||
// "rtl" or "ltr", or "" when the file declares none (callers treat that as
|
||||
// unknown, not as left-to-right). EPUB2/3 declare this on <spine>; it is
|
||||
// what foliate reads client-side, and the DB column feeds clients (and the
|
||||
// web reader's fixed-layout override) that need it up front.
|
||||
func (d *opfDocument) pageProgressionDirection() string {
|
||||
switch strings.ToLower(strings.TrimSpace(d.Spine.PageProgressionDirection)) {
|
||||
case "rtl", "right-to-left":
|
||||
return "rtl"
|
||||
case "ltr", "left-to-right", "default":
|
||||
return "ltr"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// itemByID returns manifest items with id, href and media-type, keyed by id.
|
||||
func (d *opfDocument) itemByID() map[string]opfItem {
|
||||
m := make(map[string]opfItem, len(d.Manifest.Items))
|
||||
for _, it := range d.Manifest.Items {
|
||||
if it.ID != "" && it.Href != "" && it.MediaType != "" {
|
||||
m[it.ID] = it
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// firstSpineItem returns the manifest item for the first spine idref.
|
||||
func (d *opfDocument) firstSpineItem() (opfItem, bool) {
|
||||
if len(d.Spine.Itemrefs) == 0 {
|
||||
return opfItem{}, false
|
||||
}
|
||||
item, ok := d.itemByID()[d.Spine.Itemrefs[0].IDRef]
|
||||
return item, ok
|
||||
}
|
||||
|
||||
// isRasterMedia reports whether a manifest media-type is an image but not an
|
||||
// (X)HTML document - Calibre's guard against cover *pages* masquerading as
|
||||
// cover images.
|
||||
func isRasterMedia(mediaType string) bool {
|
||||
mt := strings.ToLower(strings.TrimSpace(mediaType))
|
||||
if mt == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(mt, "xml") || strings.Contains(mt, "html") {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(mt, "image/")
|
||||
}
|
||||
|
||||
// findRasterCoverInOPF ports Calibre's read_raster_cover resolution order:
|
||||
// 1. manifest item with properties containing "cover-image"
|
||||
// 2. <meta name="cover" content="ID"> resolved through the manifest
|
||||
// 3. the first spine item being a raster image itself (store manga)
|
||||
//
|
||||
// Returns the OPF-relative href of the cover image, or "".
|
||||
func (d *opfDocument) findRasterCoverInOPF() string {
|
||||
// 1. properties="cover-image" (space-separated property list)
|
||||
for _, it := range d.Manifest.Items {
|
||||
for _, prop := range strings.Fields(it.Properties) {
|
||||
if strings.EqualFold(prop, "cover-image") && isRasterMedia(it.MediaType) {
|
||||
return it.Href
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. meta name="cover" content=<manifest image id>
|
||||
byID := d.itemByID()
|
||||
for _, m := range d.Metadata.Metas {
|
||||
if !strings.EqualFold(m.Name, "cover") {
|
||||
continue
|
||||
}
|
||||
if it, ok := byID[strings.TrimSpace(m.Content)]; ok && isRasterMedia(it.MediaType) {
|
||||
return it.Href
|
||||
}
|
||||
}
|
||||
|
||||
// 3. first spine item is itself an image (jpeg/webp/png per Calibre)
|
||||
if it, ok := d.firstSpineItem(); ok {
|
||||
mt := strings.ToLower(it.MediaType)
|
||||
if mt == "image/jpeg" || mt == "image/webp" || mt == "image/png" {
|
||||
return it.Href
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// coverPageHref returns the OPF-relative href of the cover *page* document to
|
||||
// mine for an embedded image: the guide's type="cover" reference when
|
||||
// present, otherwise the first spine item (Calibre renders the latter).
|
||||
func (d *opfDocument) coverPageHref() string {
|
||||
for _, ref := range d.Guide.References {
|
||||
if strings.EqualFold(ref.Type, "cover") && ref.Href != "" {
|
||||
return ref.Href
|
||||
}
|
||||
}
|
||||
if it, ok := d.firstSpineItem(); ok {
|
||||
if it.Href != "" && !isRasterMedia(it.MediaType) {
|
||||
return it.Href
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isISBNLike reports whether a bare identifier value is shaped like an ISBN
|
||||
// (digits, optional hyphens/spaces, optional trailing X; 10 or 13
|
||||
// significant characters). Guards the scheme-less dc:identifier fallback
|
||||
// against URLs and UUIDs sharing the same slot.
|
||||
func isISBNLike(v string) bool {
|
||||
digits := 0
|
||||
for i, r := range v {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
digits++
|
||||
case r == '-' || r == ' ':
|
||||
// separator
|
||||
case (r == 'X' || r == 'x') && i == len(v)-1:
|
||||
digits++ // ISBN-10 check character
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return digits == 10 || digits == 13
|
||||
}
|
||||
|
||||
// findImageReferenceInPage extracts the first raster image reference from a
|
||||
// cover (X)HTML page: <img src="..."> or SVG <image xlink:href="...">.
|
||||
// Token-based parsing keeps it tolerant of mixed namespaces and fragments.
|
||||
// Returns the reference relative to the page document, or "".
|
||||
func findImageReferenceInPage(pageContent []byte) string {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(pageContent))
|
||||
for {
|
||||
tok, err := decoder.Token()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
start, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(start.Name.Local) {
|
||||
case "img":
|
||||
for _, a := range start.Attr {
|
||||
if strings.EqualFold(a.Name.Local, "src") && strings.TrimSpace(a.Value) != "" {
|
||||
return strings.TrimSpace(a.Value)
|
||||
}
|
||||
}
|
||||
case "image":
|
||||
for _, a := range start.Attr {
|
||||
if strings.EqualFold(a.Name.Local, "href") && strings.TrimSpace(a.Value) != "" {
|
||||
return strings.TrimSpace(a.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// helper to build an EPUB zip from a file map for cover tests
|
||||
func writeEPUB(t *testing.T, path string, files map[string]string) {
|
||||
t.Helper()
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
w := zip.NewWriter(f)
|
||||
mimetype, err := w.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mimetype.Write([]byte("application/epub+zip"))
|
||||
for name, content := range files {
|
||||
fw, err := w.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fw.Write([]byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
const containerXML = `<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/package.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`
|
||||
|
||||
const tinyJPEG = "\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
|
||||
|
||||
// TestFindCoverInOPFAttributeOrder guards the regression where attribute
|
||||
// order defeated regex scraping: this OPF mirrors Grand Central's "3 Days to
|
||||
// Live" serialization (href before id, content before name on the meta tag).
|
||||
func TestFindCoverInOPFAttributeOrder(t *testing.T) {
|
||||
opf := `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>3 Days to Live</dc:title>
|
||||
<meta content="cover-image" name="cover"/>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item href="images/9781538752760.jpg" id="cover-image" media-type="image/jpeg" properties="cover-image"/>
|
||||
</manifest>
|
||||
<spine/>
|
||||
</package>`
|
||||
files := map[string]string{
|
||||
"META-INF/container.xml": containerXML,
|
||||
"OEBPS/package.opf": opf,
|
||||
"OEBPS/images/9781538752760.jpg": tinyJPEG,
|
||||
}
|
||||
epubPath := filepath.Join(t.TempDir(), "book.epub")
|
||||
writeEPUB(t, epubPath, files)
|
||||
|
||||
s := NewMediaScanner(nil)
|
||||
coverPath, err := s.extractEPUBCover(epubPath)
|
||||
if err != nil {
|
||||
t.Fatalf("extractEPUBCover() error: %v", err)
|
||||
}
|
||||
if coverPath == "" {
|
||||
t.Fatal("cover not extracted - attribute order still defeats resolution")
|
||||
}
|
||||
if _, err := os.Stat(coverPath); err != nil {
|
||||
t.Fatalf("cover file not written: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindCoverInOPFCoverPage covers books that declare no raster cover at
|
||||
// all: the classic EPUB2/Adobe structure where cover.xhtml wraps the image
|
||||
// (here via SVG), reachable through the guide reference or first spine item.
|
||||
func TestFindCoverInOPFCoverPage(t *testing.T) {
|
||||
opf := `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>Old Adobe Book</dc:title>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="coverpage" href="text/cover.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="coverimg" href="art/cover-wrap.jpg" media-type="image/jpeg"/>
|
||||
</manifest>
|
||||
<spine><itemref idref="coverpage"/></spine>
|
||||
<guide><reference type="cover" href="text/cover.xhtml"/></guide>
|
||||
</package>`
|
||||
coverPage := `<?xml version="1.0"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<body>
|
||||
<div><svg xmlns="http://www.w3.org/2000/svg"><image xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="../art/cover-wrap.jpg"/></svg></div>
|
||||
</body>
|
||||
</html>`
|
||||
files := map[string]string{
|
||||
"META-INF/container.xml": containerXML,
|
||||
"OEBPS/package.opf": opf,
|
||||
"OEBPS/text/cover.xhtml": coverPage,
|
||||
"OEBPS/art/cover-wrap.jpg": tinyJPEG,
|
||||
}
|
||||
epubPath := filepath.Join(t.TempDir(), "adobe.epub")
|
||||
writeEPUB(t, epubPath, files)
|
||||
|
||||
s := NewMediaScanner(nil)
|
||||
coverPath, err := s.extractEPUBCover(epubPath)
|
||||
if err != nil {
|
||||
t.Fatalf("extractEPUBCover() error: %v", err)
|
||||
}
|
||||
if coverPath == "" {
|
||||
t.Fatal("cover-page fallback failed to find SVG-wrapped image")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindCoverInOPFImageFirstSpine covers store manga whose first spine
|
||||
// item is a raster image itself (Calibre's third resolution step).
|
||||
func TestFindCoverInOPFImageFirstSpine(t *testing.T) {
|
||||
opf := `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Manga Vol 1</dc:title></metadata>
|
||||
<manifest>
|
||||
<item id="p1" href="pages/0001.jpg" media-type="image/jpeg"/>
|
||||
</manifest>
|
||||
<spine><itemref idref="p1"/></spine>
|
||||
</package>`
|
||||
files := map[string]string{
|
||||
"META-INF/container.xml": containerXML,
|
||||
"OEBPS/package.opf": opf,
|
||||
"OEBPS/pages/0001.jpg": tinyJPEG,
|
||||
}
|
||||
epubPath := filepath.Join(t.TempDir(), "manga.epub")
|
||||
writeEPUB(t, epubPath, files)
|
||||
|
||||
s := NewMediaScanner(nil)
|
||||
coverPath, err := s.extractEPUBCover(epubPath)
|
||||
if err != nil {
|
||||
t.Fatalf("extractEPUBCover() error: %v", err)
|
||||
}
|
||||
if coverPath == "" {
|
||||
t.Fatal("image-first spine cover not detected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseOPFContentTitleTypeAndSeries covers EPUB3 refines-based title
|
||||
// selection (main + subtitle joined Calibre-style) and belongs-to-collection
|
||||
// series with collection-type and group-position refines.
|
||||
func TestParseOPFContentTitleTypeAndSeries(t *testing.T) {
|
||||
opf := `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title id="t1">The Main Title</dc:title>
|
||||
<dc:title id="t2">The Subtitle</dc:title>
|
||||
<meta refines="#t1" property="title-type">main</meta>
|
||||
<meta refines="#t2" property="title-type">subtitle</meta>
|
||||
<dc:subject>Programming</dc:subject>
|
||||
<dc:subject>Algorithms</dc:subject>
|
||||
<dc:identifier>urn:isbn:978-3-16-148410-0</dc:identifier>
|
||||
<meta id="coll1" property="belongs-to-collection">Great Series</meta>
|
||||
<meta refines="#coll1" property="collection-type">series</meta>
|
||||
<meta refines="#coll1" property="group-position">4.5</meta>
|
||||
</metadata>
|
||||
</package>`
|
||||
metadata, err := parseOPFContent([]byte(opf))
|
||||
if err != nil {
|
||||
t.Fatalf("parseOPFContent() error: %v", err)
|
||||
}
|
||||
if want := "The Main Title: The Subtitle"; metadata.Title != want {
|
||||
t.Errorf("Title = %q, want %q", metadata.Title, want)
|
||||
}
|
||||
if metadata.Series != "Great Series" || metadata.SeriesNumber != 4 {
|
||||
t.Errorf("Series = %q/%d, want Great Series/4", metadata.Series, metadata.SeriesNumber)
|
||||
}
|
||||
if metadata.ISBN == "" {
|
||||
t.Error("urn:isbn: identifier not extracted")
|
||||
}
|
||||
if metadata.Genre != "Programming" {
|
||||
t.Errorf("Genre = %q, want first subject %q", metadata.Genre, "Programming")
|
||||
}
|
||||
if len(metadata.Tags) != 2 {
|
||||
t.Errorf("Tags = %v, want both subjects", metadata.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseOPFContentSubjectsWithCommas verifies subject headings keep their
|
||||
// embedded commas as single tags (Library of Congress style headings).
|
||||
func TestParseOPFContentSubjectsWithCommas(t *testing.T) {
|
||||
opf := `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>A Study in Scarlet</dc:title>
|
||||
<dc:subject>Holmes, Sherlock (Fictitious character) -- Fiction</dc:subject>
|
||||
</metadata>
|
||||
</package>`
|
||||
metadata, err := parseOPFContent([]byte(opf))
|
||||
if err != nil {
|
||||
t.Fatalf("parseOPFContent() error: %v", err)
|
||||
}
|
||||
if len(metadata.Tags) != 1 {
|
||||
t.Errorf("Tags = %v, want exactly 1 unsplit subject heading", metadata.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveOPFPath checks URL decoding and posix normalization of
|
||||
// OPF-relative hrefs.
|
||||
func TestResolveOPFPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
opfPath, href, want string
|
||||
}{
|
||||
{"OEBPS/package.opf", "images/cover.jpg", "OEBPS/images/cover.jpg"},
|
||||
{"package.opf", "cover.jpg", "cover.jpg"},
|
||||
{"OEBPS/package.opf", "../cover.jpg", "cover.jpg"},
|
||||
{"OEBPS/package.opf", "my%20covers/a%20cover.jpg", "OEBPS/my covers/a cover.jpg"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := resolveOPFPath(tt.opfPath, tt.href); got != tt.want {
|
||||
t.Errorf("resolveOPFPath(%q, %q) = %q, want %q", tt.opfPath, tt.href, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseOPFContentPageProgressionDirection verifies the EPUB spine's
|
||||
// page-progression-direction feeds ReadingDirection, and that an undeclared
|
||||
// direction stays empty rather than forcing left-to-right.
|
||||
func TestParseOPFContentPageProgressionDirection(t *testing.T) {
|
||||
makeOPF := func(spineAttrs string) string {
|
||||
return `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Dir Test</dc:title></metadata>
|
||||
<manifest><item id="p1" href="p1.xhtml" media-type="application/xhtml+xml"/></manifest>
|
||||
<spine` + spineAttrs + `><itemref idref="p1"/></spine>
|
||||
</package>`
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
spineAttrs string
|
||||
want string
|
||||
}{
|
||||
{"rtl declared lowercase", ` page-progression-direction="rtl"`, "rtl"},
|
||||
{"rtl declared uppercase", ` page-progression-direction="RTL"`, "rtl"},
|
||||
{"ltr declared", ` page-progression-direction="ltr"`, "ltr"},
|
||||
{"undeclared stays empty", ``, ""},
|
||||
{"unknown value stays empty", ` page-progression-direction="sideways"`, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
metadata, err := parseOPFContent([]byte(makeOPF(tt.spineAttrs)))
|
||||
if err != nil {
|
||||
t.Fatalf("parseOPFContent() error: %v", err)
|
||||
}
|
||||
if metadata.ReadingDirection != tt.want {
|
||||
t.Errorf("ReadingDirection = %q, want %q", metadata.ReadingDirection, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeMetadataReadingDirectionGapFill verifies the sidecar wins when it
|
||||
// declares a direction, while an embedded-only direction fills the blank.
|
||||
func TestMergeMetadataReadingDirectionGapFill(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
epubPath := filepath.Join(dir, "dir.epub")
|
||||
files := map[string]string{
|
||||
"META-INF/container.xml": containerXML,
|
||||
"OEBPS/package.opf": `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>RTL Book</dc:title></metadata>
|
||||
<manifest><item id="p1" href="p1.xhtml" media-type="application/xhtml+xml"/></manifest>
|
||||
<spine page-progression-direction="rtl"><itemref idref="p1"/></spine>
|
||||
</package>`,
|
||||
"OEBPS/p1.xhtml": `<html><body><p>hi</p></body></html>`,
|
||||
}
|
||||
writeEPUB(t, epubPath, files)
|
||||
|
||||
s := NewMediaScanner(nil)
|
||||
|
||||
// Sidecar blank -> embedded rtl fills it
|
||||
merged, err := s.mergeMetadata(epubPath, &MediaMetadata{Title: "Sidecar"})
|
||||
if err != nil {
|
||||
t.Fatalf("mergeMetadata() error: %v", err)
|
||||
}
|
||||
if merged.ReadingDirection != "rtl" {
|
||||
t.Errorf("ReadingDirection = %q, want embedded rtl fill", merged.ReadingDirection)
|
||||
}
|
||||
|
||||
// Sidecar ltr wins over embedded rtl
|
||||
merged, err = s.mergeMetadata(epubPath, &MediaMetadata{Title: "Sidecar", ReadingDirection: "ltr"})
|
||||
if err != nil {
|
||||
t.Fatalf("mergeMetadata() error: %v", err)
|
||||
}
|
||||
if merged.ReadingDirection != "ltr" {
|
||||
t.Errorf("ReadingDirection = %q, want sidecar ltr preserved", merged.ReadingDirection)
|
||||
}
|
||||
}
|
||||
+104
-11
@@ -67,6 +67,13 @@ type SaveHighlightRequest struct {
|
||||
EndPosition string
|
||||
Color string
|
||||
NoteText string
|
||||
// HighlightID, when valid, targets that exact row (web PUTs edit by
|
||||
// id): the save LWWs against it directly under its stored dedup key.
|
||||
// The computed key depends on fields that legitimately change — the
|
||||
// stored CFI drifts range→point shape after device echoes, and the
|
||||
// user can edit the selection text — so a key-based upsert would mint
|
||||
// a duplicate beside the very row being edited.
|
||||
HighlightID pgtype.UUID
|
||||
PercentageStart float64
|
||||
PercentageEnd float64
|
||||
EpubcfiStart string
|
||||
@@ -90,6 +97,35 @@ type SaveHighlightResult struct {
|
||||
|
||||
func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) {
|
||||
dedupKey := req.DedupKey
|
||||
|
||||
// Web edits arrive with the row id from the URL: resolve by id first
|
||||
// and LWW against that row under its stored key. Content-derived keys
|
||||
// are for lookups by identity (device pushes carry no row ids); an
|
||||
// edit must never re-derive identity from (possibly edited) content.
|
||||
if req.HighlightID.Valid {
|
||||
byID, idErr := s.db.GetMediaHighlight(ctx, req.HighlightID)
|
||||
if idErr != nil && !errors.Is(idErr, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("query highlight by id: %w", idErr)
|
||||
}
|
||||
if idErr == nil {
|
||||
if byID.UserID != req.UserID || byID.MediaItemID != req.MediaItemID {
|
||||
return nil, fmt.Errorf("highlight %s belongs to another user or media item", req.HighlightID)
|
||||
}
|
||||
if dedupKey == "" {
|
||||
dedupKey = byID.DedupKey.String
|
||||
}
|
||||
if byID.Deleted.Bool {
|
||||
if !incomingNewerThanTombstone(req.ModifiedAt, byID.DeletedAt, byID.LastModifiedAt) {
|
||||
return &SaveHighlightResult{Highlight: byID, Outcome: SaveOutcomeDeleted}, nil
|
||||
}
|
||||
// Newer than the tombstone: a deliberate re-create. Resurrect
|
||||
// via the LWW update (which clears deleted/deleted_at).
|
||||
}
|
||||
return s.applyLWW(ctx, req, byID, dedupKey)
|
||||
}
|
||||
// No row with that id: fall through to identity-based resolution.
|
||||
}
|
||||
|
||||
if dedupKey == "" {
|
||||
dedupKey = ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition)
|
||||
}
|
||||
@@ -186,17 +222,38 @@ func (s *AnnotationService) applyLWW(
|
||||
|
||||
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
||||
|
||||
// Web edits carry no device locators (the web reader never had a CRE
|
||||
// xpointer) and may carry no CFI either: keep the stored ones so
|
||||
// device-native serve-back and round-trip identity survive a web-side
|
||||
// note/color edit instead of being wiped to empty.
|
||||
startPosition := req.StartPosition
|
||||
if startPosition == "" {
|
||||
startPosition = existing.StartPosition.String
|
||||
}
|
||||
endPosition := req.EndPosition
|
||||
if endPosition == "" {
|
||||
endPosition = existing.EndPosition.String
|
||||
}
|
||||
epubcfiStart := req.EpubcfiStart
|
||||
if epubcfiStart == "" {
|
||||
epubcfiStart = existing.EpubcfiStart.String
|
||||
}
|
||||
epubcfiEnd := req.EpubcfiEnd
|
||||
if epubcfiEnd == "" {
|
||||
epubcfiEnd = existing.EpubcfiEnd.String
|
||||
}
|
||||
|
||||
highlight, err := s.db.UpdateMediaHighlightForSync(ctx, database.UpdateMediaHighlightForSyncParams{
|
||||
ID: existing.ID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: pgText(req.StartPosition),
|
||||
EndPosition: pgText(req.EndPosition),
|
||||
StartPosition: pgText(startPosition),
|
||||
EndPosition: pgText(endPosition),
|
||||
Color: pgText(req.Color),
|
||||
NoteText: pgText(req.NoteText),
|
||||
PercentageStart: pgFloat8(req.PercentageStart),
|
||||
PercentageEnd: pgFloat8(req.PercentageEnd),
|
||||
EpubcfiStart: pgText(req.EpubcfiStart),
|
||||
EpubcfiEnd: pgText(req.EpubcfiEnd),
|
||||
EpubcfiStart: pgText(epubcfiStart),
|
||||
EpubcfiEnd: pgText(epubcfiEnd),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
@@ -222,7 +279,9 @@ func (s *AnnotationService) compareIncoming(req SaveHighlightRequest, existing d
|
||||
textEq(req.Color, existing.Color) &&
|
||||
textEq(req.NoteText, existing.NoteText) &&
|
||||
floatEq(req.PercentageStart, existing.PercentageStart) &&
|
||||
floatEq(req.PercentageEnd, existing.PercentageEnd)
|
||||
floatEq(req.PercentageEnd, existing.PercentageEnd) &&
|
||||
locatorRefresher(req.EpubcfiStart, existing.EpubcfiStart) &&
|
||||
locatorRefresher(req.EpubcfiEnd, existing.EpubcfiEnd)
|
||||
return !contentSame, !contentSame
|
||||
}
|
||||
|
||||
@@ -233,6 +292,22 @@ func (s *AnnotationService) compareIncoming(req SaveHighlightRequest, existing d
|
||||
return req.ModifiedAt.After(existingMod.Time), true
|
||||
}
|
||||
|
||||
// locatorRefresher reports whether an incoming locator leaves the stored
|
||||
// one untouched: either the push carried none (empty coalesces to the
|
||||
// stored value in applyLWW) or it matches what is stored. A non-empty
|
||||
// incoming locator that DIFFERS is a deliberate refresh: device echoes
|
||||
// re-derive their canonical CFIs on every push, and an improvement (a
|
||||
// converter fix re-landing a corrupted anchor, drift repair) must reach
|
||||
// the row even when the annotation content is otherwise identical —
|
||||
// without this, content-equal echoes resolve to "skip" and a bad stored
|
||||
// locator can never heal.
|
||||
func locatorRefresher(incoming string, existing pgtype.Text) bool {
|
||||
if incoming == "" {
|
||||
return true
|
||||
}
|
||||
return textEq(incoming, existing)
|
||||
}
|
||||
|
||||
func (s *AnnotationService) TombstoneHighlight(
|
||||
ctx context.Context,
|
||||
userID, mediaItemID pgtype.UUID,
|
||||
@@ -590,6 +665,9 @@ type SaveBookmarkRequest struct {
|
||||
Source string
|
||||
ModifiedAt time.Time
|
||||
DeviceSyncData json.RawMessage
|
||||
// OriginSource records the client that created the bookmark (set once
|
||||
// at insert; unlike Source it is not updated by later writers).
|
||||
OriginSource string
|
||||
// DedupKey overrides the computed key for device echoes (see
|
||||
// SaveHighlightRequest).
|
||||
DedupKey string
|
||||
@@ -624,9 +702,9 @@ func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRe
|
||||
if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) {
|
||||
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil
|
||||
}
|
||||
// Newer than the tombstone: a deliberate re-create. Resurrect via the
|
||||
// LWW update instead of INSERT (the tombstoned row still holds the
|
||||
// UNIQUE(media_item_id, user_id, title) slot).
|
||||
// Newer than the tombstone: a deliberate re-create at the same
|
||||
// location. Resurrect via the LWW update so the row keeps its id
|
||||
// and origin.
|
||||
return s.applyBookmarkLWW(ctx, req, existing, dedupKey)
|
||||
}
|
||||
|
||||
@@ -656,6 +734,7 @@ func (s *AnnotationService) createBookmark(ctx context.Context, req SaveBookmark
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: deviceData,
|
||||
OriginSource: pgText(req.OriginSource),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create bookmark: %w", err)
|
||||
@@ -683,13 +762,25 @@ func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookma
|
||||
}
|
||||
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
||||
|
||||
// Web saves carry no device locators: keep the stored ones so a web
|
||||
// title/note edit never wipes the device-native position (mirrors the
|
||||
// highlight path's coalescing).
|
||||
cfiPosition := req.CFIPosition
|
||||
if cfiPosition == "" {
|
||||
cfiPosition = existing.CfiPosition.String
|
||||
}
|
||||
position := req.Position
|
||||
if position == "" {
|
||||
position = existing.Position.String
|
||||
}
|
||||
|
||||
bm, err := s.db.UpdateMediaBookmarkForSync(ctx, database.UpdateMediaBookmarkForSyncParams{
|
||||
ID: existing.ID,
|
||||
PageNumber: pgInt4(req.PageNumber),
|
||||
ChapterNumber: pgInt4(req.ChapterNumber),
|
||||
CfiPosition: pgText(req.CFIPosition),
|
||||
CfiPosition: pgText(cfiPosition),
|
||||
Title: req.Title,
|
||||
Position: pgText(req.Position),
|
||||
Position: pgText(position),
|
||||
Notes: pgText(req.Notes),
|
||||
PercentageLocation: pgFloat8(req.PercentageLoc),
|
||||
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
||||
@@ -714,7 +805,9 @@ func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookma
|
||||
func (s *AnnotationService) compareIncomingBookmark(req SaveBookmarkRequest, existing database.MediaBookmarks) (incomingNewer bool, contentChanged bool) {
|
||||
if req.ModifiedAt.IsZero() {
|
||||
contentSame := strings.EqualFold(req.Title, existing.Title) &&
|
||||
textEq(req.Notes, existing.Notes)
|
||||
textEq(req.Notes, existing.Notes) &&
|
||||
locatorRefresher(req.CFIPosition, existing.CfiPosition) &&
|
||||
locatorRefresher(req.Position, existing.Position)
|
||||
return !contentSame, !contentSame
|
||||
}
|
||||
existingMod := existing.LastModifiedAt
|
||||
|
||||
@@ -369,3 +369,82 @@ func TestIncomingNewerThanTombstone(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Content-equal device echoes must still count as changed when they carry
|
||||
// a locator that differs from the stored one: echoes re-derive canonical
|
||||
// CFIs on every push, and a better conversion (or a repair of a corrupted
|
||||
// anchor) has to reach the row — otherwise the skip path discards it and
|
||||
// the bad locator can never heal.
|
||||
func TestCompareIncomingLocatorDriftRefreshes(t *testing.T) {
|
||||
svc := &AnnotationService{}
|
||||
existing := database.MediaHighlights{
|
||||
SelectionText: "CHAPTER IV. “What a pity it is, Elinor,”",
|
||||
Color: pgtype.Text{String: "#90caf9", Valid: true},
|
||||
EpubcfiStart: pgtype.Text{String: "epubcfi(/6/12!/4/2[x]/32/3:576)", Valid: true},
|
||||
EpubcfiEnd: pgtype.Text{String: "epubcfi(/6/12!/4/2[x]/4/1:93)", Valid: true},
|
||||
}
|
||||
base := SaveHighlightRequest{
|
||||
SelectionText: existing.SelectionText,
|
||||
Color: "#90caf9",
|
||||
}
|
||||
|
||||
t.Run("identical content and locators skip", func(t *testing.T) {
|
||||
req := base
|
||||
req.EpubcfiStart = existing.EpubcfiStart.String
|
||||
req.EpubcfiEnd = existing.EpubcfiEnd.String
|
||||
_, changed := svc.compareIncoming(req, existing)
|
||||
if changed {
|
||||
t.Error("identical echo must not rewrite the row")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("better start locator refreshes despite identical content", func(t *testing.T) {
|
||||
req := base
|
||||
req.EpubcfiStart = "epubcfi(/6/12!/4/2[x]/2/3:0)"
|
||||
req.EpubcfiEnd = existing.EpubcfiEnd.String
|
||||
_, changed := svc.compareIncoming(req, existing)
|
||||
if !changed {
|
||||
t.Error("locator drift on a content-equal echo must trigger an update")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty incoming locators leave the row alone", func(t *testing.T) {
|
||||
req := base // no CFIs: applyLWW coalesces empty to stored
|
||||
_, changed := svc.compareIncoming(req, existing)
|
||||
if changed {
|
||||
t.Error("empty locators coalesce; must not count as drift")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Bookmarks share the echo-refresh rule for their locators.
|
||||
func TestCompareIncomingBookmarkLocatorDrift(t *testing.T) {
|
||||
svc := &AnnotationService{}
|
||||
existing := database.MediaBookmarks{
|
||||
Title: "in CHAPTER IV.",
|
||||
CfiPosition: pgtype.Text{String: "epubcfi(/6/12!/4/2[x]/2/3:0)", Valid: true},
|
||||
Position: pgtype.Text{String: "/body/DocFragment[6]/body/div[1]/h2[1]/text().0", Valid: true},
|
||||
}
|
||||
|
||||
t.Run("same locators skip", func(t *testing.T) {
|
||||
req := SaveBookmarkRequest{
|
||||
Title: existing.Title,
|
||||
CFIPosition: existing.CfiPosition.String,
|
||||
Position: existing.Position.String,
|
||||
}
|
||||
if _, changed := svc.compareIncomingBookmark(req, existing); changed {
|
||||
t.Error("identical bookmark echo must not rewrite the row")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("new CFI refreshes despite same title", func(t *testing.T) {
|
||||
req := SaveBookmarkRequest{
|
||||
Title: existing.Title,
|
||||
CFIPosition: "epubcfi(/6/12!/4/2[x]/2/3:5)",
|
||||
Position: existing.Position.String,
|
||||
}
|
||||
if _, changed := svc.compareIncomingBookmark(req, existing); !changed {
|
||||
t.Error("bookmark locator drift must trigger an update")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+472
-32
@@ -12,6 +12,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
@@ -246,6 +247,11 @@ func parseElementPart(part string) (string, int) {
|
||||
|
||||
type ConversionResult struct {
|
||||
EPUBCFI string
|
||||
// EndEPUBCFI is set when the conversion matched a context that is a
|
||||
// quote of the document (text search): the range end anchor of the
|
||||
// quoted text, valid across block boundaries. Selections use it as
|
||||
// the highlight end; point positions ignore it.
|
||||
EndEPUBCFI string
|
||||
Href string
|
||||
Percentage float64
|
||||
Precision string
|
||||
@@ -324,16 +330,234 @@ func (c *CFIConverter) ConvertCREToStandard(xpointer string, storedPercentage fl
|
||||
}, nil
|
||||
}
|
||||
|
||||
if contextText != "" {
|
||||
result := c.convertByTextSearch(body, xp, spine, href, storedPercentage, contextText)
|
||||
if result != nil {
|
||||
// Primary: structural walk of the CRE element path. This is exact when
|
||||
// the live CRE DOM still lines up with the raw XHTML (same-tag indices,
|
||||
// see buildCREXPointer for the counting rule). Text is the final
|
||||
// verification, not a fallback: a usable context that disagrees with
|
||||
// the structural landing rejects it so text search gets a chance.
|
||||
if len(xp.ElementPath) > 0 {
|
||||
if result := c.convertByStructuralPath(body, xp, spine, href, storedPercentage, contextText); result != nil {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
if usable, normalized := usableContextText(contextText); usable {
|
||||
result := c.convertByTextSearch(body, xp, spine, href, storedPercentage, normalized)
|
||||
if result != nil {
|
||||
return result, nil
|
||||
}
|
||||
} else if normalizeWhitespace(contextText) != "" {
|
||||
log.Printf("Bookhoard: ignoring too-short context %q for text search in %s (structural missed)", contextText, href)
|
||||
}
|
||||
|
||||
return c.convertByPercentageOffset(body, xp, spine, href, storedPercentage)
|
||||
}
|
||||
|
||||
// usableContextText reports whether a device context string is distinctive
|
||||
// enough to claim an "exact" text-search hit. Single characters from split
|
||||
// inline markup (e.g. drop-cap <span>C</span>onvergence) must never qualify:
|
||||
// findTextInNode would match the first "C" in the chapter (doc start).
|
||||
// Callers treat unusable as empty and fall through to the next strategy.
|
||||
func usableContextText(s string) (bool, string) {
|
||||
normalized := normalizeWhitespace(s)
|
||||
if normalized == "" {
|
||||
return false, ""
|
||||
}
|
||||
if utf8.RuneCountInString(normalized) < 8 {
|
||||
return false, ""
|
||||
}
|
||||
if len(strings.Fields(normalized)) < 2 {
|
||||
return false, ""
|
||||
}
|
||||
return true, normalized
|
||||
}
|
||||
|
||||
// resolveCREElementPath walks a parsed CRE element path against the raw
|
||||
// XHTML body. Indexing mirrors buildCREXPointer: 1-based nth element with
|
||||
// the same tag among element siblings (case-insensitive). Returns nil when
|
||||
// the live CRE DOM has drifted too far from disk (splits, injected wrappers)
|
||||
// so callers can fall through instead of landing on the wrong node.
|
||||
func resolveCREElementPath(body *html.Node, path []pathStep) *html.Node {
|
||||
current := body
|
||||
for _, step := range path {
|
||||
if step.tag == "" {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(step.tag, "body") {
|
||||
continue
|
||||
}
|
||||
var matches []*html.Node
|
||||
for child := current.FirstChild; child != nil; child = child.NextSibling {
|
||||
if child.Type == html.ElementNode && strings.EqualFold(child.Data, step.tag) {
|
||||
matches = append(matches, child)
|
||||
}
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
idx := step.index - 1
|
||||
if idx < 0 || idx >= len(matches) {
|
||||
return nil
|
||||
}
|
||||
current = matches[idx]
|
||||
}
|
||||
if current == body {
|
||||
return nil
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// firstTextDescendant returns the first text node with non-whitespace content
|
||||
// under n (document order), for structural landings on elements.
|
||||
func firstTextDescendant(n *html.Node) *html.Node {
|
||||
var found *html.Node
|
||||
var walk func(*html.Node) bool
|
||||
walk = func(node *html.Node) bool {
|
||||
if node.Type == html.TextNode && strings.TrimSpace(node.Data) != "" {
|
||||
found = node
|
||||
return true
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
if walk(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
walk(n)
|
||||
return found
|
||||
}
|
||||
|
||||
// textNodeAtRuneOffset walks text nodes under elem in document order and
|
||||
// returns the node containing the rune offset plus the local offset within
|
||||
// that node. Offsets beyond the end clamp to the last node.
|
||||
// textNodeAtUTF16Offset resolves a crengine text().N offset — UTF-16 code
|
||||
// units — to (text node, rune offset) within elem's text.
|
||||
func textNodeAtUTF16Offset(elem *html.Node, offset int) (*html.Node, int) {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
var target *html.Node
|
||||
local := 0
|
||||
remaining := offset
|
||||
var walk func(*html.Node) bool
|
||||
walk = func(node *html.Node) bool {
|
||||
if node.Type == html.TextNode {
|
||||
length := utf16Len(node.Data)
|
||||
if remaining < length {
|
||||
target = node
|
||||
local = utf16ToRuneIndex(node.Data, remaining)
|
||||
return true
|
||||
}
|
||||
remaining -= length
|
||||
target = node
|
||||
local = utf8.RuneCountInString(node.Data)
|
||||
return false
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
if walk(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
walk(elem)
|
||||
return target, local
|
||||
}
|
||||
|
||||
// blockFlattenedText returns the flattened visible text of the nearest
|
||||
// non-inline block ancestor (span/a/em/etc. are transparent), so drop-cap
|
||||
// splits like <span>C</span>onvergence verify against the full words.
|
||||
func blockFlattenedText(textNode *html.Node) string {
|
||||
block := findBlockParent(textNode)
|
||||
if block == nil {
|
||||
if textNode.Parent != nil {
|
||||
block = textNode.Parent
|
||||
} else {
|
||||
return strings.TrimSpace(textNode.Data)
|
||||
}
|
||||
}
|
||||
segments := collectInlineText(block)
|
||||
var runes []rune
|
||||
for _, seg := range segments {
|
||||
runes = append(runes, seg.runes...)
|
||||
}
|
||||
// Include leading direct text children the inline walk skips.
|
||||
var direct []rune
|
||||
for child := block.FirstChild; child != nil; child = child.NextSibling {
|
||||
if child.Type == html.TextNode {
|
||||
direct = append(direct, []rune(child.Data)...)
|
||||
}
|
||||
}
|
||||
combined := string(direct)
|
||||
if flattened := string(runes); flattened != "" {
|
||||
if combined != "" {
|
||||
combined += " " + flattened
|
||||
} else {
|
||||
combined = flattened
|
||||
}
|
||||
}
|
||||
return normalizeWhitespace(combined)
|
||||
}
|
||||
|
||||
func (c *CFIConverter) convertByStructuralPath(body *html.Node, xp *CREXPointer, spine *spineCache, href string, storedPercentage float64, contextText string) *ConversionResult {
|
||||
elem := resolveCREElementPath(body, xp.ElementPath)
|
||||
if elem == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var textNode *html.Node
|
||||
var localOffset int
|
||||
if xp.CharOffset > 0 {
|
||||
textNode, localOffset = textNodeAtUTF16Offset(elem, xp.CharOffset)
|
||||
} else {
|
||||
textNode = firstTextDescendant(elem)
|
||||
localOffset = 0
|
||||
}
|
||||
if textNode == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Text is the final verification, in quote form: a usable context must
|
||||
// be a prefix of the document as read forward from this landing point.
|
||||
// Device captures are exactly that shape — a position's context is the
|
||||
// text from the position onward (the plugin's walk-up may concatenate
|
||||
// the heading with the paragraphs below), and a selection is the
|
||||
// document text between its two anchors. The single-block containment
|
||||
// checks stay as secondary acceptance for reverse shapes.
|
||||
if usable, normalized := usableContextText(contextText); usable {
|
||||
doc := documentTextFrom(body, textNode, localOffset, utf8.RuneCountInString(normalized)+64)
|
||||
flat := blockFlattenedText(textNode)
|
||||
if !(strings.HasPrefix(doc, normalized) ||
|
||||
strings.Contains(flat, normalized) ||
|
||||
strings.Contains(normalized, flat)) {
|
||||
log.Printf("Bookhoard: structural landing disagrees with context in %s (reads %q vs ctx %q)",
|
||||
href, truncateForLog(doc, 80), truncateForLog(normalized, 80))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
spineIndex := xp.FragmentIndex - 1
|
||||
cfi, err := buildCFI(spineIndex, textNode, localOffset)
|
||||
if err != nil || cfi == "" {
|
||||
return nil
|
||||
}
|
||||
log.Printf("Bookhoard: structural match → %s (precision: structural)", cfi)
|
||||
return &ConversionResult{
|
||||
EPUBCFI: cfi,
|
||||
Href: href,
|
||||
Percentage: storedPercentage,
|
||||
Precision: "structural",
|
||||
}
|
||||
}
|
||||
|
||||
func truncateForLog(s string, n int) string {
|
||||
if utf8.RuneCountInString(s) <= n {
|
||||
return s
|
||||
}
|
||||
return string([]rune(s)[:n]) + "…"
|
||||
}
|
||||
|
||||
func (c *CFIConverter) convertFragmentID(s string, storedPercentage float64) (*ConversionResult, error) {
|
||||
frag, err := ParseCREFragmentID(s)
|
||||
if err != nil {
|
||||
@@ -375,28 +599,149 @@ func (c *CFIConverter) convertFragmentID(s string, storedPercentage float64) (*C
|
||||
}, nil
|
||||
}
|
||||
|
||||
// docRunePos records which text node (and rune offset within it) produced
|
||||
// each rune of the flattened document text.
|
||||
type docRunePos struct {
|
||||
node *html.Node
|
||||
off int
|
||||
}
|
||||
|
||||
// flattenDocumentForSearch renders the document's text as a
|
||||
// whitespace-normalized rune stream in document order, crossing block
|
||||
// boundaries: a single space separates blocks, while inline spans join
|
||||
// directly (drop-cap splits like <span>C</span>onvergence read as one
|
||||
// word). Every emitted rune carries its source (node, rune offset) so
|
||||
// matches map back to CFIs.
|
||||
func flattenDocumentForSearch(body *html.Node) ([]rune, []docRunePos) {
|
||||
var runes []rune
|
||||
var poss []docRunePos
|
||||
pendingSpace := false
|
||||
var lastBlock *html.Node
|
||||
|
||||
appendNode := func(n *html.Node) {
|
||||
block := findBlockParent(n)
|
||||
if lastBlock != nil && block != lastBlock {
|
||||
pendingSpace = true
|
||||
}
|
||||
lastBlock = block
|
||||
off := 0
|
||||
for _, r := range n.Data {
|
||||
if unicode.IsSpace(r) {
|
||||
pendingSpace = true
|
||||
} else {
|
||||
if pendingSpace && len(runes) > 0 {
|
||||
runes = append(runes, ' ')
|
||||
poss = append(poss, docRunePos{node: n, off: off})
|
||||
}
|
||||
pendingSpace = false
|
||||
runes = append(runes, r)
|
||||
poss = append(poss, docRunePos{node: n, off: off})
|
||||
}
|
||||
off++
|
||||
}
|
||||
}
|
||||
|
||||
var walk func(n *html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.TextNode {
|
||||
if strings.TrimSpace(n.Data) != "" {
|
||||
appendNode(n)
|
||||
}
|
||||
return
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(body)
|
||||
return runes, poss
|
||||
}
|
||||
|
||||
// documentTextFrom reads the whitespace-normalized document text starting
|
||||
// at rune `offset` in `start` (typically a structural landing point),
|
||||
// crossing block boundaries — the document "as read" from that position.
|
||||
// Capped at maxRunes.
|
||||
func documentTextFrom(body *html.Node, start *html.Node, offset, maxRunes int) string {
|
||||
runes, poss := flattenDocumentForSearch(body)
|
||||
begin := -1
|
||||
for i := range poss {
|
||||
if poss[i].node == start && poss[i].off >= offset {
|
||||
begin = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if begin < 0 {
|
||||
return ""
|
||||
}
|
||||
// The separator space before a block's first content rune shares its
|
||||
// (node, offset) — skip it so the read starts on real text.
|
||||
if runes[begin] == ' ' && begin+1 < len(runes) {
|
||||
begin++
|
||||
}
|
||||
end := len(runes)
|
||||
if begin+maxRunes < end {
|
||||
end = begin + maxRunes
|
||||
}
|
||||
return string(runes[begin:end])
|
||||
}
|
||||
|
||||
func (c *CFIConverter) convertByTextSearch(body *html.Node, xp *CREXPointer, spine *spineCache, href string, storedPercentage float64, contextText string) *ConversionResult {
|
||||
normalizedCtx := normalizeWhitespace(contextText)
|
||||
if normalizedCtx == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
match, matchOffset := findTextInNode(body, normalizedCtx)
|
||||
if match == nil {
|
||||
log.Printf("Bookhoard: text search no match for %q in %s", normalizedCtx, href)
|
||||
// Match against the whole document flattened in reading order — the
|
||||
// context may span block boundaries (a selection covering several
|
||||
// paragraphs, a walk-up capture joining a heading with what follows).
|
||||
runes, poss := flattenDocumentForSearch(body)
|
||||
text := string(runes)
|
||||
|
||||
words := strings.Fields(normalizedCtx)
|
||||
quoted := make([]string, len(words))
|
||||
for i, w := range words {
|
||||
quoted[i] = regexp.QuoteMeta(w)
|
||||
}
|
||||
pattern := strings.Join(quoted, `\s+`)
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
loc := re.FindStringIndex(text)
|
||||
if loc == nil {
|
||||
log.Printf("Bookhoard: text search no match for %q in %s", truncateForLog(normalizedCtx, 60), href)
|
||||
return nil
|
||||
}
|
||||
|
||||
startRune := utf8.RuneCountInString(text[:loc[0]])
|
||||
endRune := utf8.RuneCountInString(text[:loc[1]]) // exclusive
|
||||
spineIndex := xp.FragmentIndex - 1
|
||||
cfi, err := buildCFI(spineIndex, match, matchOffset)
|
||||
if err != nil || cfi == "" {
|
||||
|
||||
startPos := poss[startRune]
|
||||
startCFI, err := buildCFI(spineIndex, startPos.node, startPos.off)
|
||||
if err != nil || startCFI == "" {
|
||||
log.Printf("Bookhoard: text search found match but buildCFI failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("Bookhoard: text search matched %q → %s (precision: exact)", normalizedCtx, cfi)
|
||||
// The matched context is a quote of the document: its extent gives
|
||||
// selections a true range end, across block boundaries.
|
||||
endCFI := ""
|
||||
if endRune > startRune && endRune <= len(poss) {
|
||||
endPos := poss[endRune-1]
|
||||
endOff := endPos.off + 1
|
||||
if nRunes := utf8.RuneCountInString(endPos.node.Data); endOff > nRunes {
|
||||
endOff = nRunes
|
||||
}
|
||||
if ec, err := buildCFI(spineIndex, endPos.node, endOff); err == nil && ec != "" {
|
||||
endCFI = ec
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Bookhoard: text search matched %q → %s (precision: exact)", truncateForLog(normalizedCtx, 60), startCFI)
|
||||
return &ConversionResult{
|
||||
EPUBCFI: cfi,
|
||||
EPUBCFI: startCFI,
|
||||
EndEPUBCFI: endCFI,
|
||||
Href: href,
|
||||
Percentage: storedPercentage,
|
||||
Precision: "exact",
|
||||
@@ -563,7 +908,10 @@ func (c *CFIConverter) convertByPercentageOffset(body *html.Node, xp *CREXPointe
|
||||
EPUBCFI: cfi,
|
||||
Href: href,
|
||||
Percentage: storedPercentage,
|
||||
Precision: "exact",
|
||||
// This is a char-count estimate, not an exact landing: say
|
||||
// so. Callers gate storage on precision, and a guess must
|
||||
// never masquerade as an exact anchor.
|
||||
Precision: "percentage",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -712,24 +1060,96 @@ func indexChildNodes(parent *html.Node) []indexedNode {
|
||||
return nodes
|
||||
}
|
||||
|
||||
func findTextChunkIndex(parent *html.Node, textNode *html.Node) (int, int) {
|
||||
// Character-offset currency policy: the EPUB CFI spec and crengine both
|
||||
// count UTF-16 code units (JavaScript `.length` semantics — what foliate,
|
||||
// readium, KOReader and Kobo clients all observe), so every offset that
|
||||
// CROSSES the wire — CFI terminals, CRE text() offsets, the served
|
||||
// char_offset handle — is UTF-16. Internal arithmetic (book-level
|
||||
// character_offset, percentage fractions) stays rune-based, consistent
|
||||
// with TotalCharacters. These helpers convert at the boundaries; for
|
||||
// all-BMP text the two currencies are identical, so ASCII books are
|
||||
// unaffected.
|
||||
|
||||
// utf16Len returns the UTF-16 code-unit length of s.
|
||||
func utf16Len(s string) int {
|
||||
n := 0
|
||||
for _, r := range s {
|
||||
if r >= 0x10000 {
|
||||
n += 2
|
||||
} else {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// utf16ToRuneIndex converts a UTF-16 code-unit offset within s to a rune
|
||||
// index (clamped to len(runes)).
|
||||
func utf16ToRuneIndex(s string, u16 int) int {
|
||||
if u16 <= 0 {
|
||||
return 0
|
||||
}
|
||||
units := 0
|
||||
i := 0
|
||||
for _, r := range s {
|
||||
if units >= u16 {
|
||||
return i
|
||||
}
|
||||
if r >= 0x10000 {
|
||||
units += 2
|
||||
} else {
|
||||
units++
|
||||
}
|
||||
i++
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// runeToUTF16Index converts a rune index within s to a UTF-16 code-unit
|
||||
// offset (clamped to the string's unit length).
|
||||
func runeToUTF16Index(s string, runeIdx int) int {
|
||||
if runeIdx <= 0 {
|
||||
return 0
|
||||
}
|
||||
units := 0
|
||||
i := 0
|
||||
for _, r := range s {
|
||||
if i >= runeIdx {
|
||||
return units
|
||||
}
|
||||
if r >= 0x10000 {
|
||||
units += 2
|
||||
} else {
|
||||
units++
|
||||
}
|
||||
i++
|
||||
}
|
||||
return units
|
||||
}
|
||||
|
||||
// findTextChunk locates the indexed text chunk containing textNode and
|
||||
// returns its chunk index, the rune offset of the node within the chunk,
|
||||
// and the chunk text up to and including the node (for UTF-16 conversion
|
||||
// of chunk-relative offsets).
|
||||
func findTextChunk(parent *html.Node, textNode *html.Node) (int, int, string) {
|
||||
indexed := indexChildNodes(parent)
|
||||
for i, node := range indexed {
|
||||
if node.isTextChunk() {
|
||||
for j, tn := range node.textChunk {
|
||||
if tn == textNode {
|
||||
var sb strings.Builder
|
||||
chunkOffset := 0
|
||||
for k := 0; k < j; k++ {
|
||||
chunkOffset += utf8.RuneCountInString(node.textChunk[k].Data)
|
||||
for _, tn := range node.textChunk {
|
||||
if tn == textNode {
|
||||
return i, chunkOffset, sb.String() + textNode.Data
|
||||
}
|
||||
return i, chunkOffset
|
||||
chunkOffset += utf8.RuneCountInString(tn.Data)
|
||||
sb.WriteString(tn.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1, 0
|
||||
return -1, 0, ""
|
||||
}
|
||||
|
||||
|
||||
func findElementCFIIndex(parent *html.Node, element *html.Node) int {
|
||||
indexed := indexChildNodes(parent)
|
||||
for i, node := range indexed {
|
||||
@@ -764,15 +1184,17 @@ func buildCFI(spineIndex int, textNode *html.Node, charOffset int) (string, erro
|
||||
return "", fmt.Errorf("text node has no parent")
|
||||
}
|
||||
|
||||
chunkIdx, chunkOffset := findTextChunkIndex(parent, textNode)
|
||||
chunkIdx, chunkOffset, chunkText := findTextChunk(parent, textNode)
|
||||
if chunkIdx == -1 {
|
||||
return "", fmt.Errorf("text node not found in parent's indexed children")
|
||||
}
|
||||
|
||||
// The CFI terminal offset is UTF-16 code units (spec currency); the
|
||||
// internal charOffset is runes. Convert over the chunk text.
|
||||
totalOffset := chunkOffset + charOffset
|
||||
|
||||
var parts []string
|
||||
parts = append(parts, fmt.Sprintf("/%d:%d", chunkIdx, totalOffset))
|
||||
parts = append(parts, fmt.Sprintf("/%d:%d", chunkIdx, runeToUTF16Index(chunkText, totalOffset)))
|
||||
|
||||
current := parent
|
||||
for current != nil {
|
||||
@@ -899,9 +1321,16 @@ func countTextChars(n *html.Node) int {
|
||||
return count
|
||||
}
|
||||
|
||||
// countTextCharsBefore counts the text characters preceding target within
|
||||
// its document: preceding siblings at each ancestor level, strictly
|
||||
// EXCLUDING target's own subtree (callers add the in-node offset
|
||||
// separately). Starting the walk at target itself — or recursing after
|
||||
// counting the current node's subtree — would re-count the accumulated
|
||||
// document once per ancestor level and multiply the result by the node's
|
||||
// depth.
|
||||
func countTextCharsBefore(target *html.Node) int {
|
||||
count := 0
|
||||
for c := target; c != nil; c = c.PrevSibling {
|
||||
for c := target.PrevSibling; c != nil; c = c.PrevSibling {
|
||||
count += countTextChars(c)
|
||||
}
|
||||
if target.Parent != nil {
|
||||
@@ -1173,24 +1602,31 @@ func resolveCFIToNode(doc *html.Node, steps []cfiStep) (*html.Node, int, error)
|
||||
entry := indexed[lastStep.Index]
|
||||
|
||||
if entry.isTextChunk() {
|
||||
textOffset := 0
|
||||
// The CFI terminal offset arrives in UTF-16 code units (spec
|
||||
// currency — foliate/readium/KOReader/Kobo all emit UTF-16).
|
||||
// Walk the chunk in UTF-16 units, then convert the hit position
|
||||
// to the internal rune offset.
|
||||
u16Remaining := 0
|
||||
if lastStep.HasOffset {
|
||||
textOffset = lastStep.Offset
|
||||
u16Remaining = lastStep.Offset
|
||||
}
|
||||
|
||||
var targetNode *html.Node
|
||||
remainingOffset := textOffset
|
||||
runeIntoTarget := 0
|
||||
for _, tn := range entry.textChunk {
|
||||
textLen := utf8.RuneCountInString(tn.Data)
|
||||
if remainingOffset < textLen || (remainingOffset == textLen && targetNode == nil) {
|
||||
units := utf16Len(tn.Data)
|
||||
if u16Remaining < units || (u16Remaining == units && targetNode == nil) {
|
||||
targetNode = tn
|
||||
runeIntoTarget = utf16ToRuneIndex(tn.Data, u16Remaining)
|
||||
break
|
||||
}
|
||||
remainingOffset -= textLen
|
||||
u16Remaining -= units
|
||||
targetNode = tn
|
||||
runeIntoTarget = utf8.RuneCountInString(tn.Data)
|
||||
}
|
||||
if targetNode == nil && len(entry.textChunk) > 0 {
|
||||
targetNode = entry.textChunk[len(entry.textChunk)-1]
|
||||
runeIntoTarget = utf8.RuneCountInString(targetNode.Data)
|
||||
}
|
||||
|
||||
parent := targetNode.Parent
|
||||
@@ -1201,7 +1637,7 @@ func resolveCFIToNode(doc *html.Node, steps []cfiStep) (*html.Node, int, error)
|
||||
}
|
||||
totalOffset += countTextChars(c)
|
||||
}
|
||||
totalOffset += remainingOffset
|
||||
totalOffset += runeIntoTarget
|
||||
|
||||
return targetNode, totalOffset, nil
|
||||
}
|
||||
@@ -1260,7 +1696,8 @@ func buildCREXPointer(spineIndex int, node *html.Node, charOffset int) (string,
|
||||
xpointer := fmt.Sprintf("/body/DocFragment[%d]/body%s", fragIndex, strings.Join(parts, ""))
|
||||
|
||||
if charOffset > 0 || (node.Type == html.TextNode) {
|
||||
xpointer += fmt.Sprintf("/text().%d", charOffset)
|
||||
// crengine counts UTF-16 code units; charOffset is internal runes.
|
||||
xpointer += fmt.Sprintf("/text().%d", runeToUTF16Index(node.Data, charOffset))
|
||||
}
|
||||
|
||||
return xpointer, nil
|
||||
@@ -1306,8 +1743,11 @@ func (c *CFIConverter) ConvertStandardToCRE(epubcfi string, storedPercentage flo
|
||||
}
|
||||
|
||||
func (c *CFIConverter) reverseByTextSearch(epubcfi string, storedPercentage float64, contextText string) (*ReverseConversionResult, error) {
|
||||
normalizedCtx := normalizeWhitespace(contextText)
|
||||
if normalizedCtx == "" {
|
||||
usable, normalizedCtx := usableContextText(contextText)
|
||||
if !usable {
|
||||
if normalizeWhitespace(contextText) != "" {
|
||||
log.Printf("Bookhoard: CFI→CRE ignoring too-short context %q", contextText)
|
||||
}
|
||||
return &ReverseConversionResult{
|
||||
Precision: "percentage",
|
||||
Percentage: storedPercentage,
|
||||
|
||||
@@ -133,7 +133,7 @@ func writeTestEPUB(t *testing.T) string {
|
||||
}
|
||||
docs := []spineDoc{
|
||||
{"doc1.xhtml", "<body><div><p>Chapter one opening page.</p></div></body>"},
|
||||
{"doc2.xhtml", "<body><div><p>The family of Dashwood had long been settled in Sussex.</p><p>Their estate was large, and their residence was at Norland Park.</p></div></body>"},
|
||||
{"doc2.xhtml", "<body><div><p>The family of Dashwood had long been settled in Sussex.</p><p>Their estate was large, and their residence was at Norland Park.</p><p>The family crest shows a globe \U0001F30D and a rocket \U0001F680 flying onward.</p></div></body>"},
|
||||
{"doc3.xhtml", "<body><div><p>Chapter three contents.</p></div></body>"},
|
||||
{"doc4.xhtml", "<body><div><p>Chapter four contents.</p></div></body>"},
|
||||
{"doc5.xhtml", "<body><div><p>Chapter five contents.</p></div></body>"},
|
||||
@@ -519,3 +519,261 @@ func parseTestHTML(s string) *html.Node {
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
func TestUsableContextText(t *testing.T) {
|
||||
if ok, _ := usableContextText("C"); ok {
|
||||
t.Error("single char must not be usable context")
|
||||
}
|
||||
if ok, _ := usableContextText("Chapter"); ok {
|
||||
t.Error("single word must not be usable context")
|
||||
}
|
||||
if ok, norm := usableContextText("Convergence of Heaven and Earth"); !ok || norm == "" {
|
||||
t.Error("full header must be usable context")
|
||||
}
|
||||
if ok, _ := usableContextText(""); ok {
|
||||
t.Error("empty must not be usable context")
|
||||
}
|
||||
}
|
||||
|
||||
// Drop-cap regression: <p><span>C</span>onvergence of Heaven and Earth</p>.
|
||||
// A device sitting on that header used to send context "C", which text
|
||||
// search matched at the chapter heading ("Chapter 10") and stored doc-start
|
||||
// (/4/2/1:0). Structural resolution must land in the header paragraph.
|
||||
func writeDropCapEPUB(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
docs := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"ch9.xhtml", `<body><h1>Chapter 9</h1><h1>Is the Kaphar of Christ the Gospel?</h1><p>Opening of chapter nine with some text.</p></body>`},
|
||||
{"ch10.xhtml", `<body><h1>Chapter 10</h1><h1>The Three C's of the New Covenant</h1><p>The <span>C</span>leansing Life of Christ</p><p>Present yourselves as slaves for obedience, you are slaves of that same one whom you obey, either of sin resulting in death.</p><p><span>C</span>onvergence of Heaven and Earth</p><p>The overarching goal of Christ is to converge heaven and earth.</p></body>`},
|
||||
}
|
||||
|
||||
containerXML := `<?xml version="1.0"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>`
|
||||
|
||||
manifest := ""
|
||||
spineRefs := ""
|
||||
for _, d := range docs {
|
||||
id := d.name[:len(d.name)-len(".xhtml")]
|
||||
manifest += " <item id=\"" + id + "\" href=\"" + d.name + "\" media-type=\"application/xhtml+xml\"/>\n"
|
||||
spineRefs += " <itemref idref=\"" + id + "\"/>\n"
|
||||
}
|
||||
opf := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:identifier id="uid">dropcap-fixture</dc:identifier>
|
||||
<dc:title>Dropcap Fixture</dc:title>
|
||||
</metadata>
|
||||
<manifest>
|
||||
` + manifest + ` </manifest>
|
||||
<spine>
|
||||
` + spineRefs + ` </spine>
|
||||
</package>`
|
||||
|
||||
path := t.TempDir() + "/dropcap.epub"
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
zw := zip.NewWriter(f)
|
||||
write := func(name, content string) {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write([]byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("META-INF/container.xml", containerXML)
|
||||
write("OEBPS/content.opf", opf)
|
||||
for _, d := range docs {
|
||||
write("OEBPS/"+d.name, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<html xmlns=\"http://www.w3.org/1999/xhtml\">"+d.body+"</html>\n")
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestDropCapSingleCharDoesNotHitDocStart(t *testing.T) {
|
||||
c := NewCFIConverter(writeDropCapEPUB(t))
|
||||
|
||||
// CRE xpointer into the drop-cap header paragraph:
|
||||
// DocFragment[2] = ch10 (1-based), body/p[3] = Convergence header
|
||||
// (same-tag indexing, mirrors buildCREXPointer).
|
||||
xp := "/body/DocFragment[2]/body/p[3]/span[1]/text().0"
|
||||
result, err := c.ConvertCREToStandard(xp, 0.52, "C")
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertCREToStandard error: %v", err)
|
||||
}
|
||||
t.Logf("Drop-cap single-char → %s (%s)", result.EPUBCFI, result.Precision)
|
||||
if result.EPUBCFI == "" {
|
||||
t.Fatal("expected non-empty epubcfi")
|
||||
}
|
||||
if strings.HasSuffix(result.EPUBCFI, "/4/2/1:0)") {
|
||||
t.Errorf("single-char context collapsed to doc start: %s", result.EPUBCFI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropCapStructuralLandsInHeader(t *testing.T) {
|
||||
c := NewCFIConverter(writeDropCapEPUB(t))
|
||||
|
||||
xp := "/body/DocFragment[2]/body/p[3]/span[1]/text().0"
|
||||
result, err := c.ConvertCREToStandard(xp, 0.52, "Convergence of Heaven and Earth")
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertCREToStandard error: %v", err)
|
||||
}
|
||||
t.Logf("Drop-cap structural → %s (%s)", result.EPUBCFI, result.Precision)
|
||||
if result.EPUBCFI == "" {
|
||||
t.Fatal("expected non-empty epubcfi")
|
||||
}
|
||||
if strings.HasSuffix(result.EPUBCFI, "/4/2/1:0)") {
|
||||
t.Errorf("full header context still collapsed to doc start: %s", result.EPUBCFI)
|
||||
}
|
||||
if result.Precision != "structural" && result.Precision != "exact" {
|
||||
t.Errorf("expected structural/exact precision, got %s", result.Precision)
|
||||
}
|
||||
|
||||
// Round-trip back to CRE must stay in the same fragment (web → mobile).
|
||||
reverse, err := c.ConvertStandardToCRE(result.EPUBCFI, result.Percentage, "Convergence of Heaven and Earth")
|
||||
if err != nil {
|
||||
t.Fatalf("reverse conversion error: %v", err)
|
||||
}
|
||||
if reverse.XPointer == "" {
|
||||
t.Fatal("expected non-empty reverse XPointer")
|
||||
}
|
||||
if !strings.Contains(reverse.XPointer, "DocFragment[2]") {
|
||||
t.Errorf("expected reverse into DocFragment[2], got %s", reverse.XPointer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseIgnoresSingleCharContext(t *testing.T) {
|
||||
c := NewCFIConverter(writeDropCapEPUB(t))
|
||||
|
||||
reverse, err := c.ConvertStandardToCRE("epubcfi(/6/4!/4/99999/1:0)", 0.5, "C")
|
||||
if err != nil {
|
||||
t.Fatalf("reverse conversion error: %v", err)
|
||||
}
|
||||
if reverse.Precision != "percentage" {
|
||||
t.Errorf("single-char reverse context must fall back to percentage, got %s", reverse.Precision)
|
||||
}
|
||||
}
|
||||
|
||||
// A position at a chapter heading sends walk-up context: the heading text
|
||||
// concatenated with the paragraphs below (block walk-up in the plugin).
|
||||
// The structural landing at the heading is correct and must verify — the
|
||||
// context is a prefix of the document as read from the landing.
|
||||
func TestWalkUpContextVerifiesAtHeading(t *testing.T) {
|
||||
c := NewCFIConverter(writeDropCapEPUB(t))
|
||||
|
||||
// ch10: h1[1] "Chapter 10", h1[2] "The Three C's of the New Covenant",
|
||||
// then paragraphs. Position at h1[2]'s text start; the device captured
|
||||
// the heading plus the following paragraph.
|
||||
xp := "/body/DocFragment[2]/body/h1[2]/text().0"
|
||||
ctx := "The Three C's of the New Covenant The Cleansing Life of Christ"
|
||||
|
||||
result, err := c.ConvertCREToStandard(xp, 0.52, ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertCREToStandard error: %v", err)
|
||||
}
|
||||
t.Logf("walk-up heading → %s (%s)", result.EPUBCFI, result.Precision)
|
||||
if result.Precision != "structural" {
|
||||
t.Fatalf("expected structural precision (quote verification), got %s (%s)", result.Precision, result.EPUBCFI)
|
||||
}
|
||||
if strings.HasSuffix(result.EPUBCFI, "/4/2/1:0)") {
|
||||
t.Errorf("collapsed to doc start: %s", result.EPUBCFI)
|
||||
}
|
||||
// The landing must be the heading, not a paragraph below it.
|
||||
reverse, rerr := c.ConvertStandardToCRE(result.EPUBCFI, result.Percentage, "")
|
||||
if rerr != nil {
|
||||
t.Fatalf("reverse conversion error: %v", rerr)
|
||||
}
|
||||
if !strings.Contains(reverse.XPointer, "h1[2]") {
|
||||
t.Errorf("expected landing in h1[2], got %s", reverse.XPointer)
|
||||
}
|
||||
}
|
||||
|
||||
// A selection spanning blocks (heading tail into the next paragraph) must
|
||||
// be findable by text search across block boundaries, and the matched
|
||||
// quote's extent gives a true range end.
|
||||
func TestCrossBlockSearchSpansBlocks(t *testing.T) {
|
||||
c := NewCFIConverter(writeDropCapEPUB(t))
|
||||
|
||||
// No element path → structural rung skipped, text search runs.
|
||||
xp := "/body/DocFragment[2]/body"
|
||||
ctx := "New Covenant The Cleansing Life of Christ"
|
||||
|
||||
result, err := c.ConvertCREToStandard(xp, 0.52, ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertCREToStandard error: %v", err)
|
||||
}
|
||||
t.Logf("cross-block search → %s … %s (%s)", result.EPUBCFI, result.EndEPUBCFI, result.Precision)
|
||||
if result.Precision != "exact" {
|
||||
t.Fatalf("expected exact text-search precision, got %s", result.Precision)
|
||||
}
|
||||
if result.EPUBCFI == "" || result.EndEPUBCFI == "" {
|
||||
t.Fatalf("expected range anchors, got %q…%q", result.EPUBCFI, result.EndEPUBCFI)
|
||||
}
|
||||
if result.EPUBCFI == result.EndEPUBCFI {
|
||||
t.Fatalf("range collapsed: %s", result.EPUBCFI)
|
||||
}
|
||||
|
||||
// The start lands in the heading, the end in the following paragraph.
|
||||
startRev, err1 := c.ConvertStandardToCRE(result.EPUBCFI, result.Percentage, "")
|
||||
endRev, err2 := c.ConvertStandardToCRE(result.EndEPUBCFI, result.Percentage, "")
|
||||
if err1 != nil || err2 != nil {
|
||||
t.Fatalf("reverse conversions failed: %v %v", err1, err2)
|
||||
}
|
||||
if !strings.Contains(startRev.XPointer, "h1[2]") {
|
||||
t.Errorf("expected start in h1[2], got %s", startRev.XPointer)
|
||||
}
|
||||
if !strings.Contains(endRev.XPointer, "p[1]") {
|
||||
t.Errorf("expected end in p[1] (following paragraph), got %s", endRev.XPointer)
|
||||
}
|
||||
}
|
||||
|
||||
// The percentage rung is a char-count estimate: it must never label its
|
||||
// landing "exact". Feed it an unmatchable context so the ladder falls all
|
||||
// the way through.
|
||||
func TestPercentageFallbackIsHonestlyLabeled(t *testing.T) {
|
||||
c := NewCFIConverter(writeDropCapEPUB(t))
|
||||
|
||||
xp := "/body/DocFragment[2]/body/p[3]/span[1]/text().0"
|
||||
result, err := c.ConvertCREToStandard(xp, 0.52, "zzz qqq vvv uuu www")
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertCREToStandard error: %v", err)
|
||||
}
|
||||
t.Logf("unmatchable context → %s (%s)", result.EPUBCFI, result.Precision)
|
||||
if result.Precision != "percentage" {
|
||||
t.Errorf("percentage rung must not claim exact, got %s", result.Precision)
|
||||
}
|
||||
}
|
||||
|
||||
// The bookmark route supplies no context (bookmark text is a display
|
||||
// label, never book text), so the facade must still resolve the drop-cap
|
||||
// xpointer structurally instead of collapsing to the document start.
|
||||
func TestConvertToCanonical_DropCapEmptyContext(t *testing.T) {
|
||||
epubPath := writeDropCapEPUB(t)
|
||||
xp := "/body/DocFragment[2]/body/p[3]/span[1]/text().0"
|
||||
|
||||
loc := ConvertToCanonical(LocatorSourceKOReader, xp, 0.52, "", string(FormatGroupReflowable), epubPath, "")
|
||||
|
||||
t.Logf("facade drop-cap (no context) → %s (%s)", loc.CFI, loc.Precision)
|
||||
if loc.Precision != "structural" && loc.Precision != "exact" {
|
||||
t.Fatalf("expected structural/exact precision, got %s (%s)", loc.Precision, loc.CFI)
|
||||
}
|
||||
if !strings.HasPrefix(loc.CFI, "epubcfi(") {
|
||||
t.Fatalf("expected standard epubcfi, got %s", loc.CFI)
|
||||
}
|
||||
if strings.HasSuffix(loc.CFI, "/4/2/1:0)") {
|
||||
t.Errorf("empty-context conversion collapsed to doc start: %s", loc.CFI)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
package sync
|
||||
|
||||
// Ingest-side position authority for reading progress.
|
||||
//
|
||||
// Clients submit (percentage, context_text, epubcfi). The epubcfi is a
|
||||
// standard wrapped CFI — epubcfi(/6/N!/…) — resolvable against the same
|
||||
// document this package parses, so every submission is INDEPENDENTLY
|
||||
// verified: the text at the resolved anchor is extracted and compared
|
||||
// with the submitted context. A mismatch (or an unresolvable anchor)
|
||||
// heals the position by text search, with the submitted percentage
|
||||
// disambiguating repeated phrases, instead of trusting a client-side
|
||||
// projection. This keeps buggy clients from poisoning stored positions:
|
||||
// a resolver that silently returns "wherever I'm currently scrolled"
|
||||
// fails the context check and gets healed to the true location.
|
||||
//
|
||||
// The anchor's block element is also derived as a cssSelector plus a
|
||||
// block-relative character offset and served back — the readium-native
|
||||
// handle clients scroll to, so they never have to parse or trust CFIs
|
||||
// themselves.
|
||||
//
|
||||
// Note: readium-based clients number their readingOrder excluding
|
||||
// linear="no" spine items, while this package's spine index follows the
|
||||
// OPF spine as written. The spine index is therefore internal-only;
|
||||
// client-facing responses carry the anchor document's href instead.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// IsConvertibleFormat reports whether a format group uses standard CFIs
|
||||
// as its structural locator currency (i.e. whether CFI verification and
|
||||
// cssSelector derivation apply to it).
|
||||
func IsConvertibleFormat(formatGroup string) bool {
|
||||
return isConvertible(string(formatGroup))
|
||||
}
|
||||
|
||||
func truncateRunes(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n])
|
||||
}
|
||||
|
||||
// anchorBlock returns the nearest non-inline (block-level) ancestor of a
|
||||
// resolved text/element node — the server-side equivalent of the
|
||||
// readers' computed-style block walk.
|
||||
func anchorBlock(node *html.Node) *html.Node {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
if node.Type == html.ElementNode && !isInlineFormatting(node) {
|
||||
return node
|
||||
}
|
||||
return findBlockParent(node)
|
||||
}
|
||||
|
||||
// blockContextText extracts the normalized text from (node, runeOff) to
|
||||
// the end of the anchor block — the same excerpt rule the readers use for
|
||||
// context_text, ≤100 chars.
|
||||
func blockContextText(block, node *html.Node, runeOff int) string {
|
||||
segments := collectInlineText(block)
|
||||
var sb strings.Builder
|
||||
started := false
|
||||
for _, seg := range segments {
|
||||
if !started && seg.node == node {
|
||||
started = true
|
||||
runes := []rune(string(seg.runes))
|
||||
if runeOff < len(runes) {
|
||||
sb.WriteString(string(runes[runeOff:]))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if started {
|
||||
sb.WriteString(string(seg.runes))
|
||||
}
|
||||
}
|
||||
return truncateRunes(normalizeWhitespace(sb.String()), 100)
|
||||
}
|
||||
|
||||
// contextMatches reports whether a server-extracted context and a client-
|
||||
// submitted context describe the same anchor. Both are suffixes of the
|
||||
// same block text when the anchors share a block, so containment in
|
||||
// either direction verifies; empty or very short contexts never match.
|
||||
func contextMatches(serverCtx, submitted string) bool {
|
||||
s := truncateRunes(normalizeWhitespace(submitted), 100)
|
||||
t := truncateRunes(normalizeWhitespace(serverCtx), 100)
|
||||
if s == "" || t == "" {
|
||||
return false
|
||||
}
|
||||
short, long := s, t
|
||||
if len([]rune(short)) > len([]rune(long)) {
|
||||
short, long = long, short
|
||||
}
|
||||
if len([]rune(short)) < 12 {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(long, short)
|
||||
}
|
||||
|
||||
// cssSelectorFor mirrors the readers' selOf: a body-relative
|
||||
// tag:nth-child(k) chain (k = 1-based position among element siblings).
|
||||
func cssSelectorFor(block *html.Node) string {
|
||||
var segs []string
|
||||
n := block
|
||||
for n != nil && n.Type == html.ElementNode && n.Data != "body" {
|
||||
k := 1
|
||||
sib := n.Parent.FirstChild
|
||||
for sib != nil && sib != n {
|
||||
if sib.Type == html.ElementNode {
|
||||
k++
|
||||
}
|
||||
sib = sib.NextSibling
|
||||
}
|
||||
segs = append([]string{n.Data + ":nth-child(" + fmt.Sprintf("%d", k) + ")"}, segs...)
|
||||
n = n.Parent
|
||||
}
|
||||
return "body>" + strings.Join(segs, ">")
|
||||
}
|
||||
|
||||
// blockCharOffset computes the offset of (node, runeOff) within the
|
||||
// concatenated text of its block, in UTF-16 code units — the client-side
|
||||
// scroll-target currency (JavaScript .length semantics).
|
||||
func blockCharOffset(block, node *html.Node, runeOff int) int {
|
||||
segments := collectInlineText(block)
|
||||
var sb strings.Builder
|
||||
for _, seg := range segments {
|
||||
if seg.node == node {
|
||||
runes := seg.runes
|
||||
if runeOff < len(runes) {
|
||||
runes = runes[:runeOff]
|
||||
}
|
||||
sb.WriteString(string(runes))
|
||||
return runeToUTF16Index(sb.String(), utf8.RuneCountInString(sb.String()))
|
||||
}
|
||||
sb.WriteString(string(seg.runes))
|
||||
}
|
||||
return utf16Len(sb.String())
|
||||
}
|
||||
|
||||
// bookCharOffset computes the book-wide rune offset of (node, runeOff) —
|
||||
// the reading_progress.character_offset column's currency, consistent with
|
||||
// TotalCharacters and the percentage derivations.
|
||||
func bookCharOffset(conv *CFIConverter, spineIndex int, node *html.Node, runeOff int) int {
|
||||
spine, err := conv.loadSpine()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
before := 0
|
||||
for i := 0; i < spineIndex && i < len(spine.items); i++ {
|
||||
doc, _, derr := conv.getContentDoc(i + 1)
|
||||
if derr != nil {
|
||||
continue
|
||||
}
|
||||
if b := findBody(doc); b != nil {
|
||||
before += countTextChars(b)
|
||||
}
|
||||
}
|
||||
return before + countTextCharsBefore(node) + runeOff
|
||||
}
|
||||
|
||||
// ProgressAnchor is the full server-computed apply handle for a stored
|
||||
// standard CFI: the anchor block's cssSelector, the character offset
|
||||
// within that block's text, and the spine document's href.
|
||||
type ProgressAnchor struct {
|
||||
CSSSelector string
|
||||
CharOffset int
|
||||
Href string
|
||||
HealedCFI string
|
||||
Healed bool
|
||||
HealedPct *float64
|
||||
}
|
||||
|
||||
// VerifyProgressAnchor resolves a client-submitted standard CFI against
|
||||
// the EPUB, cross-checks the submitted context text, and heals the anchor
|
||||
// by text search on any mismatch. charOffset is the anchor's block-
|
||||
// relative UTF-16 offset (the served char_offset handle); bookOffset is
|
||||
// the anchor's book-wide rune offset (the character_offset column's
|
||||
// currency) — callers refresh the column from it on every verified save
|
||||
// so it never goes stale behind the anchor.
|
||||
func VerifyProgressAnchor(epubPath, epubcfi, contextText string, percentage float64) (finalCFI string, cssSelector string, anchorHref string, charOffset *int, bookOffset *int, healedPct *float64, healed bool, err error) {
|
||||
finalCFI = epubcfi
|
||||
anchorHref = ""
|
||||
|
||||
spineIndex, localSteps, err := parseEPUBCFI(epubcfi)
|
||||
if err != nil {
|
||||
cfi, sel, href, off, book, pct, healedFlag, herr := healFromContext(epubPath, contextText, percentage)
|
||||
return cfi, sel, href, off, book, pct, healedFlag, herr
|
||||
}
|
||||
conv := cachedConverter(epubPath)
|
||||
doc, docHref, err := conv.getContentDoc(spineIndex + 1)
|
||||
if err != nil {
|
||||
return healFromContext(epubPath, contextText, percentage)
|
||||
}
|
||||
node, runeOff, rerr := resolveCFIToNode(doc, localSteps)
|
||||
if rerr != nil {
|
||||
return healFromContext(epubPath, contextText, percentage)
|
||||
}
|
||||
anchorHref = docHref
|
||||
|
||||
block := anchorBlock(node)
|
||||
serverCtx := blockContextText(block, node, runeOff)
|
||||
if contextMatches(serverCtx, contextText) {
|
||||
off := blockCharOffset(block, node, runeOff)
|
||||
book := bookCharOffset(conv, spineIndex, node, runeOff)
|
||||
return finalCFI, cssSelectorFor(block), anchorHref, &off, &book, nil, false, nil
|
||||
}
|
||||
|
||||
// Mismatch: heal by text search.
|
||||
hCFI, hPct, hSel, hHref, hBook, herr := healAnchorByText(epubPath, contextText, percentage, spineIndex)
|
||||
if herr != nil {
|
||||
return finalCFI, "", hHref, nil, nil, nil, false, fmt.Errorf("context mismatch (server %q vs client %q) and heal failed: %w",
|
||||
truncateRunes(serverCtx, 40), truncateRunes(contextText, 40), herr)
|
||||
}
|
||||
hOff := blockCharOffsetFor(epubPath, hCFI)
|
||||
return hCFI, hSel, hHref, &hOff, &hBook, &hPct, true, nil
|
||||
}
|
||||
|
||||
func healFromContext(epubPath, contextText string, percentage float64) (string, string, string, *int, *int, *float64, bool, error) {
|
||||
cfi, healedPct, sel, href, book, err := healAnchorByText(epubPath, contextText, percentage, -1)
|
||||
if err != nil {
|
||||
return "", "", "", nil, nil, nil, false, err
|
||||
}
|
||||
off := blockCharOffsetFor(epubPath, cfi)
|
||||
return cfi, sel, href, &off, &book, &healedPct, true, nil
|
||||
}
|
||||
|
||||
func healAnchorByText(epubPath, contextText string, percentage float64, spineIndex int) (string, float64, string, string, int, error) {
|
||||
if epubPath == "" {
|
||||
return "", 0, "", "", 0, fmt.Errorf("no epub available for text anchoring")
|
||||
}
|
||||
conv := cachedConverter(epubPath)
|
||||
spine, err := conv.loadSpine()
|
||||
if err != nil {
|
||||
return "", 0, "", "", 0, err
|
||||
}
|
||||
needle := truncateRunes(normalizeWhitespace(contextText), 40)
|
||||
if len([]rune(needle)) < 12 {
|
||||
return "", 0, "", "", 0, fmt.Errorf("context too short to anchor")
|
||||
}
|
||||
|
||||
type match struct {
|
||||
spine int
|
||||
node *html.Node
|
||||
off int
|
||||
}
|
||||
var matches []match
|
||||
totalChars := 0
|
||||
charsBefore := make([]int, len(spine.items))
|
||||
for i := range spine.items {
|
||||
doc, _, derr := conv.getContentDoc(i + 1)
|
||||
if derr != nil {
|
||||
continue
|
||||
}
|
||||
body := findBody(doc)
|
||||
if body == nil {
|
||||
continue
|
||||
}
|
||||
charsBefore[i] = totalChars
|
||||
totalChars += countTextChars(body)
|
||||
if n, runeOff := findTextInNode(body, needle); n != nil {
|
||||
matches = append(matches, match{spine: i, node: n, off: runeOff})
|
||||
}
|
||||
}
|
||||
if len(matches) == 0 || totalChars <= 0 {
|
||||
return "", 0, "", "", 0, fmt.Errorf("context not found in book")
|
||||
}
|
||||
|
||||
best := matches[0]
|
||||
bestDist := -1.0
|
||||
for _, m := range matches {
|
||||
frac := (float64(charsBefore[m.spine]) + float64(countTextCharsBefore(m.node)+m.off)) / float64(totalChars)
|
||||
d := frac - percentage
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
if bestDist < 0 || d < bestDist {
|
||||
best = m
|
||||
bestDist = d
|
||||
}
|
||||
}
|
||||
|
||||
bookOff := charsBefore[best.spine] + countTextCharsBefore(best.node) + best.off
|
||||
healedPct := float64(bookOff) / float64(totalChars)
|
||||
cfi, err := buildCFI(best.spine, best.node, best.off)
|
||||
if err != nil {
|
||||
return "", 0, "", "", 0, err
|
||||
}
|
||||
sel := ""
|
||||
if block := anchorBlock(best.node); block != nil {
|
||||
sel = cssSelectorFor(block)
|
||||
}
|
||||
return cfi, healedPct, sel, spine.items[best.spine].href, bookOff, nil
|
||||
}
|
||||
|
||||
func blockCharOffsetFor(epubPath, cfi string) int {
|
||||
n, off, _, herr := cfiTextAtAnchorInternal(epubPath, cfi)
|
||||
if herr != nil {
|
||||
return 0
|
||||
}
|
||||
block := anchorBlock(n)
|
||||
if block == nil {
|
||||
return 0
|
||||
}
|
||||
return blockCharOffset(block, n, off)
|
||||
}
|
||||
|
||||
// cfiTextAtAnchorInternal resolves a stored standard CFI to its anchor
|
||||
// text node, rune offset, and containing document.
|
||||
func cfiTextAtAnchorInternal(epubPath, cfi string) (*html.Node, int, *html.Node, error) {
|
||||
spineIndex, localSteps, err := parseEPUBCFI(cfi)
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
conv := cachedConverter(epubPath)
|
||||
doc, _, err := conv.getContentDoc(spineIndex + 1)
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
node, off, err := resolveCFIToNode(doc, localSteps)
|
||||
return node, off, doc, err
|
||||
}
|
||||
|
||||
// ProgressCSSSelector derives the anchor block's cssSelector from a stored
|
||||
// standard CFI.
|
||||
func ProgressCSSSelector(epubPath, epubcfi string) (string, error) {
|
||||
spineIndex, localSteps, err := parseEPUBCFI(epubcfi)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conv := cachedConverter(epubPath)
|
||||
doc, _, err := conv.getContentDoc(spineIndex + 1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
node, _, err := resolveCFIToNode(doc, localSteps)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block := anchorBlock(node)
|
||||
if block == nil {
|
||||
return "", fmt.Errorf("no block ancestor for anchor")
|
||||
}
|
||||
return cssSelectorFor(block), nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestManualRealBookAnchor(t *testing.T) {
|
||||
path := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (1269)/1984 - George Orwell.epub"
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Skip("real book not present on this machine")
|
||||
}
|
||||
cfi := "epubcfi(/6/2!/4[x1984]/8[_idContainer003]/62/1:456)"
|
||||
ctx := "was at war with one of these Powers it was generally at peace with the other"
|
||||
finalCFI, sel, href, charOff, _, healedPct, healed, err := VerifyProgressAnchor(path, cfi, ctx, 0.0415)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyProgressAnchor error: %v", err)
|
||||
}
|
||||
t.Logf("final=%q\n healed=%v healedPct=%v\n selector=%q href=%q charOff=%v", finalCFI, healed, healedPct, sel, href, charOff)
|
||||
sel2, err2 := ProgressCSSSelector(path, cfi)
|
||||
t.Logf("ProgressCSSSelector: %q err=%v", sel2, err2)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The fixture (writeTestEPUB in cfi_converter_test.go): 6 spine docs.
|
||||
// doc2 = spine index 1 with two paragraphs:
|
||||
// p1: "The family of Dashwood had long been settled in Sussex."
|
||||
// p2: "Their estate was large, and their residence was at Norland Park."
|
||||
// Hand-derived local paths (html→body /4, body→div /4, div→p /4|/6,
|
||||
// p→text chunk /1):
|
||||
const (
|
||||
dashwoodCFI = "epubcfi(/6/4!/4/2/2/1:0)"
|
||||
dashwoodText = "The family of Dashwood had long been settled in Sussex."
|
||||
estateCFI = "epubcfi(/6/4!/4/2/4/1:0)"
|
||||
estateText = "Their estate was large, and their residence was at Norland Park."
|
||||
dashwoodSelect = "body>div:nth-child(1)>p:nth-child(1)"
|
||||
estateSelect = "body>div:nth-child(1)>p:nth-child(2)"
|
||||
)
|
||||
|
||||
func TestVerifyProgressAnchorAcceptsExact(t *testing.T) {
|
||||
path := writeTestEPUB(t)
|
||||
finalCFI, sel, _, charOff, _, healedPct, healed, err := VerifyProgressAnchor(path, dashwoodCFI, dashwoodText, 0.3)
|
||||
_ = charOff
|
||||
if err != nil {
|
||||
t.Fatalf("verify error: %v", err)
|
||||
}
|
||||
if healed {
|
||||
t.Errorf("exact anchor should not heal")
|
||||
}
|
||||
if healedPct != nil {
|
||||
t.Errorf("exact anchor should not carry a healed percentage")
|
||||
}
|
||||
if finalCFI != dashwoodCFI {
|
||||
t.Errorf("finalCFI = %q, want unchanged %q", finalCFI, dashwoodCFI)
|
||||
}
|
||||
if sel != dashwoodSelect {
|
||||
t.Errorf("cssSelector = %q, want %q", sel, dashwoodSelect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyProgressAnchorHealsMismatch(t *testing.T) {
|
||||
path := writeTestEPUB(t)
|
||||
// Anchored at p1 but the context is p2's text: the classic
|
||||
// client-projection bug — the server must heal to the true location.
|
||||
finalCFI, _, _, charOff, _, healedPct, healed, err := VerifyProgressAnchor(path, dashwoodCFI, estateText, 0.3)
|
||||
_ = charOff
|
||||
if err != nil {
|
||||
t.Fatalf("verify error: %v", err)
|
||||
}
|
||||
if !healed {
|
||||
t.Fatalf("expected healing, got none (cfi=%q)", finalCFI)
|
||||
}
|
||||
if finalCFI != estateCFI {
|
||||
t.Errorf("healed CFI = %q, want %q", finalCFI, estateCFI)
|
||||
}
|
||||
if healedPct == nil || *healedPct <= 0 {
|
||||
t.Errorf("healed percentage not recomputed: %v", healedPct)
|
||||
}
|
||||
|
||||
// Healing must converge: re-verifying the healed anchor is a no-op.
|
||||
finalCFI2, _, _, _, _, healedPct2, healed2, err := VerifyProgressAnchor(path, finalCFI, estateText, 0.3)
|
||||
if err != nil {
|
||||
t.Fatalf("re-verify error: %v", err)
|
||||
}
|
||||
if healed2 {
|
||||
t.Errorf("healed anchor should be stable, got healed again to %q (pct %v)", finalCFI2, healedPct2)
|
||||
}
|
||||
if finalCFI2 != finalCFI {
|
||||
t.Errorf("re-verify CFI = %q, want %q", finalCFI2, finalCFI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyProgressAnchorHealsUnresolvable(t *testing.T) {
|
||||
path := writeTestEPUB(t)
|
||||
// Element index 99 is out of range in doc2 — the anchor cannot resolve.
|
||||
bogus := "epubcfi(/6/4!/4/2/99/1:0)"
|
||||
finalCFI, _, _, charOff, _, healedPct, healed, err := VerifyProgressAnchor(path, bogus, dashwoodText, 0.05)
|
||||
_ = charOff
|
||||
if err != nil {
|
||||
t.Fatalf("verify error: %v", err)
|
||||
}
|
||||
if !healed {
|
||||
t.Fatal("unresolvable anchor should heal from context")
|
||||
}
|
||||
if finalCFI != dashwoodCFI {
|
||||
t.Errorf("healed CFI = %q, want the Dashwood anchor %q", finalCFI, dashwoodCFI)
|
||||
}
|
||||
if healedPct == nil {
|
||||
t.Errorf("healed percentage not recomputed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyProgressAnchorDumbClient(t *testing.T) {
|
||||
path := writeTestEPUB(t)
|
||||
// No CFI at all — a client that only knows percentage + context is
|
||||
// fully supported: the server anchors structurally from the text.
|
||||
finalCFI, sel, _, charOff, _, healedPct, healed, err := VerifyProgressAnchor(path, "", estateText, 0.3)
|
||||
_ = charOff
|
||||
if err != nil {
|
||||
t.Fatalf("verify error: %v", err)
|
||||
}
|
||||
if !healed {
|
||||
t.Fatal("context-only submission should count as anchored-by-heal")
|
||||
}
|
||||
if finalCFI != estateCFI {
|
||||
t.Errorf("anchored CFI = %q, want %q", finalCFI, estateCFI)
|
||||
}
|
||||
if sel != estateSelect {
|
||||
t.Errorf("cssSelector = %q, want %q", sel, estateSelect)
|
||||
}
|
||||
if healedPct == nil {
|
||||
t.Errorf("percentage not recomputed for context-only anchor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMatches(t *testing.T) {
|
||||
block := "The family of Dashwood had long been settled in Sussex."
|
||||
cases := []struct {
|
||||
name string
|
||||
serverCtx string
|
||||
submitted string
|
||||
want bool
|
||||
}{
|
||||
{"exact", block, block, true},
|
||||
{"suffix of block", block, "settled in Sussex.", true},
|
||||
{"block is suffix", "settled in Sussex.", block, true},
|
||||
{"mid-block substring", block, "Dashwood had long been", true},
|
||||
{"different text", block, "completely unrelated words here", false},
|
||||
{"too short", block, "the", false},
|
||||
{"empty submitted", block, "", false},
|
||||
{"empty server", "", "some long enough context text", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := contextMatches(tc.serverCtx, tc.submitted); got != tc.want {
|
||||
t.Errorf("contextMatches(%q, %q) = %v, want %v", tc.serverCtx, tc.submitted, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStandardCFIRange(t *testing.T) {
|
||||
// Web progress CFIs are range CFIs; parse must resolve to the start arm.
|
||||
spineIndex, localSteps, err := parseEPUBCFI("epubcfi(/6/4!/4/4:0,/4/4:53)")
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if spineIndex != 1 {
|
||||
t.Errorf("spineIndex = %d, want 1", spineIndex)
|
||||
}
|
||||
if len(localSteps) != 4 {
|
||||
t.Fatalf("steps = %d, want 4 (parent + start arm)", len(localSteps))
|
||||
}
|
||||
last := localSteps[len(localSteps)-1]
|
||||
if last.Index != 4 || !last.HasOffset || last.Offset != 53 {
|
||||
t.Errorf("start-arm step = %+v, want /4:0", last)
|
||||
}
|
||||
}
|
||||
|
||||
// The emoji in fixture doc2's third paragraph are astral plane runes:
|
||||
// one rune, two UTF-16 code units. Every wire offset (CFI terminals,
|
||||
// the served char_offset) must therefore be UTF-16, while the book-wide
|
||||
// character_offset column stays rune-based.
|
||||
//
|
||||
// Case 1: the context starts at rune 31 of the paragraph text ("The
|
||||
// family crest shows a globe " = 31 BMP runes), so its UTF-16 offset is
|
||||
// also 31 — the currencies agree.
|
||||
//
|
||||
// Case 2: the context starts at rune 33 ("and a rocket ..."), with the
|
||||
// astral 🌍 (rune 31, units 31-32) BEFORE the offset — the UTF-16 offset
|
||||
// is 34, one more than the rune offset. That +1 is the whole point of
|
||||
// the boundary conversion.
|
||||
//
|
||||
// Local path: p3 of doc2's div = /4/2/6, text chunk /1.
|
||||
func TestAstralOffsetCurrency(t *testing.T) {
|
||||
path := writeTestEPUB(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
context string
|
||||
wantTerm int
|
||||
}{
|
||||
{"before any emoji", "🌍 and a rocket 🚀 flying onward.", 31},
|
||||
{"after one emoji", "and a rocket 🚀 flying onward.", 34},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
wantCFI := fmt.Sprintf("epubcfi(/6/4!/4/2/6/1:%d)", tc.wantTerm)
|
||||
|
||||
finalCFI, sel, _, charOff, bookOff, _, healed, err := VerifyProgressAnchor(path, "", tc.context, 0.3)
|
||||
if err != nil {
|
||||
t.Fatalf("heal error: %v", err)
|
||||
}
|
||||
if !healed {
|
||||
t.Fatalf("expected heal from context-only submission")
|
||||
}
|
||||
if finalCFI != wantCFI {
|
||||
t.Errorf("healed CFI = %q, want %q (UTF-16 terminal)", finalCFI, wantCFI)
|
||||
}
|
||||
if sel != "body>div:nth-child(1)>p:nth-child(3)" {
|
||||
t.Errorf("cssSelector = %q", sel)
|
||||
}
|
||||
if charOff == nil || *charOff != tc.wantTerm {
|
||||
t.Errorf("block char_offset = %v, want %d UTF-16 units", charOff, tc.wantTerm)
|
||||
}
|
||||
if bookOff == nil || *bookOff <= 0 {
|
||||
t.Errorf("book offset = %v, want a positive book-wide rune offset", bookOff)
|
||||
}
|
||||
|
||||
// Round trip: the healed CFI must verify exactly, with the
|
||||
// same UTF-16 block handle and a stable book offset.
|
||||
finalCFI2, _, _, charOff2, bookOff2, healedPct2, healed2, err := VerifyProgressAnchor(path, finalCFI, tc.context, 0.3)
|
||||
if err != nil {
|
||||
t.Fatalf("re-verify error: %v", err)
|
||||
}
|
||||
if healed2 || healedPct2 != nil {
|
||||
t.Errorf("exact round trip should not heal (healed=%v)", healed2)
|
||||
}
|
||||
if finalCFI2 != wantCFI {
|
||||
t.Errorf("re-verified CFI = %q, want %q", finalCFI2, wantCFI)
|
||||
}
|
||||
if charOff2 == nil || *charOff2 != tc.wantTerm {
|
||||
t.Errorf("re-verified block char_offset = %v, want %d", charOff2, tc.wantTerm)
|
||||
}
|
||||
if bookOff2 == nil || bookOff == nil || *bookOff2 != *bookOff {
|
||||
t.Errorf("book offset unstable: %v vs %v", bookOff2, bookOff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The book-wide offset must order anchors the way the book orders them:
|
||||
// a later paragraph in the same document has a strictly larger
|
||||
// character_offset.
|
||||
func TestBookOffsetOrdering(t *testing.T) {
|
||||
path := writeTestEPUB(t)
|
||||
_, _, _, _, book1, _, _, err1 := VerifyProgressAnchor(path, dashwoodCFI, dashwoodText, 0.3)
|
||||
_, _, _, _, book2, _, _, err2 := VerifyProgressAnchor(path, estateCFI, estateText, 0.3)
|
||||
if err1 != nil || err2 != nil {
|
||||
t.Fatalf("verify errors: %v %v", err1, err2)
|
||||
}
|
||||
if book1 == nil || book2 == nil {
|
||||
t.Fatalf("book offsets missing: %v %v", book1, book2)
|
||||
}
|
||||
if *book2 <= *book1 {
|
||||
t.Errorf("estate offset %d should exceed dashwood offset %d", *book2, *book1)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@ const (
|
||||
|
||||
type CanonicalLocator struct {
|
||||
CFI string
|
||||
// EndCFI carries the matched context's range end (text-search
|
||||
// conversions of selections) so callers can anchor a true highlight
|
||||
// range across block boundaries.
|
||||
EndCFI string
|
||||
Precision string
|
||||
Percentage float64
|
||||
}
|
||||
@@ -58,6 +62,13 @@ func cachedConverter(epubPath string) *CFIConverter {
|
||||
return c
|
||||
}
|
||||
|
||||
// SectionPercentageCached resolves the spine-section percentage of a CRE
|
||||
// xpointer through the shared bounded converter cache, so per-annotation
|
||||
// lookups parse the EPUB once per book instead of once per annotation.
|
||||
func SectionPercentageCached(epubPath, xpointer string) float64 {
|
||||
return cachedConverter(epubPath).SectionPercentage(xpointer)
|
||||
}
|
||||
|
||||
func ConvertToCanonical(
|
||||
source LocatorSource,
|
||||
devicePos string,
|
||||
@@ -87,7 +98,7 @@ func ConvertToCanonical(
|
||||
return CanonicalLocator{CFI: devicePos, Precision: "fallback", Percentage: percentage}
|
||||
}
|
||||
if result.EPUBCFI != "" {
|
||||
return CanonicalLocator{CFI: result.EPUBCFI, Precision: result.Precision, Percentage: result.Percentage}
|
||||
return CanonicalLocator{CFI: result.EPUBCFI, EndCFI: result.EndEPUBCFI, Precision: result.Precision, Percentage: result.Percentage}
|
||||
}
|
||||
if result.Href != "" {
|
||||
return CanonicalLocator{CFI: result.Href, Precision: result.Precision, Percentage: result.Percentage}
|
||||
|
||||
@@ -274,8 +274,18 @@ func EstimatedPages(totalCharacters int64) int {
|
||||
type ProgressService struct {
|
||||
db *database.Queries
|
||||
connManager *ConnectionManager
|
||||
mediaPaths MediaPathResolver
|
||||
}
|
||||
|
||||
// MediaPathResolver resolves a media item's library-relative file path to
|
||||
// an absolute path, so the progress service can verify submitted anchors
|
||||
// against the book itself.
|
||||
type MediaPathResolver interface {
|
||||
ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error)
|
||||
}
|
||||
|
||||
func (s *ProgressService) SetMediaPathResolver(r MediaPathResolver) { s.mediaPaths = r }
|
||||
|
||||
func NewProgressService(db *database.Queries, connManager *ConnectionManager) *ProgressService {
|
||||
return &ProgressService{db: db, connManager: connManager}
|
||||
}
|
||||
@@ -435,6 +445,43 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical position authority: resolve the submitted CFI against the
|
||||
// book itself, cross-check the submitted context text, and heal the
|
||||
// anchor by text search on any mismatch. Clients submit what their
|
||||
// renderer can reliably observe (percentage + visible text); the
|
||||
// server owns the structural math and keeps buggy clients from
|
||||
// poisoning stored positions.
|
||||
if !isFixed && s.mediaPaths != nil && params.ContextText.Valid &&
|
||||
isConvertible(string(formatGroup)) && formatGroup != "" {
|
||||
if path, perr := s.mediaPaths.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath); perr == nil && path != "" {
|
||||
submittedCFI := ""
|
||||
if params.Epubcfi.Valid {
|
||||
submittedCFI = params.Epubcfi.String
|
||||
}
|
||||
submittedPct := 0.0
|
||||
if params.Percentage.Valid {
|
||||
submittedPct = params.Percentage.Float64
|
||||
}
|
||||
if finalCFI, _, _, _, bookOff, healedPct, healed, verr := VerifyProgressAnchor(path, submittedCFI, params.ContextText.String, submittedPct); verr != nil {
|
||||
log.Printf("Bookhoard: progress anchor verify failed for %s: %v", req.MediaItemID.String(), verr)
|
||||
} else {
|
||||
if healed || (!params.Epubcfi.Valid && finalCFI != "") {
|
||||
params.Epubcfi = pgtype.Text{String: finalCFI, Valid: true}
|
||||
if healedPct != nil {
|
||||
params.Percentage = pgtype.Float8{Float64: *healedPct, Valid: true}
|
||||
}
|
||||
}
|
||||
// The verified book-wide offset refreshes the column on
|
||||
// EVERY verified save — not only heals — so it never goes
|
||||
// stale behind the anchor (and a block-relative offset is
|
||||
// never written into the book-level column).
|
||||
if bookOff != nil {
|
||||
params.CharacterOffset = pgtype.Int8{Int64: int64(*bookOff), Valid: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conflictDetected := false
|
||||
if hasExisting && existing.LastSyncSource.Valid && existing.LastSyncSource.String != req.Source {
|
||||
if existing.LastSyncTimestamp.Valid {
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// Per-field user-override tracking for media item metadata.
|
||||
//
|
||||
// metadata_overrides is a TEXT[] column on media_items holding the names of
|
||||
// columns the user has customized via the metadata editor. Library scans and
|
||||
// per-book rescans MUST preserve those columns (applyMetadataOverrides);
|
||||
// only the reset-to-scanned-defaults action clears the set.
|
||||
|
||||
const (
|
||||
OverrideTitle = "title"
|
||||
OverrideAuthor = "author"
|
||||
OverrideISBN = "isbn"
|
||||
OverrideDescription = "description"
|
||||
OverrideCoverImagePath = "cover_image_path"
|
||||
OverrideSeries = "series"
|
||||
OverrideSeriesNumber = "series_number"
|
||||
OverrideTags = "tags"
|
||||
OverrideAsin = "asin"
|
||||
OverrideDatePublished = "date_published"
|
||||
OverridePublisher = "publisher"
|
||||
OverrideContributors = "contributors"
|
||||
OverrideLanguage = "language"
|
||||
OverrideEdition = "edition"
|
||||
OverridePageCount = "page_count"
|
||||
OverrideGenre = "genre"
|
||||
OverrideCopyrightYear = "copyright_year"
|
||||
OverrideGoodreadsID = "goodreads_id"
|
||||
OverrideOpenlibraryID = "openlibrary_id"
|
||||
OverrideGoogleBooksID = "google_books_id"
|
||||
OverrideMangaType = "manga_type"
|
||||
OverrideReadingDirection = "reading_direction"
|
||||
OverrideSeriesCount = "series_count"
|
||||
OverrideVolume = "volume"
|
||||
OverrideImprint = "imprint"
|
||||
OverrideAgeRating = "age_rating"
|
||||
OverrideWebURL = "web_url"
|
||||
OverrideMetadataNotes = "metadata_notes"
|
||||
OverrideCommunityRating = "community_rating"
|
||||
OverrideStoryArc = "story_arc"
|
||||
OverrideIsBlackAndWhite = "is_black_and_white"
|
||||
OverrideAlternateInfo = "alternate_info"
|
||||
OverrideScanInformation = "scan_information"
|
||||
OverrideSummary = "summary"
|
||||
)
|
||||
|
||||
// hasOverride reports whether key is in the override set.
|
||||
func hasOverride(overrides []string, key string) bool {
|
||||
for _, k := range overrides {
|
||||
if k == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MergeOverrides unions existing and added, preserving order and dropping
|
||||
// duplicates. Returns a non-nil slice so it always satisfies NOT NULL columns.
|
||||
func MergeOverrides(existing []string, added ...string) []string {
|
||||
seen := make(map[string]bool, len(existing)+len(added))
|
||||
merged := make([]string, 0, len(existing)+len(added))
|
||||
for _, k := range existing {
|
||||
if k != "" && !seen[k] {
|
||||
seen[k] = true
|
||||
merged = append(merged, k)
|
||||
}
|
||||
}
|
||||
for _, k := range added {
|
||||
if k != "" && !seen[k] {
|
||||
seen[k] = true
|
||||
merged = append(merged, k)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// Normalizers turn nullable column values into comparable strings so that
|
||||
// "unset" (invalid/zero) forms compare equal regardless of which side they
|
||||
// come from.
|
||||
|
||||
func normText(t pgtype.Text) string {
|
||||
if !t.Valid {
|
||||
return ""
|
||||
}
|
||||
return t.String
|
||||
}
|
||||
|
||||
func normInt(i pgtype.Int4) string {
|
||||
if !i.Valid {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(int64(i.Int32), 10)
|
||||
}
|
||||
|
||||
func normFloat(f pgtype.Float8) string {
|
||||
if !f.Valid {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatFloat(f.Float64, 'g', -1, 64)
|
||||
}
|
||||
|
||||
func normBool(b pgtype.Bool) string {
|
||||
if !b.Valid {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatBool(b.Bool)
|
||||
}
|
||||
|
||||
func normDate(d pgtype.Date) string {
|
||||
if !d.Valid {
|
||||
return ""
|
||||
}
|
||||
return d.Time.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func normStringSlice(s []string) string {
|
||||
if len(s) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(s, "\x1f")
|
||||
}
|
||||
|
||||
func normBytes(b []byte) string {
|
||||
if len(b) == 0 {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// detectMetadataOverridesDiff returns the keys whose value in params differs
|
||||
// from the existing row. Used by the metadata editor save path to grow the
|
||||
// override set with exactly the fields the user changed.
|
||||
func detectMetadataOverridesDiff(params database.UpdateMediaItemParams, existing database.MediaItems) []string {
|
||||
var changed []string
|
||||
add := func(key string, differs bool) {
|
||||
if differs {
|
||||
changed = append(changed, key)
|
||||
}
|
||||
}
|
||||
|
||||
add(OverrideTitle, params.Title != existing.Title)
|
||||
add(OverrideAuthor, normText(params.Author) != normText(existing.Author))
|
||||
add(OverrideISBN, normText(params.Isbn) != normText(existing.Isbn))
|
||||
add(OverrideDescription, normText(params.Description) != normText(existing.Description))
|
||||
add(OverrideSeries, normText(params.Series) != normText(existing.Series))
|
||||
add(OverrideSeriesNumber, normInt(params.SeriesNumber) != normInt(existing.SeriesNumber))
|
||||
add(OverrideTags, normStringSlice(params.Tags) != normStringSlice(existing.Tags))
|
||||
add(OverrideAsin, normText(params.Asin) != normText(existing.Asin))
|
||||
add(OverrideDatePublished, normDate(params.DatePublished) != normDate(existing.DatePublished))
|
||||
add(OverridePublisher, normText(params.Publisher) != normText(existing.Publisher))
|
||||
add(OverrideContributors, normStringSlice(params.Contributors) != normStringSlice(existing.Contributors))
|
||||
add(OverrideLanguage, normText(params.Language) != normText(existing.Language))
|
||||
add(OverrideEdition, normText(params.Edition) != normText(existing.Edition))
|
||||
add(OverridePageCount, normInt(params.PageCount) != normInt(existing.PageCount))
|
||||
add(OverrideGenre, normText(params.Genre) != normText(existing.Genre))
|
||||
add(OverrideCopyrightYear, normInt(params.CopyrightYear) != normInt(existing.CopyrightYear))
|
||||
add(OverrideGoodreadsID, normText(params.GoodreadsID) != normText(existing.GoodreadsID))
|
||||
add(OverrideOpenlibraryID, normText(params.OpenlibraryID) != normText(existing.OpenlibraryID))
|
||||
add(OverrideGoogleBooksID, normText(params.GoogleBooksID) != normText(existing.GoogleBooksID))
|
||||
add(OverrideMangaType, normText(params.MangaType) != normText(existing.MangaType))
|
||||
add(OverrideReadingDirection, normText(params.ReadingDirection) != normText(existing.ReadingDirection))
|
||||
add(OverrideSeriesCount, normInt(params.SeriesCount) != normInt(existing.SeriesCount))
|
||||
add(OverrideVolume, normInt(params.Volume) != normInt(existing.Volume))
|
||||
add(OverrideImprint, normText(params.Imprint) != normText(existing.Imprint))
|
||||
add(OverrideAgeRating, normText(params.AgeRating) != normText(existing.AgeRating))
|
||||
add(OverrideWebURL, normText(params.WebUrl) != normText(existing.WebUrl))
|
||||
add(OverrideMetadataNotes, normText(params.MetadataNotes) != normText(existing.MetadataNotes))
|
||||
add(OverrideCommunityRating, normFloat(params.CommunityRating) != normFloat(existing.CommunityRating))
|
||||
add(OverrideStoryArc, normText(params.StoryArc) != normText(existing.StoryArc))
|
||||
add(OverrideIsBlackAndWhite, normBool(params.IsBlackAndWhite) != normBool(existing.IsBlackAndWhite))
|
||||
add(OverrideAlternateInfo, normBytes(params.AlternateInfo) != normBytes(existing.AlternateInfo))
|
||||
add(OverrideScanInformation, normText(params.ScanInformation) != normText(existing.ScanInformation))
|
||||
add(OverrideSummary, normText(params.Summary) != normText(existing.Summary))
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
// DetectMetadataOverrides returns the union of the existing override set and
|
||||
// any fields whose incoming (user-submitted) values differ from the stored
|
||||
// row. Overrides accumulate: a field stays protected until an explicit reset,
|
||||
// even if a later save reverts the value.
|
||||
func DetectMetadataOverrides(params database.UpdateMediaItemParams, existing database.MediaItems) []string {
|
||||
return MergeOverrides(existing.MetadataOverrides, detectMetadataOverridesDiff(params, existing)...)
|
||||
}
|
||||
|
||||
// ApplyMetadataOverrides restores every overridden field in params from the
|
||||
// existing row so scanner updates cannot clobber user customizations. Derived
|
||||
// search columns are restored together with their base column.
|
||||
func ApplyMetadataOverrides(params *database.UpdateMediaItemParams, existing database.MediaItems) {
|
||||
o := existing.MetadataOverrides
|
||||
|
||||
if hasOverride(o, OverrideTitle) {
|
||||
params.Title = existing.Title
|
||||
}
|
||||
if hasOverride(o, OverrideAuthor) {
|
||||
params.Author = existing.Author
|
||||
}
|
||||
if hasOverride(o, OverrideISBN) {
|
||||
params.Isbn = existing.Isbn
|
||||
}
|
||||
if hasOverride(o, OverrideDescription) {
|
||||
params.Description = existing.Description
|
||||
}
|
||||
if hasOverride(o, OverrideCoverImagePath) {
|
||||
params.CoverImagePath = existing.CoverImagePath
|
||||
}
|
||||
if hasOverride(o, OverrideSeries) {
|
||||
params.Series = existing.Series
|
||||
}
|
||||
if hasOverride(o, OverrideSeriesNumber) {
|
||||
params.SeriesNumber = existing.SeriesNumber
|
||||
}
|
||||
if hasOverride(o, OverrideTags) {
|
||||
params.Tags = existing.Tags
|
||||
params.TagsSearch = existing.TagsSearch
|
||||
}
|
||||
if hasOverride(o, OverrideAsin) {
|
||||
params.Asin = existing.Asin
|
||||
}
|
||||
if hasOverride(o, OverrideDatePublished) {
|
||||
params.DatePublished = existing.DatePublished
|
||||
}
|
||||
if hasOverride(o, OverridePublisher) {
|
||||
params.Publisher = existing.Publisher
|
||||
}
|
||||
if hasOverride(o, OverrideContributors) {
|
||||
params.Contributors = existing.Contributors
|
||||
params.ContributorsSearch = existing.ContributorsSearch
|
||||
}
|
||||
if hasOverride(o, OverrideLanguage) {
|
||||
params.Language = existing.Language
|
||||
}
|
||||
if hasOverride(o, OverrideEdition) {
|
||||
params.Edition = existing.Edition
|
||||
}
|
||||
if hasOverride(o, OverridePageCount) {
|
||||
params.PageCount = existing.PageCount
|
||||
}
|
||||
if hasOverride(o, OverrideGenre) {
|
||||
params.Genre = existing.Genre
|
||||
}
|
||||
if hasOverride(o, OverrideCopyrightYear) {
|
||||
params.CopyrightYear = existing.CopyrightYear
|
||||
}
|
||||
if hasOverride(o, OverrideGoodreadsID) {
|
||||
params.GoodreadsID = existing.GoodreadsID
|
||||
}
|
||||
if hasOverride(o, OverrideOpenlibraryID) {
|
||||
params.OpenlibraryID = existing.OpenlibraryID
|
||||
}
|
||||
if hasOverride(o, OverrideGoogleBooksID) {
|
||||
params.GoogleBooksID = existing.GoogleBooksID
|
||||
}
|
||||
if hasOverride(o, OverrideMangaType) {
|
||||
params.MangaType = existing.MangaType
|
||||
}
|
||||
if hasOverride(o, OverrideReadingDirection) {
|
||||
params.ReadingDirection = existing.ReadingDirection
|
||||
}
|
||||
if hasOverride(o, OverrideSeriesCount) {
|
||||
params.SeriesCount = existing.SeriesCount
|
||||
}
|
||||
if hasOverride(o, OverrideVolume) {
|
||||
params.Volume = existing.Volume
|
||||
}
|
||||
if hasOverride(o, OverrideImprint) {
|
||||
params.Imprint = existing.Imprint
|
||||
}
|
||||
if hasOverride(o, OverrideAgeRating) {
|
||||
params.AgeRating = existing.AgeRating
|
||||
}
|
||||
if hasOverride(o, OverrideWebURL) {
|
||||
params.WebUrl = existing.WebUrl
|
||||
}
|
||||
if hasOverride(o, OverrideMetadataNotes) {
|
||||
params.MetadataNotes = existing.MetadataNotes
|
||||
}
|
||||
if hasOverride(o, OverrideCommunityRating) {
|
||||
params.CommunityRating = existing.CommunityRating
|
||||
}
|
||||
if hasOverride(o, OverrideStoryArc) {
|
||||
params.StoryArc = existing.StoryArc
|
||||
}
|
||||
if hasOverride(o, OverrideIsBlackAndWhite) {
|
||||
params.IsBlackAndWhite = existing.IsBlackAndWhite
|
||||
}
|
||||
if hasOverride(o, OverrideAlternateInfo) {
|
||||
params.AlternateInfo = existing.AlternateInfo
|
||||
}
|
||||
if hasOverride(o, OverrideScanInformation) {
|
||||
params.ScanInformation = existing.ScanInformation
|
||||
}
|
||||
if hasOverride(o, OverrideSummary) {
|
||||
params.Summary = existing.Summary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func TestMergeOverrides(t *testing.T) {
|
||||
got := MergeOverrides([]string{"title", "tags"}, "tags", "description", "")
|
||||
want := []string{"title", "tags", "description"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("MergeOverrides() = %v, want %v", got, want)
|
||||
}
|
||||
if MergeOverrides(nil) == nil {
|
||||
t.Error("MergeOverrides(nil) must return non-nil slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectMetadataOverrides(t *testing.T) {
|
||||
existing := database.MediaItems{
|
||||
Title: "Scanned Title",
|
||||
Description: pgtype.Text{String: "Scanned description", Valid: true},
|
||||
Tags: []string{"foo", "bar"},
|
||||
}
|
||||
params := database.UpdateMediaItemParams{
|
||||
// unchanged
|
||||
Title: "Scanned Title",
|
||||
// changed
|
||||
Description: pgtype.Text{String: "My custom description", Valid: true},
|
||||
Tags: []string{"foo", "bar"},
|
||||
}
|
||||
|
||||
got := DetectMetadataOverrides(params, existing)
|
||||
if !reflect.DeepEqual(got, []string{"description"}) {
|
||||
t.Errorf("DetectMetadataOverrides() = %v, want [description]", got)
|
||||
}
|
||||
|
||||
// Overrides accumulate: an existing override survives a later save.
|
||||
existing.MetadataOverrides = []string{"publisher"}
|
||||
got = DetectMetadataOverrides(params, existing)
|
||||
want := []string{"publisher", "description"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("DetectMetadataOverrides() accumulate = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// An untouched save detects nothing new.
|
||||
noop := database.UpdateMediaItemParams{
|
||||
Title: existing.Title,
|
||||
Description: existing.Description,
|
||||
Tags: existing.Tags,
|
||||
}
|
||||
got = DetectMetadataOverrides(noop, existing)
|
||||
if !reflect.DeepEqual(got, []string{"publisher"}) {
|
||||
t.Errorf("DetectMetadataOverrides() noop = %v, want [publisher]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectMetadataOverridesUnsetFormsEqual(t *testing.T) {
|
||||
// Zero-value params must not look "changed" against NULL-ish columns.
|
||||
existing := database.MediaItems{
|
||||
PageCount: pgtype.Int4{Int32: 0, Valid: false},
|
||||
SeriesNumber: pgtype.Int4{Int32: 5, Valid: true},
|
||||
CommunityRating: pgtype.Float8{Float64: 0, Valid: false},
|
||||
}
|
||||
params := database.UpdateMediaItemParams{
|
||||
PageCount: pgtype.Int4{Int32: 0, Valid: false},
|
||||
SeriesNumber: pgtype.Int4{Int32: 0, Valid: false}, // cleared by user -> changed
|
||||
CommunityRating: pgtype.Float8{Float64: 0, Valid: false},
|
||||
}
|
||||
got := DetectMetadataOverrides(params, existing)
|
||||
if !reflect.DeepEqual(got, []string{"series_number"}) {
|
||||
t.Errorf("DetectMetadataOverrides() = %v, want [series_number]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMetadataOverrides(t *testing.T) {
|
||||
existing := database.MediaItems{
|
||||
Title: "Custom Title",
|
||||
Description: pgtype.Text{String: "Custom desc", Valid: true},
|
||||
Tags: []string{"mine"},
|
||||
TagsSearch: []string{"mine"},
|
||||
CoverImagePath: pgtype.Text{String: "books/x.epub.custom_cover.jpg", Valid: true},
|
||||
}
|
||||
params := database.UpdateMediaItemParams{
|
||||
Title: "Scanned Title",
|
||||
Description: pgtype.Text{String: "Scanned desc", Valid: true},
|
||||
Tags: []string{"scanned"},
|
||||
TagsSearch: []string{"scanned"},
|
||||
CoverImagePath: pgtype.Text{String: "books/x.epub.cover.jpg", Valid: true},
|
||||
}
|
||||
|
||||
// Only title and tags overridden; scanned description and cover win.
|
||||
existing.MetadataOverrides = []string{"title", "tags"}
|
||||
ApplyMetadataOverrides(¶ms, existing)
|
||||
|
||||
if params.Title != "Custom Title" {
|
||||
t.Errorf("Title = %q, want %q", params.Title, "Custom Title")
|
||||
}
|
||||
if !reflect.DeepEqual(params.Tags, []string{"mine"}) || !reflect.DeepEqual(params.TagsSearch, []string{"mine"}) {
|
||||
t.Error("Tags/TagsSearch must be restored together")
|
||||
}
|
||||
if params.Description.String != "Scanned desc" {
|
||||
t.Errorf("Description = %q, want scanned value", params.Description.String)
|
||||
}
|
||||
if params.CoverImagePath.String != "books/x.epub.cover.jpg" {
|
||||
t.Errorf("CoverImagePath = %q, want scanned value", params.CoverImagePath.String)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package version
|
||||
|
||||
// Version is the app version. It defaults to "dev" for local builds and is
|
||||
// overridden at build time in release images via:
|
||||
//
|
||||
// go build -ldflags "-X bookhoard/internal/version.Version=v1.5"
|
||||
//
|
||||
// The release workflow passes the git tag (e.g. v1.5) as the APP_VERSION
|
||||
// Docker build arg, which the Dockerfile forwards to this flag.
|
||||
var Version = "dev"
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
"dev": "npm run build:ts:dev && npm run build:css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bookhoard/foliate-js": "git+https://github.com/john-okeefe/foliate-js.git#e448d36",
|
||||
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#e16530a",
|
||||
"alpinejs": "^3.15.8",
|
||||
"chart.js": "^4.5.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package templates
|
||||
|
||||
templ AdminArchived(user User, items []ArchivedItem, retentionDays int) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>Archived Items - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }">
|
||||
@Header(user, "/admin/archived")
|
||||
<main class="p-8">
|
||||
<div class="max-w-5xl">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("library", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Archived Items</h1>
|
||||
</div>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">
|
||||
Books hidden because their files are missing from disk. Reading history is
|
||||
kept in case a file returns; moves are detected automatically.
|
||||
if retentionDays > 0 {
|
||||
Archived items are permanently deleted after { retentionDays } days
|
||||
(ARCHIVE_RETENTION_DAYS).
|
||||
} else {
|
||||
Archived items are kept until manually purged (retention disabled).
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
if len(items) > 0 {
|
||||
<button type="button" onclick="purgeArchivedItems()" class="btn btn-secondary">
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Purge All Archived
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
if len(items) == 0 {
|
||||
<div class="card p-8 text-center">
|
||||
<p style="color: var(--text-secondary)">Nothing is archived or missing. All clear.</p>
|
||||
</div>
|
||||
} else {
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b text-left" style="border-color: var(--border); color: var(--text-secondary);">
|
||||
<th class="px-4 py-3 font-semibold">Book</th>
|
||||
<th class="px-4 py-3 font-semibold">Library</th>
|
||||
<th class="px-4 py-3 font-semibold">Status</th>
|
||||
<th class="px-4 py-3 font-semibold text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, item := range items {
|
||||
<tr class="border-b" style="border-color: color-mix(in srgb, var(--text-primary) 5%, transparent);">
|
||||
<td class="px-4 py-3">
|
||||
<p class="font-medium" style="color: var(--text-primary)">{ item.Title }</p>
|
||||
if item.Author != "" {
|
||||
<p style="color: var(--text-secondary)">{ item.Author }</p>
|
||||
}
|
||||
<p class="font-mono text-xs break-all" style="color: var(--text-secondary)">{ item.FilePath }</p>
|
||||
</td>
|
||||
<td class="px-4 py-3" style="color: var(--text-primary)">{ item.LibraryName }</td>
|
||||
<td class="px-4 py-3" style="color: var(--text-secondary)">{ item.Status }</td>
|
||||
<td class="px-4 py-3 text-right whitespace-nowrap">
|
||||
if item.MatchID != "" {
|
||||
<button
|
||||
type="button"
|
||||
data-merge-source={ item.ID }
|
||||
data-merge-target={ item.MatchID }
|
||||
class="btn btn-ghost text-xs"
|
||||
title={ "Merge this archived copy's reading data into the active copy \"" + item.MatchTitle + "\" (" + item.MatchLibraryName + ") and remove the archived entry" }
|
||||
>
|
||||
@Icon("copy", "h-3.5 w-3.5")
|
||||
Merge
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
data-unarchive={ item.ID }
|
||||
class="btn btn-ghost text-xs"
|
||||
title="Restore to libraries now; if the file is still gone the next scan hides it again"
|
||||
>
|
||||
@Icon("refresh", "h-3.5 w-3.5")
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-delete-archived={ item.ID }
|
||||
class="btn btn-ghost text-xs"
|
||||
title="Delete this item and its reading history permanently"
|
||||
>
|
||||
@Icon("trash", "h-3.5 w-3.5")
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Archived Items - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Header(user, "/admin/archived").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"p-8\"><div class=\"max-w-5xl\"><div class=\"mb-8\"><div class=\"flex items-center justify-between gap-4 flex-wrap\"><div><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("library", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Archived Items</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Books hidden because their files are missing from disk. Reading history is kept in case a file returns; moves are detected automatically. ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if retentionDays > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Archived items are permanently deleted after ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(retentionDays)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 30, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " days (ARCHIVE_RETENTION_DAYS).")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Archived items are kept until manually purged (retention disabled).")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(items) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<button type=\"button\" onclick=\"purgeArchivedItems()\" class=\"btn btn-secondary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Purge All Archived</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"card p-8 text-center\"><p style=\"color: var(--text-secondary)\">Nothing is archived or missing. All clear.</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"card overflow-hidden\"><table class=\"w-full text-sm\"><thead><tr class=\"border-b text-left\" style=\"border-color: var(--border); color: var(--text-secondary);\"><th class=\"px-4 py-3 font-semibold\">Book</th><th class=\"px-4 py-3 font-semibold\">Library</th><th class=\"px-4 py-3 font-semibold\">Status</th><th class=\"px-4 py-3 font-semibold text-right\">Actions</th></tr></thead> <tbody>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, item := range items {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<tr class=\"border-b\" style=\"border-color: color-mix(in srgb, var(--text-primary) 5%, transparent);\"><td class=\"px-4 py-3\"><p class=\"font-medium\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 65, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if item.Author != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 67, Col: 66}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<p class=\"font-mono text-xs break-all\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 69, Col: 103}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p></td><td class=\"px-4 py-3\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.LibraryName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 71, Col: 86}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</td><td class=\"px-4 py-3\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Status)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 72, Col: 83}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</td><td class=\"px-4 py-3 text-right whitespace-nowrap\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if item.MatchID != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<button type=\"button\" data-merge-source=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 77, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" data-merge-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MatchID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 78, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" class=\"btn btn-ghost text-xs\" title=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("Merge this archived copy's reading data into the active copy \"" + item.MatchTitle + "\" (" + item.MatchLibraryName + ") and remove the archived entry")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 80, Col: 174}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("copy", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "Merge</button> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<button type=\"button\" data-unarchive=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 88, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"btn btn-ghost text-xs\" title=\"Restore to libraries now; if the file is still gone the next scan hides it again\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("refresh", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "Restore</button> <button type=\"button\" data-delete-archived=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 97, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"btn btn-ghost text-xs\" title=\"Delete this item and its reading history permanently\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("trash", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "Delete</button></td></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</tbody></table></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div></main></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -59,7 +59,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("conflict-" + conflict.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 27, Col: 53}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 27, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -80,7 +80,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d copies", len(conflict.Items)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 33, Col: 87}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 33, Col: 87}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -93,7 +93,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.LibraryName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 36, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 36, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -106,7 +106,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.SHAShort)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 38, Col: 55}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 38, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -119,7 +119,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflict.ID + "/resolve")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 46, Col: 70}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 46, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -132,7 +132,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflict.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 48, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 48, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -194,7 +194,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 71, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 71, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -212,7 +212,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 73, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 73, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -230,7 +230,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 75, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 75, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -243,7 +243,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f MB", float64(item.FileSize)/(1024*1024)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 77, Col: 64}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 77, Col: 64}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -261,7 +261,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 79, Col: 85}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 79, Col: 85}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -279,7 +279,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 81, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 81, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -297,7 +297,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflictID + "/resolve")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 87, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 87, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -310,7 +310,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf(`{"action": "keep", "keep_uuid": "%s"}`, item.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 88, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 88, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -323,7 +323,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflictID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 89, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 89, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -383,7 +383,7 @@ func AdminHashConflicts(user User, conflicts []HashConflictData) templ.Component
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var19).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package templates
|
||||
|
||||
templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
||||
templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCount int64) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -34,6 +34,28 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
if archivedCount > 0 {
|
||||
<div class="card p-4 mb-6 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<p class="font-semibold" style="color: var(--text-primary)">
|
||||
<a href="/admin/archived" class="hover:underline" style="color: var(--text-primary); text-decoration: none;">
|
||||
Archived items: { archivedCount }
|
||||
</a>
|
||||
</p>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">
|
||||
Files missing from disk for two consecutive scans. Reading history is kept
|
||||
unless purged; items return automatically if their files come back.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/admin/archived" class="btn btn-secondary">View Archived</a>
|
||||
<button type="button" onclick="purgeArchivedItems()" class="btn btn-secondary">
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Purge Archived Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div id="libraries-container">
|
||||
@LibraryList(user, libraries, users)
|
||||
</div>
|
||||
@@ -129,10 +151,18 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<p class="mb-6" style="color: var(--text-primary)">
|
||||
<p class="mb-3" style="color: var(--text-primary)">
|
||||
Are you sure you want to delete <strong id="delete-library-name"></strong>?
|
||||
This will remove the library and all its folder mappings. Media files will not be deleted.
|
||||
</p>
|
||||
<ul class="mb-6 space-y-2 text-sm list-disc list-inside" style="color: var(--text-primary)">
|
||||
<li>
|
||||
<strong>Permanently removed, no archive or undo:</strong>
|
||||
the library, its folder mappings, and all book records — with their reading progress, highlights, notes, bookmarks, and ratings.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Not touched:</strong> media files on disk.
|
||||
</li>
|
||||
</ul>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="document.getElementById('delete-library-modal').classList.add('hidden')" class="btn btn-secondary">Cancel</button>
|
||||
<button type="button" id="delete-library-confirm" class="btn btn-danger">
|
||||
@@ -179,6 +209,15 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
||||
htmx.process(content);
|
||||
document.getElementById('folder-browser-modal').classList.remove('hidden');
|
||||
}
|
||||
// Close the delete-library modal after a successful delete: the
|
||||
// htmx swap already removes the deleted row from the libraries
|
||||
// list, but the modal lives outside the swapped container and
|
||||
// would otherwise stay open.
|
||||
document.getElementById('delete-library-confirm').addEventListener('htmx:afterRequest', function(evt) {
|
||||
if (evt.detail.successful) {
|
||||
document.getElementById('delete-library-modal').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+369
-338
File diff suppressed because it is too large
Load Diff
@@ -70,7 +70,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 36, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 36, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -97,7 +97,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 45, Col: 94}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 45, Col: 94}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -124,7 +124,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 54, Col: 91}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 54, Col: 91}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -166,7 +166,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("issue-" + issue.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 70, Col: 54}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 70, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -179,7 +179,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 73, Col: 98}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 73, Col: 98}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -192,7 +192,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 74, Col: 96}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 74, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -205,7 +205,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 76, Col: 140}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 76, Col: 140}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -218,7 +218,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 77, Col: 144}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 77, Col: 144}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -231,7 +231,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 78, Col: 139}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 78, Col: 139}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -244,7 +244,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 79, Col: 149}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 79, Col: 149}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -262,7 +262,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 84, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 84, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -280,7 +280,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 86, Col: 63}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 86, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -298,7 +298,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 88, Col: 66}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 88, Col: 66}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -321,7 +321,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 95, Col: 113}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 95, Col: 113}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -334,7 +334,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("#issue-" + issue.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 96, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 96, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package templates
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"bookhoard/internal/version"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
templ AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) {
|
||||
<!DOCTYPE html>
|
||||
@@ -111,6 +114,7 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan
|
||||
@ScanSettingsSection(scanSettings)
|
||||
@TunableSettingsSection(liveGroups, false)
|
||||
@TunableSettingsSection(restartGroups, true)
|
||||
@AboutSection()
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
@@ -255,3 +259,18 @@ templ TunableSettingRow(e SettingEntry) {
|
||||
<span id={ "status-" + e.Key } class="text-xs w-24 text-right" style="color: var(--text-secondary)"></span>
|
||||
</div>
|
||||
}
|
||||
|
||||
// AboutSection shows build metadata, rendered at the bottom of the System
|
||||
// Settings page. Add future rows (disk space, uptime, Go version, …) to the
|
||||
// list below.
|
||||
templ AboutSection() {
|
||||
<div class="mt-6 card p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
@Icon("info", "h-5 w-5 shrink-0")
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">About</h3>
|
||||
</div>
|
||||
<div class="space-y-2 text-sm" style="color: var(--text-secondary);">
|
||||
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Version:</span> { version.Version }</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ package templates
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"bookhoard/internal/version"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
@@ -67,7 +70,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 31, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 34, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -93,7 +96,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 45, Col: 42}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 48, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -370,7 +373,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 106, Col: 145}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 109, Col: 145}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -383,7 +386,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 107, Col: 144}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 110, Col: 144}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -396,7 +399,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 108, Col: 152}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 111, Col: 152}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -418,6 +421,10 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = AboutSection().Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</div></main></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
@@ -472,7 +479,7 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 154, Col: 69}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 158, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -564,7 +571,7 @@ func TunableSettingsSection(groups []SettingGroup, restartRequired bool) templ.C
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 194, Col: 112}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 198, Col: 112}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -622,7 +629,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(e.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 209, Col: 94}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 213, Col: 94}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -640,7 +647,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 211, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 215, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -658,7 +665,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 213, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 217, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -676,7 +683,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("#status-" + e.Key)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 219, Col: 33}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 223, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -689,7 +696,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Key)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 223, Col: 48}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 227, Col: 48}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -732,7 +739,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 233, Col: 20}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 237, Col: 20}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -750,7 +757,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Min)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 235, Col: 17}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 239, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -769,7 +776,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Max)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 238, Col: 17}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 242, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -792,7 +799,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 246, Col: 20}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 250, Col: 20}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -818,7 +825,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("status-" + e.Key)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 255, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 259, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -832,4 +839,57 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
})
|
||||
}
|
||||
|
||||
// AboutSection shows build metadata, rendered at the bottom of the System
|
||||
// Settings page. Add future rows (disk space, uptime, Go version, …) to the
|
||||
// list below.
|
||||
func AboutSection() templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var22 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var22 == nil {
|
||||
templ_7745c5c3_Var22 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "<div class=\"mt-6 card p-6\"><div class=\"flex items-center gap-2 mb-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("info", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">About</h3></div><div class=\"space-y-2 text-sm\" style=\"color: var(--text-secondary);\"><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Version:</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(version.Version)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 273, Col: 134}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</p></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
|
||||
@@ -60,7 +60,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.LibraryCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 32, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 32, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -81,7 +81,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 39, Col: 90}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 39, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -102,7 +102,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.UserCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 46, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 46, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -123,7 +123,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.DeviceCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 53, Col: 91}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 53, Col: 91}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -57,7 +57,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 42, Col: 36}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 42, Col: 36}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -78,7 +78,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 50, Col: 97}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 50, Col: 97}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -101,7 +101,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 59, Col: 80}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 59, Col: 80}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -124,7 +124,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 77, Col: 53}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 77, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -137,7 +137,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 78, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 78, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -170,7 +170,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 87, Col: 47}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 87, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -188,7 +188,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 92, Col: 126}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 92, Col: 126}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -201,7 +201,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 98, Col: 67}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 98, Col: 67}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -240,7 +240,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 117, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 117, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -253,7 +253,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 118, Col: 45}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 118, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -42,7 +42,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 8, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 8, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -60,7 +60,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 10, Col: 65}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 10, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -78,7 +78,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 12, Col: 120}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 12, Col: 120}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -96,7 +96,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 14, Col: 69}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 14, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -114,7 +114,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 16, Col: 88}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 16, Col: 88}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -127,7 +127,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 24, Col: 131}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 24, Col: 131}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -140,7 +140,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Response)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 28, Col: 128}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 28, Col: 128}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -179,7 +179,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 45, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 45, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -197,7 +197,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 47, Col: 65}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 47, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -215,7 +215,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 49, Col: 120}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 49, Col: 120}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -233,7 +233,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 51, Col: 69}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 51, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -251,7 +251,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 53, Col: 88}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 53, Col: 88}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -304,7 +304,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 70, Col: 136}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 70, Col: 136}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -107,10 +107,12 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
|
||||
}
|
||||
</button>
|
||||
if user.Role == "admin" {
|
||||
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
|
||||
@Icon("edit", "h-4 w-4")
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<!-- Rating -->
|
||||
<div class="mb-5" @mouseleave="ratingHover = 0">
|
||||
@@ -376,6 +378,12 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
<p style="color: var(--text-primary)">{ formatFileSize(book.FileSize.Int64) }</p>
|
||||
</div>
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
<div class="md:col-span-2 lg:col-span-3">
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Location</p>
|
||||
<p class="font-mono text-xs break-all select-all" style="color: var(--text-secondary)">{ book.FileLocation }</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
|
||||
<div class="mt-5 pt-4 border-t flex flex-wrap gap-2" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
|
||||
@@ -426,7 +434,9 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
</div>
|
||||
@ProgressSyncModal(user, book)
|
||||
@NotesHighlightsModal(user, book)
|
||||
if user.Role == "admin" {
|
||||
@MetadataEditorModal(book)
|
||||
}
|
||||
@ErrorToast(errorMessage)
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -290,25 +290,6 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
<input type="file" id="cover-upload-input" accept="image/jpeg,image/png,image/webp" class="hidden"
|
||||
@change="handleCoverUpload($event)" />
|
||||
<div class="w-64 space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary w-full"
|
||||
@click="generateCover()"
|
||||
x-show="coverGenerating"
|
||||
disabled
|
||||
>
|
||||
@Icon("refresh", "h-4 w-4")
|
||||
Generating...
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary w-full"
|
||||
@click="generateCover()"
|
||||
x-show="!coverGenerating"
|
||||
>
|
||||
@Icon("refresh", "h-4 w-4")
|
||||
Generate Cover
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost w-full"
|
||||
@@ -605,11 +586,42 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
class="input opacity-60" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">File Path</label>
|
||||
<input type="text" value={ book.FileLocation } readonly
|
||||
class="input opacity-60 font-mono text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 p-6 border-t flex-shrink-0" style="border-color: var(--border);">
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-6 border-t flex-shrink-0" style="border-color: var(--border);">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="rescanBook()"
|
||||
:disabled="rescanning"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
<span x-show="!rescanning" class="inline-flex items-center gap-2">@Icon("sync", "h-4 w-4")<span>Rescan</span></span>
|
||||
<svg x-show="rescanning" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="resetMetadata()"
|
||||
:disabled="resetting"
|
||||
class="btn btn-secondary"
|
||||
title="Discard all manual edits and restore scanned metadata"
|
||||
>
|
||||
<span x-show="!resetting" class="inline-flex items-center gap-2">@Icon("refresh", "h-4 w-4")<span>Reset to Scanned</span></span>
|
||||
<svg x-show="resetting" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<button
|
||||
@click="hideMetadataEditor()"
|
||||
class="btn btn-secondary"
|
||||
@@ -626,4 +638,5 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+395
-364
File diff suppressed because it is too large
Load Diff
@@ -149,7 +149,13 @@ templ BookShelf(
|
||||
<option value="">All Books ({ TotalMediaCount(libraries) })</option>
|
||||
}
|
||||
for _, lib := range libraries {
|
||||
if lib.Offline {
|
||||
if lib.ID == currentLibraryID {
|
||||
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount }) — storage offline</option>
|
||||
} else {
|
||||
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount }) — storage offline</option>
|
||||
}
|
||||
} else if lib.ID == currentLibraryID {
|
||||
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
|
||||
} else {
|
||||
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
|
||||
|
||||
+134
-44
@@ -97,7 +97,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 93, Col: 135}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 93, Col: 135}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -110,7 +110,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 95, Col: 25}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 95, Col: 25}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -173,7 +173,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 147, Col: 77}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 147, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -191,7 +191,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 149, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 149, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -203,6 +203,7 @@ func BookShelf(
|
||||
}
|
||||
}
|
||||
for _, lib := range libraries {
|
||||
if lib.Offline {
|
||||
if lib.ID == currentLibraryID {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -211,7 +212,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 154, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -224,7 +225,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 154, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -237,13 +238,13 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 77}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 154, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, ")</option>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, ") — storage offline</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -255,7 +256,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 156, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -268,7 +269,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 48}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 156, Col: 48}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -281,20 +282,109 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 156, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, ")</option>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, ") — storage offline</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
} else if lib.ID == currentLibraryID {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 159, Col: 34}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" selected>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 159, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " (")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 159, Col: 76}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ")</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 34}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " (")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 67}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, ")</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Author</label> <input type=\"text\" name=\"author_filter\" placeholder=\"Filter by author\" class=\"input\" list=\"author-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchAuthorValues($el)\"> <datalist id=\"author-datalist\"></datalist></div><div class=\"relative\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Tags</label> <input type=\"text\" name=\"tags_filter\" placeholder=\"Filter by tags\" class=\"input\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><template x-for=\"sug in tagSuggestions\" :key=\"sug.value\"><button type=\"button\" class=\"w-full text-left px-3 py-2 text-sm flex justify-between items-center\" :class=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''\" :style=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'\" @click=\"selectTagSuggestion(sug.value)\"><span x-text=\"sug.value\"></span> <span class=\"text-xs\" style=\"color: var(--text-secondary);\" x-text=\"sug.count + ' books'\"></span></button></template></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Series</label> <input type=\"text\" name=\"series_filter\" placeholder=\"Filter by series\" class=\"input\" list=\"series-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchSeriesValues($el)\"> <datalist id=\"series-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Language</label> <input type=\"text\" name=\"language_filter\" placeholder=\"Filter by language\" class=\"input\" list=\"language-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchLanguageValues($el)\"> <datalist id=\"language-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Year Range</label><div class=\"flex gap-2\"><input type=\"number\" name=\"year_min\" placeholder=\"From\" class=\"input\"> <input type=\"number\" name=\"year_max\" placeholder=\"To\" class=\"input\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Cover</label> <input type=\"button\" id=\"has-cover-tristate\" class=\"tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm\" :class=\"{\n\t\t\t\t\t\t\t\t\t\t\t'state-null': hasCoverState === null,\n\t\t\t\t\t\t\t\t\t\t\t'state-true': hasCoverState === true,\n\t\t\t\t\t\t\t\t\t\t\t'state-false': hasCoverState === false\n\t\t\t\t\t\t\t\t\t\t}\" :value=\"hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'\" @click=\"cycleHasCover()\"><template x-if=\"hasCoverState !== null\"><input type=\"hidden\" name=\"has_cover\" :value=\"hasCoverState ? 'true' : 'false'\"></template></div></div><div class=\"sticky bottom-0 px-6 py-4 border-t\" style=\"background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><button type=\"submit\" @click=\"filtersOpen = false\" class=\"btn btn-primary w-full\">Apply Filters</button></div></div></div></form><div id=\"books-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Author</label> <input type=\"text\" name=\"author_filter\" placeholder=\"Filter by author\" class=\"input\" list=\"author-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchAuthorValues($el)\"> <datalist id=\"author-datalist\"></datalist></div><div class=\"relative\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Tags</label> <input type=\"text\" name=\"tags_filter\" placeholder=\"Filter by tags\" class=\"input\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><template x-for=\"sug in tagSuggestions\" :key=\"sug.value\"><button type=\"button\" class=\"w-full text-left px-3 py-2 text-sm flex justify-between items-center\" :class=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''\" :style=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'\" @click=\"selectTagSuggestion(sug.value)\"><span x-text=\"sug.value\"></span> <span class=\"text-xs\" style=\"color: var(--text-secondary);\" x-text=\"sug.count + ' books'\"></span></button></template></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Series</label> <input type=\"text\" name=\"series_filter\" placeholder=\"Filter by series\" class=\"input\" list=\"series-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchSeriesValues($el)\"> <datalist id=\"series-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Language</label> <input type=\"text\" name=\"language_filter\" placeholder=\"Filter by language\" class=\"input\" list=\"language-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchLanguageValues($el)\"> <datalist id=\"language-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Year Range</label><div class=\"flex gap-2\"><input type=\"number\" name=\"year_min\" placeholder=\"From\" class=\"input\"> <input type=\"number\" name=\"year_max\" placeholder=\"To\" class=\"input\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Cover</label> <input type=\"button\" id=\"has-cover-tristate\" class=\"tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm\" :class=\"{\n\t\t\t\t\t\t\t\t\t\t\t'state-null': hasCoverState === null,\n\t\t\t\t\t\t\t\t\t\t\t'state-true': hasCoverState === true,\n\t\t\t\t\t\t\t\t\t\t\t'state-false': hasCoverState === false\n\t\t\t\t\t\t\t\t\t\t}\" :value=\"hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'\" @click=\"cycleHasCover()\"><template x-if=\"hasCoverState !== null\"><input type=\"hidden\" name=\"has_cover\" :value=\"hasCoverState ? 'true' : 'false'\"></template></div></div><div class=\"sticky bottom-0 px-6 py-4 border-t\" style=\"background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><button type=\"submit\" @click=\"filtersOpen = false\" class=\"btn btn-primary w-full\">Apply Filters</button></div></div></div></form><div id=\"books-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -303,53 +393,53 @@ func BookShelf(
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if errorMessage != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"col-span-full mt-2 p-4 rounded-xl border\" style=\"background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);\"><p style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"col-span-full mt-2 p-4 rounded-xl border\" style=\"background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);\"><p style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 269, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 275, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div><div id=\"pagination\" class=\"mt-6 flex justify-center items-center gap-2\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div><div id=\"pagination\" class=\"mt-6 flex justify-center items-center gap-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if count > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 278, Col: 126}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 284, Col: 126}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if offset <= 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " disabled")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " disabled")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, ">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, ">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -357,43 +447,43 @@ func BookShelf(
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span>Prev</span></button> <span class=\"px-4 py-2 text-sm\" style=\"color: var(--text-secondary);\">Page ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<span>Prev</span></button> <span class=\"px-4 py-2 text-sm\" style=\"color: var(--text-secondary);\">Page ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 287, Col: 32}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 293, Col: 32}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span> <button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</span> <button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 292, Col: 126}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 298, Col: 126}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if offset+limit >= count {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " disabled")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " disabled")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "><span>Next</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "><span>Next</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -401,12 +491,12 @@ func BookShelf(
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div></div><div x-show=\"showSaveModal\" @click.self=\"showSaveModal = false\" x-transition x-cloak class=\"fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div @click.stop class=\"card rounded-2xl p-6 w-full max-w-md\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Save Filter</h2><button @click=\"showSaveModal = false\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</div></div><div x-show=\"showSaveModal\" @click.self=\"showSaveModal = false\" x-transition x-cloak class=\"fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div @click.stop class=\"card rounded-2xl p-6 w-full max-w-md\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Save Filter</h2><button @click=\"showSaveModal = false\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -414,7 +504,7 @@ func BookShelf(
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</button></div><form hx-post=\"/api/saved-filters\" hx-target=\"#saved-filters-list\" hx-swap=\"beforeend\" hx-include=\"#filter-form\" @htmx:afterRequest=\"if(event.detail.xhr.status < 400) { afterFilterSave() }\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Filter Name</label> <input type=\"text\" name=\"filter_name\" placeholder=\"My Custom Filter\" class=\"input\" required><div id=\"filter-save-error\" style=\"display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;\"></div><input type=\"hidden\" name=\"resource_type\" value=\"media-items\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" @click=\"showSaveModal = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</button></div><form hx-post=\"/api/saved-filters\" hx-target=\"#saved-filters-list\" hx-swap=\"beforeend\" hx-include=\"#filter-form\" @htmx:afterRequest=\"if(event.detail.xhr.status < 400) { afterFilterSave() }\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Filter Name</label> <input type=\"text\" name=\"filter_name\" placeholder=\"My Custom Filter\" class=\"input\" required><div id=\"filter-save-error\" style=\"display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;\"></div><input type=\"hidden\" name=\"resource_type\" value=\"media-items\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" @click=\"showSaveModal = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(pageTitle)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 14, Col: 21}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 14, Col: 21}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -62,7 +62,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
var templ_7745c5c3_Var3 templ.SafeURL
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(backUrl)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 22, Col: 22}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 22, Col: 22}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -83,7 +83,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(backLabel)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 24, Col: 23}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 24, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -96,7 +96,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(badgeIcon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 17}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 31, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -109,7 +109,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 32}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 31, Col: 32}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -122,7 +122,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 33, Col: 93}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 33, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -135,7 +135,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 34, Col: 125}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 34, Col: 125}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -168,7 +168,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emptyIcon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 44, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 44, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -181,7 +181,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(emptyMessage)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 46, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 46, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -72,7 +72,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + collection.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 23, Col: 49}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 23, Col: 49}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -85,7 +85,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 26, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 26, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -98,7 +98,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 32, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 32, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -111,7 +111,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 45, Col: 31}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 45, Col: 31}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -124,7 +124,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 58, Col: 83}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 58, Col: 83}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -137,7 +137,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Color)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 74, Col: 86}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 74, Col: 86}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -155,7 +155,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 116, Col: 83}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 116, Col: 83}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -36,7 +36,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 9, Col: 46}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 9, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -65,7 +65,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 23, Col: 176}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 23, Col: 176}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -78,7 +78,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 25, Col: 90}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 25, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -99,7 +99,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 45, Col: 67}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 45, Col: 67}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -97,7 +97,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 71, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 71, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -110,7 +110,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 74, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 74, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -123,7 +123,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 77, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 77, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -136,7 +136,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 80, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 80, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -157,7 +157,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 89, Col: 56}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 89, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -178,7 +178,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 99, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 99, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -191,7 +191,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 100, Col: 81}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 100, Col: 81}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -246,7 +246,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 117, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 117, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -279,7 +279,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 132, Col: 166}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 132, Col: 166}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -292,7 +292,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 134, Col: 105}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 134, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -305,7 +305,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 135, Col: 87}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 135, Col: 87}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -355,7 +355,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 84}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 179, Col: 84}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -368,7 +368,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 112}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 179, Col: 112}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -381,7 +381,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 147}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 179, Col: 147}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -404,7 +404,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var17 templ.SafeURL
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 194, Col: 47}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 194, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -417,7 +417,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 196, Col: 23}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 196, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -435,7 +435,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 201, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 201, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -453,7 +453,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var20 templ.SafeURL
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 206, Col: 47}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 206, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -471,7 +471,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 209, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 209, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -551,7 +551,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 385, Col: 26}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 385, Col: 26}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -564,7 +564,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 386, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 386, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -577,7 +577,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSystem)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 387, Col: 39}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 387, Col: 39}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -620,7 +620,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.toggleBook('" + book.MediaItemID + "')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 397, Col: 70}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 397, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -638,7 +638,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 401, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 401, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -651,7 +651,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 401, Col: 54}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 401, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -669,7 +669,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 403, Col: 61}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 403, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -687,7 +687,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 406, Col: 72}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 406, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -700,7 +700,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 411, Col: 72}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 411, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -713,7 +713,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 418, Col: 87}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 418, Col: 87}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -54,7 +54,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(total)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 45, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 45, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -67,7 +67,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 49, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 49, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -80,7 +80,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 53, Col: 97}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 53, Col: 97}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -128,7 +128,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 111, Col: 72}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 111, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -141,7 +141,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 117, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 117, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -162,7 +162,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 125, Col: 105}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 125, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -175,7 +175,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 126, Col: 96}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 126, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -188,7 +188,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 133, Col: 70}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 133, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -206,7 +206,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 135, Col: 45}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 135, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -65,7 +65,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 31}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `custom_section.templ`, Line: 74, Col: 31}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -78,7 +78,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `custom_section.templ`, Line: 74, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user