Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion Jiten.Api/Controllers/AdminController.WebNovels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,22 @@ public async Task<IActionResult> PreviewWebNovel([FromQuery] string url,
}
}

/// <summary>
/// Re-checks the InsertDeck dedupe key after the admin edits the title on the add page.
/// </summary>
[HttpGet("webnovel-title-conflict")]
public async Task<IActionResult> CheckWebNovelTitleConflict([FromQuery] string? title)
{
var normalised = title?.Trim();
if (string.IsNullOrEmpty(normalised))
return Ok(new { Conflict = false });

var conflict = await dbContext.Decks
.AnyAsync(d => d.OriginalTitle == normalised && d.MediaType == MediaType.WebNovel);

return Ok(new { Conflict = conflict });
}

[HttpPost("add-webnovel-deck")]
[Consumes("multipart/form-data")]
public async Task<IActionResult> AddWebNovelDeck([FromForm] AddWebNovelDeckRequest model,
Expand Down Expand Up @@ -98,9 +114,16 @@ public async Task<IActionResult> AddWebNovelDeck([FromForm] AddWebNovelDeckReque
await model.CoverImage.CopyToAsync(stream);
}

var titles = new WebNovelTitles
{
OriginalTitle = model.OriginalTitle,
RomajiTitle = model.RomajiTitle,
EnglishTitle = model.EnglishTitle
};

// Fetching a long novel is 20+ minutes of polite requests, so it can never run in-request
var jobId = backgroundJobs.Enqueue<WebNovelImportJob>(
job => job.Import(provider, sourceId, coverPath, model.ChunkCharBudget));
job => job.Import(provider, sourceId, coverPath, model.ChunkCharBudget, titles));

logger.LogInformation("Admin queued webnovel import for {Provider}/{SourceId} (job {JobId})",
provider, sourceId, jobId);
Expand Down
12 changes: 12 additions & 0 deletions Jiten.Api/Dtos/Requests/AddWebNovelDeckRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ public class AddWebNovelDeckRequest
/// </summary>
public required string Url { get; set; }

/// <summary>
/// Optional override of the title the source reports
/// </summary>
public string? OriginalTitle { get; set; }

/// <summary>
/// Optional: the source only carries the Japanese title, so romaji is typed or auto-romanised at add time
/// </summary>
public string? RomajiTitle { get; set; }

public string? EnglishTitle { get; set; }

/// <summary>
/// Optional: uploaded or generated in the browser. Syosetu works have no cover art of their own.
/// </summary>
Expand Down
18 changes: 13 additions & 5 deletions Jiten.Api/Jobs/WebNovelImportJob.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ public class WebNovelImportJob(
/// </summary>
[Queue(WebNovelQueues.Syosetu)]
[AutomaticRetry(Attempts = 1)]
public async Task Import(WebNovelProvider provider, string sourceId, string? coverPath, int? chunkCharBudget)
public async Task Import(WebNovelProvider provider, string sourceId, string? coverPath, int? chunkCharBudget,
WebNovelTitles? titles = null)
{
await using var context = await contextFactory.CreateDbContextAsync();

Expand All @@ -37,12 +38,14 @@ public async Task Import(WebNovelProvider provider, string sourceId, string? cov

var info = await source.GetInfoAsync(sourceId);

var originalTitle = Normalise(titles?.OriginalTitle) ?? info.Title;

// InsertDeck dedupes new decks on (OriginalTitle, MediaType) and silently keeps the existing one, which
// would leave us with a ledger pointing at someone else's deck. Catch it before the long fetch.
if (await context.Decks.AnyAsync(d => d.OriginalTitle == info.Title && d.MediaType == MediaType.WebNovel))
if (await context.Decks.AnyAsync(d => d.OriginalTitle == originalTitle && d.MediaType == MediaType.WebNovel))
{
throw new InvalidOperationException(
$"A webnovel deck titled '{info.Title}' already exists. Rename or remove it before importing {sourceId}.");
$"A webnovel deck titled '{originalTitle}' already exists. Rename or remove it before importing {sourceId}.");
}

var toc = await source.GetTocAsync(sourceId);
Expand All @@ -51,7 +54,7 @@ public async Task Import(WebNovelProvider provider, string sourceId, string? cov
throw new InvalidOperationException($"{provider}/{sourceId} has no episodes.");

logger.LogInformation("WebNovelImport: {Title} ({SourceId}) — {Episodes} episodes, ~{Chars} chars",
info.Title, sourceId, toc.Count, info.TotalCharacters);
originalTitle, sourceId, toc.Count, info.TotalCharacters);

var budget = chunkCharBudget ?? SubdeckChunker.DefaultCharBudget;

Expand All @@ -74,6 +77,9 @@ public async Task Import(WebNovelProvider provider, string sourceId, string? cov
var textByEpisode = episodes.ToDictionary(e => e.Chunk.Number, e => e.Text);

var metadata = info.ToMetadata();
metadata.OriginalTitle = originalTitle;
metadata.RomajiTitle = Normalise(titles?.RomajiTitle);
metadata.EnglishTitle = Normalise(titles?.EnglishTitle);

foreach (var plan in plans)
{
Expand All @@ -98,7 +104,7 @@ public async Task Import(WebNovelProvider provider, string sourceId, string? cov
DeleteCoverDirectory(coverPath);

logger.LogInformation("WebNovelImport: created deck {DeckId} with {Subdecks} subdecks for {Title}",
parentDeckId, plans.Count, info.Title);
parentDeckId, plans.Count, originalTitle);
}
finally
{
Expand Down Expand Up @@ -130,6 +136,8 @@ private void DeleteCoverDirectory(string? coverPath)
}
}

private static string? Normalise(string? title) => string.IsNullOrWhiteSpace(title) ? null : title.Trim();

private static string JoinEpisodes(ChunkPlan plan, Dictionary<int, string> textByEpisode) =>
string.Join("\n\n", plan.EpisodesToAppend.Select(e => textByEpisode[e.Number]));

Expand Down
11 changes: 11 additions & 0 deletions Jiten.Core/WebNovel/WebNovelModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ public class WebNovelInfo
public bool IsR15 { get; set; }
}

/// <summary>
/// Titles chosen by the admin at add time. A source only reports the Japanese title, so romaji and English
/// are typed in (or auto-romanised) on the add page and carried into the import.
/// </summary>
public class WebNovelTitles
{
public string? OriginalTitle { get; set; }
public string? RomajiTitle { get; set; }
public string? EnglishTitle { get; set; }
}

/// <summary>
/// One entry in the table of contents.
/// </summary>
Expand Down
100 changes: 93 additions & 7 deletions Jiten.Web/app/pages/dashboard/add-webnovel.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { ref, computed, watch, onBeforeUnmount } from 'vue';
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
Expand Down Expand Up @@ -50,6 +50,49 @@
const coverImageUrl = ref<string | null>(null);
const chunkCharBudget = ref<number | null>(null);

const originalTitle = ref('');
const romajiTitle = ref('');
const englishTitle = ref('');
const romanizing = ref(false);
const titleConflict = ref(false);

// The importer dedupes on (OriginalTitle, WebNovel), so an edited title needs its own conflict check
let conflictTimeout: ReturnType<typeof setTimeout> | null = null;
watch(originalTitle, (newTitle) => {
if (conflictTimeout) clearTimeout(conflictTimeout);
if (!newTitle.trim()) {
titleConflict.value = false;
return;
}
conflictTimeout = setTimeout(async () => {
try {
const data = await $api<{ conflict: boolean }>('admin/webnovel-title-conflict', { query: { title: newTitle.trim() } });
titleConflict.value = data.conflict;
} catch {
titleConflict.value = false;
}
}, 500);
});

onBeforeUnmount(() => {
if (conflictTimeout) clearTimeout(conflictTimeout);
});

const autoRomanize = async () => {
if (!originalTitle.value.trim()) return;

romanizing.value = true;
try {
const data = await $api<{ romaji: string }>('utils/romanize', { method: 'POST', body: { title: originalTitle.value.trim() } });
romajiTitle.value = data.romaji;
} catch (error) {
console.error(error);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to auto-romanize title', life: 5000 });
} finally {
romanizing.value = false;
}
};

// A long novel is fetched one episode at a time at ~0.7s each, plus a pause every 10
const estimatedMinutes = computed(() => {
if (!preview.value) return 0;
Expand All @@ -63,6 +106,10 @@
preview.value = null;
try {
preview.value = await $api<WebNovelPreview>('admin/webnovel-preview', { query: { url: url.value.trim() } });
originalTitle.value = preview.value.title;
romajiTitle.value = '';
englishTitle.value = '';
titleConflict.value = preview.value.titleConflict;
} catch (error) {
console.error(error);
toast.add({ severity: 'error', summary: 'Error', detail: extractApiError(error, 'Could not read this novel.'), life: 5000 });
Expand All @@ -72,10 +119,13 @@
};

const submit = async () => {
if (!preview.value) return;
if (!preview.value || !originalTitle.value.trim()) return;

const formData = new FormData();
formData.append('url', preview.value.url);
formData.append('originalTitle', originalTitle.value.trim());
formData.append('romajiTitle', romajiTitle.value.trim());
formData.append('englishTitle', englishTitle.value.trim());
if (coverImage.value) formData.append('coverImage', coverImage.value);
if (chunkCharBudget.value) formData.append('chunkCharBudget', String(chunkCharBudget.value));

Expand All @@ -85,7 +135,7 @@
toast.add({
severity: 'success',
summary: 'Import queued',
detail: `'${preview.value.title}' is being fetched — roughly ${estimatedMinutes.value} min.`,
detail: `'${originalTitle.value.trim()}' is being fetched — roughly ${estimatedMinutes.value} min.`,
life: 5000,
});

Expand All @@ -94,6 +144,10 @@
coverImage.value = null;
coverImageUrl.value = null;
chunkCharBudget.value = null;
originalTitle.value = '';
romajiTitle.value = '';
englishTitle.value = '';
titleConflict.value = false;
} catch (error) {
console.error(error);
toast.add({ severity: 'error', summary: 'Error', detail: extractApiError(error, 'Failed to queue the import.'), life: 5000 });
Expand Down Expand Up @@ -181,8 +235,35 @@
</div>
</div>

<Message v-if="preview.titleConflict" severity="error" :closable="false" class="mt-4">
A webnovel deck titled “{{ preview.title }}” already exists. Rename or remove it first — importing would otherwise be skipped.
<div class="mt-4 rounded-lg border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 p-4">
<h3 class="text-lg font-medium mb-3">Titles</h3>
<div class="mb-4">
<label for="originalTitle" class="block text-sm font-medium mb-1">Original Title</label>
<InputText id="originalTitle" v-model="originalTitle" class="w-full" />
</div>
<div class="mb-4">
<label for="romajiTitle" class="block text-sm font-medium mb-1">Romaji Title</label>
<div class="flex gap-2">
<InputText id="romajiTitle" v-model="romajiTitle" class="flex-1" />
<Button
v-tooltip.top="'Auto-romanize from original title'"
:disabled="!originalTitle.trim() || romanizing"
@click="autoRomanize"
>
<Icon v-if="!romanizing" name="material-symbols-light:translate" size="1.5em" />
<Icon v-else name="line-md:loading-loop" size="1.5em" />
</Button>
</div>
</div>
<div>
<label for="englishTitle" class="block text-sm font-medium mb-1">English Title</label>
<InputText id="englishTitle" v-model="englishTitle" class="w-full" />
</div>
</div>

<Message v-if="titleConflict" severity="error" :closable="false" class="mt-4">
A webnovel deck titled “{{ originalTitle.trim() }}” already exists. Rename it or remove the other deck — importing would
otherwise be skipped.
</Message>

<Message severity="info" :closable="false" class="mt-4">
Expand All @@ -192,7 +273,12 @@
</div>

<div>
<CoverImageField v-model:file="coverImage" v-model:url="coverImageUrl" :title="preview.title" :subtitle="preview.author ?? ''" />
<CoverImageField
v-model:file="coverImage"
v-model:url="coverImageUrl"
:title="originalTitle"
:subtitle="romajiTitle || preview.author || ''"
/>
<p class="mt-2 text-xs text-surface-500 dark:text-surface-400">
Syosetu works have no cover art — upload one or generate it from the title.
</p>
Expand All @@ -218,7 +304,7 @@
class="w-full mt-6"
severity="success"
:loading="submitting"
:disabled="preview.titleConflict"
:disabled="titleConflict || !originalTitle.trim()"
@click="submit"
/>
</div>
Expand Down