feat: v2.0.0 - Readin Tab, Log Tab, Cards system and more

This commit is contained in:
Bogdan Stefan 2026-06-09 15:39:26 +01:00
parent e69605686b
commit c8785d3731
46 changed files with 24444 additions and 10534 deletions

View file

@ -1,28 +1,28 @@
name: Node.js build
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x, 22.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
- run: npm run build --if-present
- run: npm run lint
name: Node.js build
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x, 22.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
- run: npm run build --if-present
- run: npm run lint

3
.gitignore vendored
View file

@ -13,6 +13,8 @@ node_modules
# obsidian
data.json
history.json
reading.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
@ -24,6 +26,7 @@ data.json
.claude/
CLAUDE.md
AGENTS.md
DEVLOG.md
# Runtime data
history.json

View file

@ -2,6 +2,100 @@
All notable changes to WatchLog are documented here.
## [2.0.0] - 2026-06-07
##### Added
- Added **Cards sub-tab** in Watchlist with a responsive poster card grid
- Poster images auto-fetched from Jikan, TMDB, Google Books or OMDb and cached locally
- Added: **AniList API** as alternative anime source; toggle between Jikan and AniList in Settings
- Added: **Community Ratings**; IMDb, MAL, AniList, or TMDB scores displayed alongside personal star rating with manual refresh and 30-day auto-refresh
- Added: **API routing by type**; configurable API mapping per custom type in Settings; locked defaults for Anime, Movie, TV Show
- Added: manual **Poster URL** override field in Edit modal
- Added: **"Groups only"** filter in Watchlist; show only group headers, hide standalone titles
- Added: **"All" toggle** per filter section; quick select/deselect all within Type, Status, Rating, Priority, Group
- Added: status dropdown directly on expanded title row (no need to open Edit modal)
- Added: "Today" button on Date Watched field (auto-fills only when empty)
- Added: **Skip Episodes**; mark episodes as skippable (filler, recaps, etc.) per season
- Define skip episodes in season syntax: `"Season 1: 48 (33-37,42)"`
- Skipped episodes shown with purple border (`#6b2972`) and dash (—) marker
- 3-state click cycle for skip-defined episodes: skipped → watched → empty
- Skip stats displayed in Edit modal: "X to skip · Y to watch" next to Total Episodes and below Seasons
- Season headers show skip count (e.g. "East Blue Saga 48 eps (5 to skip)")
- Progress calculations exclude skipped episodes from totals
- Added: status badge moved to the top of the Watchlist detail modal, inline next to the title type, with a colored click-to-change dropdown (matching Reading)
- Added: Upcoming due count in the status bar; shows "N due" with the plugin icon, hidden when zero, click opens the Upcoming tab
- Added: Settings toggle "Show Upcoming count in status bar" (General, on by default)
- Added: Reading section in Quick Info with help entries; updated API & Search entry to reflect current APIs (AniList, Google Books)
- Added new **"Watchlist" settings tab** (renamed from Folders)
- Added **Reading integration into Dashboard**; new Books and Manga cards at the end of the grid
• "left" counts Reading + Plan to read; To be released excluded from the calculation
• Progress bar shows pages/chapters read of total, with an inline pages / chapters · vol line below
• Reflected in both Rectangles and Circles dashboard styles
- Added **unified Total/Time card**; Total, Time watched, and Time remaining merged into one card with three equal segments
- Added **Reading in Upcoming**; titles already in Reading can be added via the "+ add" finder (now searches Reading), shown with Book/Manga badges
- Added **Reading schedule modal**; derived from the Anime scheduler; no Time field, Current/Total chapters + volumes, recurrence and auto-increment, 0/1 total treated as a single release date
- Added **Reading in Custom Lists**; name-cell autocomplete now searches Reading alongside Watchlist
- Added **Drafts add gate**; the Add button opens a choice modal (Add in Watchlist / Add book / Add manga), each opening the correct modal with the draft text prefilled
- Added **Release date field** to the Reading modal, inline next to Added; manually editable with optional API import (Google Books / Jikan)
- Added **Reading colors** section in Settings → Customize (below Season colors); color pickers for Manga and Book
- Added: **Show hint banners** toggle in Settings > General; hide/show informational banners in Upcoming, Custom Lists, and Drafts
- Added: **Auto-populate Time column** in Custom Lists; number columns can pull remaining watch time (in minutes) from Watchlist titles with exact name match
- Enable via ⏱ toggle in Edit Columns for any number-type column
- Values are persisted and only re-fetched on demand via ↻ refresh icon in column header
- Titles not found in Watchlist show "Not found"
- Added **Log** as standalone top-level tab (extracted from Watchlist); unified timeline for both Watchlist and Reading events
• Vertical timeline with colored dots and connector lines
• Day-grouped entries with date headers
• Action color coding: green (Completed/Watched), blue (Added), red (Deleted), amber (Status/Rating changed)
• Source filter toolbar: All / Watchlist / Reading
- Added **Reading tab** with two sub-tabs: Books and Manga; fully independent from the existing Watchlist
- Added separate `reading.json` storage with dedicated `ReadingDataManager` for CRUD operations, change listeners, and schema migrations
- Added **Book tracking** with fields: title, author, status, rating, pages read/total, chapters read/total, cover URL, Open Library ID, vault page link, dates, custom fields
- Added **Manga tracking** with fields: title, author, status, rating, chapters read/total, volumes read/total, cover URL, MAL ID, vault page link, dates, custom fields
- Added **Card grid view** for both Books and Manga with responsive layout, cover images, status dots, progress bars, and author display
- Added **Detail modal** with cover, title, author, status badge, rating, Open note button, vault page controls (Change/Open with filename display and full path tooltip)
- Added **Custom fields system** per sub-tab: user-defined columns (text / number / select) with per-column color picker (8 preset colors)
- Two display styles with toggle: **Fill** (key cell background) and **Border** (key cell border with rounded corners)
- Inline editing in Detail modal (text input, number input, or select dropdown)
- Added **Manage Columns modal** with horizontal row layout: drag handle, name input, type dropdown, color dot picker, options input (for select type), delete button — all aligned inline
- Added **Favorite Quotes** section in Detail modal: parsed from `## Quotes` in the auto-generated `.md` file, displayed as styled callout blocks with page/chapter reference; Add quote inline form
- Added **Auto-generated `.md` files** per title at `WatchLog/Reading/Books/<Title>.md` or `WatchLog/Reading/Manga/<Title>.md` with YAML frontmatter, `## Notes`, and `## Quotes` sections; frontmatter syncs on edit
- Added **Jikan manga lookup** in Add Manga modal: search by title or MAL ID, auto-fill title/author/chapters/volumes/cover/MAL ID
- Added default reading folder path setting (default `WatchLog/Reading`)
##### Improved
- Improved: Custom Lists; Edit Columns modal redesigned as horizontal cards
- Improved: # and Name columns shown as grayed-out/non-editable in Edit Columns modal
- Improved: Edit Columns modal wider to fit card layout
- Improved: Cards sub-tab is now the default Watchlist view
- Improved: "History" sub-tabs renamed to "Log" in Watchlist and Upcoming
- Improved: **API settings tab** restructured into three faded callout sections — Movies & TV Shows, Anime, Books — with consistent key/test/status layout
- Improved: **shared danger/success button colors** consolidated into reusable utility classes (red borders for delete/remove, green for add), with softened resting borders
- Improved: Backup/Restore now covers all three data files (watch, reading, history) via a versioned format, with legacy backups still restorable
- Improved: removed the Custom Lists mobile input modal; tapping a cell now uses inline editing directly on both mobile and desktop (the modal was a workaround for the now-fixed keyboard bug)
-
##### Fixed
- Fixed: titles inside groups now appear correctly when filtering by status
- Fixed: priority is automatically cleared when a title is marked as Completed
- Fixed: mobile keyboard layout bug; when the keyboard opened on iOS and Android, the scrollable content area collapsed and a blank/dark region covered most of the view above the keyboard. Content was present but hidden behind it.
- Fixed: Custom Lists — adding a new column no longer resets row checkboxes
- Fixed: custom field fill mode text color now black for readability
##### Performance
- Performance: optimized episode click handling; direct DOM updates with debounced save, eliminated redundant re-renders
- Performance: centralized save pipeline, batch CSV imports, debounced inputs, Map-based lookups, dead code removal, listener leak fixes
- Performance: the keyboard layout fix is debounced via requestAnimationFrame (one write per frame), with redundant-write skipping, no duplicate observers, and full teardown on view close
##### Removed
- Removed the **"Default view"** setting; the panel now always opens on Dashboard
- Removed the standalone **"Folders"** settings tab (its contents moved into the new "Watchlist" tab)
## [1.1.0] - 2026-05-12
### Changed
@ -12,5 +106,6 @@ All notable changes to WatchLog are documented here.
- Simplified `CsvModal.ts` CSV export to use `document.createElement` directly
- File names with forbidden characters are now sanitized in the title property, fixing broken notes for titles containing characters such as `:`
## [1.0.9] - 2026-04-26
### Initial public release

40
LICENSE
View file

@ -1,21 +1,21 @@
MIT License
Copyright (c) 2026 Shynkro(BogdanS)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
MIT License
Copyright (c) 2026 Shynkro(BogdanS)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -1,49 +1,49 @@
import esbuild from "esbuild";
import process from "process";
import { builtinModules } from 'node:module';
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtinModules],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}
import esbuild from "esbuild";
import process from "process";
import { builtinModules } from 'node:module';
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtinModules],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

View file

@ -1,36 +1,43 @@
import tseslint from 'typescript-eslint';
import obsidianmd from "eslint-plugin-obsidianmd";
import globals from "globals";
import { globalIgnores } from "eslint/config";
export default tseslint.config(
{
languageOptions: {
globals: {
...globals.browser,
activeDocument: 'readonly',
activeWindow: 'readonly',
},
parserOptions: {
projectService: {
allowDefaultProject: [
'eslint.config.js',
'manifest.json'
]
},
tsconfigRootDir: import.meta.dirname,
extraFileExtensions: ['.json']
},
},
},
...obsidianmd.configs.recommended,
globalIgnores([
"node_modules",
"dist",
"esbuild.config.mjs",
"eslint.config.js",
"version-bump.mjs",
"versions.json",
"main.js",
]),
);
import tseslint from 'typescript-eslint';
import obsidianmd from "eslint-plugin-obsidianmd";
import globals from "globals";
import { globalIgnores } from "eslint/config";
export default tseslint.config(
{
languageOptions: {
globals: {
...globals.browser,
activeDocument: 'readonly',
activeWindow: 'readonly',
},
parserOptions: {
projectService: {
allowDefaultProject: [
'eslint.config.js',
'manifest.json'
]
},
tsconfigRootDir: import.meta.dirname,
extraFileExtensions: ['.json']
},
},
},
...obsidianmd.configs.recommended,
{
// Cosmetic-only: keep UI sentence-case visible without failing the lint.
plugins: { obsidianmd },
rules: {
'obsidianmd/ui/sentence-case': 'warn',
},
},
globalIgnores([
"node_modules",
"dist",
"esbuild.config.mjs",
"eslint.config.js",
"version-bump.mjs",
"versions.json",
"main.js",
]),
);

145
main.js

File diff suppressed because one or more lines are too long

View file

@ -1,10 +1,10 @@
{
"id": "watchlog",
"name": "WatchLog",
"version": "1.1.0",
"minAppVersion": "1.7.2",
"description": "Track your anime, movies, and TV shows; with episode tracking, progress stats, upcoming release alerts, and embeddable widgets.",
"author": "BogdanS",
"authorUrl": "",
"isDesktopOnly": false
}
{
"id": "watchlog",
"name": "WatchLog",
"version": "2.0.0",
"minAppVersion": "1.7.2",
"description": "Track your anime, movies, books, manga and TV shows; with episode tracking, progress stats, upcoming release alerts, and embeddable widgets.",
"author": "BogdanS",
"authorUrl": "",
"isDesktopOnly": false
}

10348
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,30 +1,30 @@
{
"name": "watchlog-plugin",
"version": "1.1.0",
"description": "Track your anime, movies, and TV shows; with episode tracking, progress stats, upcoming release alerts, and embeddable widgets.",
"main": "main.js",
"type": "module",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"lint": "eslint ."
},
"keywords": [],
"license": "0-BSD",
"devDependencies": {
"@eslint/js": "9.30.1",
"@types/node": "^16.11.6",
"esbuild": "0.25.5",
"eslint-plugin-obsidianmd": "0.1.9",
"globals": "14.0.0",
"jiti": "2.6.1",
"tslib": "2.4.0",
"typescript": "^5.8.3",
"typescript-eslint": "8.35.1"
},
"dependencies": {
"fuse.js": "^7.3.0",
"obsidian": "latest"
}
}
{
"name": "watchlog-plugin",
"version": "2.0.0",
"description": "Track your anime, movies, books, manga and TV shows; with episode tracking, progress stats, upcoming release alerts, and embeddable widgets.",
"main": "main.js",
"type": "module",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"lint": "eslint ."
},
"keywords": [],
"license": "0-BSD",
"devDependencies": {
"@eslint/js": "9.30.1",
"@types/node": "^16.11.6",
"esbuild": "0.25.5",
"eslint-plugin-obsidianmd": "0.1.9",
"globals": "14.0.0",
"jiti": "2.6.1",
"tslib": "2.4.0",
"typescript": "^5.8.3",
"typescript-eslint": "8.35.1"
},
"dependencies": {
"fuse.js": "^7.3.0",
"obsidian": "latest"
}
}

View file

@ -66,31 +66,34 @@ export class AddFromUrlModal extends Modal {
}
if (this.errorEl) this.errorEl.hide();
const result = this.plugin.settings.activeApi === 'TMDB'
? await this.plugin.apiService.getTmdbByImdbId(imdbId)
: await this.plugin.apiService.getOmdbByImdbId(imdbId);
try {
const result = this.plugin.settings.activeApi === 'TMDB'
? await this.plugin.apiService.getTmdbByImdbId(imdbId)
: await this.plugin.apiService.getOmdbByImdbId(imdbId);
if (!result) {
if (!result) {
this.showError('Title not found. Please check the URL and try again.');
return;
}
this.close();
const type = result.mediaType === 'movie' ? 'Movie' : 'TV Show';
new AddTitleModal(this.app, this.plugin, this.dataManager, this.onAdded, {
title: result.title,
type,
episodes: result.episodes,
duration: result.episodeDuration,
releaseDate: result.releaseDate,
link: result.url,
seasons: result.seasons,
}).open();
} finally {
if (this.addBtn) {
this.addBtn.disabled = false;
this.addBtn.textContent = 'Add';
}
this.showError('Title not found. Please check the URL and try again.');
return;
}
this.close();
const type = result.mediaType === 'movie' ? 'Movie' : 'TV Show';
new AddTitleModal(this.app, this.plugin, this.dataManager, this.onAdded, {
title: result.title,
type,
episodes: result.episodes,
duration: result.episodeDuration,
releaseDate: result.releaseDate,
link: result.url,
seasons: result.seasons,
}).open();
}
private showError(msg: string): void {

741
src/AddReadingModal.ts Normal file
View file

@ -0,0 +1,741 @@
import { App, FuzzySuggestModal, Modal, Notice, TFile } from 'obsidian';
import type WatchLogPlugin from './main';
import type { ReadingDataManager } from './ReadingDataManager';
import type { BookSearchResult, MangaSearchResult } from './ApiService';
import { googleBooksErrorMessage } from './ApiService';
import {
Book,
Manga,
ReadingStatus,
SELECTABLE_READING_STATUSES,
formatDateDisplay,
parseDateInput,
} from './types';
export type ReadingMode = 'book' | 'manga';
interface ReadingFormState {
title: string;
author: string;
status: ReadingStatus;
rating: number;
pagesRead: number;
totalPages: number;
chaptersRead: number;
totalChapters: number;
volumesRead: number;
totalVolumes: number;
coverUrl: string;
googleBooksId: string;
malId: string;
vaultPage: string;
dateStarted: string | null;
dateFinished: string | null;
releaseDate: string | null;
}
function emptyFormState(): ReadingFormState {
return {
title: '',
author: '',
status: 'Plan to Read',
rating: 0,
pagesRead: 0,
totalPages: 0,
chaptersRead: 0,
totalChapters: 0,
volumesRead: 0,
totalVolumes: 0,
coverUrl: '',
googleBooksId: '',
malId: '',
vaultPage: '',
dateStarted: null,
dateFinished: null,
releaseDate: null,
};
}
export function bookToFormState(book: Book): ReadingFormState {
return {
title: book.title,
author: book.author,
status: book.status,
rating: book.rating,
pagesRead: book.pagesRead,
totalPages: book.totalPages,
chaptersRead: book.chaptersRead,
totalChapters: book.totalChapters,
volumesRead: 0,
totalVolumes: 0,
coverUrl: book.coverUrl,
googleBooksId: book.googleBooksId,
malId: '',
vaultPage: book.vaultPage,
dateStarted: book.dateStarted,
dateFinished: book.dateFinished,
releaseDate: book.releaseDate,
};
}
export function mangaToFormState(manga: Manga): ReadingFormState {
return {
title: manga.title,
author: manga.author,
status: manga.status,
rating: manga.rating,
pagesRead: 0,
totalPages: 0,
chaptersRead: manga.chaptersRead,
totalChapters: manga.totalChapters,
volumesRead: manga.volumesRead,
totalVolumes: manga.totalVolumes,
coverUrl: manga.coverUrl,
googleBooksId: '',
malId: manga.malId,
vaultPage: manga.vaultPage,
dateStarted: manga.dateStarted,
dateFinished: manga.dateFinished,
releaseDate: manga.releaseDate,
};
}
export class VaultFilePicker extends FuzzySuggestModal<TFile> {
private onPick: (file: TFile) => void;
private files: TFile[];
constructor(app: App, files: TFile[], onPick: (file: TFile) => void) {
super(app);
this.files = files;
this.onPick = onPick;
this.setPlaceholder('Pick a vault note...');
}
getItems(): TFile[] {
return this.files;
}
getItemText(item: TFile): string {
return item.path;
}
onChooseItem(item: TFile): void {
this.onPick(item);
}
}
export class AddReadingModal extends Modal {
private plugin: WatchLogPlugin;
private readingData: ReadingDataManager;
private mode: ReadingMode;
private state: ReadingFormState;
private existingId: string | null;
private onSaved: () => void;
private starsWrap: HTMLElement | null = null;
private lookupResultsEl: HTMLElement | null = null;
private formEl: HTMLElement | null = null;
private openVaultBtn: HTMLButtonElement | null = null;
private linkVaultBtn: HTMLButtonElement | null = null;
private lookupSearchGen = 0;
private selectGen = 0;
private prefillSearch: string;
constructor(
app: App,
plugin: WatchLogPlugin,
readingData: ReadingDataManager,
mode: ReadingMode,
onSaved: () => void,
initial?: { state: ReadingFormState; id: string },
prefillSearch?: string,
) {
super(app);
this.plugin = plugin;
this.readingData = readingData;
this.mode = mode;
this.state = initial ? { ...initial.state } : emptyFormState();
if (!initial) {
const defStatus = readingData.getSettings().defaultStatus;
if (defStatus) this.state.status = defStatus;
}
this.existingId = initial?.id ?? null;
this.onSaved = onSaved;
this.prefillSearch = prefillSearch ?? '';
}
onOpen(): void {
const colorTheme = this.plugin.settings.colorTheme ?? 'default';
this.modalEl.setAttribute('data-theme', colorTheme);
this.contentEl.setAttribute('data-theme', colorTheme);
this.contentEl.addClass('wl-view');
this.contentEl.addClass('wl-reading-modal');
this.contentEl.addClass(this.mode === 'book' ? 'wl-reading-modal-book' : 'wl-reading-modal-manga');
this.titleEl.setText(this.headerTitle());
this.buildUI();
}
onClose(): void {
this.contentEl.empty();
}
private headerTitle(): string {
if (this.existingId) {
return this.mode === 'book' ? 'Edit book' : 'Edit manga';
}
return this.mode === 'book' ? 'Add book' : 'Add manga';
}
private buildUI(): void {
const c = this.contentEl;
c.empty();
this.renderHeaderRow(c);
this.renderLookupBar(c);
c.createDiv({ cls: 'wl-reading-modal-divider' });
this.formEl = c.createDiv({ cls: 'wl-reading-form-wrap' });
this.renderForm(this.formEl);
this.renderFooter(c);
}
private renderHeaderRow(parent: HTMLElement): void {
const row = parent.createDiv({ cls: 'wl-reading-modal-header' });
const left = row.createDiv({ cls: 'wl-reading-modal-header-left' });
left.createSpan({
cls: 'wl-reading-modal-header-icon',
text: this.mode === 'book' ? '📖' : '📓',
});
left.createSpan({
cls: 'wl-reading-modal-header-title',
text: this.headerTitle(),
});
const actions = row.createDiv({ cls: 'wl-reading-modal-header-actions' });
// "Link" button intentionally omitted from the Add modal — vault-page linking
// lives in the detail/edit modal (ReadingDetailModal).
this.openVaultBtn = actions.createEl('button', {
cls: 'wl-btn wl-btn-sm',
text: 'Open',
});
this.openVaultBtn.addEventListener('click', () => this.openLinkedVaultPage());
this.refreshVaultButtons();
}
private refreshVaultButtons(): void {
if (this.linkVaultBtn) {
this.linkVaultBtn.textContent = this.state.vaultPage ? 'Change link' : 'Link';
this.linkVaultBtn.title = this.state.vaultPage
? `Linked: ${this.state.vaultPage}`
: 'Link a vault note to this entry';
}
if (this.openVaultBtn) {
const hasLink = !!this.state.vaultPage;
this.openVaultBtn.disabled = !hasLink;
this.openVaultBtn.toggleClass('is-hidden', !hasLink);
}
}
private openLinkedVaultPage(): void {
const path = this.state.vaultPage;
if (!path) return;
const file = this.plugin.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) {
void this.plugin.app.workspace.getLeaf('tab').openFile(file);
this.close();
} else {
new Notice('Linked vault page no longer exists.');
}
}
private renderLookupBar(parent: HTMLElement): void {
const lookup = parent.createDiv({ cls: 'wl-reading-lookup' });
const input = lookup.createEl('input', {
cls: 'wl-modal-input wl-reading-lookup-input',
attr: {
type: 'text',
placeholder: this.mode === 'book'
? 'Search by title or ISBN...'
: 'Search by title or MAL ID...',
},
});
const fetchBtn = lookup.createEl('button', {
cls: 'wl-btn wl-reading-lookup-btn',
text: 'Fetch',
});
this.lookupResultsEl = parent.createDiv({ cls: 'wl-reading-lookup-results' });
// Prefill the search box (e.g. from a Drafts entry) without auto-fetching,
// mirroring the Watchlist Add modal's prefill behavior.
if (this.prefillSearch) input.value = this.prefillSearch;
const runFetch = (): void => {
const q = input.value.trim();
if (!q) {
new Notice('Enter a search term first.');
return;
}
void this.performLookup(q);
};
fetchBtn.addEventListener('click', runFetch);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
runFetch();
}
});
}
private async performLookup(query: string): Promise<void> {
const gen = ++this.lookupSearchGen;
if (!this.lookupResultsEl) return;
this.lookupResultsEl.empty();
this.lookupResultsEl.createDiv({ cls: 'wl-reading-lookup-loading', text: 'Searching...' });
try {
if (this.mode === 'book') {
if (!this.plugin.apiService.hasGoogleBooksKey()) {
if (gen !== this.lookupSearchGen) return;
this.lookupResultsEl.empty();
this.lookupResultsEl.createDiv({
cls: 'wl-reading-lookup-empty',
text: 'Google Books API key required — add one in Settings → API → Books.',
});
return;
}
const results = await this.plugin.apiService.searchGoogleBooks(query);
if (gen !== this.lookupSearchGen) return;
this.renderBookLookupResults(results);
} else {
const isNumeric = /^\d+$/.test(query);
let results: MangaSearchResult[];
if (isNumeric) {
const single = await this.plugin.apiService.getMangaByMalId(parseInt(query, 10));
if (gen !== this.lookupSearchGen) return;
results = single ? [single] : [];
} else {
results = await this.plugin.apiService.searchManga(query);
if (gen !== this.lookupSearchGen) return;
}
this.renderMangaLookupResults(results);
}
} catch (err) {
if (gen !== this.lookupSearchGen) return;
this.lookupResultsEl.empty();
const msg = this.mode === 'book' ? googleBooksErrorMessage(err) : 'Lookup failed. Check your connection.';
this.lookupResultsEl.createDiv({ cls: 'wl-reading-lookup-empty', text: msg });
}
}
private renderBookLookupResults(results: BookSearchResult[]): void {
if (!this.lookupResultsEl) return;
this.lookupResultsEl.empty();
if (results.length === 0) {
this.lookupResultsEl.createDiv({ cls: 'wl-reading-lookup-empty', text: 'No matches.' });
return;
}
for (const r of results) {
const item = this.lookupResultsEl.createDiv({ cls: 'wl-reading-lookup-item' });
this.renderLookupItemTitle(item, r.title, r.url);
const meta = [r.author || 'Unknown author', r.year ? String(r.year) : ''].filter(Boolean).join(' · ');
item.createDiv({ cls: 'wl-reading-lookup-item-meta', text: meta });
item.addEventListener('click', () => this.applyBookResult(r));
}
}
/** Title row with a globe web-link to the right (same pattern as the Watchlist). */
private renderLookupItemTitle(item: HTMLElement, title: string, url: string): void {
const titleRow = item.createDiv({ cls: 'wl-reading-lookup-item-title-row' });
titleRow.createDiv({ cls: 'wl-reading-lookup-item-title', text: title || '(untitled)' });
if (url) {
const linkIcon = titleRow.createEl('a', { cls: 'wl-acc-link-icon', text: '🌐' });
linkIcon.href = url;
linkIcon.title = 'Open external link';
linkIcon.target = '_blank';
linkIcon.rel = 'noopener noreferrer';
linkIcon.addEventListener('click', (e) => e.stopPropagation());
}
}
private renderMangaLookupResults(results: MangaSearchResult[]): void {
if (!this.lookupResultsEl) return;
this.lookupResultsEl.empty();
if (results.length === 0) {
this.lookupResultsEl.createDiv({ cls: 'wl-reading-lookup-empty', text: 'No matches.' });
return;
}
for (const r of results) {
const item = this.lookupResultsEl.createDiv({ cls: 'wl-reading-lookup-item' });
this.renderLookupItemTitle(item, r.title, r.url);
const meta = [r.author || 'Unknown author', r.year ? String(r.year) : ''].filter(Boolean).join(' · ');
item.createDiv({ cls: 'wl-reading-lookup-item-meta', text: meta });
item.addEventListener('click', () => this.applyMangaResult(r));
}
}
private applyBookResult(r: BookSearchResult): void {
// Always overwrite the auto-populated fields so re-selecting a different
// result fully replaces the previous selection's data.
const gen = ++this.selectGen;
this.state.title = r.title;
this.state.author = r.author;
this.state.totalPages = r.totalPages;
this.state.coverUrl = r.coverUrl;
this.state.googleBooksId = r.googleBooksId;
this.state.releaseDate = r.releaseDate ?? '';
this.refreshForm();
// Keep the results list open so another result can be picked.
// Search results sometimes omit page counts and only return thumbnail covers —
// fetch the full volume for the selected book to upgrade both.
if (r.googleBooksId) {
void (async () => {
try {
const detail = await this.plugin.apiService.getGoogleBookById(r.googleBooksId);
if (gen !== this.selectGen || !detail) return; // a newer selection superseded this one
if (detail.totalPages > 0) this.state.totalPages = detail.totalPages;
if (detail.coverUrl) this.state.coverUrl = detail.coverUrl;
this.refreshForm();
} catch {
// Best-effort enrichment; the book is already applied from search results.
}
})();
}
}
private applyMangaResult(r: MangaSearchResult): void {
// Always overwrite the auto-populated fields so re-selecting a different
// result fully replaces the previous selection's data.
const gen = ++this.selectGen;
this.state.title = r.title;
this.state.author = r.author;
this.state.totalChapters = r.totalChapters;
this.state.totalVolumes = r.totalVolumes;
this.state.coverUrl = r.coverUrl;
this.state.malId = r.malId > 0 ? String(r.malId) : '';
this.state.releaseDate = r.releaseDate ?? '';
this.refreshForm();
// Keep the results list open so another result can be picked.
// Search returns null chapters/volumes for ongoing titles — fetch detail only
// for the selected manga to populate the totals.
if (r.malId > 0) {
void (async () => {
const detail = await this.plugin.apiService.getMangaByMalId(r.malId);
if (gen !== this.selectGen || !detail) return; // superseded or no data
this.state.totalChapters = detail.totalChapters > 0 ? detail.totalChapters : this.state.totalChapters;
this.state.totalVolumes = detail.totalVolumes > 0 ? detail.totalVolumes : this.state.totalVolumes;
this.refreshForm();
})();
}
}
private refreshForm(): void {
if (!this.formEl) return;
this.formEl.empty();
this.renderForm(this.formEl);
}
private renderForm(parent: HTMLElement): void {
const form = parent.createDiv({ cls: 'wl-reading-form' });
// Title (required)
this.renderTextField(form, 'Title', 'Book title', this.state.title, (v) => {
this.state.title = v;
});
// Author
this.renderTextField(form, 'Author', 'Author name', this.state.author, (v) => {
this.state.author = v;
});
// Status + Rating row (compact, inline)
this.renderStatusRatingRow(form);
// PROGRESS + OPTIONAL side-by-side grid
this.renderProgressOptionalGrid(form);
}
private renderProgressOptionalGrid(parent: HTMLElement): void {
const grid = parent.createDiv({ cls: 'wl-reading-form-grid' });
const progressCol = grid.createDiv({ cls: 'wl-reading-form-grid-col' });
progressCol.createDiv({ cls: 'wl-reading-section-label', text: 'Progress' });
if (this.mode === 'book') {
this.renderTwoNumberRow(
progressCol,
{ label: 'Pages read', value: this.state.pagesRead, onChange: (n) => { this.state.pagesRead = n; } },
{ label: 'Chapters read', value: this.state.chaptersRead, onChange: (n) => { this.state.chaptersRead = n; } },
);
this.renderTwoNumberRow(
progressCol,
{ label: 'Total pages', value: this.state.totalPages, onChange: (n) => { this.state.totalPages = n; } },
{ label: 'Total chapters', value: this.state.totalChapters, onChange: (n) => { this.state.totalChapters = n; } },
);
} else {
this.renderTwoNumberRow(
progressCol,
{ label: 'Chapters read', value: this.state.chaptersRead, onChange: (n) => { this.state.chaptersRead = n; } },
{ label: 'Volumes read', value: this.state.volumesRead, onChange: (n) => { this.state.volumesRead = n; } },
);
this.renderTwoNumberRow(
progressCol,
{ label: 'Total chapters', value: this.state.totalChapters, onChange: (n) => { this.state.totalChapters = n; } },
{ label: 'Total volumes', value: this.state.totalVolumes, onChange: (n) => { this.state.totalVolumes = n; } },
);
}
const optionalCol = grid.createDiv({ cls: 'wl-reading-form-grid-col' });
optionalCol.createDiv({ cls: 'wl-reading-section-label', text: 'Optional' });
this.renderTextField(optionalCol, 'Cover URL', 'https://...', this.state.coverUrl, (v) => {
this.state.coverUrl = v;
});
this.renderDateField(optionalCol, 'Start date', this.state.dateStarted, (parsed) => {
this.state.dateStarted = parsed;
});
this.renderDateField(optionalCol, 'Release date', this.state.releaseDate, (parsed) => {
this.state.releaseDate = parsed;
});
}
private renderTextField(
parent: HTMLElement,
label: string,
placeholder: string,
value: string,
onChange: (v: string) => void,
): void {
const row = parent.createDiv({ cls: 'wl-reading-row' });
row.createSpan({ cls: 'wl-reading-label', text: label });
const input = row.createEl('input', {
cls: 'wl-modal-input',
attr: { type: 'text', placeholder },
});
input.value = value;
input.addEventListener('input', () => onChange(input.value));
}
private renderStatusRatingRow(parent: HTMLElement): void {
const row = parent.createDiv({ cls: 'wl-reading-status-rating-row' });
// Status (compact — only as wide as needed)
const statusGroup = row.createDiv({ cls: 'wl-reading-inline-group' });
statusGroup.createSpan({ cls: 'wl-reading-label', text: 'Status' });
const select = statusGroup.createEl('select', { cls: 'wl-select wl-reading-status-select' });
for (const s of SELECTABLE_READING_STATUSES) {
const opt = select.createEl('option', { text: s, value: s });
if (s === this.state.status) opt.selected = true;
}
select.addEventListener('change', () => {
this.state.status = select.value as ReadingStatus;
});
// Rating (inline next to status)
const ratingGroup = row.createDiv({ cls: 'wl-reading-inline-group' });
ratingGroup.createSpan({ cls: 'wl-reading-label', text: 'Rating' });
this.starsWrap = ratingGroup.createDiv({ cls: 'wl-stars wl-reading-stars' });
this.renderStars();
}
private renderStars(): void {
if (!this.starsWrap) return;
this.starsWrap.empty();
for (let i = 1; i <= 5; i++) {
const star = this.starsWrap.createSpan({
cls: `wl-star${this.state.rating >= i ? ' is-active' : ''}`,
text: '★',
});
star.addEventListener('click', () => {
this.state.rating = this.state.rating === i ? 0 : i;
this.renderStars();
});
}
}
private renderTwoNumberRow(
parent: HTMLElement,
left: { label: string; value: number; onChange: (n: number) => void },
right: { label: string; value: number; onChange: (n: number) => void },
): void {
const row = parent.createDiv({ cls: 'wl-reading-row wl-reading-row-split' });
this.renderNumberCol(row, left);
this.renderNumberCol(row, right);
}
private renderNumberCol(
parent: HTMLElement,
field: { label: string; value: number; onChange: (n: number) => void },
): void {
const col = parent.createDiv({ cls: 'wl-reading-col' });
col.createSpan({ cls: 'wl-reading-label', text: field.label });
const input = col.createEl('input', {
cls: 'wl-modal-input wl-modal-input-sm',
attr: { type: 'number', min: '0' },
});
input.value = String(field.value);
input.addEventListener('input', () => {
const n = parseInt(input.value, 10);
field.onChange(isNaN(n) || n < 0 ? 0 : n);
});
}
private renderDateField(
parent: HTMLElement,
label: string,
value: string | null,
onParsed: (parsed: string | null) => void,
): void {
const row = parent.createDiv({ cls: 'wl-reading-row' });
row.createSpan({ cls: 'wl-reading-label', text: label });
const input = row.createEl('input', {
cls: 'wl-modal-input',
attr: { type: 'text', placeholder: 'DD/MM/YYYY', maxlength: '10' },
});
input.value = value ? formatDateDisplay(value) : '';
input.addEventListener('change', () => {
const raw = input.value.trim();
if (!raw) {
input.removeClass('wl-input-error');
onParsed(null);
return;
}
const parsed = parseDateInput(raw);
if (parsed) {
input.removeClass('wl-input-error');
onParsed(parsed);
} else {
input.addClass('wl-input-error');
}
});
}
private renderFooter(parent: HTMLElement): void {
const footer = parent.createDiv({ cls: 'wl-reading-modal-footer' });
const cancelBtn = footer.createEl('button', { cls: 'wl-btn', text: 'Cancel' });
cancelBtn.addEventListener('click', () => this.close());
const saveBtn = footer.createEl('button', {
cls: 'wl-reading-add-btn wl-btn-success wl-reading-modal-save',
text: this.saveButtonLabel(),
});
saveBtn.addEventListener('click', () => void this.save());
}
private saveButtonLabel(): string {
if (this.existingId) return 'Save changes';
return this.mode === 'book' ? 'Add book' : 'Add manga';
}
private async save(): Promise<void> {
const title = this.state.title.trim();
if (!title) {
new Notice('Please enter a title.');
return;
}
if (this.mode === 'book') {
await this.saveBook(title);
} else {
await this.saveManga(title);
}
this.onSaved();
this.close();
}
private async saveBook(title: string): Promise<void> {
if (this.existingId) {
const existing = this.readingData.getBook(this.existingId);
if (!existing) {
new Notice('Original book no longer exists.');
return;
}
const updated: Book = {
...existing,
title,
author: this.state.author.trim(),
status: this.state.status,
rating: this.state.rating,
pagesRead: this.state.pagesRead,
totalPages: this.state.totalPages,
chaptersRead: this.state.chaptersRead,
totalChapters: this.state.totalChapters,
coverUrl: this.state.coverUrl.trim(),
googleBooksId: this.state.googleBooksId.trim(),
vaultPage: this.state.vaultPage,
dateStarted: this.state.dateStarted,
dateFinished: this.state.dateFinished,
releaseDate: this.state.releaseDate,
};
await this.readingData.updateBook(updated);
} else {
const now = new Date().toISOString();
const book: Book = {
id: this.readingData.generateBookId(title),
title,
author: this.state.author.trim(),
status: this.state.status,
rating: this.state.rating,
pagesRead: this.state.pagesRead,
totalPages: this.state.totalPages,
chaptersRead: this.state.chaptersRead,
totalChapters: this.state.totalChapters,
coverUrl: this.state.coverUrl.trim(),
googleBooksId: this.state.googleBooksId.trim(),
vaultPage: this.state.vaultPage,
dateStarted: this.state.dateStarted,
dateFinished: this.state.dateFinished,
releaseDate: this.state.releaseDate,
dateAdded: now,
dateModified: now,
customFields: {},
};
await this.readingData.addBook(book);
}
}
private async saveManga(title: string): Promise<void> {
if (this.existingId) {
const existing = this.readingData.getManga(this.existingId);
if (!existing) {
new Notice('Original manga no longer exists.');
return;
}
const updated: Manga = {
...existing,
title,
author: this.state.author.trim(),
status: this.state.status,
rating: this.state.rating,
chaptersRead: this.state.chaptersRead,
totalChapters: this.state.totalChapters,
volumesRead: this.state.volumesRead,
totalVolumes: this.state.totalVolumes,
coverUrl: this.state.coverUrl.trim(),
malId: this.state.malId.trim(),
vaultPage: this.state.vaultPage,
dateStarted: this.state.dateStarted,
dateFinished: this.state.dateFinished,
releaseDate: this.state.releaseDate,
};
await this.readingData.updateManga(updated);
} else {
const now = new Date().toISOString();
const manga: Manga = {
id: this.readingData.generateMangaId(title),
title,
author: this.state.author.trim(),
status: this.state.status,
rating: this.state.rating,
chaptersRead: this.state.chaptersRead,
totalChapters: this.state.totalChapters,
volumesRead: this.state.volumesRead,
totalVolumes: this.state.totalVolumes,
coverUrl: this.state.coverUrl.trim(),
malId: this.state.malId.trim(),
vaultPage: this.state.vaultPage,
dateStarted: this.state.dateStarted,
dateFinished: this.state.dateFinished,
releaseDate: this.state.releaseDate,
dateAdded: now,
dateModified: now,
customFields: {},
};
await this.readingData.addManga(manga);
}
}
}

View file

@ -8,7 +8,7 @@ import type {
MediaSearchResult,
Season,
} from './types';
import { formatDateDisplay, parseDateInput, parseReleaseDateInput } from './types';
import { formatDateDisplay, parseDateInput, parseReleaseDateInput, getApiGroupForType } from './types';
type SearchResult = AnimeSearchResult | MediaSearchResult;
@ -26,7 +26,9 @@ export class AddTitleModal extends Modal {
private searchQuery = '';
private searchResults: SearchResult[] = [];
private isSearching = false;
private searchGeneration = 0;
private autoSearch = false;
private noApiMessage = '';
// Editable fields
private fieldTitle = '';
@ -39,6 +41,7 @@ export class AddTitleModal extends Modal {
private fieldPriority = 'Medium';
private fieldDateStarted = '';
private fieldMalId: number | null = null;
private fieldAnilistId: number | null = null;
private skipDuplicateCheck = false;
private duplicateWarningEl: HTMLElement | null = null;
@ -117,6 +120,7 @@ export class AddTitleModal extends Modal {
typeSelect.addEventListener('change', () => {
this.selectedType = typeSelect.value;
this.searchResults = [];
this.noApiMessage = '';
this.renderResults();
this.renderForm();
});
@ -148,28 +152,42 @@ export class AddTitleModal extends Modal {
private async performSearch(): Promise<void> {
if (!this.searchQuery.trim()) return;
const gen = ++this.searchGeneration;
this.isSearching = true;
this.renderResults();
try {
const api = this.plugin.apiService;
const activeApi = this.plugin.settings.activeApi ?? 'OMDb';
const isAnime = this.selectedType === 'Anime';
const apiGroup = getApiGroupForType(this.selectedType, this.plugin.settings.typeApiMapping);
const isMovie = this.selectedType === 'Movie';
if (isAnime) {
this.searchResults = await api.searchAnime(this.searchQuery);
let results: SearchResult[] = [];
let noApi = '';
if (apiGroup === '') {
noApi = `No API configured for type "${this.selectedType}". Go to Settings → API to set one up.`;
} else if (apiGroup === 'anime') {
const animeSource = this.plugin.settings.animeApiSource ?? 'jikan';
results = animeSource === 'anilist'
? await api.searchAniList(this.searchQuery)
: await api.searchAnime(this.searchQuery);
} else if (activeApi === 'TMDB') {
this.searchResults = await api.searchTmdb(this.searchQuery, isMovie ? 'movie' : 'series');
results = await api.searchTmdb(this.searchQuery, isMovie ? 'movie' : 'series');
} else {
this.searchResults = await api.searchOmdb(this.searchQuery, isMovie ? 'movie' : 'series');
results = await api.searchOmdb(this.searchQuery, isMovie ? 'movie' : 'series');
}
if (gen !== this.searchGeneration) return; // stale
this.noApiMessage = noApi;
this.searchResults = results;
} catch {
if (gen !== this.searchGeneration) return;
new Notice('Search failed. Check your connection and API settings.');
this.searchResults = [];
} finally {
this.isSearching = false;
this.renderResults();
if (gen === this.searchGeneration) {
this.isSearching = false;
this.renderResults();
}
}
}
@ -182,6 +200,13 @@ export class AddTitleModal extends Modal {
return;
}
if (this.noApiMessage) {
const msg = this.resultsEl.createDiv({ cls: 'wl-modal-no-api' });
msg.createSpan({ cls: 'wl-modal-no-api-icon', text: '' });
msg.createSpan({ cls: 'wl-modal-no-api-text', text: this.noApiMessage });
return;
}
if (this.searchResults.length === 0) return;
this.searchResults.forEach((result, idx) => {
@ -204,6 +229,7 @@ export class AddTitleModal extends Modal {
}
itemEl.addClass('is-selected');
const gen = ++this.searchGeneration;
const api = this.plugin.apiService;
const activeApi = this.plugin.settings.activeApi ?? 'OMDb';
try {
@ -211,6 +237,7 @@ export class AddTitleModal extends Modal {
const full = activeApi === 'TMDB'
? await api.getTmdbTvDetails(result.imdbId)
: await api.getOmdbTvDetails(result.imdbId);
if (gen !== this.searchGeneration) return;
if (full) {
this.fieldTitle = full.title;
this.fieldEpisodes = full.episodes;
@ -223,6 +250,7 @@ export class AddTitleModal extends Modal {
const full = activeApi === 'TMDB'
? await api.getTmdbMovieDetails(result.imdbId)
: await api.getOmdbMovieDetails(result.imdbId);
if (gen !== this.searchGeneration) return;
if (full) {
this.fieldTitle = full.title;
this.fieldEpisodes = full.episodes;
@ -239,7 +267,13 @@ export class AddTitleModal extends Modal {
this.fieldReleaseDate = anime.releaseDate;
this.fieldLink = anime.url;
this.fieldSeasons = anime.seasons;
this.fieldMalId = anime.malId;
if (anime.anilistId && anime.anilistId > 0) {
this.fieldAnilistId = anime.anilistId;
this.fieldMalId = null;
} else {
this.fieldMalId = anime.malId;
this.fieldAnilistId = null;
}
}
} catch {
this.fieldTitle = result.title;
@ -478,9 +512,10 @@ export class AddTitleModal extends Modal {
seasons: this.fieldSeasons,
watchedEpisodes: [],
...(this.fieldMalId !== null ? { malId: this.fieldMalId } : {}),
...(this.fieldAnilistId !== null ? { anilistId: this.fieldAnilistId } : {}),
};
// Bug 3: auto-complete episodes when adding with Completed status
// Auto-complete episodes when adding with Completed status
if (entry.status === 'Completed' && entry.totalEpisodes > 0) {
entry.watchedEpisodes = Array.from({ length: entry.totalEpisodes }, (_, i) => i + 1);
if (this.plugin.settings.setFinishDateAutomatically) {
@ -490,6 +525,17 @@ export class AddTitleModal extends Modal {
await this.dataManager.addTitle(entry);
// Fire-and-forget community rating fetch (no await — non-blocking)
void (async () => {
const animeSource = this.plugin.settings.animeApiSource ?? 'jikan';
const group = getApiGroupForType(entry.type, this.plugin.settings.typeApiMapping);
if (group === '') return;
const result = await this.plugin.apiService.fetchCommunityRating(entry, animeSource, group);
if (result) {
this.dataManager.updateCommunityRating(entry.id, result.rating, result.votes, result.source);
}
})();
if (this.selectedGroupId) {
await this.dataManager.addTitleToGroup(this.selectedGroupId, entry.id);
} else if (this.newGroupName.trim()) {

View file

@ -1,11 +1,42 @@
import { App, Modal, Notice } from 'obsidian';
import type WatchLogPlugin from './main';
import type { DataManager } from './DataManager';
import type { WatchLogTitle, AirtimeEntry, AirtimeSchedule, AirtimeRecurrence, MaybeTitle } from './types';
import { getAirtimeScheduleString, getThemedColor } from './types';
import { InsertWidgetModal } from './InsertWidgetModal';
import type { ReadingDataManager } from './ReadingDataManager';
import type { WatchLogTitle, AirtimeEntry, AirtimeSchedule, AirtimeRecurrence, MaybeTitle, Book, Manga } from './types';
import { getAirtimeScheduleString, getThemedColor, getReadingTypeColor, parseDateInput, isReleaseDateFuture } from './types';
import { ConfirmModal } from './ConfirmModal';
import { MaybeAddModal } from './MaybeAddModal';
import { ReadingScheduleModal } from './ReadingScheduleModal';
import { UpcomingFinderModal, type UpcomingFinderItem } from './UpcomingFinderModal';
/**
* A normalized Upcoming card subject either a watchlist title or a reading item.
* Lets the tracker render and act on both through one shared code path.
*/
type ResolvedUpcoming = {
source: 'watchlist' | 'reading';
id: string;
title: string;
/** Badge text: a watch type ("Anime", "Movie", …) or "Book" / "Manga". */
typeName: string;
/** Colored-badge color, or null to render a plain badge (reading). */
typeColor: string | null;
externalLink: string;
/** True when the item is a single release (movie/book): 0 or 1 total units. */
isSingle: boolean;
/** Total episodes (watch) or total chapters (reading). */
totalUnits: number;
/** Lowercase noun for the incrementing unit: "episode" | "chapter". */
unitNoun: string;
/** Capitalized incrementing unit: "Episode" | "Chapter". */
unitNounCap: string;
/** Capitalized grouping unit: "Season" | "Volume". */
groupNounCap: string;
/** Badge tail: "Airing next" | "Reading next". */
nextLabel: string;
/** Status to revert to on single-release tick: "Plan to watch" | "Plan to Read". */
planStatus: string;
};
// ── Helpers ───────────────────────────────────────────────────────────────────
@ -73,7 +104,7 @@ function getDetailedCountdown(
if (schedule.recurrence === 'daily') {
const passed = isAirtimePassedNow(schedule);
// If user already ticked today, advance to tomorrow
// Already ticked today → advance to tomorrow
if (passed && acknowledgedToday) return { kind: 'future', label: 'Tomorrow', daysUntil: 1 };
if (passed) return { kind: 'aired', label: 'Aired', daysUntil: 0 };
return { kind: 'today-before', label: 'Today', daysUntil: 0 };
@ -138,9 +169,10 @@ export class AirtimeTab {
private container: HTMLElement;
private plugin: WatchLogPlugin;
private dataManager: DataManager;
private readingDataManager: ReadingDataManager;
private onCountChange?: (count: number) => void;
// Sub-tab state (Feature 2)
// Sub-tab state
currentSubTab: 'tracker' | 'history' | 'maybe' = 'tracker';
// Selection mode
@ -159,9 +191,63 @@ export class AirtimeTab {
this.container = container;
this.plugin = plugin;
this.dataManager = dataManager;
this.readingDataManager = plugin.readingDataManager;
this.onCountChange = onCountChange;
}
/**
* Normalizes an airtime entry into a renderable subject, resolving the
* underlying watchlist title or reading item. Returns null for orphan entries.
*/
private resolveEntry(entry: AirtimeEntry): ResolvedUpcoming | null {
if (entry.source === 'reading') {
const kind = entry.readingKind ?? 'book';
const item: Book | Manga | undefined =
kind === 'book' ? this.readingDataManager.getBook(entry.titleId) : this.readingDataManager.getManga(entry.titleId);
if (!item) return null;
const totalUnits = entry.totalEpisodes ?? item.totalChapters ?? 0;
return {
source: 'reading',
id: item.id,
title: item.title,
typeName: kind === 'book' ? 'Book' : 'Manga',
typeColor: this.plugin.settings.coloredTypeBadges
? getReadingTypeColor(kind, this.plugin.settings)
: null,
externalLink: item.externalLink ?? '',
isSingle: totalUnits <= 1,
totalUnits,
unitNoun: 'chapter',
unitNounCap: 'Chapter',
groupNounCap: 'Volume',
nextLabel: 'Reading next',
planStatus: 'Plan to Read',
};
}
const title = this.dataManager.getTitle(entry.titleId);
if (!title) return null;
const typeDef = this.plugin.settings.types.find((t) => t.name === title.type);
const colored = this.plugin.settings.coloredTypeBadges;
return {
source: 'watchlist',
id: title.id,
title: title.title,
typeName: title.type,
typeColor: colored && typeDef
? getThemedColor(title.type, typeDef.color, this.plugin.settings.colorTheme)
: null,
externalLink: title.externalLink,
isSingle: title.totalEpisodes <= 1,
totalUnits: entry.totalEpisodes ?? title.totalEpisodes,
unitNoun: 'episode',
unitNounCap: 'Episode',
groupNounCap: 'Season',
nextLabel: 'Airing next',
planStatus: 'Plan to watch',
};
}
/** Returns the count of Due entries in the Maybe list — usable without rendering the tab. */
static getMaybeDueCount(dataManager: DataManager): number {
const today = new Date();
@ -173,15 +259,29 @@ export class AirtimeTab {
}
/** Returns the count of Aired/Due entries — usable without rendering the tab. */
static getAiredDueCount(dataManager: DataManager): number {
static getAiredDueCount(dataManager: DataManager, readingDataManager?: ReadingDataManager): number {
const allEntries = dataManager.getAirtimeEntries();
const titles = dataManager.getTitles();
const titleMap = new Map(titles.map((t) => [t.id, t]));
let count = 0;
for (const entry of allEntries) {
const title = titles.find((t) => t.id === entry.titleId);
if (!title) continue;
const isMovie = title.totalEpisodes <= 1;
const cd = getDetailedCountdown(entry.schedule, isMovie, entry.lastAcknowledgedDate);
let isSingle: boolean;
if (entry.source === 'reading') {
// Verify the reading item still exists (skip orphans) when we have the manager.
if (readingDataManager) {
const kind = entry.readingKind ?? 'book';
const item = kind === 'book'
? readingDataManager.getBook(entry.titleId)
: readingDataManager.getManga(entry.titleId);
if (!item) continue;
}
isSingle = (entry.totalEpisodes ?? 0) <= 1;
} else {
const title = titleMap.get(entry.titleId);
if (!title) continue;
isSingle = title.totalEpisodes <= 1;
}
const cd = getDetailedCountdown(entry.schedule, isSingle, entry.lastAcknowledgedDate);
if (cd.kind === 'aired' || cd.kind === 'due') count++;
}
return count;
@ -191,7 +291,7 @@ export class AirtimeTab {
this.container.empty();
this.container.addClass('wl-airtime');
if (this.onCountChange) {
this.onCountChange(AirtimeTab.getAiredDueCount(this.dataManager) + AirtimeTab.getMaybeDueCount(this.dataManager));
this.onCountChange(AirtimeTab.getAiredDueCount(this.dataManager, this.readingDataManager) + AirtimeTab.getMaybeDueCount(this.dataManager));
}
this.renderInnerTabBar();
if (this.currentSubTab === 'tracker') {
@ -205,11 +305,11 @@ export class AirtimeTab {
private renderInnerTabBar(): void {
const bar = this.container.createDiv({ cls: 'wl-inner-tab-bar' });
const trackerCount = AirtimeTab.getAiredDueCount(this.dataManager);
const trackerCount = AirtimeTab.getAiredDueCount(this.dataManager, this.readingDataManager);
const maybeCount = AirtimeTab.getMaybeDueCount(this.dataManager);
const tabs: Array<{ key: 'tracker' | 'history' | 'maybe'; label: string; badge: number }> = [
{ key: 'tracker', label: 'Tracker', badge: trackerCount },
{ key: 'history', label: 'History', badge: 0 },
{ key: 'history', label: 'Log', badge: 0 },
{ key: 'maybe', label: 'Maybe', badge: maybeCount },
];
for (const { key, label, badge } of tabs) {
@ -230,11 +330,12 @@ export class AirtimeTab {
}
private renderTracker(): void {
// Notice banner
this.container.createDiv({
cls: 'wl-cl-draft-banner',
text: '⚠ All titles with a release date in the future will be automatically marked as "To be released" in Watchlist and added here.',
});
if (this.plugin.settings.showHintBanners) {
this.container.createDiv({
cls: 'wl-cl-draft-banner',
text: '⚠ All titles with a release date in the future will be automatically marked as "To be released" in Watchlist and added here.',
});
}
this.renderHeader();
this.renderSearch();
this.renderCards();
@ -279,21 +380,20 @@ export class AirtimeTab {
metaRow.createSpan({ cls: 'wl-airtime-schedule', text: title.releaseDate });
}
const right = card.createDiv({ cls: 'wl-airtime-card-right' });
const right = card.createDiv({ cls: 'wl-airtime-card-right wl-airtime-history-right' });
if (title.releaseDate) {
right.createDiv({ cls: 'wl-airtime-pill wl-airtime-pill-aired', text: formatDaysAgo(title.releaseDate) });
}
const actions = right.createDiv({ cls: 'wl-airtime-actions' });
const globeBtn = actions.createEl('button', { cls: 'wl-airtime-action-btn', text: '🌐' });
const globeBtn = right.createEl('button', { cls: 'wl-airtime-action-btn', text: '🌐' });
globeBtn.title = 'Open page';
globeBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (title.externalLink) window.open(title.externalLink, '_blank');
if (title.externalLink) activeWindow.open(title.externalLink, '_blank');
else new Notice('No external link set for this title.');
});
const deleteBtn = actions.createEl('button', { cls: 'wl-airtime-action-btn wl-airtime-action-btn-delete', text: '✕' });
const deleteBtn = right.createEl('button', { cls: 'wl-airtime-action-btn wl-airtime-action-btn-delete wl-btn-danger', text: '✕' });
deleteBtn.title = 'Remove from watchlist';
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
@ -311,8 +411,10 @@ export class AirtimeTab {
headerEl.createSpan({ cls: 'wl-list-count', text: String(maybeTitles.length) });
}
const controls = headerEl.createDiv({ cls: 'wl-header-controls' });
const addBtnWrap = controls.createDiv({ cls: 'wl-add-btn-wrap' });
const addBtn = addBtnWrap.createEl('button', { cls: 'wl-add-btn', text: '+ add' });
// Add pinned to the far right of the toolbar row
const rightGroup = controls.createDiv({ cls: 'wl-header-controls-right' });
const addBtnWrap = rightGroup.createDiv({ cls: 'wl-add-btn-wrap' });
const addBtn = addBtnWrap.createEl('button', { cls: 'wl-add-btn wl-btn-success', text: '+ add' });
addBtn.addEventListener('click', (e) => {
e.stopPropagation();
new MaybeAddModal(this.plugin.app, this.plugin, this.dataManager, () => this.render()).open();
@ -376,10 +478,10 @@ export class AirtimeTab {
globeBtn.title = 'Open page';
globeBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (mt.externalLink) window.open(mt.externalLink, '_blank');
if (mt.externalLink) activeWindow.open(mt.externalLink, '_blank');
else new Notice('No external link set.');
});
const deleteBtn = right.createEl('button', { cls: 'wl-airtime-action-btn wl-airtime-action-btn-delete', text: '✕' });
const deleteBtn = right.createEl('button', { cls: 'wl-airtime-action-btn wl-airtime-action-btn-delete wl-btn-danger', text: '✕' });
deleteBtn.title = 'Remove from maybe';
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
@ -396,9 +498,14 @@ export class AirtimeTab {
attr: { type: 'text', placeholder: 'Search upcoming...' },
});
input.value = this.searchQuery;
let debounceTimer: number | null = null;
input.addEventListener('input', () => {
this.searchQuery = input.value;
this.rerenderCards();
if (debounceTimer !== null) window.clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(() => {
debounceTimer = null;
this.rerenderCards();
}, 250);
});
}
@ -415,10 +522,9 @@ export class AirtimeTab {
private renderHeader(): void {
const header = this.container.createDiv({ cls: 'wl-list-header' });
// Entry count (only entries with a valid title match)
const allTitles = this.dataManager.getTitles();
// Entry count (only entries that still resolve to a watchlist or reading item)
const count = this.dataManager.getAirtimeEntries().filter(
(e) => allTitles.some((t) => t.id === e.titleId),
(e) => this.resolveEntry(e) !== null,
).length;
if (count > 0) {
header.createSpan({ cls: 'wl-list-count', text: String(count) });
@ -448,8 +554,11 @@ export class AirtimeTab {
});
}
// Select + add pinned to the far right of the toolbar row
const rightGroup = controls.createDiv({ cls: 'wl-header-controls-right' });
// Selection mode toggle button
const selBtn = controls.createEl('button', {
const selBtn = rightGroup.createEl('button', {
cls: `wl-btn wl-btn-sm${this.selectionMode ? ' is-active' : ''}`,
text: 'Select',
});
@ -459,8 +568,8 @@ export class AirtimeTab {
this.render();
});
const addBtnWrap = controls.createDiv({ cls: 'wl-add-btn-wrap' });
const addBtn = addBtnWrap.createEl('button', { cls: 'wl-add-btn', text: '+ add' });
const addBtnWrap = rightGroup.createDiv({ cls: 'wl-add-btn-wrap' });
const addBtn = addBtnWrap.createEl('button', { cls: 'wl-add-btn wl-btn-success', text: '+ add' });
addBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.openAddFlow();
@ -472,7 +581,7 @@ export class AirtimeTab {
// Delete — removes selected entries from Upcoming
const deleteBtn = bar.createEl('button', {
cls: 'wl-group-action-btn wl-group-action-btn-delete',
cls: 'wl-group-action-btn wl-group-action-btn-delete wl-btn-danger',
text: '✕',
});
deleteBtn.title = 'Remove from upcoming';
@ -484,9 +593,7 @@ export class AirtimeTab {
`Remove ${count} selected item${count !== 1 ? 's' : ''} from Upcoming? This cannot be undone.`,
() => {
void (async () => {
for (const id of Array.from(this.selectedItems)) {
await this.dataManager.removeAirtimeEntry(id);
}
await this.dataManager.removeAirtimeEntriesBatch(Array.from(this.selectedItems));
this.selectedItems.clear();
this.render();
})();
@ -498,26 +605,142 @@ export class AirtimeTab {
// ── Add flow ──────────────────────────────────────────────────────────────────
private openAddFlow(): void {
const titles = this.dataManager.getTitles();
if (titles.length === 0) {
new Notice('No titles in your watchlog library yet.');
return;
}
const entries = this.dataManager.getAirtimeEntries();
const alreadyAdded = new Set(entries.map((e) => e.titleId));
const available = titles.filter((t) => !alreadyAdded.has(t.id));
// Already-added keys, kept separate per source so a watchlist id and a
// reading id that happen to match don't shadow each other.
const addedWatch = new Set(entries.filter((e) => e.source !== 'reading').map((e) => e.titleId));
const addedReading = new Set(entries.filter((e) => e.source === 'reading').map((e) => e.titleId));
if (available.length === 0) {
new Notice('All titles are already in upcoming.');
const items: UpcomingFinderItem[] = [];
for (const t of this.dataManager.getTitles()) {
if (addedWatch.has(t.id)) continue;
items.push({ source: 'watchlist', id: t.id, title: t.title, typeLabel: t.type });
}
for (const b of this.readingDataManager.getBooks()) {
if (addedReading.has(b.id)) continue;
items.push({ source: 'reading', kind: 'book', id: b.id, title: b.title, typeLabel: 'Book' });
}
for (const m of this.readingDataManager.getMangaList()) {
if (addedReading.has(m.id)) continue;
items.push({ source: 'reading', kind: 'manga', id: m.id, title: m.title, typeLabel: 'Manga' });
}
if (this.dataManager.getTitles().length === 0 &&
this.readingDataManager.getBooks().length === 0 &&
this.readingDataManager.getMangaList().length === 0) {
new Notice('No titles in your watchlog or reading library yet.');
return;
}
if (items.length === 0) {
new Notice('Everything is already in upcoming.');
return;
}
new InsertWidgetModal(this.plugin.app, available, (title) => {
void this.startAddWithTitle(title);
new UpcomingFinderModal(this.plugin.app, items, (item) => {
if (item.source === 'reading') {
const kind = item.kind ?? 'book';
const readItem = kind === 'book'
? this.readingDataManager.getBook(item.id)
: this.readingDataManager.getManga(item.id);
if (readItem) this.startAddWithReading(readItem, kind);
} else {
const title = this.dataManager.getTitle(item.id);
if (title) void this.startAddWithTitle(title);
}
}).open();
}
private startAddWithReading(item: Book | Manga, kind: 'book' | 'manga'): void {
const prefilled: AirtimeSchedule | null =
item.releaseDate && /^\d{4}-\d{2}-\d{2}$/.test(item.releaseDate)
? { recurrence: 'once', releaseDate: item.releaseDate }
: null;
new ReadingScheduleModal(
this.plugin.app,
item,
kind,
prefilled,
null,
null,
null,
null,
async (schedule, volume, chapter, totalVolumes, totalChapters) => {
const entry: AirtimeEntry = {
id: this.dataManager.generateReadingAirtimeId(item.id),
titleId: item.id,
source: 'reading',
readingKind: kind,
schedule,
currentSeason: volume ?? undefined,
currentEpisode: chapter ?? undefined,
totalSeasons: totalVolumes ?? undefined,
totalEpisodes: totalChapters ?? undefined,
dateAdded: new Date().toISOString(),
};
await this.dataManager.addAirtimeEntry(entry);
await this.syncReadingItemFromSchedule(item.id, kind, schedule, totalVolumes, totalChapters);
this.render();
},
).open();
}
/** Pushes schedule-derived totals / release date back onto the reading item. */
private async syncReadingItemFromSchedule(
itemId: string,
kind: 'book' | 'manga',
schedule: AirtimeSchedule,
totalVolumes: number | null,
totalChapters: number | null,
): Promise<void> {
if (kind === 'book') {
const b = this.readingDataManager.getBook(itemId);
if (!b) return;
let changed = false;
if (totalChapters !== null && totalChapters !== b.totalChapters) { b.totalChapters = totalChapters; changed = true; }
// Only write a future 'once' date back (drives auto "To be released"); a
// past/due date is left alone so the just-added entry isn't auto-removed.
if (schedule.recurrence === 'once' && isReleaseDateFuture(schedule.releaseDate)) {
const newDate = schedule.releaseDate ?? null;
if (b.releaseDate !== newDate) { b.releaseDate = newDate; changed = true; }
}
if (changed) await this.readingDataManager.updateBook(b);
} else {
const m = this.readingDataManager.getManga(itemId);
if (!m) return;
let changed = false;
if (totalChapters !== null && totalChapters !== m.totalChapters) { m.totalChapters = totalChapters; changed = true; }
if (totalVolumes !== null && totalVolumes !== m.totalVolumes) { m.totalVolumes = totalVolumes; changed = true; }
// Only write a future 'once' date back (drives auto "To be released"); a
// past/due date is left alone so the just-added entry isn't auto-removed.
if (schedule.recurrence === 'once' && isReleaseDateFuture(schedule.releaseDate)) {
const newDate = schedule.releaseDate ?? null;
if (m.releaseDate !== newDate) { m.releaseDate = newDate; changed = true; }
}
if (changed) await this.readingDataManager.updateManga(m);
}
}
/**
* On a single-release tick, revert the underlying item's auto "To be released"
* status back to its plan status (watchlist title or reading item).
*/
private async revertToBeReleasedStatus(entry: AirtimeEntry, r: ResolvedUpcoming): Promise<void> {
if (r.source === 'reading') {
const kind = entry.readingKind ?? 'book';
if (kind === 'book') {
const b = this.readingDataManager.getBook(entry.titleId);
if (b && b.status === 'To be released') { b.status = 'Plan to Read'; await this.readingDataManager.updateBook(b); }
} else {
const m = this.readingDataManager.getManga(entry.titleId);
if (m && m.status === 'To be released') { m.status = 'Plan to Read'; await this.readingDataManager.updateManga(m); }
}
} else {
const t = this.dataManager.getTitle(entry.titleId);
if (t && t.status === 'To be released') { t.status = 'Plan to watch'; await this.dataManager.updateTitle(t); }
}
}
private async startAddWithTitle(title: WatchLogTitle): Promise<void> {
let prefilled: AirtimeSchedule | null = null;
@ -530,7 +753,7 @@ export class AirtimeTab {
new Notice('Schedule auto-filled from myanimelist.');
}
} catch {
// ignore — user fills manually
// ignore — fields stay empty for manual entry
}
}
@ -568,7 +791,7 @@ export class AirtimeTab {
t.totalEpisodes = totalEpisodes;
changed = true;
}
// Bug 2: sync releaseDate when schedule is 'once'
// Sync releaseDate when schedule is 'once'
if (schedule.recurrence === 'once') {
const newDate = schedule.releaseDate ?? null;
if (t.releaseDate !== newDate) {
@ -597,20 +820,18 @@ export class AirtimeTab {
return;
}
const titles = this.dataManager.getTitles();
const q = this.searchQuery.trim().toLowerCase();
const cardData = allEntries
.map((entry) => {
const title = titles.find((t) => t.id === entry.titleId);
if (!title) return null;
if (q && !title.title.toLowerCase().includes(q)) return null;
const isMovie = title.totalEpisodes <= 1;
const countdown = getDetailedCountdown(entry.schedule, isMovie, entry.lastAcknowledgedDate);
return { entry, title, countdown };
const r = this.resolveEntry(entry);
if (!r) return null;
if (q && !r.title.toLowerCase().includes(q)) return null;
const countdown = getDetailedCountdown(entry.schedule, r.isSingle, entry.lastAcknowledgedDate);
return { entry, r, countdown };
})
.filter(
(d): d is { entry: AirtimeEntry; title: WatchLogTitle; countdown: DetailedCountdown } =>
(d): d is { entry: AirtimeEntry; r: ResolvedUpcoming; countdown: DetailedCountdown } =>
d !== null,
)
.sort((a, b) => {
@ -629,8 +850,8 @@ export class AirtimeTab {
}
const cardsEl = this.container.createDiv({ cls: 'wl-airtime-cards' });
for (const { entry, title, countdown } of cardData) {
this.renderCard(cardsEl, entry, title, countdown);
for (const { entry, r, countdown } of cardData) {
this.renderCard(cardsEl, entry, r, countdown);
}
}
@ -639,11 +860,11 @@ export class AirtimeTab {
private renderCard(
parent: HTMLElement,
entry: AirtimeEntry,
title: WatchLogTitle,
r: ResolvedUpcoming,
countdown: DetailedCountdown,
): void {
const isMovie = title.totalEpisodes <= 1;
const totalEps = entry.totalEpisodes ?? title.totalEpisodes;
const isMovie = r.isSingle;
const totalEps = r.totalUnits;
const isFinalEpisode =
!isMovie &&
entry.currentEpisode !== undefined &&
@ -686,25 +907,24 @@ export class AirtimeTab {
// ── Left ──────────────────────────────────────────────────────────────────
const left = card.createDiv({ cls: 'wl-airtime-card-left' });
left.createDiv({ cls: 'wl-airtime-card-title', text: title.title });
left.createDiv({ cls: 'wl-airtime-card-title', text: r.title });
// Type badge + schedule string (above episode badge per spec)
// Type badge + schedule string, above the episode badge. Reading entries
// render a plain (theme-driven) badge; watch entries may be colored.
const metaRow = left.createDiv({ cls: 'wl-airtime-card-meta' });
const typeDef = this.plugin.settings.types.find((t) => t.name === title.type);
const colored = this.plugin.settings.coloredTypeBadges;
const typeBadge = metaRow.createSpan({
cls: colored ? 'wl-badge wl-badge-sm' : 'wl-badge-plain',
text: title.type,
cls: r.typeColor ? 'wl-badge wl-badge-sm' : 'wl-badge-plain',
text: r.typeName,
});
if (colored && typeDef) typeBadge.style.backgroundColor = getThemedColor(title.type, typeDef.color, this.plugin.settings.colorTheme);
if (r.typeColor) typeBadge.style.backgroundColor = r.typeColor;
metaRow.createSpan({ cls: 'wl-airtime-schedule', text: getAirtimeScheduleString(entry.schedule) });
// Episode badge — only for series/anime, positioned below meta row
// Progress badge — only for multi-part titles (series / manga), below meta row
if (!isMovie && (entry.currentSeason !== undefined || entry.currentEpisode !== undefined)) {
const badgeParts: string[] = [];
if (entry.currentSeason !== undefined) badgeParts.push(`Season ${entry.currentSeason}`);
if (entry.currentEpisode !== undefined) badgeParts.push(`Episode ${entry.currentEpisode}`);
badgeParts.push('Airing next');
if (entry.currentSeason !== undefined) badgeParts.push(`${r.groupNounCap} ${entry.currentSeason}`);
if (entry.currentEpisode !== undefined) badgeParts.push(`${r.unitNounCap} ${entry.currentEpisode}`);
badgeParts.push(r.nextLabel);
const badgeCls = isFinalEpisode
? 'wl-ep-badge wl-ep-badge-final'
: 'wl-ep-badge';
@ -737,14 +957,14 @@ export class AirtimeTab {
cls: 'wl-airtime-action-btn wl-airtime-action-btn-tick',
text: '✓',
});
tickBtn.title = isMovie ? 'Mark as released' : (isFinalEpisode ? 'Final episode — mark done' : 'Mark episode as aired');
tickBtn.title = isMovie ? 'Mark as released' : (isFinalEpisode ? `Final ${r.unitNoun} — mark done` : `Mark ${r.unitNoun} as aired`);
tickBtn.addEventListener('click', (e) => {
e.stopPropagation();
const confirmMsg = isMovie
? `"${title.title}" has been released.\nRemove from Upcoming and set status to Plan to watch?`
? `"${r.title}" has been released.\nRemove from Upcoming and set status to ${r.planStatus}?`
: isFinalEpisode
? `Final episode of "${title.title}" aired.\nRemove from Upcoming? (Watchlist status unchanged)`
: `Episode ${entry.currentEpisode ?? ''} of "${title.title}" aired.\nMark and track next episode?`;
? `Final ${r.unitNoun} of "${r.title}".\nRemove from Upcoming? (status unchanged)`
: `${r.unitNounCap} ${entry.currentEpisode ?? ''} of "${r.title}".\nMark and track next ${r.unitNoun}?`;
new ConfirmModal(this.plugin.app, confirmMsg, () => {
void (async () => {
@ -755,17 +975,13 @@ export class AirtimeTab {
if (isMovie) {
await this.dataManager.removeAirtimeEntry(entry.id);
const t = this.dataManager.getTitle(title.id);
if (t && t.status === 'To be released') {
t.status = 'Plan to watch';
await this.dataManager.updateTitle(t);
}
await this.revertToBeReleasedStatus(entry, r);
} else if (isFinalEpisode) {
// Final episode: remove from Upcoming, do NOT change Watchlist status
// Final unit: remove from Upcoming, do NOT change status
await this.dataManager.removeAirtimeEntry(entry.id);
} else {
// Non-final episode: increment episode and record acknowledgement date
// so the countdown resets to the next occurrence immediately.
// Non-final: increment the unit (episode/chapter) and record
// the acknowledgement date so the countdown resets immediately.
entry.currentEpisode = (entry.currentEpisode ?? 1) + 1;
entry.lastAcknowledgedDate = todayStr;
await this.dataManager.updateAirtimeEntry(entry);
@ -781,71 +997,108 @@ export class AirtimeTab {
globeBtn.title = 'Open page';
globeBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (title.externalLink) {
window.open(title.externalLink, '_blank');
if (r.externalLink) {
activeWindow.open(r.externalLink, '_blank');
} else {
new Notice('No external link set for this title.');
}
});
// Edit — open schedule modal
// Edit — open the schedule modal matching the entry's source
const editBtn = actions.createEl('button', { cls: 'wl-airtime-action-btn', text: '✏' });
editBtn.title = 'Edit schedule';
editBtn.addEventListener('click', (e) => {
e.stopPropagation();
new AirtimeScheduleModal(
this.plugin.app,
title,
{ ...entry.schedule },
entry.currentSeason ?? null,
entry.currentEpisode ?? null,
entry.totalSeasons ?? null,
entry.totalEpisodes ?? null,
async (schedule, season, episode, totalSeasons, totalEpisodes) => {
entry.schedule = schedule;
entry.currentSeason = season ?? undefined;
entry.currentEpisode = episode ?? undefined;
entry.totalSeasons = totalSeasons ?? undefined;
entry.totalEpisodes = totalEpisodes ?? undefined;
await this.dataManager.updateAirtimeEntry(entry);
// Sync back to Watchlist title
const currentTitle = this.dataManager.getTitle(entry.titleId);
if (currentTitle) {
let changed = false;
if (totalEpisodes !== null && totalEpisodes !== currentTitle.totalEpisodes) {
currentTitle.totalEpisodes = totalEpisodes;
changed = true;
}
// Bug 2: sync releaseDate when schedule is 'once'
if (schedule.recurrence === 'once') {
const newDate = schedule.releaseDate ?? null;
if (currentTitle.releaseDate !== newDate) {
currentTitle.releaseDate = newDate;
changed = true;
}
}
if (changed) await this.dataManager.updateTitle(currentTitle);
}
this.render();
},
).open();
if (r.source === 'reading') {
this.openEditReadingSchedule(entry);
} else {
this.openEditWatchSchedule(entry);
}
});
// Delete — with confirmation (Feature 1)
// Delete — with confirmation
const deleteBtn = actions.createEl('button', {
cls: 'wl-airtime-action-btn wl-airtime-action-btn-delete',
cls: 'wl-airtime-action-btn wl-airtime-action-btn-delete wl-btn-danger',
text: '✕',
});
deleteBtn.title = 'Remove from upcoming';
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
new ConfirmModal(this.plugin.app, `Remove "${title.title}" from Upcoming?`, () => {
new ConfirmModal(this.plugin.app, `Remove "${r.title}" from Upcoming?`, () => {
void this.dataManager.removeAirtimeEntry(entry.id).then(() => this.render());
}).open();
});
}
private openEditWatchSchedule(entry: AirtimeEntry): void {
const title = this.dataManager.getTitle(entry.titleId);
if (!title) return;
new AirtimeScheduleModal(
this.plugin.app,
title,
{ ...entry.schedule },
entry.currentSeason ?? null,
entry.currentEpisode ?? null,
entry.totalSeasons ?? null,
entry.totalEpisodes ?? null,
async (schedule, season, episode, totalSeasons, totalEpisodes) => {
entry.schedule = schedule;
entry.currentSeason = season ?? undefined;
entry.currentEpisode = episode ?? undefined;
entry.totalSeasons = totalSeasons ?? undefined;
entry.totalEpisodes = totalEpisodes ?? undefined;
await this.dataManager.updateAirtimeEntry(entry);
// Sync back to Watchlist title
const currentTitle = this.dataManager.getTitle(entry.titleId);
if (currentTitle) {
let changed = false;
if (totalEpisodes !== null && totalEpisodes !== currentTitle.totalEpisodes) {
currentTitle.totalEpisodes = totalEpisodes;
changed = true;
}
// Sync releaseDate when schedule is 'once'
if (schedule.recurrence === 'once') {
const newDate = schedule.releaseDate ?? null;
if (currentTitle.releaseDate !== newDate) {
currentTitle.releaseDate = newDate;
changed = true;
}
}
if (changed) await this.dataManager.updateTitle(currentTitle);
}
this.render();
},
).open();
}
private openEditReadingSchedule(entry: AirtimeEntry): void {
const kind = entry.readingKind ?? 'book';
const item: Book | Manga | undefined =
kind === 'book' ? this.readingDataManager.getBook(entry.titleId) : this.readingDataManager.getManga(entry.titleId);
if (!item) return;
new ReadingScheduleModal(
this.plugin.app,
item,
kind,
{ ...entry.schedule },
entry.currentSeason ?? null, // volume
entry.currentEpisode ?? null, // chapter
entry.totalSeasons ?? null, // total volumes
entry.totalEpisodes ?? null, // total chapters
async (schedule, volume, chapter, totalVolumes, totalChapters) => {
entry.schedule = schedule;
entry.currentSeason = volume ?? undefined;
entry.currentEpisode = chapter ?? undefined;
entry.totalSeasons = totalVolumes ?? undefined;
entry.totalEpisodes = totalChapters ?? undefined;
await this.dataManager.updateAirtimeEntry(entry);
await this.syncReadingItemFromSchedule(entry.titleId, kind, schedule, totalVolumes, totalChapters);
this.render();
},
).open();
}
}
// ── AirtimeScheduleModal ──────────────────────────────────────────────────────
@ -887,7 +1140,7 @@ class AirtimeScheduleModal extends Modal {
this.schedule = existingSchedule
? { ...existingSchedule }
: { recurrence: isMovie ? 'once' : 'weekly' };
// Fix 9: pre-fill releaseDate from title if the schedule doesn't have one yet
// Pre-fill releaseDate from title if the schedule doesn't have one yet
if (!this.schedule.releaseDate && title.releaseDate && /^\d{4}-\d{2}-\d{2}$/.test(title.releaseDate)) {
this.schedule.releaseDate = title.releaseDate;
}
@ -979,11 +1232,11 @@ class AirtimeScheduleModal extends Modal {
? this.schedule.releaseDate.split('-').reverse().join('/')
: '';
dateInput.addEventListener('change', () => {
const parts = dateInput.value.split('/');
if (parts.length === 3) {
this.schedule.recurrence = 'once';
this.schedule.releaseDate = `${parts[2]}-${parts[1]}-${parts[0]}`;
}
const parsed = parseDateInput(dateInput.value);
if (parsed) {
this.schedule.recurrence = 'once';
this.schedule.releaseDate = parsed;
}
});
makeTimeRow('Time (optional)', content);
@ -1019,10 +1272,10 @@ class AirtimeScheduleModal extends Modal {
? this.schedule.releaseDate.split('-').reverse().join('/')
: '';
inp.addEventListener('change', () => {
const parts = inp.value.split('/');
if (parts.length === 3) {
this.schedule.releaseDate = `${parts[2]}-${parts[1]}-${parts[0]}`;
}
const parsed = parseDateInput(inp.value);
if (parsed) {
this.schedule.releaseDate = parsed;
}
});
}
@ -1078,7 +1331,7 @@ class AirtimeScheduleModal extends Modal {
if (this.currentEpisode !== null) epInput.value = String(this.currentEpisode);
epInput.addEventListener('input', () => { this.currentEpisode = parseInt(epInput.value) || null; });
// ── Feature 4: Total seasons + total episodes ────────────────────────────
// ── Total seasons + total episodes ───────────────────────────────────────
const totSeasRow = makeRow('Total seasons');
const totSeasInput = totSeasRow.createEl('input', {
cls: 'wl-modal-input wl-modal-input-sm',
@ -1095,7 +1348,7 @@ class AirtimeScheduleModal extends Modal {
if (this.totalEpisodes !== null) totEpInput.value = String(this.totalEpisodes);
totEpInput.addEventListener('input', () => { this.totalEpisodes = parseInt(totEpInput.value) || null; });
// Static hint note (always visible per spec)
// Static hint note (always visible)
content.createDiv({
cls: 'wl-modal-info wl-schedule-hint',
text: 'Titles with 0 or 1 total episodes will be treated as a single release date, like a movie.',

View file

@ -2,6 +2,7 @@ import { requestUrl } from 'obsidian';
import type {
AnimeSearchResult,
MediaSearchResult,
WatchLogTitle,
JikanAnime,
OmdbSearchResponse,
OmdbDetailResponse,
@ -12,20 +13,143 @@ import type {
TmdbExternalIds,
TmdbFindResult,
Season,
AniListMedia,
AniListSearchResponse,
AniListMediaResponse,
} from './types';
const JIKAN_BASE = 'https://api.jikan.moe/v4';
const OMDB_BASE = 'https://www.omdbapi.com';
const TMDB_BASE = 'https://api.themoviedb.org/3';
const ANILIST_ENDPOINT = 'https://graphql.anilist.co';
const ANILIST_RATE_LIMIT_MS = 700;
const GOOGLE_BOOKS_BASE = 'https://www.googleapis.com/books/v1';
const JIKAN_RATE_LIMIT_MS = 400;
const API_TIMEOUT_MS = 8000;
export interface BookSearchResult {
title: string;
author: string;
year: number;
totalPages: number;
coverUrl: string;
googleBooksId: string;
releaseDate: string;
url: string;
}
export interface MangaSearchResult {
malId: number;
title: string;
author: string;
year: number;
totalChapters: number;
totalVolumes: number;
coverUrl: string;
releaseDate: string;
url: string;
}
interface GoogleVolumeInfo {
title?: string;
subtitle?: string;
authors?: string[];
publishedDate?: string;
pageCount?: number;
imageLinks?: {
smallThumbnail?: string;
thumbnail?: string;
small?: string;
medium?: string;
large?: string;
extraLarge?: string;
};
infoLink?: string;
canonicalVolumeLink?: string;
}
interface GoogleVolume {
id?: string;
volumeInfo?: GoogleVolumeInfo;
}
interface GoogleBooksResponse {
items?: GoogleVolume[];
}
interface JikanMangaItem {
mal_id: number;
title: string;
title_english?: string | null;
authors?: Array<{ name: string }>;
chapters?: number | null;
volumes?: number | null;
published?: { from?: string | null } | null;
images?: { jpg?: { image_url?: string | null } | null } | null;
url?: string | null;
}
/** Why a Google Books request failed — used to surface accurate, non-misleading UI messages. */
export type GoogleBooksErrorReason = 'no-key' | 'rate-limited' | 'http' | 'parse' | 'network';
export class GoogleBooksError extends Error {
reason: GoogleBooksErrorReason;
status?: number;
constructor(reason: GoogleBooksErrorReason, message: string, status?: number) {
super(message);
this.name = 'GoogleBooksError';
this.reason = reason;
this.status = status;
}
}
/** Maps any error from a Google Books call to a short, user-facing explanation. */
export function googleBooksErrorMessage(err: unknown): string {
if (err instanceof GoogleBooksError) {
switch (err.reason) {
case 'no-key':
return 'Google Books API key required — add one in Settings → API → Books.';
case 'rate-limited':
return 'Google Books rate limit reached — your API key is missing or over quota.';
case 'http':
return `Google Books request failed (HTTP ${err.status ?? '?'}).`;
case 'parse':
return 'Google Books returned an unreadable response.';
default:
return 'Google Books request failed — check your connection.';
}
}
return 'Google Books request failed — check your connection.';
}
export class ApiService {
private omdbApiKey: string;
private tmdbApiKey: string;
private googleBooksApiKey: string;
private anilistLastRequest = 0;
private anilistQueue: Promise<void> = Promise.resolve();
private jikanLastRequest = 0;
private jikanQueue: Promise<void> = Promise.resolve();
constructor(omdbApiKey: string, tmdbApiKey = '') {
private throttleJikan<T>(task: () => Promise<T>): Promise<T> {
const run = async (): Promise<T> => {
const now = Date.now();
const gap = now - this.jikanLastRequest;
if (gap < JIKAN_RATE_LIMIT_MS) {
await new Promise<void>((r) => window.setTimeout(r, JIKAN_RATE_LIMIT_MS - gap));
}
this.jikanLastRequest = Date.now();
return task();
};
const next = this.jikanQueue.then(run, run);
this.jikanQueue = next.then(() => undefined, () => undefined);
return next;
}
constructor(omdbApiKey: string, tmdbApiKey = '', googleBooksApiKey = '') {
this.omdbApiKey = omdbApiKey;
this.tmdbApiKey = tmdbApiKey;
this.googleBooksApiKey = googleBooksApiKey;
}
setOmdbKey(key: string): void {
@ -36,16 +160,178 @@ export class ApiService {
this.tmdbApiKey = key;
}
setGoogleBooksKey(key: string): void {
this.googleBooksApiKey = key;
}
hasGoogleBooksKey(): boolean {
return this.googleBooksApiKey.trim().length > 0;
}
private tmdbHeaders(): Record<string, string> {
return { 'Authorization': `Bearer ${this.tmdbApiKey}` };
}
private async fetchWithTimeout(url: string, headers?: Record<string, string>): Promise<unknown> {
const timeoutPromise = new Promise<never>((_, reject) =>
window.setTimeout(() => reject(new Error('Request timed out')), API_TIMEOUT_MS),
);
const fetchPromise = requestUrl({ url, headers }).then((r) => r.json as unknown);
return Promise.race([fetchPromise, timeoutPromise]);
let timer: number | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = window.setTimeout(() => reject(new Error('Request timed out')), API_TIMEOUT_MS);
});
try {
const fetchPromise = requestUrl({ url, headers }).then((r) => r.json as unknown);
return await Promise.race([fetchPromise, timeoutPromise]);
} finally {
if (timer !== null) window.clearTimeout(timer);
}
}
private async postJsonWithTimeout(
url: string,
body: unknown,
headers?: Record<string, string>,
): Promise<unknown> {
let timer: number | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = window.setTimeout(() => reject(new Error('Request timed out')), API_TIMEOUT_MS);
});
try {
const fetchPromise = requestUrl({
url,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(headers ?? {}) },
body: JSON.stringify(body),
}).then((r) => r.json as unknown);
return await Promise.race([fetchPromise, timeoutPromise]);
} finally {
if (timer !== null) window.clearTimeout(timer);
}
}
// ─── AniList (GraphQL) ───────────────────────────────────────────────────────
/** Serializes AniList requests with a minimum gap of ANILIST_RATE_LIMIT_MS between them. */
private throttleAniList<T>(task: () => Promise<T>): Promise<T> {
const run = async (): Promise<T> => {
const now = Date.now();
const gap = now - this.anilistLastRequest;
if (gap < ANILIST_RATE_LIMIT_MS) {
await new Promise<void>((r) => window.setTimeout(r, ANILIST_RATE_LIMIT_MS - gap));
}
this.anilistLastRequest = Date.now();
return task();
};
const next = this.anilistQueue.then(run, run);
this.anilistQueue = next.then(() => undefined, () => undefined);
return next;
}
private stripHtml(s: string): string {
return s
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.trim();
}
private formatAniListDate(d: { year?: number | null; month?: number | null; day?: number | null } | null | undefined): string {
if (!d?.year) return '';
const yyyy = String(d.year);
const mm = String(d.month ?? 1).padStart(2, '0');
const dd = String(d.day ?? 1).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
private mapAniListMedia(media: AniListMedia): AnimeSearchResult {
const name = media.title?.english || media.title?.romaji || media.title?.native || '';
const episodes = media.episodes ?? 0;
const duration = media.duration ?? 24;
const seasons: Season[] =
episodes > 0 ? [{ name: 'Season 1', episodes, offset: 0 }] : [];
const description = media.description ? this.stripHtml(media.description) : '';
return {
malId: 0,
anilistId: media.id,
title: name,
episodes,
duration,
releaseDate: this.formatAniListDate(media.startDate),
url: `https://anilist.co/anime/${media.id}`,
seasons,
description,
averageScore: media.averageScore ?? undefined,
genres: media.genres ?? undefined,
posterUrl: media.coverImage?.large ?? media.coverImage?.medium ?? undefined,
};
}
async searchAniList(query: string): Promise<AnimeSearchResult[]> {
const gql = `
query ($search: String) {
Page(page: 1, perPage: 10) {
media(search: $search, type: ANIME) {
id
title { romaji english native }
episodes
duration
status
season
seasonYear
startDate { year month day }
averageScore
popularity
coverImage { large medium }
description
genres
}
}
}
`;
try {
const res = await this.throttleAniList(() =>
this.postJsonWithTimeout(ANILIST_ENDPOINT, { query: gql, variables: { search: query } }),
);
const data = res as AniListSearchResponse;
const media = data.data?.Page?.media ?? [];
return media.map((m) => this.mapAniListMedia(m));
} catch {
return [];
}
}
async getAniListById(anilistId: number): Promise<AniListMedia | null> {
const gql = `
query ($id: Int) {
Media(id: $id, type: ANIME) {
id
title { romaji english native }
episodes
duration
status
averageScore
popularity
coverImage { large }
nextAiringEpisode {
airingAt
episode
timeUntilAiring
}
}
}
`;
try {
const res = await this.throttleAniList(() =>
this.postJsonWithTimeout(ANILIST_ENDPOINT, { query: gql, variables: { id: anilistId } }),
);
const data = res as AniListMediaResponse;
return data.data?.Media ?? null;
} catch {
return null;
}
}
// ─── Jikan / MAL ─────────────────────────────────────────────────────────────
@ -324,6 +610,313 @@ export class ApiService {
}
}
// ─── Community ratings ───────────────────────────────────────────────────────
private parseOmdbVotes(s: string | undefined): number {
if (!s || s === 'N/A') return 0;
const n = parseInt(s.replace(/,/g, ''), 10);
return isNaN(n) ? 0 : n;
}
private parseOmdbRating(s: string | undefined): number {
if (!s || s === 'N/A') return 0;
const n = parseFloat(s);
return isNaN(n) ? 0 : n;
}
private extractImdbId(externalLink: string): string {
const m = externalLink.match(/tt\d+/);
return m ? m[0] : '';
}
private async fetchMalRating(malId: number): Promise<{ rating: number; votes: number } | null> {
try {
const url = `${JIKAN_BASE}/anime/${malId}`;
const data = (await this.fetchWithTimeout(url)) as {
data?: { score?: number | null; scored_by?: number | null };
};
const rating = data.data?.score ?? 0;
const votes = data.data?.scored_by ?? 0;
if (!rating && !votes) return null;
return { rating, votes };
} catch {
return null;
}
}
private async fetchAniListRating(
anilistId: number,
): Promise<{ rating: number; votes: number } | null> {
const media = await this.getAniListById(anilistId);
if (!media) return null;
const rating = media.averageScore ?? 0;
const votes = media.popularity ?? 0;
if (!rating && !votes) return null;
return { rating, votes };
}
private async fetchOmdbRating(imdbId: string): Promise<{ rating: number; votes: number } | null> {
if (!this.omdbApiKey) return null;
try {
const url = `${OMDB_BASE}/?i=${encodeURIComponent(imdbId)}&apikey=${encodeURIComponent(this.omdbApiKey)}`;
const data = (await this.fetchWithTimeout(url)) as {
Response?: string;
imdbRating?: string;
imdbVotes?: string;
};
if (data.Response === 'False') return null;
const rating = this.parseOmdbRating(data.imdbRating);
const votes = this.parseOmdbVotes(data.imdbVotes);
if (!rating && !votes) return null;
return { rating, votes };
} catch {
return null;
}
}
private async fetchTmdbRatingByImdb(
imdbId: string,
): Promise<{ rating: number; votes: number } | null> {
if (!this.tmdbApiKey) return null;
try {
const url = `${TMDB_BASE}/find/${encodeURIComponent(imdbId)}?external_source=imdb_id`;
const data = (await this.fetchWithTimeout(url, this.tmdbHeaders())) as {
movie_results?: Array<{ vote_average?: number; vote_count?: number }>;
tv_results?: Array<{ vote_average?: number; vote_count?: number }>;
};
const hit =
(data.movie_results && data.movie_results[0]) ||
(data.tv_results && data.tv_results[0]) ||
null;
if (!hit) return null;
const rating = hit.vote_average ?? 0;
const votes = hit.vote_count ?? 0;
if (!rating && !votes) return null;
return { rating, votes };
} catch {
return null;
}
}
/**
* Picks the right API based on the title's stored IDs and returns the
* community rating + vote count + source label. Returns null on failure
* or if no suitable API is available.
*/
async fetchCommunityRating(
title: WatchLogTitle,
animeApiSource: 'jikan' | 'anilist' = 'jikan',
apiGroup: 'anime' | 'movie' | '' = '',
): Promise<{ rating: number; votes: number; source: 'mal' | 'anilist' | 'imdb' | 'tmdb' } | null> {
const hasMal = (title.malId ?? 0) > 0;
const hasAniList = (title.anilistId ?? 0) > 0;
const resolvedGroup: 'anime' | 'movie' | '' =
apiGroup || (title.type === 'Anime' ? 'anime' : title.type === 'Movie' || title.type === 'TV Show' || title.type === 'TvShow' ? 'movie' : '');
if (resolvedGroup === '') return null;
// Anime API group is bound to anime APIs.
if (resolvedGroup === 'anime') {
if (animeApiSource === 'anilist') {
if (hasAniList) {
const r = await this.fetchAniListRating(title.anilistId!);
if (r) return { ...r, source: 'anilist' };
}
if (hasMal) {
const r = await this.fetchMalRating(title.malId!);
if (r) return { ...r, source: 'mal' };
}
} else {
if (hasMal) {
const r = await this.fetchMalRating(title.malId!);
if (r) return { ...r, source: 'mal' };
}
if (hasAniList) {
const r = await this.fetchAniListRating(title.anilistId!);
if (r) return { ...r, source: 'anilist' };
}
}
// Last-resort: anime without any anime-api ID falls through to IMDb/TMDB.
}
const imdbId = this.extractImdbId(title.externalLink ?? '');
if (imdbId) {
if (this.omdbApiKey) {
const r = await this.fetchOmdbRating(imdbId);
if (r) return { ...r, source: 'imdb' };
}
if (this.tmdbApiKey) {
const r = await this.fetchTmdbRatingByImdb(imdbId);
if (r) return { ...r, source: 'tmdb' };
}
}
return null;
}
// ─── Google Books (Books) ─────────────────────────────────────────────────────
/**
* Picks the largest available cover from a volume's imageLinks, falling down the
* chain extraLarge large medium small thumbnail smallThumbnail. Returns
* a direct (200) HTTPS image URL upgraded from http and stripped of the page-curl
* effect so no local caching is needed. Empty string when no image is present.
*/
private googleCoverUrl(info: GoogleVolumeInfo): string {
const links = info.imageLinks;
if (!links) return '';
const raw =
links.extraLarge ??
links.large ??
links.medium ??
links.small ??
links.thumbnail ??
links.smallThumbnail ??
'';
if (!raw) return '';
return raw.replace(/^http:/, 'https:').replace(/&edge=curl/g, '');
}
/** Normalizes Google's `publishedDate` (YYYY | YYYY-MM | YYYY-MM-DD) to a YYYY-MM-DD storage value. */
private googleReleaseDate(published?: string): string {
if (!published) return '';
if (/^\d{4}-\d{2}-\d{2}$/.test(published)) return published;
if (/^\d{4}-\d{2}$/.test(published)) return `${published}-01`;
if (/^\d{4}$/.test(published)) return `${published}-01-01`;
return published;
}
private mapGoogleVolume(item: GoogleVolume): BookSearchResult {
const info = item.volumeInfo ?? {};
const published = info.publishedDate ?? '';
const year = published ? parseInt(published.slice(0, 4), 10) || 0 : 0;
const fullTitle = info.subtitle ? `${info.title ?? ''}: ${info.subtitle}` : (info.title ?? '');
return {
title: fullTitle,
author: (info.authors ?? []).join(', '),
year,
totalPages: info.pageCount ?? 0,
coverUrl: this.googleCoverUrl(info),
googleBooksId: item.id ?? '',
releaseDate: this.googleReleaseDate(published),
url: info.infoLink ?? info.canonicalVolumeLink ?? '',
};
}
/**
* Google Books requests must always carry the user's API key the anonymous
* endpoint shares a global quota and returns 429. Throws a typed GoogleBooksError
* (no-key / rate-limited / http / parse / network) so callers can show an accurate
* message instead of a generic "check your connection". `path` is everything after
* the base, e.g. `/volumes?q=...`; the `key` param is appended here.
*/
private async googleBooksFetch(path: string): Promise<unknown> {
const key = this.googleBooksApiKey.trim();
if (!key) {
throw new GoogleBooksError('no-key', 'Google Books API key not set');
}
const sep = path.includes('?') ? '&' : '?';
const url = `${GOOGLE_BOOKS_BASE}${path}${sep}key=${encodeURIComponent(key)}`;
let timer: number | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = window.setTimeout(() => reject(new GoogleBooksError('network', 'Request timed out')), API_TIMEOUT_MS);
});
try {
const reqPromise = requestUrl({ url, throw: false }).then((r) => {
// 429 (rate limit) and 403 (quota/keyInvalid) both point at the key, not the network.
if (r.status === 429 || r.status === 403) {
throw new GoogleBooksError('rate-limited', `Google Books returned ${r.status}`, r.status);
}
if (r.status < 200 || r.status >= 300) {
throw new GoogleBooksError('http', `Google Books returned ${r.status}`, r.status);
}
try {
return r.json as unknown;
} catch {
throw new GoogleBooksError('parse', 'Failed to parse Google Books response');
}
});
return await Promise.race([reqPromise, timeoutPromise]);
} catch (err) {
if (err instanceof GoogleBooksError) throw err;
throw new GoogleBooksError('network', (err as Error)?.message ?? 'Network error');
} finally {
if (timer !== null) window.clearTimeout(timer);
}
}
/** Throws GoogleBooksError on failure (no key, rate limit, etc.) — see googleBooksErrorMessage. */
async searchGoogleBooks(query: string): Promise<BookSearchResult[]> {
const data = (await this.googleBooksFetch(
`/volumes?q=${encodeURIComponent(query)}&maxResults=10`,
)) as GoogleBooksResponse;
return (data.items ?? []).slice(0, 10).map((item) => this.mapGoogleVolume(item));
}
/** Resolves on success, throws GoogleBooksError on failure (so the test can report the real cause). */
async checkGoogleBooksConnection(): Promise<boolean> {
const data = (await this.googleBooksFetch('/volumes?q=tolkien&maxResults=1')) as GoogleBooksResponse;
return Array.isArray(data.items);
}
// ─── Jikan (Manga) ──────────────────────────────────────────────────────────
private mapJikanManga(m: JikanMangaItem): MangaSearchResult {
const yearStr = m.published?.from ?? '';
const year = yearStr ? parseInt(yearStr.slice(0, 4), 10) || 0 : 0;
return {
malId: m.mal_id,
title: m.title_english ?? m.title,
author: (m.authors ?? []).map((a) => a.name).join(', '),
year,
totalChapters: m.chapters ?? 0,
totalVolumes: m.volumes ?? 0,
coverUrl: m.images?.jpg?.image_url ?? '',
releaseDate: m.published?.from ? (m.published.from.split('T')[0] ?? '') : '',
url: m.url ?? `https://myanimelist.net/manga/${m.mal_id}`,
};
}
async searchManga(query: string): Promise<MangaSearchResult[]> {
const url = `${JIKAN_BASE}/manga?q=${encodeURIComponent(query)}&limit=10`;
try {
const data = (await this.throttleJikan(() => this.fetchWithTimeout(url))) as { data?: JikanMangaItem[] };
return (data.data ?? []).map((m) => this.mapJikanManga(m));
} catch {
return [];
}
}
/**
* Detail lookup for a single manga (Jikan /manga/{mal_id}). Search-list results
* return null chapters/volumes for ongoing titles, so totals are fetched here on
* selection. Shares the existing Jikan throttle. (Jikan also exposes a richer
* `/manga/{id}/full` endpoint not used here.)
*/
async getMangaByMalId(malId: number): Promise<MangaSearchResult | null> {
const url = `${JIKAN_BASE}/manga/${malId}`;
try {
const data = (await this.throttleJikan(() => this.fetchWithTimeout(url))) as { data?: JikanMangaItem };
if (!data.data) return null;
return this.mapJikanManga(data.data);
} catch {
return null;
}
}
/**
* Detail lookup for a single Google Books volume. The search endpoint sometimes
* omits pageCount and only returns thumbnail-sized images, so on selection this
* resolves the full volume (better page count + larger cover). Returns null when
* the volume can't be fetched.
*/
async getGoogleBookById(volumeId: string): Promise<BookSearchResult | null> {
if (!volumeId) return null;
const item = (await this.googleBooksFetch(`/volumes/${encodeURIComponent(volumeId)}`)) as GoogleVolume;
if (!item || !item.id) return null;
return this.mapGoogleVolume(item);
}
async checkTmdbConnection(): Promise<boolean> {
if (!this.tmdbApiKey) return false;
try {

251
src/CommunityRating.ts Normal file
View file

@ -0,0 +1,251 @@
import { Notice } from 'obsidian';
import type WatchLogPlugin from './main';
import type { WatchLogTitle, AnimeSearchResult } from './types';
import { formatVoteCount, getApiGroupForType } from './types';
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
const SOURCE_LABELS: Record<string, string> = {
imdb: 'IMDb',
mal: 'MAL',
anilist: 'AniList',
tmdb: 'TMDB',
};
function formatScore(rating: number, source: string): string {
if (source === 'anilist') return `${Math.round(rating)}%`;
return rating.toFixed(rating >= 10 ? 0 : 1);
}
export function renderCommunityRating(
parent: HTMLElement,
plugin: WatchLogPlugin,
titleId: string,
onUpdated?: () => void,
): void {
const title = plugin.dataManager.getTitle(titleId);
if (!title) return;
parent.createSpan({ cls: 'wl-rating-divider' });
const badge = parent.createDiv({ cls: 'wl-community-badge' });
const hasRating = !!title.communitySource && (title.communityRating ?? 0) > 0;
if (hasRating) {
const source = title.communitySource ?? '';
const label = SOURCE_LABELS[source] ?? source.toUpperCase();
badge.createSpan({
cls: `wl-community-source wl-community-source--${source}`,
text: label,
});
badge.createSpan({
cls: 'wl-community-score',
text: formatScore(title.communityRating ?? 0, source),
});
if ((title.communityVotes ?? 0) > 0) {
badge.createSpan({
cls: 'wl-community-votes',
text: `(${formatVoteCount(title.communityVotes ?? 0)})`,
});
}
} else {
badge.createSpan({ cls: 'wl-community-empty', text: 'No community rating' });
}
const refreshBtn = badge.createEl('button', {
cls: 'wl-community-refresh',
text: '⟳',
attr: { title: 'Refresh community rating', type: 'button' },
});
refreshBtn.addEventListener('click', (e) => {
e.stopPropagation();
void refreshCommunityRating(plugin, titleId, refreshBtn, true).then(() => {
if (onUpdated) onUpdated();
});
});
}
/**
* Auto-refresh: triggers a background fetch when data is stale (30 days)
* or has never been fetched. For anime titles, only uses the API that
* matches the current animeApiSource setting AND that has its ID already
* present on the title no search-by-name in the background. For non-anime
* titles, uses the standard fetchCommunityRating routing.
*/
const pendingRatingRefreshes: Set<string> = new Set();
export function maybeAutoRefreshCommunityRating(
plugin: WatchLogPlugin,
titleId: string,
onUpdated?: () => void,
): void {
const title = plugin.dataManager.getTitle(titleId);
if (!title) return;
const last = title.communityRatingLastFetched ?? '';
if (last) {
const ts = Date.parse(last);
if (!isNaN(ts) && Date.now() - ts < THIRTY_DAYS_MS) return;
}
if (!hasFetchableSource(title, plugin.settings.animeApiSource ?? 'jikan', plugin.settings.typeApiMapping)) return;
if (pendingRatingRefreshes.has(titleId)) return;
pendingRatingRefreshes.add(titleId);
void backgroundRefresh(plugin, titleId)
.then((ok) => {
if (ok && onUpdated) onUpdated();
})
.finally(() => {
pendingRatingRefreshes.delete(titleId);
});
}
function hasFetchableSource(
title: WatchLogTitle,
animeApiSource: 'jikan' | 'anilist',
mapping: Record<string, 'anime' | 'movie' | ''> | undefined,
): boolean {
const group = getApiGroupForType(title.type, mapping);
if (group === '') return false;
if (group === 'anime') {
if (animeApiSource === 'anilist') return (title.anilistId ?? 0) > 0;
return (title.malId ?? 0) > 0;
}
return !!(title.externalLink ?? '').match(/tt\d+/);
}
async function backgroundRefresh(plugin: WatchLogPlugin, titleId: string): Promise<boolean> {
const title = plugin.dataManager.getTitle(titleId);
if (!title) return false;
const animeSource = plugin.settings.animeApiSource ?? 'jikan';
const group = getApiGroupForType(title.type, plugin.settings.typeApiMapping);
const result = await plugin.apiService.fetchCommunityRating(title, animeSource, group);
if (!result) return false;
plugin.dataManager.updateCommunityRating(titleId, result.rating, result.votes, result.source);
return true;
}
/**
* Manual refresh:
* - For anime: respects current animeApiSource. If the title is missing
* the ID for that source, searches by name on that API; on a reasonable
* match, stores the new ID + updates externalLink to the new source.
* - For non-anime: standard fetchCommunityRating.
* Saves changes via updateTitle (full save) when IDs/externalLink change;
* otherwise the lighter updateCommunityRating path.
*/
export async function refreshCommunityRating(
plugin: WatchLogPlugin,
titleId: string,
btnEl: HTMLElement | null,
showNotice: boolean,
): Promise<boolean> {
let title = plugin.dataManager.getTitle(titleId);
if (!title) return false;
if (btnEl) btnEl.addClass('is-loading');
try {
const animeSource = plugin.settings.animeApiSource ?? 'jikan';
const group = getApiGroupForType(title.type, plugin.settings.typeApiMapping);
if (group === '') {
if (showNotice) {
new Notice(`No API configured for type "${title.type}". Configure it in Settings → API.`);
}
return false;
}
if (group === 'anime') {
const wantsAniList = animeSource === 'anilist';
const hasPreferredId = wantsAniList
? (title.anilistId ?? 0) > 0
: (title.malId ?? 0) > 0;
if (!hasPreferredId) {
// Search by name on the preferred API.
const matched = await searchForMatch(plugin, title, wantsAniList);
if (!matched) {
if (showNotice) {
new Notice(`Could not find matching title on ${wantsAniList ? 'AniList' : 'MAL'}.`);
}
return false;
}
// Persist the new ID + externalLink before fetching the rating.
const fresh = plugin.dataManager.getTitle(titleId);
if (!fresh) return false;
if (wantsAniList) {
fresh.anilistId = matched.anilistId ?? 0;
fresh.externalLink = `https://anilist.co/anime/${fresh.anilistId}`;
} else {
fresh.malId = matched.malId;
fresh.externalLink = `https://myanimelist.net/anime/${fresh.malId}`;
}
await plugin.dataManager.updateTitle(fresh);
title = fresh;
} else {
// We already have the right ID — still make sure externalLink
// matches the preferred source so the UI link is consistent.
const expectedLink = wantsAniList
? `https://anilist.co/anime/${title.anilistId}`
: `https://myanimelist.net/anime/${title.malId}`;
if (title.externalLink !== expectedLink) {
title.externalLink = expectedLink;
await plugin.dataManager.updateTitle(title);
}
}
}
const result = await plugin.apiService.fetchCommunityRating(title, animeSource, group);
if (!result) {
if (showNotice) new Notice('Could not fetch community rating.');
return false;
}
plugin.dataManager.updateCommunityRating(
titleId,
result.rating,
result.votes,
result.source,
);
if (showNotice) {
const label = SOURCE_LABELS[result.source] ?? result.source;
new Notice(`Rating updated from ${label}.`);
}
return true;
} finally {
if (btnEl) btnEl.removeClass('is-loading');
}
}
function normalizeName(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}
function isReasonableMatch(needle: string, hay: string): boolean {
const a = normalizeName(needle);
const b = normalizeName(hay);
if (!a || !b) return false;
if (a === b) return true;
if (a.includes(b) || b.includes(a)) return true;
const aWords = new Set(a.split(' ').filter((w) => w.length > 2));
const bWords = new Set(b.split(' ').filter((w) => w.length > 2));
if (aWords.size === 0 || bWords.size === 0) return false;
let shared = 0;
for (const w of aWords) if (bWords.has(w)) shared++;
const minSize = Math.min(aWords.size, bWords.size);
return shared / minSize >= 0.5;
}
async function searchForMatch(
plugin: WatchLogPlugin,
title: WatchLogTitle,
wantsAniList: boolean,
): Promise<AnimeSearchResult | null> {
const results = wantsAniList
? await plugin.apiService.searchAniList(title.title)
: await plugin.apiService.searchAnime(title.title);
if (!results.length) return null;
const top = results[0]!;
if (isReasonableMatch(title.title, top.title)) return top;
// Try a few more results in case the first isn't the best.
for (let i = 1; i < Math.min(results.length, 5); i++) {
if (isReasonableMatch(title.title, results[i]!.title)) return results[i]!;
}
return null;
}

View file

@ -28,28 +28,44 @@ function titlesToCsv(titles: WatchLogTitle[]): string {
return [header, ...rows].join('\n');
}
function parseCsvRow(row: string): string[] {
const fields: string[] = [];
/** Full CSV parser: handles quoted fields with embedded newlines, escaped quotes, comma delimiters. */
function parseCSV(text: string): string[][] {
const rows: string[][] = [];
let row: 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++;
// Normalize CRLF to LF
const t = text.replace(/\r\n?/g, '\n');
for (let i = 0; i < t.length; i++) {
const ch = t[i];
if (inQuotes) {
if (ch === '"') {
if (t[i + 1] === '"') { cur += '"'; i++; }
else { inQuotes = false; }
} else {
inQuotes = !inQuotes;
cur += ch;
}
} else if (ch === ',' && !inQuotes) {
fields.push(cur);
cur = '';
} else {
cur += ch;
if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
row.push(cur); cur = '';
} else if (ch === '\n') {
row.push(cur); cur = '';
rows.push(row); row = [];
} else {
cur += ch;
}
}
}
fields.push(cur);
return fields;
// Final field/row
if (cur.length > 0 || row.length > 0) {
row.push(cur);
rows.push(row);
}
// Drop trailing fully-empty rows
while (rows.length > 0 && rows[rows.length - 1]!.every((c) => c === '')) rows.pop();
return rows;
}
// ── Date parsing ─────────────────────────────────────────────────────────────
@ -136,6 +152,7 @@ const WL_IMPORT_FIELDS: WlFieldDef[] = [
{ 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'] },
{ key: 'notes', label: 'Notes', autoDetect: ['notes', 'note', 'comment', 'comments'] },
];
function autoMap(headers: string[]): Record<string, string> {
@ -148,19 +165,17 @@ function autoMap(headers: string[]): Record<string, string> {
}
function applyMapping(
lines: string[],
rows: 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());
if (rows.length < 2) return [];
const headers = (rows[0] ?? []).map((h) => h.trim());
const existingTitles = dataManager.getTitles();
return lines.slice(1)
.filter((l) => l.trim())
.map((line) => {
const values = parseCsvRow(line);
return rows.slice(1)
.filter((r) => r.some((c) => c.trim()))
.map((values) => {
const get = (fieldKey: string): string => {
const colName = mapping[fieldKey];
if (!colName) return '';
@ -179,7 +194,10 @@ function applyMapping(
const priorityVal = get('priority');
if (priorityVal) entry.priority = priorityVal;
const ratingVal = get('rating');
if (ratingVal) entry.rating = parseFloat(ratingVal) || 0;
if (ratingVal) {
const r = parseFloat(ratingVal) || 0;
entry.rating = Math.max(0, Math.min(5, r));
}
const epsVal = get('totalEpisodes');
if (epsVal) entry.totalEpisodes = parseInt(epsVal) || 0;
const durVal = get('episodeDuration');
@ -192,11 +210,14 @@ function applyMapping(
if (releaseDateRaw) { const d = parseCsvDate(releaseDateRaw); if (d) entry.releaseDate = d; }
const linkVal = get('externalLink');
if (linkVal) entry.externalLink = linkVal;
const notesVal = get('notes');
if (notesVal) entry.notes = notesVal;
// 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;
&& !entry.dateStarted && !entry.dateFinished && !entry.releaseDate && !entry.externalLink
&& !entry.notes;
if (isEmpty) return null;
const isDuplicate = !!existingTitles.find(
@ -233,7 +254,7 @@ export class CsvModal extends Modal {
private selectedIds = new Set<string>();
// Import state — shared across steps
private csvLines: string[] = [];
private csvRows: string[][] = [];
private csvHeaders: string[] = [];
private columnMapping: Record<string, string> = {};
private importRows: { entry: Partial<WatchLogTitle>; isDuplicate: boolean; selected: boolean }[] = [];
@ -347,7 +368,7 @@ export class CsvModal extends Modal {
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 a = activeDocument.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;
@ -418,9 +439,8 @@ export class CsvModal extends Modal {
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.csvRows = parseCSV(text);
this.csvHeaders = (this.csvRows[0] ?? []).map((h) => h.trim());
this.columnMapping = autoMap(this.csvHeaders);
this.showMappingStep();
};
@ -474,7 +494,7 @@ export class CsvModal extends Modal {
this.columnMapping[field.key] = selectEls[field.key]?.value ?? '';
}
// Parse rows with current column mapping
this.importRows = applyMapping(this.csvLines, this.columnMapping, this.dataManager)
this.importRows = applyMapping(this.csvRows, this.columnMapping, this.dataManager)
.map((r) => ({ ...r, selected: !r.isDuplicate }));
// Check for unknown status/type values and non-numeric ratings
@ -502,13 +522,11 @@ export class CsvModal extends Modal {
// 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 headers = (this.csvRows[0] ?? []).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 rawValues = this.csvRows[lineIdx + 1] ?? [];
const rawRating = (rawValues[ratingIdx] ?? '').trim();
if (rawRating && isNaN(parseFloat(rawRating)) && !seenRatings.has(rawRating)) {
unknownRatings.push(rawRating);
@ -658,16 +676,13 @@ export class CsvModal extends Modal {
}
// 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 headers = (this.csvRows[0] ?? []).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);
const rawValues = this.csvRows[rowIdx + 1] ?? [];
return (rawValues[ratingIdx] ?? '').trim() === k;
});
if (rawRatingKey !== undefined) {
@ -819,62 +834,62 @@ export class CsvModal extends Modal {
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++) {
try {
for (let chunkStart = 0; chunkStart < toImport.length; chunkStart += CHUNK_SIZE) {
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);
const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, toImport.length);
const chunkEntries: WatchLogTitle[] = [];
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: r.entry.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);
}
chunkEntries.push(entry);
}
this.plugin.importProgress = { current: added + 1, total: toImport.length, cancel: cancelFn };
await this.dataManager.addTitleSilent(entry);
added++;
if (chunkEntries.length === 0) break;
await this.dataManager.addTitleBatch(chunkEntries);
added += chunkEntries.length;
this.plugin.importProgress = { current: added, total: toImport.length, cancel: cancelFn };
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}`;
}
} finally {
this.isImporting = false;
this.plugin.importProgress = null;
this.dataManager.notifyChange();
}
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`;
}
@ -884,7 +899,6 @@ export class CsvModal extends Modal {
}
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`;
}

View file

@ -1,4 +1,4 @@
import { App, Component, MarkdownRenderer, Modal, Notice, Platform, TFile, normalizePath, Setting } from 'obsidian';
import { App, Component, MarkdownRenderer, Modal, Notice, TFile, normalizePath, Setting } from 'obsidian';
import type WatchLogPlugin from './main';
import type { DataManager } from './DataManager';
import type { CustomListColumn, CustomListRow, CustomList } from './types';
@ -9,11 +9,25 @@ import { ConfirmModal } from './ConfirmModal';
// ─────────────────────────────────────────────────────────────────────────────
export class CustomListManager {
/** Names of lists whose ## Data JSON is corrupt — never overwrite these. */
corruptLists: Set<string> = new Set();
/** Per-list save queue: serializes writes to the same list file so table
* and notes saves cannot race and clobber each other. */
private saveQueues: Map<string, Promise<void>> = new Map();
constructor(
private readonly app: App,
private readonly plugin: WatchLogPlugin,
) {}
private async saveSerialized(listName: string, saveFn: () => Promise<void>): Promise<void> {
const prev = this.saveQueues.get(listName) ?? Promise.resolve();
const next = prev.then(saveFn).catch(e => console.error('[WL] Save error for list:', listName, e));
this.saveQueues.set(listName, next);
await next;
}
get folderPath(): string {
return this.plugin.settings.customListsFolder || 'WatchLog/CustomLists';
}
@ -43,10 +57,13 @@ export class CustomListManager {
try {
const content = await this.app.vault.read(file);
return this.parse(name, content);
} catch { return null; }
} catch (e) {
console.warn('[WL] Failed to read custom list', name, e);
return null;
}
}
private parse(name: string, content: string): CustomList {
private parse(name: string, content: string): CustomList | null {
const notesMatch = content.match(/## Notes\n([\s\S]*?)(?=\n## |\s*$)/);
const notes = (notesMatch?.[1] ?? '').trim();
@ -68,48 +85,61 @@ export class CustomListManager {
typeof r === 'object' && r !== null && 'id' in r,
);
}
} catch { /* invalid JSON */ }
} catch (e) {
console.warn('[WL] Custom list JSON parse failed for', name, e);
new Notice(`Custom list "${name}" has corrupt data and cannot be loaded.`);
this.corruptLists.add(name);
return null;
}
}
return { name, columns, rows, notes };
}
async saveList(list: CustomList): Promise<void> {
await this.ensureFolder();
const path = normalizePath(`${this.folderPath}/${list.name}.md`);
const content = this.serialize(list);
const existing = this.app.vault.getAbstractFileByPath(path);
try {
if (existing instanceof TFile) {
await this.app.vault.modify(existing, content);
} else {
await this.app.vault.create(path, content);
await this.saveSerialized(list.name, async () => {
if (this.corruptLists.has(list.name)) {
console.warn('[WL] Refusing to save corrupt custom list', list.name);
return;
}
} catch (e) {
console.error('WatchLog: failed to save custom list', e);
}
await this.ensureFolder();
const path = normalizePath(`${this.folderPath}/${list.name}.md`);
const content = this.serialize(list);
const existing = this.app.vault.getAbstractFileByPath(path);
try {
if (existing instanceof TFile) {
await this.app.vault.modify(existing, content);
} else {
await this.app.vault.create(path, content);
}
} catch (e) {
console.error('WatchLog: failed to save custom list', e);
}
});
}
/** Saves only the ## Notes section without touching ## Data JSON. */
async saveNotes(name: string, notes: string): Promise<void> {
const path = normalizePath(`${this.folderPath}/${name}.md`);
const file = this.app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return;
try {
const content = await this.app.vault.read(file);
const notesMarker = '## Notes\n';
const dataMarker = '\n## Data\n';
const notesIdx = content.indexOf(notesMarker);
if (notesIdx === -1) return;
const afterNotes = notesIdx + notesMarker.length;
const dataIdx = content.indexOf(dataMarker, afterNotes);
const updated = dataIdx !== -1
? content.slice(0, afterNotes) + notes + content.slice(dataIdx)
: content.slice(0, afterNotes) + notes + '\n';
await this.app.vault.modify(file, updated);
} catch (e) {
console.error('WatchLog: failed to save notes', e);
}
await this.saveSerialized(name, async () => {
const path = normalizePath(`${this.folderPath}/${name}.md`);
const file = this.app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return;
try {
const content = await this.app.vault.read(file);
const notesMarker = '## Notes\n';
const dataMarker = '\n## Data\n';
const notesIdx = content.indexOf(notesMarker);
if (notesIdx === -1) return;
const afterNotes = notesIdx + notesMarker.length;
const dataIdx = content.indexOf(dataMarker, afterNotes);
const updated = dataIdx !== -1
? content.slice(0, afterNotes) + notes + content.slice(dataIdx)
: content.slice(0, afterNotes) + notes + '\n';
await this.app.vault.modify(file, updated);
} catch (e) {
console.error('WatchLog: failed to save notes', e);
}
});
}
private serialize(list: CustomList): string {
@ -157,58 +187,64 @@ function renderColumnList(
listEl: HTMLElement,
cols: CustomListColumn[],
existingRows: CustomListRow[],
manager: CustomListManager,
app: App,
onReorder: (from: number, to: number) => void,
onDelete: (idx: number) => void,
onTypeChange: () => void,
onAdd?: () => void,
): void {
listEl.empty();
listEl.addClass('wl-editcol-card-grid');
let dragFromIndex = -1;
if (cols.length === 0) {
listEl.createDiv({ cls: 'wl-empty-state', text: 'No custom columns. Click "+ Add column" to add one.' });
return;
}
// Permanent auto-included columns — non-editable, non-deletable, non-draggable.
const renderAutoCard = (label: string): void => {
const card = listEl.createDiv({ cls: 'wl-editcol-card wl-editcol-card-auto' });
card.setAttribute('data-auto', label);
const nameWrap = card.createDiv({ cls: 'wl-editcol-card-name-wrap' });
nameWrap.createDiv({ cls: 'wl-editcol-card-name-label', text: label });
card.createDiv({ cls: 'wl-editcol-card-type-locked', text: 'Auto' });
card.createDiv({ cls: 'wl-editcol-card-spacer' });
};
renderAutoCard('#');
renderAutoCard('Name');
// User columns
cols.forEach((col, idx) => {
const row = listEl.createDiv({ cls: 'wl-editcols-row' });
const card = listEl.createDiv({ cls: 'wl-editcol-card' });
// ── Header line (handle | name | type | B/I toggles | × delete) ──
const header = row.createDiv({ cls: 'wl-editcols-row-header' });
// Drag handle
const handle = header.createDiv({ cls: 'wl-editcols-handle', text: '⠿' });
handle.addEventListener('mousedown', () => row.setAttribute('draggable', 'true'));
row.addEventListener('dragstart', (e) => {
// Drag handle (top of card)
const handle = card.createDiv({ cls: 'wl-editcol-card-handle', text: '⠿' });
handle.title = 'Drag to reorder';
handle.addEventListener('mousedown', () => card.setAttribute('draggable', 'true'));
card.addEventListener('dragstart', (e) => {
dragFromIndex = idx;
e.dataTransfer?.setData('text/plain', String(idx));
row.addClass('wl-cl-dragging');
card.addClass('wl-cl-dragging');
});
row.addEventListener('dragend', () => {
row.removeClass('wl-cl-dragging');
row.setAttribute('draggable', 'false');
card.addEventListener('dragend', () => {
card.removeClass('wl-cl-dragging');
card.setAttribute('draggable', 'false');
});
row.addEventListener('dragover', (e) => { e.preventDefault(); row.addClass('wl-cl-drag-over'); });
row.addEventListener('dragleave', () => row.removeClass('wl-cl-drag-over'));
row.addEventListener('drop', (e) => {
card.addEventListener('dragover', (e) => { e.preventDefault(); card.addClass('wl-cl-drag-over'); });
card.addEventListener('dragleave', () => card.removeClass('wl-cl-drag-over'));
card.addEventListener('drop', (e) => {
e.preventDefault();
row.removeClass('wl-cl-drag-over');
card.removeClass('wl-cl-drag-over');
const from = dragFromIndex;
const to = idx;
if (from !== to && from >= 0) onReorder(from, to);
});
// Name input
const nameInput = header.createEl('input', {
cls: 'wl-modal-input wl-editcols-name',
const nameInput = card.createEl('input', {
cls: 'wl-modal-input wl-editcol-card-name',
attr: { type: 'text', placeholder: 'Column name', value: col.label },
});
nameInput.addEventListener('input', () => { col.label = nameInput.value; });
// Type select
const typeSelect = header.createEl('select', { cls: 'wl-select wl-editcols-type' });
const typeSelect = card.createEl('select', { cls: 'wl-select wl-editcol-card-type' });
for (const t of ['text', 'number', 'select'] as const) {
const opt = typeSelect.createEl('option', {
value: t,
@ -221,11 +257,11 @@ function renderColumnList(
onTypeChange();
});
// Bold/Italic toggles (text/number only) — stay in header
// Bold/Italic toggles (text/number only)
if (col.type === 'text' || col.type === 'number') {
const toggles = header.createDiv({ cls: 'wl-editcols-toggles' });
const toggles = card.createDiv({ cls: 'wl-editcol-card-toggles' });
const boldBtn = toggles.createEl('button', {
cls: `wl-btn wl-btn-sm wl-editcols-toggle wl-editcols-bold-btn${col.bold ? ' is-active' : ''}`,
cls: `wl-btn wl-btn-sm wl-editcol-card-toggle wl-editcols-bold-btn${col.bold ? ' is-active' : ''}`,
text: 'B',
});
boldBtn.addEventListener('click', () => {
@ -233,18 +269,30 @@ function renderColumnList(
boldBtn.toggleClass('is-active', !!col.bold);
});
const italicBtn = toggles.createEl('button', {
cls: `wl-btn wl-btn-sm wl-editcols-toggle wl-editcols-italic-btn${col.italic ? ' is-active' : ''}`,
cls: `wl-btn wl-btn-sm wl-editcol-card-toggle wl-editcols-italic-btn${col.italic ? ' is-active' : ''}`,
text: 'I',
});
italicBtn.addEventListener('click', () => {
col.italic = !col.italic;
italicBtn.toggleClass('is-active', !!col.italic);
});
if (col.type === 'number') {
const autoTimeBtn = toggles.createEl('button', {
cls: `wl-btn wl-btn-sm wl-editcol-card-toggle wl-editcols-autotime-btn${col.autoTime ? ' is-active' : ''}`,
text: '⏱',
attr: { title: 'Auto-populate with remaining watch time from Watchlist' },
});
autoTimeBtn.addEventListener('click', () => {
col.autoTime = !col.autoTime;
autoTimeBtn.toggleClass('is-active', !!col.autoTime);
});
}
}
// Delete column button — always at far right of header, clearly separated
const delBtn = header.createEl('button', {
cls: 'wl-btn wl-btn-sm wl-editcols-del',
// Delete card button
const delBtn = card.createEl('button', {
cls: 'wl-btn wl-btn-sm wl-editcol-card-del',
text: '×',
});
delBtn.title = 'Delete this column';
@ -262,29 +310,32 @@ function renderColumnList(
}
});
// ── Options area (select type only) — below the header line ──
// Options block (select type only)
if (col.type === 'select') {
const optsArea = row.createDiv({ cls: 'wl-editcols-opts-area' });
const optsArea = card.createDiv({ cls: 'wl-editcol-card-opts' });
const renderOpts = (): void => {
optsArea.empty();
(col.options ?? []).forEach((opt, oi) => {
const optRow = optsArea.createDiv({ cls: 'wl-editcols-opt-row' });
const optRow = optsArea.createDiv({ cls: 'wl-editcol-card-opt-row' });
const optInput = optRow.createEl('input', {
cls: 'wl-modal-input',
attr: { type: 'text', value: opt, placeholder: 'Option label' },
cls: 'wl-modal-input wl-editcol-card-opt-input',
attr: { type: 'text', value: opt, placeholder: 'Option' },
});
optInput.addEventListener('input', () => {
if (!col.options) col.options = [];
col.options[oi] = optInput.value;
});
const delOpt = optRow.createEl('button', {
cls: 'wl-btn wl-btn-sm wl-editcols-opt-del',
cls: 'wl-btn wl-btn-sm wl-editcol-card-opt-del',
text: '×',
attr: { title: 'Remove this option' },
});
delOpt.addEventListener('click', () => { col.options?.splice(oi, 1); renderOpts(); });
});
const addOpt = optsArea.createEl('button', { cls: 'wl-btn wl-btn-sm', text: '+ add option' });
const addOpt = optsArea.createEl('button', {
cls: 'wl-btn wl-btn-sm wl-editcol-card-opt-add',
text: '+ option',
});
addOpt.addEventListener('click', () => {
if (!col.options) col.options = [];
col.options.push('');
@ -294,6 +345,13 @@ function renderColumnList(
renderOpts();
}
});
// Add-column card (last in row)
if (onAdd) {
const addCard = listEl.createDiv({ cls: 'wl-editcol-card wl-editcol-card-add' });
addCard.createDiv({ cls: 'wl-editcol-card-add-label', text: '+ add column' });
addCard.addEventListener('click', () => onAdd());
}
}
// ─────────────────────────────────────────────────────────────────────────────
@ -323,6 +381,7 @@ export class EditColumnsModal extends Modal {
}
onOpen(): void {
this.modalEl.addClass('wl-editcol-modal');
this.titleEl.setText(`Edit Columns — ${this.list.name}`);
this.contentEl.addClass('wl-editcols-modal');
this.renderBody();
@ -335,21 +394,6 @@ export class EditColumnsModal extends Modal {
this.listEl = this.contentEl.createDiv({ cls: 'wl-editcols-list' });
this.renderCols();
const addBtn = this.contentEl.createEl('button', {
cls: 'wl-btn wl-editcols-add-btn',
text: '+ add column',
});
addBtn.addEventListener('click', () => {
this.cols.push({
id: this.manager.generateColId([...this.list.columns.filter(c => !c.locked), ...this.cols]),
label: '',
type: 'text',
bold: false,
italic: false,
});
this.renderBody();
});
const footer = this.contentEl.createDiv({ cls: 'wl-modal-btn-row wl-editcols-footer' });
const cancelBtn = footer.createEl('button', { cls: 'wl-btn', text: 'Cancel' });
cancelBtn.addEventListener('click', () => this.close());
@ -362,7 +406,6 @@ export class EditColumnsModal extends Modal {
this.listEl,
this.cols,
this.list.rows,
this.manager,
this.app,
(from, to) => {
const [moved] = this.cols.splice(from, 1);
@ -371,6 +414,16 @@ export class EditColumnsModal extends Modal {
},
(idx) => { this.cols.splice(idx, 1); this.renderBody(); },
() => this.renderBody(),
() => {
this.cols.push({
id: this.manager.generateColId([...this.list.columns.filter(c => !c.locked), ...this.cols]),
label: '',
type: 'text',
bold: false,
italic: false,
});
this.renderBody();
},
);
}
@ -409,6 +462,7 @@ export class DefaultColumnsModal extends Modal {
}
onOpen(): void {
this.modalEl.addClass('wl-editcol-modal');
this.titleEl.setText('Default columns');
this.contentEl.addClass('wl-editcols-modal');
this.renderBody();
@ -426,21 +480,6 @@ export class DefaultColumnsModal extends Modal {
this.listEl = this.contentEl.createDiv({ cls: 'wl-editcols-list' });
this.renderCols();
const addBtn = this.contentEl.createEl('button', {
cls: 'wl-btn wl-editcols-add-btn',
text: '+ add column',
});
addBtn.addEventListener('click', () => {
this.cols.push({
id: this.manager.generateColId(this.cols),
label: '',
type: 'text',
bold: false,
italic: false,
});
this.renderBody();
});
const footer = this.contentEl.createDiv({ cls: 'wl-modal-btn-row wl-editcols-footer' });
const cancelBtn = footer.createEl('button', { cls: 'wl-btn', text: 'Cancel' });
cancelBtn.addEventListener('click', () => this.close());
@ -453,7 +492,6 @@ export class DefaultColumnsModal extends Modal {
this.listEl,
this.cols,
[],
this.manager,
this.app,
(from, to) => {
const [moved] = this.cols.splice(from, 1);
@ -462,6 +500,16 @@ export class DefaultColumnsModal extends Modal {
},
(idx) => { this.cols.splice(idx, 1); this.renderBody(); },
() => this.renderBody(),
() => {
this.cols.push({
id: this.manager.generateColId(this.cols),
label: '',
type: 'text',
bold: false,
italic: false,
});
this.renderBody();
},
);
}
@ -485,7 +533,7 @@ export class DefaultColumnsModal extends Modal {
}
// ─────────────────────────────────────────────────────────────────────────────
// NotesModal — Bug 2: replaces "open raw .md" with a simple textarea modal
// NotesModal — edits a list's notes in a simple textarea modal
// ─────────────────────────────────────────────────────────────────────────────
class NotesModal extends Modal {
@ -582,7 +630,7 @@ function wrapSelection(ta: HTMLTextAreaElement, before: string, after: string):
}
// ─────────────────────────────────────────────────────────────────────────────
// ListNameModal — replaces window.prompt() (blocked in Electron)
// ListNameModal — text-input modal (window.prompt() is blocked in Electron)
// ─────────────────────────────────────────────────────────────────────────────
class ListNameModal extends Modal {
@ -631,78 +679,6 @@ class ListNameModal extends Modal {
onClose(): void { this.contentEl.empty(); }
}
// ─────────────────────────────────────────────────────────────────────────────
// MobileCellEditModal — replaces inline input editing on iOS to avoid keyboard overlay
// ─────────────────────────────────────────────────────────────────────────────
class MobileCellEditModal extends Modal {
private inputType: 'text' | 'number';
private initialValue: string;
private label: string;
private getSuggestions: (() => { title: string; type: string; status: string }[]) | undefined;
private onSave: (value: string) => void | Promise<void>;
constructor(
app: App,
label: string,
inputType: 'text' | 'number',
initialValue: string,
onSave: (value: string) => void | Promise<void>,
getSuggestions?: () => { title: string; type: string; status: string }[],
) {
super(app);
this.label = label;
this.inputType = inputType;
this.initialValue = initialValue;
this.onSave = onSave;
this.getSuggestions = getSuggestions;
}
onOpen(): void {
this.titleEl.setText(this.label);
const { contentEl } = this;
const input = contentEl.createEl('input', {
cls: 'wl-cl-cell-input wl-mobile-edit-input',
attr: { type: this.inputType, value: this.initialValue },
});
if (this.getSuggestions) {
const suggestionsEl = contentEl.createDiv({ cls: 'wl-cl-autofill-dropdown wl-mobile-suggestions' });
input.addEventListener('input', () => {
suggestionsEl.empty();
const q = input.value.toLowerCase();
if (!q) return;
const matches = this.getSuggestions!()
.filter(t => t.title.toLowerCase().includes(q))
.slice(0, 10);
for (const s of matches) {
const item = suggestionsEl.createDiv({ cls: 'wl-result-item' });
item.createDiv({ cls: 'wl-result-title', text: s.title });
item.createDiv({ cls: 'wl-result-meta', text: `${s.type} · ${s.status}` });
item.addEventListener('mousedown', (e) => {
e.preventDefault();
input.value = s.title;
suggestionsEl.empty();
});
}
});
}
const btnRow = contentEl.createDiv({ cls: 'wl-modal-btn-row' });
btnRow.createEl('button', { cls: 'wl-btn mod-cta', text: 'Save' })
.addEventListener('click', () => { void this.onSave(input.value); this.close(); });
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); void this.onSave(input.value); this.close(); }
});
window.setTimeout(() => { input.focus(); input.select(); }, 50);
}
onClose(): void { this.contentEl.empty(); }
}
// ─────────────────────────────────────────────────────────────────────────────
// CustomListsTab
// ─────────────────────────────────────────────────────────────────────────────
@ -722,6 +698,15 @@ export class CustomListsTab {
private duplicatedRowIds: Set<string> = new Set();
private _escapeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
// Cleanup functions for inline-editor document/visualViewport listeners
private activeCleanups: Array<() => void> = [];
// Generation counter to guard against stale renders / race conditions
private renderGeneration = 0;
// Per-list save queue to prevent overlapping table + notes writes
private saveQueues: Map<string, Promise<void>> = new Map();
// Kept for keyboard navigation (set at buildTable time)
private _countEl: HTMLElement | null = null;
private _tableContainer: HTMLElement | null = null;
@ -741,7 +726,33 @@ export class CustomListsTab {
return [...ordered, ...unordered];
}
/** Tear down active inline-editor listeners (called on rerender / view close). */
destroy(): void {
for (const fn of this.activeCleanups) {
try { fn(); } catch { /* ignore */ }
}
this.activeCleanups = [];
if (this._escapeKeyHandler) {
try { activeDocument.removeEventListener('keydown', this._escapeKeyHandler); } catch { /* ignore */ }
this._escapeKeyHandler = null;
}
}
addCleanup(fn: () => void): void {
this.activeCleanups.push(fn);
}
/** Serializes saves per-list so notes + table writes don't overlap. */
saveSerialized(listName: string, saveFn: () => Promise<void>): Promise<void> {
const prev = this.saveQueues.get(listName) ?? Promise.resolve();
const next = prev.then(saveFn).catch((e) => console.error('[WL] custom-list save failed:', e));
this.saveQueues.set(listName, next);
return next;
}
async render(): Promise<void> {
const gen = ++this.renderGeneration;
this.destroy();
this.container.empty();
this.container.addClass('wl-custom-lists');
@ -761,6 +772,7 @@ export class CustomListsTab {
this.currentList = await this.manager.loadList(this.activeListName);
}
if (gen !== this.renderGeneration) return; // stale render
this.container.empty();
this.buildSubTabs();
@ -828,7 +840,7 @@ export class CustomListsTab {
});
}
const addBtn = tabBar.createEl('button', { cls: 'wl-cl-sub-tab-add', text: '+' });
const addBtn = tabBar.createEl('button', { cls: 'wl-cl-sub-tab-add wl-btn-success', text: '+' });
addBtn.addEventListener('click', () => this.promptCreateList());
}
@ -906,7 +918,7 @@ export class CustomListsTab {
const countEl = header.createSpan({ cls: 'wl-results-count wl-cl-count' });
const toolbar = header.createDiv({ cls: 'wl-cl-toolbar' });
// Notes — Bug 2: opens NotesModal instead of raw file
// Notes — opens NotesModal
const notesBtn = toolbar.createEl('button', { cls: 'wl-btn wl-btn-sm', text: 'Notes' });
notesBtn.addEventListener('click', () => {
new NotesModal(
@ -945,12 +957,17 @@ export class CustomListsTab {
const colIds = new Set(cols.map(c => c.id));
for (const row of list.rows) {
for (const key of Object.keys(row)) {
if (key !== 'id' && key !== 'name' && !colIds.has(key)) {
if (key !== 'id' && key !== 'name' && key !== 'checked' && !colIds.has(key)) {
delete (row as Record<string, unknown>)[key];
}
}
}
list.columns = cols;
for (const col of cols) {
if (col.type === 'number' && col.autoTime) {
this.autoPopulateTimeColumn(list, col);
}
}
await this.manager.saveList(list);
await this.render();
},
@ -963,19 +980,25 @@ export class CustomListsTab {
cls: 'wl-search-input',
attr: { type: 'text', placeholder: 'Search...', value: this.searchQuery },
});
let searchDebounce: number | null = null;
searchInput.addEventListener('input', () => {
this.searchQuery = searchInput.value;
if (this._tableContainer && this._countEl) {
this._tableContainer.empty();
this.buildTable(this._tableContainer, list, this._countEl);
}
if (searchDebounce !== null) window.clearTimeout(searchDebounce);
searchDebounce = window.setTimeout(() => {
searchDebounce = null;
if (this._tableContainer && this._countEl) {
this._tableContainer.empty();
this.buildTable(this._tableContainer, list, this._countEl);
}
}, 250);
});
// Draft warning
view.createDiv({
cls: 'wl-cl-draft-banner',
text: '⚠ This is a draft list. Titles here are not included in any stats or counts.',
});
if (this.plugin.settings.showHintBanners) {
view.createDiv({
cls: 'wl-cl-draft-banner',
text: '⚠ This is a draft list. Titles here are not included in any stats or counts.',
});
}
// Table
const tableWrap = view.createDiv({ cls: 'wl-cl-table-wrap' });
@ -1074,7 +1097,22 @@ export class CustomListsTab {
hRow.createDiv({ cls: 'wl-cl-th wl-cl-th-tick' }); // tick column header
hRow.createDiv({ cls: 'wl-cl-th wl-cl-th-num', text: '#' });
hRow.createDiv({ cls: 'wl-cl-th', text: 'Name' });
for (const col of nonLockedCols) hRow.createDiv({ cls: 'wl-cl-th', text: col.label });
for (const col of nonLockedCols) {
const th = hRow.createDiv({ cls: 'wl-cl-th' });
th.createSpan({ text: col.label });
if (col.type === 'number' && col.autoTime) {
const refreshBtn = th.createSpan({ cls: 'wl-cl-autotime-refresh', text: '↻' });
refreshBtn.title = 'Refresh remaining time values';
refreshBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.autoPopulateTimeColumn(list, col);
void this.manager.saveList(list).then(() => {
container.empty();
this.buildTable(container, list, countEl);
});
});
}
}
hRow.createDiv({ cls: 'wl-cl-th wl-cl-th-actions' });
// Body
@ -1086,7 +1124,7 @@ export class CustomListsTab {
const tr = tbody.createDiv({ cls: `wl-cl-tr wl-cl-tr-body${isChecked ? ' wl-cl-tr-checked' : ''}` });
tr.dataset.rowId = row.id;
// Tick button (Feature 5)
// Tick button
const tickCell = tr.createDiv({ cls: 'wl-cl-td wl-cl-td-tick' });
const tickBtn = tickCell.createDiv({ cls: `wl-cl-tick-btn${isChecked ? ' is-checked' : ''}` });
tickBtn.title = isChecked ? 'Uncheck row' : 'Check row';
@ -1161,7 +1199,7 @@ export class CustomListsTab {
// Add row
const addRowEl = container.createDiv({ cls: 'wl-cl-add-row' });
addRowEl.createEl('button', { cls: 'wl-btn wl-btn-sm', text: '+ add row' })
addRowEl.createEl('button', { cls: 'wl-btn wl-btn-sm wl-btn-success', text: '+ add row' })
.addEventListener('click', () => {
const newRow: CustomListRow = { id: this.manager.generateRowId(list.rows), name: '' };
list.rows.push(newRow);
@ -1214,25 +1252,6 @@ export class CustomListsTab {
): void {
const prevVal = String((row as Record<string, string | undefined>)['name'] ?? '');
if (Platform.isMobile) {
new MobileCellEditModal(
this.plugin.app,
'Edit Name',
'text',
prevVal,
async (value) => {
const newVal = value.trim();
const isDup = this.duplicatedRowIds.has(row.id);
(row as Record<string, unknown>)['name'] = newVal;
if (isDup && newVal !== prevVal) this.duplicatedRowIds.delete(row.id);
await this.manager.saveList(list);
this.renderNameCell(cell, row, list, countEl, nonLockedCols, tableContainer);
},
() => this.dataManager.getTitles(),
).open();
return;
}
cell.empty();
cell.addClass('wl-cl-editing');
this._escapeKeyHandler = (e: KeyboardEvent) => {
@ -1244,6 +1263,11 @@ export class CustomListsTab {
};
activeDocument.addEventListener('keydown', this._escapeKeyHandler, true);
activeDocument.addEventListener('keyup', this._escapeKeyHandler, true);
const escHandler = this._escapeKeyHandler;
this.addCleanup(() => {
activeDocument.removeEventListener('keydown', escHandler, true);
activeDocument.removeEventListener('keyup', escHandler, true);
});
// Mobile viewport: scroll active element into view when virtual keyboard shrinks viewport
const vpResizeHandler = (): void => {
@ -1251,6 +1275,9 @@ export class CustomListsTab {
if (active) active.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
if (window.visualViewport) window.visualViewport.addEventListener('resize', vpResizeHandler);
this.addCleanup(() => {
if (window.visualViewport) window.visualViewport.removeEventListener('resize', vpResizeHandler);
});
window.setTimeout(() => cell.scrollIntoView({ block: 'nearest', behavior: 'smooth' }), 50);
const wrap = cell.createDiv({ cls: 'wl-cl-autofill-wrap' });
@ -1270,8 +1297,14 @@ export class CustomListsTab {
clearDropdown();
const q = input.value.toLowerCase();
if (!q) return;
const suggestions = this.dataManager.getTitles()
.filter(t => t.title.toLowerCase().includes(q))
// Suggestions come from both the Watchlist and the Reading list (Books + Manga).
const reading = this.plugin.readingDataManager;
const suggestions: { title: string; meta: string }[] = [
...this.dataManager.getTitles().map(t => ({ title: t.title, meta: `${t.type} · ${t.status}` })),
...reading.getBooks().map(b => ({ title: b.title, meta: `Book · ${b.status}` })),
...reading.getMangaList().map(m => ({ title: m.title, meta: `Manga · ${m.status}` })),
]
.filter(s => s.title.toLowerCase().includes(q))
.slice(0, 10);
if (!suggestions.length) return;
const rect = input.getBoundingClientRect();
@ -1279,11 +1312,11 @@ export class CustomListsTab {
dropdown.style.top = `${rect.bottom}px`;
dropdown.style.left = `${rect.left}px`;
dropdown.style.width = `${rect.width}px`;
for (const title of suggestions) {
for (const sug of suggestions) {
const item = dropdown.createDiv({ cls: 'wl-result-item' });
item.createDiv({ cls: 'wl-result-title', text: title.title });
item.createDiv({ cls: 'wl-result-meta', text: `${title.type} · ${title.status}` });
item.addEventListener('mousedown', (e) => { e.preventDefault(); input.value = title.title; clearDropdown(); });
item.createDiv({ cls: 'wl-result-title', text: sug.title });
item.createDiv({ cls: 'wl-result-meta', text: sug.meta });
item.addEventListener('mousedown', (e) => { e.preventDefault(); input.value = sug.title; clearDropdown(); });
}
});
@ -1313,7 +1346,7 @@ export class CustomListsTab {
input.addEventListener('blur', () => void doSave(false));
input.addEventListener('keydown', (e) => {
// QoL 2: keyboard navigation in suggestion dropdown
// Keyboard navigation in suggestion dropdown
if (dropdown) {
const items = Array.from(dropdown.querySelectorAll<HTMLElement>('.wl-result-item'));
if (e.key === 'ArrowDown') {
@ -1393,29 +1426,6 @@ export class CustomListsTab {
): void {
const currentVal = (row as Record<string, string | number | undefined>)[col.id];
if (Platform.isMobile && col.type !== 'select') {
const initialStr = currentVal !== undefined && currentVal !== null ? String(currentVal) : '';
new MobileCellEditModal(
this.plugin.app,
`Edit ${col.label}`,
col.type === 'number' ? 'number' : 'text',
initialStr,
async (value) => {
let newVal: string | number | undefined;
if (col.type === 'number') {
const n = parseFloat(value);
newVal = isNaN(n) ? undefined : n;
} else {
newVal = value === '' ? undefined : value;
}
(row as Record<string, unknown>)[col.id] = newVal;
await this.manager.saveList(list);
this.renderCustomCell(cell, row, col, list);
},
).open();
return;
}
cell.empty();
cell.addClass('wl-cl-editing');
@ -1425,6 +1435,9 @@ export class CustomListsTab {
if (active) active.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
if (window.visualViewport) window.visualViewport.addEventListener('resize', vpResizeHandler);
this.addCleanup(() => {
if (window.visualViewport) window.visualViewport.removeEventListener('resize', vpResizeHandler);
});
window.setTimeout(() => cell.scrollIntoView({ block: 'nearest', behavior: 'smooth' }), 50);
let getValue: () => string | number | undefined;
@ -1499,7 +1512,24 @@ export class CustomListsTab {
}
}
// ── Keyboard navigation (QoL 1) ────────────────────────────────────────────
// ── Auto-populate time column ──────────────────────────────────────────────
private autoPopulateTimeColumn(list: CustomList, col: CustomListColumn): void {
const titles = this.dataManager.getTitles();
for (const row of list.rows) {
const rowName = String(row['name'] ?? '').trim();
if (!rowName) continue;
const match = titles.find(t => t.title === rowName);
if (!match) {
row[col.id] = 'Not found';
continue;
}
const remainingMinutes = this.dataManager.calcTimeRemainingForModal(match);
row[col.id] = remainingMinutes;
}
}
// ── Keyboard navigation ─────────────────────────────────────────────────────
/** Returns editable cells (Name + custom) in a row, skipping # and actions. */
private getEditableCells(tr: Element): Element[] {

View file

@ -1,88 +1,244 @@
import type WatchLogPlugin from './main';
import type { DataManager } from './DataManager';
import type { TagDefinition } from './types';
import { formatTime, getThemedColor } from './types';
import type { ReadingDataManager } from './ReadingDataManager';
import type { Book, Manga, TagDefinition, WatchLogTitle } from './types';
import { formatTime, getThemedColor, getReadingTypeColor } from './types';
const RING_CIRCUMFERENCE = 2 * Math.PI * 45; // r=45
/** Aggregated reading stats for one kind (books or manga). */
interface ReadingAggregate {
left: number; // count of Reading + Plan to Read (To be released excluded)
read: number; // sum of pages (books) / chapters (manga) read
total: number; // sum of total pages / chapters
volumesRead: number;
totalVolumes: number;
}
export class DashboardTab {
private container: HTMLElement;
private plugin: WatchLogPlugin;
private dataManager: DataManager;
private readingData: ReadingDataManager;
constructor(container: HTMLElement, plugin: WatchLogPlugin, dataManager: DataManager) {
constructor(
container: HTMLElement,
plugin: WatchLogPlugin,
dataManager: DataManager,
readingData: ReadingDataManager,
) {
this.container = container;
this.plugin = plugin;
this.dataManager = dataManager;
this.readingData = readingData;
}
render(): void {
this.container.empty();
this.container.addClass('wl-dashboard');
this.renderCards();
this.renderSummaryMetrics();
this.renderSuggestions();
// Precompute shared collections once per render so each subrenderer
// doesn't trigger another full scan of the titles array.
const allTitles = this.dataManager.getTitles();
const byType = new Map<string, WatchLogTitle[]>();
for (const t of allTitles) {
const arr = byType.get(t.type);
if (arr) arr.push(t);
else byType.set(t.type, [t]);
}
const planTitles = allTitles.filter((t) => t.status === 'Plan to watch');
// Reading data sourced from ReadingDataManager so footer counts span both datasets.
const books = this.readingData.getBooks();
const manga = this.readingData.getMangaList();
// Library + Completed counts sum watch + reading (books + manga). A reading item
// only counts as completed when its status is exactly 'Completed', matching the watch side.
const titleCount = allTitles.length + books.length + manga.length;
const completedCount =
allTitles.reduce((n, t) => n + (t.status === 'Completed' ? 1 : 0), 0) +
books.reduce((n, b) => n + (b.status === 'Completed' ? 1 : 0), 0) +
manga.reduce((n, m) => n + (m.status === 'Completed' ? 1 : 0), 0);
// Reading aggregates computed once per render (not per data change).
const booksAgg = this.aggregateBooks(books);
const mangaAgg = this.aggregateManga(manga);
this.renderCards(allTitles, byType, booksAgg, mangaAgg);
this.renderSummaryMetrics(titleCount, completedCount);
this.renderSuggestions(planTitles);
this.renderRecentlyWatched();
this.renderRecentlyAdded();
}
private renderCards(): void {
private statsFor(titles: WatchLogTitle[]): { watched: number; total: number } {
const EXCLUDED = new Set(['Dropped', 'To be released']);
let total = 0;
let watched = 0;
for (const t of titles) {
if (EXCLUDED.has(t.status)) continue;
total++;
if (t.status === 'Completed') watched++;
}
return { watched, total };
}
private renderCards(
allTitles: WatchLogTitle[],
byType: Map<string, WatchLogTitle[]>,
booksAgg: ReadingAggregate,
mangaAgg: ReadingAggregate,
): void {
const isRect = this.plugin.settings.dashboardCardStyle === 'rectangles';
const grid = this.container.createDiv({ cls: 'wl-rings-grid' });
// Always-present Total card
const totalStats = this.dataManager.getStatsByType('All');
// Unified Total / Time card, anchored at the top (spans full width).
const totalStats = this.statsFor(allTitles);
const totalPercent =
totalStats.total === 0
? 0
: Math.round((totalStats.watched / totalStats.total) * 100);
if (isRect) {
this.renderRect(grid, 'Total', '#7F77DD', totalPercent, totalStats);
} else {
this.renderRing(grid, 'Total', '#7F77DD', totalPercent, totalStats);
}
const timeWatched = this.dataManager.getTotalTimeWatched();
const timeRemaining = this.dataManager.getTotalTimeRemaining();
this.renderUnifiedCard(
grid, isRect, totalPercent, totalStats,
formatTime(timeWatched), formatTime(timeRemaining),
);
// One card per type
for (const type of this.plugin.settings.types) {
const stats = this.dataManager.getStatsByType(type.name);
const stats = this.statsFor(byType.get(type.name) ?? []);
const percent =
stats.total === 0 ? 0 : Math.round((stats.watched / stats.total) * 100);
if (isRect) {
this.renderRect(grid, type.name, type.color, percent, stats);
const item = grid.createDiv({ cls: 'wl-rect-item' });
this.fillRect(item, type.name, type.color, percent, stats);
// Reserve the sublabel line so type cards match the taller Books/Manga cards.
item.createDiv({ cls: 'wl-rect-subline', text: ' ' });
} else {
this.renderRing(grid, type.name, type.color, percent, stats);
const item = grid.createDiv({ cls: 'wl-ring-item' });
this.fillRing(item, type.name, type.color, percent, stats);
item.createDiv({ cls: 'wl-ring-subtitle wl-ring-subline', text: ' ' });
}
}
// Time Watched card
const timeWatched = this.dataManager.getTotalTimeWatched();
// Reading cards (Books + Manga) at the end of the flow.
this.renderReadingCard(grid, isRect, 'Books', booksAgg);
this.renderReadingCard(grid, isRect, 'Manga', mangaAgg);
}
// ── Unified Total / Time card ──────────────────────────────────────────────────
private renderUnifiedCard(
grid: HTMLElement,
isRect: boolean,
totalPercent: number,
totalStats: { watched: number; total: number },
timeWatchedStr: string,
timeRemainingStr: string,
): void {
// Rect cards carry an outer border; ring cards don't — match the active style.
const card = grid.createDiv({
cls: isRect ? 'wl-dash-unified wl-dash-unified-bordered' : 'wl-dash-unified',
});
// Segment 1 — Total (full card content, matching the chosen card style).
// Ring content is centered; rect content stretches so its bar spans the segment.
const seg1 = card.createDiv({ cls: isRect ? 'wl-dash-seg' : 'wl-dash-seg wl-dash-seg-center' });
if (isRect) {
this.renderTimeRect(grid, 'Time Watched', '#1D9E75', formatTime(timeWatched));
// Rect content goes straight into the segment so its border is the card's.
this.fillRect(seg1, 'Total', '#7F77DD', totalPercent, totalStats);
} else {
this.renderTimeRing(grid, 'Time Watched', '#1D9E75', formatTime(timeWatched));
// Rings are borderless; wrap so the decorative accent ring matches type cards.
this.fillRing(seg1.createDiv({ cls: 'wl-ring-item' }), 'Total', '#7F77DD', totalPercent, totalStats);
}
// Time Remaining card
const timeRemaining = this.dataManager.getTotalTimeRemaining();
// Segments 2 & 3 — Time Watched / Remaining: label + value, no bar.
this.renderTimeSegment(card, 'Time Watched', timeWatchedStr);
this.renderTimeSegment(card, 'Time Remaining', timeRemainingStr);
}
private renderTimeSegment(card: HTMLElement, label: string, value: string): void {
const seg = card.createDiv({ cls: 'wl-dash-seg wl-dash-seg-center' });
seg.createDiv({ cls: 'wl-dash-seg-label', text: label });
seg.createDiv({ cls: 'wl-dash-seg-value', text: value });
}
// ── Reading cards (Books / Manga) ──────────────────────────────────────────────
private aggregateBooks(books: Book[]): ReadingAggregate {
const agg: ReadingAggregate = { left: 0, read: 0, total: 0, volumesRead: 0, totalVolumes: 0 };
for (const b of books) {
if (b.status === 'To be released') continue; // excluded entirely
if (b.status === 'Reading' || b.status === 'Plan to Read') agg.left++;
agg.read += b.pagesRead;
agg.total += b.totalPages;
}
return agg;
}
private aggregateManga(manga: Manga[]): ReadingAggregate {
const agg: ReadingAggregate = { left: 0, read: 0, total: 0, volumesRead: 0, totalVolumes: 0 };
for (const m of manga) {
if (m.status === 'To be released') continue; // excluded entirely
if (m.status === 'Reading' || m.status === 'Plan to Read') agg.left++;
agg.read += m.chaptersRead;
agg.total += m.totalChapters;
agg.volumesRead += m.volumesRead;
agg.totalVolumes += m.totalVolumes;
}
return agg;
}
private renderReadingCard(
grid: HTMLElement,
isRect: boolean,
label: 'Books' | 'Manga',
agg: ReadingAggregate,
): void {
const percent = agg.total > 0 ? Math.round((agg.read / agg.total) * 100) : 0;
// Configured Reading type color (Books → book, Manga → manga), mirroring how
// watchlist type cards use their type color.
const color = getReadingTypeColor(label === 'Books' ? 'book' : 'manga', this.plugin.settings);
// Single inline subline under the bar: Books → pages; Manga → chapters · vol.
const subline =
label === 'Books'
? `${agg.read} / ${agg.total} pages`
: `${agg.read} / ${agg.total} chapters · ${agg.volumesRead} / ${agg.totalVolumes} vol`;
if (isRect) {
this.renderTimeRect(grid, 'Time Remaining', '#BA7517', formatTime(timeRemaining));
const item = grid.createDiv({ cls: 'wl-rect-item' });
const topRow = item.createDiv({ cls: 'wl-rect-top' });
topRow.createSpan({ cls: 'wl-rect-label', text: label });
topRow.createSpan({ cls: 'wl-rect-unwatched', text: `${agg.left} left` });
item.createDiv({ cls: 'wl-rect-value', text: `${percent}%` });
const barWrap = item.createDiv({ cls: 'wl-rect-bar-wrap' });
const bar = barWrap.createDiv({ cls: 'wl-rect-bar' });
bar.style.width = `${percent}%`;
bar.style.backgroundColor = color;
item.createDiv({ cls: 'wl-rect-subline', text: subline });
} else {
this.renderTimeRing(grid, 'Time Remaining', '#BA7517', formatTime(timeRemaining));
const item = grid.createDiv({ cls: 'wl-ring-item' });
const svg = this.makeRingSvg(percent, color, `${percent}%`, '', true);
item.appendChild(svg);
item.createDiv({ cls: 'wl-ring-label', text: label });
item.createDiv({ cls: 'wl-ring-subtitle', text: `${agg.left} left` });
item.createDiv({ cls: 'wl-ring-subtitle wl-ring-subline', text: subline });
}
}
// ── Ring (circle) cards ───────────────────────────────────────────────────────
// ── Shared card content builders ───────────────────────────────────────────────
private renderRing(
parent: HTMLElement,
label: string,
color: string,
/** Builds the progress-ring SVG. When `themeStroke` is set, the arc colour is
* applied via inline style so CSS vars (e.g. var(--wl-accent)) resolve. */
private makeRingSvg(
percent: number,
stats: { watched: number; total: number },
): void {
const item = parent.createDiv({ cls: 'wl-ring-item' });
color: string,
centerText: string,
subText: string,
themeStroke = false,
): SVGSVGElement {
const svgNS = 'http://www.w3.org/2000/svg';
const svg = activeDocument.createElementNS(svgNS, 'svg');
svg.setAttribute('viewBox', '0 0 120 120');
@ -104,102 +260,59 @@ export class DashboardTab {
arc.setAttribute('cy', '60');
arc.setAttribute('r', '45');
arc.setAttribute('fill', 'none');
arc.setAttribute('stroke', color);
if (themeStroke) arc.style.stroke = color;
else arc.setAttribute('stroke', color);
arc.setAttribute('stroke-width', '10');
arc.setAttribute('stroke-linecap', 'round');
arc.setAttribute('stroke-dasharray', String(RING_CIRCUMFERENCE));
const dashOffset = RING_CIRCUMFERENCE * (1 - percent / 100);
arc.setAttribute('stroke-dashoffset', String(dashOffset));
arc.setAttribute('stroke-dashoffset', String(RING_CIRCUMFERENCE * (1 - percent / 100)));
arc.setAttribute('transform', 'rotate(-90 60 60)');
arc.addClass('wl-ring-arc');
svg.appendChild(arc);
const text = activeDocument.createElementNS(svgNS, 'text');
text.setAttribute('x', '60');
text.setAttribute('y', '55');
text.setAttribute('y', subText ? '55' : '60');
text.setAttribute('text-anchor', 'middle');
text.setAttribute('dominant-baseline', 'middle');
text.addClass('wl-ring-percent-text');
text.textContent = `${percent}%`;
text.textContent = centerText;
svg.appendChild(text);
const subText = activeDocument.createElementNS(svgNS, 'text');
subText.setAttribute('x', '60');
subText.setAttribute('y', '72');
subText.setAttribute('text-anchor', 'middle');
subText.addClass('wl-ring-sub-text');
subText.textContent = `${stats.watched}/${stats.total}`;
svg.appendChild(subText);
if (subText) {
const sub = activeDocument.createElementNS(svgNS, 'text');
sub.setAttribute('x', '60');
sub.setAttribute('y', '72');
sub.setAttribute('text-anchor', 'middle');
sub.addClass('wl-ring-sub-text');
sub.textContent = subText;
svg.appendChild(sub);
}
return svg;
}
private fillRing(
item: HTMLElement,
label: string,
color: string,
percent: number,
stats: { watched: number; total: number },
): void {
const svg = this.makeRingSvg(percent, color, `${percent}%`, `${stats.watched}/${stats.total}`);
item.appendChild(svg);
item.createDiv({ cls: 'wl-ring-label', text: label });
const unwatched = stats.total - stats.watched;
item.createDiv({ cls: 'wl-ring-subtitle', text: `${unwatched} unwatched` });
}
private renderTimeRing(
parent: HTMLElement,
label: string,
color: string,
timeStr: string,
): void {
const item = parent.createDiv({ cls: 'wl-ring-item' });
const svgNS = 'http://www.w3.org/2000/svg';
const svg = activeDocument.createElementNS(svgNS, 'svg');
svg.setAttribute('viewBox', '0 0 120 120');
svg.setAttribute('width', '110');
svg.setAttribute('height', '110');
svg.addClass('wl-ring-svg');
const bgCircle = activeDocument.createElementNS(svgNS, 'circle');
bgCircle.setAttribute('cx', '60');
bgCircle.setAttribute('cy', '60');
bgCircle.setAttribute('r', '45');
bgCircle.setAttribute('fill', 'none');
bgCircle.setAttribute('stroke-width', '10');
bgCircle.addClass('wl-ring-track');
svg.appendChild(bgCircle);
const arc = activeDocument.createElementNS(svgNS, 'circle');
arc.setAttribute('cx', '60');
arc.setAttribute('cy', '60');
arc.setAttribute('r', '45');
arc.setAttribute('fill', 'none');
arc.setAttribute('stroke', color);
arc.setAttribute('stroke-width', '10');
arc.setAttribute('stroke-linecap', 'round');
arc.setAttribute('stroke-dasharray', String(RING_CIRCUMFERENCE));
arc.setAttribute('stroke-dashoffset', '0');
arc.setAttribute('transform', 'rotate(-90 60 60)');
arc.addClass('wl-ring-arc');
svg.appendChild(arc);
const text = activeDocument.createElementNS(svgNS, 'text');
text.setAttribute('x', '60');
text.setAttribute('y', '60');
text.setAttribute('text-anchor', 'middle');
text.setAttribute('dominant-baseline', 'middle');
text.addClass('wl-ring-time-text');
text.textContent = timeStr;
svg.appendChild(text);
item.appendChild(svg);
item.createDiv({ cls: 'wl-ring-label', text: label });
item.createDiv({ cls: 'wl-ring-subtitle', text: 'total' });
}
// ── Rectangle cards ───────────────────────────────────────────────────────────
private renderRect(
parent: HTMLElement,
private fillRect(
item: HTMLElement,
label: string,
color: string,
percent: number,
stats: { watched: number; total: number },
): void {
const item = parent.createDiv({ cls: 'wl-rect-item' });
const topRow = item.createDiv({ cls: 'wl-rect-top' });
topRow.createSpan({ cls: 'wl-rect-label', text: label });
const unwatched = stats.total - stats.watched;
@ -213,33 +326,12 @@ export class DashboardTab {
bar.style.backgroundColor = color;
}
private renderTimeRect(
parent: HTMLElement,
label: string,
color: string,
timeStr: string,
): void {
const item = parent.createDiv({ cls: 'wl-rect-item' });
const topRow = item.createDiv({ cls: 'wl-rect-top' });
topRow.createSpan({ cls: 'wl-rect-label', text: label });
topRow.createSpan({ cls: 'wl-rect-unwatched', text: 'total' });
item.createDiv({ cls: 'wl-rect-value', text: timeStr });
const barWrap = item.createDiv({ cls: 'wl-rect-bar-wrap' });
const bar = barWrap.createDiv({ cls: 'wl-rect-bar wl-rect-bar-full' });
bar.style.backgroundColor = color;
}
// ── Summary metrics ───────────────────────────────────────────────────────────
private renderSummaryMetrics(): void {
private renderSummaryMetrics(titleCount: number, completedCount: number): void {
const section = this.container.createDiv({ cls: 'wl-summary-metrics' });
const titles = this.dataManager.getTitles();
const completed = this.dataManager.getCompletedCount();
this.renderMetricRow(section, 'Titles in library', String(titles.length));
this.renderMetricRow(section, 'Completed', String(completed));
this.renderMetricRow(section, 'Titles in library', String(titleCount));
this.renderMetricRow(section, 'Completed', String(completedCount));
}
private renderMetricRow(parent: HTMLElement, label: string, value: string): void {
@ -250,8 +342,7 @@ export class DashboardTab {
// ── Suggestions ──────────────────────────────────────────────────────────────
private renderSuggestions(): void {
const planTitles = this.dataManager.getTitles().filter((t) => t.status === 'Plan to watch');
private renderSuggestions(planTitles: WatchLogTitle[]): void {
if (planTitles.length === 0) return;
const section = this.container.createDiv({ cls: 'wl-suggestions' });

View file

@ -1,15 +1,42 @@
import { App, TFile, normalizePath } from 'obsidian';
import type WatchLogPlugin from './main';
import type { WatchLogData, WatchLogTitle, WatchLogGroup, AirtimeEntry, MaybeTitle, SavedFilterPreset } from './types';
import type { WatchLogData, WatchLogTitle, WatchLogGroup, AirtimeEntry, MaybeTitle, SavedFilterPreset, WatchLogPluginSettings, Book, Manga } from './types';
import type { HistoryManager } from './HistoryManager';
import { formatHistoryDate } from './HistoryManager';
function isValidWatchLogData(data: unknown): data is WatchLogData {
return (
data !== null &&
typeof data === 'object' &&
Array.isArray((data as { titles?: unknown }).titles)
);
}
export class DataManager {
private plugin: WatchLogPlugin;
private app: App;
private data: WatchLogData;
private changeListeners: Array<() => void> = [];
private historyManager: HistoryManager | null = null;
// Debounced save state for high-frequency edits (e.g. rapid episode clicks).
// In-memory mutations are applied immediately; the disk write coalesces.
private pendingSaveTimer: number | null = null;
private pendingMdTitleIds: Set<string> = new Set();
private readonly EPISODE_SAVE_DEBOUNCE_MS = 500;
// Timestamp of the last self-initiated saveOnly. The 'raw' file watcher uses
// this to ignore the echo of our own writes, which otherwise would re-load
// data.json and notify listeners on every debounced episode-click save.
private lastSelfSaveTime = 0;
private readonly SELF_SAVE_ECHO_WINDOW_MS = 2000;
// Debounced batched save for poster URL updates. Updates land in memory
// immediately; the disk write coalesces to ~once every 5s.
private posterSaveTimer: number | null = null;
private readonly POSTER_SAVE_DEBOUNCE_MS = 5000;
// Generic queued-save debounce for cross-cutting saves (settings, drafts, lists)
private queuedSaveTimer: number | null = null;
private readonly QUEUED_SAVE_DEBOUNCE_MS = 100;
setHistoryManager(hm: HistoryManager): void {
this.historyManager = hm;
@ -22,38 +49,41 @@ export class DataManager {
}
async load(): Promise<void> {
const loaded = (await this.plugin.loadData()) as WatchLogData | null;
if (loaded) {
this.data = loaded;
} else {
this.data = { titles: [], groups: [], settings: {} };
const raw: unknown = await this.plugin.loadData();
this.data = isValidWatchLogData(raw)
? raw
: { titles: [], groups: [], settings: {} };
const changed = this.migrateData();
if (changed) {
await this.saveOnly();
}
this.migrateData();
}
private migrateData(): void {
// Ensure core arrays exist (data.json may be missing or partial on first install)
private migrateData(): boolean {
let changed = false;
if (!Array.isArray(this.data.titles)) {
this.data.titles = [];
changed = true;
}
if (!Array.isArray((this.data as unknown as Record<string, unknown>)['groups'])) {
this.data.groups = [];
changed = true;
}
// Ensure airtime array exists
if (!Array.isArray(this.data.airtime)) {
this.data.airtime = [];
changed = true;
}
for (const title of this.data.titles) {
// Ensure dateAdded is a full ISO timestamp (old data may be date-only "YYYY-MM-DD")
if (!title.dateAdded) {
title.dateAdded = new Date().toISOString();
changed = true;
} else if (!title.dateAdded.includes('T')) {
title.dateAdded = new Date(title.dateAdded).toISOString();
changed = true;
}
// Migrate lastInteracted → dateModified
if (!title.dateModified) {
const raw = title as unknown as Record<string, unknown>;
const lastInteracted = raw['lastInteracted'];
@ -62,19 +92,194 @@ export class DataManager {
} else {
title.dateModified = title.dateAdded;
}
changed = true;
}
for (const season of (title.seasons ?? [])) {
if (!Array.isArray(season.skippedEpisodes)) {
season.skippedEpisodes = [];
changed = true;
}
}
if (title.posterUrl === undefined) { title.posterUrl = ''; changed = true; }
if (title.manualPosterUrl === undefined) { title.manualPosterUrl = ''; changed = true; }
if (title.anilistId === undefined) { title.anilistId = 0; changed = true; }
if (title.communityRating === undefined) { title.communityRating = 0; changed = true; }
if (title.communityVotes === undefined) { title.communityVotes = 0; changed = true; }
if (title.communitySource === undefined) { title.communitySource = ''; changed = true; }
if (title.communityRatingLastFetched === undefined) { title.communityRatingLastFetched = ''; changed = true; }
}
if (!this.data.posterRetryDone) {
for (const title of this.data.titles) {
if (title.posterUrl === 'none') {
title.posterUrl = '';
}
}
this.data.posterRetryDone = true;
changed = true;
}
return changed;
}
/**
* Silent poster URL update. Writes the value in memory and schedules a
* debounced disk save (5s). Does NOT notify listeners the card that
* triggered the fetch updates its own <img> directly.
*/
updatePosterUrl(titleId: string, url: string): void {
const title = this.data.titles.find((t) => t.id === titleId);
if (title) {
title.posterUrl = url;
this.schedulePosterSave();
}
}
/**
* Silent community-rating update. Writes the values in memory and saves
* to disk without notifying listeners the caller is responsible for
* any targeted UI refresh.
*/
updateCommunityRating(
id: string,
rating: number,
votes: number,
source: '' | 'imdb' | 'mal' | 'anilist' | 'tmdb',
): void {
const title = this.data.titles.find((t) => t.id === id);
if (!title) return;
title.communityRating = rating;
title.communityVotes = votes;
title.communitySource = source;
title.communityRatingLastFetched = new Date().toISOString();
void this.saveOnly();
}
private schedulePosterSave(): void {
if (this.posterSaveTimer !== null) return;
this.posterSaveTimer = window.setTimeout(() => {
this.posterSaveTimer = null;
void this.saveOnly();
}, this.POSTER_SAVE_DEBOUNCE_MS);
}
/** Flush a pending debounced poster save now (e.g. on plugin unload). */
flushPosterSave(): void {
if (this.posterSaveTimer !== null) {
window.clearTimeout(this.posterSaveTimer);
this.posterSaveTimer = null;
void this.saveOnly();
}
}
/** Synchronously flush a pending poster save during plugin unload. */
flushPosterSaveSync(): void {
if (this.posterSaveTimer !== null) {
window.clearTimeout(this.posterSaveTimer);
this.posterSaveTimer = null;
this.lastSelfSaveTime = Date.now();
void this.plugin.saveData(this.data);
}
}
/** Public access to the full in-memory data snapshot. */
getData(): WatchLogData {
return this.data;
}
/** Centralized debounced save for cross-cutting paths (settings, drafts, lists). */
queueSave(): void {
if (this.queuedSaveTimer !== null) {
window.clearTimeout(this.queuedSaveTimer);
}
this.queuedSaveTimer = window.setTimeout(() => {
this.queuedSaveTimer = null;
void this.saveOnly();
}, this.QUEUED_SAVE_DEBOUNCE_MS);
}
flushQueuedSaveSync(): void {
if (this.queuedSaveTimer !== null) {
window.clearTimeout(this.queuedSaveTimer);
this.queuedSaveTimer = null;
this.lastSelfSaveTime = Date.now();
void this.plugin.saveData(this.data);
}
}
/** Save settings through the centralized data path. */
async saveSettings(settings: WatchLogPluginSettings): Promise<void> {
this.data.settings = settings;
await this.saveOnly();
}
private async saveOnly(): Promise<void> {
this.lastSelfSaveTime = Date.now();
await this.plugin.saveData(this.data);
this.lastSelfSaveTime = Date.now();
}
async save(): Promise<void> {
// An immediate save supersedes any pending debounced one — the in-memory
// state already contains those changes.
if (this.pendingSaveTimer !== null) {
window.clearTimeout(this.pendingSaveTimer);
this.pendingSaveTimer = null;
this.pendingMdTitleIds.clear();
}
await this.saveOnly();
this.notifyListeners();
}
/**
* Schedule a debounced silent save (no listener notification, no full re-render).
* Subsequent calls within the debounce window reset the timer. Callers are
* responsible for any targeted DOM updates.
*/
scheduleEpisodeSave(titleIdForMd: string): void {
this.pendingMdTitleIds.add(titleIdForMd);
if (this.pendingSaveTimer !== null) {
window.clearTimeout(this.pendingSaveTimer);
}
this.pendingSaveTimer = window.setTimeout(() => {
void this.flushPendingSave();
}, this.EPISODE_SAVE_DEBOUNCE_MS);
}
/** Flush a pending debounced save now (e.g. on view close / plugin unload). */
async flushPendingSave(): Promise<void> {
if (this.pendingSaveTimer !== null) {
window.clearTimeout(this.pendingSaveTimer);
this.pendingSaveTimer = null;
}
if (this.pendingMdTitleIds.size === 0) return;
const ids = Array.from(this.pendingMdTitleIds);
this.pendingMdTitleIds.clear();
await this.saveOnly();
for (const id of ids) {
const t = this.getTitle(id);
if (t) await this.updateMarkdownFile(t);
}
}
/** Synchronously flush a pending episode-save during plugin unload (no awaits). */
flushPendingSaveSync(): void {
if (this.pendingSaveTimer !== null) {
window.clearTimeout(this.pendingSaveTimer);
this.pendingSaveTimer = null;
}
if (this.pendingMdTitleIds.size === 0) return;
const ids = Array.from(this.pendingMdTitleIds);
this.pendingMdTitleIds.clear();
this.lastSelfSaveTime = Date.now();
void this.plugin.saveData(this.data);
for (const id of ids) {
const t = this.getTitle(id);
if (t) void this.updateMarkdownFile(t);
}
}
getTitles(): WatchLogTitle[] {
return this.data.titles ?? [];
}
@ -95,7 +300,10 @@ export class DataManager {
}
await this.updateMarkdownFile(title);
const now = new Date().toISOString();
void this.historyManager?.log(`${title.title} (${title.type}) was added on ${formatHistoryDate(now)}`);
void this.historyManager?.log(
`${title.title} (${title.type}) was added on ${formatHistoryDate(now)}`,
{ source: 'Watchlist', action: 'added', titleName: title.title },
);
}
/** Add without triggering a UI re-render. Caller must call notifyChange() after the batch. */
@ -112,6 +320,42 @@ export class DataManager {
await this.updateMarkdownFile(title);
}
/** Batch-add: push all titles into memory, save once, then write all MD files. */
async addTitleBatch(titles: WatchLogTitle[]): Promise<void> {
const upcoming: WatchLogTitle[] = [];
for (const title of titles) {
if (this.canAutoAddToUpcoming(title)) {
title.status = 'To be released';
upcoming.push(title);
}
this.data.titles.push(title);
}
await this.saveOnly();
for (const title of upcoming) {
await this.autoAddToUpcoming(title);
}
for (const title of titles) {
await this.updateMarkdownFile(title);
}
}
/** Batch-update via mutator. Single save + single notify; MD files updated. */
async batchUpdate(ids: string[], mutator: (t: WatchLogTitle) => void): Promise<void> {
const updated: WatchLogTitle[] = [];
for (const id of ids) {
const t = this.getTitle(id);
if (!t) continue;
mutator(t);
t.dateModified = new Date().toISOString();
updated.push(t);
}
if (updated.length === 0) return;
await this.save();
for (const t of updated) {
await this.updateMarkdownFile(t);
}
}
/** Remove multiple titles in one batch: single save, MD files deleted in chunks of 10. */
async removeTitlesBatch(ids: string[]): Promise<void> {
const CHUNK_SIZE = 10;
@ -125,7 +369,7 @@ export class DataManager {
group.titleIds = group.titleIds.filter((tid) => tid !== id);
}
if (this.data.airtime) {
this.data.airtime = this.data.airtime.filter((e) => e.titleId !== id);
this.data.airtime = this.data.airtime.filter((e) => e.titleId !== id || e.source === 'reading');
}
const d = this.data as unknown as Record<string, unknown>;
if (d['collapsedSeasons']) {
@ -172,24 +416,77 @@ export class DataManager {
/** Remove all Upcoming entries for a given title (e.g. when date goes past). */
async removeAirtimeEntriesForTitle(titleId: string): Promise<void> {
const before = (this.data.airtime ?? []).length;
this.data.airtime = (this.data.airtime ?? []).filter((e) => e.titleId !== titleId);
this.data.airtime = (this.data.airtime ?? []).filter((e) => e.titleId !== titleId || e.source === 'reading');
if ((this.data.airtime ?? []).length !== before) {
await this.save();
}
}
// ── Reading → Upcoming bridge ──────────────────────────────────────────────────
// Reading items (Book/Manga) live in reading.json, but their Upcoming entries
// share the same airtime list as watch titles — mirroring autoAddToUpcoming.
/**
* Auto-adds a reading item with a future release date to Upcoming, mirroring
* the watchlist `autoAddToUpcoming`. No-op if an entry already exists.
*/
async autoAddReadingToUpcoming(item: Book | Manga, kind: 'book' | 'manga'): Promise<void> {
if (!this.data.airtime) this.data.airtime = [];
if (this.data.airtime.some((e) => e.source === 'reading' && e.titleId === item.id)) return;
const entry: AirtimeEntry = {
id: this.generateReadingAirtimeId(item.id),
titleId: item.id,
source: 'reading',
readingKind: kind,
schedule: {
recurrence: 'once',
releaseDate: item.releaseDate ?? undefined,
},
dateAdded: new Date().toISOString(),
};
this.data.airtime.push(entry);
await this.plugin.saveData(this.data);
}
/** Remove all Upcoming entries for a given reading item (book/manga id). */
async removeReadingAirtimeEntries(itemId: string): Promise<void> {
const before = (this.data.airtime ?? []).length;
this.data.airtime = (this.data.airtime ?? []).filter(
(e) => !(e.source === 'reading' && e.titleId === itemId),
);
if ((this.data.airtime ?? []).length !== before) {
await this.save();
}
}
generateReadingAirtimeId(itemId: string): string {
const base = `airtime-reading-${itemId}`;
const existing = (this.data.airtime ?? []).map((e) => e.id);
if (!existing.includes(base)) return base;
let counter = 2;
while (existing.includes(`${base}-${counter}`)) counter++;
return `${base}-${counter}`;
}
async updateTitle(updated: WatchLogTitle): Promise<void> {
updated.dateModified = new Date().toISOString();
if (updated.status === 'Completed') {
updated.priority = '';
}
const idx = this.data.titles.findIndex((t) => t.id === updated.id);
if (idx >= 0) {
const old = this.data.titles[idx]!;
const now = new Date().toISOString();
if (old.rating !== updated.rating) {
void this.historyManager?.log(`${updated.title} (${updated.type}) was reviewed on ${formatHistoryDate(now)}`);
void this.historyManager?.log(
`${updated.title} (${updated.type}) was reviewed on ${formatHistoryDate(now)}`,
{ source: 'Watchlist', action: 'rating', titleName: updated.title },
);
}
if (old.status !== updated.status && updated.status === 'Completed') {
void this.historyManager?.log(
`${updated.title} (${updated.type}) was marked as watched on ${formatHistoryDate(now)}`
`${updated.title} (${updated.type}) was marked as watched on ${formatHistoryDate(now)}`,
{ source: 'Watchlist', action: 'completed', titleName: updated.title },
);
}
this.data.titles[idx] = updated;
@ -198,11 +495,42 @@ export class DataManager {
}
}
/** Update without saving or notifying listeners. Caller must call
* save() (or saveOnly + notifyChange) once after the batch completes,
* and is responsible for updating the markdown file per title if needed. */
updateTitleSilent(updated: WatchLogTitle): void {
updated.dateModified = new Date().toISOString();
if (updated.status === 'Completed') {
updated.priority = '';
}
const idx = this.data.titles.findIndex((t) => t.id === updated.id);
if (idx >= 0) {
const old = this.data.titles[idx]!;
const now = new Date().toISOString();
if (old.rating !== updated.rating) {
void this.historyManager?.log(
`${updated.title} (${updated.type}) was reviewed on ${formatHistoryDate(now)}`,
{ source: 'Watchlist', action: 'rating', titleName: updated.title },
);
}
if (old.status !== updated.status && updated.status === 'Completed') {
void this.historyManager?.log(
`${updated.title} (${updated.type}) was marked as watched on ${formatHistoryDate(now)}`,
{ source: 'Watchlist', action: 'completed', titleName: updated.title },
);
}
this.data.titles[idx] = updated;
}
}
async removeTitle(id: string): Promise<void> {
const title = this.getTitle(id);
if (title) {
const now = new Date().toISOString();
void this.historyManager?.log(`${title.title} (${title.type}) was deleted on ${formatHistoryDate(now)}`);
void this.historyManager?.log(
`${title.title} (${title.type}) was deleted on ${formatHistoryDate(now)}`,
{ source: 'Watchlist', action: 'deleted', titleName: title.title },
);
}
this.data.titles = this.data.titles.filter((t) => t.id !== id);
// Remove from any groups
@ -211,7 +539,7 @@ export class DataManager {
}
// Remove airtime entries for this title
if (this.data.airtime) {
this.data.airtime = this.data.airtime.filter((e) => e.titleId !== id);
this.data.airtime = this.data.airtime.filter((e) => e.titleId !== id || e.source === 'reading');
}
// Remove collapsed seasons for this title
const d = this.data as unknown as Record<string, unknown>;
@ -333,6 +661,13 @@ export class DataManager {
await this.save();
}
async removeAirtimeEntriesBatch(ids: string[]): Promise<void> {
if (ids.length === 0) return;
const removeSet = new Set(ids);
this.data.airtime = (this.data.airtime ?? []).filter((e) => !removeSet.has(e.id));
await this.save();
}
// ── Maybe CRUD ────────────────────────────────────────────────────────────────
getMaybeTitles(): MaybeTitle[] {
@ -378,7 +713,7 @@ export class DataManager {
return title.watchedEpisodes.includes(1) ? title.episodeDuration : 0;
}
if (title.status === 'Completed') {
return title.totalEpisodes * title.episodeDuration;
return this.getEffectiveTotal(title) * title.episodeDuration;
}
// Watching, Dropped, Plan to watch: only watched episodes
return title.watchedEpisodes.length * title.episodeDuration;
@ -392,12 +727,12 @@ export class DataManager {
if (title.type === 'Movie') {
return title.watchedEpisodes.includes(1) ? 0 : title.episodeDuration;
}
const effective = this.getEffectiveTotal(title);
if (title.status === 'Plan to watch') {
return Math.max(0, title.totalEpisodes - title.watchedEpisodes.length) * title.episodeDuration;
return Math.max(0, effective - title.watchedEpisodes.length) * title.episodeDuration;
}
if (title.status === 'Watching') {
const unwatched = Math.max(0, title.totalEpisodes - title.watchedEpisodes.length);
return unwatched * title.episodeDuration;
return Math.max(0, effective - title.watchedEpisodes.length) * title.episodeDuration;
}
// Completed or other: 0
return 0;
@ -409,14 +744,15 @@ export class DataManager {
if (title.type === 'Movie') {
return title.watchedEpisodes.includes(1) ? 0 : title.episodeDuration;
}
const effective = this.getEffectiveTotal(title);
if (title.status === 'Dropped') {
return Math.max(0, title.totalEpisodes - title.watchedEpisodes.length) * title.episodeDuration;
return Math.max(0, effective - title.watchedEpisodes.length) * title.episodeDuration;
}
if (title.status === 'Plan to watch') {
return Math.max(0, title.totalEpisodes - title.watchedEpisodes.length) * title.episodeDuration;
return Math.max(0, effective - title.watchedEpisodes.length) * title.episodeDuration;
}
if (title.status === 'Watching') {
return Math.max(0, title.totalEpisodes - title.watchedEpisodes.length) * title.episodeDuration;
return Math.max(0, effective - title.watchedEpisodes.length) * title.episodeDuration;
}
// Completed or other: 0
return 0;
@ -441,6 +777,13 @@ export class DataManager {
}
).on('raw', (path: string) => {
if (path.endsWith('watchlog/data.json')) {
// Suppress the echo of our own saveOnly() — Obsidian fires 'raw' for
// any write to the plugin data file, including our debounced
// episode-click saves. Without this guard each click cascades into
// load() + notifyListeners(), forcing a full tab re-render.
if (Date.now() - this.lastSelfSaveTime < this.SELF_SAVE_ECHO_WINDOW_MS) {
return;
}
void (async () => {
await this.load();
this.notifyListeners();
@ -488,11 +831,96 @@ export class DataManager {
await this.save();
}
// ── Skip helpers ──────────────────────────────────────────────────────────────
getTotalSkippedCount(title: WatchLogTitle): number {
return title.seasons.reduce((sum, s) => sum + (s.skippedEpisodes?.length ?? 0), 0);
}
/** totalEpisodes minus all season-defined skipped episodes. */
getEffectiveTotal(title: WatchLogTitle): number {
return Math.max(0, title.totalEpisodes - this.getTotalSkippedCount(title));
}
/** Returns true if absoluteEpNum falls in any season's skippedEpisodes list. */
isEpisodeSkipped(title: WatchLogTitle, absoluteEpNum: number): boolean {
for (const season of title.seasons) {
const rel = absoluteEpNum - season.offset;
if (rel >= 1 && rel <= season.episodes && (season.skippedEpisodes ?? []).includes(rel)) {
return true;
}
}
return false;
}
/**
* In-memory toggle of an episode's watched state plus auto-complete logic.
* Schedules a debounced save and does NOT notify listeners caller is
* responsible for targeted DOM updates. Returns the (mutated) title.
*/
applyEpisodeWatchedToggle(
id: string,
episodeNumber: number,
watched: boolean,
): WatchLogTitle | null {
const title = this.getTitle(id);
if (!title) return null;
if (watched) {
if (!title.watchedEpisodes.includes(episodeNumber)) {
title.watchedEpisodes.push(episodeNumber);
title.watchedEpisodes.sort((a, b) => a - b);
}
const now = new Date().toISOString();
if (title.totalEpisodes > 1) {
void this.historyManager?.log(
`${title.title} (${title.type}) episode ${episodeNumber} was marked as watched on ${formatHistoryDate(now)}`,
{ source: 'Watchlist', action: 'watched', titleName: title.title },
);
} else {
void this.historyManager?.log(
`${title.title} (${title.type}) was marked as watched on ${formatHistoryDate(now)}`,
{ source: 'Watchlist', action: 'watched', titleName: title.title },
);
}
} else {
title.watchedEpisodes = title.watchedEpisodes.filter((e) => e !== episodeNumber);
}
title.dateModified = new Date().toISOString();
this.applyAutoCompleteRules(title);
this.scheduleEpisodeSave(id);
return title;
}
/** Shared auto-complete / un-complete rules. Mutates title in place. */
private applyAutoCompleteRules(title: WatchLogTitle): void {
const effectiveTotal = this.getEffectiveTotal(title);
if (
this.plugin.settings.autoCompleteOnLastEpisode &&
effectiveTotal > 0 &&
title.watchedEpisodes.length >= effectiveTotal
) {
if (title.status !== 'Completed') {
title.status = 'Completed';
// Match updateTitle(): completed titles drop priority.
title.priority = '';
}
if (this.plugin.settings.setFinishDateAutomatically && !title.dateFinished) {
title.dateFinished = new Date().toISOString().split('T')[0] ?? null;
}
} else if (title.status === 'Completed' && title.watchedEpisodes.length < effectiveTotal) {
title.status = 'Watching';
title.dateFinished = null;
}
}
// ── Progress helpers ──────────────────────────────────────────────────────────
getProgress(title: WatchLogTitle): number {
if (title.totalEpisodes === 0) return 0;
return Math.round((title.watchedEpisodes.length / title.totalEpisodes) * 100);
const effective = this.getEffectiveTotal(title);
if (effective === 0) return 0;
return Math.min(100, Math.round((title.watchedEpisodes.length / effective) * 100));
}
getNextUnwatchedEpisode(title: WatchLogTitle): number | null {
@ -514,24 +942,31 @@ export class DataManager {
}
const now = new Date().toISOString();
if (title.totalEpisodes > 1) {
void this.historyManager?.log(`${title.title} (${title.type}) episode ${episodeNumber} was marked as watched on ${formatHistoryDate(now)}`);
void this.historyManager?.log(
`${title.title} (${title.type}) episode ${episodeNumber} was marked as watched on ${formatHistoryDate(now)}`,
{ source: 'Watchlist', action: 'watched', titleName: title.title },
);
} else {
void this.historyManager?.log(`${title.title} (${title.type}) was marked as watched on ${formatHistoryDate(now)}`);
void this.historyManager?.log(
`${title.title} (${title.type}) was marked as watched on ${formatHistoryDate(now)}`,
{ source: 'Watchlist', action: 'watched', titleName: title.title },
);
}
} else {
title.watchedEpisodes = title.watchedEpisodes.filter((e) => e !== episodeNumber);
}
const effectiveTotal = this.getEffectiveTotal(title);
if (
this.plugin.settings.autoCompleteOnLastEpisode &&
title.totalEpisodes > 0 &&
title.watchedEpisodes.length >= title.totalEpisodes
effectiveTotal > 0 &&
title.watchedEpisodes.length >= effectiveTotal
) {
title.status = 'Completed';
if (this.plugin.settings.setFinishDateAutomatically && !title.dateFinished) {
title.dateFinished = new Date().toISOString().split('T')[0] ?? null;
}
} else if (title.status === 'Completed' && title.watchedEpisodes.length < title.totalEpisodes) {
} else if (title.status === 'Completed' && title.watchedEpisodes.length < effectiveTotal) {
title.status = 'Watching';
title.dateFinished = null;
}
@ -552,16 +987,17 @@ export class DataManager {
title.watchedEpisodes = title.watchedEpisodes.filter((e) => !remove.has(e));
}
const effectiveTotal = this.getEffectiveTotal(title);
if (
this.plugin.settings.autoCompleteOnLastEpisode &&
title.totalEpisodes > 0 &&
title.watchedEpisodes.length >= title.totalEpisodes
effectiveTotal > 0 &&
title.watchedEpisodes.length >= effectiveTotal
) {
title.status = 'Completed';
if (this.plugin.settings.setFinishDateAutomatically && !title.dateFinished) {
title.dateFinished = new Date().toISOString().split('T')[0] ?? null;
}
} else if (title.status === 'Completed' && title.watchedEpisodes.length < title.totalEpisodes) {
} else if (title.status === 'Completed' && title.watchedEpisodes.length < effectiveTotal) {
title.status = 'Watching';
title.dateFinished = null;
}
@ -591,6 +1027,8 @@ export class DataManager {
}
}
private lastMarkdownPathById: Map<string, string> = new Map();
async updateMarkdownFile(title: WatchLogTitle): Promise<void> {
const root = this.plugin.settings.rootFolder;
const folderPath = normalizePath(`${root}/${title.type}`);
@ -599,12 +1037,28 @@ export class DataManager {
const filePath = normalizePath(`${folderPath}/${safeTitle}.md`);
const progress = this.getProgress(title);
const content = this.buildMarkdownContent(title, progress);
// If a different path was previously written for this title (rename/type change),
// remove the stale file before writing the new one.
const previousPath = this.lastMarkdownPathById.get(title.id);
if (previousPath && previousPath !== filePath) {
try {
const oldFile = this.app.vault.getAbstractFileByPath(previousPath);
if (oldFile instanceof TFile) {
await this.app.fileManager.trashFile(oldFile);
}
} catch {
// best-effort cleanup
}
}
const existing = this.app.vault.getAbstractFileByPath(filePath);
if (existing instanceof TFile) {
await this.app.vault.modify(existing, content);
} else {
await this.app.vault.create(filePath, content);
}
this.lastMarkdownPathById.set(title.id, filePath);
}
async createMarkdownFileIfMissing(title: WatchLogTitle): Promise<boolean> {

46
src/DraftChoiceModal.ts Normal file
View file

@ -0,0 +1,46 @@
import { App, Modal } from 'obsidian';
export type DraftAddChoice = 'watchlist' | 'book' | 'manga';
/**
* Small choice modal shown when adding a draft. Offers three inline options
* (Watchlist / Book / Manga), each routing to its own corresponding Add modal.
* The draft's text is shown as a subtitle to identify what's being added.
*/
export class DraftChoiceModal extends Modal {
constructor(
app: App,
private draftText: string,
private onChoice: (choice: DraftAddChoice) => void,
) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('wl-draft-choice-modal');
this.titleEl.setText('Add draft');
contentEl.createDiv({ cls: 'wl-draft-choice-subtitle', text: this.draftText });
const grid = contentEl.createDiv({ cls: 'wl-draft-choice-grid' });
const makeOption = (label: string, choice: DraftAddChoice): void => {
const btn = grid.createEl('button', { cls: 'wl-draft-choice-btn' });
btn.createDiv({ cls: 'wl-draft-choice-label', text: label });
btn.addEventListener('click', () => {
this.close();
this.onChoice(choice);
});
};
makeOption('Add in Watchlist', 'watchlist');
makeOption('Add book', 'book');
makeOption('Add manga', 'manga');
}
onClose(): void {
this.contentEl.empty();
}
}

View file

@ -1,381 +1,449 @@
import Fuse from 'fuse.js';
import type { EventRef } from 'obsidian';
import type WatchLogPlugin from './main';
import type { DataManager } from './DataManager';
import type { DraftPersistState, WatchLogTitle } from './types';
import { AddTitleModal } from './AddTitleModal';
interface LiveDraftEntry {
titleKey: string;
titleDisplay: string;
sources: string[];
firstSeen: string;
added: boolean;
}
export class DraftsTab {
private plugin: WatchLogPlugin;
private dataManager: DataManager;
private containerEl: HTMLElement;
private onCountChange: (count: number) => void;
private eventRef: EventRef | null = null;
private destroyed = false;
private scanDebounceTimer: ReturnType<typeof setTimeout> | null = null;
private lastScanEntries: LiveDraftEntry[] = [];
private persistState: DraftPersistState = {
dismissed: [],
added: [],
firstSeen: {},
titleDisplay: {},
};
constructor(
containerEl: HTMLElement,
plugin: WatchLogPlugin,
dataManager: DataManager,
onCountChange: (count: number) => void,
) {
this.containerEl = containerEl;
this.plugin = plugin;
this.dataManager = dataManager;
this.onCountChange = onCountChange;
}
async render(): Promise<void> {
this.destroyed = false;
await this.loadPersistState();
if (this.destroyed) return;
const entries = await this.scanVault();
if (this.destroyed) return;
this.renderUI(entries);
this.registerChangeListener();
}
destroy(): void {
this.destroyed = true;
if (this.scanDebounceTimer) {
clearTimeout(this.scanDebounceTimer);
this.scanDebounceTimer = null;
}
if (this.eventRef) {
this.plugin.app.metadataCache.offref(this.eventRef);
this.eventRef = null;
}
}
// ── Persistence ───────────────────────────────────────────────────────────────
private async loadPersistState(): Promise<void> {
const data = (await this.plugin.loadData()) as Record<string, unknown> | null;
const drafts = data?.['drafts'] as Partial<DraftPersistState> | undefined;
if (drafts) {
this.persistState = {
dismissed: drafts.dismissed ?? [],
added: drafts.added ?? [],
firstSeen: drafts.firstSeen ?? {},
titleDisplay: drafts.titleDisplay ?? {},
};
}
}
private async savePersistState(): Promise<void> {
const data = ((await this.plugin.loadData()) as Record<string, unknown> | null) ?? {};
await this.plugin.saveData({ ...data, drafts: this.persistState });
}
// ── Vault scanning ────────────────────────────────────────────────────────────
private getTag(): string {
return this.plugin.settings.draftsVaultTag ?? '#watchlog';
}
private async scanVault(): Promise<LiveDraftEntry[]> {
const tag = this.getTag();
const files = this.plugin.app.vault.getMarkdownFiles();
// Map: lowercase title key → { sources, displayTitle }
const liveMap: Map<string, { sources: Set<string>; displayTitle: string }> = new Map();
for (const file of files) {
const cache = this.plugin.app.metadataCache.getFileCache(file);
const hasTag = cache?.tags?.some((t) => t.tag === tag) ?? false;
if (!hasTag) continue;
try {
const content = await this.plugin.app.vault.cachedRead(file);
const lines = content.split('\n');
for (const line of lines) {
if (!line.includes(tag)) continue;
const tagIdx = line.indexOf(tag);
const afterTag = line.slice(tagIdx + tag.length).trim();
if (!afterTag) continue;
// Each comma-separated segment after the tag is a separate title
const segments = afterTag.split(',');
for (const seg of segments) {
const displayTitle = seg.trim();
if (!displayTitle || displayTitle.length > 100) continue;
const titleKey = displayTitle.toLowerCase();
if (!liveMap.has(titleKey)) {
liveMap.set(titleKey, { sources: new Set(), displayTitle });
}
liveMap.get(titleKey)!.sources.add(file.basename);
}
}
} catch {
// Skip unreadable files
}
}
// Record firstSeen and displayTitle for newly discovered entries
let stateChanged = false;
const now = new Date().toISOString();
for (const [key, { displayTitle }] of liveMap) {
if (!this.persistState.firstSeen[key]) {
this.persistState.firstSeen[key] = now;
stateChanged = true;
}
if (!this.persistState.titleDisplay[key]) {
this.persistState.titleDisplay[key] = displayTitle;
stateChanged = true;
}
}
if (stateChanged) {
await this.savePersistState();
}
// Build visible entries (dismissed entries are excluded entirely)
const entries: LiveDraftEntry[] = [];
for (const [key, { sources }] of liveMap) {
if (this.persistState.dismissed.includes(key)) continue;
entries.push({
titleKey: key,
titleDisplay: this.persistState.titleDisplay[key] ?? key,
sources: Array.from(sources),
firstSeen: this.persistState.firstSeen[key] ?? now,
added: this.persistState.added.includes(key),
});
}
// Sort oldest-first (FIFO queue)
entries.sort((a, b) => a.firstSeen.localeCompare(b.firstSeen));
this.lastScanEntries = entries;
return entries;
}
// ── Event listener ────────────────────────────────────────────────────────────
private triggerDebouncedRender(): void {
if (this.scanDebounceTimer) clearTimeout(this.scanDebounceTimer);
this.scanDebounceTimer = setTimeout(() => {
this.scanDebounceTimer = null;
void this.render();
}, 500);
}
private registerChangeListener(): void {
if (this.eventRef) {
this.plugin.app.metadataCache.offref(this.eventRef);
}
this.eventRef = this.plugin.app.metadataCache.on('changed', (_file) => {
this.triggerDebouncedRender();
});
}
// ── Fuzzy match helper ────────────────────────────────────────────────────────
private buildFuse(titles: WatchLogTitle[]): Fuse<WatchLogTitle> {
return new Fuse(titles, {
keys: ['title'],
threshold: 0.35,
includeScore: true,
});
}
private fuzzyMatchesWatchlist(displayTitle: string, fuse: Fuse<WatchLogTitle>): boolean {
const results = fuse.search(displayTitle);
return results.length > 0 && (results[0]?.score ?? 1) <= 0.35;
}
// ── Rendering ─────────────────────────────────────────────────────────────────
private renderUI(entries: LiveDraftEntry[]): void {
const el = this.containerEl;
el.empty();
const tag = this.getTag();
const watchlistTitles = this.dataManager.getTitles();
const fuse = this.buildFuse(watchlistTitles);
// Compute fuzzy match results once per entry and cache them
const fuzzyCache = new Map<string, boolean>();
for (const entry of entries) {
if (entry.added) {
fuzzyCache.set(entry.titleKey, false);
} else {
fuzzyCache.set(entry.titleKey, this.fuzzyMatchesWatchlist(entry.titleDisplay, fuse));
}
}
// Pending = not added AND not already present in the Watchlist (exact or fuzzy)
const pendingCount = entries.filter((e) => {
if (e.added) return false;
return !fuzzyCache.get(e.titleKey);
}).length;
this.onCountChange(pendingCount);
// Notice banner — matches Custom Lists draft banner style (Fix 3)
el.createDiv({
cls: 'wl-drafts-notice',
text: `⚠ Write ${tag} Movie Name in any note and it appears here automatically. Hit Add when you're ready to add it to your Watchlist.`,
});
// Count pill
const countWrap = el.createDiv({ cls: 'wl-list-title-wrap' });
countWrap.createSpan({ cls: 'wl-list-count', text: String(pendingCount) });
countWrap.createSpan({
cls: 'wl-drafts-count-label',
text: ` Pending draft${pendingCount !== 1 ? 's' : ''}`,
});
if (entries.length === 0) {
el.createDiv({
cls: 'wl-drafts-empty',
text: `No drafts found. Add ${tag} followed by a title in any vault note.`,
});
return;
}
// Sort: non-Watchlist entries first (oldest-first), Watchlist entries last (oldest-first)
entries.sort((a, b) => {
const aDup = fuzzyCache.get(a.titleKey) ?? false;
const bDup = fuzzyCache.get(b.titleKey) ?? false;
if (aDup !== bDup) return aDup ? 1 : -1;
return a.firstSeen.localeCompare(b.firstSeen);
});
// Cards (Fix 2 — Upcoming tab card style)
const cards = el.createDiv({ cls: 'wl-drafts-cards' });
for (const entry of entries) {
this.renderCard(cards, entry, fuzzyCache.get(entry.titleKey) ?? false);
}
}
private renderCard(cards: HTMLElement, entry: LiveDraftEntry, isDuplicate: boolean): void {
// Build class list (Fix 4 — dim watchlist rows; style added rows)
let cls = 'wl-drafts-card';
if (isDuplicate) cls += ' wl-drafts-card-watchlist';
if (entry.added) cls += ' wl-drafts-card-added';
const card = cards.createDiv({ cls });
// Title (left, bold)
card.createDiv({ cls: 'wl-drafts-card-title', text: entry.titleDisplay });
// Source link (center)
const sourceEl = card.createDiv({ cls: 'wl-drafts-card-source' });
if (entry.sources.length > 0) {
const primaryNote = entry.sources[0]!;
const link = sourceEl.createSpan({
cls: 'wl-drafts-source-link',
text: `[[${primaryNote}]]`,
});
link.addEventListener('click', (e) => {
e.stopPropagation();
void this.plugin.app.workspace.openLinkText(primaryNote, '');
});
if (entry.sources.length > 1) {
sourceEl.createSpan({
cls: 'wl-drafts-source-count',
text: ` (${entry.sources.length})`,
attr: { title: entry.sources.join('\n') },
});
}
}
// "In Watchlist" indicator (center-right, only when applicable) — Fix 4
const dupEl = card.createDiv({ cls: 'wl-drafts-card-dup' });
if (isDuplicate) {
dupEl.createSpan({
text: 'In Watchlist',
attr: { title: 'This title already exists in your Watchlist' },
});
}
// Actions (far right)
const actions = card.createDiv({ cls: 'wl-drafts-card-actions' });
if (entry.added || isDuplicate) {
actions.createSpan({ cls: 'wl-drafts-added-label', text: 'Added' });
} else {
const addBtn = actions.createEl('button', {
cls: 'wl-btn wl-btn-sm wl-btn-primary',
text: 'Add',
});
addBtn.addEventListener('click', () => this.openAddModal(entry));
}
const dismissBtn = actions.createEl('button', {
cls: 'wl-btn wl-btn-sm wl-drafts-dismiss',
text: '✕',
attr: { title: 'Dismiss' },
});
dismissBtn.addEventListener('click', () => void this.dismissEntry(entry.titleKey));
}
private rerenderFromCache(): void {
const entries = this.lastScanEntries
.filter((e) => !this.persistState.dismissed.includes(e.titleKey))
.map((e) => ({
...e,
added: this.persistState.added.includes(e.titleKey),
}));
this.renderUI(entries);
}
// ── Actions ───────────────────────────────────────────────────────────────────
private async dismissEntry(titleKey: string): Promise<void> {
if (!this.persistState.dismissed.includes(titleKey)) {
this.persistState.dismissed.push(titleKey);
}
this.persistState.added = this.persistState.added.filter((k) => k !== titleKey);
await this.savePersistState();
this.rerenderFromCache();
}
private openAddModal(entry: LiveDraftEntry): void {
const modal = new AddTitleModal(
this.plugin.app,
this.plugin,
this.dataManager,
() => void this.afterAdded(entry.titleKey),
{
searchQuery: entry.titleDisplay,
title: entry.titleDisplay,
type: 'Anime',
episodes: 0,
duration: 0,
releaseDate: '',
link: '',
seasons: [],
},
);
modal.open();
}
private async afterAdded(titleKey: string): Promise<void> {
const behavior = this.plugin.settings.draftsAfterAdding ?? 'keep';
if (behavior === 'remove') {
await this.dismissEntry(titleKey);
} else {
if (!this.persistState.added.includes(titleKey)) {
this.persistState.added.push(titleKey);
}
await this.savePersistState();
this.rerenderFromCache();
}
}
}
import Fuse from 'fuse.js';
import type { EventRef } from 'obsidian';
import type WatchLogPlugin from './main';
import type { DataManager } from './DataManager';
import type { DraftPersistState, WatchLogTitle } from './types';
import { AddTitleModal } from './AddTitleModal';
import { AddReadingModal } from './AddReadingModal';
import { DraftChoiceModal } from './DraftChoiceModal';
interface LiveDraftEntry {
titleKey: string;
titleDisplay: string;
sources: string[];
firstSeen: string;
added: boolean;
}
export class DraftsTab {
private plugin: WatchLogPlugin;
private dataManager: DataManager;
private containerEl: HTMLElement;
private onCountChange: (count: number) => void;
private eventRef: EventRef | null = null;
private destroyed = false;
private scanDebounceTimer: number | null = null;
private lastScanEntries: LiveDraftEntry[] = [];
private persistState: DraftPersistState = {
dismissed: [],
added: [],
firstSeen: {},
titleDisplay: {},
};
constructor(
containerEl: HTMLElement,
plugin: WatchLogPlugin,
dataManager: DataManager,
onCountChange: (count: number) => void,
) {
this.containerEl = containerEl;
this.plugin = plugin;
this.dataManager = dataManager;
this.onCountChange = onCountChange;
}
async render(): Promise<void> {
this.destroyed = false;
await this.loadPersistState();
if (this.destroyed) return;
const entries = await this.scanVault();
if (this.destroyed) return;
this.renderUI(entries);
this.registerChangeListener();
}
destroy(): void {
this.destroyed = true;
if (this.scanDebounceTimer) {
window.clearTimeout(this.scanDebounceTimer);
this.scanDebounceTimer = null;
}
if (this.eventRef) {
this.plugin.app.metadataCache.offref(this.eventRef);
this.eventRef = null;
}
}
// ── Persistence ───────────────────────────────────────────────────────────────
private async loadPersistState(): Promise<void> {
const data = this.plugin.dataManager.getData() as unknown as Record<string, unknown>;
const drafts = data['drafts'] as Partial<DraftPersistState> | undefined;
if (drafts) {
this.persistState = {
dismissed: drafts.dismissed ?? [],
added: drafts.added ?? [],
firstSeen: drafts.firstSeen ?? {},
titleDisplay: drafts.titleDisplay ?? {},
};
}
}
private async savePersistState(): Promise<void> {
const data = this.plugin.dataManager.getData() as unknown as Record<string, unknown>;
data['drafts'] = this.persistState;
this.plugin.dataManager.queueSave();
}
// ── Vault scanning ────────────────────────────────────────────────────────────
private getTag(): string {
return this.plugin.settings.draftsVaultTag ?? '#watchlog';
}
/**
* Scans the vault for inline-tag draft mentions, returning a map of
* lowercase title key { sources, displayTitle }. Pure (no persistState side
* effects) so it can back both the full render and the count-only badge scan.
* Inline-tag matching only frontmatter tags are intentionally ignored.
*/
private static async scanTaggedFiles(
plugin: WatchLogPlugin,
tag: string,
): Promise<Map<string, { sources: Set<string>; displayTitle: string }>> {
const files = plugin.app.vault.getMarkdownFiles();
const liveMap: Map<string, { sources: Set<string>; displayTitle: string }> = new Map();
for (const file of files) {
const cache = plugin.app.metadataCache.getFileCache(file);
const hasTag = cache?.tags?.some((t) => t.tag === tag) ?? false;
if (!hasTag) continue;
try {
const content = await plugin.app.vault.cachedRead(file);
const lines = content.split('\n');
for (const line of lines) {
if (!line.includes(tag)) continue;
const tagIdx = line.indexOf(tag);
const afterTag = line.slice(tagIdx + tag.length).trim();
if (!afterTag) continue;
// Each comma-separated segment after the tag is a separate title
const segments = afterTag.split(',');
for (const seg of segments) {
const displayTitle = seg.trim();
if (!displayTitle || displayTitle.length > 100) continue;
const titleKey = displayTitle.toLowerCase();
if (!liveMap.has(titleKey)) {
liveMap.set(titleKey, { sources: new Set(), displayTitle });
}
liveMap.get(titleKey)!.sources.add(file.basename);
}
}
} catch {
// Skip unreadable files
}
}
return liveMap;
}
private async scanVault(): Promise<LiveDraftEntry[]> {
const tag = this.getTag();
const liveMap = await DraftsTab.scanTaggedFiles(this.plugin, tag);
// Record firstSeen and displayTitle for newly discovered entries
let stateChanged = false;
const now = new Date().toISOString();
for (const [key, { displayTitle }] of liveMap) {
if (!this.persistState.firstSeen[key]) {
this.persistState.firstSeen[key] = now;
stateChanged = true;
}
if (!this.persistState.titleDisplay[key]) {
this.persistState.titleDisplay[key] = displayTitle;
stateChanged = true;
}
}
if (stateChanged) {
await this.savePersistState();
}
// Build visible entries (dismissed entries are excluded entirely)
const entries: LiveDraftEntry[] = [];
for (const [key, { sources }] of liveMap) {
if (this.persistState.dismissed.includes(key)) continue;
entries.push({
titleKey: key,
titleDisplay: this.persistState.titleDisplay[key] ?? key,
sources: Array.from(sources),
firstSeen: this.persistState.firstSeen[key] ?? now,
added: this.persistState.added.includes(key),
});
}
// Sort oldest-first (FIFO queue)
entries.sort((a, b) => a.firstSeen.localeCompare(b.firstSeen));
this.lastScanEntries = entries;
return entries;
}
// ── Event listener ────────────────────────────────────────────────────────────
private triggerDebouncedRender(): void {
if (this.scanDebounceTimer) window.clearTimeout(this.scanDebounceTimer);
this.scanDebounceTimer = window.setTimeout(() => {
this.scanDebounceTimer = null;
void this.render();
}, 500);
}
private registerChangeListener(): void {
if (this.eventRef) {
this.plugin.app.metadataCache.offref(this.eventRef);
}
this.eventRef = this.plugin.app.metadataCache.on('changed', (_file) => {
this.triggerDebouncedRender();
});
}
// ── Fuzzy match helper ────────────────────────────────────────────────────────
private static buildFuse(titles: WatchLogTitle[]): Fuse<WatchLogTitle> {
return new Fuse(titles, {
keys: ['title'],
threshold: 0.35,
includeScore: true,
});
}
private static fuzzyMatchesWatchlist(displayTitle: string, fuse: Fuse<WatchLogTitle>): boolean {
const results = fuse.search(displayTitle);
return results.length > 0 && (results[0]?.score ?? 1) <= 0.35;
}
/**
* Count-only drafts scan for the tab badge mirrors the pending-count logic in
* renderUI without touching the DOM or mutating persistState. "Pending" = a tagged
* draft that is not dismissed, not marked added, and not already in the Watchlist
* (exact key or fuzzy title match). Cheap: only tagged files are read, the rest is
* in-memory. Inline-tag matching only.
*/
static async computePendingCount(plugin: WatchLogPlugin, dataManager: DataManager): Promise<number> {
const tag = plugin.settings.draftsVaultTag ?? '#watchlog';
const liveMap = await DraftsTab.scanTaggedFiles(plugin, tag);
const data = plugin.dataManager.getData() as unknown as Record<string, unknown>;
const drafts = data['drafts'] as Partial<DraftPersistState> | undefined;
const dismissed = new Set(drafts?.dismissed ?? []);
const added = new Set(drafts?.added ?? []);
const titleDisplay = drafts?.titleDisplay ?? {};
const fuse = DraftsTab.buildFuse(dataManager.getTitles());
let pending = 0;
for (const [key, { displayTitle }] of liveMap) {
if (dismissed.has(key) || added.has(key)) continue;
const display = titleDisplay[key] ?? displayTitle;
if (!DraftsTab.fuzzyMatchesWatchlist(display, fuse)) pending++;
}
return pending;
}
// ── Rendering ─────────────────────────────────────────────────────────────────
private renderUI(entries: LiveDraftEntry[]): void {
const el = this.containerEl;
el.empty();
const tag = this.getTag();
const watchlistTitles = this.dataManager.getTitles();
const fuse = DraftsTab.buildFuse(watchlistTitles);
// Compute fuzzy match results once per entry and cache them
const fuzzyCache = new Map<string, boolean>();
for (const entry of entries) {
if (entry.added) {
fuzzyCache.set(entry.titleKey, false);
} else {
fuzzyCache.set(entry.titleKey, DraftsTab.fuzzyMatchesWatchlist(entry.titleDisplay, fuse));
}
}
// Pending = not added AND not already present in the Watchlist (exact or fuzzy)
const pendingCount = entries.filter((e) => {
if (e.added) return false;
return !fuzzyCache.get(e.titleKey);
}).length;
this.onCountChange(pendingCount);
if (this.plugin.settings.showHintBanners) {
el.createDiv({
cls: 'wl-drafts-notice',
text: `⚠ Write ${tag} Movie Name in any note and it appears here automatically. Hit Add when you're ready to add it to your Watchlist.`,
});
}
// Count pill
const countWrap = el.createDiv({ cls: 'wl-list-title-wrap' });
countWrap.createSpan({ cls: 'wl-list-count', text: String(pendingCount) });
countWrap.createSpan({
cls: 'wl-drafts-count-label',
text: ` Pending draft${pendingCount !== 1 ? 's' : ''}`,
});
if (entries.length === 0) {
el.createDiv({
cls: 'wl-drafts-empty',
text: `No drafts found. Add ${tag} followed by a title in any vault note.`,
});
return;
}
// Sort: non-Watchlist entries first (oldest-first), Watchlist entries last (oldest-first)
entries.sort((a, b) => {
const aDup = fuzzyCache.get(a.titleKey) ?? false;
const bDup = fuzzyCache.get(b.titleKey) ?? false;
if (aDup !== bDup) return aDup ? 1 : -1;
return a.firstSeen.localeCompare(b.firstSeen);
});
// Cards (Upcoming tab card style)
const cards = el.createDiv({ cls: 'wl-drafts-cards' });
for (const entry of entries) {
this.renderCard(cards, entry, fuzzyCache.get(entry.titleKey) ?? false);
}
}
private renderCard(cards: HTMLElement, entry: LiveDraftEntry, isDuplicate: boolean): void {
// Build class list — dim watchlist rows; style added rows
let cls = 'wl-drafts-card';
if (isDuplicate) cls += ' wl-drafts-card-watchlist';
if (entry.added) cls += ' wl-drafts-card-added';
const card = cards.createDiv({ cls });
// Title (left, bold)
card.createDiv({ cls: 'wl-drafts-card-title', text: entry.titleDisplay });
// Source link (center)
const sourceEl = card.createDiv({ cls: 'wl-drafts-card-source' });
if (entry.sources.length > 0) {
const primaryNote = entry.sources[0]!;
const link = sourceEl.createSpan({
cls: 'wl-drafts-source-link',
text: `[[${primaryNote}]]`,
});
link.addEventListener('click', (e) => {
e.stopPropagation();
void this.plugin.app.workspace.openLinkText(primaryNote, '');
});
if (entry.sources.length > 1) {
sourceEl.createSpan({
cls: 'wl-drafts-source-count',
text: ` (${entry.sources.length})`,
attr: { title: entry.sources.join('\n') },
});
}
}
// "In Watchlist" indicator (center-right, only when applicable)
const dupEl = card.createDiv({ cls: 'wl-drafts-card-dup' });
if (isDuplicate) {
dupEl.createSpan({
text: 'In Watchlist',
attr: { title: 'This title already exists in your Watchlist' },
});
}
// Actions (far right)
const actions = card.createDiv({ cls: 'wl-drafts-card-actions' });
if (entry.added || isDuplicate) {
actions.createSpan({ cls: 'wl-drafts-added-label', text: 'Added' });
} else {
const addBtn = actions.createEl('button', {
cls: 'wl-btn wl-btn-sm wl-btn-primary',
text: 'Add',
});
addBtn.addEventListener('click', () => this.openAddModal(entry));
}
const dismissBtn = actions.createEl('button', {
cls: 'wl-btn wl-btn-sm wl-drafts-dismiss',
text: '✕',
attr: { title: 'Dismiss' },
});
dismissBtn.addEventListener('click', () => void this.dismissEntry(entry.titleKey));
}
private rerenderFromCache(): void {
const entries = this.lastScanEntries
.filter((e) => !this.persistState.dismissed.includes(e.titleKey))
.map((e) => ({
...e,
added: this.persistState.added.includes(e.titleKey),
}));
this.renderUI(entries);
}
// ── Actions ───────────────────────────────────────────────────────────────────
private async dismissEntry(titleKey: string): Promise<void> {
if (!this.persistState.dismissed.includes(titleKey)) {
this.persistState.dismissed.push(titleKey);
}
this.persistState.added = this.persistState.added.filter((k) => k !== titleKey);
await this.savePersistState();
this.rerenderFromCache();
}
private openAddModal(entry: LiveDraftEntry): void {
new DraftChoiceModal(this.plugin.app, entry.titleDisplay, (choice) => {
if (choice === 'watchlist') {
this.openWatchlistAddModal(entry);
} else {
this.openReadingAddModal(entry, choice);
}
}).open();
}
private openWatchlistAddModal(entry: LiveDraftEntry): void {
const modal = new AddTitleModal(
this.plugin.app,
this.plugin,
this.dataManager,
() => void this.afterAdded(entry.titleKey),
{
searchQuery: entry.titleDisplay,
title: entry.titleDisplay,
type: 'Anime',
episodes: 0,
duration: 0,
releaseDate: '',
link: '',
seasons: [],
},
);
modal.open();
}
private openReadingAddModal(entry: LiveDraftEntry, mode: 'book' | 'manga'): void {
new AddReadingModal(
this.plugin.app,
this.plugin,
this.plugin.readingDataManager,
mode,
() => void this.afterAdded(entry.titleKey),
undefined,
entry.titleDisplay,
).open();
}
private async afterAdded(titleKey: string): Promise<void> {
const behavior = this.plugin.settings.draftsAfterAdding ?? 'keep';
if (behavior === 'remove') {
await this.dismissEntry(titleKey);
} else {
if (!this.persistState.added.includes(titleKey)) {
this.persistState.added.push(titleKey);
}
await this.savePersistState();
this.rerenderFromCache();
}
}
}

View file

@ -4,16 +4,56 @@ import type { DataManager } from './DataManager';
import type { WatchLogTitle, Season } from './types';
import { formatDateDisplay, parseDateInput, parseReleaseDateInput } from './types';
/** Parses a seasons textarea (one line per season: "Name: N") back to Season[]. */
/** Parses "5,8,33-37,42" into a sorted unique flat array of numbers. */
function parseSkipSpec(spec: string): number[] {
const result: number[] = [];
for (const part of spec.split(',').map((s) => s.trim()).filter(Boolean)) {
const rangeParts = part.split('-');
if (rangeParts.length === 2) {
const from = parseInt(rangeParts[0] ?? '0', 10);
const to = parseInt(rangeParts[1] ?? '0', 10);
if (!isNaN(from) && !isNaN(to) && from <= to) {
for (let i = from; i <= to; i++) result.push(i);
}
} else {
const n = parseInt(part, 10);
if (!isNaN(n)) result.push(n);
}
}
return [...new Set(result)].sort((a, b) => a - b);
}
/** Serializes a sorted number array into a compact skip-spec string like "5,8,33-37,42". */
function serializeSkipSpec(nums: number[]): string {
if (nums.length === 0) return '';
const sorted = [...new Set(nums)].sort((a, b) => a - b);
const parts: string[] = [];
let i = 0;
while (i < sorted.length) {
let j = i;
while (j + 1 < sorted.length && sorted[j + 1] === (sorted[j] as number) + 1) j++;
if (j > i) {
parts.push(`${sorted[i]}-${sorted[j]}`);
} else {
parts.push(String(sorted[i]));
}
i = j + 1;
}
return parts.join(',');
}
/** Parses a seasons textarea (one line per season: "Name: N" or "Name: N (skip_spec)") back to Season[]. */
function parseSeasonsText(text: string): Season[] {
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean);
const seasons: Season[] = [];
let offset = 0;
for (const line of lines) {
const match = line.match(/^(.+?):\s*(\d+)/);
// Match "Name: N" optionally followed by " (skip_spec)"
const match = line.match(/^(.+?):\s*(\d+)(?:\s*\(([^)]+)\))?/);
if (match && match[1] && match[2]) {
const eps = parseInt(match[2]);
seasons.push({ name: match[1].trim(), episodes: eps, offset });
const eps = parseInt(match[2], 10);
const skippedEpisodes = match[3] ? parseSkipSpec(match[3]) : [];
seasons.push({ name: match[1].trim(), episodes: eps, offset, skippedEpisodes });
offset += eps;
}
}
@ -21,7 +61,13 @@ function parseSeasonsText(text: string): Season[] {
}
function seasonsToText(seasons: Season[]): string {
return seasons.map((s) => `${s.name}: ${s.episodes}`).join('\n');
return seasons.map((s) => {
const base = `${s.name}: ${s.episodes}`;
if (s.skippedEpisodes && s.skippedEpisodes.length > 0) {
return `${base} (${serializeSkipSpec(s.skippedEpisodes)})`;
}
return base;
}).join('\n');
}
export class EditTitleModal extends Modal {
@ -45,6 +91,7 @@ export class EditTitleModal extends Modal {
private fieldNotes: string;
private fieldDateStarted: string;
private fieldDateFinished: string;
private fieldPosterUrl: string;
private skipDuplicateCheck = false;
private duplicateWarningEl: HTMLElement | null = null;
@ -71,11 +118,12 @@ export class EditTitleModal extends Modal {
this.fieldSeasonsText = seasonsToText(title.seasons);
this.fieldStatus = title.status;
this.fieldPriority = title.priority;
this.fieldReview = (title as unknown as { review?: string }).review ?? '';
this.fieldReview = title.review ?? '';
this.fieldRating = title.rating;
this.fieldNotes = title.notes;
this.fieldDateStarted = title.dateStarted ?? '';
this.fieldDateFinished = title.dateFinished ?? '';
this.fieldPosterUrl = title.manualPosterUrl ?? '';
}
onOpen(): void {
@ -161,7 +209,26 @@ export class EditTitleModal extends Modal {
attr: { type: 'number', min: '0' },
});
epsInput.value = String(this.fieldEpisodes);
epsInput.addEventListener('input', () => { this.fieldEpisodes = parseInt(epsInput.value) || 0; });
const epsSkipLabel = epsRow.createSpan({ cls: 'wl-modal-skip-inline' });
const epsWatchLabel = epsRow.createSpan({ cls: 'wl-modal-skip-inline' });
const updateSkipCounts = (): void => {
const parsed = parseSeasonsText(this.fieldSeasonsText);
const totalSkipped = parsed.reduce((sum, s) => sum + (s.skippedEpisodes?.length ?? 0), 0);
if (totalSkipped === 0) {
epsSkipLabel.textContent = '';
epsWatchLabel.textContent = '';
return;
}
const toWatch = Math.max(0, this.fieldEpisodes - totalSkipped);
epsSkipLabel.textContent = `· ${totalSkipped} to skip`;
epsWatchLabel.textContent = `· ${toWatch} to watch`;
};
epsInput.addEventListener('input', () => {
this.fieldEpisodes = parseInt(epsInput.value) || 0;
updateSkipCounts();
});
// Duration
const durRow = makeRow('Ep. duration (min)');
@ -209,6 +276,20 @@ export class EditTitleModal extends Modal {
linkInput.value = this.fieldLink;
linkInput.addEventListener('input', () => { this.fieldLink = linkInput.value; });
// Poster URL (manual override) — directly under External link
const posterRow = makeRow('Poster URL');
const posterStack = posterRow.createDiv({ cls: 'wl-modal-input-stack' });
const posterInput = posterStack.createEl('input', {
cls: 'wl-modal-input',
attr: { type: 'url', placeholder: 'https://example.com/poster.jpg' },
});
posterInput.value = this.fieldPosterUrl;
posterInput.addEventListener('input', () => { this.fieldPosterUrl = posterInput.value; });
posterStack.createDiv({
cls: 'wl-modal-info',
text: 'Override the auto-fetched cover. Leave blank to let WatchLog fetch one again.',
});
// Date started
const startRow = makeRow('Date started');
const startInput = startRow.createEl('input', {
@ -257,7 +338,7 @@ export class EditTitleModal extends Modal {
const seasonsHelp = seasonsRow.createDiv({ cls: 'wl-modal-input-stack' });
const seasonsInput = seasonsHelp.createEl('textarea', {
cls: 'wl-modal-textarea',
attr: { rows: '4', placeholder: 'Season 1: 12\nseason 2: 13' },
attr: { rows: '4', placeholder: 'Season 1: 12\nSeason 2: 13 (5,8,33-37)' },
});
seasonsInput.value = this.fieldSeasonsText;
@ -265,18 +346,27 @@ export class EditTitleModal extends Modal {
const updateTotalPreview = (): void => {
const parsed = parseSeasonsText(seasonsInput.value);
const total = parsed.reduce((sum, s) => sum + s.episodes, 0);
totalPreviewEl.textContent = `Total episodes: ${total}`;
const totalSkipped = parsed.reduce((sum, s) => sum + (s.skippedEpisodes?.length ?? 0), 0);
totalPreviewEl.textContent = totalSkipped > 0
? `Total episodes: ${total} · ${totalSkipped} to skip`
: `Total episodes: ${total}`;
};
updateTotalPreview();
updateSkipCounts();
seasonsInput.addEventListener('input', () => {
this.fieldSeasonsText = seasonsInput.value;
updateTotalPreview();
updateSkipCounts();
});
seasonsHelp.createDiv({
cls: 'wl-modal-info',
text: 'One per line: "Season Name: N" (e.g. "Season 1: 12")',
text: 'Format: "Season Name: N" (e.g. "Season 1: 12")',
});
seasonsHelp.createDiv({
cls: 'wl-modal-info',
text: 'Skip episodes: add "(1,3,5-10)" after count (e.g. "Season 1: 48 (33-37)")',
});
seasonsHelp.createDiv({
cls: 'wl-modal-info',
@ -349,6 +439,7 @@ export class EditTitleModal extends Modal {
seasons,
dateStarted: this.fieldDateStarted || null,
dateFinished: this.fieldDateFinished || null,
manualPosterUrl: this.fieldPosterUrl.trim(),
};
// Auto-mark all episodes watched when status is set to Completed
@ -358,7 +449,7 @@ export class EditTitleModal extends Modal {
await this.dataManager.updateTitle(updated);
// Bug 2: sync updated releaseDate to any existing 'once' airtime entry
// Sync updated releaseDate to any existing 'once' airtime entry
const allEntries = this.dataManager.getAirtimeEntries();
const existingAirtimeEntry = allEntries.find((e) => e.titleId === updated.id);
if (existingAirtimeEntry && existingAirtimeEntry.schedule.recurrence === 'once') {

View file

@ -1,10 +1,16 @@
import { normalizePath } from 'obsidian';
import type WatchLogPlugin from './main';
export type HistorySource = 'Watchlist' | 'Reading';
export type HistoryAction = 'added' | 'completed' | 'deleted' | 'status' | 'rating' | 'watched';
export interface HistoryEntry {
id: string;
timestamp: string;
message: string;
source?: HistorySource;
action?: HistoryAction;
titleName?: string;
}
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
@ -41,16 +47,23 @@ export class HistoryManager {
const parsed = JSON.parse(raw) as { entries?: HistoryEntry[] };
this.entries = Array.isArray(parsed.entries) ? parsed.entries : [];
}
} catch {
} catch (e) {
console.warn('[WL] HistoryManager.load failed:', e);
this.entries = [];
}
}
async log(message: string): Promise<void> {
async log(
message: string,
meta?: { source?: HistorySource; action?: HistoryAction; titleName?: string },
): Promise<void> {
const entry: HistoryEntry = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
timestamp: new Date().toISOString(),
message,
source: meta?.source,
action: meta?.action,
titleName: meta?.titleName,
};
this.entries.push(entry);
if (this.entries.length > this.MAX_ENTRIES) {
@ -63,6 +76,18 @@ export class HistoryManager {
return [...this.entries].reverse();
}
/** Raw stored entries (oldest-first), for inclusion in a full backup. */
exportEntries(): HistoryEntry[] {
return [...this.entries];
}
/** Replace the entire audit log (used by full-snapshot restore). */
async restore(entries: HistoryEntry[]): Promise<void> {
const list = Array.isArray(entries) ? entries : [];
this.entries = list.length > this.MAX_ENTRIES ? list.slice(-this.MAX_ENTRIES) : list;
await this.save();
}
private async save(): Promise<void> {
try {
await this.plugin.app.vault.adapter.write(

File diff suppressed because it is too large Load diff

426
src/LogTab.ts Normal file
View file

@ -0,0 +1,426 @@
import type WatchLogPlugin from './main';
import type { HistoryEntry, HistorySource, HistoryAction } from './HistoryManager';
type SourceFilter = 'all' | 'Watchlist' | 'Reading';
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const ACTION_COLORS: Record<string, string> = {
completed: '#1D9E75',
watched: '#1D9E75',
added: '#378ADD',
deleted: '#E24B4A',
status: '#BA7517',
rating: '#BA7517',
};
// Leading source dot is colored by source (not action): green Reading, blue Watchlist.
const SOURCE_COLORS: Record<string, string> = {
Reading: '#1D9E75',
Watchlist: '#378ADD',
};
function inferAction(entry: HistoryEntry): HistoryAction {
if (entry.action) return entry.action;
const m = entry.message.toLowerCase();
if (m.includes('was added')) return 'added';
if (m.includes('was deleted')) return 'deleted';
if (m.includes('was reviewed') || m.includes('rating changed')) return 'rating';
if (m.includes('episode') && m.includes('watched')) return 'watched';
if (m.includes('was marked as watched') || m.includes('completed')) return 'completed';
if (m.includes('status')) return 'status';
return 'added';
}
function inferSource(entry: HistoryEntry): HistorySource {
if (entry.source) return entry.source;
return 'Watchlist';
}
function actionLabel(entry: HistoryEntry): string {
const action = inferAction(entry);
const m = entry.message;
switch (action) {
case 'added': return 'Added';
case 'deleted': return 'Deleted';
case 'rating': {
const rm = m.match(/Rating → (\d+\/5)/);
if (rm) return `Rating → ${rm[1]}`;
return 'Rating changed';
}
case 'status': {
const sm = m.match(/status changed to (.+)$/);
return sm ? `Status → ${sm[1]}` : 'Status changed';
}
case 'completed': return 'Completed';
case 'watched': {
const pm = m.match(/At (page|chapter|volume) (\d+) \/ (\d+)/i);
if (pm) return `At ${pm[1]} ${pm[2]} / ${pm[3]}`;
const em = m.match(/episode (\d+)/i);
return em ? `Episode ${em[1]} watched` : 'Watched';
}
}
return 'Updated';
}
function typeFromEntry(entry: HistoryEntry): string {
const m = entry.message.match(/\(([^)]+)\)/);
return m ? m[1]! : '';
}
function titleFromEntry(entry: HistoryEntry): string {
let title: string;
if (entry.titleName) {
title = entry.titleName;
} else {
const m = entry.message.match(/^(.+?)\s*\(/);
title = m ? m[1]! : entry.message;
}
const type = typeFromEntry(entry);
if (type) return `${title} (${type})`;
return title;
}
function formatDayHeader(dateStr: string): string {
const d = new Date(dateStr + 'T12:00:00');
return `${DAYS[d.getDay()]}, ${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
}
function formatTime(isoTs: string): string {
const d = new Date(isoTs);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function dateKey(isoTs: string): string {
const d = new Date(isoTs);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
interface VirtualRow {
type: 'header' | 'entry';
date?: string;
entry?: HistoryEntry;
isLastInGroup?: boolean;
}
const ROW_HEIGHT = 32;
const HEADER_HEIGHT = 40;
export class LogTab {
private container: HTMLElement;
private plugin: WatchLogPlugin;
private sourceFilter: SourceFilter = 'all';
private scrollContainer: HTMLElement | null = null;
private spacer: HTMLElement | null = null;
private viewport: HTMLElement | null = null;
private rows: VirtualRow[] = [];
private rowOffsets: number[] = [];
private totalHeight = 0;
private scrollHandler: (() => void) | null = null;
private scrollRAF: number | null = null;
private resizeObserver: ResizeObserver | null = null;
private lastFirst = -1;
private lastLast = -1;
private renderedNodes: Map<number, HTMLElement> = new Map();
constructor(container: HTMLElement, plugin: WatchLogPlugin) {
this.container = container;
this.plugin = plugin;
}
destroy(): void {
this.destroyVirtualScroll();
}
render(): void {
this.destroyVirtualScroll();
this.container.empty();
this.container.addClass('wl-log-tab');
this.renderFilterBar();
const allEntries = this.plugin.historyManager?.getEntries() ?? [];
const filtered = this.sourceFilter === 'all'
? allEntries
: allEntries.filter((e) => inferSource(e) === this.sourceFilter);
if (filtered.length === 0) {
this.container.createDiv({
cls: 'wl-log-empty',
text: this.sourceFilter === 'all'
? 'No events yet. Actions from Watchlist and Reading will appear here.'
: `No ${this.sourceFilter} events yet.`,
});
return;
}
this.buildVirtualRows(filtered);
this.renderVirtualTimeline();
}
private renderFilterBar(): void {
const bar = this.container.createDiv({ cls: 'wl-log-filter-bar' });
const filters: { label: string; value: SourceFilter }[] = [
{ label: 'All', value: 'all' },
{ label: 'Watchlist', value: 'Watchlist' },
{ label: 'Reading', value: 'Reading' },
];
for (const f of filters) {
const btn = bar.createEl('button', {
cls: `wl-log-filter-btn${this.sourceFilter === f.value ? ' is-active' : ''}`,
text: f.label,
});
btn.addEventListener('click', () => {
if (this.sourceFilter === f.value) return;
this.sourceFilter = f.value;
this.render();
});
}
}
private buildVirtualRows(entries: HistoryEntry[]): void {
this.rows = [];
this.rowOffsets = [];
const map = new Map<string, HistoryEntry[]>();
for (const entry of entries) {
const key = dateKey(entry.timestamp);
let list = map.get(key);
if (!list) {
list = [];
map.set(key, list);
}
list.push(entry);
}
const groups: { date: string; entries: HistoryEntry[] }[] = [];
for (const [date, list] of map) {
groups.push({ date, entries: list });
}
let offset = 0;
for (const group of groups) {
this.rows.push({ type: 'header', date: group.date });
this.rowOffsets.push(offset);
offset += HEADER_HEIGHT;
for (let i = 0; i < group.entries.length; i++) {
this.rows.push({
type: 'entry',
entry: group.entries[i],
isLastInGroup: i === group.entries.length - 1,
});
this.rowOffsets.push(offset);
offset += ROW_HEIGHT;
}
}
this.totalHeight = offset;
}
private renderVirtualTimeline(): void {
const scroll = this.container.createDiv({ cls: 'wl-log-timeline wl-log-virtual-scroll' });
const spacer = scroll.createDiv({ cls: 'wl-log-spacer' });
const viewport = spacer.createDiv({ cls: 'wl-log-viewport' });
this.scrollContainer = scroll;
this.spacer = spacer;
this.viewport = viewport;
this.renderedNodes.clear();
this.lastFirst = -1;
this.lastLast = -1;
spacer.style.height = `${this.totalHeight}px`;
spacer.style.position = 'relative';
viewport.style.position = 'absolute';
viewport.style.top = '0';
viewport.style.left = '0';
viewport.style.right = '0';
viewport.style.height = `${this.totalHeight}px`;
this.scrollHandler = () => {
if (this.scrollRAF !== null) return;
this.scrollRAF = window.requestAnimationFrame(() => {
this.scrollRAF = null;
this.renderVisibleRows();
});
};
scroll.addEventListener('scroll', this.scrollHandler, { passive: true });
this.resizeObserver = new ResizeObserver(() => {
this.renderedNodes.clear();
if (this.viewport) this.viewport.empty();
this.lastFirst = -1;
this.lastLast = -1;
this.renderVisibleRows();
});
this.resizeObserver.observe(scroll);
this.renderVisibleRows();
}
private renderVisibleRows(): void {
const scroll = this.scrollContainer;
const viewport = this.viewport;
if (!scroll || !viewport) return;
const scrollTop = scroll.scrollTop;
const viewHeight = scroll.clientHeight;
if (viewHeight <= 0) return;
const first = this.findFirstVisible(scrollTop);
const last = this.findLastVisible(scrollTop + viewHeight);
if (first === this.lastFirst && last === this.lastLast) return;
this.lastFirst = first;
this.lastLast = last;
// Remove rows that left the visible window
for (const [idx, el] of this.renderedNodes) {
if (idx < first || idx > last) {
el.remove();
this.renderedNodes.delete(idx);
}
}
// Add rows that newly entered the visible window
for (let i = first; i <= last; i++) {
if (this.renderedNodes.has(i)) continue;
const row = this.rows[i];
if (!row) continue;
let el: HTMLElement;
if (row.type === 'header') {
el = activeDocument.createElement('div');
el.className = 'wl-log-day-header';
el.style.height = `${HEADER_HEIGHT}px`;
el.textContent = formatDayHeader(row.date!);
} else {
el = this.buildEntryRow(row);
}
const offset = this.rowOffsets[i] ?? 0;
el.style.position = 'absolute';
el.style.top = `${offset}px`;
el.style.left = '0';
el.style.right = '0';
viewport.appendChild(el);
this.renderedNodes.set(i, el);
}
}
private buildEntryRow(row: VirtualRow): HTMLElement {
const entry = row.entry!;
const action = inferAction(entry);
const color = ACTION_COLORS[action] ?? '#888780';
const source = inferSource(entry);
const dotColor = SOURCE_COLORS[source] ?? '#888780';
const el = activeDocument.createElement('div');
el.className = 'wl-log-entry';
el.style.height = `${ROW_HEIGHT}px`;
const dotCol = activeDocument.createElement('div');
dotCol.className = 'wl-log-dot-col';
const dot = activeDocument.createElement('div');
dot.className = 'wl-log-dot';
dot.style.backgroundColor = dotColor;
dotCol.appendChild(dot);
if (!row.isLastInGroup) {
const connector = activeDocument.createElement('div');
connector.className = 'wl-log-connector';
dotCol.appendChild(connector);
}
el.appendChild(dotCol);
const content = activeDocument.createElement('div');
content.className = 'wl-log-content';
const srcSpan = activeDocument.createElement('span');
srcSpan.className = 'wl-log-source';
srcSpan.textContent = source;
content.appendChild(srcSpan);
const sep1 = activeDocument.createElement('span');
sep1.className = 'wl-log-sep';
sep1.textContent = ' · ';
content.appendChild(sep1);
const titleSpan = activeDocument.createElement('span');
titleSpan.className = 'wl-log-title';
titleSpan.textContent = titleFromEntry(entry);
content.appendChild(titleSpan);
const sep2 = activeDocument.createElement('span');
sep2.className = 'wl-log-sep';
sep2.textContent = ' — ';
content.appendChild(sep2);
const actionSpan = activeDocument.createElement('span');
actionSpan.className = 'wl-log-action';
actionSpan.textContent = actionLabel(entry);
actionSpan.style.color = color;
content.appendChild(actionSpan);
el.appendChild(content);
const timeEl = activeDocument.createElement('div');
timeEl.className = 'wl-log-time';
timeEl.textContent = formatTime(entry.timestamp);
el.appendChild(timeEl);
return el;
}
private findFirstVisible(scrollTop: number): number {
let lo = 0;
let hi = this.rows.length - 1;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
const rowEnd = this.rowOffsets[mid]! + (this.rows[mid]!.type === 'header' ? HEADER_HEIGHT : ROW_HEIGHT);
if (rowEnd <= scrollTop) {
lo = mid + 1;
} else {
hi = mid;
}
}
return Math.max(0, lo - 2);
}
private findLastVisible(bottom: number): number {
let lo = 0;
let hi = this.rows.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >>> 1;
if (this.rowOffsets[mid]! >= bottom) {
hi = mid - 1;
} else {
lo = mid;
}
}
return Math.min(this.rows.length - 1, lo + 2);
}
private destroyVirtualScroll(): void {
if (this.scrollRAF !== null) {
window.cancelAnimationFrame(this.scrollRAF);
this.scrollRAF = null;
}
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
if (this.scrollContainer && this.scrollHandler) {
this.scrollContainer.removeEventListener('scroll', this.scrollHandler);
}
this.scrollHandler = null;
this.scrollContainer = null;
this.spacer = null;
this.viewport = null;
this.renderedNodes.clear();
this.lastFirst = -1;
this.lastLast = -1;
}
}

View file

@ -1,8 +1,8 @@
import { App, Modal, Notice } from 'obsidian';
import type WatchLogPlugin from './main';
import type { DataManager } from './DataManager';
import type { MaybeTitle, AnimeSearchResult, MediaSearchResult, Season } from './types';
import { parseReleaseDateInput } from './types';
import type { MaybeTitle, AnimeSearchResult, MediaSearchResult } from './types';
import { parseReleaseDateInput, getApiGroupForType } from './types';
type SearchResult = AnimeSearchResult | MediaSearchResult;
@ -19,14 +19,13 @@ export class MaybeAddModal extends Modal {
private searchQuery = '';
private searchResults: SearchResult[] = [];
private isSearching = false;
private autoSearch = false;
private searchGeneration = 0;
private fieldTitle = '';
private fieldEpisodes = 0;
private fieldDuration = 0;
private fieldReleaseDate = '';
private fieldLink = '';
private fieldSeasons: Season[] = [];
private resultsEl: HTMLElement | null = null;
private formEl: HTMLElement | null = null;
@ -95,26 +94,38 @@ export class MaybeAddModal extends Modal {
private async performSearch(): Promise<void> {
if (!this.searchQuery.trim()) return;
const gen = ++this.searchGeneration;
this.isSearching = true;
this.renderResults();
try {
const api = this.plugin.apiService;
const activeApi = this.plugin.settings.activeApi ?? 'OMDb';
const isAnime = this.selectedType === 'Anime';
const apiGroup = getApiGroupForType(this.selectedType, this.plugin.settings.typeApiMapping);
const isMovie = this.selectedType === 'Movie';
if (isAnime) {
this.searchResults = await api.searchAnime(this.searchQuery);
let results: SearchResult[] = [];
if (apiGroup === '') {
new Notice(`No API configured for type "${this.selectedType}". Configure it in Settings → API.`);
} else if (apiGroup === 'anime') {
const animeSource = this.plugin.settings.animeApiSource ?? 'jikan';
results = animeSource === 'anilist'
? await api.searchAniList(this.searchQuery)
: await api.searchAnime(this.searchQuery);
} else if (activeApi === 'TMDB') {
this.searchResults = await api.searchTmdb(this.searchQuery, isMovie ? 'movie' : 'series');
results = await api.searchTmdb(this.searchQuery, isMovie ? 'movie' : 'series');
} else {
this.searchResults = await api.searchOmdb(this.searchQuery, isMovie ? 'movie' : 'series');
results = await api.searchOmdb(this.searchQuery, isMovie ? 'movie' : 'series');
}
if (gen !== this.searchGeneration) return; // stale
this.searchResults = results;
} catch {
if (gen !== this.searchGeneration) return;
new Notice('Search failed. Check your connection and API settings.');
this.searchResults = [];
} finally {
this.isSearching = false;
this.renderResults();
if (gen === this.searchGeneration) {
this.isSearching = false;
this.renderResults();
}
}
}
@ -138,26 +149,31 @@ export class MaybeAddModal extends Modal {
for (const child of Array.from(this.resultsEl.children)) child.removeClass('is-selected');
}
itemEl.addClass('is-selected');
const gen = ++this.searchGeneration;
const api = this.plugin.apiService;
const activeApi = this.plugin.settings.activeApi ?? 'OMDb';
try {
if (!isAnimeResult(result) && result.mediaType === 'tv') {
const full = activeApi === 'TMDB' ? await api.getTmdbTvDetails(result.imdbId) : await api.getOmdbTvDetails(result.imdbId);
if (full) { this.fieldTitle = full.title; this.fieldEpisodes = full.episodes; this.fieldDuration = full.episodeDuration; this.fieldReleaseDate = full.releaseDate; this.fieldLink = full.url; this.fieldSeasons = full.seasons; }
if (gen !== this.searchGeneration) return;
if (full) { this.fieldTitle = full.title; this.fieldEpisodes = full.episodes; this.fieldDuration = full.episodeDuration; this.fieldReleaseDate = full.releaseDate; this.fieldLink = full.url; }
} else if (!isAnimeResult(result) && result.mediaType === 'movie') {
const full = activeApi === 'TMDB' ? await api.getTmdbMovieDetails(result.imdbId) : await api.getOmdbMovieDetails(result.imdbId);
if (full) { this.fieldTitle = full.title; this.fieldEpisodes = full.episodes; this.fieldDuration = full.episodeDuration; this.fieldReleaseDate = full.releaseDate; this.fieldLink = full.url; this.fieldSeasons = full.seasons; }
if (gen !== this.searchGeneration) return;
if (full) { this.fieldTitle = full.title; this.fieldEpisodes = full.episodes; this.fieldDuration = full.episodeDuration; this.fieldReleaseDate = full.releaseDate; this.fieldLink = full.url; }
} else {
const anime = result as AnimeSearchResult;
this.fieldTitle = anime.title; this.fieldEpisodes = anime.episodes; this.fieldDuration = anime.duration;
this.fieldReleaseDate = anime.releaseDate; this.fieldLink = anime.url; this.fieldSeasons = anime.seasons;
this.fieldReleaseDate = anime.releaseDate; this.fieldLink = anime.url;
}
} catch {
if (gen !== this.searchGeneration) return;
this.fieldTitle = result.title;
this.fieldEpisodes = isAnimeResult(result) ? result.episodes : result.episodes;
this.fieldEpisodes = result.episodes;
this.fieldDuration = isAnimeResult(result) ? result.duration : result.episodeDuration;
this.fieldReleaseDate = result.releaseDate; this.fieldLink = result.url; this.fieldSeasons = result.seasons;
this.fieldReleaseDate = result.releaseDate; this.fieldLink = result.url;
}
if (gen !== this.searchGeneration) return;
this.renderForm();
}

259
src/PosterService.ts Normal file
View file

@ -0,0 +1,259 @@
import { requestUrl } from 'obsidian';
import type { DataManager } from './DataManager';
import type { WatchLogTitle, WatchLogPluginSettings } from './types';
import { getApiGroupForType } from './types';
const JIKAN_BASE = 'https://api.jikan.moe/v4';
const OMDB_BASE = 'https://www.omdbapi.com';
const TMDB_BASE = 'https://api.themoviedb.org/3';
const TMDB_IMG_BASE = 'https://image.tmdb.org/t/p/w300';
const ANILIST_ENDPOINT = 'https://graphql.anilist.co';
const API_TIMEOUT_MS = 8000;
// Rate-limit delays between consecutive queued requests
const JIKAN_DELAY_MS = 400; // ≈2.5 req/sec (Jikan caps at 3/sec)
const ANILIST_DELAY_MS = 700; // AniList caps at 90/min
const TMDB_DELAY_MS = 30; // ≈33 req/sec (TMDB caps at 40/sec)
const OMDB_DELAY_MS = 100;
interface QueueItem {
title: WatchLogTitle;
resolve: (url: string | null) => void;
}
interface JikanImagesShape {
data?: {
images?: {
jpg?: {
image_url?: string;
large_image_url?: string;
};
};
};
// Search shape: data is an array
}
interface JikanSearchShape {
data?: Array<{
images?: {
jpg?: {
image_url?: string;
large_image_url?: string;
};
};
}>;
}
interface TmdbMultiResult {
results?: Array<{ poster_path?: string | null }>;
}
interface OmdbResult {
Poster?: string;
Response?: string;
}
interface AniListPosterResponse {
data?: {
Media?: {
coverImage?: { large?: string | null; medium?: string | null } | null;
} | null;
};
}
export class PosterService {
private dataManager: DataManager;
private getSettings: () => WatchLogPluginSettings;
private queue: QueueItem[] = [];
private isProcessing = false;
private disposed = false;
constructor(
dataManager: DataManager,
getSettings: () => WatchLogPluginSettings,
) {
this.dataManager = dataManager;
this.getSettings = getSettings;
}
destroy(): void {
this.disposed = true;
this.clearQueue();
}
/**
* Enqueue a poster fetch for the given title. Resolves with the URL or null.
* The result is also persisted via DataManager.updatePosterUrl (silent + debounced).
*/
enqueue(title: WatchLogTitle): Promise<string | null> {
return new Promise((resolve) => {
if (this.disposed) { resolve(null); return; }
this.queue.push({ title, resolve });
void this.processQueue();
});
}
/** Empty the queue and resolve all pending promises with null. */
clearQueue(): void {
const pending = this.queue.splice(0);
for (const item of pending) {
item.resolve(null);
}
}
private async processQueue(): Promise<void> {
if (this.isProcessing) return;
this.isProcessing = true;
while (this.queue.length > 0) {
if (this.disposed) break;
const item = this.queue.shift();
if (!item) break;
// Manual override: don't auto-fetch, don't touch posterUrl.
if (item.title.manualPosterUrl && item.title.manualPosterUrl.trim() !== '') {
item.resolve(item.title.manualPosterUrl);
continue;
}
try {
const url = await this.fetchPosterForTitle(item.title);
if (this.disposed) { item.resolve(null); break; }
const finalUrl = url || 'none';
this.dataManager.updatePosterUrl(item.title.id, finalUrl);
item.resolve(url);
} catch {
if (!this.disposed) this.dataManager.updatePosterUrl(item.title.id, 'none');
item.resolve(null);
}
const delay = this.getDelayForTitle(item.title);
if (delay > 0) {
await new Promise<void>((r) => window.setTimeout(r, delay));
}
}
this.isProcessing = false;
}
private getDelayForTitle(title: WatchLogTitle): number {
const settings = this.getSettings();
const group = getApiGroupForType(title.type, settings.typeApiMapping);
if (group === 'anime') {
if (title.anilistId && title.anilistId > 0) return ANILIST_DELAY_MS;
return JIKAN_DELAY_MS;
}
if (settings.tmdbApiKey) return TMDB_DELAY_MS;
if (settings.omdbApiKey) return OMDB_DELAY_MS;
return 0;
}
private async fetchPosterForTitle(title: WatchLogTitle): Promise<string | null> {
// Already cached
if (title.posterUrl && title.posterUrl.startsWith('http')) return title.posterUrl;
if (title.posterUrl === 'none') return null;
const settings = this.getSettings();
const group = getApiGroupForType(title.type, settings.typeApiMapping);
if (group === 'anime') return this.fetchAnimePoster(title);
if (group === 'movie') return this.fetchMediaPoster(title);
return null;
}
private cleanTitleForSearch(name: string): string {
const cleaned = name
.replace(/\s*-?\s*[Ss]eason\s*\d+/gi, '')
.replace(/\s*-?\s*[Ss]eries\s*\d+/gi, '')
.replace(/\s*-?\s*[Pp]art\s*\d+/gi, '')
.replace(/\s*-?\s*[Vv]ol(ume)?\.?\s*\d+/gi, '')
.replace(/\s*\(\d{4}\)/g, '')
.replace(/\s*-?\s*[Ss]\d+/gi, '')
.replace(/\s+/g, ' ')
.trim();
return cleaned || name;
}
private async fetchAnimePoster(title: WatchLogTitle): Promise<string | null> {
if (title.anilistId && title.anilistId > 0) {
const gql = `query ($id: Int) { Media(id: $id, type: ANIME) { coverImage { large medium } } }`;
const data = (await this.postJson(ANILIST_ENDPOINT, {
query: gql,
variables: { id: title.anilistId },
})) as AniListPosterResponse | null;
const cover = data?.data?.Media?.coverImage;
return cover?.large ?? cover?.medium ?? null;
}
if (title.malId) {
const url = `${JIKAN_BASE}/anime/${title.malId}`;
const data = (await this.fetchJson(url)) as JikanImagesShape | null;
const jpg = data?.data?.images?.jpg;
return jpg?.large_image_url ?? jpg?.image_url ?? null;
}
const q = encodeURIComponent(this.cleanTitleForSearch(title.title));
const url = `${JIKAN_BASE}/anime?q=${q}&limit=1`;
const data = (await this.fetchJson(url)) as JikanSearchShape | null;
const first = data?.data?.[0];
const jpg = first?.images?.jpg;
return jpg?.large_image_url ?? jpg?.image_url ?? null;
}
private async fetchMediaPoster(title: WatchLogTitle): Promise<string | null> {
const settings = this.getSettings();
const searchName = this.cleanTitleForSearch(title.title);
if (settings.tmdbApiKey) {
const q = encodeURIComponent(searchName);
const url = `${TMDB_BASE}/search/multi?query=${q}&page=1`;
const data = (await this.fetchJson(url, {
Authorization: `Bearer ${settings.tmdbApiKey}`,
})) as TmdbMultiResult | null;
const poster = data?.results?.[0]?.poster_path;
if (poster) return `${TMDB_IMG_BASE}${poster}`;
return null;
}
if (settings.omdbApiKey) {
const q = encodeURIComponent(searchName);
const url = `${OMDB_BASE}/?t=${q}&apikey=${encodeURIComponent(settings.omdbApiKey)}`;
const data = (await this.fetchJson(url)) as OmdbResult | null;
const poster = data?.Poster;
if (poster && poster !== 'N/A') return poster;
return null;
}
return null;
}
private async fetchJson(
url: string,
headers?: Record<string, string>,
): Promise<unknown> {
let timer: number | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = window.setTimeout(() => reject(new Error('Request timed out')), API_TIMEOUT_MS);
});
try {
const fetchPromise = requestUrl({ url, headers }).then((r) => r.json as unknown);
return await Promise.race([fetchPromise, timeoutPromise]);
} finally {
if (timer !== null) window.clearTimeout(timer);
}
}
private async postJson(
url: string,
body: unknown,
headers?: Record<string, string>,
): Promise<unknown> {
let timer: number | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = window.setTimeout(() => reject(new Error('Request timed out')), API_TIMEOUT_MS);
});
try {
const fetchPromise = requestUrl({
url,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(headers ?? {}) },
body: JSON.stringify(body),
}).then((r) => r.json as unknown);
return await Promise.race([fetchPromise, timeoutPromise]);
} finally {
if (timer !== null) window.clearTimeout(timer);
}
}
}

View file

@ -0,0 +1,50 @@
import { App, Modal } from 'obsidian';
export type ReadingCsvKind = 'book' | 'manga';
/**
* Gate modal for Reading CSV import/export. Mirrors the "Add draft" choice modal
* (title top-left, × top-right, a context line, equal-width horizontal buttons):
* the user first picks Books or Manga, then the kind-specific Reading CSV modal opens.
*/
export class ReadingCsvChoiceModal extends Modal {
constructor(
app: App,
private mode: 'export' | 'import',
private onChoice: (kind: ReadingCsvKind) => void,
) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('wl-draft-choice-modal');
this.titleEl.setText(this.mode === 'export' ? 'Export to CSV' : 'Import from CSV');
contentEl.createDiv({
cls: 'wl-draft-choice-subtitle',
text: this.mode === 'export'
? 'Which library do you want to export?'
: 'Which library do you want to import into?',
});
const grid = contentEl.createDiv({ cls: 'wl-draft-choice-grid' });
const makeOption = (label: string, kind: ReadingCsvKind): void => {
const btn = grid.createEl('button', { cls: 'wl-draft-choice-btn' });
btn.createDiv({ cls: 'wl-draft-choice-label', text: label });
btn.addEventListener('click', () => {
this.close();
this.onChoice(kind);
});
};
makeOption('Books', 'book');
makeOption('Manga', 'manga');
}
onClose(): void {
this.contentEl.empty();
}
}

838
src/ReadingCsvModal.ts Normal file
View file

@ -0,0 +1,838 @@
import { App, Modal, Notice } from 'obsidian';
import type WatchLogPlugin from './main';
import type { Book, Manga, ReadingStatus } from './types';
import { READING_STATUSES, SELECTABLE_READING_STATUSES } from './types';
import type { ReadingCsvKind } from './ReadingCsvChoiceModal';
// ── CSV helpers (self-contained — deliberately not shared with the watch CsvModal) ──
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;
}
/** Full CSV parser: quoted fields with embedded newlines, escaped quotes, comma delimiter, CRLF. */
function parseCSV(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let cur = '';
let inQuotes = false;
const t = text.replace(/\r\n?/g, '\n');
for (let i = 0; i < t.length; i++) {
const ch = t[i];
if (inQuotes) {
if (ch === '"') {
if (t[i + 1] === '"') { cur += '"'; i++; }
else { inQuotes = false; }
} else {
cur += ch;
}
} else {
if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
row.push(cur); cur = '';
} else if (ch === '\n') {
row.push(cur); cur = '';
rows.push(row); row = [];
} else {
cur += ch;
}
}
}
if (cur.length > 0 || row.length > 0) {
row.push(cur);
rows.push(row);
}
while (rows.length > 0 && rows[rows.length - 1]!.every((c) => c === '')) rows.pop();
return rows;
}
// ── Date parsing (same logic as the watch CSV path) ─────────────────────────────
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 {
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
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; }
else if (b > 12) { mm = a; dd = b; }
else { dd = a; mm = b; }
if (mm < 1 || mm > 12 || dd < 1 || dd > 31) return null;
return `${y}-${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`;
}
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')}`;
}
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')}`;
}
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')}`;
}
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;
}
}
// ── Schemas (Books and Manga kept fully separate) ───────────────────────────────
type Coercion = 'string' | 'int' | 'rating' | 'date';
interface ReadingFieldDef {
key: string;
label: string;
coercion: Coercion;
autoDetect: string[];
}
// Export column order (includes dateAdded, which is export-only).
const BOOK_EXPORT_COLUMNS = [
'title', 'author', 'status', 'rating',
'pagesRead', 'totalPages', 'chaptersRead', 'totalChapters',
'dateStarted', 'dateFinished', 'releaseDate', 'dateAdded',
'externalLink', 'googleBooksId',
] as const;
const MANGA_EXPORT_COLUMNS = [
'title', 'author', 'status', 'rating',
'chaptersRead', 'totalChapters', 'volumesRead', 'totalVolumes',
'dateStarted', 'dateFinished', 'releaseDate', 'dateAdded',
'externalLink', 'malId',
] as const;
// Import targets — same as export minus dateAdded (auto-set on insert).
const COMMON_HEAD: ReadingFieldDef[] = [
{ key: 'title', label: 'Title', coercion: 'string', autoDetect: ['title', 'name'] },
{ key: 'author', label: 'Author', coercion: 'string', autoDetect: ['author', 'writer', 'by'] },
{ key: 'status', label: 'Status', coercion: 'string', autoDetect: ['status'] },
{ key: 'rating', label: 'Rating', coercion: 'rating', autoDetect: ['rating', 'score'] },
];
const COMMON_TAIL: ReadingFieldDef[] = [
{ key: 'dateStarted', label: 'Date Started', coercion: 'date', autoDetect: ['started', 'datestarted', 'date started', 'date_started', 'start date'] },
{ key: 'dateFinished', label: 'Date Finished', coercion: 'date', autoDetect: ['finished', 'datefinished', 'date finished', 'date_finished', 'end date', 'finish date', 'completed date'] },
{ key: 'releaseDate', label: 'Release Date', coercion: 'date', autoDetect: ['releasedate', 'release date', 'release_date', 'published', 'publish date'] },
{ key: 'externalLink', label: 'Link', coercion: 'string', autoDetect: ['link', 'externallink', 'external link', 'external_link', 'url'] },
];
const BOOK_IMPORT_FIELDS: ReadingFieldDef[] = [
...COMMON_HEAD,
{ key: 'pagesRead', label: 'Pages Read', coercion: 'int', autoDetect: ['pagesread', 'pages read', 'read pages', 'page'] },
{ key: 'totalPages', label: 'Total Pages', coercion: 'int', autoDetect: ['totalpages', 'total pages', 'pages', 'page count'] },
{ key: 'chaptersRead', label: 'Chapters Read', coercion: 'int', autoDetect: ['chaptersread', 'chapters read', 'read chapters'] },
{ key: 'totalChapters', label: 'Total Chapters', coercion: 'int', autoDetect: ['totalchapters', 'total chapters', 'chapters', 'chapter count'] },
...COMMON_TAIL,
{ key: 'googleBooksId', label: 'Google Books ID', coercion: 'string', autoDetect: ['googlebooksid', 'google books id', 'gbid', 'volumeid'] },
];
const MANGA_IMPORT_FIELDS: ReadingFieldDef[] = [
...COMMON_HEAD,
{ key: 'chaptersRead', label: 'Chapters Read', coercion: 'int', autoDetect: ['chaptersread', 'chapters read', 'read chapters'] },
{ key: 'totalChapters', label: 'Total Chapters', coercion: 'int', autoDetect: ['totalchapters', 'total chapters', 'chapters', 'chapter count'] },
{ key: 'volumesRead', label: 'Volumes Read', coercion: 'int', autoDetect: ['volumesread', 'volumes read', 'read volumes'] },
{ key: 'totalVolumes', label: 'Total Volumes', coercion: 'int', autoDetect: ['totalvolumes', 'total volumes', 'volumes', 'volume count'] },
...COMMON_TAIL,
{ key: 'malId', label: 'MAL ID', coercion: 'string', autoDetect: ['malid', 'mal id', 'myanimelist id', 'mal'] },
];
interface ReadingSchema {
exportColumns: readonly string[];
importFields: ReadingFieldDef[];
}
function schemaFor(kind: ReadingCsvKind): ReadingSchema {
return kind === 'book'
? { exportColumns: BOOK_EXPORT_COLUMNS, importFields: BOOK_IMPORT_FIELDS }
: { exportColumns: MANGA_EXPORT_COLUMNS, importFields: MANGA_IMPORT_FIELDS };
}
function autoMap(fields: ReadingFieldDef[], headers: string[]): Record<string, string> {
const mapping: Record<string, string> = {};
for (const field of fields) {
const match = headers.find((h) => field.autoDetect.includes(h.toLowerCase()));
mapping[field.key] = match ?? '';
}
return mapping;
}
// ── Modal ───────────────────────────────────────────────────────────────────────
type ReadingEntry = Partial<Book & Manga>;
export class ReadingCsvModal extends Modal {
private plugin: WatchLogPlugin;
private kind: ReadingCsvKind;
private mode: 'export' | 'import';
private schema: ReadingSchema;
private get kindLabel(): string {
return this.kind === 'book' ? 'Books' : 'Manga';
}
private get itemNoun(): string {
return this.kind === 'book' ? 'book' : 'manga';
}
// Export state
private selectedIds = new Set<string>();
// Import state
private csvRows: string[][] = [];
private csvHeaders: string[] = [];
private columnMapping: Record<string, string> = {};
private importRows: { entry: ReadingEntry; isDuplicate: boolean; selected: boolean }[] = [];
// Value-mapping state
private statusValueMap: Record<string, string> = {}; // csvValue → resolved status ('' = blank)
private ratingValueMap: Record<string, string> = {}; // csvValue → '1''5' or '' (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;
// 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, kind: ReadingCsvKind, mode: 'export' | 'import') {
super(app);
this.plugin = plugin;
this.kind = kind;
this.mode = mode;
this.schema = schemaFor(kind);
}
private getItems(): (Book | Manga)[] {
return this.kind === 'book'
? this.plugin.readingDataManager.getBooks()
: this.plugin.readingDataManager.getMangaList();
}
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 itemsToCsv(items: (Book | Manga)[]): string {
const cols = this.schema.exportColumns;
const header = cols.join(',');
const rows = items.map((it) =>
cols.map((col) => escapeCsvField((it as unknown as Record<string, string | number | null | undefined>)[col])).join(','),
);
return [header, ...rows].join('\n');
}
private renderExport(el: HTMLElement): void {
el.createEl('h2', { cls: 'wl-modal-title', text: `Export ${this.kindLabel} to CSV` });
const items = this.getItems();
items.forEach((it) => this.selectedIds.add(it.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: `${items.length} selected` });
const listEl = el.createDiv({ cls: 'wl-csv-list' });
const checkboxes: HTMLInputElement[] = [];
const updateCount = (): void => {
countEl.textContent = `${this.selectedIds.size} selected`;
};
for (const item of items) {
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: item.title });
const meta = [item.author, item.status].filter(Boolean).join(' · ');
if (meta) row.createSpan({ cls: 'wl-csv-row-meta', text: meta });
cb.addEventListener('change', () => {
if (cb.checked) this.selectedIds.add(item.id);
else this.selectedIds.delete(item.id);
updateCount();
});
}
selectAllBtn.addEventListener('click', () => {
items.forEach((it) => this.selectedIds.add(it.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 = items.filter((it) => this.selectedIds.has(it.id));
if (toExport.length === 0) {
new Notice(`No ${this.itemNoun} selected.`);
return;
}
const csv = this.itemsToCsv(toExport);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = activeDocument.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-${this.itemNoun === 'book' ? 'books' : 'manga'}-export-${dateStr}.csv`;
a.click();
URL.revokeObjectURL(url);
new Notice(`Exported ${toExport.length} ${this.kindLabel.toLowerCase()}.`);
this.close();
});
}
// ── Import ──────────────────────────────────────────────────────────────────
private renderImport(el: HTMLElement): void {
el.createEl('h2', { cls: 'wl-modal-title', text: `Import ${this.kindLabel} 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' });
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.csvRows = parseCSV(text);
this.csvHeaders = (this.csvRows[0] ?? []).map((h) => h.trim());
this.columnMapping = autoMap(this.schema.importFields, 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 ${this.itemNoun === 'book' ? 'Book' : 'Manga'} 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 entries, 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 this.schema.importFields) {
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 this.schema.importFields) {
this.columnMapping[field.key] = selectEls[field.key]?.value ?? '';
}
this.importRows = this.applyMapping()
.map((r) => ({ ...r, selected: !r.isDuplicate }));
// Unknown statuses and non-numeric ratings → value-mapping step.
const knownStatuses = new Set<string>(READING_STATUSES as string[]);
const unknownStatuses: string[] = [];
const unknownRatings: string[] = [];
const seenStatuses = new Set<string>();
const seenRatings = new Set<string>();
const headers = (this.csvRows[0] ?? []).map((h) => h.trim());
const ratingColName = this.columnMapping['rating'];
const ratingIdx = ratingColName ? headers.indexOf(ratingColName) : -1;
for (const r of this.importRows) {
const sv = (r.entry.status as string | undefined)?.trim();
if (sv && !knownStatuses.has(sv) && !seenStatuses.has(sv)) {
unknownStatuses.push(sv);
seenStatuses.add(sv);
}
if (ratingIdx >= 0) {
const lineIdx = this.importRows.indexOf(r);
const rawValues = this.csvRows[lineIdx + 1] ?? [];
const rawRating = (rawValues[ratingIdx] ?? '').trim();
if (rawRating && isNaN(parseFloat(rawRating)) && !seenRatings.has(rawRating)) {
unknownRatings.push(rawRating);
seenRatings.add(rawRating);
}
}
}
if (unknownStatuses.length > 0 || unknownRatings.length > 0) {
this.showValueMapStep(unknownStatuses, unknownRatings);
} else {
this.showPreviewStep();
}
});
}
private applyMapping(): { entry: ReadingEntry; isDuplicate: boolean }[] {
if (this.csvRows.length < 2) return [];
const headers = (this.csvRows[0] ?? []).map((h) => h.trim());
const existing = this.getItems();
return this.csvRows.slice(1)
.filter((r) => r.some((c) => c.trim()))
.map((values): { entry: ReadingEntry; isDuplicate: boolean } | null => {
const get = (fieldKey: string): string => {
const colName = this.columnMapping[fieldKey];
if (!colName) return '';
const idx = headers.indexOf(colName);
return idx >= 0 ? (values[idx] ?? '').trim() : '';
};
const entry: ReadingEntry = {};
for (const field of this.schema.importFields) {
const raw = get(field.key);
if (!raw) continue;
switch (field.coercion) {
case 'string':
(entry as Record<string, unknown>)[field.key] = raw;
break;
case 'int':
(entry as Record<string, unknown>)[field.key] = parseInt(raw) || 0;
break;
case 'rating': {
const v = parseFloat(raw) || 0;
entry.rating = Math.max(0, Math.min(5, v));
break;
}
case 'date': {
const d = parseCsvDate(raw);
if (d) (entry as Record<string, unknown>)[field.key] = d;
break;
}
}
}
if (!entry.title || !entry.title.trim()) return null;
const isDuplicate = !!existing.find(
(it) => it.title.toLowerCase() === (entry.title ?? '').toLowerCase(),
);
return { entry, isDuplicate };
})
.filter((r): r is { entry: ReadingEntry; isDuplicate: boolean } => r !== null);
}
// ── Step: Value Mapping ───────────────────────────────────────────────────────
private showValueMapStep(unknownStatuses: string[], unknownRatings: string[]): void {
if (!this.stepMapping || !this.stepValueMap) return;
this.stepMapping.hide();
this.stepValueMap.show();
this.renderValueMapStep(this.stepValueMap, unknownStatuses, unknownRatings);
}
private renderValueMapStep(el: HTMLElement, unknownStatuses: 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 leave it blank.',
});
// Reading statuses are a fixed set — no "create new status" option (unlike watch types).
const statusSelects: 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 SELECTABLE_READING_STATUSES) {
sel.createEl('option', { value: name, text: name });
}
const lc = sv.toLowerCase();
const match = SELECTABLE_READING_STATUSES.find((s) => s.toLowerCase() === lc);
if (match) sel.value = match;
}
}
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', () => {
this.statusValueMap = {};
for (const sv of unknownStatuses) this.statusValueMap[sv] = statusSelects[sv]?.value ?? '';
this.ratingValueMap = {};
for (const rv of unknownRatings) this.ratingValueMap[rv] = ratingSelects[rv]?.value ?? '';
this.applyValueMapsToRows();
this.showPreviewStep();
});
}
private applyValueMapsToRows(): void {
const headers = (this.csvRows[0] ?? []).map((h) => h.trim());
const ratingColName = this.columnMapping['rating'];
const ratingIdx = ratingColName ? headers.indexOf(ratingColName) : -1;
for (const r of this.importRows) {
const sv = (r.entry.status as string | undefined)?.trim() ?? '';
if (sv && Object.prototype.hasOwnProperty.call(this.statusValueMap, sv)) {
const resolved = this.statusValueMap[sv] ?? '';
r.entry.status = (resolved || undefined) as ReadingStatus | undefined;
}
if (ratingIdx >= 0) {
const rowIdx = this.importRows.indexOf(r);
const rawValues = this.csvRows[rowIdx + 1] ?? [];
const rawRating = (rawValues[ratingIdx] ?? '').trim();
if (rawRating && Object.prototype.hasOwnProperty.call(this.ratingValueMap, rawRating)) {
const resolved = this.ratingValueMap[rawRating] ?? '';
r.entry.rating = resolved ? parseInt(resolved) : 0;
}
}
}
}
// ── 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;
const hadValueMap = Object.keys(this.statusValueMap).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.author, 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 buildBook(entry: ReadingEntry, defaultStatus: ReadingStatus): Book {
const titleName = entry.title!.trim();
const now = new Date().toISOString();
return {
id: this.plugin.readingDataManager.generateBookId(titleName),
title: titleName,
author: entry.author ?? '',
status: (entry.status) || defaultStatus,
rating: entry.rating ?? 0,
pagesRead: entry.pagesRead ?? 0,
totalPages: entry.totalPages ?? 0,
chaptersRead: entry.chaptersRead ?? 0,
totalChapters: entry.totalChapters ?? 0,
coverUrl: '',
googleBooksId: entry.googleBooksId ?? '',
externalLink: entry.externalLink ?? '',
vaultPage: '',
dateStarted: entry.dateStarted ?? null,
dateFinished: entry.dateFinished ?? null,
releaseDate: entry.releaseDate ?? null,
dateAdded: now,
dateModified: now,
customFields: {},
};
}
private buildManga(entry: ReadingEntry, defaultStatus: ReadingStatus): Manga {
const titleName = entry.title!.trim();
const now = new Date().toISOString();
return {
id: this.plugin.readingDataManager.generateMangaId(titleName),
title: titleName,
author: entry.author ?? '',
status: (entry.status) || defaultStatus,
rating: entry.rating ?? 0,
chaptersRead: entry.chaptersRead ?? 0,
totalChapters: entry.totalChapters ?? 0,
volumesRead: entry.volumesRead ?? 0,
totalVolumes: entry.totalVolumes ?? 0,
coverUrl: '',
malId: entry.malId ?? '',
externalLink: entry.externalLink ?? '',
vaultPage: '',
dateStarted: entry.dateStarted ?? null,
dateFinished: entry.dateFinished ?? null,
releaseDate: entry.releaseDate ?? null,
dateAdded: now,
dateModified: now,
customFields: {},
};
}
private async doImport(): Promise<void> {
const toImport = this.importRows.filter((r) => r.selected && r.entry.title?.trim());
if (toImport.length === 0) {
new Notice(`No ${this.itemNoun} selected to import.`);
return;
}
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%`;
const reading = this.plugin.readingDataManager;
const defaultStatus: ReadingStatus = reading.getSettings().defaultStatus ?? 'Plan to Read';
const CHUNK_SIZE = 10;
let added = 0;
try {
for (let chunkStart = 0; chunkStart < toImport.length; chunkStart += CHUNK_SIZE) {
if (this.importCancelled) break;
const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, toImport.length);
if (this.kind === 'book') {
const chunk: Book[] = [];
for (let i = chunkStart; i < chunkEnd; i++) {
if (this.importCancelled) break;
chunk.push(this.buildBook(toImport[i]!.entry, defaultStatus));
}
if (chunk.length === 0) break;
await reading.addBookBatch(chunk);
added += chunk.length;
} else {
const chunk: Manga[] = [];
for (let i = chunkStart; i < chunkEnd; i++) {
if (this.importCancelled) break;
chunk.push(this.buildManga(toImport[i]!.entry, defaultStatus));
}
if (chunk.length === 0) break;
await reading.addMangaBatch(chunk);
added += chunk.length;
}
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}`;
}
} finally {
this.isImporting = false;
reading.notifyChange();
}
if (this.importCancelled) {
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} ${this.itemNoun}${added !== 1 ? 's' : ''} imported.`);
} else {
if (this.progressText) this.progressText.textContent = `Done — ${added} imported`;
if (this.cancelBtn) { this.cancelBtn.disabled = false; this.cancelBtn.textContent = 'Close'; }
new Notice(`Imported ${added} ${this.itemNoun}${added !== 1 ? 's' : ''}.`);
this.close();
}
}
}

850
src/ReadingDataManager.ts Normal file
View file

@ -0,0 +1,850 @@
import { normalizePath, TFile } from 'obsidian';
import type WatchLogPlugin from './main';
import type { HistoryManager } from './HistoryManager';
import {
Book,
Manga,
ReadingCustomColumn,
ReadingData,
ReadingSettings,
DEFAULT_READING_SETTINGS,
isReleaseDateFuture,
} from './types';
export type ReadingKind = 'book' | 'manga';
function sanitizeFilename(input: string): string {
return input.replace(/[*"\\/<>:|?]/g, '-').trim();
}
function yamlEscape(value: string): string {
if (value === '') return '""';
// Double-quote and escape backslashes and double quotes.
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
function slugify(input: string): string {
return input
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
export class ReadingDataManager {
private plugin: WatchLogPlugin;
private data: ReadingData;
private changeListeners: Array<() => void> = [];
private historyManager: HistoryManager | null = null;
constructor(plugin: WatchLogPlugin) {
this.plugin = plugin;
this.data = this.emptyData();
}
setHistoryManager(hm: HistoryManager): void {
this.historyManager = hm;
}
private emptyData(): ReadingData {
return {
books: [],
manga: [],
bookColumns: [],
mangaColumns: [],
settings: { ...DEFAULT_READING_SETTINGS },
};
}
private get filePath(): string {
return normalizePath(`${this.plugin.app.vault.configDir}/plugins/watchlog/reading.json`);
}
async load(): Promise<void> {
try {
const exists = await this.plugin.app.vault.adapter.exists(this.filePath);
if (exists) {
const raw = await this.plugin.app.vault.adapter.read(this.filePath);
const parsed = JSON.parse(raw) as Partial<ReadingData>;
this.data = {
books: Array.isArray(parsed.books) ? parsed.books : [],
manga: Array.isArray(parsed.manga) ? parsed.manga : [],
bookColumns: Array.isArray(parsed.bookColumns) ? parsed.bookColumns : [],
mangaColumns: Array.isArray(parsed.mangaColumns) ? parsed.mangaColumns : [],
settings: { ...DEFAULT_READING_SETTINGS, ...(parsed.settings ?? {}) },
};
} else {
this.data = this.emptyData();
}
} catch (e) {
console.warn('[WL] ReadingDataManager.load failed:', e);
this.data = this.emptyData();
}
const changed = this.migrate();
if (changed) {
await this.saveOnly();
}
// Additive reconcile: ensure every future-dated reading item has an Upcoming
// entry (mirrors the watchlist auto-add). Purely additive — never removes, so
// manually-scheduled recurring entries are preserved across reloads.
const dm = this.plugin.dataManager;
if (dm) {
for (const b of this.data.books) if (isReleaseDateFuture(b.releaseDate)) await dm.autoAddReadingToUpcoming(b, 'book');
for (const m of this.data.manga) if (isReleaseDateFuture(m.releaseDate)) await dm.autoAddReadingToUpcoming(m, 'manga');
}
}
/** Ensures every record has the full set of fields with sensible defaults. */
private migrate(): boolean {
let changed = false;
// "On Hold" was removed; never leave it as the default for new entries.
if ((this.data.settings.defaultStatus as string) === 'On Hold') {
this.data.settings.defaultStatus = 'Plan to Read';
changed = true;
}
for (const b of this.data.books) {
if (typeof b.author !== 'string') { b.author = ''; changed = true; }
if (typeof b.rating !== 'number') { b.rating = 0; changed = true; }
if (typeof b.pagesRead !== 'number') { b.pagesRead = 0; changed = true; }
if (typeof b.totalPages !== 'number') { b.totalPages = 0; changed = true; }
if (typeof b.chaptersRead !== 'number') { b.chaptersRead = 0; changed = true; }
if (typeof b.totalChapters !== 'number') { b.totalChapters = 0; changed = true; }
if (typeof b.coverUrl !== 'string') { b.coverUrl = ''; changed = true; }
// Books now use Google Books volume ids; pre-existing entries keep their
// old openLibraryId value untouched and simply get an empty googleBooksId.
if (typeof b.googleBooksId !== 'string') { b.googleBooksId = ''; changed = true; }
if (typeof b.vaultPage !== 'string') { b.vaultPage = ''; changed = true; }
if (b.dateStarted === undefined) { b.dateStarted = null; changed = true; }
if (b.dateFinished === undefined) { b.dateFinished = null; changed = true; }
if (b.releaseDate === undefined) { b.releaseDate = null; changed = true; }
// "On Hold" was removed; migrate any legacy value to "Plan to Read".
if ((b.status as string) === 'On Hold') { b.status = 'Plan to Read'; changed = true; }
if (!b.dateAdded) { b.dateAdded = new Date().toISOString(); changed = true; }
if (!b.dateModified) { b.dateModified = b.dateAdded; changed = true; }
if (!b.customFields || typeof b.customFields !== 'object') {
b.customFields = {};
changed = true;
}
}
for (const m of this.data.manga) {
if (typeof m.author !== 'string') { m.author = ''; changed = true; }
if (typeof m.rating !== 'number') { m.rating = 0; changed = true; }
if (typeof m.chaptersRead !== 'number') { m.chaptersRead = 0; changed = true; }
if (typeof m.totalChapters !== 'number') { m.totalChapters = 0; changed = true; }
if (typeof m.volumesRead !== 'number') { m.volumesRead = 0; changed = true; }
if (typeof m.totalVolumes !== 'number') { m.totalVolumes = 0; changed = true; }
if (typeof m.coverUrl !== 'string') { m.coverUrl = ''; changed = true; }
if (typeof m.malId !== 'string') { m.malId = ''; changed = true; }
if (typeof m.vaultPage !== 'string') { m.vaultPage = ''; changed = true; }
if (m.dateStarted === undefined) { m.dateStarted = null; changed = true; }
if (m.dateFinished === undefined) { m.dateFinished = null; changed = true; }
if (m.releaseDate === undefined) { m.releaseDate = null; changed = true; }
// "On Hold" was removed; migrate any legacy value to "Plan to Read".
if ((m.status as string) === 'On Hold') { m.status = 'Plan to Read'; changed = true; }
if (!m.dateAdded) { m.dateAdded = new Date().toISOString(); changed = true; }
if (!m.dateModified) { m.dateModified = m.dateAdded; changed = true; }
if (!m.customFields || typeof m.customFields !== 'object') {
m.customFields = {};
changed = true;
}
}
for (const c of this.data.bookColumns) {
if (!Array.isArray(c.options)) { c.options = []; changed = true; }
if (!c.color) { c.color = '#5F5E5A'; changed = true; }
}
for (const c of this.data.mangaColumns) {
if (!Array.isArray(c.options)) { c.options = []; changed = true; }
if (!c.color) { c.color = '#5F5E5A'; changed = true; }
}
// Reconcile auto "To be released" status against each release date.
for (const b of this.data.books) if (this.applyReleaseStatus(b)) changed = true;
for (const m of this.data.manga) if (this.applyReleaseStatus(m)) changed = true;
return changed;
}
/**
* Forces "To be released" when the item's release date is in the future, and
* reverts it to "Plan to Read" once the date has passed (or is cleared).
* Mirrors the watchlist auto-status mechanism. Returns true if status changed.
*/
private applyReleaseStatus(item: Book | Manga): boolean {
const future = isReleaseDateFuture(item.releaseDate);
if (future) {
if (item.status !== 'To be released') {
item.status = 'To be released';
return true;
}
} else if (item.status === 'To be released') {
item.status = 'Plan to Read';
return true;
}
return false;
}
/**
* Mirrors the watchlist auto-add: a reading item with a future release date is
* added to the shared Upcoming tracker (owned by DataManager), and removed once
* the date passes or is cleared. Connects Reading into the same Upcoming pathway.
*/
private async syncUpcoming(item: Book | Manga, kind: ReadingKind): Promise<void> {
const dm = this.plugin.dataManager;
if (!dm) return;
if (isReleaseDateFuture(item.releaseDate)) {
await dm.autoAddReadingToUpcoming(item, kind);
} else {
await dm.removeReadingAirtimeEntries(item.id);
}
}
getData(): ReadingData {
return this.data;
}
getSettings(): ReadingSettings {
return this.data.settings;
}
async updateSettings(patch: Partial<ReadingSettings>): Promise<void> {
this.data.settings = { ...this.data.settings, ...patch };
await this.save();
}
// ── Persistence ───────────────────────────────────────────────────────────
private async saveOnly(): Promise<void> {
try {
await this.plugin.app.vault.adapter.write(
this.filePath,
JSON.stringify(this.data, null, 2),
);
} catch (e) {
console.warn('[WL] ReadingDataManager.save failed:', e);
}
}
private async save(): Promise<void> {
await this.saveOnly();
this.notifyListeners();
}
// ── Listeners ─────────────────────────────────────────────────────────────
onChange(listener: () => void): void {
this.changeListeners.push(listener);
}
offChange(listener: () => void): void {
this.changeListeners = this.changeListeners.filter((l) => l !== listener);
}
notifyChange(): void {
this.notifyListeners();
}
async saveAndNotify(): Promise<void> {
await this.saveOnly();
this.notifyListeners();
}
/**
* Overwrite the entire reading dataset from a backup, then reload through the
* normal load() path (migration + Upcoming reconcile) and notify listeners.
*/
async restore(data: ReadingData): Promise<void> {
await this.plugin.app.vault.adapter.write(
this.filePath,
JSON.stringify(data, null, 2),
);
await this.load();
this.notifyListeners();
}
private notifyListeners(): void {
for (const listener of this.changeListeners) {
listener();
}
}
private coverSaveTimer: number | null = null;
private readonly COVER_SAVE_DEBOUNCE_MS = 5000;
/**
* Silent cover URL update for the lazy Cards re-fetch. Writes the value in
* memory and schedules a debounced disk save (5s). Does NOT notify listeners
* the card that triggered the fetch updates its own <img> directly, so the open
* grid is not rebuilt (which would reset scroll position).
*/
updateCoverUrl(kind: ReadingKind, id: string, url: string): void {
const item = kind === 'book' ? this.getBook(id) : this.getManga(id);
if (!item) return;
item.coverUrl = url;
if (this.coverSaveTimer !== null) return;
this.coverSaveTimer = window.setTimeout(() => {
this.coverSaveTimer = null;
void this.saveOnly();
}, this.COVER_SAVE_DEBOUNCE_MS);
}
/**
* Like updateCoverUrl, but also persists a source ID that was just resolved via a
* title-based lookup (for CSV-imported entries that arrived with neither cover nor
* ID). Stores googleBooksId / malId so subsequent loads use the cheap ID path.
* Silent + debounced same scroll-preserving guarantees as updateCoverUrl.
*/
updateCoverAndSource(kind: ReadingKind, id: string, url: string, sourceId: string): void {
const item = kind === 'book' ? this.getBook(id) : this.getManga(id);
if (!item) return;
item.coverUrl = url;
if (kind === 'book') (item as Book).googleBooksId = sourceId;
else (item as Manga).malId = sourceId;
if (this.coverSaveTimer !== null) return;
this.coverSaveTimer = window.setTimeout(() => {
this.coverSaveTimer = null;
void this.saveOnly();
}, this.COVER_SAVE_DEBOUNCE_MS);
}
/** Flush a pending debounced cover save now (e.g. on plugin unload). */
flushCoverSave(): void {
if (this.coverSaveTimer !== null) {
window.clearTimeout(this.coverSaveTimer);
this.coverSaveTimer = null;
void this.saveOnly();
}
}
// ── Books CRUD ────────────────────────────────────────────────────────────
getBooks(): Book[] {
return this.data.books;
}
getBook(id: string): Book | undefined {
return this.data.books.find((b) => b.id === id);
}
async addBook(book: Book): Promise<void> {
this.applyReleaseStatus(book);
this.data.books.push(book);
await this.save();
await this.syncUpcoming(book, 'book');
try { await this.writeReadingNote('book', book); } catch (e) { console.warn('[WL] writeReadingNote failed:', e); }
void this.historyManager?.log(
`${book.title} (Book) was added`,
{ source: 'Reading', action: 'added', titleName: book.title },
);
}
async addBookSilent(book: Book): Promise<void> {
this.applyReleaseStatus(book);
this.data.books.push(book);
await this.saveOnly();
}
async updateBook(updated: Book): Promise<void> {
updated.dateModified = new Date().toISOString();
this.applyReleaseStatus(updated);
const idx = this.data.books.findIndex((b) => b.id === updated.id);
if (idx >= 0) {
const old = this.data.books[idx]!;
if (old.status !== updated.status) {
const act = updated.status === 'Completed' ? 'completed' as const : 'status' as const;
// Mirror the watch behavior: stamp today's finish date on the transition to Completed.
if (updated.status === 'Completed' && this.plugin.settings.setFinishDateAutomatically && !updated.dateFinished) {
updated.dateFinished = new Date().toISOString().split('T')[0] ?? null;
}
void this.historyManager?.log(
`${updated.title} (Book) status changed to ${updated.status}`,
{ source: 'Reading', action: act, titleName: updated.title },
);
}
if (old.rating !== updated.rating) {
void this.historyManager?.log(
`${updated.title} (Book) Rating → ${updated.rating}/5`,
{ source: 'Reading', action: 'rating', titleName: updated.title },
);
}
this.data.books[idx] = updated;
await this.save();
// Only reconcile Upcoming when the release date actually changed, so
// rating/status/progress edits never disturb a manual recurring entry.
if (old.releaseDate !== updated.releaseDate) await this.syncUpcoming(updated, 'book');
try { await this.writeReadingNote('book', updated); } catch (e) { console.warn('[WL] writeReadingNote failed:', e); }
}
}
updateBookSilent(updated: Book): void {
updated.dateModified = new Date().toISOString();
this.applyReleaseStatus(updated);
const idx = this.data.books.findIndex((b) => b.id === updated.id);
if (idx >= 0) {
this.data.books[idx] = updated;
}
}
async removeBook(id: string): Promise<void> {
const book = this.getBook(id);
this.data.books = this.data.books.filter((b) => b.id !== id);
await this.save();
await this.plugin.dataManager?.removeReadingAirtimeEntries(id);
if (book) {
void this.historyManager?.log(
`${book.title} (Book) was deleted`,
{ source: 'Reading', action: 'deleted', titleName: book.title },
);
try { await this.deleteReadingNote('book', book); } catch (e) { console.warn('[WL] deleteReadingNote failed:', e); }
}
}
/**
* Insert many books in one shot: single persist, then per-item Upcoming reconcile
* and note-file write (mirrors the watch addTitleBatch). Used by the chunked CSV import.
*/
async addBookBatch(books: Book[]): Promise<void> {
for (const book of books) {
this.applyReleaseStatus(book);
this.data.books.push(book);
}
await this.saveOnly();
for (const book of books) {
await this.syncUpcoming(book, 'book');
try { await this.writeReadingNote('book', book); } catch (e) { console.warn('[WL] writeReadingNote failed:', e); }
void this.historyManager?.log(
`${book.title} (Book) was added`,
{ source: 'Reading', action: 'added', titleName: book.title },
);
}
}
generateBookId(title: string): string {
const base = slugify(title) || 'book';
const existing = new Set(this.data.books.map((b) => b.id));
if (!existing.has(base)) return base;
let counter = 2;
while (existing.has(`${base}-${counter}`)) counter++;
return `${base}-${counter}`;
}
// ── Manga CRUD ────────────────────────────────────────────────────────────
getMangaList(): Manga[] {
return this.data.manga;
}
getManga(id: string): Manga | undefined {
return this.data.manga.find((m) => m.id === id);
}
async addManga(manga: Manga): Promise<void> {
this.applyReleaseStatus(manga);
this.data.manga.push(manga);
await this.save();
await this.syncUpcoming(manga, 'manga');
try { await this.writeReadingNote('manga', manga); } catch (e) { console.warn('[WL] writeReadingNote failed:', e); }
void this.historyManager?.log(
`${manga.title} (Manga) was added`,
{ source: 'Reading', action: 'added', titleName: manga.title },
);
}
async addMangaSilent(manga: Manga): Promise<void> {
this.applyReleaseStatus(manga);
this.data.manga.push(manga);
await this.saveOnly();
}
async updateManga(updated: Manga): Promise<void> {
updated.dateModified = new Date().toISOString();
this.applyReleaseStatus(updated);
const idx = this.data.manga.findIndex((m) => m.id === updated.id);
if (idx >= 0) {
const old = this.data.manga[idx]!;
if (old.status !== updated.status) {
const act = updated.status === 'Completed' ? 'completed' as const : 'status' as const;
// Mirror the watch behavior: stamp today's finish date on the transition to Completed.
if (updated.status === 'Completed' && this.plugin.settings.setFinishDateAutomatically && !updated.dateFinished) {
updated.dateFinished = new Date().toISOString().split('T')[0] ?? null;
}
void this.historyManager?.log(
`${updated.title} (Manga) status changed to ${updated.status}`,
{ source: 'Reading', action: act, titleName: updated.title },
);
}
if (old.rating !== updated.rating) {
void this.historyManager?.log(
`${updated.title} (Manga) Rating → ${updated.rating}/5`,
{ source: 'Reading', action: 'rating', titleName: updated.title },
);
}
this.data.manga[idx] = updated;
await this.save();
// Only reconcile Upcoming when the release date actually changed, so
// rating/status/progress edits never disturb a manual recurring entry.
if (old.releaseDate !== updated.releaseDate) await this.syncUpcoming(updated, 'manga');
try { await this.writeReadingNote('manga', updated); } catch (e) { console.warn('[WL] writeReadingNote failed:', e); }
}
}
updateMangaSilent(updated: Manga): void {
updated.dateModified = new Date().toISOString();
this.applyReleaseStatus(updated);
const idx = this.data.manga.findIndex((m) => m.id === updated.id);
if (idx >= 0) {
this.data.manga[idx] = updated;
}
}
async removeManga(id: string): Promise<void> {
const manga = this.getManga(id);
this.data.manga = this.data.manga.filter((m) => m.id !== id);
await this.save();
await this.plugin.dataManager?.removeReadingAirtimeEntries(id);
if (manga) {
void this.historyManager?.log(
`${manga.title} (Manga) was deleted`,
{ source: 'Reading', action: 'deleted', titleName: manga.title },
);
try { await this.deleteReadingNote('manga', manga); } catch (e) { console.warn('[WL] deleteReadingNote failed:', e); }
}
}
async removeBooksBatch(ids: string[]): Promise<void> {
const CHUNK = 10;
const toDelete: Book[] = [];
for (const id of ids) {
const book = this.getBook(id);
if (book) toDelete.push(book);
}
this.data.books = this.data.books.filter((b) => !ids.includes(b.id));
await this.save();
for (const id of ids) await this.plugin.dataManager?.removeReadingAirtimeEntries(id);
for (const book of toDelete) {
void this.historyManager?.log(
`${book.title} (Book) was deleted`,
{ source: 'Reading', action: 'deleted', titleName: book.title },
);
}
for (let i = 0; i < toDelete.length; i += CHUNK) {
const chunk = toDelete.slice(i, i + CHUNK);
await Promise.all(chunk.map((b) => this.deleteReadingNote('book', b).catch(() => {})));
}
}
async removeMangaBatch(ids: string[]): Promise<void> {
const CHUNK = 10;
const toDelete: Manga[] = [];
for (const id of ids) {
const manga = this.getManga(id);
if (manga) toDelete.push(manga);
}
this.data.manga = this.data.manga.filter((m) => !ids.includes(m.id));
await this.save();
for (const id of ids) await this.plugin.dataManager?.removeReadingAirtimeEntries(id);
for (const manga of toDelete) {
void this.historyManager?.log(
`${manga.title} (Manga) was deleted`,
{ source: 'Reading', action: 'deleted', titleName: manga.title },
);
}
for (let i = 0; i < toDelete.length; i += CHUNK) {
const chunk = toDelete.slice(i, i + CHUNK);
await Promise.all(chunk.map((m) => this.deleteReadingNote('manga', m).catch(() => {})));
}
}
/**
* Insert many manga in one shot: single persist, then per-item Upcoming reconcile
* and note-file write (mirrors the watch addTitleBatch). Used by the chunked CSV import.
*/
async addMangaBatch(mangaList: Manga[]): Promise<void> {
for (const manga of mangaList) {
this.applyReleaseStatus(manga);
this.data.manga.push(manga);
}
await this.saveOnly();
for (const manga of mangaList) {
await this.syncUpcoming(manga, 'manga');
try { await this.writeReadingNote('manga', manga); } catch (e) { console.warn('[WL] writeReadingNote failed:', e); }
void this.historyManager?.log(
`${manga.title} (Manga) was added`,
{ source: 'Reading', action: 'added', titleName: manga.title },
);
}
}
generateMangaId(title: string): string {
const base = slugify(title) || 'manga';
const existing = new Set(this.data.manga.map((m) => m.id));
if (!existing.has(base)) return base;
let counter = 2;
while (existing.has(`${base}-${counter}`)) counter++;
return `${base}-${counter}`;
}
// ── Custom columns CRUD ───────────────────────────────────────────────────
getBookColumns(): ReadingCustomColumn[] {
return this.data.bookColumns;
}
getMangaColumns(): ReadingCustomColumn[] {
return this.data.mangaColumns;
}
private columnsFor(kind: 'book' | 'manga'): ReadingCustomColumn[] {
return kind === 'book' ? this.data.bookColumns : this.data.mangaColumns;
}
async addColumn(kind: 'book' | 'manga', column: ReadingCustomColumn): Promise<void> {
this.columnsFor(kind).push(column);
await this.save();
}
async updateColumn(kind: 'book' | 'manga', updated: ReadingCustomColumn): Promise<void> {
const cols = this.columnsFor(kind);
const idx = cols.findIndex((c) => c.id === updated.id);
if (idx >= 0) {
cols[idx] = updated;
await this.save();
}
}
async removeColumn(kind: 'book' | 'manga', id: string): Promise<void> {
if (kind === 'book') {
this.data.bookColumns = this.data.bookColumns.filter((c) => c.id !== id);
for (const b of this.data.books) {
if (b.customFields && Object.prototype.hasOwnProperty.call(b.customFields, id)) {
delete b.customFields[id];
}
}
} else {
this.data.mangaColumns = this.data.mangaColumns.filter((c) => c.id !== id);
for (const m of this.data.manga) {
if (m.customFields && Object.prototype.hasOwnProperty.call(m.customFields, id)) {
delete m.customFields[id];
}
}
}
await this.save();
}
generateColumnId(kind: 'book' | 'manga', name: string): string {
const base = `col-${slugify(name) || 'field'}`;
const existing = new Set(this.columnsFor(kind).map((c) => c.id));
if (!existing.has(base)) return base;
let counter = 2;
while (existing.has(`${base}-${counter}`)) counter++;
return `${base}-${counter}`;
}
async reorderColumns(kind: ReadingKind, fromIdx: number, toIdx: number): Promise<void> {
const cols = this.columnsFor(kind);
if (fromIdx < 0 || fromIdx >= cols.length || toIdx < 0 || toIdx >= cols.length) return;
const [moved] = cols.splice(fromIdx, 1);
if (!moved) return;
cols.splice(toIdx, 0, moved);
await this.save();
}
// ── Custom field values ──────────────────────────────────────────────────
async setCustomField(
kind: ReadingKind,
itemId: string,
columnId: string,
value: string | number | null,
): Promise<void> {
const item = kind === 'book' ? this.getBook(itemId) : this.getManga(itemId);
if (!item) return;
if (!item.customFields) item.customFields = {};
if (value === null || value === '') {
delete item.customFields[columnId];
} else {
item.customFields[columnId] = value;
}
item.dateModified = new Date().toISOString();
await this.save();
}
// ── Reading note files ───────────────────────────────────────────────────
private kindFolder(kind: ReadingKind): string {
const root = this.data.settings.defaultFolder || 'WatchLog/Reading';
return normalizePath(`${root}/${kind === 'book' ? 'Books' : 'Manga'}`);
}
noteFilePath(kind: ReadingKind, title: string): string {
const safe = sanitizeFilename(title) || (kind === 'book' ? 'book' : 'manga');
return normalizePath(`${this.kindFolder(kind)}/${safe}.md`);
}
private async ensureFolder(path: string): Promise<void> {
const normalized = normalizePath(path);
if (!this.plugin.app.vault.getAbstractFileByPath(normalized)) {
try {
await this.plugin.app.vault.createFolder(normalized);
} catch {
// folder may already exist
}
}
}
private buildFrontmatter(kind: ReadingKind, item: Book | Manga): string {
const lines: string[] = ['---'];
lines.push(`title: ${yamlEscape(sanitizeFilename(item.title))}`);
lines.push(`author: ${yamlEscape(item.author ?? '')}`);
lines.push(`status: ${yamlEscape(item.status)}`);
lines.push(`rating: ${item.rating | 0}`);
if (kind === 'book') {
const b = item as Book;
lines.push(`pagesRead: ${b.pagesRead | 0}`);
lines.push(`totalPages: ${b.totalPages | 0}`);
lines.push(`chaptersRead: ${b.chaptersRead | 0}`);
lines.push(`totalChapters: ${b.totalChapters | 0}`);
if (b.googleBooksId) lines.push(`googleBooksId: ${yamlEscape(b.googleBooksId)}`);
} else {
const m = item as Manga;
lines.push(`chaptersRead: ${m.chaptersRead | 0}`);
lines.push(`totalChapters: ${m.totalChapters | 0}`);
lines.push(`volumesRead: ${m.volumesRead | 0}`);
lines.push(`totalVolumes: ${m.totalVolumes | 0}`);
if (m.malId) lines.push(`malId: ${yamlEscape(m.malId)}`);
}
lines.push(`dateStarted: ${item.dateStarted ?? 'null'}`);
lines.push(`dateFinished: ${item.dateFinished ?? 'null'}`);
lines.push(`releaseDate: ${item.releaseDate ?? 'null'}`);
lines.push(`dateAdded: ${yamlEscape(item.dateAdded)}`);
lines.push(`type: ${kind === 'book' ? 'book' : 'manga'}`);
lines.push('---');
return lines.join('\n');
}
private buildInitialNoteContent(kind: ReadingKind, item: Book | Manga): string {
return `${this.buildFrontmatter(kind, item)}\n\n## Notes\n\n\n## Quotes\n`;
}
private rebuildWithFrontmatter(existing: string, frontmatter: string): string {
const fmMatch = existing.match(/^---\n[\s\S]*?\n---\n?/);
if (fmMatch) {
return frontmatter + '\n' + existing.slice(fmMatch[0].length);
}
// No frontmatter found — prepend it.
return frontmatter + '\n\n' + existing;
}
private lastNotePathById = new Map<string, string>();
/** Writes (creates or updates) the note file. Preserves Notes/Quotes body when the file exists. */
async writeReadingNote(kind: ReadingKind, item: Book | Manga): Promise<string> {
await this.ensureFolder(this.kindFolder(kind));
const filePath = this.noteFilePath(kind, item.title);
const frontmatter = this.buildFrontmatter(kind, item);
// Handle rename: trash the previous file if the path changed.
const prevKey = `${kind}:${item.id}`;
const previousPath = this.lastNotePathById.get(prevKey);
if (previousPath && previousPath !== filePath) {
const oldFile = this.plugin.app.vault.getAbstractFileByPath(previousPath);
if (oldFile instanceof TFile) {
try {
await this.plugin.app.fileManager.trashFile(oldFile);
} catch {
// best-effort
}
}
}
const existing = this.plugin.app.vault.getAbstractFileByPath(filePath);
if (existing instanceof TFile) {
const current = await this.plugin.app.vault.read(existing);
const updated = this.rebuildWithFrontmatter(current, frontmatter);
if (updated !== current) {
await this.plugin.app.vault.modify(existing, updated);
}
} else {
await this.plugin.app.vault.create(filePath, this.buildInitialNoteContent(kind, item));
}
this.lastNotePathById.set(prevKey, filePath);
return filePath;
}
async ensureReadingNote(kind: ReadingKind, item: Book | Manga): Promise<string> {
const filePath = this.noteFilePath(kind, item.title);
const existing = this.plugin.app.vault.getAbstractFileByPath(filePath);
if (existing instanceof TFile) {
this.lastNotePathById.set(`${kind}:${item.id}`, filePath);
return filePath;
}
return this.writeReadingNote(kind, item);
}
/** Creates the note file only if it's missing; returns true when a file was created. */
async createReadingNoteIfMissing(kind: ReadingKind, item: Book | Manga): Promise<boolean> {
const filePath = this.noteFilePath(kind, item.title);
if (this.plugin.app.vault.getAbstractFileByPath(filePath) instanceof TFile) return false;
await this.writeReadingNote(kind, item);
return true;
}
async readReadingNote(kind: ReadingKind, item: Book | Manga): Promise<string | null> {
const filePath = this.noteFilePath(kind, item.title);
const file = this.plugin.app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) return null;
return this.plugin.app.vault.read(file);
}
/** Appends a `> [!quote]` callout to the ## Quotes section. Creates the section if missing. */
async appendQuote(
kind: ReadingKind,
item: Book | Manga,
text: string,
reference: string,
): Promise<void> {
await this.ensureReadingNote(kind, item);
const filePath = this.noteFilePath(kind, item.title);
const file = this.plugin.app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) return;
const current = await this.plugin.app.vault.read(file);
const refTrim = reference.trim();
const titlePart = refTrim ? ` ${refTrim}` : '';
const bodyLines = text
.split(/\r?\n/)
.map((l) => `> ${l}`)
.join('\n');
const callout = `> [!quote]${titlePart}\n${bodyLines}`;
let updated: string;
const quotesMatch = current.match(/(^|\n)## Quotes[ \t]*\r?\n/);
if (quotesMatch && quotesMatch.index !== undefined) {
const insertAt = quotesMatch.index + quotesMatch[0].length;
const before = current.slice(0, insertAt);
const after = current.slice(insertAt);
const needsLeadingNl = before.length > 0 && !before.endsWith('\n\n');
updated = `${before}${needsLeadingNl ? '\n' : ''}${callout}\n${after.startsWith('\n') ? '' : '\n'}${after}`;
} else {
const sep = current.endsWith('\n') ? '' : '\n';
updated = `${current}${sep}\n## Quotes\n\n${callout}\n`;
}
await this.plugin.app.vault.modify(file, updated);
}
async deleteReadingNote(kind: ReadingKind, item: Book | Manga): Promise<void> {
const filePath = this.noteFilePath(kind, item.title);
const file = this.plugin.app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) {
try {
await this.plugin.app.fileManager.trashFile(file);
} catch {
// best-effort
}
}
this.lastNotePathById.delete(`${kind}:${item.id}`);
}
}

1411
src/ReadingDetailModal.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,350 @@
import { App, Modal, Notice } from 'obsidian';
import type WatchLogPlugin from './main';
import type { ReadingDataManager } from './ReadingDataManager';
import { ReadingCustomColumn, FIELD_COLORS, DEFAULT_FIELD_COLOR } from './types';
import { ConfirmModal } from './ConfirmModal';
type Kind = 'book' | 'manga';
export class ReadingManageColumnsModal extends Modal {
private plugin: WatchLogPlugin;
private readingData: ReadingDataManager;
private kind: Kind;
private onChanged: () => void;
private listEl: HTMLElement | null = null;
private styleGroupEl: HTMLElement | null = null;
private dragFromIndex = -1;
constructor(
app: App,
plugin: WatchLogPlugin,
readingData: ReadingDataManager,
kind: Kind,
onChanged: () => void,
) {
super(app);
this.plugin = plugin;
this.readingData = readingData;
this.kind = kind;
this.onChanged = onChanged;
}
onOpen(): void {
const colorTheme = this.plugin.settings.colorTheme ?? 'default';
this.modalEl.setAttribute('data-theme', colorTheme);
this.contentEl.setAttribute('data-theme', colorTheme);
this.contentEl.addClass('wl-view');
this.contentEl.addClass('wl-reading-modal');
this.contentEl.addClass('wl-reading-manage-cols');
this.titleEl.setText(this.kind === 'book' ? 'Manage book fields' : 'Manage manga fields');
this.build();
}
onClose(): void {
this.contentEl.empty();
}
private build(): void {
const c = this.contentEl;
c.empty();
const header = c.createDiv({ cls: 'wl-reading-modal-header' });
header.createSpan({
cls: 'wl-reading-modal-header-icon',
text: this.kind === 'book' ? '📖' : '📓',
});
header.createSpan({
cls: 'wl-reading-modal-header-title',
text: this.kind === 'book' ? 'Manage book fields' : 'Manage manga fields',
});
c.createDiv({
cls: 'wl-reading-manage-cols-desc',
text: 'Custom fields appear in the title detail modal. Changes save automatically.',
});
this.renderStyleToggle(c);
this.listEl = c.createDiv({ cls: 'wl-reading-manage-cols-list' });
this.renderList();
c.createDiv({ cls: 'wl-reading-modal-divider' });
this.renderAddSection(c);
const footer = c.createDiv({ cls: 'wl-reading-modal-footer' });
const closeBtn = footer.createEl('button', { cls: 'wl-btn', text: 'Close' });
closeBtn.addEventListener('click', () => this.close());
}
private renderStyleToggle(parent: HTMLElement): void {
const settings = this.readingData.getSettings();
const currentStyle = this.kind === 'book'
? (settings.bookCustomFieldStyle ?? 'fill')
: (settings.mangaCustomFieldStyle ?? 'fill');
const row = parent.createDiv({ cls: 'wl-reading-cols-style-row' });
row.createSpan({ cls: 'wl-reading-cols-style-label', text: 'Display style:' });
this.styleGroupEl = row.createDiv({ cls: 'wl-reading-cols-style-group' });
for (const opt of ['fill', 'border'] as const) {
const btn = this.styleGroupEl.createEl('button', {
cls: `wl-reading-cols-style-btn${currentStyle === opt ? ' is-active' : ''}`,
text: opt.charAt(0).toUpperCase() + opt.slice(1),
});
btn.addEventListener('click', () => {
void (async () => {
const key = this.kind === 'book' ? 'bookCustomFieldStyle' : 'mangaCustomFieldStyle';
await this.readingData.updateSettings({ [key]: opt });
this.onChanged();
this.styleGroupEl?.querySelectorAll('.wl-reading-cols-style-btn').forEach((b) => {
b.removeClass('is-active');
});
btn.addClass('is-active');
})();
});
}
}
private getColumns(): ReadingCustomColumn[] {
return this.kind === 'book'
? this.readingData.getBookColumns()
: this.readingData.getMangaColumns();
}
private renderList(): void {
if (!this.listEl) return;
this.listEl.empty();
const cols = this.getColumns();
if (cols.length === 0) {
this.listEl.createDiv({
cls: 'wl-reading-manage-cols-empty',
text: 'No custom fields yet. Add one below.',
});
return;
}
cols.forEach((col, idx) => {
const card = this.listEl!.createDiv({ cls: 'wl-editcol-card wl-reading-col-card' });
const handle = card.createDiv({ cls: 'wl-editcol-card-handle', text: '⠿' });
handle.title = 'Drag to reorder';
handle.addEventListener('mousedown', () => card.setAttribute('draggable', 'true'));
card.addEventListener('dragstart', (e) => {
this.dragFromIndex = idx;
e.dataTransfer?.setData('text/plain', String(idx));
card.addClass('wl-cl-dragging');
});
card.addEventListener('dragend', () => {
card.removeClass('wl-cl-dragging');
card.setAttribute('draggable', 'false');
});
card.addEventListener('dragover', (e) => {
e.preventDefault();
card.addClass('wl-cl-drag-over');
});
card.addEventListener('dragleave', () => card.removeClass('wl-cl-drag-over'));
card.addEventListener('drop', (e) => {
e.preventDefault();
card.removeClass('wl-cl-drag-over');
const from = this.dragFromIndex;
const to = idx;
this.dragFromIndex = -1;
if (from === to || from < 0) return;
void this.reorder(from, to);
});
const nameInput = card.createEl('input', {
cls: 'wl-modal-input wl-editcol-card-name',
attr: { type: 'text', placeholder: 'Field name' },
});
nameInput.value = col.name;
nameInput.addEventListener('change', () => {
const next = nameInput.value.trim();
if (!next) {
nameInput.value = col.name;
return;
}
if (next === col.name) return;
void this.updateColumn({ ...col, name: next });
});
const typeSelect = card.createEl('select', { cls: 'wl-select wl-editcol-card-type' });
for (const t of ['text', 'number', 'select'] as const) {
const opt = typeSelect.createEl('option', {
value: t,
text: t.charAt(0).toUpperCase() + t.slice(1),
});
if (col.type === t) opt.selected = true;
}
const colorDot = card.createEl('button', { cls: 'wl-reading-col-color-dot' });
colorDot.style.backgroundColor = col.color ?? DEFAULT_FIELD_COLOR;
colorDot.title = 'Choose field color';
colorDot.addEventListener('click', (e) => {
e.stopPropagation();
this.openColorPalette(colorDot, col);
});
const optsInput = card.createEl('input', {
cls: 'wl-modal-input wl-reading-col-opts-input',
attr: { type: 'text', placeholder: 'Comma-separated values' },
});
optsInput.value = col.options.join(', ');
optsInput.style.visibility = col.type === 'select' ? '' : 'hidden';
optsInput.addEventListener('change', () => {
const parsed = optsInput.value
.split(',')
.map((s) => s.trim())
.filter(Boolean);
void this.updateColumn({ ...col, options: parsed });
});
typeSelect.addEventListener('change', () => {
const type = typeSelect.value as ReadingCustomColumn['type'];
optsInput.style.visibility = type === 'select' ? '' : 'hidden';
void this.updateColumn({ ...col, type });
});
const delBtn = card.createEl('button', {
cls: 'wl-btn wl-btn-sm wl-editcol-card-del',
text: '✕',
});
delBtn.title = 'Delete this field';
delBtn.addEventListener('click', () => {
new ConfirmModal(
this.plugin.app,
`Delete field "${col.name}"? This removes its data from every ${
this.kind === 'book' ? 'book' : 'manga'
} entry.`,
() => void this.deleteColumn(col.id),
).open();
});
});
}
private openColorPalette(anchor: HTMLElement, col: ReadingCustomColumn): void {
// Remove any existing palette
this.contentEl.querySelectorAll('.wl-reading-col-palette').forEach((el) => el.remove());
const rect = anchor.getBoundingClientRect();
const palette = this.contentEl.createDiv({ cls: 'wl-reading-col-palette' });
palette.style.top = `${rect.bottom + 4}px`;
palette.style.left = `${rect.left}px`;
// Capture the owning document once so add/remove can't desync across popout windows.
const doc = this.contentEl.ownerDocument;
const currentColor = col.color ?? DEFAULT_FIELD_COLOR;
for (const { color600, color50 } of FIELD_COLORS) {
const swatch = palette.createEl('button', { cls: 'wl-reading-col-palette-swatch' });
swatch.style.backgroundColor = color50;
swatch.style.borderColor = color600;
if (currentColor === color600) swatch.addClass('is-active');
swatch.title = color600;
swatch.addEventListener('click', (e) => {
e.stopPropagation();
palette.remove();
void this.updateColumn({ ...col, color: color600 });
});
}
const close = (e: MouseEvent): void => {
if (!palette.contains(e.target as Node) && e.target !== anchor) {
palette.remove();
doc.removeEventListener('mousedown', close, true);
}
};
window.setTimeout(() => doc.addEventListener('mousedown', close, true), 0);
}
private renderAddSection(parent: HTMLElement): void {
const wrap = parent.createDiv({ cls: 'wl-reading-add-col' });
wrap.createDiv({ cls: 'wl-reading-section-label', text: 'Add field' });
const row = wrap.createDiv({ cls: 'wl-reading-add-col-row' });
const nameInput = row.createEl('input', {
cls: 'wl-modal-input wl-reading-add-col-name',
attr: { type: 'text', placeholder: 'Field name' },
});
const typeSelect = row.createEl('select', { cls: 'wl-select wl-reading-add-col-type' });
for (const t of ['text', 'number', 'select'] as const) {
typeSelect.createEl('option', {
value: t,
text: t.charAt(0).toUpperCase() + t.slice(1),
});
}
const optsInput = row.createEl('input', {
cls: 'wl-modal-input wl-reading-add-col-opts',
attr: {
type: 'text',
placeholder: 'Options (comma-separated)',
},
});
const refreshOpts = (): void => {
optsInput.style.display = typeSelect.value === 'select' ? '' : 'none';
};
typeSelect.addEventListener('change', refreshOpts);
refreshOpts();
const addBtn = row.createEl('button', {
cls: 'wl-reading-add-btn wl-btn-success wl-reading-add-col-btn',
text: 'Add',
});
addBtn.addEventListener('click', () => {
const name = nameInput.value.trim();
if (!name) {
new Notice('Enter a field name first.');
return;
}
const type = typeSelect.value as ReadingCustomColumn['type'];
const options =
type === 'select'
? optsInput.value
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: [];
const col: ReadingCustomColumn = {
id: this.readingData.generateColumnId(this.kind, name),
name,
type,
options,
color: DEFAULT_FIELD_COLOR,
};
void this.addColumn(col).then(() => {
nameInput.value = '';
optsInput.value = '';
typeSelect.value = 'text';
refreshOpts();
});
});
}
private async addColumn(col: ReadingCustomColumn): Promise<void> {
await this.readingData.addColumn(this.kind, col);
this.renderList();
this.onChanged();
}
private async updateColumn(col: ReadingCustomColumn): Promise<void> {
await this.readingData.updateColumn(this.kind, col);
this.renderList();
this.onChanged();
}
private async deleteColumn(id: string): Promise<void> {
await this.readingData.removeColumn(this.kind, id);
this.renderList();
this.onChanged();
}
private async reorder(from: number, to: number): Promise<void> {
await this.readingData.reorderColumns(this.kind, from, to);
this.renderList();
this.onChanged();
}
}

210
src/ReadingScheduleModal.ts Normal file
View file

@ -0,0 +1,210 @@
import { App, Modal, Notice } from 'obsidian';
import type { Book, Manga, AirtimeSchedule, AirtimeRecurrence } from './types';
import { parseDateInput } from './types';
/**
* Schedule modal for Reading titles (Books & Manga). Derived from the watchlist
* AirtimeScheduleModal's series branch same recurrence + auto-increment logic
* but with:
* - no "Time (HH:MM)" field,
* - chapter/volume counters instead of episode/season counters.
*
* The four counters map onto the shared AirtimeEntry slots in AirtimeTab:
* current volume currentSeason total volumes totalSeasons
* current chapter currentEpisode total chapters totalEpisodes
* so reading entries reuse the existing scheduler/countdown machinery unchanged.
*/
export class ReadingScheduleModal extends Modal {
private item: Book | Manga;
private kind: 'book' | 'manga';
private schedule: AirtimeSchedule;
private currentVolume: number | null;
private currentChapter: number | null;
private totalVolumes: number | null;
private totalChapters: number | null;
private onSave: (
schedule: AirtimeSchedule,
volume: number | null,
chapter: number | null,
totalVolumes: number | null,
totalChapters: number | null,
) => Promise<void>;
constructor(
app: App,
item: Book | Manga,
kind: 'book' | 'manga',
existingSchedule: AirtimeSchedule | null,
currentVolume: number | null,
currentChapter: number | null,
totalVolumes: number | null,
totalChapters: number | null,
onSave: (
schedule: AirtimeSchedule,
volume: number | null,
chapter: number | null,
totalVolumes: number | null,
totalChapters: number | null,
) => Promise<void>,
) {
super(app);
this.item = item;
this.kind = kind;
this.schedule = existingSchedule ? { ...existingSchedule } : { recurrence: 'once' };
// Time is never used for reading; strip any inherited value.
this.schedule.releaseTime = undefined;
// Pre-fill releaseDate from the item if the schedule doesn't have one yet.
if (!this.schedule.releaseDate && item.releaseDate && /^\d{4}-\d{2}-\d{2}$/.test(item.releaseDate)) {
this.schedule.releaseDate = item.releaseDate;
}
this.currentVolume = currentVolume;
this.currentChapter = currentChapter;
// Pre-fill totals from the item's tracked counts if not yet set.
const itemTotalChapters = item.totalChapters ?? 0;
const itemTotalVolumes = kind === 'manga' ? ((item as Manga).totalVolumes ?? 0) : 0;
this.totalChapters = totalChapters ?? (itemTotalChapters > 0 ? itemTotalChapters : null);
this.totalVolumes = totalVolumes ?? (itemTotalVolumes > 0 ? itemTotalVolumes : null);
this.onSave = onSave;
}
onOpen(): void {
this.titleEl.setText('Set reading schedule');
this.contentEl.addClass('wl-add-modal');
this.renderForm();
}
onClose(): void {
this.contentEl.empty();
}
private renderForm(): void {
this.contentEl.empty();
this.contentEl.addClass('wl-add-modal');
const content = this.contentEl;
const makeRow = (label: string): HTMLElement => {
const row = content.createDiv({ cls: 'wl-modal-row' });
row.createSpan({ cls: 'wl-modal-label', text: label });
return row;
};
const makeNumberRow = (
label: string,
value: number | null,
placeholder: string,
onInput: (v: number | null) => void,
): void => {
const row = makeRow(label);
const inp = row.createEl('input', {
cls: 'wl-modal-input wl-modal-input-sm',
attr: { type: 'number', min: '0', placeholder },
});
if (value !== null) inp.value = String(value);
inp.addEventListener('input', () => { onInput(parseInt(inp.value) || null); });
};
// ── Recurrence (no time field) ──────────────────────────────────────────────
const recRow = makeRow('Recurrence');
const recSelect = recRow.createEl('select', { cls: 'wl-select' });
const recOptions: Array<[AirtimeRecurrence, string]> = [
['once', 'Once'],
['daily', 'Daily'],
['weekly', 'Weekly'],
['monthly', 'Monthly'],
];
for (const [val, label] of recOptions) {
const opt = recSelect.createEl('option', { text: label, value: val });
if (val === this.schedule.recurrence) opt.selected = true;
}
const extraEl = content.createDiv();
const renderExtra = (): void => {
extraEl.empty();
const rec = this.schedule.recurrence;
if (rec === 'once') {
const r = extraEl.createDiv({ cls: 'wl-modal-row' });
r.createSpan({ cls: 'wl-modal-label', text: 'Date' });
const inp = r.createEl('input', {
cls: 'wl-modal-input',
attr: { type: 'text', placeholder: 'Dd/mm/yyyy', maxlength: '10' },
});
inp.value = this.schedule.releaseDate
? this.schedule.releaseDate.split('-').reverse().join('/')
: '';
inp.addEventListener('change', () => {
const parsed = parseDateInput(inp.value);
if (parsed) this.schedule.releaseDate = parsed;
});
}
if (rec === 'weekly') {
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const r = extraEl.createDiv({ cls: 'wl-modal-row' });
r.createSpan({ cls: 'wl-modal-label', text: 'Day of week' });
const daySelect = r.createEl('select', { cls: 'wl-select' });
DAYS.forEach((d, i) => {
const opt = daySelect.createEl('option', { text: d, value: String(i) });
if (i === (this.schedule.dayOfWeek ?? 6)) opt.selected = true;
});
daySelect.addEventListener('change', () => {
this.schedule.dayOfWeek = parseInt(daySelect.value);
});
}
if (rec === 'monthly') {
const r = extraEl.createDiv({ cls: 'wl-modal-row' });
r.createSpan({ cls: 'wl-modal-label', text: 'Day of month' });
const inp = r.createEl('input', {
cls: 'wl-modal-input wl-modal-input-sm',
attr: { type: 'number', min: '1', max: '31', placeholder: '1' },
});
inp.value = String(this.schedule.dayOfMonth ?? 1);
inp.addEventListener('input', () => { this.schedule.dayOfMonth = parseInt(inp.value) || 1; });
}
};
recSelect.addEventListener('change', () => {
this.schedule.recurrence = recSelect.value as AirtimeRecurrence;
renderExtra();
});
renderExtra();
// ── Chapter / volume counters ───────────────────────────────────────────────
// Books typically use only chapters; Manga use chapters + volumes. The user
// fills whichever applies and leaves the rest empty.
makeNumberRow('Current chapter', this.currentChapter, 'E.g. 12', (v) => { this.currentChapter = v; });
makeNumberRow('Current volume', this.currentVolume, 'E.g. 2', (v) => { this.currentVolume = v; });
makeNumberRow('Total chapters', this.totalChapters, 'E.g. 120', (v) => { this.totalChapters = v; });
makeNumberRow('Total volumes', this.totalVolumes, 'E.g. 12', (v) => { this.totalVolumes = v; });
// Static hint note (adapted for reading).
content.createDiv({
cls: 'wl-modal-info wl-schedule-hint',
text: 'Titles with 0 or 1 total chapters will be treated as a single release date, like a book.',
});
// ── Buttons ─────────────────────────────────────────────────────────────────
const btnRow = content.createDiv({ cls: 'wl-modal-btn-row' });
const cancelBtn = btnRow.createEl('button', { cls: 'wl-btn wl-btn-mr', text: 'Cancel' });
cancelBtn.addEventListener('click', () => this.close());
const saveBtn = btnRow.createEl('button', { cls: 'wl-btn wl-btn-primary', text: 'Save' });
saveBtn.addEventListener('click', () => {
void (async () => {
if (this.schedule.recurrence === 'once' && !this.schedule.releaseDate) {
new Notice('Set a release date.');
return;
}
await this.onSave(
this.schedule,
this.currentVolume,
this.currentChapter,
this.totalVolumes,
this.totalChapters,
);
this.close();
})();
});
}
}

1503
src/ReadingTab.ts Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

668
src/TitleDetailModal.ts Normal file
View file

@ -0,0 +1,668 @@
import { App, Modal } from 'obsidian';
import type WatchLogPlugin from './main';
import type { DataManager } from './DataManager';
import type { WatchLogTitle, WatchLogGroup, Season, TagDefinition } from './types';
import { formatTime, formatDateDisplay, parseDateInput, getThemedColor, getDisplayPoster } from './types';
import { EditTitleModal } from './EditTitleModal';
import { ConfirmModal } from './ConfirmModal';
import { renderCommunityRating, maybeAutoRefreshCommunityRating } from './CommunityRating';
export class TitleDetailModal extends Modal {
private plugin: WatchLogPlugin;
private dataManager: DataManager;
private title: WatchLogTitle;
private onChanged?: () => void;
private changed = false;
private collapsedSeasons: Set<number> = new Set();
// Section anchors so we can re-render incrementally
private statsBoxEl: HTMLElement | null = null;
private episodesSectionEl: HTMLElement | null = null;
private starsWrapEl: HTMLElement | null = null;
constructor(
app: App,
plugin: WatchLogPlugin,
title: WatchLogTitle,
onChanged?: () => void,
) {
super(app);
this.plugin = plugin;
this.dataManager = plugin.dataManager;
this.title = title;
this.onChanged = onChanged;
this.collapsedSeasons = this.dataManager.getCollapsedSeasonsForTitle(title.id);
}
onOpen(): void {
const colorTheme = this.plugin.settings.colorTheme ?? 'default';
// The theme CSS variables (--wl-watched-color, --wl-rating-color, ...)
// are scoped to `.wl-view` and `.wl-view[data-theme=...]`. Mark the
// content root with both so episode boxes, stars, and progress bars
// pick up the active theme just like List view's expand does.
this.modalEl.setAttribute('data-theme', colorTheme);
this.contentEl.setAttribute('data-theme', colorTheme);
this.contentEl.addClass('wl-view');
this.contentEl.addClass('wl-detail-modal');
this.renderAll();
}
onClose(): void {
this.contentEl.empty();
if (this.changed && this.onChanged) {
this.onChanged();
}
}
private getTagDef(name: string, list: TagDefinition[]): TagDefinition | undefined {
return list.find((d) => d.name === name);
}
private refreshTitle(): void {
const fresh = this.dataManager.getTitle(this.title.id);
if (fresh) this.title = fresh;
}
private markChanged(): void {
this.changed = true;
}
private renderAll(): void {
this.contentEl.empty();
this.renderHeader(this.contentEl);
this.renderEpisodesSection(this.contentEl);
this.renderRatingSection(this.contentEl);
this.renderNotesSection(this.contentEl);
this.renderDateSection(this.contentEl);
this.renderFooter(this.contentEl);
}
// ── Header ────────────────────────────────────────────────────────────────
private renderHeader(parent: HTMLElement): void {
const header = parent.createDiv({ cls: 'wl-detail-header' });
const left = header.createDiv({ cls: 'wl-detail-header-left' });
left.createEl('h2', { cls: 'wl-detail-title', text: this.title.title });
const badgeRow = left.createDiv({ cls: 'wl-detail-badge-row' });
const typeDef = this.getTagDef(this.title.type, this.plugin.settings.types);
const typeColor = typeDef
? getThemedColor(this.title.type, typeDef.color, this.plugin.settings.colorTheme)
: '#888780';
const typeBadge = badgeRow.createSpan({
cls: 'wl-card-type-badge',
text: this.title.type,
});
typeBadge.style.backgroundColor = typeColor;
if (this.title.externalLink) {
const linkIcon = badgeRow.createEl('a', { cls: 'wl-acc-link-icon', text: '🌐' });
linkIcon.href = this.title.externalLink;
linkIcon.title = 'Open external link';
linkIcon.target = '_blank';
linkIcon.rel = 'noopener noreferrer';
}
// Editable status badge, inline to the right of the type badge / link icon.
// Mirrors the Reading detail modal's clickable badge + dropdown, but draws
// from the user-configured watchlist statuses and their colors.
const statusWrap = badgeRow.createSpan({ cls: 'wl-reading-detail-status-wrap' });
this.renderStatusBadge(statusWrap);
this.statsBoxEl = header.createDiv({ cls: 'wl-detail-stats-box' });
this.renderStatsBox();
}
private renderStatsBox(): void {
if (!this.statsBoxEl) return;
this.statsBoxEl.empty();
const t = this.title;
const timeLeft = this.dataManager.calcTimeRemainingForModal(t);
const timeWatched = this.dataManager.calcTimeWatched(t);
const watchedEps = t.watchedEpisodes.length;
const effectiveTotal = this.dataManager.getEffectiveTotal(t);
const progress = this.dataManager.getProgress(t);
const makeBlock = (value: string, label: string): void => {
const block = this.statsBoxEl!.createDiv({ cls: 'wl-acc-stat-block' });
block.createDiv({ cls: 'wl-acc-percent', text: value });
block.createDiv({ cls: 'wl-acc-progress-label', text: label });
};
makeBlock(formatTime(timeLeft), 'left');
makeBlock(formatTime(timeWatched), 'watched');
makeBlock(`${watchedEps} / ${effectiveTotal}`, 'episodes');
const progBlock = this.statsBoxEl.createDiv({ cls: 'wl-acc-header-right' });
progBlock.createDiv({ cls: 'wl-acc-percent', text: `${progress}%` });
progBlock.createDiv({ cls: 'wl-acc-progress-label', text: 'progress' });
const barWrap = progBlock.createDiv({ cls: 'wl-acc-progress-wrap' });
barWrap.createDiv({ cls: 'wl-progress-bar' }).style.width = `${progress}%`;
}
// ── Episodes / Seasons ────────────────────────────────────────────────────
private renderEpisodesSection(parent: HTMLElement): void {
this.episodesSectionEl = parent.createDiv({ cls: 'wl-detail-episodes' });
this.renderEpisodesBody();
}
private renderEpisodesBody(): void {
if (!this.episodesSectionEl) return;
this.episodesSectionEl.empty();
const t = this.title;
if (t.type === 'Movie') {
const row = this.episodesSectionEl.createDiv({ cls: 'wl-movie-row' });
const cb = row.createEl('input', {
cls: 'wl-movie-checkbox',
attr: { type: 'checkbox' },
});
cb.checked = t.watchedEpisodes.includes(1);
row.createSpan({ cls: 'wl-movie-label', text: 'Watched' });
cb.addEventListener('change', () => {
void this.dataManager.markEpisodeWatched(t.id, 1, cb.checked).then(() => {
this.refreshTitle();
this.markChanged();
this.renderStatsBox();
});
});
return;
}
if (t.seasons.length === 0) {
if (t.totalEpisodes > 0) {
this.renderEpisodeGrid(this.episodesSectionEl, null);
}
return;
}
t.seasons.forEach((season, seasonIdx) => {
let isCollapsed = this.collapsedSeasons.has(seasonIdx);
const seasonWrap = this.episodesSectionEl!.createDiv({ cls: 'wl-season-wrap' });
const seasonHeader = seasonWrap.createDiv({ cls: 'wl-season-header' });
const badge = seasonHeader.createSpan({ cls: 'wl-season-badge' });
badge.textContent = season.name;
const palette = this.plugin.settings.seasonPalette;
badge.style.backgroundColor = palette[seasonIdx % palette.length] ?? '#888780';
const skipCount = (season.skippedEpisodes ?? []).length;
const skipSuffix = skipCount > 0 ? ` (${skipCount} to skip)` : '';
seasonHeader.createSpan({
cls: 'wl-season-ep-count',
text: `${season.episodes} eps${skipSuffix}`,
});
const chevron = seasonHeader.createSpan({
cls: `wl-chevron${isCollapsed ? '' : ' is-open'}`,
text: '',
});
if (!isCollapsed) {
this.renderEpisodeGrid(seasonWrap, season);
}
seasonHeader.addEventListener('click', () => {
if (isCollapsed) {
isCollapsed = false;
this.collapsedSeasons.delete(seasonIdx);
chevron.classList.add('is-open');
this.renderEpisodeGrid(seasonWrap, season);
} else {
isCollapsed = true;
this.collapsedSeasons.add(seasonIdx);
chevron.classList.remove('is-open');
seasonWrap.querySelector('.wl-episode-grid')?.remove();
}
void this.dataManager.persistCollapsedSeasons(this.title.id, this.collapsedSeasons);
});
});
}
private renderEpisodeGrid(parent: HTMLElement, season: Season | null): void {
const t = this.title;
const grid = parent.createDiv({ cls: 'wl-episode-grid' });
const count = season ? season.episodes : t.totalEpisodes;
const offset = season ? season.offset : 0;
const seasonEps = Array.from({ length: count }, (_, i) => offset + i + 1);
const fillBtn = grid.createDiv({ cls: 'wl-season-fill-btn' });
const refreshFillBtn = (): void => {
const watched = new Set(this.title.watchedEpisodes);
const allWatched = seasonEps.length > 0 && seasonEps.every((ep) => watched.has(ep));
fillBtn.classList.toggle('is-clear', allWatched);
fillBtn.classList.toggle('is-fill', !allWatched);
fillBtn.textContent = allWatched ? '✗' : '✓';
fillBtn.title = allWatched
? 'Clear all episodes in this season'
: 'Mark all episodes in this season as watched';
};
refreshFillBtn();
fillBtn.addEventListener('click', () => {
const watched = new Set(this.title.watchedEpisodes);
const allWatched = seasonEps.length > 0 && seasonEps.every((ep) => watched.has(ep));
void this.dataManager
.markSeasonWatched(this.title.id, seasonEps, !allWatched)
.then(() => {
this.refreshTitle();
this.markChanged();
this.renderEpisodesBody();
this.renderStatsBox();
});
});
const perSeason = this.plugin.settings.episodeNumbering === 'per-season';
for (let i = 0; i < count; i++) {
const epNum = offset + i + 1;
const relNum = i + 1;
const displayNum = perSeason ? relNum : epNum;
const box = grid.createDiv({ cls: 'wl-episode-box' });
const refreshBox = (): void => {
const isWatched = this.title.watchedEpisodes.includes(epNum);
const isSkipped = season ? (season.skippedEpisodes ?? []).includes(relNum) : false;
box.classList.toggle('is-watched', isWatched);
box.classList.toggle('is-skipped', isSkipped);
box.textContent = isSkipped && !isWatched ? '—' : isWatched ? '✓' : String(displayNum);
box.title = `Episode ${epNum}${isSkipped ? ' (skipped)' : ''}`;
};
refreshBox();
box.addEventListener('click', () => {
const isWatched = this.title.watchedEpisodes.includes(epNum);
this.dataManager.applyEpisodeWatchedToggle(this.title.id, epNum, !isWatched);
this.refreshTitle();
this.markChanged();
refreshBox();
refreshFillBtn();
this.renderStatsBox();
});
}
}
// ── Rating ────────────────────────────────────────────────────────────────
private renderRatingSection(parent: HTMLElement): void {
const wrap = parent.createDiv({ cls: 'wl-detail-rating' });
wrap.createSpan({ cls: 'wl-stars-label', text: 'Rating' });
this.starsWrapEl = wrap.createDiv({ cls: 'wl-stars wl-detail-stars' });
this.renderStars();
const rerenderCommunity = (): void => {
const next = wrap.querySelector('.wl-rating-divider');
let n: ChildNode | null = next;
while (n) {
const toRemove = n;
n = n.nextSibling;
toRemove.parentNode?.removeChild(toRemove);
}
renderCommunityRating(wrap, this.plugin, this.title.id, rerenderCommunity);
this.refreshTitle();
};
renderCommunityRating(wrap, this.plugin, this.title.id, rerenderCommunity);
maybeAutoRefreshCommunityRating(this.plugin, this.title.id, rerenderCommunity);
}
private renderStars(): void {
if (!this.starsWrapEl) return;
this.starsWrapEl.empty();
for (let i = 1; i <= 5; i++) {
const star = this.starsWrapEl.createSpan({
cls: `wl-star${this.title.rating >= i ? ' is-active' : ''}`,
text: '★',
});
star.addEventListener('click', () => {
void (async () => {
const t = this.dataManager.getTitle(this.title.id);
if (!t) return;
t.rating = t.rating === i ? 0 : i;
await this.dataManager.updateTitle(t);
this.refreshTitle();
this.markChanged();
this.renderStars();
})();
});
}
}
// ── Notes ─────────────────────────────────────────────────────────────────
private renderNotesSection(parent: HTMLElement): void {
const wrap = parent.createDiv({ cls: 'wl-detail-notes' });
wrap.createSpan({ cls: 'wl-stars-label', text: 'Notes' });
const textarea = wrap.createEl('textarea', {
cls: 'wl-detail-notes-input',
attr: { placeholder: 'Add notes...', rows: '3' },
});
textarea.value = this.title.notes;
const autoResize = (): void => {
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
};
textarea.addEventListener('input', autoResize);
window.setTimeout(autoResize, 0);
textarea.addEventListener('blur', () => {
if (textarea.value === this.title.notes) return;
void (async () => {
const t = this.dataManager.getTitle(this.title.id);
if (!t) return;
t.notes = textarea.value;
await this.dataManager.updateTitle(t);
this.refreshTitle();
this.markChanged();
})();
});
}
// ── Date watched ──────────────────────────────────────────────────────────
private renderDateSection(parent: HTMLElement): void {
const wrap = parent.createDiv({ cls: 'wl-detail-date' });
wrap.createSpan({ cls: 'wl-stars-label', text: 'Date watched' });
const todayBtn = wrap.createEl('button', {
cls: 'wl-btn wl-btn-sm wl-footer-today-btn',
text: 'Today',
});
const dateInput = wrap.createEl('input', {
cls: 'wl-footer-date',
attr: { type: 'text', placeholder: 'Dd/mm/yyyy', maxlength: '10' },
});
dateInput.value = formatDateDisplay(this.title.dateFinished);
const refreshDimmed = (): void => {
todayBtn.toggleClass('is-dimmed', !!dateInput.value.trim());
};
refreshDimmed();
dateInput.addEventListener('change', () => {
void (async () => {
const t = this.dataManager.getTitle(this.title.id);
if (!t) return;
const parsed = parseDateInput(dateInput.value);
if (dateInput.value.trim() && !parsed) {
dateInput.addClass('wl-input-error');
return;
}
dateInput.removeClass('wl-input-error');
t.dateFinished = parsed;
await this.dataManager.updateTitle(t);
this.refreshTitle();
this.markChanged();
refreshDimmed();
})();
});
todayBtn.addEventListener('click', () => {
if (dateInput.value.trim()) return;
const now = new Date();
const dd = String(now.getDate()).padStart(2, '0');
const mm = String(now.getMonth() + 1).padStart(2, '0');
const yyyy = now.getFullYear();
dateInput.value = `${dd}/${mm}/${yyyy}`;
refreshDimmed();
dateInput.dispatchEvent(new Event('change'));
});
}
// ── Status ────────────────────────────────────────────────────────────────
private statusColor(name: string): string {
const def = this.getTagDef(name, this.plugin.settings.statuses);
return def
? getThemedColor(name, def.color, this.plugin.settings.colorTheme)
: '#888780';
}
private renderStatusBadge(wrap: HTMLElement): void {
wrap.empty();
const status = this.title.status;
const badge = wrap.createSpan({ cls: 'wl-reading-detail-status', text: status });
badge.style.backgroundColor = this.statusColor(status);
badge.title = 'Click to change status';
badge.addEventListener('click', (e) => {
e.stopPropagation();
this.openStatusDropdown(badge, wrap);
});
}
private openStatusDropdown(anchor: HTMLElement, wrap: HTMLElement): void {
this.contentEl.querySelectorAll('.wl-reading-status-dropdown').forEach((el) => el.remove());
const rect = anchor.getBoundingClientRect();
const dropdown = this.contentEl.createDiv({ cls: 'wl-reading-status-dropdown' });
dropdown.style.top = `${rect.bottom + 4}px`;
dropdown.style.left = `${rect.left}px`;
// Capture the owning document once so add/remove can't desync across popout windows.
const doc = this.contentEl.ownerDocument;
for (const s of this.plugin.settings.statuses) {
if (s.name === 'To be released') continue;
const opt = dropdown.createDiv({ cls: 'wl-reading-status-option' });
const dot = opt.createSpan({ cls: 'wl-reading-status-option-dot' });
dot.style.backgroundColor = this.statusColor(s.name);
opt.createSpan({ text: s.name });
opt.addEventListener('click', () => {
dropdown.remove();
doc.removeEventListener('mousedown', closeListener, true);
void this.saveStatus(s.name).then(() => this.renderStatusBadge(wrap));
});
}
const closeListener = (e: MouseEvent): void => {
if (!dropdown.contains(e.target as Node)) {
dropdown.remove();
doc.removeEventListener('mousedown', closeListener, true);
}
};
window.setTimeout(() => doc.addEventListener('mousedown', closeListener, true), 0);
}
private async saveStatus(status: string): Promise<void> {
const t = this.dataManager.getTitle(this.title.id);
if (!t) return;
t.status = status;
await this.dataManager.updateTitle(t);
this.refreshTitle();
this.markChanged();
}
// ── Footer ────────────────────────────────────────────────────────────────
private renderFooter(parent: HTMLElement): void {
const footer = parent.createDiv({ cls: 'wl-detail-footer' });
const removeBtn = footer.createEl('button', {
cls: 'wl-delete-btn wl-btn-danger wl-detail-remove',
text: 'Remove',
});
removeBtn.addEventListener('click', () => {
new ConfirmModal(
this.plugin.app,
`Remove "${this.title.title}" from watchlog?`,
() => {
void this.dataManager.removeTitle(this.title.id).then(() => {
this.markChanged();
this.close();
});
},
).open();
});
const editBtn = footer.createEl('button', {
cls: 'wl-edit-btn wl-detail-edit',
text: 'Edit',
});
editBtn.addEventListener('click', () => {
const current = this.dataManager.getTitle(this.title.id);
if (!current) return;
this.close();
new EditTitleModal(this.plugin.app, this.plugin, this.dataManager, current, () => {
if (this.onChanged) this.onChanged();
}).open();
});
}
}
// ── Group detail modal ──────────────────────────────────────────────────────
export class GroupDetailModal extends Modal {
private plugin: WatchLogPlugin;
private dataManager: DataManager;
private group: WatchLogGroup;
private members: WatchLogTitle[];
private onChanged?: () => void;
private changed = false;
constructor(
app: App,
plugin: WatchLogPlugin,
group: WatchLogGroup,
members: WatchLogTitle[],
onChanged?: () => void,
) {
super(app);
this.plugin = plugin;
this.dataManager = plugin.dataManager;
this.group = group;
this.members = members;
this.onChanged = onChanged;
}
onOpen(): void {
const colorTheme = this.plugin.settings.colorTheme ?? 'default';
this.modalEl.setAttribute('data-theme', colorTheme);
this.contentEl.setAttribute('data-theme', colorTheme);
this.contentEl.addClass('wl-view');
this.contentEl.addClass('wl-detail-modal');
this.renderAll();
}
onClose(): void {
this.contentEl.empty();
if (this.changed && this.onChanged) this.onChanged();
}
private renderAll(): void {
this.contentEl.empty();
const header = this.contentEl.createDiv({ cls: 'wl-detail-header' });
const left = header.createDiv({ cls: 'wl-detail-header-left' });
left.createEl('h2', { cls: 'wl-detail-title', text: this.group.name });
left.createDiv({
cls: 'wl-detail-group-meta',
text: `${this.members.length} title${this.members.length !== 1 ? 's' : ''}`,
});
const totalEps = this.members.reduce(
(s, m) => s + this.dataManager.getEffectiveTotal(m),
0,
);
const watchedEps = this.members.reduce((s, m) => s + m.watchedEpisodes.length, 0);
const progress = totalEps > 0 ? Math.round((watchedEps / totalEps) * 100) : 0;
const statsBox = header.createDiv({ cls: 'wl-detail-stats-box' });
const progBlock = statsBox.createDiv({ cls: 'wl-acc-header-right' });
progBlock.createDiv({ cls: 'wl-acc-percent', text: `${progress}%` });
progBlock.createDiv({ cls: 'wl-acc-progress-label', text: 'progress' });
const barWrap = progBlock.createDiv({ cls: 'wl-acc-progress-wrap' });
barWrap.createDiv({ cls: 'wl-progress-bar' }).style.width = `${progress}%`;
const grid = this.contentEl.createDiv({ cls: 'wl-cards-grid wl-detail-group-grid' });
for (const m of this.members) {
const card = buildTitleCardElement(this.plugin, m, {
onOpenDetail: () => {
this.close();
new TitleDetailModal(this.plugin.app, this.plugin, m, () => {
this.changed = true;
if (this.onChanged) this.onChanged();
}).open();
},
onEdit: () => {
this.close();
new EditTitleModal(
this.plugin.app,
this.plugin,
this.dataManager,
m,
() => {
this.changed = true;
if (this.onChanged) this.onChanged();
},
).open();
},
});
grid.appendChild(card);
}
}
}
// Builds a standalone .wl-card element matching the Cards grid look. Used by
// the Cards grid (via ListTab) and by GroupDetailModal so member tiles look
// identical to the main view. Posters with no cached URL get fetched eagerly
// here since a group modal has a small, finite member count.
export function buildTitleCardElement(
plugin: WatchLogPlugin,
title: WatchLogTitle,
handlers: { onOpenDetail: () => void; onEdit: () => void },
): HTMLElement {
const settings = plugin.settings;
const typeDef = settings.types.find((d) => d.name === title.type);
const statusDef = settings.statuses.find((d) => d.name === title.status);
const typeColor = typeDef
? getThemedColor(title.type, typeDef.color, settings.colorTheme)
: '#888780';
const tmp = activeDocument.createElement('div');
const card = tmp.createDiv({ cls: 'wl-card' });
card.dataset.titleId = title.id;
const placeholder = card.createDiv({ cls: 'wl-card-poster-placeholder' });
placeholder.style.backgroundColor = typeColor;
const letter = (title.title.trim().charAt(0) || '?').toUpperCase();
placeholder.createSpan({ text: letter });
const img = card.createEl('img', { cls: 'wl-card-poster' });
img.alt = title.title;
const showImg = (url: string): void => {
img.src = url;
img.style.display = 'block';
placeholder.style.display = 'none';
};
const showPlaceholder = (): void => {
img.style.display = 'none';
placeholder.style.display = '';
};
const display = getDisplayPoster(title);
const isManual = !!(title.manualPosterUrl && title.manualPosterUrl.trim() !== '');
if (display && display.startsWith('http')) {
showImg(display);
img.onerror = () => {
showPlaceholder();
if (!isManual) plugin.dataManager.updatePosterUrl(title.id, 'none');
};
} else if (!isManual && title.posterUrl === '') {
placeholder.addClass('is-loading');
void plugin.posterService?.enqueue(title).then((url) => {
placeholder.removeClass('is-loading');
if (url) showImg(url);
});
}
if (statusDef) {
const statusBadge = card.createSpan({
cls: 'wl-card-status-badge',
text: title.status,
});
statusBadge.style.backgroundColor = getThemedColor(
title.status,
statusDef.color,
settings.colorTheme,
);
}
const overlay = card.createDiv({ cls: 'wl-card-overlay' });
overlay.createSpan({ cls: 'wl-card-title', text: title.title });
const typeBadge = overlay.createSpan({ cls: 'wl-card-type-badge', text: title.type });
typeBadge.style.backgroundColor = typeColor;
const total = title.totalEpisodes;
if (total && total > 0) {
const isCompleted = title.status === 'Completed';
const ratio = isCompleted
? 1
: Math.max(0, Math.min(1, title.watchedEpisodes.length / total));
const bar = overlay.createDiv({ cls: 'wl-card-progress-bar' });
const fill = bar.createDiv({ cls: 'wl-card-progress-fill' });
fill.style.width = `${ratio * 100}%`;
}
const menuBtn = card.createEl('button', { cls: 'wl-card-menu-btn', text: '⋮' });
menuBtn.setAttr('aria-label', 'Edit');
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
handlers.onEdit();
});
card.addEventListener('click', () => handlers.onOpenDetail());
return card;
}

View file

@ -0,0 +1,40 @@
import { App, FuzzySuggestModal } from 'obsidian';
/** A unified finder entry spanning watchlist titles and reading items. */
export interface UpcomingFinderItem {
source: 'watchlist' | 'reading';
kind?: 'book' | 'manga';
id: string;
title: string;
/** Type/kind label shown in parentheses (e.g. "Anime", "Book", "Manga"). */
typeLabel: string;
}
/**
* The shared "+ add" finder for the Upcoming tab. Extends the watchlist finder to
* also surface Reading items, so a reading title can be added to Upcoming through
* the exact same pathway never a separate add flow.
*/
export class UpcomingFinderModal extends FuzzySuggestModal<UpcomingFinderItem> {
private items: UpcomingFinderItem[];
private onSelect: (item: UpcomingFinderItem) => void;
constructor(app: App, items: UpcomingFinderItem[], onSelect: (item: UpcomingFinderItem) => void) {
super(app);
this.items = items;
this.onSelect = onSelect;
this.setPlaceholder('Search a title to add to Upcoming...');
}
getItems(): UpcomingFinderItem[] {
return this.items;
}
getItemText(item: UpcomingFinderItem): string {
return `${item.title} (${item.typeLabel})`;
}
onChooseItem(item: UpcomingFinderItem): void {
this.onSelect(item);
}
}

View file

@ -1,240 +1,555 @@
import { ItemView, WorkspaceLeaf } from 'obsidian';
import type WatchLogPlugin from './main';
import type { DataManager } from './DataManager';
import { DashboardTab } from './DashboardTab';
import { ListTab } from './ListTab';
import { AirtimeTab } from './AirtimeTab';
import { CustomListsTab } from './CustomListsTab';
import { DraftsTab } from './DraftsTab';
export const WATCHLOG_VIEW_TYPE = 'watchlog-view';
type TabName = 'dashboard' | 'watchlist' | 'upcoming' | 'custom-lists' | 'drafts';
export class WatchLogView extends ItemView {
private plugin: WatchLogPlugin;
private dataManager: DataManager;
private activeTab: TabName;
private dashboardTab: DashboardTab | null = null;
private listTab: ListTab | null = null;
private airtimeTab: AirtimeTab | null = null;
private customListsTab: CustomListsTab | null = null;
private draftsTab: DraftsTab | null = null;
private airtimeBtn: HTMLButtonElement | null = null;
private draftsBtn: HTMLButtonElement | null = null;
private tabContentEl: HTMLElement | null = null;
private dataChangeListener: () => void;
constructor(leaf: WorkspaceLeaf, plugin: WatchLogPlugin, dataManager: DataManager) {
super(leaf);
this.plugin = plugin;
this.dataManager = dataManager;
// defaultView is 'dashboard' | 'watchlist'; map to internal TabName
const dv = plugin.settings.defaultView as string;
this.activeTab = (dv === 'watchlist' || dv === 'dashboard') ? dv as TabName : 'watchlist';
// Re-render in-place so tab instances (and their state) are preserved
this.dataChangeListener = () => this.refreshActiveTab();
}
getViewType(): string {
return WATCHLOG_VIEW_TYPE;
}
getDisplayText(): string {
return 'Watchlog';
}
getIcon(): string {
return 'tv';
}
async onOpen(): Promise<void> {
await super.onOpen();
this.dataManager.onChange(this.dataChangeListener);
this.buildUI();
}
async onClose(): Promise<void> {
await super.onClose();
this.dataManager.offChange(this.dataChangeListener);
this.destroyDraftsTab();
}
/** Reapplies the active colour theme and rebuilds the UI. Used by SettingsTab. */
refreshUI(): void {
this.applyColorTheme(this.contentEl);
this.buildUI();
}
private applyColorTheme(root: HTMLElement): void {
const colorTheme = this.plugin.settings.colorTheme ?? 'default';
if (colorTheme === 'default') {
root.removeAttribute('data-theme');
} else {
root.setAttribute('data-theme', colorTheme);
}
}
private destroyDraftsTab(): void {
if (this.draftsTab) {
this.draftsTab.destroy();
this.draftsTab = null;
}
}
private buildUI(): void {
// Destroy any live drafts listener before rebuilding the UI
this.destroyDraftsTab();
const root = this.contentEl;
root.empty();
root.addClass('wl-view');
this.applyColorTheme(root);
// Tab bar
const tabBar = root.createDiv({ cls: 'wl-tab-bar' });
const dashBtn = tabBar.createEl('button', {
cls: `wl-tab-btn${this.activeTab === 'dashboard' ? ' is-active' : ''}`,
text: 'Dashboard',
});
const listBtn = tabBar.createEl('button', {
cls: `wl-tab-btn${this.activeTab === 'watchlist' ? ' is-active' : ''}`,
text: 'Watchlist',
});
this.airtimeBtn = tabBar.createEl('button', {
cls: `wl-tab-btn${this.activeTab === 'upcoming' ? ' is-active' : ''}`,
text: 'Upcoming',
});
const customListsBtn = tabBar.createEl('button', {
cls: `wl-tab-btn${this.activeTab === 'custom-lists' ? ' is-active' : ''}`,
text: 'Custom lists',
});
this.draftsBtn = tabBar.createEl('button', {
cls: `wl-tab-btn${this.activeTab === 'drafts' ? ' is-active' : ''}`,
text: 'Drafts',
});
const allBtns = [dashBtn, listBtn, this.airtimeBtn, customListsBtn, this.draftsBtn];
dashBtn.addEventListener('click', () => {
if (this.activeTab === 'dashboard') return;
this.destroyDraftsTab();
this.activeTab = 'dashboard';
allBtns.forEach((b) => b.removeClass('is-active'));
dashBtn.addClass('is-active');
this.renderTabContent();
});
listBtn.addEventListener('click', () => {
if (this.activeTab === 'watchlist') return;
this.destroyDraftsTab();
this.activeTab = 'watchlist';
allBtns.forEach((b) => b.removeClass('is-active'));
listBtn.addClass('is-active');
this.renderTabContent();
});
this.airtimeBtn.addEventListener('click', () => {
if (this.activeTab === 'upcoming') return;
this.destroyDraftsTab();
this.activeTab = 'upcoming';
allBtns.forEach((b) => b.removeClass('is-active'));
this.airtimeBtn!.addClass('is-active');
this.renderTabContent();
});
customListsBtn.addEventListener('click', () => {
if (this.activeTab === 'custom-lists') return;
this.destroyDraftsTab();
this.activeTab = 'custom-lists';
allBtns.forEach((b) => b.removeClass('is-active'));
customListsBtn.addClass('is-active');
this.renderTabContent();
});
this.draftsBtn.addEventListener('click', () => {
if (this.activeTab === 'drafts') return;
this.activeTab = 'drafts';
allBtns.forEach((b) => b.removeClass('is-active'));
this.draftsBtn!.addClass('is-active');
this.renderTabContent();
});
this.tabContentEl = root.createDiv({ cls: 'wl-tab-content' });
this.renderTabContent();
// Initialize Upcoming badge immediately
this.updateUpcomingBadge();
}
private renderTabContent(): void {
if (!this.tabContentEl) return;
this.tabContentEl.empty();
if (this.activeTab === 'dashboard') {
this.dashboardTab = new DashboardTab(this.tabContentEl, this.plugin, this.dataManager);
this.dashboardTab.render();
} else if (this.activeTab === 'watchlist') {
// Always create a new instance when switching to Watchlist tab so that the
// container element is correct; state (filters, expandedId) is preserved
// via the instance kept on this.listTab during data-change refreshes.
this.listTab?.destroy();
this.listTab = new ListTab(this.tabContentEl, this.plugin, this.dataManager);
this.listTab.render();
} else if (this.activeTab === 'upcoming') {
this.airtimeTab = new AirtimeTab(this.tabContentEl, this.plugin, this.dataManager, (count) => {
if (this.airtimeBtn) {
this.airtimeBtn.textContent = count > 0 ? `Upcoming (${count})` : 'Upcoming';
}
});
this.airtimeTab.render();
} else if (this.activeTab === 'drafts') {
this.draftsTab = new DraftsTab(
this.tabContentEl,
this.plugin,
this.dataManager,
(count) => {
if (this.draftsBtn) {
this.draftsBtn.textContent = count > 0 ? `Drafts (${count})` : 'Drafts';
}
},
);
void this.draftsTab.render();
} else {
// custom-lists
this.customListsTab = new CustomListsTab(this.tabContentEl, this.plugin, this.dataManager);
void this.customListsTab.render();
}
}
/**
* Called by the data change listener. Re-renders the active tab without
* destroying the ListTab instance, so expandedId and filter state survive.
*/
private refreshActiveTab(): void {
this.applyColorTheme(this.contentEl);
if (this.activeTab === 'watchlist' && this.listTab) {
this.listTab.render();
} else if (this.activeTab === 'dashboard' && this.dashboardTab) {
this.dashboardTab.render();
} else if (this.activeTab === 'upcoming' && this.airtimeTab) {
this.airtimeTab.render();
} else if (this.activeTab === 'custom-lists' && this.customListsTab) {
void this.customListsTab.render();
} else if (this.activeTab === 'drafts' && this.draftsTab) {
void this.draftsTab.render();
} else {
this.renderTabContent();
}
this.updateUpcomingBadge();
}
private updateUpcomingBadge(): void {
if (!this.airtimeBtn) return;
const count = AirtimeTab.getAiredDueCount(this.dataManager) + AirtimeTab.getMaybeDueCount(this.dataManager);
this.airtimeBtn.textContent = count > 0 ? `Upcoming (${count})` : 'Upcoming';
}
}
import { ItemView, Platform, WorkspaceLeaf } from 'obsidian';
import type WatchLogPlugin from './main';
import type { DataManager } from './DataManager';
import { DashboardTab } from './DashboardTab';
import { ListTab } from './ListTab';
import { AirtimeTab } from './AirtimeTab';
import { CustomListsTab } from './CustomListsTab';
import { DraftsTab } from './DraftsTab';
import { ReadingTab } from './ReadingTab';
import { LogTab } from './LogTab';
export const WATCHLOG_VIEW_TYPE = 'watchlog-view';
export type TabName = 'dashboard' | 'watchlist' | 'upcoming' | 'reading' | 'custom-lists' | 'drafts' | 'log';
const TAB_CLASSES = ['wl-dashboard', 'wl-list', 'wl-airtime', 'wl-reading-tab', 'wl-custom-lists', 'wl-log-tab'];
export class WatchLogView extends ItemView {
private plugin: WatchLogPlugin;
private dataManager: DataManager;
private activeTab: TabName;
private dashboardTab: DashboardTab | null = null;
private listTab: ListTab | null = null;
private airtimeTab: AirtimeTab | null = null;
private readingTab: ReadingTab | null = null;
private customListsTab: CustomListsTab | null = null;
private draftsTab: DraftsTab | null = null;
private logTab: LogTab | null = null;
private airtimeBtn: HTMLButtonElement | null = null;
private draftsBtn: HTMLButtonElement | null = null;
private tabButtons: Partial<Record<TabName, HTMLButtonElement>> = {};
private tabContentEl: HTMLElement | null = null;
private dataChangeListener: () => void;
private mobileResizeObserver: ResizeObserver | null = null;
private mobileSizerRaf: number | null = null;
private lastAppliedTabHeight = -1;
// --- Mobile keyboard "edit pin" state (see edit-pin block below) ---
private editPinActive = false;
private smallestEditHeight = -1;
private pinReleaseTimer: number | null = null;
private focusListenersBound = false;
constructor(leaf: WorkspaceLeaf, plugin: WatchLogPlugin, dataManager: DataManager) {
super(leaf);
this.plugin = plugin;
this.dataManager = dataManager;
// The panel always opens on the Dashboard tab.
this.activeTab = 'dashboard';
// Re-render in-place so tab instances (and their state) are preserved
this.dataChangeListener = () => {
this.refreshActiveTab();
};
}
getViewType(): string {
return WATCHLOG_VIEW_TYPE;
}
getDisplayText(): string {
return 'Watchlog';
}
getIcon(): string {
return 'tv';
}
async onOpen(): Promise<void> {
await super.onOpen();
this.dataManager.onChange(this.dataChangeListener);
this.buildUI();
if (Platform.isMobile) {
this.setupTabContentSizer();
}
}
// ===================== Mobile keyboard layout fix =====================
// Permanent fix for the iOS/Android on-screen-keyboard layout collapse.
// When the keyboard opens, this WebView freezes .wl-tab-content at its
// content height instead of distributing .wl-view's free space via
// flex-grow, leaving the tab content the wrong size until a reflow forces
// it. Fix: observe .wl-view (contentEl) and, on every settle (keyboard
// open/close, rotation, split resize), imperatively set an explicit pixel
// height (+ flex:none !important) on the *current* .wl-tab-content. The
// element is re-queried in the callback so it survives tab-content rebuilds.
// =====================================================================
private setupTabContentSizer(): void {
// Never run more than one observer (onOpen could run again, etc.).
this.mobileResizeObserver?.disconnect();
this.mobileResizeObserver = new ResizeObserver(() => {
// Coalesce bursts of resize events (keyboard animation fires many)
// into a single write per frame so we don't thrash the DOM.
if (this.mobileSizerRaf !== null) return;
this.mobileSizerRaf = window.requestAnimationFrame(() => {
this.mobileSizerRaf = null;
this.applyTabContentHeight();
});
});
this.mobileResizeObserver.observe(this.contentEl);
// --- Mobile keyboard fix, layer 2: the "edit pin" (focus listeners) ---
// Detect when an inline editor inside the scroller (.wl-tab-content) gains
// or loses focus, so we can pin/release the scroller height. Bound once.
if (!this.focusListenersBound) {
this.registerDomEvent(this.contentEl, 'focusin', (e: FocusEvent) => this.onScrollerFocusIn(e));
this.registerDomEvent(this.contentEl, 'focusout', (e: FocusEvent) => this.onScrollerFocusOut(e));
this.focusListenersBound = true;
}
}
// ===================== Mobile keyboard fix — edit pin =====================
// Layer 2 of the permanent mobile keyboard fix. On short lists (notably the
// Custom Lists tab) inline-editing a cell opens the keyboard, but some resize
// readings during that window momentarily report the FULL no-keyboard height.
// Honouring them re-expands .wl-tab-content below the keyboard and exposes a
// dark band under the short content. Fix: while an editor inside the scroller
// is focused, pin the scroller to the SMALLEST clientHeight observed and
// ignore any larger reading. Release is guaranteed on focusout (after a grace
// window, so cell-to-cell navigation doesn't flicker), with a safety net in
// applyTabContentHeight() for the case where focus leaves without a focusout.
// =========================================================================
/** True when `target` is an input/textarea/contenteditable inside .wl-tab-content. */
private isEditorInScroller(target: EventTarget | null): boolean {
const node = target as HTMLElement | null;
if (!node || typeof node.tagName !== 'string') return false;
const tabContent = this.contentEl.querySelector(':scope > .wl-tab-content');
if (!tabContent || !tabContent.contains(node)) return false;
const tag = node.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea' || node.isContentEditable === true;
}
private onScrollerFocusIn(e: FocusEvent): void {
if (!Platform.isMobile || !this.isEditorInScroller(e.target)) return;
// Re-focusing within the grace window (cell navigation) — keep the pin.
if (this.pinReleaseTimer !== null) {
window.clearTimeout(this.pinReleaseTimer);
this.pinReleaseTimer = null;
}
// Start a fresh editing session: re-measure the smallest height.
if (!this.editPinActive) this.smallestEditHeight = -1;
this.editPinActive = true;
// Force the next reading (keyboard-shrunk) to be applied & pinned.
this.lastAppliedTabHeight = -1;
this.applyTabContentHeight();
}
private onScrollerFocusOut(e: FocusEvent): void {
if (!this.isEditorInScroller(e.target)) return;
if (this.pinReleaseTimer !== null) window.clearTimeout(this.pinReleaseTimer);
// Grace window: only release if focus has truly left every editor in the
// scroller (cell-to-cell navigation re-focuses within this window).
this.pinReleaseTimer = window.setTimeout(() => {
this.pinReleaseTimer = null;
const active = this.contentEl.ownerDocument.activeElement;
if (this.isEditorInScroller(active)) return;
this.releaseEditPin();
}, 250);
}
/** Drops the pin and hands sizing back to the ResizeObserver (full height). */
private releaseEditPin(): void {
if (!this.editPinActive) return;
this.editPinActive = false;
this.smallestEditHeight = -1;
const tabContent = this.contentEl.querySelector<HTMLElement>(':scope > .wl-tab-content');
// Remove the pinned height and let the observer re-assert the correct
// full height now the keyboard has closed.
tabContent?.style.removeProperty('height');
this.lastAppliedTabHeight = -1;
this.applyTabContentHeight();
}
private applyTabContentHeight(): void {
const viewContent = this.contentEl;
if (!viewContent) return;
const tabBar = viewContent.querySelector<HTMLElement>(':scope > .wl-tab-bar');
const tabContent = viewContent.querySelector<HTMLElement>(':scope > .wl-tab-content');
if (!tabContent) return;
const wlViewClientHeight = viewContent.clientHeight;
const tabBarHeight = tabBar ? tabBar.offsetHeight : 0;
let target = wlViewClientHeight - tabBarHeight;
// Guard: don't write until wl-view has a real measured height.
if (target <= 0) return;
// --- Edit pin layer ---------------------------------------------------
if (this.editPinActive) {
// Safety net: focus left the scroller without a focusout firing
// (e.g. the editor element was removed on save). Release now so the
// scroller doesn't stay pinned at the keyboard-shrunk height.
if (!this.isEditorInScroller(this.contentEl.ownerDocument.activeElement)) {
this.releaseEditPin();
return;
}
// Track the smallest height seen while editing and pin to it,
// ignoring any larger (no-keyboard) reading during this window.
if (this.smallestEditHeight < 0 || target < this.smallestEditHeight) {
this.smallestEditHeight = target;
}
target = this.smallestEditHeight;
}
// Guard: skip redundant writes when the height hasn't changed.
if (target === this.lastAppliedTabHeight) return;
this.lastAppliedTabHeight = target;
// Explicit px height + flex:none beats the `flex: 1 1 0%` base rule, which
// this WebView fails to distribute. min-height:0 / overflow-y stay intact.
tabContent.style.setProperty('height', `${target}px`, 'important');
tabContent.style.setProperty('flex', 'none', 'important');
}
/** Called after tab content changes on mobile to re-apply the height. */
refreshMobileLayout(): void {
if (!Platform.isMobile || !this.mobileResizeObserver) return;
// Tab switch recreates .wl-tab-content with no inline height; force a
// re-apply even if the wl-view height itself hasn't changed.
this.lastAppliedTabHeight = -1;
this.applyTabContentHeight();
}
async onClose(): Promise<void> {
await super.onClose();
this.dataManager.offChange(this.dataChangeListener);
this.overflowObserver?.disconnect();
this.overflowObserver = null;
if (this.mobileResizeObserver) {
this.mobileResizeObserver.disconnect();
this.mobileResizeObserver = null;
}
if (this.mobileSizerRaf !== null) {
window.cancelAnimationFrame(this.mobileSizerRaf);
this.mobileSizerRaf = null;
}
// Clear edit-pin timer/state (focus listeners auto-removed via registerDomEvent).
if (this.pinReleaseTimer !== null) {
window.clearTimeout(this.pinReleaseTimer);
this.pinReleaseTimer = null;
}
this.editPinActive = false;
this.smallestEditHeight = -1;
// Clear the explicit sizing the mobile keyboard fix applied to wl-tab-content.
const tabContent = this.contentEl.querySelector<HTMLElement>('.wl-tab-content');
if (tabContent) {
tabContent.style.removeProperty('height');
tabContent.style.removeProperty('flex');
}
this.destroyDraftsTab();
this.customListsTab?.destroy();
this.listTab?.destroy();
this.readingTab?.destroy();
this.readingTab = null;
this.logTab?.destroy();
this.logTab = null;
}
/** Reapplies the active colour theme and rebuilds the UI. Used by SettingsTab. */
refreshUI(): void {
this.applyColorTheme(this.contentEl);
this.buildUI();
}
private applyColorTheme(root: HTMLElement): void {
const colorTheme = this.plugin.settings.colorTheme ?? 'default';
if (colorTheme === 'default') {
root.removeAttribute('data-theme');
} else {
root.setAttribute('data-theme', colorTheme);
}
}
private destroyDraftsTab(): void {
if (this.draftsTab) {
this.draftsTab.destroy();
this.draftsTab = null;
}
}
private buildUI(): void {
// Destroy any live drafts listener before rebuilding the UI
this.destroyDraftsTab();
const root = this.contentEl;
root.empty();
root.addClass('wl-view');
this.applyColorTheme(root);
// Tab bar
const tabBar = root.createDiv({ cls: 'wl-tab-bar' });
const dashBtn = tabBar.createEl('button', {
cls: `wl-tab-btn${this.activeTab === 'dashboard' ? ' is-active' : ''}`,
text: 'Dashboard',
});
const listBtn = tabBar.createEl('button', {
cls: `wl-tab-btn${this.activeTab === 'watchlist' ? ' is-active' : ''}`,
text: 'Watchlist',
});
const readingBtn = tabBar.createEl('button', {
cls: `wl-tab-btn${this.activeTab === 'reading' ? ' is-active' : ''}`,
text: 'Reading',
});
this.airtimeBtn = tabBar.createEl('button', {
cls: `wl-tab-btn${this.activeTab === 'upcoming' ? ' is-active' : ''}`,
text: 'Upcoming',
});
const customListsBtn = tabBar.createEl('button', {
cls: `wl-tab-btn${this.activeTab === 'custom-lists' ? ' is-active' : ''}`,
text: 'Custom lists',
});
this.draftsBtn = tabBar.createEl('button', {
cls: `wl-tab-btn${this.activeTab === 'drafts' ? ' is-active' : ''}`,
text: 'Drafts',
});
const logBtn = tabBar.createEl('button', {
cls: `wl-tab-btn${this.activeTab === 'log' ? ' is-active' : ''}`,
text: 'Log',
});
const allBtns = [dashBtn, listBtn, this.airtimeBtn, readingBtn, customListsBtn, this.draftsBtn, logBtn];
this.tabButtons = {
dashboard: dashBtn,
watchlist: listBtn,
upcoming: this.airtimeBtn,
reading: readingBtn,
'custom-lists': customListsBtn,
drafts: this.draftsBtn,
log: logBtn,
};
dashBtn.addEventListener('click', () => {
if (this.activeTab === 'dashboard') return;
this.destroyDraftsTab();
this.activeTab = 'dashboard';
allBtns.forEach((b) => b.removeClass('is-active'));
dashBtn.addClass('is-active');
this.renderTabContent();
});
listBtn.addEventListener('click', () => {
if (this.activeTab === 'watchlist') return;
this.destroyDraftsTab();
this.activeTab = 'watchlist';
allBtns.forEach((b) => b.removeClass('is-active'));
listBtn.addClass('is-active');
this.renderTabContent();
});
this.airtimeBtn.addEventListener('click', () => {
if (this.activeTab === 'upcoming') return;
this.destroyDraftsTab();
this.activeTab = 'upcoming';
allBtns.forEach((b) => b.removeClass('is-active'));
this.airtimeBtn!.addClass('is-active');
this.renderTabContent();
});
readingBtn.addEventListener('click', () => {
if (this.activeTab === 'reading') return;
this.destroyDraftsTab();
this.activeTab = 'reading';
allBtns.forEach((b) => b.removeClass('is-active'));
readingBtn.addClass('is-active');
this.renderTabContent();
});
customListsBtn.addEventListener('click', () => {
if (this.activeTab === 'custom-lists') return;
this.destroyDraftsTab();
this.activeTab = 'custom-lists';
allBtns.forEach((b) => b.removeClass('is-active'));
customListsBtn.addClass('is-active');
this.renderTabContent();
});
this.draftsBtn.addEventListener('click', () => {
if (this.activeTab === 'drafts') return;
this.activeTab = 'drafts';
allBtns.forEach((b) => b.removeClass('is-active'));
this.draftsBtn!.addClass('is-active');
this.renderTabContent();
});
logBtn.addEventListener('click', () => {
if (this.activeTab === 'log') return;
this.destroyDraftsTab();
this.activeTab = 'log';
allBtns.forEach((b) => b.removeClass('is-active'));
logBtn.addClass('is-active');
this.renderTabContent();
});
this.tabContentEl = root.createDiv({ cls: 'wl-tab-content' });
this.renderTabContent();
// Initialize Upcoming badge immediately
this.updateUpcomingBadge();
// Initialize the Drafts badge. The count needs a count-only vault scan, so it
// only becomes correct once the metadata cache is ready — gate it on 'resolved'
// (also fires for changes made while Obsidian was closed, avoiding a stale count).
// A direct call handles the case where the cache is already resolved when the
// panel is (re)opened, since 'resolved' won't fire again then.
void this.updateDraftsBadge();
this.registerEvent(this.plugin.app.metadataCache.on('resolved', () => {
void this.updateDraftsBadge();
}));
}
/**
* Programmatically switch tabs (e.g. from the status-bar Upcoming counter).
* Mirrors a tab-button click: updates active state and re-renders the content.
*/
setActiveTab(tab: TabName): void {
if (this.activeTab === tab) return;
// UI not built yet — record the intent so the initial render reflects it.
if (!this.tabContentEl) { this.activeTab = tab; return; }
if (tab !== 'drafts') this.destroyDraftsTab();
this.activeTab = tab;
for (const b of Object.values(this.tabButtons)) b?.removeClass('is-active');
this.tabButtons[tab]?.addClass('is-active');
this.renderTabContent();
}
private renderTabContent(): void {
if (!this.tabContentEl) return;
// Tear down any active inline editors / document listeners from the previous tab
this.customListsTab?.destroy();
if (this.activeTab !== 'reading') {
this.readingTab?.destroy();
this.readingTab = null;
}
if (this.activeTab !== 'log') {
this.logTab?.destroy();
this.logTab = null;
}
this.tabContentEl.empty();
for (const cls of TAB_CLASSES) this.tabContentEl.removeClass(cls);
if (this.activeTab === 'dashboard') {
this.dashboardTab = new DashboardTab(this.tabContentEl, this.plugin, this.dataManager, this.plugin.readingDataManager);
this.dashboardTab.render();
} else if (this.activeTab === 'watchlist') {
// Always create a new instance when switching to Watchlist tab so that the
// container element is correct; state (filters, expandedId) is preserved
// via the instance kept on this.listTab during data-change refreshes.
this.listTab?.destroy();
this.listTab = new ListTab(this.tabContentEl, this.plugin, this.dataManager);
this.listTab.render();
} else if (this.activeTab === 'upcoming') {
this.airtimeTab = new AirtimeTab(this.tabContentEl, this.plugin, this.dataManager, (count) => {
if (this.airtimeBtn) {
this.airtimeBtn.textContent = count > 0 ? `Upcoming (${count})` : 'Upcoming';
}
});
this.airtimeTab.render();
} else if (this.activeTab === 'reading') {
this.readingTab?.destroy();
this.readingTab = new ReadingTab(
this.tabContentEl,
this.plugin,
this.plugin.readingDataManager,
);
this.readingTab.render();
} else if (this.activeTab === 'drafts') {
this.draftsTab = new DraftsTab(
this.tabContentEl,
this.plugin,
this.dataManager,
(count) => this.setDraftsBadge(count),
);
void this.draftsTab.render();
} else if (this.activeTab === 'log') {
this.logTab = new LogTab(this.tabContentEl, this.plugin);
this.logTab.render();
} else {
// custom-lists
this.customListsTab = new CustomListsTab(this.tabContentEl, this.plugin, this.dataManager);
void this.customListsTab.render();
}
this.guardOverflow();
this.refreshMobileLayout();
}
/**
* Called by the data change listener. Re-renders the active tab without
* destroying the ListTab instance, so expandedId and filter state survive.
*/
private refreshActiveTab(): void {
this.applyColorTheme(this.contentEl);
if (this.tabContentEl) {
for (const cls of TAB_CLASSES) this.tabContentEl.removeClass(cls);
}
if (this.activeTab === 'watchlist' && this.listTab) {
this.listTab.render();
} else if (this.activeTab === 'dashboard' && this.dashboardTab) {
this.dashboardTab.render();
} else if (this.activeTab === 'upcoming' && this.airtimeTab) {
this.airtimeTab.render();
} else if (this.activeTab === 'reading' && this.readingTab) {
this.readingTab.render();
} else if (this.activeTab === 'custom-lists' && this.customListsTab) {
void this.customListsTab.render();
} else if (this.activeTab === 'drafts' && this.draftsTab) {
void this.draftsTab.render();
} else if (this.activeTab === 'log' && this.logTab) {
this.logTab.render();
} else {
this.renderTabContent();
return;
}
this.guardOverflow();
this.updateUpcomingBadge();
}
private overflowObserver: MutationObserver | null = null;
private guardOverflow(): void {
if (!this.tabContentEl) return;
this.overflowObserver?.disconnect();
const el = this.tabContentEl;
const renderTime = Date.now();
this.overflowObserver = new MutationObserver(() => {
if (Date.now() - renderTime > 300) return;
const computed = window.getComputedStyle(el).overflow;
if (computed === 'hidden') {
el.style.overflow = 'auto';
}
});
this.overflowObserver.observe(el, { attributes: true, attributeFilter: ['style', 'class'] });
}
private updateUpcomingBadge(): void {
if (!this.airtimeBtn) return;
const count = AirtimeTab.getAiredDueCount(this.dataManager, this.plugin.readingDataManager) + AirtimeTab.getMaybeDueCount(this.dataManager);
this.airtimeBtn.textContent = count > 0 ? `Upcoming (${count})` : 'Upcoming';
}
private setDraftsBadge(count: number): void {
if (this.draftsBtn) {
this.draftsBtn.textContent = count > 0 ? `Drafts (${count})` : 'Drafts';
}
}
/** Count-only drafts scan to keep the tab badge correct even before the Drafts tab is opened. */
private async updateDraftsBadge(): Promise<void> {
if (!this.draftsBtn) return;
const count = await DraftsTab.computePendingCount(this.plugin, this.dataManager);
this.setDraftsBadge(count);
}
}

View file

@ -15,6 +15,8 @@ export class WidgetRenderer {
private widgetRegistry = new Map<HTMLElement, string>();
/** Tracks which registered widgets are in mini mode (for correct re-render on data change). */
private widgetMiniRegistry = new Map<HTMLElement, boolean>();
/** Generic re-render hooks for non-todo widgets (stats, upcoming, now-watching, now-next). */
private widgetRerender = new Map<HTMLElement, () => void>();
private dataChangedListener: EventListener;
constructor(plugin: WatchLogPlugin, dataManager: DataManager) {
@ -51,7 +53,7 @@ export class WidgetRenderer {
(source, el, _ctx) => this.processWlNowNext(source, el),
);
// Re-render all active widgets when data changes (Fix 1: list → widget sync)
// Re-render all active widgets when data changes (list → widget sync)
this.dataChangedListener = () => this.onDataChanged();
activeDocument.addEventListener('watchlog-data-changed', this.dataChangedListener);
@ -87,6 +89,23 @@ export class WidgetRenderer {
this.widgetRegistry.delete(el);
this.widgetMiniRegistry.delete(el);
}
const staleGeneric: HTMLElement[] = [];
for (const [el, rerender] of this.widgetRerender) {
if (!el.isConnected) {
staleGeneric.push(el);
continue;
}
try { rerender(); } catch (e) { console.warn('[WL] widget rerender failed:', e); }
}
for (const el of staleGeneric) this.widgetRerender.delete(el);
}
/** Clear all widget registries (plugin unload). */
cleanup(): void {
this.widgetRegistry.clear();
this.widgetMiniRegistry.clear();
this.widgetRerender.clear();
}
private process(
@ -369,7 +388,13 @@ export class WidgetRenderer {
// ── wl-stat ───────────────────────────────────────────────────────────────────
private processWlStat(source: string, el: HTMLElement): void {
this.widgetRerender.set(el, () => this.renderWlStat(source, el));
this.renderWlStat(source, el);
}
private renderWlStat(source: string, el: HTMLElement): void {
const kind = source.trim().toLowerCase();
el.empty();
el.addClass('wl-wstat');
if (kind === 'watched') {
@ -478,6 +503,12 @@ export class WidgetRenderer {
// ── wl-upcoming ───────────────────────────────────────────────────────────────
private processWlUpcoming(source: string, el: HTMLElement): void {
this.widgetRerender.set(el, () => this.renderWlUpcoming(source, el));
this.renderWlUpcoming(source, el);
}
private renderWlUpcoming(source: string, el: HTMLElement): void {
el.empty();
const kind = source.trim().toLowerCase();
const isFull = kind === 'next full';
if (kind !== 'next' && kind !== 'next full') {
@ -487,6 +518,7 @@ export class WidgetRenderer {
const entries = this.dataManager.getAirtimeEntries();
const titles = this.dataManager.getTitles();
const titleMap = new Map(titles.map((t) => [t.id, t]));
// Find the entry with the soonest future release date
const now = Date.now();
@ -494,7 +526,7 @@ export class WidgetRenderer {
let bestMs = Infinity;
for (const entry of entries) {
const title = titles.find((t) => t.id === entry.titleId);
const title = titleMap.get(entry.titleId);
if (!title || entry.schedule.recurrence !== 'once' || !entry.schedule.releaseDate) continue;
const ms = new Date(entry.schedule.releaseDate + 'T12:00:00').getTime();
if (ms >= now && ms < bestMs) {
@ -568,6 +600,12 @@ export class WidgetRenderer {
// ── wl-nowwatching ────────────────────────────────────────────────────────────
private processWlNowWatching(source: string, el: HTMLElement): void {
this.widgetRerender.set(el, () => this.renderWlNowWatching(source, el));
this.renderWlNowWatching(source, el);
}
private renderWlNowWatching(source: string, el: HTMLElement): void {
el.empty();
const isFull = source.trim().toLowerCase() === 'full';
el.addClass('wl-wnowwatching');
@ -592,12 +630,13 @@ export class WidgetRenderer {
if (pinnedGroup) {
card.createSpan({ cls: 'wl-nowwatching-title', text: pinnedGroup.name });
const allTitles = this.dataManager.getTitles();
const titleMap = new Map(allTitles.map((t) => [t.id, t]));
const members = pinnedGroup.titleIds
.map((id) => allTitles.find((t) => t.id === id))
.map((id) => titleMap.get(id))
.filter((t): t is WatchLogTitle => t !== undefined);
const totalWatched = members.reduce((s, t) => s + t.watchedEpisodes.length, 0);
const totalEps = members.reduce((s, t) => s + t.totalEpisodes, 0);
const progress = totalEps > 0 ? Math.round((totalWatched / totalEps) * 100) : 0;
const totalEps = members.reduce((s, t) => s + this.dataManager.getEffectiveTotal(t), 0);
const progress = totalEps > 0 ? Math.min(100, Math.round((totalWatched / totalEps) * 100)) : 0;
card.createSpan({ cls: 'wl-nowwatching-ep', text: `${members.length} title${members.length !== 1 ? 's' : ''}` });
const barWrap = card.createDiv({ cls: 'wl-nowwatching-bar-wrap' });
barWrap.createDiv({ cls: 'wl-nowwatching-bar' }).style.width = `${progress}%`;
@ -645,12 +684,13 @@ export class WidgetRenderer {
if (pinnedGroup) {
card.createDiv({ cls: 'wl-full-card-title', text: pinnedGroup.name });
const allTitles = this.dataManager.getTitles();
const titleMap = new Map(allTitles.map((t) => [t.id, t]));
const members = pinnedGroup.titleIds
.map((id) => allTitles.find((t) => t.id === id))
.map((id) => titleMap.get(id))
.filter((t): t is WatchLogTitle => t !== undefined);
const totalWatched = members.reduce((s, t) => s + t.watchedEpisodes.length, 0);
const totalEps = members.reduce((s, t) => s + t.totalEpisodes, 0);
const progress = totalEps > 0 ? Math.round((totalWatched / totalEps) * 100) : 0;
const totalEps = members.reduce((s, t) => s + this.dataManager.getEffectiveTotal(t), 0);
const progress = totalEps > 0 ? Math.min(100, Math.round((totalWatched / totalEps) * 100)) : 0;
const bottomRow = card.createDiv({ cls: 'wl-nw-bottom-row' });
bottomRow.createSpan({ cls: 'wl-full-card-sub', text: `${members.length} title${members.length !== 1 ? 's' : ''}` });
const barCol = bottomRow.createDiv({ cls: 'wl-nw-bar-col' });
@ -680,7 +720,13 @@ export class WidgetRenderer {
// ── wl-now-next ───────────────────────────────────────────────────────────────
private processWlNowNext(_source: string, el: HTMLElement): void {
private processWlNowNext(source: string, el: HTMLElement): void {
this.widgetRerender.set(el, () => this.renderWlNowNext(source, el));
this.renderWlNowNext(source, el);
}
private renderWlNowNext(_source: string, el: HTMLElement): void {
el.empty();
el.addClass('wl-wnownext');
const pinnedTitle = this.dataManager.getTitles().find((t) => t.pinned);
@ -691,12 +737,13 @@ export class WidgetRenderer {
const entries = this.dataManager.getAirtimeEntries();
const titles = this.dataManager.getTitles();
const titleMap = new Map(titles.map((t) => [t.id, t]));
const now = Date.now();
let bestEntry: { titleName: string; type: string; releaseDate: string; daysUntil: number } | null = null;
let bestMs = Infinity;
for (const entry of entries) {
const title = titles.find((t) => t.id === entry.titleId);
const title = titleMap.get(entry.titleId);
if (!title || entry.schedule.recurrence !== 'once' || !entry.schedule.releaseDate) continue;
const ms = new Date(entry.schedule.releaseDate + 'T12:00:00').getTime();
if (ms >= now && ms < bestMs) {
@ -728,12 +775,13 @@ export class WidgetRenderer {
} else if (pinnedGroup) {
nowCol.createDiv({ cls: 'wl-full-card-title', text: pinnedGroup.name });
const allTitles = this.dataManager.getTitles();
const titleMap2 = new Map(allTitles.map((t) => [t.id, t]));
const members = pinnedGroup.titleIds
.map((id) => allTitles.find((t) => t.id === id))
.map((id) => titleMap2.get(id))
.filter((t): t is WatchLogTitle => t !== undefined);
const totalWatched = members.reduce((s, t) => s + t.watchedEpisodes.length, 0);
const totalEps = members.reduce((s, t) => s + t.totalEpisodes, 0);
const progress = totalEps > 0 ? Math.round((totalWatched / totalEps) * 100) : 0;
const totalEps = members.reduce((s, t) => s + this.dataManager.getEffectiveTotal(t), 0);
const progress = totalEps > 0 ? Math.min(100, Math.round((totalWatched / totalEps) * 100)) : 0;
nowCol.createDiv({ cls: 'wl-full-card-sub', text: `${members.length} title${members.length !== 1 ? 's' : ''} · ${progress}%` });
} else {
const pinned = pinnedTitle!;

View file

@ -1,9 +1,12 @@
import { App, Editor, FuzzySuggestModal, MarkdownView, Notice, Plugin } from 'obsidian';
import { App, Editor, FuzzySuggestModal, MarkdownView, Notice, Platform, Plugin, setIcon } from 'obsidian';
import { DEFAULT_SETTINGS, WatchLogPluginSettings, AirtimeSchedule } from './types';
import { DataManager } from './DataManager';
import { ReadingDataManager } from './ReadingDataManager';
import { ApiService } from './ApiService';
import { HistoryManager } from './HistoryManager';
import { WatchLogView, WATCHLOG_VIEW_TYPE } from './WatchLogView';
import { PosterService } from './PosterService';
import { WatchLogView, WATCHLOG_VIEW_TYPE, TabName } from './WatchLogView';
import { AirtimeTab } from './AirtimeTab';
import { WatchLogSettingsTab } from './SettingsTab';
import { AddTitleModal } from './AddTitleModal';
import { InsertWidgetModal } from './InsertWidgetModal';
@ -12,8 +15,11 @@ import { WidgetRenderer } from './WidgetRenderer';
export default class WatchLogPlugin extends Plugin {
settings: WatchLogPluginSettings = DEFAULT_SETTINGS;
dataManager: DataManager = new DataManager(this);
readingDataManager: ReadingDataManager = new ReadingDataManager(this);
apiService: ApiService = new ApiService('', '');
historyManager: HistoryManager = new HistoryManager(this);
posterService!: PosterService;
widgetRenderer?: WidgetRenderer;
// Runtime import progress state (not persisted)
importProgress: { current: number; total: number; cancel: () => void } | null = null;
@ -21,30 +27,37 @@ export default class WatchLogPlugin extends Plugin {
// Track which entry+day combos have already fired a notification
private notifiedEntries: Set<string> = new Set();
// Status-bar Upcoming "due" counter (desktop only; null on mobile)
private statusBarEl: HTMLElement | null = null;
async onload(): Promise<void> {
// Load settings first, then data
await this.loadSettings();
this.dataManager = new DataManager(this);
await this.dataManager.load();
this.dataManager.startWatchingExternalChanges();
this.apiService = new ApiService(this.settings.omdbApiKey, this.settings.tmdbApiKey);
this.readingDataManager = new ReadingDataManager(this);
await this.readingDataManager.load();
this.apiService = new ApiService(this.settings.omdbApiKey, this.settings.tmdbApiKey, this.settings.googleBooksApiKey);
this.historyManager = new HistoryManager(this);
await this.historyManager.load();
this.dataManager.setHistoryManager(this.historyManager);
this.readingDataManager.setHistoryManager(this.historyManager);
this.posterService = new PosterService(
this.dataManager,
() => this.settings,
);
// Ensure vault folders exist
await this.dataManager.ensureFolders();
// Start the airtime notification scheduler (checks every 60 seconds)
this.startAirtimeScheduler();
// Register the sidebar view
this.registerView(WATCHLOG_VIEW_TYPE, (leaf) => {
return new WatchLogView(leaf, this, this.dataManager);
});
// Register the inline widget code block processor
new WidgetRenderer(this, this.dataManager);
this.widgetRenderer = new WidgetRenderer(this, this.dataManager);
// Ribbon icon
this.addRibbonIcon('tv', 'Watchlog', () => {
@ -94,14 +107,29 @@ export default class WatchLogPlugin extends Plugin {
// Settings tab
this.addSettingTab(new WatchLogSettingsTab(this.app, this));
// Status-bar Upcoming "due" counter
this.setupStatusBar();
// Keep the status bar in sync with data edits (no new timer — the 60s
// airtime interval drives the time-based refresh). 'watchlog-data-changed'
// is a custom event, so register the listener directly (like WidgetRenderer).
const statusBarListener = (): void => this.updateStatusBar();
activeDocument.addEventListener('watchlog-data-changed', statusBarListener);
this.register(() => activeDocument.removeEventListener('watchlog-data-changed', statusBarListener));
}
onunload(): void {
// Synchronously flush any pending debounced saves so no in-memory edits are lost.
this.dataManager.flushPendingSaveSync();
this.dataManager.flushPosterSaveSync();
this.dataManager.flushQueuedSaveSync();
this.readingDataManager?.flushCoverSave();
this.posterService?.destroy();
this.widgetRenderer?.cleanup();
}
private startAirtimeScheduler(): void {
// Check immediately on startup, then every 60 seconds
this.checkAirtimeNotifications();
this.registerInterval(window.setInterval(() => {
this.checkAirtimeNotifications();
@ -134,21 +162,74 @@ export default class WatchLogPlugin extends Plugin {
if (this.notifiedEntries.has(notifKey)) continue;
this.notifiedEntries.add(notifKey);
const title = this.dataManager.getTitle(entry.titleId);
if (!title) continue;
new Notice(title.title, 8000);
// Resolve the entry's subject — a watch title or a reading item.
let name: string;
let maxUnits: number;
if (entry.source === 'reading') {
const kind = entry.readingKind ?? 'book';
const item = kind === 'book'
? this.readingDataManager.getBook(entry.titleId)
: this.readingDataManager.getManga(entry.titleId);
if (!item) continue;
name = item.title;
maxUnits = entry.totalEpisodes ?? item.totalChapters ?? 0;
} else {
const title = this.dataManager.getTitle(entry.titleId);
if (!title) continue;
name = title.title;
maxUnits = entry.totalEpisodes ?? title.totalEpisodes;
}
new Notice(name, 8000);
// Auto-increment episode for series/anime
if ((title.totalEpisodes ?? 0) > 1 && entry.currentEpisode !== undefined) {
const maxEps = entry.totalEpisodes ?? title.totalEpisodes;
// Auto-increment the unit (episode/chapter) for multi-part titles
if (maxUnits > 1 && entry.currentEpisode !== undefined) {
const nextEp = entry.currentEpisode + 1;
if (nextEp <= maxEps) {
if (nextEp <= maxUnits) {
entry.currentEpisode = nextEp;
void this.dataManager.updateAirtimeEntry(entry);
}
// If final episode: leave in Upcoming, you handle via tick button
// If final unit: leave in Upcoming, you handle via tick button
}
}
// The due count is time-based, so refresh the status bar on every tick.
this.updateStatusBar();
}
// ── Status bar: Upcoming "due" counter ──────────────────────────────────────
private setupStatusBar(): void {
// The status bar doesn't exist on mobile — skip entirely there.
if (Platform.isMobile) return;
this.statusBarEl = this.addStatusBarItem();
this.statusBarEl.addClass('wl-statusbar-upcoming');
this.statusBarEl.style.cursor = 'pointer';
this.statusBarEl.setAttribute('aria-label', 'WatchLog — Upcoming due');
this.statusBarEl.addEventListener('click', () => void this.activateView('upcoming'));
this.updateStatusBar();
}
/** Refreshes the status-bar Upcoming counter. Hidden when off or when 0 due. */
updateStatusBar(): void {
const el = this.statusBarEl;
if (!el) return;
if (!this.settings.showUpcomingStatusBar) {
el.hide();
return;
}
const count =
AirtimeTab.getAiredDueCount(this.dataManager, this.readingDataManager) +
AirtimeTab.getMaybeDueCount(this.dataManager);
if (count <= 0) {
el.hide();
return;
}
el.empty();
el.show();
// Orange icon + "N due" — reuse the ribbon's 'tv' icon.
el.style.color = 'var(--color-orange)';
const icon = el.createSpan({ cls: 'wl-statusbar-icon' });
setIcon(icon, 'tv');
el.createSpan({ cls: 'wl-statusbar-text', text: `${count} due` });
}
/**
@ -180,7 +261,7 @@ export default class WatchLogPlugin extends Plugin {
async loadSettings(): Promise<void> {
const saved = (await this.loadData()) as {
settings?: Partial<WatchLogPluginSettings> & { defaultView?: string };
settings?: Partial<WatchLogPluginSettings>;
} | null;
this.settings = Object.assign({}, DEFAULT_SETTINGS, saved?.settings ?? {});
if (saved?.settings?.listFilters) {
@ -190,9 +271,26 @@ export default class WatchLogPlugin extends Plugin {
saved.settings.listFilters,
);
}
// Migrate old defaultView value 'list' → 'watchlist'
if ((this.settings.defaultView as string) === 'list') {
this.settings.defaultView = 'watchlist';
if (this.settings.animeApiSource === undefined) {
this.settings.animeApiSource = 'jikan';
}
if (this.settings.typeApiMapping === undefined) {
this.settings.typeApiMapping = {};
}
// Reading type badge colors (Manga / Book) — backfill defaults for old installs.
if (!this.settings.readingTypeColors) {
this.settings.readingTypeColors = { manga: '#D4537E', book: '#D85A30' };
} else {
if (!this.settings.readingTypeColors.manga) this.settings.readingTypeColors.manga = '#D4537E';
if (!this.settings.readingTypeColors.book) this.settings.readingTypeColors.book = '#D85A30';
}
// Default for the defaultWatchlistView setting (Cards)
if (this.settings.defaultWatchlistView !== 'list' && this.settings.defaultWatchlistView !== 'cards') {
this.settings.defaultWatchlistView = 'cards';
}
// Backfill the status-bar Upcoming counter toggle for existing installs (default ON).
if (this.settings.showUpcomingStatusBar === undefined) {
this.settings.showUpcomingStatusBar = true;
}
// Ensure array fields are never undefined after a partial merge
if (!this.settings.types?.length) this.settings.types = DEFAULT_SETTINGS.types;
@ -213,8 +311,7 @@ export default class WatchLogPlugin extends Plugin {
}
async saveSettings(): Promise<void> {
const current = ((await this.loadData()) as Record<string, unknown> | null) ?? {};
await this.saveData({ ...current, settings: this.settings });
await this.dataManager.saveSettings(this.settings);
}
private openWidgetPalette(editor: Editor): void {
@ -263,18 +360,21 @@ export default class WatchLogPlugin extends Plugin {
}).open();
}
private async activateView(): Promise<void> {
private async activateView(tab?: TabName): Promise<void> {
const { workspace } = this.app;
const existing = workspace.getLeavesOfType(WATCHLOG_VIEW_TYPE);
if (existing.length > 0) {
void workspace.revealLeaf(existing[0]!);
const leaf = existing[0]!;
void workspace.revealLeaf(leaf);
if (tab && leaf.view instanceof WatchLogView) leaf.view.setActiveTab(tab);
return;
}
const leaf = workspace.getLeaf('tab');
await leaf.setViewState({ type: WATCHLOG_VIEW_TYPE, active: true });
void workspace.revealLeaf(leaf);
if (tab && leaf.view instanceof WatchLogView) leaf.view.setActiveTab(tab);
}
}

File diff suppressed because it is too large Load diff

2828
styles.css

File diff suppressed because it is too large Load diff

View file

@ -1,30 +1,30 @@
{
"compilerOptions": {
"baseUrl": "src",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2017",
"allowJs": true,
"noImplicitAny": true,
"noImplicitThis": true,
"noImplicitReturns": true,
"moduleResolution": "node",
"importHelpers": true,
"noUncheckedIndexedAccess": true,
"isolatedModules": true,
"strictNullChecks": true,
"strictBindCallApply": true,
"allowSyntheticDefaultImports": true,
"useUnknownInCatchVariables": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES2017"
]
},
"include": [
"src/**/*.ts"
]
}
{
"compilerOptions": {
"baseUrl": "src",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2017",
"allowJs": true,
"noImplicitAny": true,
"noImplicitThis": true,
"noImplicitReturns": true,
"moduleResolution": "node",
"importHelpers": true,
"noUncheckedIndexedAccess": true,
"isolatedModules": true,
"strictNullChecks": true,
"strictBindCallApply": true,
"allowSyntheticDefaultImports": true,
"useUnknownInCatchVariables": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES2017"
]
},
"include": [
"src/**/*.ts"
]
}

View file

@ -1,17 +1,17 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json
// but only if the target version is not already in versions.json
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
if (!Object.values(versions).includes(minAppVersion)) {
versions[targetVersion] = minAppVersion;
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
}
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json
// but only if the target version is not already in versions.json
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
if (!Object.values(versions).includes(minAppVersion)) {
versions[targetVersion] = minAppVersion;
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
}

View file

@ -1,14 +1,15 @@
{
"1.1.0": "1.4.0",
"1.0.9": "1.4.0",
"1.0.8": "1.4.0",
"1.0.7": "1.4.0",
"1.0.6": "1.4.0",
"1.0.5": "1.4.0",
"1.0.4": "1.4.0",
"1.0.0": "0.15.0",
"0.9.8": "1.4.0",
"0.9.7": "1.4.0",
"0.9.5": "1.4.0",
"0.7.0": "1.4.0"
}
{
"2.0.0": "1.4.0",
"1.1.0": "1.4.0",
"1.0.9": "1.4.0",
"1.0.8": "1.4.0",
"1.0.7": "1.4.0",
"1.0.6": "1.4.0",
"1.0.5": "1.4.0",
"1.0.4": "1.4.0",
"1.0.0": "1.4.0",
"0.9.8": "1.4.0",
"0.9.7": "1.4.0",
"0.9.5": "1.4.0",
"0.7.0": "1.4.0"
}