fix(settings): display complete endpoint path in koreader sync url

The KOReader settings page displayed the origin-only base URL without the /api/v1/koreader path suffix, causing manual URL copy and built-in progress sync setup to fail due to incomplete endpoint paths.

Updates the UI to display the full KOReader sync URL with the API endpoint path included, consolidates redundant copy fields, and updates plugin URL normalization to handle full stock sync URLs seamlessly.

Closes #981
This commit is contained in:
neon
2026-08-13 19:08:53 +00:00
parent 0104898371
commit becfb6a72e
4 changed files with 38 additions and 50 deletions
@@ -120,7 +120,7 @@ const creating = ref(false)
const showPassword = ref(false)
const deleteConfirmOpen = ref(false)
const helpOpen = ref(false)
const copiedUrlTarget = ref<'plugin' | 'stock' | null>(null)
const urlCopied = ref(false)
const selectedUnmatched = ref<KoreaderUnmatchedBook | null>(null)
const selectedManualLink = ref<KoreaderManualHashLink | null>(null)
const linkSearchQuery = ref('')
@@ -152,10 +152,7 @@ onUnmounted(() => {
if (copiedUrlTimer) clearTimeout(copiedUrlTimer)
})
const syncUrl = computed(() => getSyncUrl())
const stockSyncUrl = computed(() => new URL('/api/v1/koreader', syncUrl.value).toString())
const urlCopied = computed(() => copiedUrlTarget.value === 'plugin')
const stockUrlCopied = computed(() => copiedUrlTarget.value === 'stock')
const syncUrl = computed(() => new URL('/api/v1/koreader', getSyncUrl()).toString())
const hasCredentials = computed(() => !!credentials.value)
const deviceCount = computed(() => syncStatus.value?.devices.length ?? 0)
const totalSyncedBooks = computed(() => syncStatus.value?.totalSyncedBooks ?? 0)
@@ -383,30 +380,22 @@ async function handleDelete() {
}
}
async function copyUrlWithFeedback(url: string, target: 'plugin' | 'stock') {
const copied = await copyToClipboard(url)
async function handleCopySyncUrl() {
const copied = await copyToClipboard(syncUrl.value)
if (!copied) {
toast.error(t('settings.reader.koreader.syncUrlCopyFailed'))
return
}
copiedUrlTarget.value = target
urlCopied.value = true
toast.success(t('settings.reader.koreader.syncUrlCopied'))
if (copiedUrlTimer) clearTimeout(copiedUrlTimer)
copiedUrlTimer = setTimeout(() => {
copiedUrlTarget.value = null
urlCopied.value = false
copiedUrlTimer = null
}, 2000)
}
async function handleCopyUrl() {
await copyUrlWithFeedback(syncUrl.value, 'plugin')
}
async function handleCopyStockUrl() {
await copyUrlWithFeedback(stockSyncUrl.value, 'stock')
}
async function handleRefresh() {
try {
await fetchSyncStatus(true)
@@ -793,13 +782,24 @@ async function handleDownloadPlugin() {
</p>
<div class="border border-border rounded-lg overflow-hidden shadow-xs divide-y divide-border">
<div class="px-4 py-4 bg-card md:px-5">
<div class="mb-2 flex items-center gap-2">
<BookOpen :size="14" class="text-muted-foreground shrink-0" />
<label for="koreader-plugin-server-url" class="settings-label">{{ t('settings.reader.koreader.pluginServerUrl') }}</label>
<div class="flex items-start gap-3">
<div class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md bg-muted">
<Link2 :size="15" class="text-muted-foreground" />
</div>
<div class="min-w-0">
<label for="koreader-sync-url" class="settings-label">{{ t('settings.reader.koreader.syncUrl') }}</label>
<p class="settings-hint mt-1">
{{ t('settings.reader.koreader.syncUrlHint') }}
</p>
</div>
</div>
<div class="flex flex-col gap-2 md:flex-row md:items-center">
<input id="koreader-plugin-server-url" :value="syncUrl" readonly class="input-field flex-1 min-w-0 font-mono text-xs md:text-sm" />
<button class="settings-btn-outline w-full min-h-10 justify-center md:w-auto md:min-h-0" @click="handleCopyUrl">
<div class="mt-3 flex flex-col gap-2 md:flex-row md:items-center">
<input id="koreader-sync-url" :value="syncUrl" readonly class="input-field flex-1 min-w-0 font-mono text-xs md:text-sm" />
<button
id="koreader-sync-url-copy"
class="settings-btn-outline w-full min-h-10 justify-center md:w-auto md:min-h-0"
@click="handleCopySyncUrl"
>
<Check v-if="urlCopied" :size="12" />
<Copy v-else :size="12" />
{{ urlCopied ? t('settings.reader.koreader.copied') : t('settings.reader.koreader.copyUrl') }}
@@ -1249,26 +1249,7 @@ async function handleDownloadPlugin() {
{{ t('settings.reader.koreader.stockStep1Prefix') }}
<span class="font-mono text-foreground">Tools &gt; Progress sync</span>{{ t('settings.reader.koreader.stockStep1Suffix') }}
</li>
<li class="space-y-2">
<label for="koreader-stock-server-url">{{ t('settings.reader.koreader.stockStep2InlineUrl') }}</label>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<input
id="koreader-stock-server-url"
:value="stockSyncUrl"
readonly
class="input-field flex-1 min-w-0 font-mono text-xs md:text-sm"
/>
<button
id="koreader-stock-server-url-copy"
class="settings-btn-outline w-full min-h-10 justify-center shrink-0 sm:w-auto sm:min-h-0"
@click="handleCopyStockUrl"
>
<Check v-if="stockUrlCopied" :size="12" />
<Copy v-else :size="12" />
{{ stockUrlCopied ? t('settings.reader.koreader.copied') : t('settings.reader.koreader.copyUrl') }}
</button>
</div>
</li>
<li>{{ t('settings.reader.koreader.stockStep2') }}</li>
<li>{{ t('settings.reader.koreader.stockStep3') }}</li>
</ol>
</div>
@@ -323,8 +323,9 @@ describe('KoreaderSettings', () => {
expect(wrapper.text()).toContain('reader-user')
expect(wrapper.text()).toContain('14 books')
expect(wrapper.text()).toContain('1 device')
const syncUrlInput = wrapper.find('input[readonly]').element as HTMLInputElement
expect(syncUrlInput.value).toBe('https://bookorbit.example')
expect(wrapper.text()).toContain('KOReader sync URL')
expect(wrapper.text()).toContain("Works with the BookOrbit plugin and KOReader's built-in progress sync.")
expect((wrapper.find('#koreader-sync-url').element as HTMLInputElement).value).toBe('https://bookorbit.example/api/v1/koreader')
expect(wrapper.text()).toContain('Kobo Libra 2')
expect(wrapper.text()).toContain('Project Hail Mary')
expect(wrapper.text()).toContain('Latest plugin: v1.6.0')
@@ -351,11 +352,10 @@ describe('KoreaderSettings', () => {
await buttonByText(wrapper, 'KOReader setup steps')!.trigger('click')
expect(wrapper.text()).toContain('Download the preconfigured plugin above.')
const stockUrlInput = wrapper.find('#koreader-stock-server-url')
expect(wrapper.find('label[for="koreader-stock-server-url"]').text()).toBe('Set the custom sync server to the following URL:')
expect((stockUrlInput.element as HTMLInputElement).value).toBe('https://bookorbit.example/api/v1/koreader')
expect(wrapper.text()).toContain('Set the custom sync server to the URL shown above.')
expect(wrapper.findAll('#koreader-sync-url')).toHaveLength(1)
const copyButton = wrapper.find('#koreader-stock-server-url-copy')
const copyButton = wrapper.find('#koreader-sync-url-copy')
await copyButton.trigger('click')
expect(vi.mocked(copyToClipboard)).toHaveBeenLastCalledWith('https://bookorbit.example/api/v1/koreader')
@@ -469,7 +469,7 @@ describe('KoreaderSettings', () => {
expect(koreaderMock.fetchUnmatchedBooks).toHaveBeenCalledTimes(2)
expect(koreaderMock.fetchManualHashLinks).toHaveBeenCalledTimes(2)
expect(koreaderMock.updateCredentials).toHaveBeenCalledWith({ syncEnabled: false })
expect(vi.mocked(copyToClipboard)).toHaveBeenCalledWith('https://bookorbit.example')
expect(vi.mocked(copyToClipboard)).toHaveBeenCalledWith('https://bookorbit.example/api/v1/koreader')
expect(koreaderMock.downloadPluginPackage).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('Delete KOReader credentials?')
+2
View File
@@ -2105,6 +2105,8 @@
"credentialsCreatedLabel": "Credentials created",
"setup": "Setup",
"pluginServerUrl": "Plugin server URL",
"syncUrl": "KOReader sync URL",
"syncUrlHint": "Works with the BookOrbit plugin and KOReader's built-in progress sync.",
"copied": "Copied",
"copyUrl": "Copy URL",
"preconfiguredPlugin": "Preconfigured BookOrbit plugin",
@@ -108,6 +108,11 @@ local function assertEqual(actual, expected, label)
end
end
assertEqual(BookOrbitApi.normalizeServerUrl("https://bookorbit.example.com"),
"https://bookorbit.example.com/api/v1", "origin-only server URL is normalized")
assertEqual(BookOrbitApi.normalizeServerUrl("https://bookorbit.example.com/api/v1/koreader"),
"https://bookorbit.example.com/api/v1", "stock sync URL is normalized")
local decoded, err = BookOrbitApi.decodeResponse({ "" })
assertEqual(type(decoded), "table", "empty body decodes to table")
assertEqual(err, nil, "empty body has no error")