mirror of
https://github.com/shynkro/watchlog-obsidian-plugin.git
synced 2026-07-22 06:53:16 +00:00
feat: v2.1.0 - note open button, mobile toolbar toggle, season log event, add chooser, reading/history sync fix
This commit is contained in:
parent
7df3ce4dc5
commit
d66b849bc9
20 changed files with 611 additions and 145 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -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
86
main.js
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
43
src/AddTitleChoiceModal.ts
Normal file
43
src/AddTitleChoiceModal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
62
src/MobileToolbar.ts
Normal 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
38
src/NoteLinkButton.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
104
src/main.ts
104
src/main.ts
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────
|
||||
|
|
|
|||
74
styles.css
74
styles.css
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue