shynkro_watchlog-obsidian-p.../src/CsvModal.ts
2026-04-26 18:19:55 +01:00

899 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { App, Modal, Notice } from 'obsidian';
import type WatchLogPlugin from './main';
import type { DataManager } from './DataManager';
import type { WatchLogTitle } from './types';
// ── CSV helpers ───────────────────────────────────────────────────────────────
const CSV_COLUMNS = [
'title', 'type', 'status', 'priority', 'rating',
'totalEpisodes', 'episodeDuration',
'dateStarted', 'dateFinished', 'releaseDate', 'dateAdded',
'externalLink', 'notes',
] as const;
function escapeCsvField(value: string | number | null | undefined): string {
const s = String(value ?? '');
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}
function titlesToCsv(titles: WatchLogTitle[]): string {
const header = CSV_COLUMNS.join(',');
const rows = titles.map((t) =>
CSV_COLUMNS.map((col) => escapeCsvField((t as unknown as Record<string, string | number | null | undefined>)[col])).join(',')
);
return [header, ...rows].join('\n');
}
function parseCsvRow(row: string): string[] {
const fields: string[] = [];
let cur = '';
let inQuotes = false;
for (let i = 0; i < row.length; i++) {
const ch = row[i];
if (ch === '"') {
if (inQuotes && row[i + 1] === '"') {
cur += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (ch === ',' && !inQuotes) {
fields.push(cur);
cur = '';
} else {
cur += ch;
}
}
fields.push(cur);
return fields;
}
// ── Date parsing ─────────────────────────────────────────────────────────────
const MONTH_NAMES: Record<string, number> = {
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
};
/** Parses a date string in any common format and returns YYYY-MM-DD, or null if unparseable. */
function parseCsvDate(raw: string): string | null {
const s = raw.trim();
if (!s) return null;
try {
// YYYY-MM-DD (already in storage format)
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
// D/M/YYYY, DD/MM/YYYY, M/D/YYYY, MM/DD/YYYY
const slash = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (slash) {
const a = parseInt(slash[1]!), b = parseInt(slash[2]!), y = parseInt(slash[3]!);
let dd: number, mm: number;
if (a > 12) { dd = a; mm = b; } // Must be DD/MM
else if (b > 12) { mm = a; dd = b; } // Must be MM/DD
else { dd = a; mm = b; } // Ambiguous → assume DD/MM
if (mm < 1 || mm > 12 || dd < 1 || dd > 31) return null;
return `${y}-${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`;
}
// "27 Jun 2025" (DD Mon YYYY)
const dmy = s.match(/^(\d{1,2})\s+([A-Za-z]{3,})\s+(\d{4})$/);
if (dmy) {
const mm = MONTH_NAMES[dmy[2]!.toLowerCase().slice(0, 3)];
const dd = parseInt(dmy[1]!), y = parseInt(dmy[3]!);
if (mm) return `${y}-${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`;
}
// "Jun 27, 2025" or "Jun 27 2025" (Mon DD YYYY)
const mdy = s.match(/^([A-Za-z]{3,})\s+(\d{1,2}),?\s+(\d{4})$/);
if (mdy) {
const mm = MONTH_NAMES[mdy[1]!.toLowerCase().slice(0, 3)];
const dd = parseInt(mdy[2]!), y = parseInt(mdy[3]!);
if (mm) return `${y}-${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`;
}
// DD.MM.YYYY or DD-MM-YYYY (dot/dash with year at end)
const dotDash = s.match(/^(\d{1,2})[.-](\d{1,2})[.-](\d{4})$/);
if (dotDash) {
const dd = parseInt(dotDash[1]!), mm = parseInt(dotDash[2]!), y = parseInt(dotDash[3]!);
if (mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31)
return `${y}-${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`;
}
// Fallback: native Date.parse
const parsed = new Date(s);
if (!isNaN(parsed.getTime())) {
const dd = parsed.getDate(), mm = parsed.getMonth() + 1, y = parsed.getFullYear();
return `${y}-${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`;
}
return null;
} catch {
return null;
}
}
// ── Field mapping definitions ────────────────────────────────────────────────
interface WlFieldDef {
key: string;
label: string;
autoDetect: string[];
}
const WL_IMPORT_FIELDS: WlFieldDef[] = [
{ key: 'title', label: 'Name', autoDetect: ['title', 'name'] },
{ key: 'type', label: 'Type', autoDetect: ['type'] },
{ key: 'status', label: 'Status', autoDetect: ['status'] },
{ key: 'priority', label: 'Priority', autoDetect: ['priority'] },
{ key: 'rating', label: 'Rating', autoDetect: ['rating', 'score'] },
{ key: 'totalEpisodes', label: 'Episodes', autoDetect: ['episodes', 'totalepisodes', 'total episodes', 'episode count', 'ep count'] },
{ key: 'episodeDuration',label: 'Duration (min)',autoDetect: ['duration', 'episodeduration', 'episode duration', 'minutes', 'runtime'] },
{ key: 'dateStarted', label: 'Date Started', autoDetect: ['started', 'datestarted', 'date started', 'date_started', 'start date'] },
{ key: 'dateFinished', label: 'Date Finished', autoDetect: ['finished', 'datefinished', 'date finished', 'date_finished', 'end date', 'finish date', 'completed date'] },
{ key: 'releaseDate', label: 'Release Date', autoDetect: ['releasedate', 'release date', 'release_date', 'air date', 'airdate'] },
{ key: 'externalLink', label: 'Link', autoDetect: ['link', 'externallink', 'external link', 'external_link', 'url'] },
];
function autoMap(headers: string[]): Record<string, string> {
const mapping: Record<string, string> = {};
for (const field of WL_IMPORT_FIELDS) {
const match = headers.find((h) => field.autoDetect.includes(h.toLowerCase()));
mapping[field.key] = match ?? '';
}
return mapping;
}
function applyMapping(
lines: string[],
mapping: Record<string, string>,
dataManager: DataManager,
): { entry: Partial<WatchLogTitle>; isDuplicate: boolean }[] {
if (lines.length < 2) return [];
const headerLine = lines[0] ?? '';
const headers = parseCsvRow(headerLine).map((h) => h.trim());
const existingTitles = dataManager.getTitles();
return lines.slice(1)
.filter((l) => l.trim())
.map((line) => {
const values = parseCsvRow(line);
const get = (fieldKey: string): string => {
const colName = mapping[fieldKey];
if (!colName) return '';
const idx = headers.indexOf(colName);
return idx >= 0 ? (values[idx] ?? '').trim() : '';
};
const entry: Partial<WatchLogTitle> = {};
const titleVal = get('title');
if (titleVal) entry.title = titleVal;
const typeVal = get('type');
if (typeVal) entry.type = typeVal;
const statusVal = get('status');
if (statusVal) entry.status = statusVal;
const priorityVal = get('priority');
if (priorityVal) entry.priority = priorityVal;
const ratingVal = get('rating');
if (ratingVal) entry.rating = parseFloat(ratingVal) || 0;
const epsVal = get('totalEpisodes');
if (epsVal) entry.totalEpisodes = parseInt(epsVal) || 0;
const durVal = get('episodeDuration');
if (durVal) entry.episodeDuration = parseInt(durVal) || 0;
const startedRaw = get('dateStarted');
if (startedRaw) { const d = parseCsvDate(startedRaw); if (d) entry.dateStarted = d; }
const finishedRaw = get('dateFinished');
if (finishedRaw) { const d = parseCsvDate(finishedRaw); if (d) entry.dateFinished = d; }
const releaseDateRaw = get('releaseDate');
if (releaseDateRaw) { const d = parseCsvDate(releaseDateRaw); if (d) entry.releaseDate = d; }
const linkVal = get('externalLink');
if (linkVal) entry.externalLink = linkVal;
// Skip rows where all mapped fields resolved to empty
const isEmpty = !entry.title?.trim() && !entry.type && !entry.status
&& !entry.rating && !entry.totalEpisodes && !entry.episodeDuration
&& !entry.dateStarted && !entry.dateFinished && !entry.releaseDate && !entry.externalLink;
if (isEmpty) return null;
const isDuplicate = !!existingTitles.find(
(t) => t.title.toLowerCase() === (entry.title ?? '').toLowerCase()
);
return { entry, isDuplicate };
})
.filter((r): r is { entry: Partial<WatchLogTitle>; isDuplicate: boolean } => r !== null);
}
// Sentinel for "create new type" in value mapping
const CREATE_NEW_TYPE = '__create__';
// Theme-aware random colors for auto-created types
const TYPE_AUTO_COLORS: Record<string, string[]> = {
default: ['#6366F1', '#8B5CF6', '#EC4899', '#14B8A6', '#F59E0B', '#10B981', '#3B82F6', '#EF4444'],
nightfall: ['#7B2CBF', '#9D4EDD', '#C77DFF', '#5A189A', '#E040FB', '#B388FF', '#CE93D8', '#9C27B0'],
bluez: ['#2C7DA0', '#468FAF', '#61A5C2', '#1B6A8A', '#0077B6', '#00B4D8', '#48CAE4', '#90E0EF'],
};
function randomAutoColor(theme: string): string {
const palette = TYPE_AUTO_COLORS[theme] ?? TYPE_AUTO_COLORS['default']!;
return palette[Math.floor(Math.random() * palette.length)] ?? '#888780';
}
// ── Modal ─────────────────────────────────────────────────────────────────────
export class CsvModal extends Modal {
private plugin: WatchLogPlugin;
private dataManager: DataManager;
private mode: 'export' | 'import';
// Export state
private selectedIds = new Set<string>();
// Import state — shared across steps
private csvLines: string[] = [];
private csvHeaders: string[] = [];
private columnMapping: Record<string, string> = {};
private importRows: { entry: Partial<WatchLogTitle>; isDuplicate: boolean; selected: boolean }[] = [];
// Value mapping state
private statusValueMap: Record<string, string> = {}; // csvValue → resolved status ('' = leave blank)
private typeValueMap: Record<string, string> = {}; // csvValue → resolved type ('' = blank, CREATE_NEW_TYPE = create)
private ratingValueMap: Record<string, string> = {}; // csvValue → '1''5' or '' (leave blank)
// Step containers
private stepUpload: HTMLElement | null = null;
private stepMapping: HTMLElement | null = null;
private stepValueMap: HTMLElement | null = null;
private stepPreview: HTMLElement | null = null;
private importBtn: HTMLButtonElement | null = null;
private cancelBtn: HTMLButtonElement | null = null;
// Import progress state
private isImporting = false;
private importCancelled = false;
private progressWrap: HTMLElement | null = null;
private progressBarFill: HTMLElement | null = null;
private progressText: HTMLElement | null = null;
constructor(
app: App,
plugin: WatchLogPlugin,
dataManager: DataManager,
mode: 'export' | 'import',
) {
super(app);
this.plugin = plugin;
this.dataManager = dataManager;
this.mode = mode;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('wl-csv-modal');
if (this.mode === 'export') {
this.renderExport(contentEl);
} else {
this.renderImport(contentEl);
}
}
onClose(): void {
this.contentEl.empty();
}
// ── Export ────────────────────────────────────────────────────────────────────
private renderExport(el: HTMLElement): void {
el.createEl('h2', { cls: 'wl-modal-title', text: 'Export to CSV' });
const titles = this.dataManager.getTitles();
titles.forEach((t) => this.selectedIds.add(t.id));
const ctrlRow = el.createDiv({ cls: 'wl-csv-ctrl-row' });
const selectAllBtn = ctrlRow.createEl('button', { cls: 'wl-btn', text: 'Select all' });
const selectNoneBtn = ctrlRow.createEl('button', { cls: 'wl-btn', text: 'Select none' });
const countEl = ctrlRow.createSpan({ cls: 'wl-csv-count', text: `${titles.length} selected` });
const listEl = el.createDiv({ cls: 'wl-csv-list' });
const checkboxes: HTMLInputElement[] = [];
const updateCount = (): void => {
countEl.textContent = `${this.selectedIds.size} selected`;
};
for (const title of titles) {
const row = listEl.createDiv({ cls: 'wl-csv-row' });
const cb = row.createEl('input', { attr: { type: 'checkbox' } });
cb.checked = true;
checkboxes.push(cb);
row.createSpan({ cls: 'wl-csv-row-title', text: title.title });
row.createSpan({ cls: 'wl-csv-row-meta', text: `${title.type} · ${title.status}` });
cb.addEventListener('change', () => {
if (cb.checked) this.selectedIds.add(title.id);
else this.selectedIds.delete(title.id);
updateCount();
});
}
selectAllBtn.addEventListener('click', () => {
titles.forEach((t) => this.selectedIds.add(t.id));
checkboxes.forEach((cb) => { cb.checked = true; });
updateCount();
});
selectNoneBtn.addEventListener('click', () => {
this.selectedIds.clear();
checkboxes.forEach((cb) => { cb.checked = false; });
updateCount();
});
const btnRow = el.createDiv({ cls: 'wl-modal-btn-row wl-csv-btn-row' });
const cancelBtn = btnRow.createEl('button', { cls: 'wl-btn', text: 'Cancel' });
const exportBtn = btnRow.createEl('button', { cls: 'wl-btn wl-btn-primary', text: 'Export CSV' });
cancelBtn.addEventListener('click', () => this.close());
exportBtn.addEventListener('click', () => {
const toExport = titles.filter((t) => this.selectedIds.has(t.id));
if (toExport.length === 0) {
new Notice('No titles selected.');
return;
}
const csv = titlesToCsv(toExport);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const today = new Date();
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
a.href = url;
a.download = `watchlog-export-${dateStr}.csv`;
a.click();
URL.revokeObjectURL(url);
new Notice(`Exported ${toExport.length} titles.`);
this.close();
});
}
// ── Import ────────────────────────────────────────────────────────────────────
private renderImport(el: HTMLElement): void {
el.createEl('h2', { cls: 'wl-modal-title', text: 'Import from CSV' });
this.stepUpload = el.createDiv({ cls: 'wl-csv-step' });
const dropZone = this.stepUpload.createDiv({ cls: 'wl-csv-drop-zone' });
dropZone.createDiv({ cls: 'wl-csv-drop-label', text: 'Select a CSV file to import' });
const fileInput = dropZone.createEl('input', {
attr: { type: 'file', accept: '.csv' },
cls: 'wl-csv-file-input',
});
this.stepMapping = el.createDiv({ cls: 'wl-csv-step' });
this.stepMapping.hide();
this.stepValueMap = el.createDiv({ cls: 'wl-csv-step' });
this.stepValueMap.hide();
this.stepPreview = el.createDiv({ cls: 'wl-csv-step' });
this.stepPreview.hide();
const btnRow = el.createDiv({ cls: 'wl-modal-btn-row wl-csv-btn-row' });
// Background-import note (hidden until import starts)
this.progressWrap = btnRow.createDiv({ cls: 'wl-csv-progress-wrap' });
this.progressWrap.hide();
this.progressWrap.createSpan({ cls: 'wl-csv-bg-note', text: 'You can close this window — import will continue in the background.' });
const progressTrack = this.progressWrap.createDiv({ cls: 'wl-csv-progress-track' });
this.progressBarFill = progressTrack.createDiv({ cls: 'wl-csv-progress-fill' });
this.progressText = this.progressWrap.createSpan({ cls: 'wl-csv-progress-text', text: '0 / 0' });
this.cancelBtn = btnRow.createEl('button', { cls: 'wl-btn', text: 'Cancel' });
this.importBtn = btnRow.createEl('button', {
cls: 'wl-btn wl-btn-primary',
text: 'Import selected',
});
this.importBtn.disabled = true;
this.importBtn.hide();
this.cancelBtn.addEventListener('click', () => {
if (this.isImporting) {
this.importCancelled = true;
if (this.cancelBtn) {
this.cancelBtn.disabled = true;
this.cancelBtn.textContent = 'Cancelling…';
}
} else {
this.close();
}
});
this.importBtn.addEventListener('click', () => void this.doImport());
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const text = ev.target?.result as string;
this.csvLines = text.split('\n');
const headerLine = this.csvLines[0] ?? '';
this.csvHeaders = parseCsvRow(headerLine).map((h) => h.trim());
this.columnMapping = autoMap(this.csvHeaders);
this.showMappingStep();
};
reader.readAsText(file);
});
}
// ── Step: Column Mapping ──────────────────────────────────────────────────────
private showMappingStep(): void {
if (!this.stepUpload || !this.stepMapping) return;
this.stepUpload.hide();
this.stepMapping.show();
this.renderMappingStep(this.stepMapping);
}
private renderMappingStep(el: HTMLElement): void {
el.empty();
el.createDiv({ cls: 'wl-csv-step-title', text: 'Map your columns' });
el.createDiv({
cls: 'wl-csv-step-desc',
text: 'Match each WatchLog field to a column from your CSV. Select "Skip" to leave a field empty.',
});
el.createDiv({
cls: 'wl-csv-info-note',
text: 'Rows with empty fields will be skipped automatically. If you see missing titles, check that your CSV has no empty rows at the top.',
});
const SKIP_VALUE = '';
const grid = el.createDiv({ cls: 'wl-csv-map-grid' });
const selectEls: Record<string, HTMLSelectElement> = {};
for (const field of WL_IMPORT_FIELDS) {
const row = grid.createDiv({ cls: 'wl-csv-map-row' });
row.createSpan({ cls: 'wl-csv-map-label', text: field.label });
const sel = row.createEl('select', { cls: 'wl-select wl-csv-map-select' });
selectEls[field.key] = sel;
sel.createEl('option', { value: SKIP_VALUE, text: '— skip —' });
for (const col of this.csvHeaders) {
sel.createEl('option', { value: col, text: col });
}
sel.value = this.columnMapping[field.key] ?? SKIP_VALUE;
}
const previewBtn = el.createEl('button', {
cls: 'wl-btn wl-btn-primary wl-csv-preview-btn',
text: 'Next →',
});
previewBtn.addEventListener('click', () => {
for (const field of WL_IMPORT_FIELDS) {
this.columnMapping[field.key] = selectEls[field.key]?.value ?? '';
}
// Parse rows with current column mapping
this.importRows = applyMapping(this.csvLines, this.columnMapping, this.dataManager)
.map((r) => ({ ...r, selected: !r.isDuplicate }));
// Check for unknown status/type values and non-numeric ratings
const existingStatusNames = new Set(this.plugin.settings.statuses.map((s) => s.name));
const existingTypeNames = new Set(this.plugin.settings.types.map((t) => t.name));
const unknownStatuses: string[] = [];
const unknownTypes: string[] = [];
const unknownRatings: string[] = [];
const seenStatuses = new Set<string>();
const seenTypes = new Set<string>();
const seenRatings = new Set<string>();
for (const r of this.importRows) {
const sv = r.entry.status?.trim();
if (sv && !existingStatusNames.has(sv) && !seenStatuses.has(sv)) {
unknownStatuses.push(sv);
seenStatuses.add(sv);
}
const tv = r.entry.type?.trim();
if (tv && !existingTypeNames.has(tv) && !seenTypes.has(tv)) {
unknownTypes.push(tv);
seenTypes.add(tv);
}
// Detect non-numeric rating values (r.entry.rating would be 0 from parseFloat on non-numeric)
const ratingColName = this.columnMapping['rating'];
if (ratingColName) {
const headerLine = this.csvLines[0] ?? '';
const headers = parseCsvRow(headerLine).map((h) => h.trim());
const ratingIdx = headers.indexOf(ratingColName);
if (ratingIdx >= 0) {
const lineIdx = this.importRows.indexOf(r);
const rawLine = this.csvLines[lineIdx + 1] ?? '';
const rawValues = parseCsvRow(rawLine);
const rawRating = (rawValues[ratingIdx] ?? '').trim();
if (rawRating && isNaN(parseFloat(rawRating)) && !seenRatings.has(rawRating)) {
unknownRatings.push(rawRating);
seenRatings.add(rawRating);
}
}
}
}
if (unknownStatuses.length > 0 || unknownTypes.length > 0 || unknownRatings.length > 0) {
this.showValueMapStep(unknownStatuses, unknownTypes, unknownRatings);
} else {
this.showPreviewStep();
}
});
}
// ── Step: Value Mapping ───────────────────────────────────────────────────────
private showValueMapStep(unknownStatuses: string[], unknownTypes: string[], unknownRatings: string[] = []): void {
if (!this.stepMapping || !this.stepValueMap) return;
this.stepMapping.hide();
this.stepValueMap.show();
this.renderValueMapStep(this.stepValueMap, unknownStatuses, unknownTypes, unknownRatings);
}
private renderValueMapStep(el: HTMLElement, unknownStatuses: string[], unknownTypes: string[], unknownRatings: string[] = []): void {
el.empty();
el.createDiv({ cls: 'wl-csv-step-title', text: 'Map unknown values' });
el.createDiv({
cls: 'wl-csv-step-desc',
text: 'Some values in your CSV were not recognized. Map each to an existing value, or choose how to handle it.',
});
const existingStatuses = this.plugin.settings.statuses.map((s) => s.name);
const existingTypes = this.plugin.settings.types.map((t) => t.name);
const statusSelects: Record<string, HTMLSelectElement> = {};
const typeSelects: Record<string, HTMLSelectElement> = {};
const ratingSelects: Record<string, HTMLSelectElement> = {};
if (unknownStatuses.length > 0) {
el.createDiv({ cls: 'wl-csv-valmap-section-title', text: 'Status' });
for (const sv of unknownStatuses) {
const row = el.createDiv({ cls: 'wl-csv-valmap-row' });
row.createSpan({ cls: 'wl-csv-valmap-orig', text: `"${sv}"` });
row.createSpan({ cls: 'wl-csv-valmap-arrow', text: '→' });
const sel = row.createEl('select', { cls: 'wl-select wl-csv-valmap-select' });
statusSelects[sv] = sel;
sel.createEl('option', { value: '', text: '— leave blank —' });
for (const name of existingStatuses) {
sel.createEl('option', { value: name, text: name });
}
// Pre-fill with closest match (case-insensitive)
const lc = sv.toLowerCase();
const match = existingStatuses.find((s) => s.toLowerCase() === lc);
if (match) sel.value = match;
}
}
if (unknownTypes.length > 0) {
el.createDiv({ cls: 'wl-csv-valmap-section-title', text: 'Type' });
for (const tv of unknownTypes) {
const row = el.createDiv({ cls: 'wl-csv-valmap-row' });
row.createSpan({ cls: 'wl-csv-valmap-orig', text: `"${tv}"` });
row.createSpan({ cls: 'wl-csv-valmap-arrow', text: '→' });
const sel = row.createEl('select', { cls: 'wl-select wl-csv-valmap-select' });
typeSelects[tv] = sel;
sel.createEl('option', { value: '', text: '— leave blank —' });
for (const name of existingTypes) {
sel.createEl('option', { value: name, text: name });
}
sel.createEl('option', { value: CREATE_NEW_TYPE, text: '+ create new type' });
// Pre-fill with closest match
const lc = tv.toLowerCase();
const match = existingTypes.find((t) => t.toLowerCase() === lc);
if (match) sel.value = match;
else sel.value = CREATE_NEW_TYPE;
}
}
if (unknownRatings.length > 0) {
el.createDiv({ cls: 'wl-csv-valmap-section-title', text: 'Rating' });
for (const rv of unknownRatings) {
const row = el.createDiv({ cls: 'wl-csv-valmap-row' });
row.createSpan({ cls: 'wl-csv-valmap-orig', text: `"${rv}"` });
row.createSpan({ cls: 'wl-csv-valmap-arrow', text: '→' });
const sel = row.createEl('select', { cls: 'wl-select wl-csv-valmap-select' });
ratingSelects[rv] = sel;
sel.createEl('option', { value: '', text: '— leave blank —' });
for (let i = 1; i <= 5; i++) {
sel.createEl('option', { value: String(i), text: `${i}/5` });
}
}
}
const btnRow = el.createDiv({ cls: 'wl-csv-valmap-btn-row' });
const backBtn = btnRow.createEl('button', { cls: 'wl-btn', text: '← back' });
const confirmBtn = btnRow.createEl('button', {
cls: 'wl-btn wl-btn-primary',
text: 'Preview →',
});
backBtn.addEventListener('click', () => {
if (!this.stepValueMap || !this.stepMapping) return;
this.stepValueMap.hide();
this.stepMapping.show();
});
confirmBtn.addEventListener('click', () => {
// Collect mappings
this.statusValueMap = {};
for (const sv of unknownStatuses) {
this.statusValueMap[sv] = statusSelects[sv]?.value ?? '';
}
this.typeValueMap = {};
for (const tv of unknownTypes) {
this.typeValueMap[tv] = typeSelects[tv]?.value ?? '';
}
this.ratingValueMap = {};
for (const rv of unknownRatings) {
this.ratingValueMap[rv] = ratingSelects[rv]?.value ?? '';
}
// Apply value maps to importRows
this.applyValueMapsToRows();
this.showPreviewStep();
});
}
private applyValueMapsToRows(): void {
for (const r of this.importRows) {
// Status
const sv = r.entry.status?.trim() ?? '';
if (sv && Object.prototype.hasOwnProperty.call(this.statusValueMap, sv)) {
const resolved = this.statusValueMap[sv] ?? '';
r.entry.status = resolved || undefined;
}
// Type — keep CREATE_NEW_TYPE sentinel; doImport will handle it
const tv = r.entry.type?.trim() ?? '';
if (tv && Object.prototype.hasOwnProperty.call(this.typeValueMap, tv)) {
const resolved = this.typeValueMap[tv] ?? '';
if (resolved === CREATE_NEW_TYPE) {
// keep original value, mark for creation
r.entry.type = tv;
} else {
r.entry.type = resolved || undefined;
}
}
// Rating — map non-numeric value to numeric
const rawRatingKey = Object.keys(this.ratingValueMap).find((k) => {
// Find if this row's original CSV rating matched this key
const ratingColName = this.columnMapping['rating'];
if (!ratingColName) return false;
const headerLine = this.csvLines[0] ?? '';
const headers = parseCsvRow(headerLine).map((h) => h.trim());
const ratingIdx = headers.indexOf(ratingColName);
if (ratingIdx < 0) return false;
const rowIdx = this.importRows.indexOf(r);
const rawLine = this.csvLines[rowIdx + 1] ?? '';
const rawValues = parseCsvRow(rawLine);
return (rawValues[ratingIdx] ?? '').trim() === k;
});
if (rawRatingKey !== undefined) {
const resolved = this.ratingValueMap[rawRatingKey] ?? '';
r.entry.rating = resolved ? parseInt(resolved) : 0;
}
// Re-check duplicate status after value resolution
r.isDuplicate = !!this.dataManager.getTitles().find(
(t) => t.title.toLowerCase() === (r.entry.title ?? '').toLowerCase()
);
}
}
// ── Step: Preview ─────────────────────────────────────────────────────────────
private showPreviewStep(): void {
if (!this.stepValueMap || !this.stepMapping || !this.stepPreview || !this.importBtn) return;
this.stepValueMap.hide();
this.stepMapping.hide();
this.stepPreview.show();
this.importBtn.show();
this.importBtn.disabled = this.importRows.filter((r) => r.selected).length === 0;
this.renderPreviewStep(this.stepPreview);
}
private renderPreviewStep(el: HTMLElement): void {
el.empty();
const dupeCount = this.importRows.filter((r) => r.isDuplicate).length;
if (dupeCount > 0) {
el.createDiv({
cls: 'wl-csv-dupe-warning',
text: `${dupeCount} duplicate${dupeCount !== 1 ? 's' : ''} found (highlighted). These are deselected by default.`,
});
}
const ctrlRow = el.createDiv({ cls: 'wl-csv-ctrl-row' });
const backBtn = ctrlRow.createEl('button', { cls: 'wl-btn', text: '← back' });
const selectAllBtn = ctrlRow.createEl('button', { cls: 'wl-btn', text: 'Select all' });
const selectNoneBtn = ctrlRow.createEl('button', { cls: 'wl-btn', text: 'Select none' });
const countEl = ctrlRow.createSpan({ cls: 'wl-csv-count', text: '' });
backBtn.addEventListener('click', () => {
if (!this.stepPreview || !this.stepValueMap || !this.stepMapping || !this.importBtn) return;
this.stepPreview.hide();
this.importBtn.hide();
this.importBtn.disabled = true;
// Go back to value-map step if it was shown, else column mapping
const hadValueMap = Object.keys(this.statusValueMap).length > 0 || Object.keys(this.typeValueMap).length > 0 || Object.keys(this.ratingValueMap).length > 0;
if (hadValueMap) {
this.stepValueMap.show();
} else {
this.stepMapping.show();
}
});
const listEl = el.createDiv({ cls: 'wl-csv-list' });
const checkboxes: HTMLInputElement[] = [];
const updateCount = (): void => {
const sel = this.importRows.filter((r) => r.selected).length;
countEl.textContent = `${sel} of ${this.importRows.length} selected`;
if (this.importBtn) this.importBtn.disabled = sel === 0;
};
for (let i = 0; i < this.importRows.length; i++) {
const r = this.importRows[i]!;
const row = listEl.createDiv({
cls: `wl-csv-row${r.isDuplicate ? ' wl-csv-row-dupe' : ''}`,
});
const cb = row.createEl('input', { attr: { type: 'checkbox' } });
cb.checked = r.selected;
checkboxes.push(cb);
row.createSpan({ cls: 'wl-csv-row-title', text: r.entry.title ?? '(no title)' });
const metaParts = [r.entry.type, r.entry.status].filter(Boolean).join(' · ');
if (metaParts) row.createSpan({ cls: 'wl-csv-row-meta', text: metaParts });
if (r.isDuplicate) {
row.createSpan({ cls: 'wl-csv-dupe-badge', text: 'duplicate' });
}
const idx = i;
cb.addEventListener('change', () => {
this.importRows[idx]!.selected = cb.checked;
updateCount();
});
}
selectAllBtn.addEventListener('click', () => {
this.importRows.forEach((r) => { r.selected = true; });
checkboxes.forEach((cb) => { cb.checked = true; });
updateCount();
});
selectNoneBtn.addEventListener('click', () => {
this.importRows.forEach((r) => { r.selected = false; });
checkboxes.forEach((cb) => { cb.checked = false; });
updateCount();
});
updateCount();
}
// ── Import ────────────────────────────────────────────────────────────────────
private async doImport(): Promise<void> {
const toImport = this.importRows.filter((r) => r.selected && r.entry.title?.trim());
if (toImport.length === 0) {
new Notice('No titles selected to import.');
return;
}
// Set up progress UI
this.isImporting = true;
this.importCancelled = false;
if (this.importBtn) this.importBtn.disabled = true;
if (this.cancelBtn) this.cancelBtn.textContent = 'Cancel import';
if (this.progressWrap) this.progressWrap.show();
if (this.progressText) this.progressText.textContent = `0 / ${toImport.length}`;
if (this.progressBarFill) this.progressBarFill.style.width = `0%`;
// Expose progress to the Watchlist header
const cancelFn = (): void => { this.importCancelled = true; };
this.plugin.importProgress = { current: 0, total: toImport.length, cancel: cancelFn };
// Auto-create types marked CREATE_NEW_TYPE or not in existing types
const theme = this.plugin.settings.colorTheme ?? 'default';
const existingTypeNames = new Set(this.plugin.settings.types.map((t) => t.name.toLowerCase()));
const typesToCreate = new Set<string>();
for (const r of toImport) {
const typeName = r.entry.type?.trim();
if (typeName && !existingTypeNames.has(typeName.toLowerCase())) {
typesToCreate.add(typeName);
}
}
for (const [orig, resolved] of Object.entries(this.typeValueMap)) {
if (resolved === CREATE_NEW_TYPE && !existingTypeNames.has(orig.toLowerCase())) {
typesToCreate.add(orig);
}
}
if (typesToCreate.size > 0) {
for (const typeName of typesToCreate) {
this.plugin.settings.types.push({ name: typeName, color: randomAutoColor(theme) });
}
await this.plugin.saveSettings();
}
const CHUNK_SIZE = 10;
let added = 0;
for (let chunkStart = 0; chunkStart < toImport.length; chunkStart += CHUNK_SIZE) {
if (this.importCancelled) break;
const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, toImport.length);
for (let i = chunkStart; i < chunkEnd; i++) {
if (this.importCancelled) break;
const r = toImport[i]!;
const titleName = r.entry.title!.trim();
const entry: WatchLogTitle = {
id: this.dataManager.generateId(titleName),
title: titleName,
type: r.entry.type?.trim() || this.plugin.settings.types[0]?.name || 'Anime',
status: r.entry.status || 'Plan to watch',
priority: r.entry.priority || 'Medium',
review: '',
rating: r.entry.rating ?? 0,
notes: '',
dateStarted: r.entry.dateStarted ?? null,
dateFinished: r.entry.dateFinished ?? null,
dateAdded: new Date().toISOString(),
dateModified: new Date().toISOString(),
totalEpisodes: r.entry.totalEpisodes ?? 0,
episodeDuration: r.entry.episodeDuration ?? 0,
releaseDate: r.entry.releaseDate ?? null,
externalLink: r.entry.externalLink ?? '',
seasons: [],
watchedEpisodes: [],
};
if (entry.status === 'Completed' && entry.totalEpisodes > 0) {
entry.watchedEpisodes = Array.from({ length: entry.totalEpisodes }, (_, k) => k + 1);
}
this.plugin.importProgress = { current: added + 1, total: toImport.length, cancel: cancelFn };
await this.dataManager.addTitleSilent(entry);
added++;
const pct = Math.round((added / toImport.length) * 100);
if (this.progressBarFill) this.progressBarFill.style.width = `${pct}%`;
if (this.progressText) this.progressText.textContent = `${added} / ${toImport.length}`;
}
}
this.isImporting = false;
// Clear header progress bar and trigger a final UI refresh
this.plugin.importProgress = null;
this.dataManager.notifyChange();
const newTypesMsg = typesToCreate.size > 0
? ` (${typesToCreate.size} new type${typesToCreate.size !== 1 ? 's' : ''} created)`
: '';
if (this.importCancelled) {
// Import was cancelled — stay open so user can see what was imported
if (this.progressText) {
this.progressText.textContent = `Cancelled — ${added} / ${toImport.length} imported`;
}
if (this.cancelBtn) {
this.cancelBtn.disabled = false;
this.cancelBtn.textContent = 'Close';
}
new Notice(`Import cancelled. ${added} title${added !== 1 ? 's' : ''} imported.`);
} else {
// All done — show success state, then close
if (this.progressText) {
this.progressText.textContent = `Done — ${added} imported`;
}
if (this.cancelBtn) {
this.cancelBtn.disabled = false;
this.cancelBtn.textContent = 'Close';
}
new Notice(`Imported ${added} title${added !== 1 ? 's' : ''}${newTypesMsg}.`);
this.close();
}
}
}