mirror of
https://github.com/shynkro/watchlog-obsidian-plugin.git
synced 2026-07-22 06:53:16 +00:00
feat: v2.0.0 - Readin Tab, Log Tab, Cards system and more
This commit is contained in:
parent
e69605686b
commit
c8785d3731
46 changed files with 24444 additions and 10534 deletions
56
.github/workflows/lint.yml
vendored
56
.github/workflows/lint.yml
vendored
|
|
@ -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
3
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
95
CHANGELOG.md
95
CHANGELOG.md
|
|
@ -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
40
LICENSE
|
|
@ -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.
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
145
main.js
File diff suppressed because one or more lines are too long
|
|
@ -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
10348
package-lock.json
generated
File diff suppressed because it is too large
Load diff
60
package.json
60
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
741
src/AddReadingModal.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/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
251
src/CommunityRating.ts
Normal 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;
|
||||
}
|
||||
176
src/CsvModal.ts
176
src/CsvModal.ts
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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
46
src/DraftChoiceModal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
830
src/DraftsTab.ts
830
src/DraftsTab.ts
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
5021
src/ListTab.ts
5021
src/ListTab.ts
File diff suppressed because it is too large
Load diff
426
src/LogTab.ts
Normal file
426
src/LogTab.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
259
src/PosterService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
50
src/ReadingCsvChoiceModal.ts
Normal file
50
src/ReadingCsvChoiceModal.ts
Normal 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
838
src/ReadingCsvModal.ts
Normal 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
850
src/ReadingDataManager.ts
Normal 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
1411
src/ReadingDetailModal.ts
Normal file
File diff suppressed because it is too large
Load diff
350
src/ReadingManageColumnsModal.ts
Normal file
350
src/ReadingManageColumnsModal.ts
Normal 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
210
src/ReadingScheduleModal.ts
Normal 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
1503
src/ReadingTab.ts
Normal file
File diff suppressed because it is too large
Load diff
2310
src/SettingsTab.ts
2310
src/SettingsTab.ts
File diff suppressed because it is too large
Load diff
668
src/TitleDetailModal.ts
Normal file
668
src/TitleDetailModal.ts
Normal 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;
|
||||
}
|
||||
40
src/UpcomingFinderModal.ts
Normal file
40
src/UpcomingFinderModal.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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!;
|
||||
|
|
|
|||
156
src/main.ts
156
src/main.ts
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
1240
src/types.ts
1240
src/types.ts
File diff suppressed because it is too large
Load diff
2828
styles.css
2828
styles.css
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue