mirror of
https://github.com/shynkro/watchlog-obsidian-plugin.git
synced 2026-07-22 06:53:16 +00:00
Initial release v1.0.9
This commit is contained in:
commit
971bb0961e
41 changed files with 21286 additions and 0 deletions
10
.editorconfig
Normal file
10
.editorconfig
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
28
.github/workflows/lint.yml
vendored
Normal file
28
.github/workflows/lint.yml
vendored
Normal file
|
|
@ -0,0 +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
|
||||
|
||||
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# vscode
|
||||
.vscode
|
||||
|
||||
# Intellij
|
||||
*.iml
|
||||
.idea
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
# Exclude sourcemaps
|
||||
*.map
|
||||
|
||||
# obsidian
|
||||
data.json
|
||||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
|
||||
# AI assistant / dev-only files
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
# Runtime data
|
||||
history.json
|
||||
|
||||
# Built output
|
||||
main.js
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
tag-version-prefix=""
|
||||
6
CHANGELOG.md
Normal file
6
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to WatchLog are documented here.
|
||||
|
||||
## [1.0.9] - 2026-04-26
|
||||
### Initial public release
|
||||
5
LICENSE
Normal file
5
LICENSE
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Copyright (C) 2020-2025 by Dynalist Inc.
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
163
README.md
Normal file
163
README.md
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# WatchLog
|
||||
|
||||
Track your anime, movies, and TV shows directly inside Obsidian — with episode tracking, progress stats, upcoming release alerts, and embeddable widgets.
|
||||
|
||||
## Features
|
||||
|
||||
### Watchlist
|
||||
- **Full title management** — add, edit, and delete titles with fields for type, status, priority, rating (0–5 stars), notes, episode count, episode duration, release date, and an external link.
|
||||
- **Episode tracking** — mark individual episodes as watched; seasons are shown as collapsible groups with per-season progress bars.
|
||||
- **Groups** — bundle related titles (a film and its sequel, an anime and its movie) into a single collapsible row. Group rating, status, and progress are computed automatically from members.
|
||||
- **Pinning** — pin a title or group so it appears in "Now Watching" widgets across your vault.
|
||||
- **Sorting** — two-level sort (primary + secondary) across eleven keys: date added, title, status, type, rating, priority, episode duration, progress, remaining episodes, date modified, and random.
|
||||
- **Filtering** — exclude by type, status, priority, rating, or group; show only unrated or unprioritized titles; show only recently released titles (past 7 days). Save and restore named filter presets.
|
||||
- **Fuzzy search** — instant search across all title names.
|
||||
- **Selection mode** — select multiple titles or groups for batch delete or CSV export.
|
||||
- **History log** — a second sub-tab records every add, complete, review, and delete action with timestamps (up to 1 000 entries).
|
||||
|
||||
### Dashboard
|
||||
- Per-type progress rings or rectangular cards (Anime, Movie, TV Show, etc.) plus a combined Total card.
|
||||
- Total time watched and total time remaining, computed from episode counts and durations.
|
||||
- Library summary: total titles and completed count.
|
||||
- Suggestions panel: shortest unwatched title per type, with a random-pick button.
|
||||
- Recently watched and recently added sections (last 3 each).
|
||||
|
||||
### Upcoming Releases
|
||||
- **Tracker** — schedule releases with recurrence (once, daily, weekly, monthly), optional air time (HH:MM), and automatic countdown labels ("Today", "Tomorrow", "in N days").
|
||||
- **Auto-status** — any title added with a future release date is automatically marked "To be released" and added to the Tracker.
|
||||
- **Tick button** — mark the current episode as watched and advance the countdown in one click.
|
||||
- **Notifications** — desktop notifications fire at the scheduled air time (checked every 60 seconds).
|
||||
- **History sub-tab** — shows releases from the past 6 months with relative timestamps.
|
||||
- **Maybe sub-tab** — holds titles you are considering for the Tracker; add them when you are ready.
|
||||
|
||||
### Drafts
|
||||
- Monitors your entire vault for a configurable tag (default `#watchlog`).
|
||||
- Extracts title names following the tag — supports comma-separated lists on the same line.
|
||||
- Shows pending titles (not yet in Watchlist), already-added titles (dimmed), and dismissed titles.
|
||||
- One-click "Add" opens the add dialog with the title pre-filled.
|
||||
|
||||
### Custom Lists
|
||||
- Create freeform tables stored as Markdown files in your vault.
|
||||
- Define custom columns with type (text, number, select), optional bold/italic formatting, and a lock flag to prevent accidental deletion.
|
||||
- Edit cells inline or via a modal; drag to reorder columns.
|
||||
- Each list has a Notes section rendered as Markdown.
|
||||
- Pre-configure default columns in settings to apply to every new list.
|
||||
|
||||
### Inline Widgets
|
||||
Embed live plugin data anywhere in your vault using fenced code blocks:
|
||||
|
||||
| Widget | What it shows |
|
||||
|--------|--------------|
|
||||
| `wl-todo` | Full progress card for a specific title — status, progress bar, next episode checkbox |
|
||||
| `wl-todo:mini` | Compact single-line version of the above |
|
||||
| `wl-stat:watched` | Total time watched (all Watching + Completed titles) |
|
||||
| `wl-stat:remaining` | Total time remaining (Plan to watch + Watching) |
|
||||
| `wl-stat:completed` | Count of Completed titles |
|
||||
| `wl-stat:time` | Time watched + time remaining in one card |
|
||||
| `wl-stat:time completed full` | Wide triple card: Time Watched · Time Remaining · Completed |
|
||||
| `wl-upcoming:next` | Next upcoming title with name, type, release date, and countdown |
|
||||
| `wl-nowwatching` | Currently pinned title with name, type badge, and progress bar |
|
||||
| `wl-now-next` | Wide dual card: Now Watching · Up Next |
|
||||
|
||||
Widget state syncs bidirectionally with the Watchlist when the sync setting is enabled.
|
||||
|
||||
### Note File Generation
|
||||
- Each title automatically gets a Markdown file in `WatchLog/[Type]/[Title].md` with YAML frontmatter (title, type, status, priority, rating, dates, progress, external link) and a `## Notes` section.
|
||||
- Files are kept up to date whenever a title is edited.
|
||||
- A "Regenerate note files" button in Settings scans all titles and creates any missing files without overwriting existing ones.
|
||||
|
||||
### API Integration (optional)
|
||||
- **Jikan / MyAnimeList** — anime search and metadata, free, no key required.
|
||||
- **OMDb** — movies and TV shows, free API key required. Returns season-by-season episode counts.
|
||||
- **TMDB** — movies and TV shows, free API read token required. Alternative to OMDb.
|
||||
- **Add from URL** — paste an IMDb link to auto-fill all fields.
|
||||
|
||||
### Import / Export
|
||||
- **CSV export** — export selected titles with 13 fields to a timestamped CSV file.
|
||||
- **CSV import** — smart column detection, manual mapping, value mapping (status/type/rating), duplicate preview, and auto-creation of new types.
|
||||
- **JSON backup** — full data export and restore (with confirmation dialog).
|
||||
|
||||
### Customization
|
||||
- Three color themes: Default, Nightfall (purple), Bluez (blue).
|
||||
- Fully configurable type, status, and priority tags with custom colors.
|
||||
- Configurable season palette colors.
|
||||
- Episode numbering mode: absolute (1→n across all seasons) or per-season (resets each season, display only).
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Manual
|
||||
|
||||
1. Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](../../releases/latest).
|
||||
2. Create the folder `.obsidian/plugins/watchlog/` inside your vault.
|
||||
3. Copy the three files into that folder.
|
||||
4. In Obsidian, go to **Settings → Community plugins**, disable Safe mode if prompted, and enable **WatchLog**.
|
||||
|
||||
### Community Plugins _(coming soon)_
|
||||
|
||||
---
|
||||
|
||||
## API Keys (Optional)
|
||||
|
||||
WatchLog works out of the box for anime (powered by Jikan — no key required).
|
||||
|
||||
For movies and TV shows, you can optionally connect one of:
|
||||
|
||||
- **OMDb** — [Get a free API key](https://www.omdbapi.com/apikey.aspx)
|
||||
- **TMDB** — [Get a free API key](https://www.themoviedb.org/settings/api)
|
||||
|
||||
Enter your key in **Settings → WatchLog → API**. The settings page includes direct links to both sites and a "Test connection" button for each.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Adding a title
|
||||
|
||||
Click the **+** button in the Watchlist header, or use the Obsidian command palette and search for "WatchLog: Add title". Fill in the title name, type, and any other fields — or use the search bar inside the dialog to look it up via the configured API.
|
||||
|
||||
### Tracking episodes
|
||||
|
||||
Expand a title row by clicking it. Check off individual episodes, or use the season-level checkbox to mark a whole season at once. Progress is shown as a bar and a percentage in the collapsed row.
|
||||
|
||||
### Using widgets
|
||||
|
||||
In any Markdown note, create a fenced code block with a widget name:
|
||||
|
||||
````
|
||||
```wl-todo
|
||||
My Favourite Anime
|
||||
```
|
||||
````
|
||||
|
||||
The widget renders live in Reading view. See the **Widgets** section of the plugin Settings for a full syntax reference with copy buttons.
|
||||
|
||||
### Upcoming releases
|
||||
|
||||
Open the **Upcoming** tab and click **+** to schedule a title. Set the recurrence and — optionally — an air time. The plugin will notify you at that time and advance the episode counter automatically.
|
||||
|
||||
### Drafts
|
||||
|
||||
In any vault note, write a line like:
|
||||
|
||||
```
|
||||
#watchlog Some Movie, Another Show
|
||||
```
|
||||
|
||||
Open the **Drafts** tab to see all pending titles detected across your vault. Click **Add** to move them into your Watchlist.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
49
esbuild.config.mjs
Normal file
49
esbuild.config.mjs
Normal file
|
|
@ -0,0 +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();
|
||||
}
|
||||
34
eslint.config.mts
Normal file
34
eslint.config.mts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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,
|
||||
},
|
||||
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",
|
||||
]),
|
||||
);
|
||||
57
main.js
Normal file
57
main.js
Normal file
File diff suppressed because one or more lines are too long
10
manifest.json
Normal file
10
manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "watchlog",
|
||||
"name": "WatchLog",
|
||||
"version": "1.1.0",
|
||||
"minAppVersion": "1.4.0",
|
||||
"description": "Track your anime, movies, and TV shows inside Obsidian.",
|
||||
"author": "BogdanS",
|
||||
"authorUrl": "",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
5174
package-lock.json
generated
Normal file
5174
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
30
package.json
Normal file
30
package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "watchlog-plugin",
|
||||
"version": "1.1.0",
|
||||
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
BIN
screenshots/dashboard_tab.png
Normal file
BIN
screenshots/dashboard_tab.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 226 KiB |
BIN
screenshots/expanded_title.png
Normal file
BIN
screenshots/expanded_title.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 211 KiB |
BIN
screenshots/upcoming_tab.png
Normal file
BIN
screenshots/upcoming_tab.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 298 KiB |
BIN
screenshots/watchlist_tab.png
Normal file
BIN
screenshots/watchlist_tab.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
102
src/AddFromUrlModal.ts
Normal file
102
src/AddFromUrlModal.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { App, Modal } from 'obsidian';
|
||||
import type WatchLogPlugin from './main';
|
||||
import type { DataManager } from './DataManager';
|
||||
import { AddTitleModal } from './AddTitleModal';
|
||||
|
||||
export class AddFromUrlModal extends Modal {
|
||||
private plugin: WatchLogPlugin;
|
||||
private dataManager: DataManager;
|
||||
private onAdded: () => void;
|
||||
private urlInput: HTMLInputElement | null = null;
|
||||
private errorEl: HTMLElement | null = null;
|
||||
private addBtn: HTMLButtonElement | null = null;
|
||||
|
||||
constructor(app: App, plugin: WatchLogPlugin, dataManager: DataManager, onAdded: () => void) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.dataManager = dataManager;
|
||||
this.onAdded = onAdded;
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
this.titleEl.setText('Add from URL');
|
||||
const content = this.contentEl;
|
||||
content.addClass('wl-add-modal');
|
||||
|
||||
content.createDiv({
|
||||
cls: 'wl-modal-info',
|
||||
text: 'Please enter an IMDb URL (e.g. https://www.imdb.com/title/tt1375666/)',
|
||||
});
|
||||
|
||||
const inputRow = content.createDiv({ cls: 'wl-modal-row' });
|
||||
this.urlInput = inputRow.createEl('input', {
|
||||
cls: 'wl-modal-input',
|
||||
attr: { type: 'url', placeholder: 'https://www.imdb.com/title/ttXXXXXXX/' },
|
||||
});
|
||||
this.urlInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') void this.handleAdd();
|
||||
});
|
||||
|
||||
this.errorEl = content.createDiv({ cls: 'wl-modal-error' });
|
||||
this.errorEl.hide();
|
||||
|
||||
const btnRow = content.createDiv({ cls: 'wl-modal-btn-row' });
|
||||
this.addBtn = btnRow.createEl('button', { cls: 'wl-btn wl-btn-primary', text: 'Add' });
|
||||
this.addBtn.addEventListener('click', () => void this.handleAdd());
|
||||
|
||||
setTimeout(() => this.urlInput?.focus(), 50);
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
|
||||
private async handleAdd(): Promise<void> {
|
||||
const url = this.urlInput?.value.trim() ?? '';
|
||||
const match = url.match(/tt\d+/);
|
||||
if (!match) {
|
||||
this.showError('Title not found. Please check the URL and try again.');
|
||||
return;
|
||||
}
|
||||
const imdbId = match[0];
|
||||
|
||||
if (this.addBtn) {
|
||||
this.addBtn.disabled = true;
|
||||
this.addBtn.textContent = 'Loading…';
|
||||
}
|
||||
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);
|
||||
|
||||
if (!result) {
|
||||
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 {
|
||||
if (this.errorEl) {
|
||||
this.errorEl.textContent = msg;
|
||||
this.errorEl.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
512
src/AddTitleModal.ts
Normal file
512
src/AddTitleModal.ts
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
import { App, Modal, Notice } from 'obsidian';
|
||||
import type WatchLogPlugin from './main';
|
||||
import type { DataManager } from './DataManager';
|
||||
import type {
|
||||
WatchLogTitle,
|
||||
WatchLogGroup,
|
||||
AnimeSearchResult,
|
||||
MediaSearchResult,
|
||||
Season,
|
||||
} from './types';
|
||||
import { formatDateDisplay, parseDateInput, parseReleaseDateInput } from './types';
|
||||
|
||||
type SearchResult = AnimeSearchResult | MediaSearchResult;
|
||||
|
||||
function isAnimeResult(r: SearchResult): r is AnimeSearchResult {
|
||||
return 'malId' in r;
|
||||
}
|
||||
|
||||
export class AddTitleModal extends Modal {
|
||||
private plugin: WatchLogPlugin;
|
||||
private dataManager: DataManager;
|
||||
private onAdded: () => void;
|
||||
|
||||
// Form state
|
||||
private selectedType = 'Anime';
|
||||
private searchQuery = '';
|
||||
private searchResults: SearchResult[] = [];
|
||||
private isSearching = false;
|
||||
private autoSearch = false;
|
||||
|
||||
// Editable fields
|
||||
private fieldTitle = '';
|
||||
private fieldEpisodes = 0;
|
||||
private fieldDuration = 0;
|
||||
private fieldReleaseDate = '';
|
||||
private fieldLink = '';
|
||||
private fieldSeasons: Season[] = [];
|
||||
private fieldStatus = 'Plan to watch';
|
||||
private fieldPriority = 'Medium';
|
||||
private fieldDateStarted = '';
|
||||
private fieldMalId: number | null = null;
|
||||
private skipDuplicateCheck = false;
|
||||
private duplicateWarningEl: HTMLElement | null = null;
|
||||
|
||||
// Group assignment fields
|
||||
private selectedGroupId = '';
|
||||
private newGroupName = '';
|
||||
|
||||
// UI refs
|
||||
private resultsEl: HTMLElement | null = null;
|
||||
private formEl: HTMLElement | null = null;
|
||||
private searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: WatchLogPlugin,
|
||||
dataManager: DataManager,
|
||||
onAdded: () => void,
|
||||
prefill?: {
|
||||
title?: string;
|
||||
searchQuery?: string;
|
||||
type: string;
|
||||
episodes: number;
|
||||
duration: number;
|
||||
releaseDate: string;
|
||||
link: string;
|
||||
seasons: Season[];
|
||||
},
|
||||
) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.dataManager = dataManager;
|
||||
this.onAdded = onAdded;
|
||||
if (prefill) {
|
||||
this.selectedType = prefill.type;
|
||||
if (prefill.title) {
|
||||
this.fieldTitle = prefill.title;
|
||||
}
|
||||
if (prefill.searchQuery) {
|
||||
this.searchQuery = prefill.searchQuery;
|
||||
this.autoSearch = true;
|
||||
}
|
||||
this.fieldEpisodes = prefill.episodes;
|
||||
this.fieldDuration = prefill.duration;
|
||||
this.fieldReleaseDate = prefill.releaseDate;
|
||||
this.fieldLink = prefill.link;
|
||||
this.fieldSeasons = prefill.seasons;
|
||||
}
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
this.titleEl.setText('Add title');
|
||||
this.contentEl.addClass('wl-add-modal');
|
||||
this.buildUI();
|
||||
if (this.autoSearch) {
|
||||
void this.performSearch();
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
if (this.searchDebounce) clearTimeout(this.searchDebounce);
|
||||
}
|
||||
|
||||
private buildUI(): void {
|
||||
const content = this.contentEl;
|
||||
content.empty();
|
||||
|
||||
// Type selector row
|
||||
const typeRow = content.createDiv({ cls: 'wl-modal-row' });
|
||||
typeRow.createSpan({ cls: 'wl-modal-label', text: 'Type' });
|
||||
const typeSelect = typeRow.createEl('select', { cls: 'wl-select' });
|
||||
for (const t of this.plugin.settings.types) {
|
||||
const opt = typeSelect.createEl('option', { text: t.name, value: t.name });
|
||||
if (t.name === this.selectedType) opt.selected = true;
|
||||
}
|
||||
typeSelect.addEventListener('change', () => {
|
||||
this.selectedType = typeSelect.value;
|
||||
this.searchResults = [];
|
||||
this.renderResults();
|
||||
this.renderForm();
|
||||
});
|
||||
|
||||
// Search input
|
||||
const searchRow = content.createDiv({ cls: 'wl-modal-row' });
|
||||
searchRow.createSpan({ cls: 'wl-modal-label', text: 'Search' });
|
||||
const searchInput = searchRow.createEl('input', {
|
||||
cls: 'wl-modal-input',
|
||||
attr: { type: 'text', placeholder: 'Search for a title...' },
|
||||
});
|
||||
searchInput.value = this.searchQuery;
|
||||
searchInput.addEventListener('input', () => {
|
||||
this.searchQuery = searchInput.value;
|
||||
if (this.searchDebounce) clearTimeout(this.searchDebounce);
|
||||
this.searchDebounce = setTimeout(() => void this.performSearch(), 600);
|
||||
});
|
||||
|
||||
const searchBtn = searchRow.createEl('button', { cls: 'wl-btn', text: 'Search' });
|
||||
searchBtn.addEventListener('click', () => void this.performSearch());
|
||||
|
||||
// Results list
|
||||
this.resultsEl = content.createDiv({ cls: 'wl-modal-results' });
|
||||
|
||||
// Form section
|
||||
this.formEl = content.createDiv({ cls: 'wl-modal-form' });
|
||||
this.renderForm();
|
||||
}
|
||||
|
||||
private async performSearch(): Promise<void> {
|
||||
if (!this.searchQuery.trim()) return;
|
||||
this.isSearching = true;
|
||||
this.renderResults();
|
||||
|
||||
try {
|
||||
const api = this.plugin.apiService;
|
||||
const activeApi = this.plugin.settings.activeApi ?? 'OMDb';
|
||||
const isAnime = this.selectedType === 'Anime';
|
||||
const isMovie = this.selectedType === 'Movie';
|
||||
|
||||
if (isAnime) {
|
||||
this.searchResults = await api.searchAnime(this.searchQuery);
|
||||
} else if (activeApi === 'TMDB') {
|
||||
this.searchResults = await api.searchTmdb(this.searchQuery, isMovie ? 'movie' : 'series');
|
||||
} else {
|
||||
this.searchResults = await api.searchOmdb(this.searchQuery, isMovie ? 'movie' : 'series');
|
||||
}
|
||||
} catch {
|
||||
new Notice('Search failed. Check your connection and API settings.');
|
||||
this.searchResults = [];
|
||||
} finally {
|
||||
this.isSearching = false;
|
||||
this.renderResults();
|
||||
}
|
||||
}
|
||||
|
||||
private renderResults(): void {
|
||||
if (!this.resultsEl) return;
|
||||
this.resultsEl.empty();
|
||||
|
||||
if (this.isSearching) {
|
||||
this.resultsEl.createDiv({ cls: 'wl-modal-loading', text: 'Searching...' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.searchResults.length === 0) return;
|
||||
|
||||
this.searchResults.forEach((result, idx) => {
|
||||
const item = this.resultsEl!.createDiv({ cls: 'wl-result-item' });
|
||||
const epText = isAnimeResult(result)
|
||||
? `${result.episodes} eps`
|
||||
: result.episodes > 0 ? `${result.episodes} eps` : result.mediaType;
|
||||
item.createDiv({ cls: 'wl-result-title', text: result.title });
|
||||
item.createDiv({ cls: 'wl-result-meta', text: `${epText} · ${result.releaseDate}` });
|
||||
item.dataset['idx'] = String(idx);
|
||||
item.addEventListener('click', () => void this.selectResult(result, item));
|
||||
});
|
||||
}
|
||||
|
||||
private async selectResult(result: SearchResult, itemEl: HTMLElement): Promise<void> {
|
||||
if (this.resultsEl) {
|
||||
for (const child of Array.from(this.resultsEl.children)) {
|
||||
child.removeClass('is-selected');
|
||||
}
|
||||
}
|
||||
itemEl.addClass('is-selected');
|
||||
|
||||
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;
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
} 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.fieldMalId = anime.malId;
|
||||
}
|
||||
} catch {
|
||||
this.fieldTitle = result.title;
|
||||
this.fieldEpisodes = isAnimeResult(result) ? result.episodes : result.episodes;
|
||||
this.fieldDuration = isAnimeResult(result) ? result.duration : result.episodeDuration;
|
||||
this.fieldReleaseDate = result.releaseDate;
|
||||
this.fieldLink = result.url;
|
||||
this.fieldSeasons = result.seasons;
|
||||
}
|
||||
|
||||
this.renderForm();
|
||||
}
|
||||
|
||||
private renderForm(): void {
|
||||
if (!this.formEl) return;
|
||||
this.formEl.empty();
|
||||
|
||||
const makeRow = (label: string): HTMLElement => {
|
||||
const row = this.formEl!.createDiv({ cls: 'wl-modal-row' });
|
||||
row.createSpan({ cls: 'wl-modal-label', text: label });
|
||||
return row;
|
||||
};
|
||||
|
||||
// Title
|
||||
const titleRow = makeRow('Title');
|
||||
const titleInput = titleRow.createEl('input', {
|
||||
cls: 'wl-modal-input',
|
||||
attr: { type: 'text', placeholder: 'Title name' },
|
||||
});
|
||||
titleInput.value = this.fieldTitle;
|
||||
titleInput.addEventListener('input', () => { this.fieldTitle = titleInput.value; });
|
||||
|
||||
// Episodes
|
||||
const epsRow = makeRow('Episodes');
|
||||
const epsInput = epsRow.createEl('input', {
|
||||
cls: 'wl-modal-input wl-modal-input-sm',
|
||||
attr: { type: 'number', min: '0', placeholder: '0' },
|
||||
});
|
||||
epsInput.value = String(this.fieldEpisodes);
|
||||
epsInput.addEventListener('input', () => { this.fieldEpisodes = parseInt(epsInput.value) || 0; });
|
||||
|
||||
// Duration
|
||||
const durRow = makeRow('Ep. duration (min)');
|
||||
const durInput = durRow.createEl('input', {
|
||||
cls: 'wl-modal-input wl-modal-input-sm',
|
||||
attr: { type: 'number', min: '0', placeholder: '24' },
|
||||
});
|
||||
durInput.value = String(this.fieldDuration);
|
||||
durInput.addEventListener('input', () => { this.fieldDuration = parseInt(durInput.value) || 0; });
|
||||
|
||||
// Release date
|
||||
const relRow = makeRow('Release date');
|
||||
const relStack = relRow.createDiv({ cls: 'wl-modal-input-stack' });
|
||||
const relInput = relStack.createEl('input', {
|
||||
cls: 'wl-modal-input',
|
||||
// eslint-disable-next-line obsidianmd/ui/sentence-case
|
||||
attr: { type: 'text', placeholder: 'DD/MM/YYYY or YYYY-MM-DD' },
|
||||
});
|
||||
relInput.value = this.fieldReleaseDate;
|
||||
const relErrorEl = relStack.createDiv({ cls: 'wl-modal-error wl-hidden' });
|
||||
relInput.addEventListener('change', () => {
|
||||
const raw = relInput.value.trim();
|
||||
if (!raw) {
|
||||
this.fieldReleaseDate = '';
|
||||
relErrorEl.addClass('wl-hidden');
|
||||
return;
|
||||
}
|
||||
const parsed = parseReleaseDateInput(raw);
|
||||
if (parsed) {
|
||||
this.fieldReleaseDate = parsed;
|
||||
relInput.value = parsed;
|
||||
relErrorEl.addClass('wl-hidden');
|
||||
} else {
|
||||
this.fieldReleaseDate = raw;
|
||||
// eslint-disable-next-line obsidianmd/ui/sentence-case
|
||||
relErrorEl.textContent = 'Unrecognised format. Expected DD/MM/YYYY or YYYY-MM-DD.';
|
||||
relErrorEl.removeClass('wl-hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// External link
|
||||
const linkRow = makeRow('External link');
|
||||
const linkInput = linkRow.createEl('input', {
|
||||
cls: 'wl-modal-input',
|
||||
attr: { type: 'url', placeholder: 'https://example.com' },
|
||||
});
|
||||
linkInput.value = this.fieldLink;
|
||||
linkInput.addEventListener('input', () => { this.fieldLink = linkInput.value; });
|
||||
|
||||
// Status (exclude "To be released" — it is auto-assigned based on releaseDate)
|
||||
const statusRow = makeRow('Status');
|
||||
const statusSelect = statusRow.createEl('select', { cls: 'wl-select' });
|
||||
for (const s of this.plugin.settings.statuses.filter((s) => s.name !== 'To be released')) {
|
||||
const opt = statusSelect.createEl('option', { text: s.name, value: s.name });
|
||||
if (s.name === this.fieldStatus) opt.selected = true;
|
||||
}
|
||||
statusSelect.addEventListener('change', () => { this.fieldStatus = statusSelect.value; });
|
||||
|
||||
// Priority
|
||||
const priRow = makeRow('Priority');
|
||||
const priSelect = priRow.createEl('select', { cls: 'wl-select' });
|
||||
for (const p of this.plugin.settings.priorities) {
|
||||
const opt = priSelect.createEl('option', { text: p.name, value: p.name });
|
||||
if (p.name === this.fieldPriority) opt.selected = true;
|
||||
}
|
||||
priSelect.addEventListener('change', () => { this.fieldPriority = priSelect.value; });
|
||||
|
||||
// Date started
|
||||
const startRow = makeRow('Date started');
|
||||
const startInput = startRow.createEl('input', {
|
||||
cls: 'wl-modal-input',
|
||||
attr: { type: 'text', placeholder: '15/01/2024', maxlength: '10' },
|
||||
});
|
||||
startInput.value = this.fieldDateStarted ? formatDateDisplay(this.fieldDateStarted) : '';
|
||||
startInput.addEventListener('change', () => {
|
||||
const parsed = parseDateInput(startInput.value);
|
||||
if (startInput.value.trim() && !parsed) {
|
||||
startInput.addClass('wl-input-error');
|
||||
} else {
|
||||
startInput.removeClass('wl-input-error');
|
||||
this.fieldDateStarted = parsed ?? '';
|
||||
}
|
||||
});
|
||||
|
||||
// Group fields (mutually exclusive)
|
||||
const groups = this.dataManager.getGroups();
|
||||
const addToGroupRow = makeRow('Add to group');
|
||||
const groupSelect = addToGroupRow.createEl('select', { cls: 'wl-select' });
|
||||
groupSelect.createEl('option', { text: '— none —', value: '' });
|
||||
for (const g of groups) {
|
||||
const opt = groupSelect.createEl('option', { text: g.name, value: g.id });
|
||||
if (g.id === this.selectedGroupId) opt.selected = true;
|
||||
}
|
||||
|
||||
const newGroupRow = makeRow('Or create new group');
|
||||
const newGroupInput = newGroupRow.createEl('input', {
|
||||
cls: 'wl-modal-input',
|
||||
attr: { type: 'text', placeholder: 'New group name...' },
|
||||
});
|
||||
newGroupInput.value = this.newGroupName;
|
||||
|
||||
groupSelect.addEventListener('change', () => {
|
||||
this.selectedGroupId = groupSelect.value;
|
||||
if (this.selectedGroupId) {
|
||||
this.newGroupName = '';
|
||||
newGroupInput.value = '';
|
||||
newGroupInput.disabled = true;
|
||||
} else {
|
||||
newGroupInput.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
newGroupInput.addEventListener('input', () => {
|
||||
this.newGroupName = newGroupInput.value;
|
||||
if (this.newGroupName.trim()) {
|
||||
this.selectedGroupId = '';
|
||||
groupSelect.value = '';
|
||||
groupSelect.disabled = true;
|
||||
} else {
|
||||
groupSelect.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (this.selectedGroupId) newGroupInput.disabled = true;
|
||||
if (this.newGroupName.trim()) groupSelect.disabled = true;
|
||||
|
||||
// Seasons info
|
||||
if (this.fieldSeasons.length > 0) {
|
||||
const seasRow = makeRow('Seasons');
|
||||
seasRow.createSpan({
|
||||
cls: 'wl-modal-info',
|
||||
text: this.fieldSeasons.map((s) => `${s.name} (${s.episodes} eps)`).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
// Add button
|
||||
const btnRow = this.formEl.createDiv({ cls: 'wl-modal-btn-row' });
|
||||
const addBtn = btnRow.createEl('button', { cls: 'wl-btn wl-btn-primary', text: 'Add to watchlog' });
|
||||
addBtn.addEventListener('click', () => void this.addTitle());
|
||||
}
|
||||
|
||||
private showDuplicateWarning(titleName: string, btnRow: HTMLElement): void {
|
||||
if (this.duplicateWarningEl) return;
|
||||
const warn = btnRow.createDiv({ cls: 'wl-duplicate-warning' });
|
||||
this.duplicateWarningEl = warn;
|
||||
warn.createSpan({ text: `"${titleName}" already exists. Add anyway?` });
|
||||
const continueBtn = warn.createEl('button', { cls: 'wl-btn wl-btn-primary', text: 'Continue' });
|
||||
const cancelBtn = warn.createEl('button', { cls: 'wl-btn', text: 'Cancel' });
|
||||
continueBtn.addEventListener('click', () => {
|
||||
this.skipDuplicateCheck = true;
|
||||
warn.remove();
|
||||
this.duplicateWarningEl = null;
|
||||
void this.addTitle();
|
||||
});
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
warn.remove();
|
||||
this.duplicateWarningEl = null;
|
||||
this.skipDuplicateCheck = false;
|
||||
});
|
||||
}
|
||||
|
||||
private async addTitle(): Promise<void> {
|
||||
const titleName = this.fieldTitle.trim();
|
||||
if (!titleName) {
|
||||
new Notice('Please enter a title name.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.skipDuplicateCheck) {
|
||||
const duplicate = this.dataManager.getTitles().find(
|
||||
(t) => t.title.toLowerCase() === titleName.toLowerCase()
|
||||
);
|
||||
if (duplicate) {
|
||||
const btnRow = this.contentEl.querySelector<HTMLElement>('.wl-modal-btn-row');
|
||||
if (btnRow) this.showDuplicateWarning(titleName, btnRow);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.skipDuplicateCheck = false;
|
||||
|
||||
const entry: WatchLogTitle = {
|
||||
id: this.dataManager.generateId(titleName),
|
||||
title: titleName,
|
||||
type: this.selectedType,
|
||||
status: this.fieldStatus,
|
||||
priority: this.fieldPriority,
|
||||
review: '',
|
||||
rating: 0,
|
||||
notes: '',
|
||||
dateStarted: this.fieldDateStarted || null,
|
||||
dateFinished: null,
|
||||
dateAdded: new Date().toISOString(),
|
||||
dateModified: new Date().toISOString(),
|
||||
totalEpisodes: this.fieldEpisodes,
|
||||
episodeDuration: this.fieldDuration,
|
||||
releaseDate: this.fieldReleaseDate || null,
|
||||
externalLink: this.fieldLink,
|
||||
seasons: this.fieldSeasons,
|
||||
watchedEpisodes: [],
|
||||
...(this.fieldMalId !== null ? { malId: this.fieldMalId } : {}),
|
||||
};
|
||||
|
||||
// Bug 3: 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) {
|
||||
entry.dateFinished = new Date().toISOString().split('T')[0] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
await this.dataManager.addTitle(entry);
|
||||
|
||||
if (this.selectedGroupId) {
|
||||
await this.dataManager.addTitleToGroup(this.selectedGroupId, entry.id);
|
||||
} else if (this.newGroupName.trim()) {
|
||||
const name = this.newGroupName.trim();
|
||||
const group: WatchLogGroup = {
|
||||
id: this.dataManager.generateGroupId(name),
|
||||
name,
|
||||
titleIds: [entry.id],
|
||||
dateAdded: new Date().toISOString(),
|
||||
};
|
||||
await this.dataManager.addGroup(group);
|
||||
}
|
||||
|
||||
new Notice(`"${titleName}" added to WatchLog.`);
|
||||
this.close();
|
||||
this.onAdded();
|
||||
}
|
||||
}
|
||||
1131
src/AirtimeTab.ts
Normal file
1131
src/AirtimeTab.ts
Normal file
File diff suppressed because it is too large
Load diff
349
src/ApiService.ts
Normal file
349
src/ApiService.ts
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
import { requestUrl } from 'obsidian';
|
||||
import type {
|
||||
AnimeSearchResult,
|
||||
MediaSearchResult,
|
||||
JikanAnime,
|
||||
OmdbSearchResponse,
|
||||
OmdbDetailResponse,
|
||||
OmdbSeasonResponse,
|
||||
TmdbSearchResponse,
|
||||
TmdbMovieDetail,
|
||||
TmdbTvDetail,
|
||||
TmdbExternalIds,
|
||||
TmdbFindResult,
|
||||
Season,
|
||||
} 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 API_TIMEOUT_MS = 8000;
|
||||
|
||||
export class ApiService {
|
||||
private omdbApiKey: string;
|
||||
private tmdbApiKey: string;
|
||||
|
||||
constructor(omdbApiKey: string, tmdbApiKey = '') {
|
||||
this.omdbApiKey = omdbApiKey;
|
||||
this.tmdbApiKey = tmdbApiKey;
|
||||
}
|
||||
|
||||
setOmdbKey(key: string): void {
|
||||
this.omdbApiKey = key;
|
||||
}
|
||||
|
||||
setTmdbKey(key: string): void {
|
||||
this.tmdbApiKey = key;
|
||||
}
|
||||
|
||||
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) =>
|
||||
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]);
|
||||
}
|
||||
|
||||
// ─── Jikan / MAL ─────────────────────────────────────────────────────────────
|
||||
|
||||
async searchAnime(query: string): Promise<AnimeSearchResult[]> {
|
||||
const url = `${JIKAN_BASE}/anime?q=${encodeURIComponent(query)}&limit=10&sfw=false`;
|
||||
try {
|
||||
const data = (await this.fetchWithTimeout(url)) as { data?: JikanAnime[] };
|
||||
return (data.data ?? []).map((a) => this.mapJikanAnime(a));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private mapJikanAnime(anime: JikanAnime): AnimeSearchResult {
|
||||
const durationStr = anime.duration ?? '24 min per ep';
|
||||
const durationMatch = durationStr.match(/(\d+)\s*min/);
|
||||
const duration = durationMatch && durationMatch[1] ? parseInt(durationMatch[1]) : 24;
|
||||
const episodes = anime.episodes ?? 0;
|
||||
const seasons: Season[] =
|
||||
episodes > 0 ? [{ name: 'Season 1', episodes, offset: 0 }] : [];
|
||||
return {
|
||||
malId: anime.mal_id,
|
||||
title: anime.title_english ?? anime.title,
|
||||
episodes,
|
||||
duration,
|
||||
releaseDate: anime.aired?.from ? (anime.aired.from.split('T')[0] ?? '') : '',
|
||||
url: anime.url,
|
||||
seasons,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── OMDb ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async searchOmdb(query: string, type: 'movie' | 'series'): Promise<MediaSearchResult[]> {
|
||||
if (!this.omdbApiKey) return [];
|
||||
const url = `${OMDB_BASE}/?s=${encodeURIComponent(query)}&type=${type}&apikey=${encodeURIComponent(this.omdbApiKey)}`;
|
||||
try {
|
||||
const data = (await this.fetchWithTimeout(url)) as OmdbSearchResponse;
|
||||
if (data.Response === 'False') return [];
|
||||
return (data.Search ?? []).slice(0, 10).map((item) => ({
|
||||
imdbId: item.imdbID,
|
||||
title: item.Title,
|
||||
mediaType: type === 'movie' ? ('movie' as const) : ('tv' as const),
|
||||
episodes: type === 'movie' ? 1 : 0,
|
||||
episodeDuration: 0,
|
||||
releaseDate: item.Year ? `${item.Year}-01-01` : '',
|
||||
url: `https://www.imdb.com/title/${item.imdbID}`,
|
||||
seasons: [],
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getOmdbMovieDetails(imdbId: string): Promise<MediaSearchResult | null> {
|
||||
if (!this.omdbApiKey) return null;
|
||||
const url = `${OMDB_BASE}/?i=${encodeURIComponent(imdbId)}&apikey=${encodeURIComponent(this.omdbApiKey)}`;
|
||||
try {
|
||||
const data = (await this.fetchWithTimeout(url)) as OmdbDetailResponse;
|
||||
if (data.Response === 'False' || !data.imdbID) return null;
|
||||
const runtimeMin = data.Runtime ? parseInt(data.Runtime.replace(/[^0-9]/g, '')) : 120;
|
||||
return {
|
||||
imdbId: data.imdbID,
|
||||
title: data.Title,
|
||||
mediaType: 'movie',
|
||||
episodes: 1,
|
||||
episodeDuration: isNaN(runtimeMin) ? 120 : runtimeMin,
|
||||
releaseDate: this.parseOmdbDate(data.Released ?? data.Year),
|
||||
url: `https://www.imdb.com/title/${data.imdbID}`,
|
||||
seasons: [{ name: 'Movie', episodes: 1, offset: 0 }],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getOmdbTvDetails(imdbId: string): Promise<MediaSearchResult | null> {
|
||||
if (!this.omdbApiKey) return null;
|
||||
const url = `${OMDB_BASE}/?i=${encodeURIComponent(imdbId)}&apikey=${encodeURIComponent(this.omdbApiKey)}`;
|
||||
try {
|
||||
const data = (await this.fetchWithTimeout(url)) as OmdbDetailResponse;
|
||||
if (data.Response === 'False' || !data.imdbID) return null;
|
||||
|
||||
const totalSeasons = parseInt(data.totalSeasons ?? '1') || 1;
|
||||
const seasons: Season[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (let s = 1; s <= totalSeasons; s++) {
|
||||
const count = await this.getOmdbSeasonEpisodeCount(imdbId, s);
|
||||
if (count === null) break;
|
||||
seasons.push({ name: `Season ${s}`, episodes: count, offset });
|
||||
offset += count;
|
||||
}
|
||||
|
||||
const totalEpisodes = seasons.reduce((sum, s) => sum + s.episodes, 0);
|
||||
return {
|
||||
imdbId: data.imdbID,
|
||||
title: data.Title,
|
||||
mediaType: 'tv',
|
||||
episodes: totalEpisodes,
|
||||
episodeDuration: 45,
|
||||
releaseDate: this.parseOmdbDate(data.Released ?? data.Year),
|
||||
url: `https://www.imdb.com/title/${data.imdbID}`,
|
||||
seasons,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async getOmdbSeasonEpisodeCount(imdbId: string, season: number): Promise<number | null> {
|
||||
const url = `${OMDB_BASE}/?i=${encodeURIComponent(imdbId)}&Season=${season}&apikey=${encodeURIComponent(this.omdbApiKey)}`;
|
||||
try {
|
||||
const data = (await this.fetchWithTimeout(url)) as OmdbSeasonResponse;
|
||||
if (data.Response === 'False') return null;
|
||||
return (data.Episodes ?? []).length;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Jikan schedule ──────────────────────────────────────────────────────────
|
||||
|
||||
async getAnimeScheduleByMalId(
|
||||
malId: number,
|
||||
): Promise<{ dayOfWeek: number; time: string } | null> {
|
||||
try {
|
||||
const url = `${JIKAN_BASE}/anime/${malId}`;
|
||||
const data = (await this.fetchWithTimeout(url)) as {
|
||||
data?: { broadcast?: { day?: string | null; time?: string | null } };
|
||||
};
|
||||
const broadcast = data.data?.broadcast;
|
||||
if (!broadcast?.day || !broadcast.time) return null;
|
||||
|
||||
const dayMap: Record<string, number> = {
|
||||
Mondays: 1, Tuesdays: 2, Wednesdays: 3, Thursdays: 4,
|
||||
Fridays: 5, Saturdays: 6, Sundays: 0,
|
||||
};
|
||||
const dayOfWeek = dayMap[broadcast.day];
|
||||
if (dayOfWeek === undefined) return null;
|
||||
return { dayOfWeek, time: broadcast.time };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts OMDb "DD Mon YYYY" dates (e.g. "08 Feb 2026") to YYYY-MM-DD.
|
||||
* Returns the input unchanged if it doesn't match that pattern (e.g. year-only "2026").
|
||||
*/
|
||||
private parseOmdbDate(dateStr: string): string {
|
||||
const MONTHS: Record<string, string> = {
|
||||
Jan: '01', Feb: '02', Mar: '03', Apr: '04', May: '05', Jun: '06',
|
||||
Jul: '07', Aug: '08', Sep: '09', Oct: '10', Nov: '11', Dec: '12',
|
||||
};
|
||||
const m = dateStr.match(/^(\d{2})\s+([A-Za-z]{3})\s+(\d{4})$/);
|
||||
if (m && m[1] && m[2] && m[3]) {
|
||||
const mon = MONTHS[m[2]];
|
||||
if (mon) return `${m[3]}-${mon}-${m[1]}`;
|
||||
}
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
/** Fetches OMDb detail by raw IMDb ID, auto-detecting movie vs. TV. */
|
||||
async getOmdbByImdbId(imdbId: string): Promise<MediaSearchResult | null> {
|
||||
if (!this.omdbApiKey) return null;
|
||||
const url = `${OMDB_BASE}/?i=${encodeURIComponent(imdbId)}&apikey=${encodeURIComponent(this.omdbApiKey)}`;
|
||||
try {
|
||||
const data = (await this.fetchWithTimeout(url)) as OmdbDetailResponse;
|
||||
if (data.Response === 'False' || !data.imdbID) return null;
|
||||
if (data.Type === 'movie') return this.getOmdbMovieDetails(imdbId);
|
||||
return this.getOmdbTvDetails(imdbId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── TMDB ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async searchTmdb(query: string, type: 'movie' | 'series'): Promise<MediaSearchResult[]> {
|
||||
if (!this.tmdbApiKey) return [];
|
||||
const endpoint = type === 'movie' ? 'movie' : 'tv';
|
||||
const url = `${TMDB_BASE}/search/${endpoint}?query=${encodeURIComponent(query)}&page=1`;
|
||||
try {
|
||||
const data = (await this.fetchWithTimeout(url, this.tmdbHeaders())) as TmdbSearchResponse;
|
||||
return (data.results ?? []).slice(0, 10).map((item) => ({
|
||||
imdbId: String(item.id),
|
||||
title: item.title ?? item.name ?? '',
|
||||
mediaType: type === 'movie' ? ('movie' as const) : ('tv' as const),
|
||||
episodes: 0,
|
||||
episodeDuration: 0,
|
||||
releaseDate: item.release_date ?? item.first_air_date ?? '',
|
||||
url: '',
|
||||
seasons: [],
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getTmdbMovieDetails(tmdbId: string): Promise<MediaSearchResult | null> {
|
||||
if (!this.tmdbApiKey) return null;
|
||||
try {
|
||||
const [detailData, extData] = await Promise.all([
|
||||
this.fetchWithTimeout(`${TMDB_BASE}/movie/${tmdbId}`, this.tmdbHeaders()),
|
||||
this.fetchWithTimeout(`${TMDB_BASE}/movie/${tmdbId}/external_ids`, this.tmdbHeaders()),
|
||||
]);
|
||||
const detail = detailData as TmdbMovieDetail;
|
||||
const ext = extData as TmdbExternalIds;
|
||||
const imdbId = ext.imdb_id ?? detail.imdb_id ?? '';
|
||||
return {
|
||||
imdbId: String(detail.id),
|
||||
title: detail.title,
|
||||
mediaType: 'movie',
|
||||
episodes: 1,
|
||||
episodeDuration: detail.runtime ?? 120,
|
||||
releaseDate: detail.release_date ?? '',
|
||||
url: imdbId ? `https://www.imdb.com/title/${imdbId}` : '',
|
||||
seasons: [{ name: 'Movie', episodes: 1, offset: 0 }],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getTmdbTvDetails(tmdbId: string): Promise<MediaSearchResult | null> {
|
||||
if (!this.tmdbApiKey) return null;
|
||||
try {
|
||||
const [detailData, extData] = await Promise.all([
|
||||
this.fetchWithTimeout(`${TMDB_BASE}/tv/${tmdbId}`, this.tmdbHeaders()),
|
||||
this.fetchWithTimeout(`${TMDB_BASE}/tv/${tmdbId}/external_ids`, this.tmdbHeaders()),
|
||||
]);
|
||||
const detail = detailData as TmdbTvDetail;
|
||||
const ext = extData as TmdbExternalIds;
|
||||
const imdbId = ext.imdb_id ?? '';
|
||||
|
||||
const rawSeasons = (detail.seasons ?? []).filter((s) => s.season_number !== 0);
|
||||
let offset = 0;
|
||||
const seasons: Season[] = rawSeasons.map((s) => {
|
||||
const season: Season = { name: s.name, episodes: s.episode_count, offset };
|
||||
offset += s.episode_count;
|
||||
return season;
|
||||
});
|
||||
const totalEpisodes = seasons.reduce((sum, s) => sum + s.episodes, 0) || (detail.number_of_episodes ?? 0);
|
||||
|
||||
return {
|
||||
imdbId: String(detail.id),
|
||||
title: detail.name,
|
||||
mediaType: 'tv',
|
||||
episodes: totalEpisodes,
|
||||
episodeDuration: (detail.episode_run_time ?? [])[0] ?? 45,
|
||||
releaseDate: detail.first_air_date ?? '',
|
||||
url: imdbId ? `https://www.imdb.com/title/${imdbId}` : '',
|
||||
seasons,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getTmdbByImdbId(imdbId: string): Promise<MediaSearchResult | null> {
|
||||
if (!this.tmdbApiKey) return null;
|
||||
const url = `${TMDB_BASE}/find/${encodeURIComponent(imdbId)}?external_source=imdb_id`;
|
||||
try {
|
||||
const data = (await this.fetchWithTimeout(url, this.tmdbHeaders())) as TmdbFindResult;
|
||||
if (data.movie_results && data.movie_results.length > 0) {
|
||||
return this.getTmdbMovieDetails(String(data.movie_results[0]!.id));
|
||||
}
|
||||
if (data.tv_results && data.tv_results.length > 0) {
|
||||
return this.getTmdbTvDetails(String(data.tv_results[0]!.id));
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async checkTmdbConnection(): Promise<boolean> {
|
||||
if (!this.tmdbApiKey) return false;
|
||||
try {
|
||||
const url = `${TMDB_BASE}/movie/550`;
|
||||
const data = (await this.fetchWithTimeout(url, this.tmdbHeaders())) as { id?: number };
|
||||
return data.id !== undefined;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async checkOmdbConnection(): Promise<boolean> {
|
||||
if (!this.omdbApiKey) return false;
|
||||
try {
|
||||
// Use a known IMDb ID (The Shawshank Redemption) as a connectivity test
|
||||
const url = `${OMDB_BASE}/?i=tt0111161&apikey=${encodeURIComponent(this.omdbApiKey)}`;
|
||||
const data = (await this.fetchWithTimeout(url)) as OmdbDetailResponse;
|
||||
return data.Response === 'True';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/ConfirmModal.ts
Normal file
26
src/ConfirmModal.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { App, Modal } from 'obsidian';
|
||||
|
||||
export class ConfirmModal extends Modal {
|
||||
private message: string;
|
||||
private onConfirm: () => void;
|
||||
|
||||
constructor(app: App, message: string, onConfirm: () => void) {
|
||||
super(app);
|
||||
this.message = message;
|
||||
this.onConfirm = onConfirm;
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.createDiv({ cls: 'wl-confirm-msg', text: this.message });
|
||||
const btnRow = contentEl.createDiv({ cls: 'wl-modal-btn-row' });
|
||||
btnRow.createEl('button', { cls: 'wl-btn wl-btn-danger', text: 'Confirm' })
|
||||
.addEventListener('click', () => { this.close(); this.onConfirm(); });
|
||||
btnRow.createEl('button', { cls: 'wl-btn', text: 'Cancel' })
|
||||
.addEventListener('click', () => this.close());
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
899
src/CsvModal.ts
Normal file
899
src/CsvModal.ts
Normal file
|
|
@ -0,0 +1,899 @@
|
|||
import { App, Modal, Notice } from 'obsidian';
|
||||
import type WatchLogPlugin from './main';
|
||||
import type { DataManager } from './DataManager';
|
||||
import type { WatchLogTitle } from './types';
|
||||
|
||||
// ── CSV helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const CSV_COLUMNS = [
|
||||
'title', 'type', 'status', 'priority', 'rating',
|
||||
'totalEpisodes', 'episodeDuration',
|
||||
'dateStarted', 'dateFinished', 'releaseDate', 'dateAdded',
|
||||
'externalLink', 'notes',
|
||||
] as const;
|
||||
|
||||
function escapeCsvField(value: string | number | null | undefined): string {
|
||||
const s = String(value ?? '');
|
||||
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function titlesToCsv(titles: WatchLogTitle[]): string {
|
||||
const header = CSV_COLUMNS.join(',');
|
||||
const rows = titles.map((t) =>
|
||||
CSV_COLUMNS.map((col) => escapeCsvField((t as unknown as Record<string, string | number | null | undefined>)[col])).join(',')
|
||||
);
|
||||
return [header, ...rows].join('\n');
|
||||
}
|
||||
|
||||
function parseCsvRow(row: string): string[] {
|
||||
const fields: string[] = [];
|
||||
let cur = '';
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
const ch = row[i];
|
||||
if (ch === '"') {
|
||||
if (inQuotes && row[i + 1] === '"') {
|
||||
cur += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (ch === ',' && !inQuotes) {
|
||||
fields.push(cur);
|
||||
cur = '';
|
||||
} else {
|
||||
cur += ch;
|
||||
}
|
||||
}
|
||||
fields.push(cur);
|
||||
return fields;
|
||||
}
|
||||
|
||||
// ── Date parsing ─────────────────────────────────────────────────────────────
|
||||
|
||||
const MONTH_NAMES: Record<string, number> = {
|
||||
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
|
||||
jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
|
||||
};
|
||||
|
||||
/** Parses a date string in any common format and returns YYYY-MM-DD, or null if unparseable. */
|
||||
function parseCsvDate(raw: string): string | null {
|
||||
const s = raw.trim();
|
||||
if (!s) return null;
|
||||
try {
|
||||
// YYYY-MM-DD (already in storage format)
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
|
||||
|
||||
// D/M/YYYY, DD/MM/YYYY, M/D/YYYY, MM/DD/YYYY
|
||||
const slash = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||
if (slash) {
|
||||
const a = parseInt(slash[1]!), b = parseInt(slash[2]!), y = parseInt(slash[3]!);
|
||||
let dd: number, mm: number;
|
||||
if (a > 12) { dd = a; mm = b; } // Must be DD/MM
|
||||
else if (b > 12) { mm = a; dd = b; } // Must be MM/DD
|
||||
else { dd = a; mm = b; } // Ambiguous → assume DD/MM
|
||||
if (mm < 1 || mm > 12 || dd < 1 || dd > 31) return null;
|
||||
return `${y}-${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// "27 Jun 2025" (DD Mon YYYY)
|
||||
const dmy = s.match(/^(\d{1,2})\s+([A-Za-z]{3,})\s+(\d{4})$/);
|
||||
if (dmy) {
|
||||
const mm = MONTH_NAMES[dmy[2]!.toLowerCase().slice(0, 3)];
|
||||
const dd = parseInt(dmy[1]!), y = parseInt(dmy[3]!);
|
||||
if (mm) return `${y}-${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// "Jun 27, 2025" or "Jun 27 2025" (Mon DD YYYY)
|
||||
const mdy = s.match(/^([A-Za-z]{3,})\s+(\d{1,2}),?\s+(\d{4})$/);
|
||||
if (mdy) {
|
||||
const mm = MONTH_NAMES[mdy[1]!.toLowerCase().slice(0, 3)];
|
||||
const dd = parseInt(mdy[2]!), y = parseInt(mdy[3]!);
|
||||
if (mm) return `${y}-${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// DD.MM.YYYY or DD-MM-YYYY (dot/dash with year at end)
|
||||
const dotDash = s.match(/^(\d{1,2})[.-](\d{1,2})[.-](\d{4})$/);
|
||||
if (dotDash) {
|
||||
const dd = parseInt(dotDash[1]!), mm = parseInt(dotDash[2]!), y = parseInt(dotDash[3]!);
|
||||
if (mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31)
|
||||
return `${y}-${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Fallback: native Date.parse
|
||||
const parsed = new Date(s);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
const dd = parsed.getDate(), mm = parsed.getMonth() + 1, y = parsed.getFullYear();
|
||||
return `${y}-${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Field mapping definitions ────────────────────────────────────────────────
|
||||
|
||||
interface WlFieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
autoDetect: string[];
|
||||
}
|
||||
|
||||
const WL_IMPORT_FIELDS: WlFieldDef[] = [
|
||||
{ key: 'title', label: 'Name', autoDetect: ['title', 'name'] },
|
||||
{ key: 'type', label: 'Type', autoDetect: ['type'] },
|
||||
{ key: 'status', label: 'Status', autoDetect: ['status'] },
|
||||
{ key: 'priority', label: 'Priority', autoDetect: ['priority'] },
|
||||
{ key: 'rating', label: 'Rating', autoDetect: ['rating', 'score'] },
|
||||
{ key: 'totalEpisodes', label: 'Episodes', autoDetect: ['episodes', 'totalepisodes', 'total episodes', 'episode count', 'ep count'] },
|
||||
{ key: 'episodeDuration',label: 'Duration (min)',autoDetect: ['duration', 'episodeduration', 'episode duration', 'minutes', 'runtime'] },
|
||||
{ key: 'dateStarted', label: 'Date Started', autoDetect: ['started', 'datestarted', 'date started', 'date_started', 'start date'] },
|
||||
{ key: 'dateFinished', label: 'Date Finished', autoDetect: ['finished', 'datefinished', 'date finished', 'date_finished', 'end date', 'finish date', 'completed date'] },
|
||||
{ key: 'releaseDate', label: 'Release Date', autoDetect: ['releasedate', 'release date', 'release_date', 'air date', 'airdate'] },
|
||||
{ key: 'externalLink', label: 'Link', autoDetect: ['link', 'externallink', 'external link', 'external_link', 'url'] },
|
||||
];
|
||||
|
||||
function autoMap(headers: string[]): Record<string, string> {
|
||||
const mapping: Record<string, string> = {};
|
||||
for (const field of WL_IMPORT_FIELDS) {
|
||||
const match = headers.find((h) => field.autoDetect.includes(h.toLowerCase()));
|
||||
mapping[field.key] = match ?? '';
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
function applyMapping(
|
||||
lines: string[],
|
||||
mapping: Record<string, string>,
|
||||
dataManager: DataManager,
|
||||
): { entry: Partial<WatchLogTitle>; isDuplicate: boolean }[] {
|
||||
if (lines.length < 2) return [];
|
||||
const headerLine = lines[0] ?? '';
|
||||
const headers = parseCsvRow(headerLine).map((h) => h.trim());
|
||||
const existingTitles = dataManager.getTitles();
|
||||
|
||||
return lines.slice(1)
|
||||
.filter((l) => l.trim())
|
||||
.map((line) => {
|
||||
const values = parseCsvRow(line);
|
||||
const get = (fieldKey: string): string => {
|
||||
const colName = mapping[fieldKey];
|
||||
if (!colName) return '';
|
||||
const idx = headers.indexOf(colName);
|
||||
return idx >= 0 ? (values[idx] ?? '').trim() : '';
|
||||
};
|
||||
|
||||
const entry: Partial<WatchLogTitle> = {};
|
||||
|
||||
const titleVal = get('title');
|
||||
if (titleVal) entry.title = titleVal;
|
||||
const typeVal = get('type');
|
||||
if (typeVal) entry.type = typeVal;
|
||||
const statusVal = get('status');
|
||||
if (statusVal) entry.status = statusVal;
|
||||
const priorityVal = get('priority');
|
||||
if (priorityVal) entry.priority = priorityVal;
|
||||
const ratingVal = get('rating');
|
||||
if (ratingVal) entry.rating = parseFloat(ratingVal) || 0;
|
||||
const epsVal = get('totalEpisodes');
|
||||
if (epsVal) entry.totalEpisodes = parseInt(epsVal) || 0;
|
||||
const durVal = get('episodeDuration');
|
||||
if (durVal) entry.episodeDuration = parseInt(durVal) || 0;
|
||||
const startedRaw = get('dateStarted');
|
||||
if (startedRaw) { const d = parseCsvDate(startedRaw); if (d) entry.dateStarted = d; }
|
||||
const finishedRaw = get('dateFinished');
|
||||
if (finishedRaw) { const d = parseCsvDate(finishedRaw); if (d) entry.dateFinished = d; }
|
||||
const releaseDateRaw = get('releaseDate');
|
||||
if (releaseDateRaw) { const d = parseCsvDate(releaseDateRaw); if (d) entry.releaseDate = d; }
|
||||
const linkVal = get('externalLink');
|
||||
if (linkVal) entry.externalLink = linkVal;
|
||||
|
||||
// Skip rows where all mapped fields resolved to empty
|
||||
const isEmpty = !entry.title?.trim() && !entry.type && !entry.status
|
||||
&& !entry.rating && !entry.totalEpisodes && !entry.episodeDuration
|
||||
&& !entry.dateStarted && !entry.dateFinished && !entry.releaseDate && !entry.externalLink;
|
||||
if (isEmpty) return null;
|
||||
|
||||
const isDuplicate = !!existingTitles.find(
|
||||
(t) => t.title.toLowerCase() === (entry.title ?? '').toLowerCase()
|
||||
);
|
||||
return { entry, isDuplicate };
|
||||
})
|
||||
.filter((r): r is { entry: Partial<WatchLogTitle>; isDuplicate: boolean } => r !== null);
|
||||
}
|
||||
|
||||
// Sentinel for "create new type" in value mapping
|
||||
const CREATE_NEW_TYPE = '__create__';
|
||||
|
||||
// Theme-aware random colors for auto-created types
|
||||
const TYPE_AUTO_COLORS: Record<string, string[]> = {
|
||||
default: ['#6366F1', '#8B5CF6', '#EC4899', '#14B8A6', '#F59E0B', '#10B981', '#3B82F6', '#EF4444'],
|
||||
nightfall: ['#7B2CBF', '#9D4EDD', '#C77DFF', '#5A189A', '#E040FB', '#B388FF', '#CE93D8', '#9C27B0'],
|
||||
bluez: ['#2C7DA0', '#468FAF', '#61A5C2', '#1B6A8A', '#0077B6', '#00B4D8', '#48CAE4', '#90E0EF'],
|
||||
};
|
||||
|
||||
function randomAutoColor(theme: string): string {
|
||||
const palette = TYPE_AUTO_COLORS[theme] ?? TYPE_AUTO_COLORS['default']!;
|
||||
return palette[Math.floor(Math.random() * palette.length)] ?? '#888780';
|
||||
}
|
||||
|
||||
// ── Modal ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class CsvModal extends Modal {
|
||||
private plugin: WatchLogPlugin;
|
||||
private dataManager: DataManager;
|
||||
private mode: 'export' | 'import';
|
||||
|
||||
// Export state
|
||||
private selectedIds = new Set<string>();
|
||||
|
||||
// Import state — shared across steps
|
||||
private csvLines: string[] = [];
|
||||
private csvHeaders: string[] = [];
|
||||
private columnMapping: Record<string, string> = {};
|
||||
private importRows: { entry: Partial<WatchLogTitle>; isDuplicate: boolean; selected: boolean }[] = [];
|
||||
|
||||
// Value mapping state
|
||||
private statusValueMap: Record<string, string> = {}; // csvValue → resolved status ('' = leave blank)
|
||||
private typeValueMap: Record<string, string> = {}; // csvValue → resolved type ('' = blank, CREATE_NEW_TYPE = create)
|
||||
private ratingValueMap: Record<string, string> = {}; // csvValue → '1'–'5' or '' (leave blank)
|
||||
|
||||
// Step containers
|
||||
private stepUpload: HTMLElement | null = null;
|
||||
private stepMapping: HTMLElement | null = null;
|
||||
private stepValueMap: HTMLElement | null = null;
|
||||
private stepPreview: HTMLElement | null = null;
|
||||
private importBtn: HTMLButtonElement | null = null;
|
||||
private cancelBtn: HTMLButtonElement | null = null;
|
||||
|
||||
// Import progress state
|
||||
private isImporting = false;
|
||||
private importCancelled = false;
|
||||
private progressWrap: HTMLElement | null = null;
|
||||
private progressBarFill: HTMLElement | null = null;
|
||||
private progressText: HTMLElement | null = null;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: WatchLogPlugin,
|
||||
dataManager: DataManager,
|
||||
mode: 'export' | 'import',
|
||||
) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.dataManager = dataManager;
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.addClass('wl-csv-modal');
|
||||
|
||||
if (this.mode === 'export') {
|
||||
this.renderExport(contentEl);
|
||||
} else {
|
||||
this.renderImport(contentEl);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
|
||||
// ── Export ────────────────────────────────────────────────────────────────────
|
||||
|
||||
private renderExport(el: HTMLElement): void {
|
||||
el.createEl('h2', { cls: 'wl-modal-title', text: 'Export to CSV' });
|
||||
|
||||
const titles = this.dataManager.getTitles();
|
||||
titles.forEach((t) => this.selectedIds.add(t.id));
|
||||
|
||||
const ctrlRow = el.createDiv({ cls: 'wl-csv-ctrl-row' });
|
||||
const selectAllBtn = ctrlRow.createEl('button', { cls: 'wl-btn', text: 'Select all' });
|
||||
const selectNoneBtn = ctrlRow.createEl('button', { cls: 'wl-btn', text: 'Select none' });
|
||||
const countEl = ctrlRow.createSpan({ cls: 'wl-csv-count', text: `${titles.length} selected` });
|
||||
|
||||
const listEl = el.createDiv({ cls: 'wl-csv-list' });
|
||||
const checkboxes: HTMLInputElement[] = [];
|
||||
|
||||
const updateCount = (): void => {
|
||||
countEl.textContent = `${this.selectedIds.size} selected`;
|
||||
};
|
||||
|
||||
for (const title of titles) {
|
||||
const row = listEl.createDiv({ cls: 'wl-csv-row' });
|
||||
const cb = row.createEl('input', { attr: { type: 'checkbox' } });
|
||||
cb.checked = true;
|
||||
checkboxes.push(cb);
|
||||
row.createSpan({ cls: 'wl-csv-row-title', text: title.title });
|
||||
row.createSpan({ cls: 'wl-csv-row-meta', text: `${title.type} · ${title.status}` });
|
||||
|
||||
cb.addEventListener('change', () => {
|
||||
if (cb.checked) this.selectedIds.add(title.id);
|
||||
else this.selectedIds.delete(title.id);
|
||||
updateCount();
|
||||
});
|
||||
}
|
||||
|
||||
selectAllBtn.addEventListener('click', () => {
|
||||
titles.forEach((t) => this.selectedIds.add(t.id));
|
||||
checkboxes.forEach((cb) => { cb.checked = true; });
|
||||
updateCount();
|
||||
});
|
||||
|
||||
selectNoneBtn.addEventListener('click', () => {
|
||||
this.selectedIds.clear();
|
||||
checkboxes.forEach((cb) => { cb.checked = false; });
|
||||
updateCount();
|
||||
});
|
||||
|
||||
const btnRow = el.createDiv({ cls: 'wl-modal-btn-row wl-csv-btn-row' });
|
||||
const cancelBtn = btnRow.createEl('button', { cls: 'wl-btn', text: 'Cancel' });
|
||||
const exportBtn = btnRow.createEl('button', { cls: 'wl-btn wl-btn-primary', text: 'Export CSV' });
|
||||
|
||||
cancelBtn.addEventListener('click', () => this.close());
|
||||
exportBtn.addEventListener('click', () => {
|
||||
const toExport = titles.filter((t) => this.selectedIds.has(t.id));
|
||||
if (toExport.length === 0) {
|
||||
new Notice('No titles selected.');
|
||||
return;
|
||||
}
|
||||
const csv = titlesToCsv(toExport);
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const today = new Date();
|
||||
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
a.href = url;
|
||||
a.download = `watchlog-export-${dateStr}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
new Notice(`Exported ${toExport.length} titles.`);
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Import ────────────────────────────────────────────────────────────────────
|
||||
|
||||
private renderImport(el: HTMLElement): void {
|
||||
el.createEl('h2', { cls: 'wl-modal-title', text: 'Import from CSV' });
|
||||
|
||||
this.stepUpload = el.createDiv({ cls: 'wl-csv-step' });
|
||||
const dropZone = this.stepUpload.createDiv({ cls: 'wl-csv-drop-zone' });
|
||||
dropZone.createDiv({ cls: 'wl-csv-drop-label', text: 'Select a CSV file to import' });
|
||||
const fileInput = dropZone.createEl('input', {
|
||||
attr: { type: 'file', accept: '.csv' },
|
||||
cls: 'wl-csv-file-input',
|
||||
});
|
||||
|
||||
this.stepMapping = el.createDiv({ cls: 'wl-csv-step' });
|
||||
this.stepMapping.hide();
|
||||
|
||||
this.stepValueMap = el.createDiv({ cls: 'wl-csv-step' });
|
||||
this.stepValueMap.hide();
|
||||
|
||||
this.stepPreview = el.createDiv({ cls: 'wl-csv-step' });
|
||||
this.stepPreview.hide();
|
||||
|
||||
const btnRow = el.createDiv({ cls: 'wl-modal-btn-row wl-csv-btn-row' });
|
||||
|
||||
// Background-import note (hidden until import starts)
|
||||
this.progressWrap = btnRow.createDiv({ cls: 'wl-csv-progress-wrap' });
|
||||
this.progressWrap.hide();
|
||||
this.progressWrap.createSpan({ cls: 'wl-csv-bg-note', text: 'You can close this window — import will continue in the background.' });
|
||||
const progressTrack = this.progressWrap.createDiv({ cls: 'wl-csv-progress-track' });
|
||||
this.progressBarFill = progressTrack.createDiv({ cls: 'wl-csv-progress-fill' });
|
||||
this.progressText = this.progressWrap.createSpan({ cls: 'wl-csv-progress-text', text: '0 / 0' });
|
||||
|
||||
this.cancelBtn = btnRow.createEl('button', { cls: 'wl-btn', text: 'Cancel' });
|
||||
this.importBtn = btnRow.createEl('button', {
|
||||
cls: 'wl-btn wl-btn-primary',
|
||||
text: 'Import selected',
|
||||
});
|
||||
this.importBtn.disabled = true;
|
||||
this.importBtn.hide();
|
||||
|
||||
this.cancelBtn.addEventListener('click', () => {
|
||||
if (this.isImporting) {
|
||||
this.importCancelled = true;
|
||||
if (this.cancelBtn) {
|
||||
this.cancelBtn.disabled = true;
|
||||
this.cancelBtn.textContent = 'Cancelling…';
|
||||
}
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
this.importBtn.addEventListener('click', () => void this.doImport());
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result as string;
|
||||
this.csvLines = text.split('\n');
|
||||
const headerLine = this.csvLines[0] ?? '';
|
||||
this.csvHeaders = parseCsvRow(headerLine).map((h) => h.trim());
|
||||
this.columnMapping = autoMap(this.csvHeaders);
|
||||
this.showMappingStep();
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Step: Column Mapping ──────────────────────────────────────────────────────
|
||||
|
||||
private showMappingStep(): void {
|
||||
if (!this.stepUpload || !this.stepMapping) return;
|
||||
this.stepUpload.hide();
|
||||
this.stepMapping.show();
|
||||
this.renderMappingStep(this.stepMapping);
|
||||
}
|
||||
|
||||
private renderMappingStep(el: HTMLElement): void {
|
||||
el.empty();
|
||||
el.createDiv({ cls: 'wl-csv-step-title', text: 'Map your columns' });
|
||||
el.createDiv({
|
||||
cls: 'wl-csv-step-desc',
|
||||
text: 'Match each WatchLog field to a column from your CSV. Select "Skip" to leave a field empty.',
|
||||
});
|
||||
el.createDiv({
|
||||
cls: 'wl-csv-info-note',
|
||||
text: 'Rows with empty fields will be skipped automatically. If you see missing titles, check that your CSV has no empty rows at the top.',
|
||||
});
|
||||
|
||||
const SKIP_VALUE = '';
|
||||
const grid = el.createDiv({ cls: 'wl-csv-map-grid' });
|
||||
const selectEls: Record<string, HTMLSelectElement> = {};
|
||||
|
||||
for (const field of WL_IMPORT_FIELDS) {
|
||||
const row = grid.createDiv({ cls: 'wl-csv-map-row' });
|
||||
row.createSpan({ cls: 'wl-csv-map-label', text: field.label });
|
||||
const sel = row.createEl('select', { cls: 'wl-select wl-csv-map-select' });
|
||||
selectEls[field.key] = sel;
|
||||
sel.createEl('option', { value: SKIP_VALUE, text: '— skip —' });
|
||||
for (const col of this.csvHeaders) {
|
||||
sel.createEl('option', { value: col, text: col });
|
||||
}
|
||||
sel.value = this.columnMapping[field.key] ?? SKIP_VALUE;
|
||||
}
|
||||
|
||||
const previewBtn = el.createEl('button', {
|
||||
cls: 'wl-btn wl-btn-primary wl-csv-preview-btn',
|
||||
text: 'Next →',
|
||||
});
|
||||
previewBtn.addEventListener('click', () => {
|
||||
for (const field of WL_IMPORT_FIELDS) {
|
||||
this.columnMapping[field.key] = selectEls[field.key]?.value ?? '';
|
||||
}
|
||||
// Parse rows with current column mapping
|
||||
this.importRows = applyMapping(this.csvLines, this.columnMapping, this.dataManager)
|
||||
.map((r) => ({ ...r, selected: !r.isDuplicate }));
|
||||
|
||||
// Check for unknown status/type values and non-numeric ratings
|
||||
const existingStatusNames = new Set(this.plugin.settings.statuses.map((s) => s.name));
|
||||
const existingTypeNames = new Set(this.plugin.settings.types.map((t) => t.name));
|
||||
|
||||
const unknownStatuses: string[] = [];
|
||||
const unknownTypes: string[] = [];
|
||||
const unknownRatings: string[] = [];
|
||||
const seenStatuses = new Set<string>();
|
||||
const seenTypes = new Set<string>();
|
||||
const seenRatings = new Set<string>();
|
||||
|
||||
for (const r of this.importRows) {
|
||||
const sv = r.entry.status?.trim();
|
||||
if (sv && !existingStatusNames.has(sv) && !seenStatuses.has(sv)) {
|
||||
unknownStatuses.push(sv);
|
||||
seenStatuses.add(sv);
|
||||
}
|
||||
const tv = r.entry.type?.trim();
|
||||
if (tv && !existingTypeNames.has(tv) && !seenTypes.has(tv)) {
|
||||
unknownTypes.push(tv);
|
||||
seenTypes.add(tv);
|
||||
}
|
||||
// Detect non-numeric rating values (r.entry.rating would be 0 from parseFloat on non-numeric)
|
||||
const ratingColName = this.columnMapping['rating'];
|
||||
if (ratingColName) {
|
||||
const headerLine = this.csvLines[0] ?? '';
|
||||
const headers = parseCsvRow(headerLine).map((h) => h.trim());
|
||||
const ratingIdx = headers.indexOf(ratingColName);
|
||||
if (ratingIdx >= 0) {
|
||||
const lineIdx = this.importRows.indexOf(r);
|
||||
const rawLine = this.csvLines[lineIdx + 1] ?? '';
|
||||
const rawValues = parseCsvRow(rawLine);
|
||||
const rawRating = (rawValues[ratingIdx] ?? '').trim();
|
||||
if (rawRating && isNaN(parseFloat(rawRating)) && !seenRatings.has(rawRating)) {
|
||||
unknownRatings.push(rawRating);
|
||||
seenRatings.add(rawRating);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unknownStatuses.length > 0 || unknownTypes.length > 0 || unknownRatings.length > 0) {
|
||||
this.showValueMapStep(unknownStatuses, unknownTypes, unknownRatings);
|
||||
} else {
|
||||
this.showPreviewStep();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Step: Value Mapping ───────────────────────────────────────────────────────
|
||||
|
||||
private showValueMapStep(unknownStatuses: string[], unknownTypes: string[], unknownRatings: string[] = []): void {
|
||||
if (!this.stepMapping || !this.stepValueMap) return;
|
||||
this.stepMapping.hide();
|
||||
this.stepValueMap.show();
|
||||
this.renderValueMapStep(this.stepValueMap, unknownStatuses, unknownTypes, unknownRatings);
|
||||
}
|
||||
|
||||
private renderValueMapStep(el: HTMLElement, unknownStatuses: string[], unknownTypes: string[], unknownRatings: string[] = []): void {
|
||||
el.empty();
|
||||
el.createDiv({ cls: 'wl-csv-step-title', text: 'Map unknown values' });
|
||||
el.createDiv({
|
||||
cls: 'wl-csv-step-desc',
|
||||
text: 'Some values in your CSV were not recognized. Map each to an existing value, or choose how to handle it.',
|
||||
});
|
||||
|
||||
const existingStatuses = this.plugin.settings.statuses.map((s) => s.name);
|
||||
const existingTypes = this.plugin.settings.types.map((t) => t.name);
|
||||
const statusSelects: Record<string, HTMLSelectElement> = {};
|
||||
const typeSelects: Record<string, HTMLSelectElement> = {};
|
||||
const ratingSelects: Record<string, HTMLSelectElement> = {};
|
||||
|
||||
if (unknownStatuses.length > 0) {
|
||||
el.createDiv({ cls: 'wl-csv-valmap-section-title', text: 'Status' });
|
||||
for (const sv of unknownStatuses) {
|
||||
const row = el.createDiv({ cls: 'wl-csv-valmap-row' });
|
||||
row.createSpan({ cls: 'wl-csv-valmap-orig', text: `"${sv}"` });
|
||||
row.createSpan({ cls: 'wl-csv-valmap-arrow', text: '→' });
|
||||
const sel = row.createEl('select', { cls: 'wl-select wl-csv-valmap-select' });
|
||||
statusSelects[sv] = sel;
|
||||
sel.createEl('option', { value: '', text: '— leave blank —' });
|
||||
for (const name of existingStatuses) {
|
||||
sel.createEl('option', { value: name, text: name });
|
||||
}
|
||||
// Pre-fill with closest match (case-insensitive)
|
||||
const lc = sv.toLowerCase();
|
||||
const match = existingStatuses.find((s) => s.toLowerCase() === lc);
|
||||
if (match) sel.value = match;
|
||||
}
|
||||
}
|
||||
|
||||
if (unknownTypes.length > 0) {
|
||||
el.createDiv({ cls: 'wl-csv-valmap-section-title', text: 'Type' });
|
||||
for (const tv of unknownTypes) {
|
||||
const row = el.createDiv({ cls: 'wl-csv-valmap-row' });
|
||||
row.createSpan({ cls: 'wl-csv-valmap-orig', text: `"${tv}"` });
|
||||
row.createSpan({ cls: 'wl-csv-valmap-arrow', text: '→' });
|
||||
const sel = row.createEl('select', { cls: 'wl-select wl-csv-valmap-select' });
|
||||
typeSelects[tv] = sel;
|
||||
sel.createEl('option', { value: '', text: '— leave blank —' });
|
||||
for (const name of existingTypes) {
|
||||
sel.createEl('option', { value: name, text: name });
|
||||
}
|
||||
sel.createEl('option', { value: CREATE_NEW_TYPE, text: '+ create new type' });
|
||||
// Pre-fill with closest match
|
||||
const lc = tv.toLowerCase();
|
||||
const match = existingTypes.find((t) => t.toLowerCase() === lc);
|
||||
if (match) sel.value = match;
|
||||
else sel.value = CREATE_NEW_TYPE;
|
||||
}
|
||||
}
|
||||
|
||||
if (unknownRatings.length > 0) {
|
||||
el.createDiv({ cls: 'wl-csv-valmap-section-title', text: 'Rating' });
|
||||
for (const rv of unknownRatings) {
|
||||
const row = el.createDiv({ cls: 'wl-csv-valmap-row' });
|
||||
row.createSpan({ cls: 'wl-csv-valmap-orig', text: `"${rv}"` });
|
||||
row.createSpan({ cls: 'wl-csv-valmap-arrow', text: '→' });
|
||||
const sel = row.createEl('select', { cls: 'wl-select wl-csv-valmap-select' });
|
||||
ratingSelects[rv] = sel;
|
||||
sel.createEl('option', { value: '', text: '— leave blank —' });
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
sel.createEl('option', { value: String(i), text: `${i}/5` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const btnRow = el.createDiv({ cls: 'wl-csv-valmap-btn-row' });
|
||||
const backBtn = btnRow.createEl('button', { cls: 'wl-btn', text: '← back' });
|
||||
const confirmBtn = btnRow.createEl('button', {
|
||||
cls: 'wl-btn wl-btn-primary',
|
||||
text: 'Preview →',
|
||||
});
|
||||
|
||||
backBtn.addEventListener('click', () => {
|
||||
if (!this.stepValueMap || !this.stepMapping) return;
|
||||
this.stepValueMap.hide();
|
||||
this.stepMapping.show();
|
||||
});
|
||||
|
||||
confirmBtn.addEventListener('click', () => {
|
||||
// Collect mappings
|
||||
this.statusValueMap = {};
|
||||
for (const sv of unknownStatuses) {
|
||||
this.statusValueMap[sv] = statusSelects[sv]?.value ?? '';
|
||||
}
|
||||
this.typeValueMap = {};
|
||||
for (const tv of unknownTypes) {
|
||||
this.typeValueMap[tv] = typeSelects[tv]?.value ?? '';
|
||||
}
|
||||
this.ratingValueMap = {};
|
||||
for (const rv of unknownRatings) {
|
||||
this.ratingValueMap[rv] = ratingSelects[rv]?.value ?? '';
|
||||
}
|
||||
// Apply value maps to importRows
|
||||
this.applyValueMapsToRows();
|
||||
this.showPreviewStep();
|
||||
});
|
||||
}
|
||||
|
||||
private applyValueMapsToRows(): void {
|
||||
for (const r of this.importRows) {
|
||||
// Status
|
||||
const sv = r.entry.status?.trim() ?? '';
|
||||
if (sv && Object.prototype.hasOwnProperty.call(this.statusValueMap, sv)) {
|
||||
const resolved = this.statusValueMap[sv] ?? '';
|
||||
r.entry.status = resolved || undefined;
|
||||
}
|
||||
// Type — keep CREATE_NEW_TYPE sentinel; doImport will handle it
|
||||
const tv = r.entry.type?.trim() ?? '';
|
||||
if (tv && Object.prototype.hasOwnProperty.call(this.typeValueMap, tv)) {
|
||||
const resolved = this.typeValueMap[tv] ?? '';
|
||||
if (resolved === CREATE_NEW_TYPE) {
|
||||
// keep original value, mark for creation
|
||||
r.entry.type = tv;
|
||||
} else {
|
||||
r.entry.type = resolved || undefined;
|
||||
}
|
||||
}
|
||||
// Rating — map non-numeric value to numeric
|
||||
const rawRatingKey = Object.keys(this.ratingValueMap).find((k) => {
|
||||
// Find if this row's original CSV rating matched this key
|
||||
const ratingColName = this.columnMapping['rating'];
|
||||
if (!ratingColName) return false;
|
||||
const headerLine = this.csvLines[0] ?? '';
|
||||
const headers = parseCsvRow(headerLine).map((h) => h.trim());
|
||||
const ratingIdx = headers.indexOf(ratingColName);
|
||||
if (ratingIdx < 0) return false;
|
||||
const rowIdx = this.importRows.indexOf(r);
|
||||
const rawLine = this.csvLines[rowIdx + 1] ?? '';
|
||||
const rawValues = parseCsvRow(rawLine);
|
||||
return (rawValues[ratingIdx] ?? '').trim() === k;
|
||||
});
|
||||
if (rawRatingKey !== undefined) {
|
||||
const resolved = this.ratingValueMap[rawRatingKey] ?? '';
|
||||
r.entry.rating = resolved ? parseInt(resolved) : 0;
|
||||
}
|
||||
|
||||
// Re-check duplicate status after value resolution
|
||||
r.isDuplicate = !!this.dataManager.getTitles().find(
|
||||
(t) => t.title.toLowerCase() === (r.entry.title ?? '').toLowerCase()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step: Preview ─────────────────────────────────────────────────────────────
|
||||
|
||||
private showPreviewStep(): void {
|
||||
if (!this.stepValueMap || !this.stepMapping || !this.stepPreview || !this.importBtn) return;
|
||||
this.stepValueMap.hide();
|
||||
this.stepMapping.hide();
|
||||
this.stepPreview.show();
|
||||
this.importBtn.show();
|
||||
this.importBtn.disabled = this.importRows.filter((r) => r.selected).length === 0;
|
||||
this.renderPreviewStep(this.stepPreview);
|
||||
}
|
||||
|
||||
private renderPreviewStep(el: HTMLElement): void {
|
||||
el.empty();
|
||||
|
||||
const dupeCount = this.importRows.filter((r) => r.isDuplicate).length;
|
||||
if (dupeCount > 0) {
|
||||
el.createDiv({
|
||||
cls: 'wl-csv-dupe-warning',
|
||||
text: `${dupeCount} duplicate${dupeCount !== 1 ? 's' : ''} found (highlighted). These are deselected by default.`,
|
||||
});
|
||||
}
|
||||
|
||||
const ctrlRow = el.createDiv({ cls: 'wl-csv-ctrl-row' });
|
||||
const backBtn = ctrlRow.createEl('button', { cls: 'wl-btn', text: '← back' });
|
||||
const selectAllBtn = ctrlRow.createEl('button', { cls: 'wl-btn', text: 'Select all' });
|
||||
const selectNoneBtn = ctrlRow.createEl('button', { cls: 'wl-btn', text: 'Select none' });
|
||||
const countEl = ctrlRow.createSpan({ cls: 'wl-csv-count', text: '' });
|
||||
|
||||
backBtn.addEventListener('click', () => {
|
||||
if (!this.stepPreview || !this.stepValueMap || !this.stepMapping || !this.importBtn) return;
|
||||
this.stepPreview.hide();
|
||||
this.importBtn.hide();
|
||||
this.importBtn.disabled = true;
|
||||
// Go back to value-map step if it was shown, else column mapping
|
||||
const hadValueMap = Object.keys(this.statusValueMap).length > 0 || Object.keys(this.typeValueMap).length > 0 || Object.keys(this.ratingValueMap).length > 0;
|
||||
if (hadValueMap) {
|
||||
this.stepValueMap.show();
|
||||
} else {
|
||||
this.stepMapping.show();
|
||||
}
|
||||
});
|
||||
|
||||
const listEl = el.createDiv({ cls: 'wl-csv-list' });
|
||||
const checkboxes: HTMLInputElement[] = [];
|
||||
|
||||
const updateCount = (): void => {
|
||||
const sel = this.importRows.filter((r) => r.selected).length;
|
||||
countEl.textContent = `${sel} of ${this.importRows.length} selected`;
|
||||
if (this.importBtn) this.importBtn.disabled = sel === 0;
|
||||
};
|
||||
|
||||
for (let i = 0; i < this.importRows.length; i++) {
|
||||
const r = this.importRows[i]!;
|
||||
const row = listEl.createDiv({
|
||||
cls: `wl-csv-row${r.isDuplicate ? ' wl-csv-row-dupe' : ''}`,
|
||||
});
|
||||
const cb = row.createEl('input', { attr: { type: 'checkbox' } });
|
||||
cb.checked = r.selected;
|
||||
checkboxes.push(cb);
|
||||
row.createSpan({ cls: 'wl-csv-row-title', text: r.entry.title ?? '(no title)' });
|
||||
const metaParts = [r.entry.type, r.entry.status].filter(Boolean).join(' · ');
|
||||
if (metaParts) row.createSpan({ cls: 'wl-csv-row-meta', text: metaParts });
|
||||
if (r.isDuplicate) {
|
||||
row.createSpan({ cls: 'wl-csv-dupe-badge', text: 'duplicate' });
|
||||
}
|
||||
const idx = i;
|
||||
cb.addEventListener('change', () => {
|
||||
this.importRows[idx]!.selected = cb.checked;
|
||||
updateCount();
|
||||
});
|
||||
}
|
||||
|
||||
selectAllBtn.addEventListener('click', () => {
|
||||
this.importRows.forEach((r) => { r.selected = true; });
|
||||
checkboxes.forEach((cb) => { cb.checked = true; });
|
||||
updateCount();
|
||||
});
|
||||
|
||||
selectNoneBtn.addEventListener('click', () => {
|
||||
this.importRows.forEach((r) => { r.selected = false; });
|
||||
checkboxes.forEach((cb) => { cb.checked = false; });
|
||||
updateCount();
|
||||
});
|
||||
|
||||
updateCount();
|
||||
}
|
||||
|
||||
// ── Import ────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async doImport(): Promise<void> {
|
||||
const toImport = this.importRows.filter((r) => r.selected && r.entry.title?.trim());
|
||||
if (toImport.length === 0) {
|
||||
new Notice('No titles selected to import.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up progress UI
|
||||
this.isImporting = true;
|
||||
this.importCancelled = false;
|
||||
if (this.importBtn) this.importBtn.disabled = true;
|
||||
if (this.cancelBtn) this.cancelBtn.textContent = 'Cancel import';
|
||||
if (this.progressWrap) this.progressWrap.show();
|
||||
if (this.progressText) this.progressText.textContent = `0 / ${toImport.length}`;
|
||||
if (this.progressBarFill) this.progressBarFill.style.width = `0%`;
|
||||
|
||||
// Expose progress to the Watchlist header
|
||||
const cancelFn = (): void => { this.importCancelled = true; };
|
||||
this.plugin.importProgress = { current: 0, total: toImport.length, cancel: cancelFn };
|
||||
|
||||
// Auto-create types marked CREATE_NEW_TYPE or not in existing types
|
||||
const theme = this.plugin.settings.colorTheme ?? 'default';
|
||||
const existingTypeNames = new Set(this.plugin.settings.types.map((t) => t.name.toLowerCase()));
|
||||
const typesToCreate = new Set<string>();
|
||||
|
||||
for (const r of toImport) {
|
||||
const typeName = r.entry.type?.trim();
|
||||
if (typeName && !existingTypeNames.has(typeName.toLowerCase())) {
|
||||
typesToCreate.add(typeName);
|
||||
}
|
||||
}
|
||||
for (const [orig, resolved] of Object.entries(this.typeValueMap)) {
|
||||
if (resolved === CREATE_NEW_TYPE && !existingTypeNames.has(orig.toLowerCase())) {
|
||||
typesToCreate.add(orig);
|
||||
}
|
||||
}
|
||||
|
||||
if (typesToCreate.size > 0) {
|
||||
for (const typeName of typesToCreate) {
|
||||
this.plugin.settings.types.push({ name: typeName, color: randomAutoColor(theme) });
|
||||
}
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = 10;
|
||||
let added = 0;
|
||||
|
||||
for (let chunkStart = 0; chunkStart < toImport.length; chunkStart += CHUNK_SIZE) {
|
||||
if (this.importCancelled) break;
|
||||
|
||||
const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, toImport.length);
|
||||
for (let i = chunkStart; i < chunkEnd; i++) {
|
||||
if (this.importCancelled) break;
|
||||
|
||||
const r = toImport[i]!;
|
||||
const titleName = r.entry.title!.trim();
|
||||
const entry: WatchLogTitle = {
|
||||
id: this.dataManager.generateId(titleName),
|
||||
title: titleName,
|
||||
type: r.entry.type?.trim() || this.plugin.settings.types[0]?.name || 'Anime',
|
||||
status: r.entry.status || 'Plan to watch',
|
||||
priority: r.entry.priority || 'Medium',
|
||||
review: '',
|
||||
rating: r.entry.rating ?? 0,
|
||||
notes: '',
|
||||
dateStarted: r.entry.dateStarted ?? null,
|
||||
dateFinished: r.entry.dateFinished ?? null,
|
||||
dateAdded: new Date().toISOString(),
|
||||
dateModified: new Date().toISOString(),
|
||||
totalEpisodes: r.entry.totalEpisodes ?? 0,
|
||||
episodeDuration: r.entry.episodeDuration ?? 0,
|
||||
releaseDate: r.entry.releaseDate ?? null,
|
||||
externalLink: r.entry.externalLink ?? '',
|
||||
seasons: [],
|
||||
watchedEpisodes: [],
|
||||
};
|
||||
|
||||
if (entry.status === 'Completed' && entry.totalEpisodes > 0) {
|
||||
entry.watchedEpisodes = Array.from({ length: entry.totalEpisodes }, (_, k) => k + 1);
|
||||
}
|
||||
|
||||
this.plugin.importProgress = { current: added + 1, total: toImport.length, cancel: cancelFn };
|
||||
await this.dataManager.addTitleSilent(entry);
|
||||
added++;
|
||||
|
||||
const pct = Math.round((added / toImport.length) * 100);
|
||||
if (this.progressBarFill) this.progressBarFill.style.width = `${pct}%`;
|
||||
if (this.progressText) this.progressText.textContent = `${added} / ${toImport.length}`;
|
||||
}
|
||||
}
|
||||
|
||||
this.isImporting = false;
|
||||
|
||||
// Clear header progress bar and trigger a final UI refresh
|
||||
this.plugin.importProgress = null;
|
||||
this.dataManager.notifyChange();
|
||||
|
||||
const newTypesMsg = typesToCreate.size > 0
|
||||
? ` (${typesToCreate.size} new type${typesToCreate.size !== 1 ? 's' : ''} created)`
|
||||
: '';
|
||||
|
||||
if (this.importCancelled) {
|
||||
// Import was cancelled — stay open so user can see what was imported
|
||||
if (this.progressText) {
|
||||
this.progressText.textContent = `Cancelled — ${added} / ${toImport.length} imported`;
|
||||
}
|
||||
if (this.cancelBtn) {
|
||||
this.cancelBtn.disabled = false;
|
||||
this.cancelBtn.textContent = 'Close';
|
||||
}
|
||||
new Notice(`Import cancelled. ${added} title${added !== 1 ? 's' : ''} imported.`);
|
||||
} else {
|
||||
// All done — show success state, then close
|
||||
if (this.progressText) {
|
||||
this.progressText.textContent = `Done — ${added} imported`;
|
||||
}
|
||||
if (this.cancelBtn) {
|
||||
this.cancelBtn.disabled = false;
|
||||
this.cancelBtn.textContent = 'Close';
|
||||
}
|
||||
new Notice(`Imported ${added} title${added !== 1 ? 's' : ''}${newTypesMsg}.`);
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
1588
src/CustomListsTab.ts
Normal file
1588
src/CustomListsTab.ts
Normal file
File diff suppressed because it is too large
Load diff
396
src/DashboardTab.ts
Normal file
396
src/DashboardTab.ts
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
import type WatchLogPlugin from './main';
|
||||
import type { DataManager } from './DataManager';
|
||||
import type { TagDefinition } from './types';
|
||||
import { formatTime, getThemedColor } from './types';
|
||||
|
||||
const RING_CIRCUMFERENCE = 2 * Math.PI * 45; // r=45
|
||||
|
||||
export class DashboardTab {
|
||||
private container: HTMLElement;
|
||||
private plugin: WatchLogPlugin;
|
||||
private dataManager: DataManager;
|
||||
|
||||
constructor(container: HTMLElement, plugin: WatchLogPlugin, dataManager: DataManager) {
|
||||
this.container = container;
|
||||
this.plugin = plugin;
|
||||
this.dataManager = dataManager;
|
||||
}
|
||||
|
||||
render(): void {
|
||||
this.container.empty();
|
||||
this.container.addClass('wl-dashboard');
|
||||
|
||||
this.renderCards();
|
||||
this.renderSummaryMetrics();
|
||||
this.renderSuggestions();
|
||||
this.renderRecentlyWatched();
|
||||
this.renderRecentlyAdded();
|
||||
}
|
||||
|
||||
private renderCards(): 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');
|
||||
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);
|
||||
}
|
||||
|
||||
// One card per type
|
||||
for (const type of this.plugin.settings.types) {
|
||||
const stats = this.dataManager.getStatsByType(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);
|
||||
} else {
|
||||
this.renderRing(grid, type.name, type.color, percent, stats);
|
||||
}
|
||||
}
|
||||
|
||||
// Time Watched card
|
||||
const timeWatched = this.dataManager.getTotalTimeWatched();
|
||||
if (isRect) {
|
||||
this.renderTimeRect(grid, 'Time Watched', '#1D9E75', formatTime(timeWatched));
|
||||
} else {
|
||||
this.renderTimeRing(grid, 'Time Watched', '#1D9E75', formatTime(timeWatched));
|
||||
}
|
||||
|
||||
// Time Remaining card
|
||||
const timeRemaining = this.dataManager.getTotalTimeRemaining();
|
||||
if (isRect) {
|
||||
this.renderTimeRect(grid, 'Time Remaining', '#BA7517', formatTime(timeRemaining));
|
||||
} else {
|
||||
this.renderTimeRing(grid, 'Time Remaining', '#BA7517', formatTime(timeRemaining));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ring (circle) cards ───────────────────────────────────────────────────────
|
||||
|
||||
private renderRing(
|
||||
parent: HTMLElement,
|
||||
label: string,
|
||||
color: string,
|
||||
percent: number,
|
||||
stats: { watched: number; total: number },
|
||||
): void {
|
||||
const item = parent.createDiv({ cls: 'wl-ring-item' });
|
||||
|
||||
const svgNS = 'http://www.w3.org/2000/svg';
|
||||
const svg = document.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 = document.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 = document.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));
|
||||
const dashOffset = RING_CIRCUMFERENCE * (1 - percent / 100);
|
||||
arc.setAttribute('stroke-dashoffset', String(dashOffset));
|
||||
arc.setAttribute('transform', 'rotate(-90 60 60)');
|
||||
arc.addClass('wl-ring-arc');
|
||||
svg.appendChild(arc);
|
||||
|
||||
const text = document.createElementNS(svgNS, 'text');
|
||||
text.setAttribute('x', '60');
|
||||
text.setAttribute('y', '55');
|
||||
text.setAttribute('text-anchor', 'middle');
|
||||
text.setAttribute('dominant-baseline', 'middle');
|
||||
text.addClass('wl-ring-percent-text');
|
||||
text.textContent = `${percent}%`;
|
||||
svg.appendChild(text);
|
||||
|
||||
const subText = document.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);
|
||||
|
||||
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 = document.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 = document.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 = document.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 = document.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,
|
||||
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;
|
||||
topRow.createSpan({ cls: 'wl-rect-unwatched', text: `${unwatched} 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;
|
||||
}
|
||||
|
||||
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 {
|
||||
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));
|
||||
}
|
||||
|
||||
private renderMetricRow(parent: HTMLElement, label: string, value: string): void {
|
||||
const row = parent.createDiv({ cls: 'wl-metric-row' });
|
||||
row.createSpan({ cls: 'wl-metric-label', text: label });
|
||||
row.createSpan({ cls: 'wl-metric-value', text: value });
|
||||
}
|
||||
|
||||
// ── Suggestions ──────────────────────────────────────────────────────────────
|
||||
|
||||
private renderSuggestions(): void {
|
||||
const planTitles = this.dataManager.getTitles().filter((t) => t.status === 'Plan to watch');
|
||||
if (planTitles.length === 0) return;
|
||||
|
||||
const section = this.container.createDiv({ cls: 'wl-suggestions' });
|
||||
section.createDiv({ cls: 'wl-section-title', text: "Don't know what to watch next?" });
|
||||
|
||||
const grid = section.createDiv({ cls: 'wl-suggestions-grid' });
|
||||
|
||||
const shortestByType = (typeName: string): string | null => {
|
||||
const candidates = planTitles
|
||||
.filter((t) => t.type === typeName)
|
||||
.filter((t) => t.totalEpisodes > 0 || t.episodeDuration > 0);
|
||||
if (candidates.length === 0) {
|
||||
// fallback: any plan-to-watch of this type, no length filter
|
||||
const fallback = planTitles.filter((t) => t.type === typeName);
|
||||
return fallback[0]?.title ?? null;
|
||||
}
|
||||
candidates.sort((a, b) => {
|
||||
const aVal = a.totalEpisodes > 0 ? a.totalEpisodes * (a.episodeDuration || 24) : a.episodeDuration;
|
||||
const bVal = b.totalEpisodes > 0 ? b.totalEpisodes * (b.episodeDuration || 24) : b.episodeDuration;
|
||||
return aVal - bVal;
|
||||
});
|
||||
return candidates[0]?.title ?? null;
|
||||
};
|
||||
|
||||
// Fixed 2×2 grid: Anime, Movie, TV Show, Random
|
||||
const fixedTypes = ['Anime', 'Movie', 'TV Show'];
|
||||
for (const typeName of fixedTypes) {
|
||||
const pick = shortestByType(typeName);
|
||||
const card = grid.createDiv({ cls: 'wl-suggestion-card' });
|
||||
card.createDiv({ cls: 'wl-suggestion-label', text: `Shortest ${typeName}` });
|
||||
card.createDiv({
|
||||
cls: `wl-suggestion-title${pick ? '' : ' wl-suggestion-empty'}`,
|
||||
text: pick ?? 'Nothing planned',
|
||||
});
|
||||
}
|
||||
|
||||
// Random card (bottom-right, equal size)
|
||||
const randomCard = grid.createDiv({ cls: 'wl-suggestion-card' });
|
||||
const randomHeader = randomCard.createDiv({ cls: 'wl-suggestion-random-header' });
|
||||
randomHeader.createDiv({ cls: 'wl-suggestion-label', text: 'Random' });
|
||||
const shuffleBtn = randomHeader.createEl('button', { cls: 'wl-suggestion-shuffle', text: '🔀' });
|
||||
shuffleBtn.title = 'Pick another';
|
||||
|
||||
const getRandomTitle = (): string =>
|
||||
planTitles[Math.floor(Math.random() * planTitles.length)]?.title ?? '';
|
||||
const randomTitleEl = randomCard.createDiv({ cls: 'wl-suggestion-title', text: getRandomTitle() });
|
||||
|
||||
shuffleBtn.addEventListener('click', () => {
|
||||
randomTitleEl.textContent = getRandomTitle();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Recently watched ──────────────────────────────────────────────────────────
|
||||
|
||||
private renderRecentlyWatched(): void {
|
||||
const section = this.container.createDiv({ cls: 'wl-recently-watched' });
|
||||
section.createDiv({ cls: 'wl-section-title', text: 'Recently watched' });
|
||||
|
||||
const recent = this.dataManager.getRecentlyWatched(3);
|
||||
|
||||
if (recent.length === 0) {
|
||||
section.createDiv({ cls: 'wl-empty-state', text: 'No titles watched yet.' });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const title of recent) {
|
||||
const item = section.createDiv({ cls: 'wl-rw-item' });
|
||||
|
||||
item.createDiv({ cls: 'wl-rw-title', text: title.title });
|
||||
|
||||
const typeDef = this.getTagDef(title.type, this.plugin.settings.types);
|
||||
const colored = this.plugin.settings.coloredTypeBadges;
|
||||
const badgeWrap = item.createDiv({ cls: 'wl-rw-col-badge' });
|
||||
const badge = badgeWrap.createSpan({
|
||||
cls: colored ? 'wl-badge wl-badge-sm' : 'wl-badge-plain',
|
||||
text: title.type,
|
||||
});
|
||||
if (colored && typeDef) badge.style.backgroundColor = getThemedColor(title.type, typeDef.color, this.plugin.settings.colorTheme);
|
||||
|
||||
const epCol = item.createDiv({ cls: 'wl-rw-col-ep' });
|
||||
const isMovie = title.type === 'Movie';
|
||||
if (!isMovie) {
|
||||
const nextEp = this.dataManager.getNextUnwatchedEpisode(title);
|
||||
epCol.textContent = nextEp !== null ? `Ep ${nextEp}` : '✓';
|
||||
}
|
||||
|
||||
const pctCol = item.createDiv({ cls: 'wl-rw-col-pct' });
|
||||
if (isMovie) {
|
||||
pctCol.textContent = title.watchedEpisodes.includes(1) ? '100%' : '0%';
|
||||
} else {
|
||||
pctCol.textContent = `${this.dataManager.getProgress(title)}%`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recently added ────────────────────────────────────────────────────────────
|
||||
|
||||
private renderRecentlyAdded(): void {
|
||||
const section = this.container.createDiv({ cls: 'wl-recently-watched' });
|
||||
section.createDiv({ cls: 'wl-section-title', text: 'Recently added' });
|
||||
|
||||
const recent = this.dataManager.getRecentlyAdded(3);
|
||||
|
||||
if (recent.length === 0) {
|
||||
section.createDiv({ cls: 'wl-empty-state', text: 'No titles in your library yet.' });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const title of recent) {
|
||||
const item = section.createDiv({ cls: 'wl-rw-item' });
|
||||
|
||||
item.createDiv({ cls: 'wl-rw-title', text: title.title });
|
||||
|
||||
const typeDef = this.getTagDef(title.type, this.plugin.settings.types);
|
||||
const colored = this.plugin.settings.coloredTypeBadges;
|
||||
const badgeWrap = item.createDiv({ cls: 'wl-rw-col-badge' });
|
||||
const badge = badgeWrap.createSpan({
|
||||
cls: colored ? 'wl-badge wl-badge-sm' : 'wl-badge-plain',
|
||||
text: title.type,
|
||||
});
|
||||
if (colored && typeDef) badge.style.backgroundColor = getThemedColor(title.type, typeDef.color, this.plugin.settings.colorTheme);
|
||||
|
||||
const epCol = item.createDiv({ cls: 'wl-rw-col-ep' });
|
||||
const isMovie = title.type === 'Movie';
|
||||
if (!isMovie) {
|
||||
const nextEp = this.dataManager.getNextUnwatchedEpisode(title);
|
||||
epCol.textContent = nextEp !== null ? `Ep ${nextEp}` : '✓';
|
||||
}
|
||||
|
||||
const pctCol = item.createDiv({ cls: 'wl-rw-col-pct' });
|
||||
if (isMovie) {
|
||||
pctCol.textContent = title.watchedEpisodes.includes(1) ? '100%' : '0%';
|
||||
} else {
|
||||
pctCol.textContent = `${this.dataManager.getProgress(title)}%`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getTagDef(name: string, tags: TagDefinition[]): TagDefinition | undefined {
|
||||
return tags.find((t) => t.name === name);
|
||||
}
|
||||
}
|
||||
697
src/DataManager.ts
Normal file
697
src/DataManager.ts
Normal file
|
|
@ -0,0 +1,697 @@
|
|||
import { App, TFile, normalizePath } from 'obsidian';
|
||||
import type WatchLogPlugin from './main';
|
||||
import type { WatchLogData, WatchLogTitle, WatchLogGroup, AirtimeEntry, MaybeTitle, SavedFilterPreset } from './types';
|
||||
import type { HistoryManager } from './HistoryManager';
|
||||
import { formatHistoryDate } from './HistoryManager';
|
||||
|
||||
export class DataManager {
|
||||
private plugin: WatchLogPlugin;
|
||||
private app: App;
|
||||
private data: WatchLogData;
|
||||
private changeListeners: Array<() => void> = [];
|
||||
private historyManager: HistoryManager | null = null;
|
||||
|
||||
setHistoryManager(hm: HistoryManager): void {
|
||||
this.historyManager = hm;
|
||||
}
|
||||
|
||||
constructor(plugin: WatchLogPlugin) {
|
||||
this.plugin = plugin;
|
||||
this.app = plugin.app;
|
||||
this.data = { titles: [], groups: [], settings: {} };
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
const loaded = (await this.plugin.loadData()) as WatchLogData | null;
|
||||
if (loaded) {
|
||||
this.data = loaded;
|
||||
} else {
|
||||
this.data = { titles: [], groups: [], settings: {} };
|
||||
}
|
||||
this.migrateData();
|
||||
}
|
||||
|
||||
private migrateData(): void {
|
||||
// Ensure core arrays exist (data.json may be missing or partial on first install)
|
||||
if (!Array.isArray(this.data.titles)) {
|
||||
this.data.titles = [];
|
||||
}
|
||||
if (!Array.isArray((this.data as unknown as Record<string, unknown>)['groups'])) {
|
||||
this.data.groups = [];
|
||||
}
|
||||
|
||||
// Ensure airtime array exists
|
||||
if (!Array.isArray(this.data.airtime)) {
|
||||
this.data.airtime = [];
|
||||
}
|
||||
|
||||
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();
|
||||
} else if (!title.dateAdded.includes('T')) {
|
||||
title.dateAdded = new Date(title.dateAdded).toISOString();
|
||||
}
|
||||
|
||||
// Migrate lastInteracted → dateModified
|
||||
if (!title.dateModified) {
|
||||
const raw = title as unknown as Record<string, unknown>;
|
||||
const lastInteracted = raw['lastInteracted'];
|
||||
if (typeof lastInteracted === 'string' && lastInteracted) {
|
||||
title.dateModified = lastInteracted;
|
||||
} else {
|
||||
title.dateModified = title.dateAdded;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async saveOnly(): Promise<void> {
|
||||
await this.plugin.saveData(this.data);
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
await this.saveOnly();
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
getTitles(): WatchLogTitle[] {
|
||||
return this.data.titles ?? [];
|
||||
}
|
||||
|
||||
getTitle(id: string): WatchLogTitle | undefined {
|
||||
return (this.data.titles ?? []).find((t) => t.id === id);
|
||||
}
|
||||
|
||||
async addTitle(title: WatchLogTitle): Promise<void> {
|
||||
const autoUpcoming = this.canAutoAddToUpcoming(title);
|
||||
if (autoUpcoming) {
|
||||
title.status = 'To be released';
|
||||
}
|
||||
this.data.titles.push(title);
|
||||
await this.save();
|
||||
if (autoUpcoming) {
|
||||
await this.autoAddToUpcoming(title);
|
||||
}
|
||||
await this.updateMarkdownFile(title);
|
||||
const now = new Date().toISOString();
|
||||
void this.historyManager?.log(`${title.title} (${title.type}) was added on ${formatHistoryDate(now)}`);
|
||||
}
|
||||
|
||||
/** Add without triggering a UI re-render. Caller must call notifyChange() after the batch. */
|
||||
async addTitleSilent(title: WatchLogTitle): Promise<void> {
|
||||
const autoUpcoming = this.canAutoAddToUpcoming(title);
|
||||
if (autoUpcoming) {
|
||||
title.status = 'To be released';
|
||||
}
|
||||
this.data.titles.push(title);
|
||||
await this.saveOnly();
|
||||
if (autoUpcoming) {
|
||||
await this.autoAddToUpcoming(title);
|
||||
}
|
||||
await this.updateMarkdownFile(title);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
const titlesToDelete: WatchLogTitle[] = [];
|
||||
|
||||
for (const id of ids) {
|
||||
const title = this.getTitle(id);
|
||||
if (title) titlesToDelete.push(title);
|
||||
this.data.titles = this.data.titles.filter((t) => t.id !== id);
|
||||
for (const group of this.data.groups) {
|
||||
group.titleIds = group.titleIds.filter((tid) => tid !== id);
|
||||
}
|
||||
if (this.data.airtime) {
|
||||
this.data.airtime = this.data.airtime.filter((e) => e.titleId !== id);
|
||||
}
|
||||
const d = this.data as unknown as Record<string, unknown>;
|
||||
if (d['collapsedSeasons']) {
|
||||
delete (d['collapsedSeasons'] as Record<string, number[]>)[id];
|
||||
}
|
||||
}
|
||||
|
||||
await this.save();
|
||||
|
||||
for (let i = 0; i < titlesToDelete.length; i += CHUNK_SIZE) {
|
||||
const chunk = titlesToDelete.slice(i, i + CHUNK_SIZE);
|
||||
await Promise.all(chunk.map((t) => this.deleteMarkdownFile(t)));
|
||||
}
|
||||
}
|
||||
|
||||
canAutoAddToUpcoming(title: WatchLogTitle): boolean {
|
||||
if (!title.releaseDate) return false;
|
||||
// Only auto-add when we have a full YYYY-MM-DD date
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(title.releaseDate)) return false;
|
||||
const release = new Date(title.releaseDate + 'T12:00:00');
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return release > today;
|
||||
}
|
||||
|
||||
async autoAddToUpcoming(title: WatchLogTitle): Promise<void> {
|
||||
if (!this.data.airtime) this.data.airtime = [];
|
||||
// Avoid duplicates
|
||||
if (this.data.airtime.some((e) => e.titleId === title.id)) return;
|
||||
const entry: import('./types').AirtimeEntry = {
|
||||
id: this.generateAirtimeId(title.id),
|
||||
titleId: title.id,
|
||||
schedule: {
|
||||
recurrence: 'once',
|
||||
releaseDate: title.releaseDate ?? undefined,
|
||||
},
|
||||
dateAdded: new Date().toISOString(),
|
||||
};
|
||||
this.data.airtime.push(entry);
|
||||
// Persist airtime entry
|
||||
await this.plugin.saveData(this.data);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
if ((this.data.airtime ?? []).length !== before) {
|
||||
await this.save();
|
||||
}
|
||||
}
|
||||
|
||||
async updateTitle(updated: WatchLogTitle): Promise<void> {
|
||||
updated.dateModified = new Date().toISOString();
|
||||
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)}`);
|
||||
}
|
||||
if (old.status !== updated.status && updated.status === 'Completed') {
|
||||
void this.historyManager?.log(
|
||||
`${updated.title} (${updated.type}) was marked as watched on ${formatHistoryDate(now)}`
|
||||
);
|
||||
}
|
||||
this.data.titles[idx] = updated;
|
||||
await this.save();
|
||||
await this.updateMarkdownFile(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)}`);
|
||||
}
|
||||
this.data.titles = this.data.titles.filter((t) => t.id !== id);
|
||||
// Remove from any groups
|
||||
for (const group of this.data.groups) {
|
||||
group.titleIds = group.titleIds.filter((tid) => tid !== id);
|
||||
}
|
||||
// Remove airtime entries for this title
|
||||
if (this.data.airtime) {
|
||||
this.data.airtime = this.data.airtime.filter((e) => e.titleId !== id);
|
||||
}
|
||||
// Remove collapsed seasons for this title
|
||||
const d = this.data as unknown as Record<string, unknown>;
|
||||
if (d['collapsedSeasons']) {
|
||||
delete (d['collapsedSeasons'] as Record<string, number[]>)[id];
|
||||
}
|
||||
await this.save();
|
||||
if (title) {
|
||||
await this.deleteMarkdownFile(title);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Group CRUD ────────────────────────────────────────────────────────────────
|
||||
|
||||
getGroups(): WatchLogGroup[] {
|
||||
return this.data.groups ?? [];
|
||||
}
|
||||
|
||||
getGroup(id: string): WatchLogGroup | undefined {
|
||||
return (this.data.groups ?? []).find((g) => g.id === id);
|
||||
}
|
||||
|
||||
async addGroup(group: WatchLogGroup): Promise<void> {
|
||||
this.data.groups.push(group);
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async updateGroup(updated: WatchLogGroup): Promise<void> {
|
||||
const idx = this.data.groups.findIndex((g) => g.id === updated.id);
|
||||
if (idx >= 0) {
|
||||
this.data.groups[idx] = updated;
|
||||
await this.save();
|
||||
}
|
||||
}
|
||||
|
||||
async removeGroup(id: string): Promise<void> {
|
||||
this.data.groups = this.data.groups.filter((g) => g.id !== id);
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async addTitleToGroup(groupId: string, titleId: string): Promise<void> {
|
||||
const group = this.getGroup(groupId);
|
||||
if (!group) return;
|
||||
if (!group.titleIds.includes(titleId)) {
|
||||
group.titleIds.push(titleId);
|
||||
await this.updateGroup(group);
|
||||
}
|
||||
}
|
||||
|
||||
getGroupedTitleIds(): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const group of (this.data.groups ?? [])) {
|
||||
for (const id of group.titleIds) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
generateGroupId(name: string): string {
|
||||
let id =
|
||||
'group-' +
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
const existing = (this.data.groups ?? []).map((g) => g.id);
|
||||
if (!existing.includes(id)) return id;
|
||||
let counter = 2;
|
||||
while (existing.includes(`${id}-${counter}`)) counter++;
|
||||
return `${id}-${counter}`;
|
||||
}
|
||||
|
||||
// ── Pinned group ──────────────────────────────────────────────────────────────
|
||||
|
||||
getPinnedGroupId(): string | null {
|
||||
return this.data.pinnedGroupId ?? null;
|
||||
}
|
||||
|
||||
async setPinnedGroupId(id: string | null): Promise<void> {
|
||||
this.data.pinnedGroupId = id;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
// ── Saved filter preset ───────────────────────────────────────────────────────
|
||||
|
||||
getSavedFilterPreset(): SavedFilterPreset | null {
|
||||
return this.data.savedFilterPreset ?? null;
|
||||
}
|
||||
|
||||
async setSavedFilterPreset(preset: SavedFilterPreset | null): Promise<void> {
|
||||
this.data.savedFilterPreset = preset;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
// ── Airtime CRUD ──────────────────────────────────────────────────────────────
|
||||
|
||||
getAirtimeEntries(): AirtimeEntry[] {
|
||||
return this.data.airtime ?? [];
|
||||
}
|
||||
|
||||
async addAirtimeEntry(entry: AirtimeEntry): Promise<void> {
|
||||
if (!this.data.airtime) this.data.airtime = [];
|
||||
this.data.airtime.push(entry);
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async updateAirtimeEntry(updated: AirtimeEntry): Promise<void> {
|
||||
if (!this.data.airtime) return;
|
||||
const idx = this.data.airtime.findIndex((e) => e.id === updated.id);
|
||||
if (idx >= 0) {
|
||||
this.data.airtime[idx] = updated;
|
||||
await this.save();
|
||||
}
|
||||
}
|
||||
|
||||
async removeAirtimeEntry(id: string): Promise<void> {
|
||||
this.data.airtime = (this.data.airtime ?? []).filter((e) => e.id !== id);
|
||||
await this.save();
|
||||
}
|
||||
|
||||
// ── Maybe CRUD ────────────────────────────────────────────────────────────────
|
||||
|
||||
getMaybeTitles(): MaybeTitle[] {
|
||||
return this.data.maybe ?? [];
|
||||
}
|
||||
|
||||
async addMaybeTitle(title: MaybeTitle): Promise<void> {
|
||||
if (!this.data.maybe) this.data.maybe = [];
|
||||
this.data.maybe.push(title);
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async removeMaybeTitle(id: string): Promise<void> {
|
||||
this.data.maybe = (this.data.maybe ?? []).filter((t) => t.id !== id);
|
||||
await this.save();
|
||||
}
|
||||
|
||||
generateMaybeId(titleName: string): string {
|
||||
let base = 'maybe-' + titleName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
const existing = (this.data.maybe ?? []).map((t) => t.id);
|
||||
if (!existing.includes(base)) return base;
|
||||
let counter = 2;
|
||||
while (existing.includes(`${base}-${counter}`)) counter++;
|
||||
return `${base}-${counter}`;
|
||||
}
|
||||
|
||||
generateAirtimeId(titleId: string): string {
|
||||
const base = `airtime-${titleId}`;
|
||||
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}`;
|
||||
}
|
||||
|
||||
// ── Time calculations ─────────────────────────────────────────────────────────
|
||||
|
||||
calcTimeWatched(title: WatchLogTitle): number {
|
||||
if (title.episodeDuration <= 0) return 0;
|
||||
// "To be released" titles haven't been watched yet
|
||||
if (title.status === 'To be released') return 0;
|
||||
if (title.type === 'Movie') {
|
||||
return title.watchedEpisodes.includes(1) ? title.episodeDuration : 0;
|
||||
}
|
||||
if (title.status === 'Completed') {
|
||||
return title.totalEpisodes * title.episodeDuration;
|
||||
}
|
||||
// Watching, Dropped, Plan to watch: only watched episodes
|
||||
return title.watchedEpisodes.length * title.episodeDuration;
|
||||
}
|
||||
|
||||
calcTimeRemaining(title: WatchLogTitle): number {
|
||||
if (title.episodeDuration <= 0) return 0;
|
||||
if (title.status === 'Dropped') return 0;
|
||||
// "To be released" doesn't count toward time remaining (not yet available)
|
||||
if (title.status === 'To be released') return 0;
|
||||
if (title.type === 'Movie') {
|
||||
return title.watchedEpisodes.includes(1) ? 0 : title.episodeDuration;
|
||||
}
|
||||
if (title.status === 'Plan to watch') {
|
||||
return Math.max(0, title.totalEpisodes - title.watchedEpisodes.length) * title.episodeDuration;
|
||||
}
|
||||
if (title.status === 'Watching') {
|
||||
const unwatched = Math.max(0, title.totalEpisodes - title.watchedEpisodes.length);
|
||||
return unwatched * title.episodeDuration;
|
||||
}
|
||||
// Completed or other: 0
|
||||
return 0;
|
||||
}
|
||||
|
||||
calcTimeRemainingForModal(title: WatchLogTitle): number {
|
||||
if (title.episodeDuration <= 0) return 0;
|
||||
if (title.status === 'To be released') return 0;
|
||||
if (title.type === 'Movie') {
|
||||
return title.watchedEpisodes.includes(1) ? 0 : title.episodeDuration;
|
||||
}
|
||||
if (title.status === 'Dropped') {
|
||||
return Math.max(0, title.totalEpisodes - title.watchedEpisodes.length) * title.episodeDuration;
|
||||
}
|
||||
if (title.status === 'Plan to watch') {
|
||||
return Math.max(0, title.totalEpisodes - title.watchedEpisodes.length) * title.episodeDuration;
|
||||
}
|
||||
if (title.status === 'Watching') {
|
||||
return Math.max(0, title.totalEpisodes - title.watchedEpisodes.length) * title.episodeDuration;
|
||||
}
|
||||
// Completed or other: 0
|
||||
return 0;
|
||||
}
|
||||
|
||||
getTotalTimeWatched(): number {
|
||||
return (this.data.titles ?? []).reduce((sum, t) => sum + this.calcTimeWatched(t), 0);
|
||||
}
|
||||
|
||||
getTotalTimeRemaining(): number {
|
||||
return (this.data.titles ?? []).reduce((sum, t) => sum + this.calcTimeRemaining(t), 0);
|
||||
}
|
||||
|
||||
// ── External change watcher ───────────────────────────────────────────────────
|
||||
|
||||
startWatchingExternalChanges(): void {
|
||||
// 'raw' fires when Obsidian Sync writes a file directly to disk (not via vault API).
|
||||
// It's a real runtime event but not declared in Obsidian's public type definitions.
|
||||
const ref = (
|
||||
this.app.vault as unknown as {
|
||||
on(event: 'raw', cb: (path: string) => void): import('obsidian').EventRef;
|
||||
}
|
||||
).on('raw', (path: string) => {
|
||||
if (path.endsWith('watchlog/data.json')) {
|
||||
void (async () => {
|
||||
await this.load();
|
||||
this.notifyListeners();
|
||||
})();
|
||||
}
|
||||
});
|
||||
this.plugin.registerEvent(ref);
|
||||
}
|
||||
|
||||
// ── Listeners ─────────────────────────────────────────────────────────────────
|
||||
|
||||
onChange(listener: () => void): void {
|
||||
this.changeListeners.push(listener);
|
||||
}
|
||||
|
||||
offChange(listener: () => void): void {
|
||||
this.changeListeners = this.changeListeners.filter((l) => l !== listener);
|
||||
}
|
||||
|
||||
/** Public trigger for external callers (e.g. CsvModal) that need to force a UI refresh. */
|
||||
notifyChange(): void {
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
private notifyListeners(): void {
|
||||
for (const listener of this.changeListeners) {
|
||||
listener();
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('watchlog-data-changed'));
|
||||
}
|
||||
|
||||
// ── Season collapse persistence ──────────────────────────────────────────────
|
||||
|
||||
getCollapsedSeasonsForTitle(titleId: string): Set<number> {
|
||||
const stored = (this.data as unknown as Record<string, unknown>)['collapsedSeasons'] as
|
||||
| Record<string, number[]>
|
||||
| undefined;
|
||||
return new Set<number>(stored?.[titleId] ?? []);
|
||||
}
|
||||
|
||||
async persistCollapsedSeasons(titleId: string, seasons: Set<number>): Promise<void> {
|
||||
const d = this.data as unknown as Record<string, unknown>;
|
||||
if (!d['collapsedSeasons']) d['collapsedSeasons'] = {};
|
||||
(d['collapsedSeasons'] as Record<string, number[]>)[titleId] = Array.from(seasons);
|
||||
await this.save();
|
||||
}
|
||||
|
||||
// ── Progress helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
getProgress(title: WatchLogTitle): number {
|
||||
if (title.totalEpisodes === 0) return 0;
|
||||
return Math.round((title.watchedEpisodes.length / title.totalEpisodes) * 100);
|
||||
}
|
||||
|
||||
getNextUnwatchedEpisode(title: WatchLogTitle): number | null {
|
||||
const watched = new Set(title.watchedEpisodes);
|
||||
for (let i = 1; i <= title.totalEpisodes; i++) {
|
||||
if (!watched.has(i)) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async markEpisodeWatched(id: string, episodeNumber: number, watched: boolean): Promise<void> {
|
||||
const title = this.getTitle(id);
|
||||
if (!title) return;
|
||||
|
||||
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)}`);
|
||||
} else {
|
||||
void this.historyManager?.log(`${title.title} (${title.type}) was marked as watched on ${formatHistoryDate(now)}`);
|
||||
}
|
||||
} else {
|
||||
title.watchedEpisodes = title.watchedEpisodes.filter((e) => e !== episodeNumber);
|
||||
}
|
||||
|
||||
if (
|
||||
this.plugin.settings.autoCompleteOnLastEpisode &&
|
||||
title.totalEpisodes > 0 &&
|
||||
title.watchedEpisodes.length >= title.totalEpisodes
|
||||
) {
|
||||
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) {
|
||||
title.status = 'Watching';
|
||||
title.dateFinished = null;
|
||||
}
|
||||
|
||||
await this.updateTitle(title);
|
||||
}
|
||||
|
||||
async markSeasonWatched(id: string, episodeNumbers: number[], watched: boolean): Promise<void> {
|
||||
const title = this.getTitle(id);
|
||||
if (!title) return;
|
||||
|
||||
if (watched) {
|
||||
const set = new Set(title.watchedEpisodes);
|
||||
for (const ep of episodeNumbers) set.add(ep);
|
||||
title.watchedEpisodes = Array.from(set).sort((a, b) => a - b);
|
||||
} else {
|
||||
const remove = new Set(episodeNumbers);
|
||||
title.watchedEpisodes = title.watchedEpisodes.filter((e) => !remove.has(e));
|
||||
}
|
||||
|
||||
if (
|
||||
this.plugin.settings.autoCompleteOnLastEpisode &&
|
||||
title.totalEpisodes > 0 &&
|
||||
title.watchedEpisodes.length >= title.totalEpisodes
|
||||
) {
|
||||
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) {
|
||||
title.status = 'Watching';
|
||||
title.dateFinished = null;
|
||||
}
|
||||
|
||||
await this.updateTitle(title);
|
||||
}
|
||||
|
||||
// ── Folder management ─────────────────────────────────────────────────────────
|
||||
|
||||
async ensureFolders(): Promise<void> {
|
||||
if (!this.plugin.settings.autoCreateFolders) return;
|
||||
const root = this.plugin.settings.rootFolder;
|
||||
await this.ensureFolder(root);
|
||||
for (const type of this.plugin.settings.types) {
|
||||
await this.ensureFolder(`${root}/${type.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
async ensureFolder(path: string): Promise<void> {
|
||||
const normalized = normalizePath(path);
|
||||
if (!this.app.vault.getAbstractFileByPath(normalized)) {
|
||||
try {
|
||||
await this.app.vault.createFolder(normalized);
|
||||
} catch {
|
||||
// folder may already exist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateMarkdownFile(title: WatchLogTitle): Promise<void> {
|
||||
const root = this.plugin.settings.rootFolder;
|
||||
const folderPath = normalizePath(`${root}/${title.type}`);
|
||||
await this.ensureFolder(folderPath);
|
||||
const safeTitle = title.title.replace(/[*"\\/<>:|?]/g, '-');
|
||||
const filePath = normalizePath(`${folderPath}/${safeTitle}.md`);
|
||||
const progress = this.getProgress(title);
|
||||
const content = this.buildMarkdownContent(title, progress);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async createMarkdownFileIfMissing(title: WatchLogTitle): Promise<boolean> {
|
||||
const root = this.plugin.settings.rootFolder;
|
||||
const folderPath = normalizePath(`${root}/${title.type}`);
|
||||
const safeTitle = title.title.replace(/[*"\\/<>:|?]/g, '-');
|
||||
const filePath = normalizePath(`${folderPath}/${safeTitle}.md`);
|
||||
if (this.app.vault.getAbstractFileByPath(filePath) instanceof TFile) return false;
|
||||
await this.ensureFolder(normalizePath(root));
|
||||
await this.ensureFolder(folderPath);
|
||||
const content = this.buildMarkdownContent(title, this.getProgress(title));
|
||||
await this.app.vault.create(filePath, content);
|
||||
return true;
|
||||
}
|
||||
|
||||
private buildMarkdownContent(title: WatchLogTitle, progress: number): string {
|
||||
return `---
|
||||
title: ${title.title}
|
||||
type: ${title.type}
|
||||
status: ${title.status}
|
||||
priority: ${title.priority}
|
||||
rating: ${title.rating}
|
||||
dateStarted: ${title.dateStarted ?? 'null'}
|
||||
dateFinished: ${title.dateFinished ?? 'null'}
|
||||
progress: ${progress}%
|
||||
totalEpisodes: ${title.totalEpisodes}
|
||||
externalLink: ${title.externalLink}
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
${title.notes}
|
||||
`;
|
||||
}
|
||||
|
||||
private async deleteMarkdownFile(title: WatchLogTitle): Promise<void> {
|
||||
const root = this.plugin.settings.rootFolder;
|
||||
const safeTitle = title.title.replace(/[*"\\/<>:|?]/g, '-');
|
||||
const filePath = normalizePath(`${root}/${title.type}/${safeTitle}.md`);
|
||||
const file = this.app.vault.getAbstractFileByPath(filePath);
|
||||
if (file instanceof TFile) {
|
||||
await this.app.fileManager.trashFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dashboard helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
getRecentlyWatched(count: number): WatchLogTitle[] {
|
||||
return [...(this.data.titles ?? [])]
|
||||
.filter((t) => t.watchedEpisodes.length > 0 || t.status === 'Completed')
|
||||
.sort((a, b) => b.dateModified.localeCompare(a.dateModified))
|
||||
.slice(0, count);
|
||||
}
|
||||
|
||||
getRecentlyAdded(count: number): WatchLogTitle[] {
|
||||
return [...(this.data.titles ?? [])]
|
||||
.sort((a, b) => b.dateAdded.localeCompare(a.dateAdded))
|
||||
.slice(0, count);
|
||||
}
|
||||
|
||||
getStatsByType(type: string): { watched: number; total: number } {
|
||||
const EXCLUDED = ['Dropped', 'To be released'];
|
||||
const all = this.data.titles ?? [];
|
||||
const titles = (type === 'All' ? all : all.filter((t) => t.type === type))
|
||||
.filter((t) => !EXCLUDED.includes(t.status));
|
||||
const total = titles.length;
|
||||
const watched = titles.filter((t) => t.status === 'Completed').length;
|
||||
return { watched, total };
|
||||
}
|
||||
|
||||
getCompletedCount(): number {
|
||||
return (this.data.titles ?? []).filter((t) => t.status === 'Completed').length;
|
||||
}
|
||||
|
||||
// ── ID generation ─────────────────────────────────────────────────────────────
|
||||
|
||||
generateId(title: string): string {
|
||||
let id = title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
const existing = (this.data.titles ?? []).map((t) => t.id);
|
||||
if (!existing.includes(id)) return id;
|
||||
let counter = 2;
|
||||
while (existing.includes(`${id}-${counter}`)) {
|
||||
counter++;
|
||||
}
|
||||
return `${id}-${counter}`;
|
||||
}
|
||||
}
|
||||
343
src/DraftsTab.ts
Normal file
343
src/DraftsTab.ts
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
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 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> {
|
||||
await this.loadPersistState();
|
||||
const entries = await this.scanVault();
|
||||
this.renderUI(entries);
|
||||
this.registerChangeListener();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
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) 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));
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ── Event listener ────────────────────────────────────────────────────────────
|
||||
|
||||
private registerChangeListener(): void {
|
||||
if (this.eventRef) {
|
||||
this.plugin.app.metadataCache.offref(this.eventRef);
|
||||
}
|
||||
this.eventRef = this.plugin.app.metadataCache.on('changed', (file) => {
|
||||
const tag = this.getTag();
|
||||
const cache = this.plugin.app.metadataCache.getFileCache(file);
|
||||
const hasTag = cache?.tags?.some((t) => t.tag === tag) ?? false;
|
||||
if (!hasTag) return;
|
||||
void this.render();
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
// Pending = not added AND not already present in the Watchlist (exact or fuzzy)
|
||||
const pendingCount = entries.filter((e) => {
|
||||
if (e.added) return false;
|
||||
return !this.fuzzyMatchesWatchlist(e.titleDisplay, fuse);
|
||||
}).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 = a.added ? false : this.fuzzyMatchesWatchlist(a.titleDisplay, fuse);
|
||||
const bDup = b.added ? false : this.fuzzyMatchesWatchlist(b.titleDisplay, fuse);
|
||||
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, fuse);
|
||||
}
|
||||
}
|
||||
|
||||
private renderCard(cards: HTMLElement, entry: LiveDraftEntry, fuse: Fuse<WatchLogTitle>): void {
|
||||
const isDuplicate = entry.added ? false : this.fuzzyMatchesWatchlist(entry.titleDisplay, fuse);
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// ── 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();
|
||||
await this.render();
|
||||
}
|
||||
|
||||
private openAddModal(entry: LiveDraftEntry): void {
|
||||
const modal = new AddTitleModal(
|
||||
this.plugin.app,
|
||||
this.plugin,
|
||||
this.dataManager,
|
||||
() => void this.afterAdded(entry.titleKey),
|
||||
{
|
||||
searchQuery: 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();
|
||||
await this.render();
|
||||
}
|
||||
}
|
||||
}
|
||||
414
src/EditTitleModal.ts
Normal file
414
src/EditTitleModal.ts
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
import { App, Modal, Notice } from 'obsidian';
|
||||
import type WatchLogPlugin from './main';
|
||||
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[]. */
|
||||
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+)/);
|
||||
if (match && match[1] && match[2]) {
|
||||
const eps = parseInt(match[2]);
|
||||
seasons.push({ name: match[1].trim(), episodes: eps, offset });
|
||||
offset += eps;
|
||||
}
|
||||
}
|
||||
return seasons;
|
||||
}
|
||||
|
||||
function seasonsToText(seasons: Season[]): string {
|
||||
return seasons.map((s) => `${s.name}: ${s.episodes}`).join('\n');
|
||||
}
|
||||
|
||||
export class EditTitleModal extends Modal {
|
||||
private plugin: WatchLogPlugin;
|
||||
private dataManager: DataManager;
|
||||
private original: WatchLogTitle;
|
||||
private onSaved: () => void;
|
||||
|
||||
// Editable state (mirrors all WatchLogTitle fields)
|
||||
private fieldTitle: string;
|
||||
private fieldType: string;
|
||||
private fieldEpisodes: number;
|
||||
private fieldDuration: number;
|
||||
private fieldReleaseDate: string;
|
||||
private fieldLink: string;
|
||||
private fieldSeasonsText: string;
|
||||
private fieldStatus: string;
|
||||
private fieldPriority: string;
|
||||
private fieldReview: string;
|
||||
private fieldRating: number;
|
||||
private fieldNotes: string;
|
||||
private fieldDateStarted: string;
|
||||
private fieldDateFinished: string;
|
||||
private skipDuplicateCheck = false;
|
||||
private duplicateWarningEl: HTMLElement | null = null;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: WatchLogPlugin,
|
||||
dataManager: DataManager,
|
||||
title: WatchLogTitle,
|
||||
onSaved: () => void,
|
||||
) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.dataManager = dataManager;
|
||||
this.original = title;
|
||||
this.onSaved = onSaved;
|
||||
|
||||
// Pre-populate from existing title
|
||||
this.fieldTitle = title.title;
|
||||
this.fieldType = title.type;
|
||||
this.fieldEpisodes = title.totalEpisodes;
|
||||
this.fieldDuration = title.episodeDuration;
|
||||
this.fieldReleaseDate = title.releaseDate ?? '';
|
||||
this.fieldLink = title.externalLink;
|
||||
this.fieldSeasonsText = seasonsToText(title.seasons);
|
||||
this.fieldStatus = title.status;
|
||||
this.fieldPriority = title.priority;
|
||||
this.fieldReview = (title as unknown as { review?: string }).review ?? '';
|
||||
this.fieldRating = title.rating;
|
||||
this.fieldNotes = title.notes;
|
||||
this.fieldDateStarted = title.dateStarted ?? '';
|
||||
this.fieldDateFinished = title.dateFinished ?? '';
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
this.titleEl.setText(`Edit: ${this.original.title}`);
|
||||
this.contentEl.addClass('wl-add-modal');
|
||||
this.buildUI();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
|
||||
private buildUI(): void {
|
||||
const content = this.contentEl;
|
||||
content.empty();
|
||||
|
||||
const makeRow = (label: string): HTMLElement => {
|
||||
const row = content.createDiv({ cls: 'wl-modal-row' });
|
||||
row.createSpan({ cls: 'wl-modal-label', text: label });
|
||||
return row;
|
||||
};
|
||||
|
||||
// Title
|
||||
const titleRow = makeRow('Title');
|
||||
const titleInput = titleRow.createEl('input', {
|
||||
cls: 'wl-modal-input',
|
||||
attr: { type: 'text' },
|
||||
});
|
||||
titleInput.value = this.fieldTitle;
|
||||
titleInput.addEventListener('input', () => { this.fieldTitle = titleInput.value; });
|
||||
|
||||
// Type
|
||||
const typeRow = makeRow('Type');
|
||||
const typeSelect = typeRow.createEl('select', { cls: 'wl-select' });
|
||||
for (const t of this.plugin.settings.types) {
|
||||
const opt = typeSelect.createEl('option', { text: t.name, value: t.name });
|
||||
if (t.name === this.fieldType) opt.selected = true;
|
||||
}
|
||||
typeSelect.addEventListener('change', () => { this.fieldType = typeSelect.value; });
|
||||
|
||||
// Status — "To be released" is auto-assigned; show it only if title currently has it
|
||||
const statusRow = makeRow('Status');
|
||||
const statusSelect = statusRow.createEl('select', { cls: 'wl-select' });
|
||||
const showToBeReleased = this.fieldStatus === 'To be released';
|
||||
for (const s of this.plugin.settings.statuses) {
|
||||
if (s.name === 'To be released' && !showToBeReleased) continue;
|
||||
const opt = statusSelect.createEl('option', { text: s.name, value: s.name });
|
||||
if (s.name === this.fieldStatus) opt.selected = true;
|
||||
}
|
||||
statusSelect.addEventListener('change', () => { this.fieldStatus = statusSelect.value; });
|
||||
|
||||
// Priority
|
||||
const priRow = makeRow('Priority');
|
||||
const priSelect = priRow.createEl('select', { cls: 'wl-select' });
|
||||
for (const p of this.plugin.settings.priorities) {
|
||||
const opt = priSelect.createEl('option', { text: p.name, value: p.name });
|
||||
if (p.name === this.fieldPriority) opt.selected = true;
|
||||
}
|
||||
priSelect.addEventListener('change', () => { this.fieldPriority = priSelect.value; });
|
||||
|
||||
// Rating
|
||||
const ratingRow = makeRow('Rating');
|
||||
const starsWrap = ratingRow.createDiv({ cls: 'wl-stars' });
|
||||
const updateStarDisplay = (): void => {
|
||||
starsWrap.empty();
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const star = starsWrap.createSpan({
|
||||
cls: `wl-star${this.fieldRating >= i ? ' is-active' : ''}`,
|
||||
text: '★',
|
||||
});
|
||||
star.addEventListener('click', () => {
|
||||
this.fieldRating = this.fieldRating === i ? 0 : i;
|
||||
updateStarDisplay();
|
||||
});
|
||||
}
|
||||
};
|
||||
updateStarDisplay();
|
||||
|
||||
// Episodes
|
||||
const epsRow = makeRow('Total episodes');
|
||||
const epsInput = epsRow.createEl('input', {
|
||||
cls: 'wl-modal-input wl-modal-input-sm',
|
||||
attr: { type: 'number', min: '0' },
|
||||
});
|
||||
epsInput.value = String(this.fieldEpisodes);
|
||||
epsInput.addEventListener('input', () => { this.fieldEpisodes = parseInt(epsInput.value) || 0; });
|
||||
|
||||
// Duration
|
||||
const durRow = makeRow('Ep. duration (min)');
|
||||
const durInput = durRow.createEl('input', {
|
||||
cls: 'wl-modal-input wl-modal-input-sm',
|
||||
attr: { type: 'number', min: '0' },
|
||||
});
|
||||
durInput.value = String(this.fieldDuration);
|
||||
durInput.addEventListener('input', () => { this.fieldDuration = parseInt(durInput.value) || 0; });
|
||||
|
||||
// Release date
|
||||
const relRow = makeRow('Release date');
|
||||
const relStack = relRow.createDiv({ cls: 'wl-modal-input-stack' });
|
||||
const relInput = relStack.createEl('input', {
|
||||
cls: 'wl-modal-input',
|
||||
// eslint-disable-next-line obsidianmd/ui/sentence-case
|
||||
attr: { type: 'text', placeholder: 'DD/MM/YYYY or YYYY-MM-DD' },
|
||||
});
|
||||
relInput.value = this.fieldReleaseDate;
|
||||
const relErrorEl = relStack.createDiv({ cls: 'wl-modal-error wl-hidden' });
|
||||
relInput.addEventListener('change', () => {
|
||||
const raw = relInput.value.trim();
|
||||
if (!raw) {
|
||||
this.fieldReleaseDate = '';
|
||||
relErrorEl.addClass('wl-hidden');
|
||||
return;
|
||||
}
|
||||
const parsed = parseReleaseDateInput(raw);
|
||||
if (parsed) {
|
||||
this.fieldReleaseDate = parsed;
|
||||
relInput.value = parsed;
|
||||
relErrorEl.addClass('wl-hidden');
|
||||
} else {
|
||||
this.fieldReleaseDate = raw;
|
||||
// eslint-disable-next-line obsidianmd/ui/sentence-case
|
||||
relErrorEl.textContent = 'Unrecognised format. Expected DD/MM/YYYY or YYYY-MM-DD.';
|
||||
relErrorEl.removeClass('wl-hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// External link
|
||||
const linkRow = makeRow('External link');
|
||||
const linkInput = linkRow.createEl('input', {
|
||||
cls: 'wl-modal-input',
|
||||
attr: { type: 'url', placeholder: 'HTTPS://...' },
|
||||
});
|
||||
linkInput.value = this.fieldLink;
|
||||
linkInput.addEventListener('input', () => { this.fieldLink = linkInput.value; });
|
||||
|
||||
// Date started
|
||||
const startRow = makeRow('Date started');
|
||||
const startInput = startRow.createEl('input', {
|
||||
cls: 'wl-modal-input',
|
||||
attr: { type: 'text', placeholder: 'Dd/mm/yyyy', maxlength: '10' },
|
||||
});
|
||||
startInput.value = formatDateDisplay(this.fieldDateStarted);
|
||||
startInput.addEventListener('change', () => {
|
||||
const parsed = parseDateInput(startInput.value);
|
||||
if (startInput.value.trim() && !parsed) {
|
||||
startInput.addClass('wl-input-error');
|
||||
} else {
|
||||
startInput.removeClass('wl-input-error');
|
||||
this.fieldDateStarted = parsed ?? '';
|
||||
}
|
||||
});
|
||||
|
||||
// Date finished
|
||||
const finRow = makeRow('Date finished');
|
||||
const finInput = finRow.createEl('input', {
|
||||
cls: 'wl-modal-input',
|
||||
attr: { type: 'text', placeholder: 'Dd/mm/yyyy', maxlength: '10' },
|
||||
});
|
||||
finInput.value = formatDateDisplay(this.fieldDateFinished);
|
||||
finInput.addEventListener('change', () => {
|
||||
const parsed = parseDateInput(finInput.value);
|
||||
if (finInput.value.trim() && !parsed) {
|
||||
finInput.addClass('wl-input-error');
|
||||
} else {
|
||||
finInput.removeClass('wl-input-error');
|
||||
this.fieldDateFinished = parsed ?? '';
|
||||
}
|
||||
});
|
||||
|
||||
// Notes
|
||||
const notesRow = makeRow('Notes');
|
||||
const notesInput = notesRow.createEl('textarea', {
|
||||
cls: 'wl-modal-textarea',
|
||||
attr: { rows: '3', placeholder: 'Your notes...' },
|
||||
});
|
||||
notesInput.value = this.fieldNotes;
|
||||
notesInput.addEventListener('input', () => { this.fieldNotes = notesInput.value; });
|
||||
|
||||
// Seasons
|
||||
const seasonsRow = makeRow('Seasons');
|
||||
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' },
|
||||
});
|
||||
seasonsInput.value = this.fieldSeasonsText;
|
||||
|
||||
const totalPreviewEl = seasonsHelp.createDiv({ cls: 'wl-modal-season-total' });
|
||||
const updateTotalPreview = (): void => {
|
||||
const parsed = parseSeasonsText(seasonsInput.value);
|
||||
const total = parsed.reduce((sum, s) => sum + s.episodes, 0);
|
||||
totalPreviewEl.textContent = `Total episodes: ${total}`;
|
||||
};
|
||||
updateTotalPreview();
|
||||
|
||||
seasonsInput.addEventListener('input', () => {
|
||||
this.fieldSeasonsText = seasonsInput.value;
|
||||
updateTotalPreview();
|
||||
});
|
||||
|
||||
seasonsHelp.createDiv({
|
||||
cls: 'wl-modal-info',
|
||||
text: 'One per line: "Season Name: N" (e.g. "Season 1: 12")',
|
||||
});
|
||||
seasonsHelp.createDiv({
|
||||
cls: 'wl-modal-info',
|
||||
text: 'Note: when adding a new season, remember to update "Total Episodes" so progress calculates correctly.',
|
||||
});
|
||||
|
||||
// Save / Cancel
|
||||
const btnRow = content.createDiv({ cls: 'wl-modal-btn-row' });
|
||||
const cancelBtn = btnRow.createEl('button', { cls: 'wl-btn', text: 'Cancel' });
|
||||
cancelBtn.addEventListener('click', () => this.close());
|
||||
const saveBtn = btnRow.createEl('button', { cls: 'wl-btn wl-btn-primary', text: 'Save changes' });
|
||||
saveBtn.addEventListener('click', () => void this.saveTitle());
|
||||
}
|
||||
|
||||
private showDuplicateWarning(titleName: string, btnRow: HTMLElement): void {
|
||||
if (this.duplicateWarningEl) return;
|
||||
const warn = btnRow.createDiv({ cls: 'wl-duplicate-warning' });
|
||||
this.duplicateWarningEl = warn;
|
||||
warn.createSpan({ text: `"${titleName}" already exists. Save anyway?` });
|
||||
const continueBtn = warn.createEl('button', { cls: 'wl-btn wl-btn-primary', text: 'Continue' });
|
||||
const cancelBtn = warn.createEl('button', { cls: 'wl-btn', text: 'Cancel' });
|
||||
continueBtn.addEventListener('click', () => {
|
||||
this.skipDuplicateCheck = true;
|
||||
warn.remove();
|
||||
this.duplicateWarningEl = null;
|
||||
void this.saveTitle();
|
||||
});
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
warn.remove();
|
||||
this.duplicateWarningEl = null;
|
||||
this.skipDuplicateCheck = false;
|
||||
});
|
||||
}
|
||||
|
||||
private async saveTitle(): Promise<void> {
|
||||
const titleName = this.fieldTitle.trim();
|
||||
if (!titleName) {
|
||||
new Notice('Title name cannot be empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.skipDuplicateCheck) {
|
||||
const duplicate = this.dataManager.getTitles().find(
|
||||
(t) => t.id !== this.original.id && t.title.toLowerCase() === titleName.toLowerCase()
|
||||
);
|
||||
if (duplicate) {
|
||||
const btnRow = this.contentEl.querySelector<HTMLElement>('.wl-modal-btn-row');
|
||||
if (btnRow) this.showDuplicateWarning(titleName, btnRow);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.skipDuplicateCheck = false;
|
||||
|
||||
const seasons = parseSeasonsText(this.fieldSeasonsText);
|
||||
const newReleaseDate = this.fieldReleaseDate || null;
|
||||
|
||||
const updated: WatchLogTitle = {
|
||||
...this.original,
|
||||
title: titleName,
|
||||
type: this.fieldType,
|
||||
status: this.fieldStatus,
|
||||
priority: this.fieldPriority,
|
||||
review: this.fieldReview,
|
||||
rating: this.fieldRating,
|
||||
notes: this.fieldNotes,
|
||||
totalEpisodes: this.fieldEpisodes,
|
||||
episodeDuration: this.fieldDuration,
|
||||
releaseDate: newReleaseDate,
|
||||
externalLink: this.fieldLink,
|
||||
seasons,
|
||||
dateStarted: this.fieldDateStarted || null,
|
||||
dateFinished: this.fieldDateFinished || null,
|
||||
};
|
||||
|
||||
// Auto-mark all episodes watched when status is set to Completed
|
||||
if (updated.status === 'Completed' && updated.totalEpisodes > 0) {
|
||||
updated.watchedEpisodes = Array.from({ length: updated.totalEpisodes }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
await this.dataManager.updateTitle(updated);
|
||||
|
||||
// Bug 2: 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') {
|
||||
const newDateForEntry = newReleaseDate ?? undefined;
|
||||
if (existingAirtimeEntry.schedule.releaseDate !== newDateForEntry) {
|
||||
existingAirtimeEntry.schedule.releaseDate = newDateForEntry;
|
||||
await this.dataManager.updateAirtimeEntry(existingAirtimeEntry);
|
||||
}
|
||||
}
|
||||
|
||||
// ── handle releaseDate changes ───────────────────────────────────────────────
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const isFullDate = (d: string | null): d is string =>
|
||||
d !== null && /^\d{4}-\d{2}-\d{2}$/.test(d);
|
||||
|
||||
if (isFullDate(newReleaseDate)) {
|
||||
const releaseMs = new Date(newReleaseDate + 'T12:00:00').getTime();
|
||||
const isFuture = releaseMs > today.getTime();
|
||||
|
||||
if (isFuture) {
|
||||
if (updated.status !== 'To be released') {
|
||||
updated.status = 'To be released';
|
||||
await this.dataManager.updateTitle(updated);
|
||||
}
|
||||
// Auto-add to Upcoming if not already there
|
||||
const entries = this.dataManager.getAirtimeEntries();
|
||||
const alreadyInUpcoming = entries.some((e) => e.titleId === updated.id);
|
||||
if (!alreadyInUpcoming) {
|
||||
await this.dataManager.autoAddToUpcoming(updated);
|
||||
}
|
||||
} else {
|
||||
// Date is past/today — revert "To be released" status
|
||||
if (updated.status === 'To be released') {
|
||||
updated.status = 'Plan to watch';
|
||||
await this.dataManager.updateTitle(updated);
|
||||
await this.dataManager.removeAirtimeEntriesForTitle(updated.id);
|
||||
}
|
||||
}
|
||||
} else if (!newReleaseDate && updated.status === 'To be released') {
|
||||
// Date cleared — revert "To be released" status
|
||||
updated.status = 'Plan to watch';
|
||||
await this.dataManager.updateTitle(updated);
|
||||
await this.dataManager.removeAirtimeEntriesForTitle(updated.id);
|
||||
}
|
||||
|
||||
new Notice(`"${titleName}" updated.`);
|
||||
this.close();
|
||||
this.onSaved();
|
||||
}
|
||||
}
|
||||
76
src/HistoryManager.ts
Normal file
76
src/HistoryManager.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { normalizePath } from 'obsidian';
|
||||
import type WatchLogPlugin from './main';
|
||||
|
||||
export interface HistoryEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
|
||||
export function formatHistoryDate(isoTs: string): string {
|
||||
const d = new Date(isoTs);
|
||||
const day = DAYS[d.getDay()] ?? '';
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const yyyy = d.getFullYear();
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const min = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${day} ${dd}/${mm}/${yyyy} at ${hh}:${min}`;
|
||||
}
|
||||
|
||||
export class HistoryManager {
|
||||
private plugin: WatchLogPlugin;
|
||||
private entries: HistoryEntry[] = [];
|
||||
private readonly MAX_ENTRIES = 1000;
|
||||
|
||||
constructor(plugin: WatchLogPlugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
private get filePath(): string {
|
||||
return normalizePath(`${this.plugin.app.vault.configDir}/plugins/watchlog/history.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 { entries?: HistoryEntry[] };
|
||||
this.entries = Array.isArray(parsed.entries) ? parsed.entries : [];
|
||||
}
|
||||
} catch {
|
||||
this.entries = [];
|
||||
}
|
||||
}
|
||||
|
||||
async log(message: string): Promise<void> {
|
||||
const entry: HistoryEntry = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
};
|
||||
this.entries.push(entry);
|
||||
if (this.entries.length > this.MAX_ENTRIES) {
|
||||
this.entries = this.entries.slice(-this.MAX_ENTRIES);
|
||||
}
|
||||
await this.save();
|
||||
}
|
||||
|
||||
getEntries(): HistoryEntry[] {
|
||||
return [...this.entries].reverse();
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
try {
|
||||
await this.plugin.app.vault.adapter.write(
|
||||
this.filePath,
|
||||
JSON.stringify({ entries: this.entries }, null, 2),
|
||||
);
|
||||
} catch {
|
||||
// write errors are non-fatal
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/InsertWidgetModal.ts
Normal file
26
src/InsertWidgetModal.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { App, FuzzySuggestModal } from 'obsidian';
|
||||
import type { WatchLogTitle } from './types';
|
||||
|
||||
export class InsertWidgetModal extends FuzzySuggestModal<WatchLogTitle> {
|
||||
private onSelect: (title: WatchLogTitle) => void;
|
||||
private titles: WatchLogTitle[];
|
||||
|
||||
constructor(app: App, titles: WatchLogTitle[], onSelect: (title: WatchLogTitle) => void) {
|
||||
super(app);
|
||||
this.titles = titles;
|
||||
this.onSelect = onSelect;
|
||||
this.setPlaceholder('Search for a title to insert...');
|
||||
}
|
||||
|
||||
getItems(): WatchLogTitle[] {
|
||||
return this.titles;
|
||||
}
|
||||
|
||||
getItemText(title: WatchLogTitle): string {
|
||||
return `${title.title} (${title.type})`;
|
||||
}
|
||||
|
||||
onChooseItem(title: WatchLogTitle): void {
|
||||
this.onSelect(title);
|
||||
}
|
||||
}
|
||||
2069
src/ListTab.ts
Normal file
2069
src/ListTab.ts
Normal file
File diff suppressed because it is too large
Load diff
233
src/MaybeAddModal.ts
Normal file
233
src/MaybeAddModal.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
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';
|
||||
|
||||
type SearchResult = AnimeSearchResult | MediaSearchResult;
|
||||
|
||||
function isAnimeResult(r: SearchResult): r is AnimeSearchResult {
|
||||
return 'malId' in r;
|
||||
}
|
||||
|
||||
export class MaybeAddModal extends Modal {
|
||||
private plugin: WatchLogPlugin;
|
||||
private dataManager: DataManager;
|
||||
private onAdded: () => void;
|
||||
|
||||
private selectedType = 'Anime';
|
||||
private searchQuery = '';
|
||||
private searchResults: SearchResult[] = [];
|
||||
private isSearching = false;
|
||||
private autoSearch = false;
|
||||
|
||||
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;
|
||||
private searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: WatchLogPlugin,
|
||||
dataManager: DataManager,
|
||||
onAdded: () => void,
|
||||
) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.dataManager = dataManager;
|
||||
this.onAdded = onAdded;
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
// eslint-disable-next-line obsidianmd/ui/sentence-case
|
||||
this.titleEl.setText('Add to Maybe');
|
||||
this.contentEl.addClass('wl-add-modal');
|
||||
this.buildUI();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
if (this.searchDebounce) clearTimeout(this.searchDebounce);
|
||||
}
|
||||
|
||||
private buildUI(): void {
|
||||
const content = this.contentEl;
|
||||
content.empty();
|
||||
|
||||
const typeRow = content.createDiv({ cls: 'wl-modal-row' });
|
||||
typeRow.createSpan({ cls: 'wl-modal-label', text: 'Type' });
|
||||
const typeSelect = typeRow.createEl('select', { cls: 'wl-select' });
|
||||
for (const t of this.plugin.settings.types) {
|
||||
const opt = typeSelect.createEl('option', { text: t.name, value: t.name });
|
||||
if (t.name === this.selectedType) opt.selected = true;
|
||||
}
|
||||
typeSelect.addEventListener('change', () => {
|
||||
this.selectedType = typeSelect.value;
|
||||
this.searchResults = [];
|
||||
this.renderResults();
|
||||
this.renderForm();
|
||||
});
|
||||
|
||||
const searchRow = content.createDiv({ cls: 'wl-modal-row' });
|
||||
searchRow.createSpan({ cls: 'wl-modal-label', text: 'Search' });
|
||||
const searchInput = searchRow.createEl('input', {
|
||||
cls: 'wl-modal-input',
|
||||
attr: { type: 'text', placeholder: 'Search for a title...' },
|
||||
});
|
||||
searchInput.value = this.searchQuery;
|
||||
searchInput.addEventListener('input', () => {
|
||||
this.searchQuery = searchInput.value;
|
||||
if (this.searchDebounce) clearTimeout(this.searchDebounce);
|
||||
this.searchDebounce = setTimeout(() => void this.performSearch(), 600);
|
||||
});
|
||||
const searchBtn = searchRow.createEl('button', { cls: 'wl-btn', text: 'Search' });
|
||||
searchBtn.addEventListener('click', () => void this.performSearch());
|
||||
|
||||
this.resultsEl = content.createDiv({ cls: 'wl-modal-results' });
|
||||
this.formEl = content.createDiv({ cls: 'wl-modal-form' });
|
||||
this.renderForm();
|
||||
}
|
||||
|
||||
private async performSearch(): Promise<void> {
|
||||
if (!this.searchQuery.trim()) return;
|
||||
this.isSearching = true;
|
||||
this.renderResults();
|
||||
try {
|
||||
const api = this.plugin.apiService;
|
||||
const activeApi = this.plugin.settings.activeApi ?? 'OMDb';
|
||||
const isAnime = this.selectedType === 'Anime';
|
||||
const isMovie = this.selectedType === 'Movie';
|
||||
if (isAnime) {
|
||||
this.searchResults = await api.searchAnime(this.searchQuery);
|
||||
} else if (activeApi === 'TMDB') {
|
||||
this.searchResults = await api.searchTmdb(this.searchQuery, isMovie ? 'movie' : 'series');
|
||||
} else {
|
||||
this.searchResults = await api.searchOmdb(this.searchQuery, isMovie ? 'movie' : 'series');
|
||||
}
|
||||
} catch {
|
||||
new Notice('Search failed. Check your connection and API settings.');
|
||||
this.searchResults = [];
|
||||
} finally {
|
||||
this.isSearching = false;
|
||||
this.renderResults();
|
||||
}
|
||||
}
|
||||
|
||||
private renderResults(): void {
|
||||
if (!this.resultsEl) return;
|
||||
this.resultsEl.empty();
|
||||
if (this.isSearching) { this.resultsEl.createDiv({ cls: 'wl-modal-loading', text: 'Searching...' }); return; }
|
||||
if (!this.searchResults.length) return;
|
||||
this.searchResults.forEach((result, idx) => {
|
||||
const item = this.resultsEl!.createDiv({ cls: 'wl-result-item' });
|
||||
const epText = isAnimeResult(result) ? `${result.episodes} eps` : result.episodes > 0 ? `${result.episodes} eps` : result.mediaType;
|
||||
item.createDiv({ cls: 'wl-result-title', text: result.title });
|
||||
item.createDiv({ cls: 'wl-result-meta', text: `${epText} · ${result.releaseDate}` });
|
||||
item.dataset['idx'] = String(idx);
|
||||
item.addEventListener('click', () => void this.selectResult(result, item));
|
||||
});
|
||||
}
|
||||
|
||||
private async selectResult(result: SearchResult, itemEl: HTMLElement): Promise<void> {
|
||||
if (this.resultsEl) {
|
||||
for (const child of Array.from(this.resultsEl.children)) child.removeClass('is-selected');
|
||||
}
|
||||
itemEl.addClass('is-selected');
|
||||
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; }
|
||||
} 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; }
|
||||
} 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;
|
||||
}
|
||||
} catch {
|
||||
this.fieldTitle = result.title;
|
||||
this.fieldEpisodes = isAnimeResult(result) ? result.episodes : result.episodes;
|
||||
this.fieldDuration = isAnimeResult(result) ? result.duration : result.episodeDuration;
|
||||
this.fieldReleaseDate = result.releaseDate; this.fieldLink = result.url; this.fieldSeasons = result.seasons;
|
||||
}
|
||||
this.renderForm();
|
||||
}
|
||||
|
||||
private renderForm(): void {
|
||||
if (!this.formEl) return;
|
||||
this.formEl.empty();
|
||||
const makeRow = (label: string): HTMLElement => {
|
||||
const row = this.formEl!.createDiv({ cls: 'wl-modal-row' });
|
||||
row.createSpan({ cls: 'wl-modal-label', text: label });
|
||||
return row;
|
||||
};
|
||||
|
||||
const titleRow = makeRow('Title');
|
||||
const titleInput = titleRow.createEl('input', { cls: 'wl-modal-input', attr: { type: 'text', placeholder: 'Title name' } });
|
||||
titleInput.value = this.fieldTitle;
|
||||
titleInput.addEventListener('input', () => { this.fieldTitle = titleInput.value; });
|
||||
|
||||
const epsRow = makeRow('Episodes');
|
||||
const epsInput = epsRow.createEl('input', { cls: 'wl-modal-input wl-modal-input-sm', attr: { type: 'number', min: '0', placeholder: '0' } });
|
||||
epsInput.value = String(this.fieldEpisodes);
|
||||
epsInput.addEventListener('input', () => { this.fieldEpisodes = parseInt(epsInput.value) || 0; });
|
||||
|
||||
const durRow = makeRow('Ep. duration (min)');
|
||||
const durInput = durRow.createEl('input', { cls: 'wl-modal-input wl-modal-input-sm', attr: { type: 'number', min: '0', placeholder: '24' } });
|
||||
durInput.value = String(this.fieldDuration);
|
||||
durInput.addEventListener('input', () => { this.fieldDuration = parseInt(durInput.value) || 0; });
|
||||
|
||||
const relRow = makeRow('Release date');
|
||||
const relStack = relRow.createDiv({ cls: 'wl-modal-input-stack' });
|
||||
// eslint-disable-next-line obsidianmd/ui/sentence-case
|
||||
const relInput = relStack.createEl('input', { cls: 'wl-modal-input', attr: { type: 'text', placeholder: 'DD/MM/YYYY or YYYY-MM-DD' } });
|
||||
relInput.value = this.fieldReleaseDate;
|
||||
const relErrorEl = relStack.createDiv({ cls: 'wl-modal-error wl-hidden' });
|
||||
relInput.addEventListener('change', () => {
|
||||
const raw = relInput.value.trim();
|
||||
if (!raw) { this.fieldReleaseDate = ''; relErrorEl.addClass('wl-hidden'); return; }
|
||||
const parsed = parseReleaseDateInput(raw);
|
||||
if (parsed) { this.fieldReleaseDate = parsed; relInput.value = parsed; relErrorEl.addClass('wl-hidden'); }
|
||||
else { this.fieldReleaseDate = raw; relErrorEl.textContent = 'Unrecognised format.'; relErrorEl.removeClass('wl-hidden'); }
|
||||
});
|
||||
|
||||
const linkRow = makeRow('External link');
|
||||
const linkInput = linkRow.createEl('input', { cls: 'wl-modal-input', attr: { type: 'url', placeholder: 'https://example.com' } });
|
||||
linkInput.value = this.fieldLink;
|
||||
linkInput.addEventListener('input', () => { this.fieldLink = linkInput.value; });
|
||||
|
||||
const btnRow = this.formEl.createDiv({ cls: 'wl-modal-btn-row' });
|
||||
// eslint-disable-next-line obsidianmd/ui/sentence-case
|
||||
const addBtn = btnRow.createEl('button', { cls: 'wl-btn wl-btn-primary', text: 'Add to Maybe' });
|
||||
addBtn.addEventListener('click', () => void this.addMaybeTitle());
|
||||
}
|
||||
|
||||
private async addMaybeTitle(): Promise<void> {
|
||||
const titleName = this.fieldTitle.trim();
|
||||
if (!titleName) { new Notice('Please enter a title name.'); return; }
|
||||
|
||||
const entry: MaybeTitle = {
|
||||
id: this.dataManager.generateMaybeId(titleName),
|
||||
title: titleName,
|
||||
type: this.selectedType,
|
||||
releaseDate: this.fieldReleaseDate || null,
|
||||
externalLink: this.fieldLink,
|
||||
totalEpisodes: this.fieldEpisodes,
|
||||
episodeDuration: this.fieldDuration,
|
||||
dateAdded: new Date().toISOString(),
|
||||
};
|
||||
await this.dataManager.addMaybeTitle(entry);
|
||||
new Notice(`"${titleName}" added to Maybe.`);
|
||||
this.close();
|
||||
this.onAdded();
|
||||
}
|
||||
}
|
||||
928
src/SettingsTab.ts
Normal file
928
src/SettingsTab.ts
Normal file
|
|
@ -0,0 +1,928 @@
|
|||
import { App, Notice, PluginSettingTab, Setting } from 'obsidian';
|
||||
import type WatchLogPlugin from './main';
|
||||
import type { TagDefinition } from './types';
|
||||
import { CsvModal } from './CsvModal';
|
||||
import { WatchLogView } from './WatchLogView';
|
||||
import { CustomListManager, DefaultColumnsModal } from './CustomListsTab';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
|
||||
type SettingsSection = 'general' | 'api' | 'customize' | 'folders' | 'drafts' | 'custom-lists' | 'widgets' | 'quick-info';
|
||||
|
||||
export class WatchLogSettingsTab extends PluginSettingTab {
|
||||
private plugin: WatchLogPlugin;
|
||||
private activeSection: SettingsSection = 'general';
|
||||
|
||||
constructor(app: App, plugin: WatchLogPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
containerEl.addClass('wl-settings');
|
||||
|
||||
// Nav bar
|
||||
const nav = containerEl.createDiv({ cls: 'wl-settings-nav' });
|
||||
const sections: { key: SettingsSection; label: string }[] = [
|
||||
{ key: 'general', label: 'General' },
|
||||
{ key: 'api', label: 'API' },
|
||||
{ key: 'customize', label: 'Customize' },
|
||||
{ key: 'folders', label: 'Folders' },
|
||||
{ key: 'drafts', label: 'Drafts' },
|
||||
{ key: 'custom-lists', label: 'Custom Lists' },
|
||||
{ key: 'widgets', label: 'Widgets' },
|
||||
{ key: 'quick-info', label: 'Quick Info' },
|
||||
];
|
||||
|
||||
const buttons: Map<SettingsSection, HTMLButtonElement> = new Map();
|
||||
for (const sec of sections) {
|
||||
const btn = nav.createEl('button', {
|
||||
cls: `wl-settings-nav-btn${this.activeSection === sec.key ? ' is-active' : ''}`,
|
||||
text: sec.label,
|
||||
});
|
||||
buttons.set(sec.key, btn);
|
||||
btn.addEventListener('click', () => {
|
||||
this.activeSection = sec.key;
|
||||
buttons.forEach((b, k) => {
|
||||
if (k === sec.key) { b.addClass('is-active'); } else { b.removeClass('is-active'); }
|
||||
});
|
||||
body.empty();
|
||||
this.renderSection(body);
|
||||
});
|
||||
}
|
||||
|
||||
const body = containerEl.createDiv({ cls: 'wl-settings-body' });
|
||||
this.renderSection(body);
|
||||
}
|
||||
|
||||
private renderSection(body: HTMLElement): void {
|
||||
body.empty();
|
||||
switch (this.activeSection) {
|
||||
case 'general': this.renderGeneral(body); break;
|
||||
case 'api': this.renderApi(body); break;
|
||||
case 'customize': this.renderCustomize(body); break;
|
||||
case 'folders': this.renderFolders(body); break;
|
||||
case 'drafts': this.renderDrafts(body); break;
|
||||
case 'custom-lists': this.renderCustomLists(body); break;
|
||||
case 'widgets': this.renderWidgets(body); break;
|
||||
case 'quick-info': this.renderQuickInfo(body); break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── General ────────────────────────────────────────────────────────────────
|
||||
|
||||
private renderGeneral(el: HTMLElement): void {
|
||||
new Setting(el)
|
||||
.setName('Default view')
|
||||
.setDesc('Which tab opens when you launch the watchlog panel.')
|
||||
.addDropdown((d) =>
|
||||
d
|
||||
.addOptions({ dashboard: 'Dashboard', watchlist: 'Watchlist' })
|
||||
.setValue(this.plugin.settings.defaultView)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.defaultView = v as 'dashboard' | 'watchlist';
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(el)
|
||||
.setName('Dashboard card style')
|
||||
.setDesc('Choose between ring charts or rectangular stat cards on the dashboard.')
|
||||
.addDropdown((d) =>
|
||||
d
|
||||
.addOptions({ circles: 'Circles', rectangles: 'Rectangles' })
|
||||
.setValue(this.plugin.settings.dashboardCardStyle ?? 'circles')
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.dashboardCardStyle = v as 'circles' | 'rectangles';
|
||||
await this.plugin.saveSettings();
|
||||
document.dispatchEvent(new CustomEvent('watchlog-data-changed'));
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(el)
|
||||
.setName('Episode numbering')
|
||||
.setDesc('Absolute: episodes numbered 1→n across all seasons. Per season: each season restarts from 1 (display only, data is unchanged).')
|
||||
.addDropdown((d) =>
|
||||
d
|
||||
.addOptions({ absolute: 'Absolute', 'per-season': 'Per season' })
|
||||
.setValue(this.plugin.settings.episodeNumbering ?? 'absolute')
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.episodeNumbering = v as 'absolute' | 'per-season';
|
||||
await this.plugin.saveSettings();
|
||||
document.dispatchEvent(new CustomEvent('watchlog-data-changed'));
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(el)
|
||||
.setName('Auto-complete on last episode')
|
||||
.setDesc('Mark status as completed when all episodes are watched.')
|
||||
.addToggle((t) =>
|
||||
t
|
||||
.setValue(this.plugin.settings.autoCompleteOnLastEpisode)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.autoCompleteOnLastEpisode = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(el)
|
||||
.setName('Set finish date automatically')
|
||||
.setDesc("Record today's date as the finish date when a title is completed.")
|
||||
.addToggle((t) =>
|
||||
t
|
||||
.setValue(this.plugin.settings.setFinishDateAutomatically)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.setFinishDateAutomatically = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(el)
|
||||
.setName('Sync widget with watchlist')
|
||||
.setDesc('Changes in todo widget are reflected in the watchlist and vice versa.')
|
||||
.addToggle((t) =>
|
||||
t
|
||||
.setValue(this.plugin.settings.syncWidgetWithMainList)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.syncWidgetWithMainList = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(el)
|
||||
.setName('Colored type badges')
|
||||
.setDesc('Show type and status badges with their configured colors. Disable for a plain text style.')
|
||||
.addToggle((t) =>
|
||||
t
|
||||
.setValue(this.plugin.settings.coloredTypeBadges)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.coloredTypeBadges = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Backup & Restore ──────────────────────────────────────────────────────────
|
||||
el.createDiv({ cls: 'wl-settings-section-title', text: 'Backup & Restore' });
|
||||
|
||||
new Setting(el)
|
||||
.setName('Export backup')
|
||||
.setDesc('Export all watchlog data into a single timestamped .JSON file.')
|
||||
.addButton((b) =>
|
||||
b.setButtonText('Export backup').onClick(() => this.exportBackup()),
|
||||
);
|
||||
|
||||
new Setting(el)
|
||||
.setName('Restore from backup')
|
||||
.setDesc('Restore all watchlog data from a .JSON backup file. Current data will be replaced.')
|
||||
.addButton((b) =>
|
||||
b.setButtonText('Restore from backup').onClick(() => this.openRestoreDialog()),
|
||||
);
|
||||
|
||||
const regenHolder = {
|
||||
wrap: null as HTMLElement | null,
|
||||
fill: null as HTMLElement | null,
|
||||
text: null as HTMLElement | null,
|
||||
};
|
||||
|
||||
new Setting(el)
|
||||
.setName('Regenerate note files')
|
||||
.setDesc("Creates missing .md files for titles that don't have one. Existing files are not modified.")
|
||||
.addButton((b) =>
|
||||
b.setButtonText('Regenerate').onClick(() => {
|
||||
const { wrap, fill, text } = regenHolder;
|
||||
if (wrap && fill && text) void this.runRegenerate(wrap, fill, text);
|
||||
}),
|
||||
);
|
||||
|
||||
const regenProgressWrap = el.createDiv({ cls: 'wl-regen-progress-wrap' });
|
||||
regenProgressWrap.hide();
|
||||
regenProgressWrap.createSpan({ cls: 'wl-csv-bg-note', text: 'You can close settings — regeneration continues in the background.' });
|
||||
const regenProgressTrack = regenProgressWrap.createDiv({ cls: 'wl-csv-progress-track' });
|
||||
const regenProgressFill = regenProgressTrack.createDiv({ cls: 'wl-csv-progress-fill' });
|
||||
const regenProgressText = regenProgressWrap.createSpan({ cls: 'wl-csv-progress-text', text: '0 / 0' });
|
||||
|
||||
regenHolder.wrap = regenProgressWrap;
|
||||
regenHolder.fill = regenProgressFill;
|
||||
regenHolder.text = regenProgressText;
|
||||
|
||||
// ── Import / Export CSV ───────────────────────────────────────────────────────
|
||||
el.createDiv({ cls: 'wl-settings-section-title', text: 'Import / Export CSV' });
|
||||
|
||||
new Setting(el)
|
||||
.setName('Export to CSV')
|
||||
.setDesc('Export selected titles as a CSV file.')
|
||||
.addButton((b) =>
|
||||
b.setButtonText('Export CSV').onClick(() => {
|
||||
new CsvModal(this.app, this.plugin, this.plugin.dataManager, 'export').open();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(el)
|
||||
.setName('Import from CSV')
|
||||
.setDesc('Import titles from a CSV file. Duplicates are highlighted before import.')
|
||||
.addButton((b) =>
|
||||
b.setButtonText('Import CSV').onClick(() => {
|
||||
new CsvModal(this.app, this.plugin, this.plugin.dataManager, 'import').open();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private exportBackup(): void {
|
||||
const today = new Date();
|
||||
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
const filename = `watchlog-backup-${dateStr}.json`;
|
||||
|
||||
void this.plugin.loadData().then((data) => {
|
||||
const json = JSON.stringify(data ?? {}, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
new Notice(`Backup exported as ${filename}`);
|
||||
});
|
||||
}
|
||||
|
||||
private openRestoreDialog(): void {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.hide();
|
||||
document.body.appendChild(input);
|
||||
input.addEventListener('change', () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) { document.body.removeChild(input); return; }
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
document.body.removeChild(input);
|
||||
const text = e.target?.result as string;
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(text); } catch {
|
||||
new Notice('Invalid backup file — could not parse JSON.');
|
||||
return;
|
||||
}
|
||||
// Basic validation
|
||||
if (typeof parsed !== 'object' || parsed === null || !('titles' in parsed)) {
|
||||
new Notice('Invalid backup file — missing required fields.');
|
||||
return;
|
||||
}
|
||||
new ConfirmModal(this.app, 'This will replace all current watchlog data. Continue?', () => {
|
||||
void this.plugin.saveData(parsed).then(async () => {
|
||||
await this.plugin.loadSettings();
|
||||
await this.plugin.dataManager.load();
|
||||
document.dispatchEvent(new CustomEvent('watchlog-data-changed'));
|
||||
new Notice('Backup restored successfully.');
|
||||
});
|
||||
}).open();
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
input.click();
|
||||
}
|
||||
|
||||
private async runRegenerate(
|
||||
progressWrap: HTMLElement,
|
||||
progressBarFill: HTMLElement,
|
||||
progressText: HTMLElement,
|
||||
): Promise<void> {
|
||||
const titles = this.plugin.dataManager.getTitles();
|
||||
const total = titles.length;
|
||||
progressWrap.show();
|
||||
if (total === 0) {
|
||||
progressText.textContent = 'Done — no missing files found';
|
||||
return;
|
||||
}
|
||||
progressBarFill.style.width = `0%`;
|
||||
progressText.textContent = `0 / ${total}`;
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
for (let i = 0; i < titles.length; i++) {
|
||||
const wasCreated = await this.plugin.dataManager.createMarkdownFileIfMissing(titles[i]!);
|
||||
if (wasCreated) created++; else skipped++;
|
||||
const pct = Math.round(((i + 1) / total) * 100);
|
||||
progressBarFill.style.width = `${pct}%`;
|
||||
progressText.textContent = `${i + 1} / ${total}`;
|
||||
}
|
||||
if (created === 0) {
|
||||
progressText.textContent = 'Done — no missing files found';
|
||||
} else {
|
||||
progressText.textContent = `Done — ${created} file${created !== 1 ? 's' : ''} created, ${skipped} already existed`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── API ────────────────────────────────────────────────────────────────────
|
||||
|
||||
private renderApi(el: HTMLElement): void {
|
||||
el.createDiv({
|
||||
cls: 'wl-settings-info',
|
||||
text: 'Anime data uses the Jikan API (jikan.moe) — a free public MyAnimeList wrapper. No API key required.',
|
||||
});
|
||||
|
||||
// Active API selector
|
||||
new Setting(el)
|
||||
.setName('Active API')
|
||||
.setDesc('Which API to use for movies and tv shows.')
|
||||
.addDropdown((d) =>
|
||||
d
|
||||
.addOptions({ OMDb: 'OMDb', TMDB: 'TMDB' })
|
||||
.setValue(this.plugin.settings.activeApi ?? 'OMDb')
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.activeApi = v as 'OMDb' | 'TMDB';
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
// OMDb section
|
||||
el.createDiv({ cls: 'wl-settings-section-title', text: 'OMDb API' });
|
||||
const omdbInfo = el.createDiv({ cls: 'wl-settings-info' });
|
||||
omdbInfo.createSpan({ text: 'Get a free API key at ' });
|
||||
omdbInfo.createEl('a', {
|
||||
text: 'omdbapi.com/apikey.aspx',
|
||||
href: 'https://www.omdbapi.com/apikey.aspx',
|
||||
attr: { target: '_blank', rel: 'noopener noreferrer' },
|
||||
});
|
||||
omdbInfo.createSpan({ text: '.' });
|
||||
|
||||
let omdbStatus: HTMLElement;
|
||||
new Setting(el)
|
||||
.setName('Omdb API key')
|
||||
.addText((t) => {
|
||||
t
|
||||
.setPlaceholder('Paste your omdb key here')
|
||||
.setValue(this.plugin.settings.omdbApiKey)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.omdbApiKey = v;
|
||||
this.plugin.apiService.setOmdbKey(v);
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
t.inputEl.type = 'password';
|
||||
return t;
|
||||
})
|
||||
.addButton((b) =>
|
||||
b.setButtonText('Test').onClick(async () => {
|
||||
const ok = await this.plugin.apiService.checkOmdbConnection();
|
||||
omdbStatus.textContent = ok ? '✓ Connected' : '✗ Failed — check your key';
|
||||
omdbStatus.className = ok ? 'wl-status-ok' : 'wl-status-error';
|
||||
}),
|
||||
);
|
||||
omdbStatus = el.createDiv({
|
||||
cls: 'wl-status-indicator',
|
||||
text: this.plugin.settings.omdbApiKey ? '(not tested)' : 'Not set',
|
||||
});
|
||||
|
||||
// TMDB section
|
||||
el.createDiv({ cls: 'wl-settings-section-title', text: 'TMDB API' });
|
||||
const tmdbInfo = el.createDiv({ cls: 'wl-settings-info' });
|
||||
tmdbInfo.createSpan({ text: 'Get a free API key at ' });
|
||||
tmdbInfo.createEl('a', {
|
||||
text: 'themoviedb.org/settings/api',
|
||||
href: 'https://www.themoviedb.org/settings/api',
|
||||
attr: { target: '_blank', rel: 'noopener noreferrer' },
|
||||
});
|
||||
tmdbInfo.createSpan({ text: '.' });
|
||||
|
||||
let tmdbStatus: HTMLElement;
|
||||
new Setting(el)
|
||||
.setName('Tmdb API read access token')
|
||||
.addText((t) => {
|
||||
t
|
||||
.setPlaceholder('Paste your tmdb key here')
|
||||
.setValue(this.plugin.settings.tmdbApiKey ?? '')
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.tmdbApiKey = v;
|
||||
this.plugin.apiService.setTmdbKey(v);
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
t.inputEl.type = 'password';
|
||||
return t;
|
||||
})
|
||||
.addButton((b) =>
|
||||
b.setButtonText('Test').onClick(async () => {
|
||||
const ok = await this.plugin.apiService.checkTmdbConnection();
|
||||
tmdbStatus.textContent = ok ? '✓ Connected' : '✗ Failed — check your key';
|
||||
tmdbStatus.className = ok ? 'wl-status-ok' : 'wl-status-error';
|
||||
}),
|
||||
);
|
||||
tmdbStatus = el.createDiv({
|
||||
cls: 'wl-status-indicator',
|
||||
text: this.plugin.settings.tmdbApiKey ? '(not tested)' : 'Not set',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Customize ──────────────────────────────────────────────────────────────
|
||||
|
||||
private renderCustomize(el: HTMLElement): void {
|
||||
this.renderThemePicker(el);
|
||||
|
||||
type TagKey = 'types' | 'statuses' | 'priorities';
|
||||
const sections: Array<{ key: TagKey; label: string }> = [
|
||||
{ key: 'types', label: 'Type' },
|
||||
{ key: 'statuses', label: 'Status' },
|
||||
{ key: 'priorities', label: 'Priority' },
|
||||
];
|
||||
|
||||
for (const sec of sections) {
|
||||
this.renderTagSection(el, sec.label, sec.key);
|
||||
}
|
||||
|
||||
this.renderSeasonColors(el);
|
||||
}
|
||||
|
||||
private renderThemePicker(el: HTMLElement): void {
|
||||
const wrap = el.createDiv({ cls: 'wl-tag-section' });
|
||||
wrap.createDiv({ cls: 'wl-settings-section-title', text: 'Theme' });
|
||||
|
||||
const themes: Array<{ key: 'default' | 'nightfall' | 'bluez'; label: string; colors: string[] }> = [
|
||||
{
|
||||
key: 'default',
|
||||
label: 'Default',
|
||||
colors: ['#1D9E75', '#378ADD', '#BA7517', '#7F77DD', '#E24B4A'],
|
||||
},
|
||||
{
|
||||
key: 'nightfall',
|
||||
label: 'Nightfall',
|
||||
colors: ['#10002B', '#3C096C', '#7B2CBF', '#C77DFF', '#E0AAFF'],
|
||||
},
|
||||
{
|
||||
key: 'bluez',
|
||||
label: 'Bluez',
|
||||
colors: ['#012A4A', '#01497C', '#2C7DA0', '#61A5C2', '#A9D6E5'],
|
||||
},
|
||||
];
|
||||
|
||||
const currentTheme = this.plugin.settings.colorTheme ?? 'default';
|
||||
const cardsRow = wrap.createDiv({ cls: 'wl-theme-cards' });
|
||||
|
||||
const allCards: HTMLElement[] = [];
|
||||
for (const theme of themes) {
|
||||
const card = cardsRow.createDiv({
|
||||
cls: `wl-theme-card${currentTheme === theme.key ? ' is-selected' : ''}`,
|
||||
});
|
||||
allCards.push(card);
|
||||
card.createDiv({ cls: 'wl-theme-card-name', text: theme.label });
|
||||
const strip = card.createDiv({ cls: 'wl-theme-strip' });
|
||||
for (const color of theme.colors) {
|
||||
const swatch = strip.createDiv({ cls: 'wl-theme-swatch' });
|
||||
swatch.style.backgroundColor = color;
|
||||
}
|
||||
card.addEventListener('click', () => {
|
||||
void (async () => {
|
||||
this.plugin.settings.colorTheme = theme.key;
|
||||
await this.plugin.saveSettings();
|
||||
for (const c of allCards) c.removeClass('is-selected');
|
||||
card.addClass('is-selected');
|
||||
document.dispatchEvent(new CustomEvent('watchlog-data-changed'));
|
||||
const leaves = this.plugin.app.workspace.getLeavesOfType('watchlog-view');
|
||||
for (const leaf of leaves) {
|
||||
if (leaf.view instanceof WatchLogView) {
|
||||
leaf.view.refreshUI();
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private renderSeasonColors(el: HTMLElement): void {
|
||||
const wrap = el.createDiv({ cls: 'wl-tag-section' });
|
||||
wrap.createDiv({ cls: 'wl-settings-section-title', text: 'Season colors' });
|
||||
wrap.createDiv({
|
||||
cls: 'wl-settings-info',
|
||||
text: 'Colors cycle through seasons in order (Season 1 = color 1, etc.).',
|
||||
});
|
||||
|
||||
const colorsEl = wrap.createDiv({ cls: 'wl-season-colors-list' });
|
||||
const renderColors = (): void => {
|
||||
colorsEl.empty();
|
||||
this.plugin.settings.seasonPalette.forEach((color, idx) => {
|
||||
const pill = colorsEl.createDiv({ cls: 'wl-season-color-pill' });
|
||||
const swatch = pill.createEl('input', {
|
||||
cls: 'wl-color-input',
|
||||
attr: { type: 'color', value: color },
|
||||
});
|
||||
swatch.addEventListener('change', () => {
|
||||
void (async () => {
|
||||
this.plugin.settings.seasonPalette[idx] = swatch.value;
|
||||
await this.plugin.saveSettings();
|
||||
})();
|
||||
});
|
||||
pill.createSpan({ cls: 'wl-season-color-label', text: `Season ${idx + 1}` });
|
||||
const del = pill.createSpan({ cls: 'wl-tag-del', text: '×' });
|
||||
del.addEventListener('click', () => {
|
||||
void (async () => {
|
||||
this.plugin.settings.seasonPalette.splice(idx, 1);
|
||||
await this.plugin.saveSettings();
|
||||
renderColors();
|
||||
})();
|
||||
});
|
||||
});
|
||||
};
|
||||
renderColors();
|
||||
|
||||
const addRow = wrap.createDiv({ cls: 'wl-tag-add-row' });
|
||||
const colorInput = addRow.createEl('input', {
|
||||
cls: 'wl-color-input',
|
||||
attr: { type: 'color', value: '#888780' },
|
||||
});
|
||||
const addBtn = addRow.createEl('button', { cls: 'wl-btn', text: '+ add color' });
|
||||
addBtn.addEventListener('click', () => {
|
||||
void (async () => {
|
||||
this.plugin.settings.seasonPalette.push(colorInput.value);
|
||||
await this.plugin.saveSettings();
|
||||
colorInput.value = '#888780';
|
||||
renderColors();
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
private static readonly LOCKED_TYPES = ['Anime', 'Movie', 'TV Show'];
|
||||
|
||||
private renderTagSection(
|
||||
parent: HTMLElement,
|
||||
label: string,
|
||||
key: 'types' | 'statuses' | 'priorities',
|
||||
): void {
|
||||
const isStatuses = key === 'statuses';
|
||||
const isTypes = key === 'types';
|
||||
const wrap = parent.createDiv({ cls: 'wl-tag-section' });
|
||||
wrap.createDiv({ cls: 'wl-settings-section-title', text: label });
|
||||
|
||||
if (isStatuses) {
|
||||
wrap.createDiv({
|
||||
cls: 'wl-settings-info',
|
||||
text: 'Status names are locked. Only the color can be changed.',
|
||||
});
|
||||
}
|
||||
|
||||
if (isTypes) {
|
||||
wrap.createDiv({
|
||||
cls: 'wl-settings-info',
|
||||
text: 'Built-in types are locked. Custom types can be added and removed.',
|
||||
});
|
||||
}
|
||||
|
||||
const tagsEl = wrap.createDiv({ cls: 'wl-tags-list' });
|
||||
const renderTags = (): void => {
|
||||
tagsEl.empty();
|
||||
for (const tag of this.plugin.settings[key]) {
|
||||
this.renderTagPill(tagsEl, tag, key, renderTags);
|
||||
}
|
||||
};
|
||||
renderTags();
|
||||
|
||||
if (!isStatuses) {
|
||||
// Add row
|
||||
const addRow = wrap.createDiv({ cls: 'wl-tag-add-row' });
|
||||
const nameInput = addRow.createEl('input', {
|
||||
cls: 'wl-modal-input',
|
||||
attr: { type: 'text', placeholder: 'Name' },
|
||||
});
|
||||
const colorInput = addRow.createEl('input', {
|
||||
cls: 'wl-color-input',
|
||||
attr: { type: 'color', value: '#888780' },
|
||||
});
|
||||
const addBtn = addRow.createEl('button', { cls: 'wl-btn', text: '+ add' });
|
||||
addBtn.addEventListener('click', () => {
|
||||
void (async () => {
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) return;
|
||||
this.plugin.settings[key].push({ name, color: colorInput.value });
|
||||
await this.plugin.saveSettings();
|
||||
nameInput.value = '';
|
||||
colorInput.value = '#888780';
|
||||
renderTags();
|
||||
})();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private renderTagPill(
|
||||
parent: HTMLElement,
|
||||
tag: TagDefinition,
|
||||
key: 'types' | 'statuses' | 'priorities',
|
||||
refresh: () => void,
|
||||
): void {
|
||||
const pill = parent.createDiv({ cls: 'wl-tag-pill' });
|
||||
|
||||
// Clickable color dot — opens native color picker
|
||||
const dotWrap = pill.createDiv({ cls: 'wl-tag-dot-wrap' });
|
||||
const dot = dotWrap.createSpan({ cls: 'wl-tag-dot' });
|
||||
dot.style.backgroundColor = tag.color;
|
||||
const colorPicker = dotWrap.createEl('input', {
|
||||
cls: 'wl-tag-color-picker',
|
||||
attr: { type: 'color', value: tag.color },
|
||||
});
|
||||
dot.addEventListener('click', () => colorPicker.click());
|
||||
colorPicker.addEventListener('change', () => {
|
||||
void (async () => {
|
||||
tag.color = colorPicker.value;
|
||||
dot.style.backgroundColor = colorPicker.value;
|
||||
await this.plugin.saveSettings();
|
||||
})();
|
||||
});
|
||||
|
||||
pill.createSpan({ cls: 'wl-tag-name', text: tag.name });
|
||||
|
||||
// Statuses and locked built-in types: no delete button
|
||||
const isLockedType = key === 'types' && WatchLogSettingsTab.LOCKED_TYPES.includes(tag.name);
|
||||
if (key !== 'statuses' && !isLockedType) {
|
||||
const del = pill.createSpan({ cls: 'wl-tag-del', text: '×' });
|
||||
del.addEventListener('click', () => {
|
||||
void (async () => {
|
||||
this.plugin.settings[key] = this.plugin.settings[key].filter((t) => t.name !== tag.name);
|
||||
await this.plugin.saveSettings();
|
||||
refresh();
|
||||
new Notice(`Removed "${tag.name}".`);
|
||||
})();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Folders ────────────────────────────────────────────────────────────────
|
||||
|
||||
private renderFolders(el: HTMLElement): void {
|
||||
new Setting(el)
|
||||
.setName('Root folder name')
|
||||
.setDesc('Vault folder where watchlog Markdown files are stored.')
|
||||
.addText((t) =>
|
||||
t
|
||||
.setValue(this.plugin.settings.rootFolder)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.rootFolder = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(el)
|
||||
.setName('Auto-create folders')
|
||||
.setDesc('Automatically create type subfolders on plugin load.')
|
||||
.addToggle((t) =>
|
||||
t
|
||||
.setValue(this.plugin.settings.autoCreateFolders)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.autoCreateFolders = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
el.createDiv({ cls: 'wl-settings-section-title', text: 'Type subfolders' });
|
||||
const listEl = el.createDiv({ cls: 'wl-folder-list' });
|
||||
const renderList = (): void => {
|
||||
listEl.empty();
|
||||
for (const type of this.plugin.settings.types) {
|
||||
const row = listEl.createDiv({ cls: 'wl-folder-row' });
|
||||
row.createSpan({ cls: 'wl-folder-type', text: type.name });
|
||||
row.createSpan({
|
||||
cls: 'wl-folder-path',
|
||||
text: `${this.plugin.settings.rootFolder}/${type.name}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
renderList();
|
||||
|
||||
new Setting(el)
|
||||
.setName('Create folders now')
|
||||
.setDesc('Manually trigger folder creation for all current types.')
|
||||
.addButton((b) =>
|
||||
b.setButtonText('Create').onClick(async () => {
|
||||
await this.plugin.dataManager.ensureFolders();
|
||||
new Notice('Folders created.');
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Drafts ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private renderDrafts(el: HTMLElement): void {
|
||||
el.createDiv({ cls: 'wl-settings-section-title', text: 'Drafts' });
|
||||
|
||||
new Setting(el)
|
||||
.setName('Vault tag to monitor')
|
||||
.setDesc('Lines containing this tag followed by a title will appear in the drafts tab.')
|
||||
.addText((t) =>
|
||||
t
|
||||
.setPlaceholder('#watchlog')
|
||||
.setValue(this.plugin.settings.draftsVaultTag ?? '#watchlog')
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.draftsVaultTag = v.trim() || '#watchlog';
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(el)
|
||||
.setName('After adding a title')
|
||||
.setDesc('What happens to a draft entry once you hit add and confirm.')
|
||||
.addDropdown((d) =>
|
||||
d
|
||||
.addOptions({ keep: 'Keep as Added', remove: 'Remove' })
|
||||
.setValue(this.plugin.settings.draftsAfterAdding ?? 'keep')
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.draftsAfterAdding = v as 'keep' | 'remove';
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Custom Lists ────────────────────────────────────────────────────────────
|
||||
|
||||
private renderCustomLists(el: HTMLElement): void {
|
||||
el.createDiv({ cls: 'wl-settings-section-title', text: 'Custom Lists' });
|
||||
|
||||
new Setting(el)
|
||||
.setName('Custom lists folder path')
|
||||
.setDesc('Vault folder where custom list files are stored.')
|
||||
.addText((t) =>
|
||||
t
|
||||
.setValue(this.plugin.settings.customListsFolder ?? 'WatchLog/CustomLists')
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.customListsFolder = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(el)
|
||||
.setName('Default columns')
|
||||
.setDesc('Columns pre-populated when creating a new list. The name column is always included.')
|
||||
.addButton((b) => {
|
||||
const count = (this.plugin.settings.defaultCustomColumns ?? []).length;
|
||||
b.setButtonText(`Edit (${count} column${count !== 1 ? 's' : ''})`);
|
||||
b.onClick(() => {
|
||||
new DefaultColumnsModal(this.app, this.plugin).open();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(el)
|
||||
.setName('Create folder now')
|
||||
.setDesc('Manually create the custom lists folder in the vault.')
|
||||
.addButton((b) =>
|
||||
b.setButtonText('Create').onClick(async () => {
|
||||
const manager = new CustomListManager(this.app, this.plugin);
|
||||
await manager.ensureFolder();
|
||||
new Notice('Custom lists folder created.');
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Quick Info ──────────────────────────────────────────────────────────────
|
||||
|
||||
private renderQuickInfo(el: HTMLElement): void {
|
||||
el.createDiv({ cls: 'wl-settings-section-title', text: 'Quick Info' });
|
||||
|
||||
const sections: { title: string; items: { heading: string; body: string }[] }[] = [
|
||||
{
|
||||
title: '📊 Dashboard',
|
||||
items: [
|
||||
{
|
||||
heading: 'Please note',
|
||||
body: 'Statuses To be released or Dropped (time left) are not included in any calculations, only Watching, Completed and Dropped (time watched).',
|
||||
},
|
||||
{
|
||||
heading: 'Time Watched',
|
||||
body: 'Calculated as: episodes watched × episode duration, summed across all titles with status Watching and Completed.',
|
||||
},
|
||||
{
|
||||
heading: 'Time Remaining',
|
||||
body: 'Calculated as: episodes remaining × episode duration, summed across all titles with status Watching and Plan to Watch.',
|
||||
},
|
||||
{
|
||||
heading: 'Dropped',
|
||||
body: 'Calculated as: episodes watched × episode duration, summed across all titles with status Dropped.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '📋 Watchlist',
|
||||
items: [
|
||||
{
|
||||
heading: 'Why Groups exist',
|
||||
body: 'Groups let you combine related titles under one entry; for example, a movie and its sequel, a TV show and its anime adaptation, or an anime series and its movie. They appear as a single collapsible row in your Watchlist.',
|
||||
},
|
||||
{
|
||||
heading: 'Group Rating',
|
||||
body: "A group's rating is automatically calculated as the average of all individual title ratings within it.",
|
||||
},
|
||||
{
|
||||
heading: 'Pin to Top',
|
||||
body: "Pinning a title moves it to the top of your Watchlist regardless of sorting. Pinned titles also appear in the Now Watching widget, so you can quickly see what you're currently watching inside your notes or Homepage.",
|
||||
},
|
||||
{
|
||||
heading: 'How to add a new season',
|
||||
body: 'Open the title and click Edit at the bottom. In the Edit modal, scroll down to the Seasons field and add a new line in the format "Season Name: N" (e.g. "Season 3: 10" or "Season name: 10").',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '🔍 API & Search',
|
||||
items: [
|
||||
{
|
||||
heading: 'Anime vs Movies & TV Shows',
|
||||
body: 'WatchLog uses two separate APIs. Anime titles are searched via Jikan (MyAnimeList data). Movies and TV Shows use either OMDb or TMDB, depending on which is set as active in Settings. Make sure you have the correct type selected before searching; if you have Anime selected and search for a movie or TV show, it most likely won\'t be found, and vice versa.\n\nNote: APIs are there to help, not mandatory; you can manually add a new title.\n\nNote: The "Add from URL" button also uses an API.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '📅 Upcoming',
|
||||
items: [
|
||||
{
|
||||
heading: 'How Upcoming works',
|
||||
body: 'Any title with a release date set in the future is automatically marked as To be released and appears in this tab. This status cannot be set manually; it is determined solely by the release date you enter. If a title has already started airing but only some episodes are out (e.g. an ongoing series), it will not appear here automatically; you\'ll need to manually add it through the Add button in the Upcoming tab.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const section of sections) {
|
||||
const wrapper = el.createDiv({ cls: 'wl-qi-section' });
|
||||
|
||||
const header = wrapper.createDiv({ cls: 'wl-qi-section-header' });
|
||||
const chevron = header.createSpan({ cls: 'wl-qi-chevron', text: '▶' });
|
||||
header.createSpan({ cls: 'wl-qi-section-label', text: section.title });
|
||||
|
||||
const body = wrapper.createDiv({ cls: 'wl-qi-section-body wl-qi-section-body-hidden' });
|
||||
|
||||
for (const item of section.items) {
|
||||
const banner = body.createDiv({ cls: 'wl-qi-banner' });
|
||||
banner.createDiv({ cls: 'wl-qi-banner-heading', text: item.heading });
|
||||
banner.createDiv({ cls: 'wl-qi-banner-body', text: item.body });
|
||||
}
|
||||
|
||||
header.addEventListener('click', () => {
|
||||
const isOpen = !body.hasClass('wl-qi-section-body-hidden');
|
||||
if (isOpen) { body.addClass('wl-qi-section-body-hidden'); } else { body.removeClass('wl-qi-section-body-hidden'); }
|
||||
chevron.textContent = isOpen ? '▶' : '▼';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Widgets ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private renderWidgets(el: HTMLElement): void {
|
||||
el.createDiv({
|
||||
cls: 'wl-settings-info',
|
||||
text: 'Insert widgets into notes using the "WatchLog: Insert widget" command. Configure the keyboard shortcut in Obsidian Settings → Hotkeys.',
|
||||
});
|
||||
|
||||
const widgets = [
|
||||
{
|
||||
name: 'wl-todo',
|
||||
desc: 'Track a specific title inline — shows type, status, progress, and a "next episode" checkbox.',
|
||||
syntax: '```wl-todo\nTitle Name\nmini\n```',
|
||||
},
|
||||
{
|
||||
name: 'wl-todo (full)',
|
||||
desc: 'Track a specific title inline (full card).',
|
||||
syntax: '```wl-todo\nTitle Name\n```',
|
||||
},
|
||||
{
|
||||
name: 'wl-stat: completed',
|
||||
desc: 'Completed titles count — compact inline stat card.',
|
||||
syntax: '```wl-stat\ncompleted\n```',
|
||||
},
|
||||
{
|
||||
name: 'wl-stat: time',
|
||||
desc: 'Time watched & remaining combined (mini).',
|
||||
syntax: '```wl-stat\ntime\n```',
|
||||
},
|
||||
{
|
||||
name: 'wl-upcoming: next',
|
||||
desc: 'Next upcoming title with name, type badge, release date, and countdown.',
|
||||
syntax: '```wl-upcoming\nnext\n```',
|
||||
},
|
||||
{
|
||||
name: 'wl-nowwatching',
|
||||
desc: 'Currently pinned title with name, type badge, and progress bar.',
|
||||
syntax: '```wl-nowwatching\n```',
|
||||
},
|
||||
{
|
||||
name: 'wl-stat: time completed full',
|
||||
desc: 'Full-width triple card: Time Watched · Time Remaining · Completed.',
|
||||
syntax: '```wl-stat\ntime completed full\n```',
|
||||
},
|
||||
{
|
||||
name: 'wl-now-next',
|
||||
desc: 'Full-width double card: Now Watching · Up Next.',
|
||||
syntax: '```wl-now-next\n```',
|
||||
},
|
||||
];
|
||||
|
||||
for (const w of widgets) {
|
||||
const section = el.createDiv({ cls: 'wl-widget-info-section' });
|
||||
section.createDiv({ cls: 'wl-widget-info-name', text: w.name });
|
||||
section.createDiv({ cls: 'wl-widget-info-desc', text: w.desc });
|
||||
const codeRow = section.createDiv({ cls: 'wl-widget-info-code-row' });
|
||||
codeRow.createEl('code', { cls: 'wl-widget-info-code', text: w.syntax });
|
||||
const copyBtn = codeRow.createEl('button', { cls: 'wl-btn wl-btn-sm', text: 'Copy' });
|
||||
copyBtn.addEventListener('click', () => {
|
||||
void navigator.clipboard.writeText(w.syntax).then(() => {
|
||||
copyBtn.textContent = 'Copied!';
|
||||
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
237
src/WatchLogView.ts
Normal file
237
src/WatchLogView.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
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> {
|
||||
this.dataManager.onChange(this.dataChangeListener);
|
||||
this.buildUI();
|
||||
}
|
||||
|
||||
async onClose(): Promise<void> {
|
||||
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 = 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';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
761
src/WidgetRenderer.ts
Normal file
761
src/WidgetRenderer.ts
Normal file
|
|
@ -0,0 +1,761 @@
|
|||
import { MarkdownPostProcessorContext } from 'obsidian';
|
||||
import type WatchLogPlugin from './main';
|
||||
import type { DataManager } from './DataManager';
|
||||
import type { WatchLogTitle, TagDefinition } from './types';
|
||||
import { formatTime, getThemedColor } from './types';
|
||||
|
||||
export class WidgetRenderer {
|
||||
private plugin: WatchLogPlugin;
|
||||
private dataManager: DataManager;
|
||||
|
||||
/**
|
||||
* Registry of all currently-rendered widget container elements.
|
||||
* el → titleId. Stale (disconnected) entries are removed lazily.
|
||||
*/
|
||||
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>();
|
||||
private dataChangedListener: EventListener;
|
||||
|
||||
constructor(plugin: WatchLogPlugin, dataManager: DataManager) {
|
||||
this.plugin = plugin;
|
||||
this.dataManager = dataManager;
|
||||
|
||||
this.plugin.registerMarkdownCodeBlockProcessor(
|
||||
'watchlog',
|
||||
(source, el, ctx) => this.process(source, el, ctx),
|
||||
);
|
||||
|
||||
this.plugin.registerMarkdownCodeBlockProcessor(
|
||||
'wl-todo',
|
||||
(source, el, _ctx) => this.processWlTodo(source, el),
|
||||
);
|
||||
|
||||
this.plugin.registerMarkdownCodeBlockProcessor(
|
||||
'wl-stat',
|
||||
(source, el, _ctx) => this.processWlStat(source, el),
|
||||
);
|
||||
|
||||
this.plugin.registerMarkdownCodeBlockProcessor(
|
||||
'wl-upcoming',
|
||||
(source, el, _ctx) => this.processWlUpcoming(source, el),
|
||||
);
|
||||
|
||||
this.plugin.registerMarkdownCodeBlockProcessor(
|
||||
'wl-nowwatching',
|
||||
(source, el, _ctx) => this.processWlNowWatching(source, el),
|
||||
);
|
||||
|
||||
this.plugin.registerMarkdownCodeBlockProcessor(
|
||||
'wl-now-next',
|
||||
(source, el, _ctx) => this.processWlNowNext(source, el),
|
||||
);
|
||||
|
||||
// Re-render all active widgets when data changes (Fix 1: list → widget sync)
|
||||
this.dataChangedListener = () => this.onDataChanged();
|
||||
document.addEventListener('watchlog-data-changed', this.dataChangedListener);
|
||||
|
||||
// Clean up listener when plugin unloads
|
||||
plugin.register(() => {
|
||||
document.removeEventListener('watchlog-data-changed', this.dataChangedListener);
|
||||
});
|
||||
}
|
||||
|
||||
/** Called by the `watchlog-data-changed` DOM event. Re-renders all live widgets. */
|
||||
private onDataChanged(): void {
|
||||
const stale: HTMLElement[] = [];
|
||||
|
||||
for (const [el, titleId] of this.widgetRegistry) {
|
||||
if (!el.isConnected) {
|
||||
stale.push(el);
|
||||
continue;
|
||||
}
|
||||
const title = this.dataManager.getTitle(titleId);
|
||||
if (!title) {
|
||||
stale.push(el);
|
||||
continue;
|
||||
}
|
||||
const isMini = this.widgetMiniRegistry.get(el) ?? false;
|
||||
if (isMini) {
|
||||
this.renderWidgetMinimal(el, title);
|
||||
} else {
|
||||
this.renderWidget(el, title);
|
||||
}
|
||||
}
|
||||
|
||||
for (const el of stale) {
|
||||
this.widgetRegistry.delete(el);
|
||||
this.widgetMiniRegistry.delete(el);
|
||||
}
|
||||
}
|
||||
|
||||
private process(
|
||||
source: string,
|
||||
el: HTMLElement,
|
||||
_ctx: MarkdownPostProcessorContext,
|
||||
): void {
|
||||
const idMatch = source.match(/id:\s*(.+)/);
|
||||
const id = idMatch?.[1]?.trim();
|
||||
if (!id) {
|
||||
el.createDiv({ cls: 'wl-widget-error', text: 'WatchLog: missing id field.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const title = this.dataManager.getTitle(id);
|
||||
if (!title) {
|
||||
el.createDiv({
|
||||
cls: 'wl-widget-error',
|
||||
text: `WatchLog: title "${id}" not found.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Register so this widget is re-rendered on future data changes
|
||||
this.widgetRegistry.set(el, id);
|
||||
|
||||
this.renderWidget(el, title);
|
||||
}
|
||||
|
||||
private renderWidget(el: HTMLElement, title: WatchLogTitle): void {
|
||||
this.renderWidgetFull(el, title);
|
||||
}
|
||||
|
||||
private renderWidgetFull(el: HTMLElement, title: WatchLogTitle): void {
|
||||
el.empty();
|
||||
el.addClass('wl-widget');
|
||||
|
||||
const mainRow = el.createDiv({ cls: 'wl-widget-main-row' });
|
||||
|
||||
// ── Checkbox 1: visual-only task indicator ─────────────────────────────
|
||||
// Does NOT modify WatchLog data — purely a personal note marker.
|
||||
const taskCb = mainRow.createEl('input', {
|
||||
cls: 'wl-widget-cb',
|
||||
attr: { type: 'checkbox' },
|
||||
});
|
||||
taskCb.title = 'Personal task marker (does not affect watchlog data)';
|
||||
taskCb.addEventListener('change', () => {
|
||||
const titleSpan = mainRow.querySelector<HTMLElement>('.wl-widget-title');
|
||||
if (titleSpan) {
|
||||
titleSpan.style.textDecoration = taskCb.checked ? 'line-through' : '';
|
||||
titleSpan.style.opacity = taskCb.checked ? '0.5' : '';
|
||||
}
|
||||
});
|
||||
|
||||
// Title
|
||||
mainRow.createSpan({ cls: 'wl-widget-title', text: title.title });
|
||||
|
||||
this.sep(mainRow);
|
||||
|
||||
// Type badge (respects coloredTypeBadges setting)
|
||||
const typeDef = this.getTagDef(title.type, this.plugin.settings.types);
|
||||
const colored = this.plugin.settings.coloredTypeBadges;
|
||||
const typeBadge = mainRow.createSpan({
|
||||
cls: colored ? 'wl-badge wl-badge-sm' : 'wl-badge-plain',
|
||||
text: title.type,
|
||||
});
|
||||
if (colored && typeDef) typeBadge.style.backgroundColor = getThemedColor(title.type, typeDef.color, this.plugin.settings.colorTheme);
|
||||
|
||||
this.sep(mainRow);
|
||||
|
||||
// Status badge (respects coloredTypeBadges setting)
|
||||
const statusDef = this.getTagDef(title.status, this.plugin.settings.statuses);
|
||||
const statusBadge = mainRow.createSpan({
|
||||
cls: colored ? 'wl-badge wl-badge-sm' : 'wl-badge-plain',
|
||||
text: title.status,
|
||||
});
|
||||
if (colored && statusDef) statusBadge.style.backgroundColor = getThemedColor(title.status, statusDef.color, this.plugin.settings.colorTheme);
|
||||
|
||||
this.sep(mainRow);
|
||||
|
||||
// Progress bar
|
||||
const progress = this.dataManager.getProgress(title);
|
||||
const barWrap = mainRow.createDiv({ cls: 'wl-widget-bar-wrap' });
|
||||
barWrap.createDiv({ cls: 'wl-progress-bar' }).style.width = `${progress}%`;
|
||||
|
||||
this.sep(mainRow);
|
||||
|
||||
// Percentage
|
||||
mainRow.createSpan({ cls: 'wl-widget-percent', text: `${progress}%` });
|
||||
|
||||
this.sep(mainRow);
|
||||
|
||||
// Date label
|
||||
const dateStr = title.dateStarted ?? title.dateAdded;
|
||||
const dateLabel = title.dateStarted
|
||||
? `since ${this.formatShortDate(dateStr)}`
|
||||
: `added ${this.formatShortDate(dateStr)}`;
|
||||
mainRow.createSpan({ cls: 'wl-widget-date', text: dateLabel });
|
||||
|
||||
// ── Checkbox 2: "Next up" — writes to WatchLog data ───────────────────
|
||||
const isMovie = title.type === 'Movie';
|
||||
if (!isMovie) {
|
||||
const nextEp = this.dataManager.getNextUnwatchedEpisode(title);
|
||||
const nextRow = el.createDiv({ cls: 'wl-widget-next-row' });
|
||||
|
||||
if (nextEp !== null) {
|
||||
const nextCb = nextRow.createEl('input', {
|
||||
cls: 'wl-widget-next-cb',
|
||||
attr: { type: 'checkbox' },
|
||||
});
|
||||
nextCb.checked = false;
|
||||
nextCb.title = `Mark Episode ${nextEp} as watched`;
|
||||
nextCb.addEventListener('change', () => {
|
||||
void this.dataManager.markEpisodeWatched(title.id, nextEp, true).then(() => {
|
||||
const updated = this.dataManager.getTitle(title.id);
|
||||
if (updated) this.renderWidget(el, updated);
|
||||
});
|
||||
});
|
||||
nextRow.createSpan({ cls: 'wl-widget-next-label', text: `Next up: Ep ${nextEp}` });
|
||||
} else {
|
||||
nextRow.createSpan({ cls: 'wl-widget-next-label', text: '✓ All episodes watched' });
|
||||
}
|
||||
} else {
|
||||
// Movie: watch checkbox that writes to WatchLog data
|
||||
const watchRow = el.createDiv({ cls: 'wl-widget-next-row' });
|
||||
const watched = title.watchedEpisodes.includes(1);
|
||||
const movieCb = watchRow.createEl('input', {
|
||||
cls: 'wl-widget-next-cb',
|
||||
attr: { type: 'checkbox' },
|
||||
});
|
||||
movieCb.checked = watched;
|
||||
movieCb.title = 'Mark movie as watched';
|
||||
movieCb.addEventListener('change', () => {
|
||||
void this.dataManager.markEpisodeWatched(title.id, 1, movieCb.checked).then(() => {
|
||||
const updated = this.dataManager.getTitle(title.id);
|
||||
if (updated) this.renderWidget(el, updated);
|
||||
});
|
||||
});
|
||||
watchRow.createSpan({
|
||||
cls: 'wl-widget-next-label',
|
||||
text: watched ? 'Watched' : 'Mark as watched',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private renderWidgetMinimal(el: HTMLElement, title: WatchLogTitle): void {
|
||||
el.empty();
|
||||
el.addClass('wl-widget-minimal');
|
||||
|
||||
const row = el.createDiv({ cls: 'wl-widget-minimal-row' });
|
||||
|
||||
// Left checkbox: visual-only task marker
|
||||
const taskCb = row.createEl('input', {
|
||||
cls: 'wl-widget-cb',
|
||||
attr: { type: 'checkbox' },
|
||||
});
|
||||
taskCb.title = 'Personal task marker (does not affect watchlog data)';
|
||||
taskCb.addEventListener('change', () => {
|
||||
const titleSpan = row.querySelector<HTMLElement>('.wl-widget-title');
|
||||
if (titleSpan) {
|
||||
titleSpan.style.textDecoration = taskCb.checked ? 'line-through' : '';
|
||||
titleSpan.style.opacity = taskCb.checked ? '0.5' : '';
|
||||
}
|
||||
});
|
||||
|
||||
// Title
|
||||
row.createSpan({ cls: 'wl-widget-title', text: title.title });
|
||||
|
||||
this.sep(row);
|
||||
|
||||
// Type badge
|
||||
const typeDef = this.getTagDef(title.type, this.plugin.settings.types);
|
||||
const colored = this.plugin.settings.coloredTypeBadges;
|
||||
const typeBadge = row.createSpan({
|
||||
cls: colored ? 'wl-badge wl-badge-sm' : 'wl-badge-plain',
|
||||
text: title.type,
|
||||
});
|
||||
if (colored && typeDef) typeBadge.style.backgroundColor = getThemedColor(title.type, typeDef.color, this.plugin.settings.colorTheme);
|
||||
|
||||
this.sep(row);
|
||||
|
||||
// Status badge (respects coloredTypeBadges setting)
|
||||
const statusDef = this.getTagDef(title.status, this.plugin.settings.statuses);
|
||||
const statusBadge = row.createSpan({
|
||||
cls: colored ? 'wl-badge wl-badge-sm' : 'wl-badge-plain',
|
||||
text: title.status,
|
||||
});
|
||||
if (colored && statusDef) statusBadge.style.backgroundColor = getThemedColor(title.status, statusDef.color, this.plugin.settings.colorTheme);
|
||||
|
||||
this.sep(row);
|
||||
|
||||
// Progress %
|
||||
const progress = this.dataManager.getProgress(title);
|
||||
row.createSpan({ cls: 'wl-widget-percent', text: `${progress}%` });
|
||||
|
||||
this.sep(row);
|
||||
|
||||
// Right checkbox: "Mark as watched" / next episode
|
||||
const isMovie = title.type === 'Movie';
|
||||
if (isMovie) {
|
||||
const watched = title.watchedEpisodes.includes(1);
|
||||
const movieCb = row.createEl('input', {
|
||||
cls: 'wl-widget-next-cb',
|
||||
attr: { type: 'checkbox' },
|
||||
});
|
||||
movieCb.checked = watched;
|
||||
movieCb.title = 'Mark movie as watched';
|
||||
movieCb.addEventListener('change', () => {
|
||||
void this.dataManager.markEpisodeWatched(title.id, 1, movieCb.checked).then(() => {
|
||||
const updated = this.dataManager.getTitle(title.id);
|
||||
if (updated) this.renderWidgetMinimal(el, updated);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
const nextEp = this.dataManager.getNextUnwatchedEpisode(title);
|
||||
const nextCb = row.createEl('input', {
|
||||
cls: 'wl-widget-next-cb',
|
||||
attr: { type: 'checkbox' },
|
||||
});
|
||||
if (nextEp !== null) {
|
||||
nextCb.checked = false;
|
||||
nextCb.title = `Mark Episode ${nextEp} as watched`;
|
||||
nextCb.addEventListener('change', () => {
|
||||
void this.dataManager.markEpisodeWatched(title.id, nextEp, true).then(() => {
|
||||
const updated = this.dataManager.getTitle(title.id);
|
||||
if (updated) this.renderWidgetMinimal(el, updated);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
nextCb.checked = true;
|
||||
nextCb.disabled = true;
|
||||
nextCb.title = 'All episodes watched';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sep(parent: HTMLElement): void {
|
||||
parent.createSpan({ cls: 'wl-widget-sep', text: '·' });
|
||||
}
|
||||
|
||||
private getTagDef(name: string, tags: TagDefinition[]): TagDefinition | undefined {
|
||||
return tags.find((t) => t.name === name);
|
||||
}
|
||||
|
||||
private formatShortDate(dateStr: string): string {
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getFullYear()).slice(2)}`;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
// ── wl-todo ───────────────────────────────────────────────────────────────────
|
||||
|
||||
private processWlTodo(source: string, el: HTMLElement): void {
|
||||
const lines = source.trim().split('\n').map((l) => l.trim()).filter(Boolean);
|
||||
const isMini = lines.includes('mini');
|
||||
const titleName = lines.find((l) => l !== 'mini') ?? '';
|
||||
if (!titleName) {
|
||||
el.createDiv({ cls: 'wl-widget-error', text: 'wl-todo: missing title name.' });
|
||||
return;
|
||||
}
|
||||
const title = this.dataManager.getTitles().find(
|
||||
(t) => t.title.toLowerCase() === titleName.toLowerCase(),
|
||||
);
|
||||
if (!title) {
|
||||
el.createDiv({ cls: 'wl-widget-error', text: `wl-todo: "${titleName}" not found.` });
|
||||
return;
|
||||
}
|
||||
this.widgetRegistry.set(el, title.id);
|
||||
this.widgetMiniRegistry.set(el, isMini);
|
||||
if (isMini) {
|
||||
this.renderWidgetMinimal(el, title);
|
||||
} else {
|
||||
this.renderWidget(el, title);
|
||||
}
|
||||
}
|
||||
|
||||
// ── wl-stat ───────────────────────────────────────────────────────────────────
|
||||
|
||||
private processWlStat(source: string, el: HTMLElement): void {
|
||||
const kind = source.trim().toLowerCase();
|
||||
el.addClass('wl-wstat');
|
||||
|
||||
if (kind === 'watched') {
|
||||
const minutes = this.dataManager.getTotalTimeWatched();
|
||||
this.renderStatCard(el, '🕐', formatTime(minutes), 'watched');
|
||||
} else if (kind === 'completed') {
|
||||
const count = this.dataManager.getCompletedCount();
|
||||
this.renderStatCard(el, '✅', `${count} titles`, 'completed');
|
||||
} else if (kind === 'remaining') {
|
||||
const minutes = this.dataManager.getTotalTimeRemaining();
|
||||
this.renderStatCard(el, '⏳', formatTime(minutes), 'remaining');
|
||||
} else if (kind === 'time') {
|
||||
const watched = this.dataManager.getTotalTimeWatched();
|
||||
const remaining = this.dataManager.getTotalTimeRemaining();
|
||||
this.renderTimeCardMini(el, watched, remaining);
|
||||
} else if (kind === 'time full') {
|
||||
const watched = this.dataManager.getTotalTimeWatched();
|
||||
const remaining = this.dataManager.getTotalTimeRemaining();
|
||||
this.renderTimeCardFull(el, watched, remaining);
|
||||
} else if (kind === 'completed full') {
|
||||
const count = this.dataManager.getCompletedCount();
|
||||
this.renderCompletedCardFull(el, count);
|
||||
} else if (kind === 'time completed full') {
|
||||
const watched = this.dataManager.getTotalTimeWatched();
|
||||
const remaining = this.dataManager.getTotalTimeRemaining();
|
||||
const count = this.dataManager.getCompletedCount();
|
||||
this.renderTripleStatCard(el, watched, remaining, count);
|
||||
} else {
|
||||
el.createDiv({ cls: 'wl-widget-error', text: `wl-stat: unknown stat "${kind}". Use watched, completed, remaining, time, time full, completed full, or time completed full.` });
|
||||
}
|
||||
}
|
||||
|
||||
private renderStatCard(el: HTMLElement, icon: string, value: string, label: string): void {
|
||||
el.empty();
|
||||
const card = el.createDiv({ cls: 'wl-stat-card' });
|
||||
card.createSpan({ cls: 'wl-stat-icon', text: icon });
|
||||
card.createSpan({ cls: 'wl-stat-value', text: value });
|
||||
card.createSpan({ cls: 'wl-stat-label', text: label });
|
||||
}
|
||||
|
||||
private renderTimeCardMini(el: HTMLElement, watchedMins: number, remainingMins: number): void {
|
||||
el.empty();
|
||||
const card = el.createDiv({ cls: 'wl-stat-card wl-stat-card-time' });
|
||||
card.createSpan({ cls: 'wl-stat-icon', text: '🕐' });
|
||||
card.createSpan({ cls: 'wl-stat-value', text: formatTime(watchedMins) });
|
||||
card.createSpan({ cls: 'wl-stat-label', text: 'watched' });
|
||||
card.createSpan({ cls: 'wl-stat-sep', text: '·' });
|
||||
card.createSpan({ cls: 'wl-stat-icon', text: '⏳' });
|
||||
card.createSpan({ cls: 'wl-stat-value', text: formatTime(remainingMins) });
|
||||
card.createSpan({ cls: 'wl-stat-label', text: 'left' });
|
||||
}
|
||||
|
||||
private renderTimeCardFull(el: HTMLElement, watchedMins: number, remainingMins: number): void {
|
||||
el.empty();
|
||||
const card = el.createDiv({ cls: 'wl-full-card wl-full-card-time' });
|
||||
card.createDiv({ cls: 'wl-full-card-header', text: 'Time' });
|
||||
const cols = card.createDiv({ cls: 'wl-full-card-time-cols' });
|
||||
const makeCol = (mins: number, label: string) => {
|
||||
const col = cols.createDiv({ cls: 'wl-full-card-time-col' });
|
||||
col.createDiv({ cls: 'wl-full-card-value', text: formatTime(mins) });
|
||||
col.createDiv({ cls: 'wl-full-card-days', text: this.formatTimeDays(mins) });
|
||||
col.createDiv({ cls: 'wl-full-card-sub', text: label });
|
||||
};
|
||||
makeCol(watchedMins, 'watched');
|
||||
makeCol(remainingMins, 'remaining');
|
||||
}
|
||||
|
||||
private formatTimeDays(minutes: number): string {
|
||||
const days = Math.floor(minutes / 1440);
|
||||
const hours = Math.floor((minutes % 1440) / 60);
|
||||
if (days === 0) return hours > 0 ? `${hours}h` : '0h';
|
||||
return `${days} days ${hours}h`;
|
||||
}
|
||||
|
||||
private renderCompletedCardFull(el: HTMLElement, count: number): void {
|
||||
el.empty();
|
||||
const card = el.createDiv({ cls: 'wl-full-card' });
|
||||
card.createDiv({ cls: 'wl-full-card-header', text: 'Completed' });
|
||||
card.createDiv({ cls: 'wl-full-card-value', text: String(count) });
|
||||
card.createDiv({ cls: 'wl-full-card-sub', text: 'titles completed' });
|
||||
}
|
||||
|
||||
private renderTripleStatCard(el: HTMLElement, watchedMins: number, remainingMins: number, completed: number): void {
|
||||
el.empty();
|
||||
el.addClass('wl-wstat-triple');
|
||||
const card = el.createDiv({ cls: 'wl-full-card wl-full-card-triple' });
|
||||
const cols = card.createDiv({ cls: 'wl-triple-cols' });
|
||||
|
||||
const makeTimeCol = (mins: number, label: string) => {
|
||||
const col = cols.createDiv({ cls: 'wl-triple-col' });
|
||||
col.createDiv({ cls: 'wl-full-card-value', text: formatTime(mins) });
|
||||
col.createDiv({ cls: 'wl-full-card-days', text: this.formatTimeDays(mins) });
|
||||
col.createDiv({ cls: 'wl-full-card-sub', text: label });
|
||||
};
|
||||
|
||||
makeTimeCol(watchedMins, 'watched');
|
||||
cols.createDiv({ cls: 'wl-vert-sep' });
|
||||
makeTimeCol(remainingMins, 'remaining');
|
||||
cols.createDiv({ cls: 'wl-vert-sep' });
|
||||
|
||||
const completedCol = cols.createDiv({ cls: 'wl-triple-col' });
|
||||
completedCol.createDiv({ cls: 'wl-full-card-value', text: String(completed) });
|
||||
completedCol.createDiv({ cls: 'wl-full-card-sub', text: 'completed' });
|
||||
}
|
||||
|
||||
// ── wl-upcoming ───────────────────────────────────────────────────────────────
|
||||
|
||||
private processWlUpcoming(source: string, el: HTMLElement): void {
|
||||
const kind = source.trim().toLowerCase();
|
||||
const isFull = kind === 'next full';
|
||||
if (kind !== 'next' && kind !== 'next full') {
|
||||
el.createDiv({ cls: 'wl-widget-error', text: 'wl-upcoming: only "next" or "next full" is supported.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = this.dataManager.getAirtimeEntries();
|
||||
const titles = this.dataManager.getTitles();
|
||||
|
||||
// Find the entry with the soonest future release date
|
||||
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);
|
||||
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) {
|
||||
bestMs = ms;
|
||||
const daysUntil = Math.round((ms - now) / 86400000);
|
||||
bestEntry = {
|
||||
titleName: title.title,
|
||||
type: title.type,
|
||||
releaseDate: entry.schedule.releaseDate,
|
||||
daysUntil,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
el.addClass('wl-wupcoming');
|
||||
|
||||
if (isFull) {
|
||||
this.renderUpcomingFull(el, bestEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bestEntry) {
|
||||
el.createDiv({ cls: 'wl-upcoming-card wl-upcoming-empty', text: 'No upcoming titles.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const card = el.createDiv({ cls: 'wl-upcoming-card' });
|
||||
card.createSpan({ cls: 'wl-upcoming-title', text: bestEntry.titleName });
|
||||
|
||||
const typeDef = this.getTagDef(bestEntry.type, this.plugin.settings.types);
|
||||
const colored = this.plugin.settings.coloredTypeBadges;
|
||||
const badge = card.createSpan({
|
||||
cls: colored ? 'wl-badge wl-badge-sm' : 'wl-badge-plain',
|
||||
text: bestEntry.type,
|
||||
});
|
||||
if (colored && typeDef) badge.style.backgroundColor = getThemedColor(bestEntry.type, typeDef.color, this.plugin.settings.colorTheme);
|
||||
|
||||
card.createSpan({ cls: 'wl-upcoming-date', text: bestEntry.releaseDate });
|
||||
const daysUntil = bestEntry.daysUntil;
|
||||
const monthSuffix = daysUntil >= 30 ? ` (${Math.round(daysUntil / 30)} month${Math.round(daysUntil / 30) !== 1 ? 's' : ''})` : '';
|
||||
const countdown = daysUntil === 0 ? 'today' : daysUntil === 1 ? 'tomorrow' : `in ${daysUntil} days${monthSuffix}`;
|
||||
card.createSpan({ cls: 'wl-upcoming-countdown', text: countdown });
|
||||
}
|
||||
|
||||
private renderUpcomingFull(
|
||||
el: HTMLElement,
|
||||
bestEntry: { titleName: string; type: string; releaseDate: string; daysUntil: number } | null,
|
||||
): void {
|
||||
el.empty();
|
||||
const card = el.createDiv({ cls: 'wl-full-card' });
|
||||
card.createDiv({ cls: 'wl-full-card-header', text: 'Up Next' });
|
||||
if (!bestEntry) {
|
||||
card.createDiv({ cls: 'wl-full-card-sub', text: 'No upcoming titles.' });
|
||||
return;
|
||||
}
|
||||
card.createDiv({ cls: 'wl-full-card-title', text: bestEntry.titleName });
|
||||
const meta = card.createDiv({ cls: 'wl-full-card-meta' });
|
||||
const typeDef = this.getTagDef(bestEntry.type, this.plugin.settings.types);
|
||||
const colored = this.plugin.settings.coloredTypeBadges;
|
||||
const badge = meta.createSpan({
|
||||
cls: colored ? 'wl-badge wl-badge-sm' : 'wl-badge-plain',
|
||||
text: bestEntry.type,
|
||||
});
|
||||
if (colored && typeDef) badge.style.backgroundColor = getThemedColor(bestEntry.type, typeDef.color, this.plugin.settings.colorTheme);
|
||||
const daysUntil = bestEntry.daysUntil;
|
||||
const countdown = daysUntil === 0 ? 'today' : daysUntil === 1 ? 'tomorrow' : `in ${daysUntil} day${daysUntil !== 1 ? 's' : ''}`;
|
||||
meta.createSpan({ text: countdown });
|
||||
meta.createSpan({ cls: 'wl-full-card-sub', text: bestEntry.releaseDate });
|
||||
}
|
||||
|
||||
// ── wl-nowwatching ────────────────────────────────────────────────────────────
|
||||
|
||||
private processWlNowWatching(source: string, el: HTMLElement): void {
|
||||
const isFull = source.trim().toLowerCase() === 'full';
|
||||
el.addClass('wl-wnowwatching');
|
||||
|
||||
const pinnedTitle = this.dataManager.getTitles().find((t) => t.pinned);
|
||||
const pinnedGroupId = this.dataManager.getPinnedGroupId();
|
||||
const pinnedGroup = pinnedGroupId
|
||||
? this.dataManager.getGroups().find((g) => g.id === pinnedGroupId)
|
||||
: null;
|
||||
|
||||
if (isFull) {
|
||||
this.renderNowWatchingFull(el, pinnedTitle ?? null, pinnedGroup ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pinnedTitle && !pinnedGroup) {
|
||||
el.createDiv({ cls: 'wl-nowwatching-card wl-nowwatching-empty', text: 'Pin a title to set it as now watching.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const card = el.createDiv({ cls: 'wl-nowwatching-card' });
|
||||
|
||||
if (pinnedGroup) {
|
||||
card.createSpan({ cls: 'wl-nowwatching-title', text: pinnedGroup.name });
|
||||
const allTitles = this.dataManager.getTitles();
|
||||
const members = pinnedGroup.titleIds
|
||||
.map((id) => allTitles.find((t) => t.id === 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;
|
||||
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}%`;
|
||||
card.createSpan({ cls: 'wl-nowwatching-pct', text: `${progress}%` });
|
||||
return;
|
||||
}
|
||||
|
||||
const pinned = pinnedTitle!;
|
||||
card.createSpan({ cls: 'wl-nowwatching-title', text: pinned.title });
|
||||
|
||||
const typeDef = this.getTagDef(pinned.type, this.plugin.settings.types);
|
||||
const colored = this.plugin.settings.coloredTypeBadges;
|
||||
const badge = card.createSpan({
|
||||
cls: colored ? 'wl-badge wl-badge-sm' : 'wl-badge-plain',
|
||||
text: pinned.type,
|
||||
});
|
||||
if (colored && typeDef) badge.style.backgroundColor = getThemedColor(pinned.type, typeDef.color, this.plugin.settings.colorTheme);
|
||||
|
||||
if (pinned.type !== 'Movie') {
|
||||
const nextEp = this.dataManager.getNextUnwatchedEpisode(pinned);
|
||||
card.createSpan({ cls: 'wl-nowwatching-ep', text: nextEp !== null ? `Ep ${nextEp}` : '✓ Completed' });
|
||||
}
|
||||
|
||||
const progress = this.dataManager.getProgress(pinned);
|
||||
const barWrap = card.createDiv({ cls: 'wl-nowwatching-bar-wrap' });
|
||||
const bar = barWrap.createDiv({ cls: 'wl-nowwatching-bar' });
|
||||
bar.style.width = `${progress}%`;
|
||||
card.createSpan({ cls: 'wl-nowwatching-pct', text: `${progress}%` });
|
||||
}
|
||||
|
||||
private renderNowWatchingFull(
|
||||
el: HTMLElement,
|
||||
pinnedTitle: WatchLogTitle | null,
|
||||
pinnedGroup: import('./types').WatchLogGroup | null,
|
||||
): void {
|
||||
el.empty();
|
||||
const card = el.createDiv({ cls: 'wl-full-card wl-full-card-nw' });
|
||||
card.createDiv({ cls: 'wl-full-card-header', text: 'NOW WATCHING' });
|
||||
|
||||
if (!pinnedTitle && !pinnedGroup) {
|
||||
card.createDiv({ cls: 'wl-full-card-sub', text: 'Pin a title to set it as now watching.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pinnedGroup) {
|
||||
card.createDiv({ cls: 'wl-full-card-title', text: pinnedGroup.name });
|
||||
const allTitles = this.dataManager.getTitles();
|
||||
const members = pinnedGroup.titleIds
|
||||
.map((id) => allTitles.find((t) => t.id === 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 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' });
|
||||
barCol.createDiv({ cls: 'wl-nw-pct', text: `${progress}%` });
|
||||
barCol.createDiv({ cls: 'wl-full-card-bar-wrap' }).createDiv({ cls: 'wl-full-card-bar' }).style.width = `${progress}%`;
|
||||
return;
|
||||
}
|
||||
|
||||
const pinned = pinnedTitle!;
|
||||
card.createDiv({ cls: 'wl-full-card-title', text: pinned.title });
|
||||
|
||||
const progress = this.dataManager.getProgress(pinned);
|
||||
const bottomRow = card.createDiv({ cls: 'wl-nw-bottom-row' });
|
||||
|
||||
const typeDef = this.getTagDef(pinned.type, this.plugin.settings.types);
|
||||
const colored = this.plugin.settings.coloredTypeBadges;
|
||||
const badge = bottomRow.createSpan({
|
||||
cls: colored ? 'wl-badge wl-badge-sm' : 'wl-badge-plain',
|
||||
text: pinned.type,
|
||||
});
|
||||
if (colored && typeDef) badge.style.backgroundColor = getThemedColor(pinned.type, typeDef.color, this.plugin.settings.colorTheme);
|
||||
|
||||
const barCol = bottomRow.createDiv({ cls: 'wl-nw-bar-col' });
|
||||
barCol.createDiv({ cls: 'wl-nw-pct', text: `${progress}%` });
|
||||
barCol.createDiv({ cls: 'wl-full-card-bar-wrap' }).createDiv({ cls: 'wl-full-card-bar' }).style.width = `${progress}%`;
|
||||
}
|
||||
|
||||
// ── wl-now-next ───────────────────────────────────────────────────────────────
|
||||
|
||||
private processWlNowNext(_source: string, el: HTMLElement): void {
|
||||
el.addClass('wl-wnownext');
|
||||
|
||||
const pinnedTitle = this.dataManager.getTitles().find((t) => t.pinned);
|
||||
const pinnedGroupId = this.dataManager.getPinnedGroupId();
|
||||
const pinnedGroup = pinnedGroupId
|
||||
? this.dataManager.getGroups().find((g) => g.id === pinnedGroupId)
|
||||
: null;
|
||||
|
||||
const entries = this.dataManager.getAirtimeEntries();
|
||||
const titles = this.dataManager.getTitles();
|
||||
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);
|
||||
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) {
|
||||
bestMs = ms;
|
||||
const daysUntil = Math.round((ms - now) / 86400000);
|
||||
bestEntry = { titleName: title.title, type: title.type, releaseDate: entry.schedule.releaseDate, daysUntil };
|
||||
}
|
||||
}
|
||||
|
||||
this.renderNowNextCard(el, pinnedTitle ?? null, pinnedGroup ?? null, bestEntry);
|
||||
}
|
||||
|
||||
private renderNowNextCard(
|
||||
el: HTMLElement,
|
||||
pinnedTitle: WatchLogTitle | null,
|
||||
pinnedGroup: import('./types').WatchLogGroup | null,
|
||||
bestEntry: { titleName: string; type: string; releaseDate: string; daysUntil: number } | null,
|
||||
): void {
|
||||
el.empty();
|
||||
const card = el.createDiv({ cls: 'wl-full-card wl-full-card-now-next' });
|
||||
const cols = card.createDiv({ cls: 'wl-double-cols' });
|
||||
|
||||
// Left column: Now Watching
|
||||
const nowCol = cols.createDiv({ cls: 'wl-double-col' });
|
||||
nowCol.createDiv({ cls: 'wl-full-card-header', text: 'NOW WATCHING' });
|
||||
|
||||
if (!pinnedTitle && !pinnedGroup) {
|
||||
nowCol.createDiv({ cls: 'wl-full-card-sub', text: 'Nothing pinned.' });
|
||||
} else if (pinnedGroup) {
|
||||
nowCol.createDiv({ cls: 'wl-full-card-title', text: pinnedGroup.name });
|
||||
const allTitles = this.dataManager.getTitles();
|
||||
const members = pinnedGroup.titleIds
|
||||
.map((id) => allTitles.find((t) => t.id === 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;
|
||||
nowCol.createDiv({ cls: 'wl-full-card-sub', text: `${members.length} title${members.length !== 1 ? 's' : ''} · ${progress}%` });
|
||||
} else {
|
||||
const pinned = pinnedTitle!;
|
||||
nowCol.createDiv({ cls: 'wl-full-card-title', text: pinned.title });
|
||||
const progress = this.dataManager.getProgress(pinned);
|
||||
nowCol.createDiv({ cls: 'wl-full-card-sub', text: `${progress}%` });
|
||||
}
|
||||
|
||||
// Vertical separator
|
||||
cols.createDiv({ cls: 'wl-vert-sep' });
|
||||
|
||||
// Right column: Up Next
|
||||
const nextCol = cols.createDiv({ cls: 'wl-double-col' });
|
||||
nextCol.createDiv({ cls: 'wl-full-card-header', text: 'UPCOMING NEXT' });
|
||||
|
||||
if (!bestEntry) {
|
||||
nextCol.createDiv({ cls: 'wl-full-card-sub', text: 'No upcoming.' });
|
||||
} else {
|
||||
nextCol.createDiv({ cls: 'wl-full-card-title', text: bestEntry.titleName });
|
||||
const daysUntil = bestEntry.daysUntil;
|
||||
const countdown = daysUntil === 0 ? 'today' : daysUntil === 1 ? 'tomorrow' : `in ${daysUntil}d`;
|
||||
nextCol.createDiv({ cls: 'wl-full-card-sub', text: `${bestEntry.releaseDate} · ${countdown}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
299
src/main.ts
Normal file
299
src/main.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import { App, Editor, FuzzySuggestModal, MarkdownView, Notice, Plugin } from 'obsidian';
|
||||
import { DEFAULT_SETTINGS, WatchLogPluginSettings, AirtimeSchedule } from './types';
|
||||
import { DataManager } from './DataManager';
|
||||
import { ApiService } from './ApiService';
|
||||
import { HistoryManager } from './HistoryManager';
|
||||
import { WatchLogView, WATCHLOG_VIEW_TYPE } from './WatchLogView';
|
||||
import { WatchLogSettingsTab } from './SettingsTab';
|
||||
import { AddTitleModal } from './AddTitleModal';
|
||||
import { InsertWidgetModal } from './InsertWidgetModal';
|
||||
import { WidgetRenderer } from './WidgetRenderer';
|
||||
|
||||
export default class WatchLogPlugin extends Plugin {
|
||||
settings: WatchLogPluginSettings = DEFAULT_SETTINGS;
|
||||
dataManager: DataManager = new DataManager(this);
|
||||
apiService: ApiService = new ApiService('', '');
|
||||
historyManager: HistoryManager = new HistoryManager(this);
|
||||
|
||||
// Runtime import progress state (not persisted)
|
||||
importProgress: { current: number; total: number; cancel: () => void } | null = null;
|
||||
|
||||
// Track which entry+day combos have already fired a notification
|
||||
private notifiedEntries: Set<string> = new Set();
|
||||
|
||||
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.historyManager = new HistoryManager(this);
|
||||
await this.historyManager.load();
|
||||
this.dataManager.setHistoryManager(this.historyManager);
|
||||
|
||||
// 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);
|
||||
|
||||
// Ribbon icon
|
||||
this.addRibbonIcon('tv', 'Watchlog', () => {
|
||||
void this.activateView();
|
||||
});
|
||||
|
||||
// Command: Open panel
|
||||
this.addCommand({
|
||||
id: 'open-panel',
|
||||
name: 'Open panel',
|
||||
callback: () => void this.activateView(),
|
||||
});
|
||||
|
||||
// Command: Add title
|
||||
this.addCommand({
|
||||
id: 'add-title',
|
||||
name: 'Add title',
|
||||
callback: () => {
|
||||
new AddTitleModal(this.app, this, this.dataManager, () => {
|
||||
void this.activateView();
|
||||
}).open();
|
||||
},
|
||||
});
|
||||
|
||||
// Command: Insert widget
|
||||
this.addCommand({
|
||||
id: 'insert-widget',
|
||||
name: 'Insert widget',
|
||||
editorCallback: (editor: Editor, _view: MarkdownView) => {
|
||||
this.openWidgetPalette(editor);
|
||||
},
|
||||
});
|
||||
|
||||
// Command: Search title
|
||||
this.addCommand({
|
||||
id: 'search-title',
|
||||
name: 'Search title',
|
||||
callback: () => {
|
||||
const titles = this.dataManager.getTitles();
|
||||
new InsertWidgetModal(this.app, titles, (title) => {
|
||||
void this.activateView().then(() => {
|
||||
new Notice(`"${title.title}" — ${title.type} · ${title.status}`);
|
||||
});
|
||||
}).open();
|
||||
},
|
||||
});
|
||||
|
||||
// Settings tab
|
||||
this.addSettingTab(new WatchLogSettingsTab(this.app, this));
|
||||
}
|
||||
|
||||
onunload(): void {
|
||||
|
||||
}
|
||||
|
||||
private startAirtimeScheduler(): void {
|
||||
// Check immediately on startup, then every 60 seconds
|
||||
this.checkAirtimeNotifications();
|
||||
this.registerInterval(window.setInterval(() => {
|
||||
this.checkAirtimeNotifications();
|
||||
}, 60000));
|
||||
}
|
||||
|
||||
/** Allow AirtimeTab to prevent the scheduler from double-firing after a manual tick. */
|
||||
markAirtimeHandled(key: string): void {
|
||||
this.notifiedEntries.add(key);
|
||||
}
|
||||
|
||||
private checkAirtimeNotifications(): void {
|
||||
const now = new Date();
|
||||
const currentHHMM =
|
||||
`${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
const today =
|
||||
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const entries = this.dataManager.getAirtimeEntries();
|
||||
for (const entry of entries) {
|
||||
// Only fire when the entry has a specific time set and it matches now
|
||||
if (!entry.schedule.releaseTime) continue;
|
||||
if (entry.schedule.releaseTime !== currentHHMM) continue;
|
||||
|
||||
// Use direct day-of-week / date check to avoid the getAirtimeCountdown rollover bug
|
||||
if (!this.isScheduledForToday(entry.schedule)) continue;
|
||||
|
||||
// Deduplicate: only fire once per entry per day
|
||||
const notifKey = `${entry.id}-${today}`;
|
||||
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);
|
||||
|
||||
// Auto-increment episode for series/anime
|
||||
if ((title.totalEpisodes ?? 0) > 1 && entry.currentEpisode !== undefined) {
|
||||
const maxEps = entry.totalEpisodes ?? title.totalEpisodes;
|
||||
const nextEp = entry.currentEpisode + 1;
|
||||
if (nextEp <= maxEps) {
|
||||
entry.currentEpisode = nextEp;
|
||||
void this.dataManager.updateAirtimeEntry(entry);
|
||||
}
|
||||
// If final episode: leave in Upcoming, you handle via tick button
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if today is a day when the given schedule fires.
|
||||
* Avoids the rollover issue in getAirtimeNextDate/getAirtimeCountdown.
|
||||
*/
|
||||
private isScheduledForToday(schedule: AirtimeSchedule): boolean {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
if (schedule.recurrence === 'daily') return true;
|
||||
|
||||
if (schedule.recurrence === 'weekly') {
|
||||
return schedule.dayOfWeek === now.getDay();
|
||||
}
|
||||
|
||||
if (schedule.recurrence === 'monthly') {
|
||||
return now.getDate() === (schedule.dayOfMonth ?? -1);
|
||||
}
|
||||
|
||||
if (schedule.recurrence === 'once' && schedule.releaseDate) {
|
||||
const rel = new Date(schedule.releaseDate + 'T00:00:00');
|
||||
const relDay = new Date(rel.getFullYear(), rel.getMonth(), rel.getDate());
|
||||
return relDay.getTime() === today.getTime();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
const saved = (await this.loadData()) as {
|
||||
settings?: Partial<WatchLogPluginSettings> & { defaultView?: string };
|
||||
} | null;
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, saved?.settings ?? {});
|
||||
if (saved?.settings?.listFilters) {
|
||||
this.settings.listFilters = Object.assign(
|
||||
{},
|
||||
DEFAULT_SETTINGS.listFilters,
|
||||
saved.settings.listFilters,
|
||||
);
|
||||
}
|
||||
// Migrate old defaultView value 'list' → 'watchlist'
|
||||
if ((this.settings.defaultView as string) === 'list') {
|
||||
this.settings.defaultView = 'watchlist';
|
||||
}
|
||||
// Ensure array fields are never undefined after a partial merge
|
||||
if (!this.settings.types?.length) this.settings.types = DEFAULT_SETTINGS.types;
|
||||
if (!this.settings.statuses?.length) this.settings.statuses = DEFAULT_SETTINGS.statuses;
|
||||
if (!this.settings.reviews?.length) this.settings.reviews = DEFAULT_SETTINGS.reviews;
|
||||
if (!this.settings.priorities?.length) this.settings.priorities = DEFAULT_SETTINGS.priorities;
|
||||
if (!this.settings.seasonPalette?.length) this.settings.seasonPalette = DEFAULT_SETTINGS.seasonPalette;
|
||||
// Migrate Plan to watch color from old grey to new teal
|
||||
const ptw = this.settings.statuses.find((s) => s.name === 'Plan to watch');
|
||||
if (ptw && ptw.color === '#888780') ptw.color = '#00A9A5';
|
||||
|
||||
// Ensure "To be released" status exists (migration for existing users)
|
||||
if (!this.settings.statuses.find((s) => s.name === 'To be released')) {
|
||||
const completedIdx = this.settings.statuses.findIndex((s) => s.name === 'Completed');
|
||||
const insertAt = completedIdx >= 0 ? completedIdx + 1 : this.settings.statuses.length;
|
||||
this.settings.statuses.splice(insertAt, 0, { name: 'To be released', color: '#E8873A' });
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
const current = ((await this.loadData()) as Record<string, unknown> | null) ?? {};
|
||||
await this.saveData({ ...current, settings: this.settings });
|
||||
}
|
||||
|
||||
private openWidgetPalette(editor: Editor): void {
|
||||
const widgets = [
|
||||
{ name: 'wl-todo — Track a title (full)', id: 'wl-todo' },
|
||||
{ name: 'wl-todo mini — Track a title (compact)', id: 'wl-todo:mini' },
|
||||
{ name: 'wl-stat: watched — Time watched (mini)', id: 'wl-stat:watched' },
|
||||
{ name: 'wl-stat: completed — Completed titles (mini)', id: 'wl-stat:completed' },
|
||||
{ name: 'wl-stat: remaining — Time remaining (mini)', id: 'wl-stat:remaining' },
|
||||
{ name: 'wl-stat: time — Watched + remaining (mini)', id: 'wl-stat:time' },
|
||||
{ name: 'wl-upcoming: next — Next upcoming (mini)', id: 'wl-upcoming:next' },
|
||||
{ name: 'wl-stat: time completed full — Time · Remaining · Completed card', id: 'wl-stat:time completed full' },
|
||||
{ name: 'wl-now-next — Now Watching + Up Next card', id: 'wl-now-next' },
|
||||
];
|
||||
|
||||
new WidgetSelectModal(this.app, widgets, (selected) => {
|
||||
if (selected.id === 'wl-todo' || selected.id === 'wl-todo:mini') {
|
||||
const titles = this.dataManager.getTitles();
|
||||
if (titles.length === 0) {
|
||||
new Notice('No titles in your watchlog library yet.');
|
||||
return;
|
||||
}
|
||||
const isMini = selected.id === 'wl-todo:mini';
|
||||
new InsertWidgetModal(this.app, titles, (title) => {
|
||||
if (isMini) {
|
||||
editor.replaceSelection(`\`\`\`wl-todo\n${title.title}\nmini\n\`\`\``);
|
||||
} else {
|
||||
editor.replaceSelection(`\`\`\`wl-todo\n${title.title}\n\`\`\``);
|
||||
}
|
||||
}).open();
|
||||
} else if (selected.id === 'wl-stat:watched') {
|
||||
editor.replaceSelection('```wl-stat\nwatched\n```');
|
||||
} else if (selected.id === 'wl-stat:completed') {
|
||||
editor.replaceSelection('```wl-stat\ncompleted\n```');
|
||||
} else if (selected.id === 'wl-stat:remaining') {
|
||||
editor.replaceSelection('```wl-stat\nremaining\n```');
|
||||
} else if (selected.id === 'wl-stat:time') {
|
||||
editor.replaceSelection('```wl-stat\ntime\n```');
|
||||
} else if (selected.id === 'wl-upcoming:next') {
|
||||
editor.replaceSelection('```wl-upcoming\nnext\n```');
|
||||
} else if (selected.id === 'wl-stat:time completed full') {
|
||||
editor.replaceSelection('```wl-stat\ntime completed full\n```');
|
||||
} else if (selected.id === 'wl-now-next') {
|
||||
editor.replaceSelection('```wl-now-next\n```');
|
||||
}
|
||||
}).open();
|
||||
}
|
||||
|
||||
private async activateView(): Promise<void> {
|
||||
const { workspace } = this.app;
|
||||
|
||||
const existing = workspace.getLeavesOfType(WATCHLOG_VIEW_TYPE);
|
||||
if (existing.length > 0) {
|
||||
void workspace.revealLeaf(existing[0]!);
|
||||
return;
|
||||
}
|
||||
|
||||
const leaf = workspace.getLeaf('tab');
|
||||
await leaf.setViewState({ type: WATCHLOG_VIEW_TYPE, active: true });
|
||||
void workspace.revealLeaf(leaf);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Widget selection modal ────────────────────────────────────────────────────
|
||||
|
||||
interface WidgetItem { name: string; id: string; }
|
||||
|
||||
class WidgetSelectModal extends FuzzySuggestModal<WidgetItem> {
|
||||
private items: WidgetItem[];
|
||||
private onSelect: (item: WidgetItem) => void;
|
||||
|
||||
constructor(app: App, items: WidgetItem[], onSelect: (item: WidgetItem) => void) {
|
||||
super(app);
|
||||
this.items = items;
|
||||
this.onSelect = onSelect;
|
||||
this.setPlaceholder('Select a widget to insert...');
|
||||
}
|
||||
|
||||
getItems(): WidgetItem[] { return this.items; }
|
||||
getItemText(item: WidgetItem): string { return item.name; }
|
||||
onChooseItem(item: WidgetItem): void { this.onSelect(item); }
|
||||
}
|
||||
515
src/types.ts
Normal file
515
src/types.ts
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
export interface Season {
|
||||
name: string;
|
||||
episodes: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface WatchLogTitle {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
review: string;
|
||||
rating: number;
|
||||
notes: string;
|
||||
dateStarted: string | null;
|
||||
dateFinished: string | null;
|
||||
dateAdded: string;
|
||||
dateModified: string;
|
||||
totalEpisodes: number;
|
||||
episodeDuration: number;
|
||||
releaseDate: string | null;
|
||||
externalLink: string;
|
||||
seasons: Season[];
|
||||
watchedEpisodes: number[];
|
||||
malId?: number;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
export interface WatchLogGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
titleIds: string[];
|
||||
dateAdded: string;
|
||||
}
|
||||
|
||||
export interface TagDefinition {
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// ── Custom Lists types ────────────────────────────────────────────────────────
|
||||
|
||||
export interface CustomListColumn {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'text' | 'number' | 'select';
|
||||
locked?: boolean;
|
||||
options?: string[]; // only for type: 'select'
|
||||
bold?: boolean; // only for type: 'text' | 'number'
|
||||
italic?: boolean; // only for type: 'text' | 'number'
|
||||
}
|
||||
|
||||
export interface CustomListRow {
|
||||
id: string;
|
||||
checked?: boolean;
|
||||
[key: string]: string | number | boolean | undefined;
|
||||
}
|
||||
|
||||
// ── Maybe types (Feature 2c) ──────────────────────────────────────────────────
|
||||
|
||||
export interface MaybeTitle {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
releaseDate: string | null;
|
||||
externalLink: string;
|
||||
totalEpisodes: number;
|
||||
episodeDuration: number;
|
||||
dateAdded: string;
|
||||
}
|
||||
|
||||
export interface CustomList {
|
||||
name: string;
|
||||
columns: CustomListColumn[];
|
||||
rows: CustomListRow[];
|
||||
notes: string;
|
||||
}
|
||||
|
||||
// ── Drafts types ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DraftPersistState {
|
||||
dismissed: string[]; // lowercased title keys
|
||||
added: string[]; // lowercased title keys
|
||||
firstSeen: Record<string, string>; // lowercase key → ISO timestamp
|
||||
titleDisplay: Record<string, string>; // lowercase key → original-case display title
|
||||
}
|
||||
|
||||
export interface WatchLogPluginSettings {
|
||||
colorTheme: 'default' | 'nightfall' | 'bluez';
|
||||
defaultView: 'dashboard' | 'watchlist';
|
||||
autoCompleteOnLastEpisode: boolean;
|
||||
setFinishDateAutomatically: boolean;
|
||||
syncWidgetWithMainList: boolean;
|
||||
omdbApiKey: string;
|
||||
tmdbApiKey: string;
|
||||
activeApi: 'OMDb' | 'TMDB';
|
||||
types: TagDefinition[];
|
||||
statuses: TagDefinition[];
|
||||
reviews: TagDefinition[];
|
||||
priorities: TagDefinition[];
|
||||
rootFolder: string;
|
||||
autoCreateFolders: boolean;
|
||||
coloredTypeBadges: boolean;
|
||||
seasonPalette: string[];
|
||||
dashboardCardStyle: 'circles' | 'rectangles';
|
||||
episodeNumbering: 'absolute' | 'per-season';
|
||||
customListsFolder: string;
|
||||
defaultCustomColumns: CustomListColumn[];
|
||||
listFilters: {
|
||||
typeExclude: string[];
|
||||
statusExclude: string[];
|
||||
groupExclude: string[];
|
||||
ratingExclude: string[];
|
||||
priorityExclude: string[];
|
||||
sort: string;
|
||||
sortDir?: 'asc' | 'desc';
|
||||
secondSort?: string;
|
||||
secondSortDir?: 'asc' | 'desc';
|
||||
ratingEmptyOnly?: boolean;
|
||||
priorityEmptyOnly?: boolean;
|
||||
recentlyArrivedOnly?: boolean;
|
||||
};
|
||||
draftsVaultTag: string;
|
||||
draftsAfterAdding: 'remove' | 'keep';
|
||||
customListTabOrder: string[];
|
||||
}
|
||||
|
||||
// ── Airtime types ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type AirtimeRecurrence = 'once' | 'daily' | 'weekly' | 'monthly';
|
||||
|
||||
export interface AirtimeSchedule {
|
||||
recurrence: AirtimeRecurrence;
|
||||
releaseDate?: string; // YYYY-MM-DD, for 'once'
|
||||
releaseTime?: string; // HH:MM, for any recurrence
|
||||
dayOfWeek?: number; // 0=Sunday … 6=Saturday, for 'weekly'
|
||||
dayOfMonth?: number; // 1–31, for 'monthly'
|
||||
}
|
||||
|
||||
export interface AirtimeEntry {
|
||||
id: string;
|
||||
titleId: string;
|
||||
schedule: AirtimeSchedule;
|
||||
currentSeason?: number;
|
||||
currentEpisode?: number;
|
||||
/** Total episodes to track for final-episode detection (synced from Watchlist). */
|
||||
totalEpisodes?: number;
|
||||
/** Total seasons (informational). */
|
||||
totalSeasons?: number;
|
||||
/** YYYY-MM-DD: set when you tick an episode, so the countdown resets to the next occurrence. */
|
||||
lastAcknowledgedDate?: string;
|
||||
dateAdded: string;
|
||||
}
|
||||
|
||||
export interface SavedFilterPreset {
|
||||
typeExclude: string[];
|
||||
statusExclude: string[];
|
||||
groupExclude: string[];
|
||||
ratingExclude: string[];
|
||||
priorityExclude: string[];
|
||||
ratingEmptyOnly?: boolean;
|
||||
priorityEmptyOnly?: boolean;
|
||||
recentlyArrivedOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface WatchLogData {
|
||||
titles: WatchLogTitle[];
|
||||
groups: WatchLogGroup[];
|
||||
settings: Partial<WatchLogPluginSettings>;
|
||||
airtime?: AirtimeEntry[];
|
||||
maybe?: MaybeTitle[];
|
||||
pinnedGroupId?: string | null;
|
||||
drafts?: DraftPersistState;
|
||||
savedFilterPreset?: SavedFilterPreset | null;
|
||||
}
|
||||
|
||||
// ── Airtime utility functions ─────────────────────────────────────────────────
|
||||
|
||||
export function getAirtimeNextDate(schedule: AirtimeSchedule): Date | null {
|
||||
const now = new Date();
|
||||
const todayMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
if (schedule.recurrence === 'once') {
|
||||
if (!schedule.releaseDate) return null;
|
||||
const d = new Date(schedule.releaseDate + 'T12:00:00');
|
||||
if (schedule.releaseTime) {
|
||||
const parts = schedule.releaseTime.split(':');
|
||||
d.setHours(parseInt(parts[0] ?? '0'), parseInt(parts[1] ?? '0'), 0, 0);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
if (schedule.recurrence === 'daily') {
|
||||
const d = new Date(todayMidnight);
|
||||
if (schedule.releaseTime) {
|
||||
const parts = schedule.releaseTime.split(':');
|
||||
d.setHours(parseInt(parts[0] ?? '0'), parseInt(parts[1] ?? '0'), 0, 0);
|
||||
if (d <= now) d.setDate(d.getDate() + 1);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
if (schedule.recurrence === 'weekly' && schedule.dayOfWeek !== undefined) {
|
||||
const currentDay = now.getDay();
|
||||
let daysUntil = (schedule.dayOfWeek - currentDay + 7) % 7;
|
||||
const d = new Date(todayMidnight);
|
||||
if (schedule.releaseTime) {
|
||||
const parts = schedule.releaseTime.split(':');
|
||||
d.setHours(parseInt(parts[0] ?? '0'), parseInt(parts[1] ?? '0'), 0, 0);
|
||||
if (daysUntil === 0 && d <= now) daysUntil = 7;
|
||||
}
|
||||
d.setDate(d.getDate() + daysUntil);
|
||||
return d;
|
||||
}
|
||||
|
||||
if (schedule.recurrence === 'monthly' && schedule.dayOfMonth) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth(), schedule.dayOfMonth);
|
||||
if (schedule.releaseTime) {
|
||||
const parts = schedule.releaseTime.split(':');
|
||||
d.setHours(parseInt(parts[0] ?? '0'), parseInt(parts[1] ?? '0'), 0, 0);
|
||||
}
|
||||
if (d <= now) d.setMonth(d.getMonth() + 1);
|
||||
return d;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getAirtimeCountdown(
|
||||
schedule: AirtimeSchedule,
|
||||
): { label: string; kind: 'today' | 'tomorrow' | 'days' | 'missed' } {
|
||||
const next = getAirtimeNextDate(schedule);
|
||||
if (!next) return { label: '—', kind: 'days' };
|
||||
|
||||
const now = new Date();
|
||||
if (schedule.recurrence === 'once' && next < now) {
|
||||
return { label: 'Missed', kind: 'missed' };
|
||||
}
|
||||
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const nextMidnight = new Date(next.getFullYear(), next.getMonth(), next.getDate());
|
||||
|
||||
if (nextMidnight.getTime() === today.getTime()) return { label: 'Today', kind: 'today' };
|
||||
if (nextMidnight.getTime() === tomorrow.getTime()) return { label: 'Tomorrow', kind: 'tomorrow' };
|
||||
|
||||
const daysUntil = Math.round((nextMidnight.getTime() - today.getTime()) / 86400000);
|
||||
if (daysUntil < 0) return { label: 'Missed', kind: 'missed' };
|
||||
return { label: `in ${daysUntil} days`, kind: 'days' };
|
||||
}
|
||||
|
||||
export function getAirtimeScheduleString(schedule: AirtimeSchedule): string {
|
||||
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const time = schedule.releaseTime ? ` · ${schedule.releaseTime}` : '';
|
||||
|
||||
if (schedule.recurrence === 'once') {
|
||||
if (!schedule.releaseDate) return 'No date set';
|
||||
const d = new Date(schedule.releaseDate + 'T12:00:00');
|
||||
return `Release date · ${d.getDate()} ${MONTHS[d.getMonth()] ?? ''} ${d.getFullYear()}${time}`;
|
||||
}
|
||||
if (schedule.recurrence === 'daily') return `Every day${time}`;
|
||||
if (schedule.recurrence === 'weekly') {
|
||||
const dayName =
|
||||
schedule.dayOfWeek !== undefined ? (DAYS[schedule.dayOfWeek] ?? 'Unknown') : 'Unknown';
|
||||
return `Every ${dayName}${time}`;
|
||||
}
|
||||
if (schedule.recurrence === 'monthly') {
|
||||
return `Monthly on day ${schedule.dayOfMonth ?? '?'}${time}`;
|
||||
}
|
||||
return '—';
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: WatchLogPluginSettings = {
|
||||
colorTheme: 'default',
|
||||
defaultView: 'watchlist',
|
||||
autoCompleteOnLastEpisode: true,
|
||||
setFinishDateAutomatically: false,
|
||||
syncWidgetWithMainList: true,
|
||||
omdbApiKey: '',
|
||||
tmdbApiKey: '',
|
||||
activeApi: 'OMDb',
|
||||
types: [
|
||||
{ name: 'Anime', color: '#1D9E75' },
|
||||
{ name: 'Movie', color: '#378ADD' },
|
||||
{ name: 'TV Show', color: '#BA7517' },
|
||||
{ name: 'Korean TV Show', color: '#7F77DD' },
|
||||
{ name: 'Animation', color: '#D85A30' },
|
||||
],
|
||||
statuses: [
|
||||
{ name: 'Watching', color: '#1D9E75' },
|
||||
{ name: 'Plan to watch', color: '#00A9A5' },
|
||||
{ name: 'Completed', color: '#378ADD' },
|
||||
{ name: 'To be released', color: '#E8873A' },
|
||||
{ name: 'Dropped', color: '#E24B4A' },
|
||||
],
|
||||
reviews: [
|
||||
{ name: 'Nah', color: '#E24B4A' },
|
||||
{ name: 'Awesome', color: '#1D9E75' },
|
||||
{ name: 'Marvelous', color: '#7F77DD' },
|
||||
],
|
||||
priorities: [
|
||||
{ name: 'Low', color: '#888780' },
|
||||
{ name: 'Medium', color: '#3b82f6' },
|
||||
{ name: 'High', color: '#E24B4A' },
|
||||
],
|
||||
rootFolder: 'WatchLog',
|
||||
autoCreateFolders: true,
|
||||
coloredTypeBadges: true,
|
||||
dashboardCardStyle: 'circles',
|
||||
episodeNumbering: 'absolute',
|
||||
customListsFolder: 'WatchLog/CustomLists',
|
||||
defaultCustomColumns: [],
|
||||
draftsVaultTag: '#watchlog',
|
||||
draftsAfterAdding: 'keep',
|
||||
customListTabOrder: [],
|
||||
listFilters: {
|
||||
typeExclude: [],
|
||||
statusExclude: [],
|
||||
groupExclude: [],
|
||||
ratingExclude: [],
|
||||
priorityExclude: [],
|
||||
sort: 'dateAdded',
|
||||
sortDir: 'desc',
|
||||
secondSort: 'none',
|
||||
secondSortDir: 'asc',
|
||||
},
|
||||
seasonPalette: [
|
||||
'#1D9E75',
|
||||
'#BA7517',
|
||||
'#378ADD',
|
||||
'#7F77DD',
|
||||
'#D85A30',
|
||||
'#D4537E',
|
||||
'#639922',
|
||||
'#888780',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the user-defined badge color for a type/status/priority.
|
||||
* Themes do not override Type, Status, or Priority colors — those are
|
||||
* fully user-controlled. The theme parameter is accepted for API compatibility
|
||||
* but is intentionally unused.
|
||||
*/
|
||||
export function getThemedColor(name: string, defaultColor: string, theme: string): string {
|
||||
return defaultColor;
|
||||
}
|
||||
|
||||
// ── Shared utility ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Converts a stored YYYY-MM-DD date string to DD/MM/YYYY for display. */
|
||||
export function formatDateDisplay(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '';
|
||||
const m = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (m) return `${m[3]}/${m[2]}/${m[1]}`;
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
/** Parses a DD/MM/YYYY user input string into YYYY-MM-DD storage format. Returns null if invalid. */
|
||||
export function parseDateInput(str: string): string | null {
|
||||
const trimmed = str.trim();
|
||||
if (!trimmed) return null;
|
||||
const parts = trimmed.split('/');
|
||||
if (parts.length !== 3) return null;
|
||||
const [dd, mm, yyyy] = parts;
|
||||
if (!dd || !mm || !yyyy || yyyy.length !== 4) return null;
|
||||
const d = parseInt(dd, 10), mo = parseInt(mm, 10), y = parseInt(yyyy, 10);
|
||||
if (isNaN(d) || isNaN(mo) || isNaN(y) || mo < 1 || mo > 12 || d < 1 || d > 31) return null;
|
||||
return `${yyyy}-${mm.padStart(2, '0')}-${dd.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Parses a release date in DD/MM/YYYY, DD-MM-YYYY, or YYYY-MM-DD format. Returns YYYY-MM-DD or null. */
|
||||
export function parseReleaseDateInput(str: string): string | null {
|
||||
const trimmed = str.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return trimmed;
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
const m = trimmed.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/);
|
||||
if (m) {
|
||||
const dd = m[1]!.padStart(2, '0');
|
||||
const mm2 = m[2]!.padStart(2, '0');
|
||||
const yyyy = m[3]!;
|
||||
const d = parseInt(dd, 10), mo = parseInt(mm2, 10);
|
||||
if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
|
||||
return `${yyyy}-${mm2}-${dd}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatTime(minutes: number): string {
|
||||
if (minutes <= 0) return '0m';
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
const hStr = h >= 1000 ? String(h).replace(/\B(?=(\d{3})+(?!\d))/g, '.') : String(h);
|
||||
if (h === 0) return `${m}m`;
|
||||
if (m === 0) return `${hStr}h`;
|
||||
return `${hStr}h ${m}m`;
|
||||
}
|
||||
|
||||
// ── API result types ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface AnimeSearchResult {
|
||||
malId: number;
|
||||
title: string;
|
||||
episodes: number;
|
||||
duration: number;
|
||||
releaseDate: string;
|
||||
url: string;
|
||||
seasons: Season[];
|
||||
}
|
||||
|
||||
export interface MediaSearchResult {
|
||||
imdbId: string;
|
||||
title: string;
|
||||
mediaType: 'movie' | 'tv';
|
||||
episodes: number;
|
||||
episodeDuration: number;
|
||||
releaseDate: string;
|
||||
url: string;
|
||||
seasons: Season[];
|
||||
}
|
||||
|
||||
// ── Jikan API shapes ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface JikanAnime {
|
||||
mal_id: number;
|
||||
title: string;
|
||||
title_english: string | null;
|
||||
episodes: number | null;
|
||||
duration: string | null;
|
||||
aired: { from: string | null } | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
// ── OMDb API shapes ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface OmdbSearchItem {
|
||||
Title: string;
|
||||
Year: string;
|
||||
imdbID: string;
|
||||
Type: string;
|
||||
}
|
||||
|
||||
export interface OmdbSearchResponse {
|
||||
Search?: OmdbSearchItem[];
|
||||
Response: string;
|
||||
Error?: string;
|
||||
}
|
||||
|
||||
export interface OmdbDetailResponse {
|
||||
Title: string;
|
||||
Year: string;
|
||||
Released?: string;
|
||||
Runtime?: string;
|
||||
totalSeasons?: string;
|
||||
imdbID: string;
|
||||
Type: string;
|
||||
Response: string;
|
||||
Error?: string;
|
||||
}
|
||||
|
||||
export interface OmdbSeasonResponse {
|
||||
Season: string;
|
||||
Episodes?: Array<{ Episode: string; Title: string; imdbID: string }>;
|
||||
Response: string;
|
||||
}
|
||||
|
||||
// ── TMDB API shapes ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface TmdbSearchItem {
|
||||
id: number;
|
||||
title?: string;
|
||||
name?: string;
|
||||
release_date?: string;
|
||||
first_air_date?: string;
|
||||
}
|
||||
|
||||
export interface TmdbSearchResponse {
|
||||
results?: TmdbSearchItem[];
|
||||
}
|
||||
|
||||
export interface TmdbMovieDetail {
|
||||
id: number;
|
||||
title: string;
|
||||
runtime?: number;
|
||||
release_date?: string;
|
||||
imdb_id?: string;
|
||||
}
|
||||
|
||||
export interface TmdbTvSeason {
|
||||
season_number: number;
|
||||
name: string;
|
||||
episode_count: number;
|
||||
}
|
||||
|
||||
export interface TmdbTvDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
episode_run_time?: number[];
|
||||
first_air_date?: string;
|
||||
number_of_episodes?: number;
|
||||
seasons?: TmdbTvSeason[];
|
||||
}
|
||||
|
||||
export interface TmdbExternalIds {
|
||||
imdb_id?: string;
|
||||
}
|
||||
|
||||
export interface TmdbFindResult {
|
||||
movie_results?: TmdbMovieDetail[];
|
||||
tv_results?: TmdbTvDetail[];
|
||||
}
|
||||
4025
styles.css
Normal file
4025
styles.css
Normal file
File diff suppressed because it is too large
Load diff
30
tsconfig.json
Normal file
30
tsconfig.json
Normal file
|
|
@ -0,0 +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"
|
||||
]
|
||||
}
|
||||
17
version-bump.mjs
Normal file
17
version-bump.mjs
Normal file
|
|
@ -0,0 +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'));
|
||||
}
|
||||
14
versions.json
Normal file
14
versions.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
Loading…
Reference in a new issue