feat: v2.1.0 - note open button, mobile toolbar toggle, season log event, add chooser, reading/history sync fix

This commit is contained in:
Bogdan Stefan 2026-06-17 21:33:35 +01:00
parent 7df3ce4dc5
commit d66b849bc9
20 changed files with 611 additions and 145 deletions

View file

@ -2,6 +2,18 @@
All notable changes to WatchLog are documented here.
## [2.1.0] - 2026-06-17
### Added
- **Open note button** — a new file-text icon in the title detail modal opens the title's `.md` note directly in Obsidian. Available in both the Watchlist and Reading (Books + Manga) modals.
- **Mobile toolbar toggle** — on mobile, a chevron button collapses the action buttons behind a toggle so the search bar stays usable by default. Search ↔ actions swap with a crossfade. Added to both the Watchlist and Reading tabs.
- **"Season watched" log event** — ticking an entire season now records a single summary "Season X watched" entry in the Log, but only when that action completes the season.
- **Add chooser modal** — the "+ add" button now opens a small chooser offering "Add from URL" and "Add manually / via API", consolidating the two add paths into one entry point (desktop and mobile).
### Fixed
- Reading titles not appearing on mobile.
## [2.0.0] - 2026-06-07
##### Added

86
main.js

File diff suppressed because one or more lines are too long

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "watchlog-plugin",
"version": "2.0.0",
"version": "2.1.0",
"description": "Track your anime, movies, books, manga and TV shows; with episode tracking, progress stats, upcoming release alerts, and embeddable widgets.",
"main": "main.js",
"type": "module",

View file

@ -0,0 +1,43 @@
import { App, Modal } from 'obsidian';
export type AddTitleChoice = 'url' | 'manual';
/**
* Small chooser modal shown when adding a title to the Watchlist. Offers two
* inline options (Add from URL / Add manually or via API), each routing to its
* own corresponding Add modal. This modal only routes it holds no form logic.
*/
export class AddTitleChoiceModal extends Modal {
constructor(
app: App,
private onChoice: (choice: AddTitleChoice) => void,
) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('wl-draft-choice-modal');
this.titleEl.setText('Add title');
const grid = contentEl.createDiv({ cls: 'wl-draft-choice-grid' });
const makeOption = (label: string, choice: AddTitleChoice): void => {
const btn = grid.createEl('button', { cls: 'wl-draft-choice-btn' });
btn.createDiv({ cls: 'wl-draft-choice-label', text: label });
btn.addEventListener('click', () => {
this.close();
this.onChoice(choice);
});
};
makeOption('Add from URL', 'url');
makeOption('Add manually / via API', 'manual');
}
onClose(): void {
this.contentEl.empty();
}
}

View file

@ -220,6 +220,17 @@ export class DataManager {
this.lastSelfSaveTime = Date.now();
}
/**
* Persist the full in-memory data object to data.json. Reading and activity-log
* data now live as keys (`reading` / `history`) inside this same object, so
* ReadingDataManager and HistoryManager route their saves through here. Going
* through saveOnly() also stamps lastSelfSaveTime, so the 'raw' watcher ignores
* the echo of these writes (no spurious reload + re-render).
*/
async persist(): Promise<void> {
await this.saveOnly();
}
async save(): Promise<void> {
// An immediate save supersedes any pending debounced one — the in-memory
// state already contains those changes.
@ -423,8 +434,8 @@ export class DataManager {
}
// ── Reading → Upcoming bridge ──────────────────────────────────────────────────
// Reading items (Book/Manga) live in reading.json, but their Upcoming entries
// share the same airtime list as watch titles — mirroring autoAddToUpcoming.
// Reading items (Book/Manga) live under the data.reading key, but their Upcoming
// entries share the same airtime list as watch titles — mirroring autoAddToUpcoming.
/**
* Auto-adds a reading item with a future release date to Upcoming, mirroring
@ -787,6 +798,11 @@ export class DataManager {
void (async () => {
await this.load();
this.notifyListeners();
// Reading + activity-log data live inside data.json now, so a synced
// data.json carries them too. Re-bind those managers' in-memory views
// to the freshly-loaded object and refresh their UIs (Reading / Log tabs).
await this.plugin.readingDataManager?.adoptExternalChange();
this.plugin.historyManager?.adoptExternalChange();
})();
}
});
@ -974,10 +990,20 @@ export class DataManager {
await this.updateTitle(title);
}
async markSeasonWatched(id: string, episodeNumbers: number[], watched: boolean): Promise<void> {
async markSeasonWatched(
id: string,
episodeNumbers: number[],
watched: boolean,
seasonLabel?: string,
): Promise<void> {
const title = this.getTitle(id);
if (!title) return;
// Capture season-completion state BEFORE the tick, so we can detect a
// not-complete → complete transition and log a single summary event.
const before = new Set(title.watchedEpisodes);
const wasComplete = episodeNumbers.length > 0 && episodeNumbers.every((ep) => before.has(ep));
if (watched) {
const set = new Set(title.watchedEpisodes);
for (const ep of episodeNumbers) set.add(ep);
@ -987,6 +1013,21 @@ export class DataManager {
title.watchedEpisodes = title.watchedEpisodes.filter((e) => !remove.has(e));
}
// "Tick entire season" summary event: emit ONE history entry (no per-episode
// events) only when this action makes the season newly complete. Skip if the
// season was already complete, if we're clearing, or if there's no season label.
if (watched && seasonLabel) {
const after = new Set(title.watchedEpisodes);
const nowComplete = episodeNumbers.length > 0 && episodeNumbers.every((ep) => after.has(ep));
if (!wasComplete && nowComplete) {
const now = new Date().toISOString();
void this.historyManager?.log(
`${title.title} (${title.type}) ${seasonLabel} was fully watched on ${formatHistoryDate(now)}`,
{ source: 'Watchlist', action: 'watched', titleName: title.title },
);
}
}
const effectiveTotal = this.getEffectiveTotal(title);
if (
this.plugin.settings.autoCompleteOnLastEpisode &&
@ -1029,12 +1070,18 @@ export class DataManager {
private lastMarkdownPathById: Map<string, string> = new Map();
/** Resolves the per-title `.md` note path — the same scheme used to create it. */
getNoteFilePath(title: WatchLogTitle): string {
const root = this.plugin.settings.rootFolder;
const safeTitle = title.title.replace(/[*"\\/<>:|?]/g, '-');
return normalizePath(`${root}/${title.type}/${safeTitle}.md`);
}
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 filePath = this.getNoteFilePath(title);
const progress = this.getProgress(title);
const content = this.buildMarkdownContent(title, progress);
@ -1064,8 +1111,7 @@ export class DataManager {
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`);
const filePath = this.getNoteFilePath(title);
if (this.app.vault.getAbstractFileByPath(filePath) instanceof TFile) return false;
await this.ensureFolder(normalizePath(root));
await this.ensureFolder(folderPath);
@ -1095,9 +1141,7 @@ ${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 filePath = this.getNoteFilePath(title);
const file = this.app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) {
await this.app.fileManager.trashFile(file);

View file

@ -1,4 +1,3 @@
import { normalizePath } from 'obsidian';
import type WatchLogPlugin from './main';
export type HistorySource = 'Watchlist' | 'Reading';
@ -35,22 +34,30 @@ export class HistoryManager {
this.plugin = plugin;
}
private get filePath(): string {
return normalizePath(`${this.plugin.app.vault.configDir}/plugins/watchlog/history.json`);
/**
* Loads the activity log from the shared `data.history` key (in memory, not a
* file) and binds `master.history` to our canonical array so appends are
* reflected in the object DataManager persists.
*/
async load(): Promise<void> {
const master = this.plugin.dataManager.getData();
const h = master.history;
this.entries = Array.isArray(h) ? h : [];
if (this.entries.length > this.MAX_ENTRIES) {
this.entries = this.entries.slice(-this.MAX_ENTRIES);
}
master.history = this.entries;
}
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 (e) {
console.warn('[WL] HistoryManager.load failed:', e);
this.entries = [];
/** Re-bind to a freshly synced data.json (driven by DataManager's 'raw' watcher). */
adoptExternalChange(): void {
const master = this.plugin.dataManager.getData();
const h = master.history;
this.entries = Array.isArray(h) ? h : [];
if (this.entries.length > this.MAX_ENTRIES) {
this.entries = this.entries.slice(-this.MAX_ENTRIES);
}
master.history = this.entries;
}
async log(
@ -90,10 +97,11 @@ export class HistoryManager {
private async save(): Promise<void> {
try {
await this.plugin.app.vault.adapter.write(
this.filePath,
JSON.stringify({ entries: this.entries }, null, 2),
);
// Activity log lives inside data.json now (Sync-replicated). Re-bind the
// master reference (an external reload may have replaced the object), then
// persist the whole file through DataManager.
this.plugin.dataManager.getData().history = this.entries;
await this.plugin.dataManager.persist();
} catch {
// write errors are non-fatal
}

View file

@ -6,6 +6,8 @@ import { formatTime, formatDateDisplay, parseDateInput, getThemedColor, getDispl
import { renderCommunityRating, maybeAutoRefreshCommunityRating, refreshCommunityRating } from './CommunityRating';
import { AddTitleModal } from './AddTitleModal';
import { AddFromUrlModal } from './AddFromUrlModal';
import { AddTitleChoiceModal } from './AddTitleChoiceModal';
import { renderToolbarWithMobileToggle } from './MobileToolbar';
import { EditTitleModal } from './EditTitleModal';
import { ConfirmModal } from './ConfirmModal';
import { TitleDetailModal, GroupDetailModal } from './TitleDetailModal';
@ -68,6 +70,10 @@ export class ListTab {
// Watchlist sub-tab
currentSubTab: 'list' | 'cards' = 'list';
// Mobile toolbar toggle — retracted (false) shows search, expanded (true)
// shows the action buttons. Desktop ignores this (both are always visible).
private toolbarExpanded = false;
// Virtual-scroll cleanup — removes the rAF-throttled scroll listener on re-render
private scrollCleanup: (() => void) | null = null;
@ -942,9 +948,21 @@ export class ListTab {
const controls = header.createDiv({ cls: 'wl-header-controls' });
// Search sits inline as the first item in the toolbar row (matches Reading)
this.renderSearch(controls);
renderToolbarWithMobileToggle({
controls,
renderSearch: (parent) => this.renderSearch(parent),
renderActions: (parent) => this.renderToolbarActions(parent),
expanded: this.toolbarExpanded,
onToggleChange: (expanded) => { this.toolbarExpanded = expanded; },
});
}
/**
* Renders the toolbar action buttons (saved filter, filters, sorting,
* selection, and the two add buttons). On desktop these sit inline after the
* search input; on mobile they live in the crossfading toolbar slot.
*/
private renderToolbarActions(controls: HTMLElement): void {
// Saved filter preset button (shown only when a preset is stored)
const preset = this.dataManager.getSavedFilterPreset();
if (preset) {
@ -1007,26 +1025,30 @@ export class ListTab {
this.render();
});
// Add buttons pinned to the far right of the toolbar row
// Add button pinned to the far right of the toolbar row — opens a chooser
// modal routing to the manual/API or the from-URL add flows.
const rightGroup = controls.createDiv({ cls: 'wl-header-controls-right' });
// + Add from URL
const addUrlBtn = rightGroup.createEl('button', { cls: 'wl-btn wl-btn-sm wl-btn-success', text: '+add from URL' });
addUrlBtn.addEventListener('click', () => {
new AddFromUrlModal(this.plugin.app, this.plugin, this.dataManager, () => {
this.render();
}).open();
});
// + Add
const addBtnWrap = rightGroup.createDiv({ cls: 'wl-add-btn-wrap' });
const addBtn = addBtnWrap.createEl('button', { cls: 'wl-add-btn wl-btn-success', text: '+ add' });
addBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.openAddTitleModal();
this.openAddTitleChooser();
});
}
private openAddTitleChooser(): void {
new AddTitleChoiceModal(this.plugin.app, (choice) => {
if (choice === 'url') {
new AddFromUrlModal(this.plugin.app, this.plugin, this.dataManager, () => {
this.render();
}).open();
} else {
this.openAddTitleModal();
}
}).open();
}
private renderFiltersDropdown(parent: HTMLElement): void {
const wrap = parent.createDiv({ cls: 'wl-add-btn-wrap' });
const btn = wrap.createEl('button', { cls: 'wl-btn wl-btn-sm', text: 'Filters ▼' });
@ -2613,7 +2635,7 @@ export class ListTab {
const watched = new Set(title.watchedEpisodes);
const allWatched = seasonEps.length > 0 && seasonEps.every((ep) => watched.has(ep));
// Bulk operation: cheap to do via the existing notify-path which triggers a full re-render.
void this.dataManager.markSeasonWatched(title.id, seasonEps, !allWatched).then(() => this.rerenderTable());
void this.dataManager.markSeasonWatched(title.id, seasonEps, !allWatched, season?.name).then(() => this.rerenderTable());
});
const perSeason = this.plugin.settings.episodeNumbering === 'per-season';

View file

@ -60,6 +60,8 @@ function actionLabel(entry: HistoryEntry): string {
case 'watched': {
const pm = m.match(/At (page|chapter|volume) (\d+) \/ (\d+)/i);
if (pm) return `At ${pm[1]} ${pm[2]} / ${pm[3]}`;
const seasonM = m.match(/\)\s+(.+?)\s+was fully watched on/i);
if (seasonM) return `${seasonM[1]} watched`;
const em = m.match(/episode (\d+)/i);
return em ? `Episode ${em[1]} watched` : 'Watched';
}

62
src/MobileToolbar.ts Normal file
View file

@ -0,0 +1,62 @@
import { Platform, setIcon } from 'obsidian';
export interface MobileToolbarOptions {
/** The flex container row that holds the toolbar (e.g. wl-header-controls / wl-reading-toolbar). */
controls: HTMLElement;
/** Builds the search input into the given parent. */
renderSearch: (parent: HTMLElement) => void;
/** Builds the full set of action buttons into the given parent. */
renderActions: (parent: HTMLElement) => void;
/** Current toggle state — true shows actions, false (retracted, default) shows search. */
expanded: boolean;
/** Persists the new toggle state on the caller so it survives re-renders. */
onToggleChange: (expanded: boolean) => void;
}
/**
* Renders a toolbar that, on mobile only, crossfades between a search input
* (default / retracted) and its action buttons via a chevron toggle button.
* Both panes are absolutely positioned inside a fixed-height slot so swapping
* causes no reflow only opacity is animated. On desktop the search and the
* actions render inline together, unchanged.
*
* Shared by the Watchlist (ListTab) and Reading (ReadingTab) toolbars so the
* toggle behaviour lives in exactly one place.
*/
export function renderToolbarWithMobileToggle(options: MobileToolbarOptions): void {
const { controls, renderSearch, renderActions } = options;
if (!Platform.isMobile) {
// Desktop: search sits inline followed by the full set of action buttons.
renderSearch(controls);
renderActions(controls);
return;
}
let expanded = options.expanded;
const slot = controls.createDiv({ cls: 'wl-toolbar-slot' });
const searchWrap = slot.createDiv({ cls: 'wl-toolbar-slot-pane wl-toolbar-fade' });
renderSearch(searchWrap);
const actionsWrap = slot.createDiv({ cls: 'wl-toolbar-slot-pane wl-toolbar-fade' });
renderActions(actionsWrap);
const applyState = (): void => {
searchWrap.toggleClass('wl-toolbar-pane-hidden', expanded);
actionsWrap.toggleClass('wl-toolbar-pane-hidden', !expanded);
};
applyState();
const toggleBtn = controls.createEl('button', { cls: 'wl-btn wl-btn-sm wl-toolbar-toggle-btn' });
const applyIcon = (): void => {
setIcon(toggleBtn, expanded ? 'chevron-right' : 'chevron-left');
toggleBtn.setAttr('aria-label', expanded ? 'Show search' : 'Show actions');
};
applyIcon();
toggleBtn.addEventListener('click', () => {
expanded = !expanded;
options.onToggleChange(expanded);
applyState();
applyIcon();
});
}

38
src/NoteLinkButton.ts Normal file
View file

@ -0,0 +1,38 @@
import { App, Notice, TFile, setIcon } from 'obsidian';
/**
* Builds the shared "Open note" file-text icon button used by the Watchlist and
* Reading detail modals. Callers resolve their own `.md` note path/file (paths
* differ per tab) and pass the resolved target here; this helper only renders
* the icon and wires the open-on-click with graceful failure handling.
*
* @param app Obsidian app, used to resolve the path and open the leaf.
* @param parent Element the button is appended to (sizing comes from the class).
* @param target The resolved note as a `TFile`, or a vault path string to look up.
* @param onOpen Optional callback fired after a successful open (e.g. close the modal).
* @returns The created button element.
*/
export function appendNoteLinkButton(
app: App,
parent: HTMLElement,
target: TFile | string,
onOpen?: () => void,
): HTMLElement {
// Sizing/alignment come from the .wl-acc-link-icon class (matches the globe).
const noteIcon = parent.createEl('span', { cls: 'wl-acc-link-icon' });
setIcon(noteIcon, 'file-text');
noteIcon.setAttr('aria-label', 'Open note');
noteIcon.title = 'Open note';
noteIcon.addEventListener('click', (e) => {
e.stopPropagation();
const file =
target instanceof TFile ? target : app.vault.getAbstractFileByPath(target);
if (!(file instanceof TFile)) {
new Notice('Note file not found for this title.');
return;
}
void app.workspace.getLeaf().openFile(file);
onOpen?.();
});
return noteIcon;
}

View file

@ -55,30 +55,34 @@ export class ReadingDataManager {
};
}
private get filePath(): string {
return normalizePath(`${this.plugin.app.vault.configDir}/plugins/watchlog/reading.json`);
/** The single saveData object (owned by DataManager) that now holds reading data. */
private get master() {
return this.plugin.dataManager.getData();
}
/**
* Reads the reading dataset from the shared `data.reading` key (in memory, not a
* file), normalizes it, and binds `master.reading` to our canonical reference so
* subsequent mutations are reflected in the object DataManager persists.
*/
private bindFromMaster(): void {
const parsed = this.master.reading as Partial<ReadingData> | undefined;
if (parsed && typeof parsed === 'object') {
this.data = {
books: Array.isArray(parsed.books) ? parsed.books : [],
manga: Array.isArray(parsed.manga) ? parsed.manga : [],
bookColumns: Array.isArray(parsed.bookColumns) ? parsed.bookColumns : [],
mangaColumns: Array.isArray(parsed.mangaColumns) ? parsed.mangaColumns : [],
settings: { ...DEFAULT_READING_SETTINGS, ...(parsed.settings ?? {}) },
};
} else {
this.data = this.emptyData();
}
this.master.reading = this.data;
}
async load(): Promise<void> {
try {
const exists = await this.plugin.app.vault.adapter.exists(this.filePath);
if (exists) {
const raw = await this.plugin.app.vault.adapter.read(this.filePath);
const parsed = JSON.parse(raw) as Partial<ReadingData>;
this.data = {
books: Array.isArray(parsed.books) ? parsed.books : [],
manga: Array.isArray(parsed.manga) ? parsed.manga : [],
bookColumns: Array.isArray(parsed.bookColumns) ? parsed.bookColumns : [],
mangaColumns: Array.isArray(parsed.mangaColumns) ? parsed.mangaColumns : [],
settings: { ...DEFAULT_READING_SETTINGS, ...(parsed.settings ?? {}) },
};
} else {
this.data = this.emptyData();
}
} catch (e) {
console.warn('[WL] ReadingDataManager.load failed:', e);
this.data = this.emptyData();
}
this.bindFromMaster();
const changed = this.migrate();
if (changed) {
await this.saveOnly();
@ -218,15 +222,26 @@ export class ReadingDataManager {
private async saveOnly(): Promise<void> {
try {
await this.plugin.app.vault.adapter.write(
this.filePath,
JSON.stringify(this.data, null, 2),
);
// Keep the master reference pointed at our current data (an external sync
// reload may have replaced the master object), then persist all of data.json.
this.master.reading = this.data;
await this.plugin.dataManager.persist();
} catch (e) {
console.warn('[WL] ReadingDataManager.save failed:', e);
}
}
/**
* Re-bind to a freshly synced data.json (driven by DataManager's 'raw' watcher),
* run field migration, and refresh the Reading tab.
*/
async adoptExternalChange(): Promise<void> {
this.bindFromMaster();
const changed = this.migrate();
if (changed) await this.saveOnly();
this.notifyListeners();
}
private async save(): Promise<void> {
await this.saveOnly();
this.notifyListeners();
@ -256,10 +271,8 @@ export class ReadingDataManager {
* normal load() path (migration + Upcoming reconcile) and notify listeners.
*/
async restore(data: ReadingData): Promise<void> {
await this.plugin.app.vault.adapter.write(
this.filePath,
JSON.stringify(data, null, 2),
);
this.master.reading = data;
await this.plugin.dataManager.persist();
await this.load();
this.notifyListeners();
}

View file

@ -7,6 +7,7 @@ import {
formatDateDisplay, parseDateInput,
} from './types';
import { googleBooksErrorMessage } from './ApiService';
import { appendNoteLinkButton } from './NoteLinkButton';
import { ConfirmModal } from './ConfirmModal';
import { VaultFilePicker } from './AddReadingModal';
import { readingStatusColor, coverFallbackColor } from './ReadingTab';
@ -242,6 +243,16 @@ export class ReadingDetailModal extends Modal {
const statusWrap = meta.createSpan({ cls: 'wl-reading-detail-status-wrap' });
this.renderStatusBadge(statusWrap);
// Open this entry's .md note, inline between the status pill and the stars.
// Reading resolves its own note path (Books/Manga live in their own folder);
// the shared helper builds the button and handles opening / graceful failure.
appendNoteLinkButton(
this.plugin.app,
meta,
this.readingData.noteFilePath(this.mode, item.title),
() => this.close(),
);
this.starsWrapEl = meta.createDiv({ cls: 'wl-stars wl-reading-detail-stars' });
this.renderStars();

View file

@ -8,6 +8,7 @@ import { AddReadingModal } from './AddReadingModal';
import { ReadingDetailModal } from './ReadingDetailModal';
import { ReadingManageColumnsModal } from './ReadingManageColumnsModal';
import { ConfirmModal } from './ConfirmModal';
import { renderToolbarWithMobileToggle } from './MobileToolbar';
type SubTab = 'books' | 'manga';
@ -144,6 +145,10 @@ export class ReadingTab {
private selectionMode = false;
private selectedIds: Set<string> = new Set();
// Mobile toolbar toggle — retracted (false) shows search, expanded (true)
// shows the action buttons. Desktop ignores this (both are always visible).
private toolbarExpanded = false;
// Virtual scroll state
private vsScrollContainer: HTMLElement | null = null;
private vsScrollSpacer: HTMLElement | null = null;
@ -242,9 +247,20 @@ export class ReadingTab {
private renderToolbar(): void {
const toolbar = this.container.createDiv({ cls: 'wl-reading-toolbar' });
const left = toolbar.createDiv({ cls: 'wl-reading-toolbar-left' });
// On mobile the search input and the action buttons crossfade inside a
// single slot via a chevron toggle (shared with the Watchlist toolbar);
// on desktop they render inline together, unchanged.
renderToolbarWithMobileToggle({
controls: toolbar,
renderSearch: (parent) => this.renderReadingSearch(parent),
renderActions: (parent) => this.renderReadingActions(parent),
expanded: this.toolbarExpanded,
onToggleChange: (expanded) => { this.toolbarExpanded = expanded; },
});
}
const searchWrap = left.createDiv({ cls: 'wl-reading-search-wrap' });
private renderReadingSearch(parent: HTMLElement): void {
const searchWrap = parent.createDiv({ cls: 'wl-reading-search-wrap' });
const placeholder = this.activeSubTab === 'books' ? 'Search books...' : 'Search manga...';
const searchInput = searchWrap.createEl('input', {
cls: 'wl-reading-search-input',
@ -257,13 +273,15 @@ export class ReadingTab {
window.clearTimeout(debounce);
debounce = window.setTimeout(() => this.renderContent(), 200);
});
}
private renderReadingActions(parent: HTMLElement): void {
// Saved filter button — shown only when a filter has been saved. Pressing
// it applies the saved filter to the active sub-tab (mirrors Watchlist).
const savedFilter = this.getSavedFilter();
if (savedFilter) {
this.savedFilterActive = this.filtersMatchSaved(savedFilter);
const savedBtn = left.createEl('button', {
const savedBtn = parent.createEl('button', {
cls: `wl-btn wl-btn-sm${this.savedFilterActive ? ' wl-btn-preset-active' : ''}`,
text: 'Saved filter',
});
@ -283,7 +301,7 @@ export class ReadingTab {
this.savedFilterBtnEl = null;
}
this.filterBtn = left.createEl('button', {
this.filterBtn = parent.createEl('button', {
cls: 'wl-btn wl-btn-sm wl-reading-filter-btn',
text: 'Filter',
});
@ -293,7 +311,7 @@ export class ReadingTab {
this.toggleFilterPopover();
});
this.resetBtn = left.createEl('button', {
this.resetBtn = parent.createEl('button', {
cls: 'wl-btn wl-btn-sm wl-reading-reset-btn',
text: 'Reset',
});
@ -307,7 +325,7 @@ export class ReadingTab {
});
this.refreshFilterButtonBadge();
this.sortBtn = left.createEl('button', {
this.sortBtn = parent.createEl('button', {
cls: 'wl-btn wl-btn-sm wl-reading-sort-btn',
text: 'Sort',
});
@ -318,13 +336,13 @@ export class ReadingTab {
// Selection mode action bar
if (this.selectionMode && this.selectedIds.size > 0) {
this.renderSelectionActionBar(left);
this.renderSelectionActionBar(parent);
}
// Select All / Deselect All
if (this.selectionMode) {
const hasAny = this.selectedIds.size > 0;
const selAllBtn = left.createEl('button', {
const selAllBtn = parent.createEl('button', {
cls: 'wl-btn wl-btn-sm',
text: hasAny ? 'None' : 'All',
});
@ -342,7 +360,7 @@ export class ReadingTab {
}
// Selection mode toggle
const selBtn = left.createEl('button', {
const selBtn = parent.createEl('button', {
cls: `wl-btn wl-btn-sm${this.selectionMode ? ' is-active' : ''}`,
text: 'Select',
});
@ -352,7 +370,8 @@ export class ReadingTab {
this.render();
});
const right = toolbar.createDiv({ cls: 'wl-reading-toolbar-right' });
// Gear + Add pinned to the far right of the toolbar row.
const right = parent.createDiv({ cls: 'wl-reading-toolbar-right' });
const manageBtn = right.createEl('button', {
cls: 'wl-btn wl-btn-sm wl-reading-manage-btn',
attr: { 'aria-label': 'Manage columns', title: 'Manage columns' },

View file

@ -207,10 +207,16 @@ export class WatchLogSettingsTab extends PluginSettingTab {
const filename = `watchlog-backup-${dateStr}.json`;
// Version-2 snapshot: a self-describing wrapper around all three data sources.
// Reading + history now live inside the watch object (data.json); strip them
// from `watch` so the snapshot keeps its three distinct fields without dupes.
const watchOnly = Object.assign({}, this.plugin.dataManager.getData() ?? {}) as unknown as Record<string, unknown>;
delete watchOnly['reading'];
delete watchOnly['history'];
delete watchOnly['migratedReadingHistory'];
const snapshot = {
version: 2,
exportedAt: today.toISOString(),
watch: this.plugin.dataManager.getData() ?? {},
watch: watchOnly,
reading: this.plugin.readingDataManager.getData(),
history: this.plugin.historyManager.exportEntries(),
};
@ -320,7 +326,11 @@ export class WatchLogSettingsTab extends PluginSettingTab {
}
new ConfirmModal(this.app, 'This will replace ALL current watchlog data — watchlist, reading (books & manga) and activity log. Continue?', () => {
void (async () => {
await this.plugin.saveData(watch);
// Reading + history now persist inside data.json. Mark the data as
// already-migrated so the one-time legacy import never re-runs and
// clobbers the just-restored data on the next load.
const restored = Object.assign({}, watch as Record<string, unknown>, { migratedReadingHistory: true });
await this.plugin.saveData(restored);
await this.plugin.loadSettings();
await this.plugin.dataManager.load();
await this.plugin.readingDataManager.restore(reading as ReadingData);
@ -338,12 +348,23 @@ export class WatchLogSettingsTab extends PluginSettingTab {
return;
}
new ConfirmModal(this.app, 'This is a legacy (watchlist-only) backup. It will replace your current watchlist. Reading and activity-log data are left untouched. Continue?', () => {
void this.plugin.saveData(parsed).then(async () => {
void (async () => {
// data.json now also holds reading + history. A legacy backup carries
// neither, so preserve the CURRENT in-memory reading/history (and the
// migrated flag) when overwriting the watch data — truly "untouched".
const merged = Object.assign({}, parsed as Record<string, unknown>, {
reading: this.plugin.readingDataManager.getData(),
history: this.plugin.historyManager.exportEntries(),
migratedReadingHistory: true,
});
await this.plugin.saveData(merged);
await this.plugin.loadSettings();
await this.plugin.dataManager.load();
await this.plugin.historyManager.load();
await this.plugin.readingDataManager.load();
activeDocument.dispatchEvent(new CustomEvent('watchlog-data-changed'));
new Notice('Legacy backup restored — watchlist only; reading and activity log left untouched.');
});
})();
}).open();
};
reader.readAsText(file);

View file

@ -3,6 +3,7 @@ import type WatchLogPlugin from './main';
import type { DataManager } from './DataManager';
import type { WatchLogTitle, WatchLogGroup, Season, TagDefinition } from './types';
import { formatTime, formatDateDisplay, parseDateInput, getThemedColor, getDisplayPoster } from './types';
import { appendNoteLinkButton } from './NoteLinkButton';
import { EditTitleModal } from './EditTitleModal';
import { ConfirmModal } from './ConfirmModal';
import { renderCommunityRating, maybeAutoRefreshCommunityRating } from './CommunityRating';
@ -102,6 +103,16 @@ export class TitleDetailModal extends Modal {
linkIcon.rel = 'noopener noreferrer';
}
// Open the per-title .md note inside Obsidian, inline right after the globe.
// Watchlist resolves its note path from the title; the shared helper builds
// the button and handles opening / graceful failure.
appendNoteLinkButton(
this.app,
badgeRow,
this.dataManager.getNoteFilePath(this.title),
() => this.close(),
);
// Editable status badge, inline to the right of the type badge / link icon.
// Mirrors the Reading detail modal's clickable badge + dropdown, but draws
// from the user-configured watchlist statuses and their colors.
@ -234,7 +245,7 @@ export class TitleDetailModal extends Modal {
const watched = new Set(this.title.watchedEpisodes);
const allWatched = seasonEps.length > 0 && seasonEps.every((ep) => watched.has(ep));
void this.dataManager
.markSeasonWatched(this.title.id, seasonEps, !allWatched)
.markSeasonWatched(this.title.id, seasonEps, !allWatched, season?.name)
.then(() => {
this.refreshTitle();
this.markChanged();

View file

@ -1,5 +1,7 @@
import { App, Editor, FuzzySuggestModal, MarkdownView, Notice, Platform, Plugin, setIcon } from 'obsidian';
import { App, Editor, FuzzySuggestModal, MarkdownView, Notice, Platform, Plugin, normalizePath, setIcon } from 'obsidian';
import { DEFAULT_SETTINGS, WatchLogPluginSettings, AirtimeSchedule } from './types';
import type { ReadingData } from './types';
import type { HistoryEntry } from './HistoryManager';
import { DataManager } from './DataManager';
import { ReadingDataManager } from './ReadingDataManager';
import { ApiService } from './ApiService';
@ -34,16 +36,22 @@ export default class WatchLogPlugin extends Plugin {
await this.loadSettings();
this.dataManager = new DataManager(this);
await this.dataManager.load();
// One-time migration: pull legacy reading.json / history.json into data.json so
// Obsidian Sync replicates them. Must run after data.json is loaded and before the
// reading/history managers bind to it. Idempotent — gated by a flag in data.json.
await this.migrateReadingHistory();
this.dataManager.startWatchingExternalChanges();
this.readingDataManager = new ReadingDataManager(this);
await this.readingDataManager.load();
this.apiService = new ApiService(this.settings.omdbApiKey, this.settings.tmdbApiKey, this.settings.googleBooksApiKey);
this.readingDataManager = new ReadingDataManager(this);
this.historyManager = new HistoryManager(this);
await this.historyManager.load();
this.dataManager.setHistoryManager(this.historyManager);
this.readingDataManager.setHistoryManager(this.historyManager);
// History binds first so any reading-load logging has a live target.
await this.historyManager.load();
await this.readingDataManager.load();
this.posterService = new PosterService(
this.dataManager,
@ -312,6 +320,92 @@ export default class WatchLogPlugin extends Plugin {
await this.dataManager.saveSettings(this.settings);
}
/**
* One-time migration of legacy `reading.json` / `history.json` into `data.json`.
*
* Obsidian Sync replicates data.json (the saveData channel) but NOT the raw
* adapter-written reading.json / history.json. This copies their contents into
* the single saveData object so Sync carries reading + activity-log data too.
*
* Gated by `data.migratedReadingHistory`. The flag and the migrated data are
* written together in ONE atomic saveData(), so an interrupted load simply
* re-runs the (idempotent) migration next time. The flag is only set when both
* legacy reads succeeded (a valid empty/absent file counts as success); a
* parse failure leaves the flag unset to preserve a recovery chance. Legacy
* files are left on disk untouched as a backup.
*/
private async migrateReadingHistory(): Promise<void> {
const master = this.dataManager.getData();
if (master.migratedReadingHistory === true) {
return;
}
const adapter = this.app.vault.adapter;
const dir = `${this.app.vault.configDir}/plugins/watchlog`;
const readingPath = normalizePath(`${dir}/reading.json`);
const historyPath = normalizePath(`${dir}/history.json`);
// ── Read legacy reading.json ──────────────────────────────────────────────
let readingOk = false;
let readingData: ReadingData | null = null;
try {
if (await adapter.exists(readingPath)) {
const raw = await adapter.read(readingPath);
readingData = JSON.parse(raw) as ReadingData;
readingOk = true;
} else {
readingOk = true; // absent = nothing to migrate (still a success)
}
} catch (e) {
readingOk = false;
console.warn('[WL] reading.json read/parse failed — migration will retry next load:', e);
}
// ── Read legacy history.json ──────────────────────────────────────────────
let historyOk = false;
let historyEntries: HistoryEntry[] | null = null;
try {
if (await adapter.exists(historyPath)) {
const raw = await adapter.read(historyPath);
const parsed = JSON.parse(raw) as { entries?: HistoryEntry[] };
historyEntries = Array.isArray(parsed.entries) ? parsed.entries : [];
historyOk = true;
} else {
historyOk = true;
}
} catch (e) {
historyOk = false;
console.warn('[WL] history.json read/parse failed — migration will retry next load:', e);
}
// ── Copy into the saveData object (with the don't-overwrite-with-empty belt) ──
if (readingOk && readingData) {
const legacyHasReading =
(readingData.books?.length ?? 0) > 0 || (readingData.manga?.length ?? 0) > 0 ||
(readingData.bookColumns?.length ?? 0) > 0 || (readingData.mangaColumns?.length ?? 0) > 0;
const masterHasReading = !!master.reading &&
(((master.reading.books?.length ?? 0) > 0) || ((master.reading.manga?.length ?? 0) > 0));
if (legacyHasReading || !masterHasReading) {
master.reading = readingData;
}
}
if (historyOk && historyEntries) {
const masterHasHistory = Array.isArray(master.history) && master.history.length > 0;
if (historyEntries.length > 0 || !masterHasHistory) {
master.history = historyEntries;
}
}
// Only mark migrated when both reads succeeded; otherwise retry next load.
if (readingOk && historyOk) {
master.migratedReadingHistory = true;
}
// Single atomic write: migrated data AND flag together.
await this.dataManager.persist();
}
private openWidgetPalette(editor: Editor): void {
const widgets = [
{ name: 'wl-todo — Track a title (full)', id: 'wl-todo' },

View file

@ -1,3 +1,5 @@
import type { HistoryEntry } from './HistoryManager';
export interface Season {
name: string;
episodes: number;
@ -336,6 +338,13 @@ export interface WatchLogData {
drafts?: DraftPersistState;
savedFilterPreset?: SavedFilterPreset | null;
posterRetryDone?: boolean;
// Reading + activity-log data now live inside data.json (persisted via Obsidian
// saveData), so Obsidian Sync replicates them alongside the watchlist. They are
// migrated once from the legacy reading.json / history.json files; the boolean
// flag below gates that one-time migration.
reading?: ReadingData;
history?: HistoryEntry[];
migratedReadingHistory?: boolean;
}
// ── Airtime utility functions ─────────────────────────────────────────────────

View file

@ -1956,6 +1956,54 @@
font-size: 11px;
}
/* Mobile toolbar crossfade slot
On mobile the search input and the action buttons overlap inside a single
fixed-height slot and crossfade between each other (toggled by the chevron
button). Both panes are absolutely positioned so swapping them causes no
reflow only opacity is animated (mobile WebView layout transitions are
unreliable). */
.wl-toolbar-slot {
position: relative;
flex: 1;
min-width: 0;
height: 30px;
}
.wl-toolbar-slot-pane {
position: absolute;
inset: 0;
display: flex;
align-items: center;
gap: 6px;
}
/* Search input fills the full slot width on mobile (overriding the desktop
max-width cap). */
.wl-toolbar-slot-pane .wl-reading-search-wrap {
max-width: none;
}
/* Animate opacity only — never width/flex/layout. */
.wl-toolbar-fade {
transition: opacity 180ms ease;
}
.wl-toolbar-pane-hidden {
opacity: 0;
pointer-events: none;
}
/* Square chevron toggle button at the right end of the toolbar row. */
.wl-toolbar-toggle-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
}
/* Active (selected) state for the selection mode toggle button */
.wl-btn.is-active {
background: var(--interactive-accent);
@ -2089,11 +2137,24 @@
/* ── Link icon in accordion ─────────────────────────────── */
.wl-acc-link-icon {
font-size: 13px;
--icon-size: 13px;
text-decoration: none;
opacity: 0.6;
transition: opacity 0.15s;
cursor: pointer;
flex-shrink: 0;
/* Center the glyph/SVG in a fixed box so the emoji globe and the file-text
icon align identically, instead of the SVG sitting on the text baseline. */
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Block-center the Lucide SVG at the same size as the globe glyph (13px). */
.wl-acc-link-icon svg {
display: block;
width: 13px;
height: 13px;
}
.wl-acc-link-icon:hover {
@ -5053,22 +5114,17 @@
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
}
.wl-reading-toolbar-left {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
/* Gear + Add group pushed to the far right of the toolbar row (was driven by
the toolbar's space-between before search/actions were flattened into one
flex row). */
.wl-reading-toolbar-right {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
margin-left: auto;
}
.wl-reading-search-wrap {

View file

@ -1,4 +1,5 @@
{
"2.1.0": "1.4.0",
"2.0.0": "1.4.0",
"1.1.0": "1.4.0",
"1.0.9": "1.4.0",