Initial release v1.0.9

This commit is contained in:
Shynkro 2026-04-26 18:19:55 +01:00
commit 971bb0961e
41 changed files with 21286 additions and 0 deletions

10
.editorconfig Normal file
View 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
View 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
View 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
View file

@ -0,0 +1 @@
tag-version-prefix=""

6
CHANGELOG.md Normal file
View 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
View 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
View 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 (05 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
![Dashboard](screenshots/dashboard_tab.png)
![Watchlist](screenshots/watchlist_tab.png)
![Upcoming](screenshots/upcoming_tab.png)
![Expanded Title](screenshots/expanded_title.png)
---
## 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
View 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
View 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

File diff suppressed because one or more lines are too long

10
manifest.json Normal file
View 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

File diff suppressed because it is too large Load diff

30
package.json Normal file
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

102
src/AddFromUrlModal.ts Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

349
src/ApiService.ts Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

396
src/DashboardTab.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

233
src/MaybeAddModal.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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; // 131, 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

File diff suppressed because it is too large Load diff

30
tsconfig.json Normal file
View 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
View 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
View 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"
}