diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7748ceb..0e3f1b5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,28 +1,28 @@ -name: Node.js build - -on: - push: - branches: ["**"] - pull_request: - branches: ["**"] - -jobs: - build: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [20.x, 22.x] - # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ - - steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: "npm" - - run: npm ci - - run: npm run build --if-present - - run: npm run lint - +name: Node.js build + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.x, 22.x] + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: "npm" + - run: npm ci + - run: npm run build --if-present + - run: npm run lint + diff --git a/.gitignore b/.gitignore index 484a979..0732767 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ node_modules # obsidian data.json +history.json +reading.json # Exclude macOS Finder (System Explorer) View States .DS_Store @@ -24,6 +26,7 @@ data.json .claude/ CLAUDE.md AGENTS.md +DEVLOG.md # Runtime data history.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ee1acf..bc2c046 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,100 @@ All notable changes to WatchLog are documented here. +## [2.0.0] - 2026-06-07 + +##### Added +- Added **Cards sub-tab** in Watchlist with a responsive poster card grid + - Poster images auto-fetched from Jikan, TMDB, Google Books or OMDb and cached locally +- Added: **AniList API** as alternative anime source; toggle between Jikan and AniList in Settings +- Added: **Community Ratings**; IMDb, MAL, AniList, or TMDB scores displayed alongside personal star rating with manual refresh and 30-day auto-refresh +- Added: **API routing by type**; configurable API mapping per custom type in Settings; locked defaults for Anime, Movie, TV Show +- Added: manual **Poster URL** override field in Edit modal +- Added: **"Groups only"** filter in Watchlist; show only group headers, hide standalone titles +- Added: **"All" toggle** per filter section; quick select/deselect all within Type, Status, Rating, Priority, Group +- Added: status dropdown directly on expanded title row (no need to open Edit modal) +- Added: "Today" button on Date Watched field (auto-fills only when empty) +- Added: **Skip Episodes**; mark episodes as skippable (filler, recaps, etc.) per season + - Define skip episodes in season syntax: `"Season 1: 48 (33-37,42)"` + - Skipped episodes shown with purple border (`#6b2972`) and dash (—) marker + - 3-state click cycle for skip-defined episodes: skipped → watched → empty + - Skip stats displayed in Edit modal: "X to skip · Y to watch" next to Total Episodes and below Seasons + - Season headers show skip count (e.g. "East Blue Saga 48 eps (5 to skip)") + - Progress calculations exclude skipped episodes from totals +- Added: status badge moved to the top of the Watchlist detail modal, inline next to the title type, with a colored click-to-change dropdown (matching Reading) +- Added: Upcoming due count in the status bar; shows "N due" with the plugin icon, hidden when zero, click opens the Upcoming tab +- Added: Settings toggle "Show Upcoming count in status bar" (General, on by default) +- Added: Reading section in Quick Info with help entries; updated API & Search entry to reflect current APIs (AniList, Google Books) +- Added new **"Watchlist" settings tab** (renamed from Folders) +- Added **Reading integration into Dashboard**; new Books and Manga cards at the end of the grid + • "left" counts Reading + Plan to read; To be released excluded from the calculation + • Progress bar shows pages/chapters read of total, with an inline pages / chapters · vol line below + • Reflected in both Rectangles and Circles dashboard styles +- Added **unified Total/Time card**; Total, Time watched, and Time remaining merged into one card with three equal segments +- Added **Reading in Upcoming**; titles already in Reading can be added via the "+ add" finder (now searches Reading), shown with Book/Manga badges +- Added **Reading schedule modal**; derived from the Anime scheduler; no Time field, Current/Total chapters + volumes, recurrence and auto-increment, 0/1 total treated as a single release date +- Added **Reading in Custom Lists**; name-cell autocomplete now searches Reading alongside Watchlist +- Added **Drafts add gate**; the Add button opens a choice modal (Add in Watchlist / Add book / Add manga), each opening the correct modal with the draft text prefilled +- Added **Release date field** to the Reading modal, inline next to Added; manually editable with optional API import (Google Books / Jikan) +- Added **Reading colors** section in Settings → Customize (below Season colors); color pickers for Manga and Book +- Added: **Show hint banners** toggle in Settings > General; hide/show informational banners in Upcoming, Custom Lists, and Drafts +- Added: **Auto-populate Time column** in Custom Lists; number columns can pull remaining watch time (in minutes) from Watchlist titles with exact name match + - Enable via ⏱ toggle in Edit Columns for any number-type column + - Values are persisted and only re-fetched on demand via ↻ refresh icon in column header + - Titles not found in Watchlist show "Not found" + +- Added **Log** as standalone top-level tab (extracted from Watchlist); unified timeline for both Watchlist and Reading events + • Vertical timeline with colored dots and connector lines + • Day-grouped entries with date headers + • Action color coding: green (Completed/Watched), blue (Added), red (Deleted), amber (Status/Rating changed) + • Source filter toolbar: All / Watchlist / Reading + +- Added **Reading tab** with two sub-tabs: Books and Manga; fully independent from the existing Watchlist +- Added separate `reading.json` storage with dedicated `ReadingDataManager` for CRUD operations, change listeners, and schema migrations +- Added **Book tracking** with fields: title, author, status, rating, pages read/total, chapters read/total, cover URL, Open Library ID, vault page link, dates, custom fields +- Added **Manga tracking** with fields: title, author, status, rating, chapters read/total, volumes read/total, cover URL, MAL ID, vault page link, dates, custom fields +- Added **Card grid view** for both Books and Manga with responsive layout, cover images, status dots, progress bars, and author display +- Added **Detail modal** with cover, title, author, status badge, rating, Open note button, vault page controls (Change/Open with filename display and full path tooltip) +- Added **Custom fields system** per sub-tab: user-defined columns (text / number / select) with per-column color picker (8 preset colors) + - Two display styles with toggle: **Fill** (key cell background) and **Border** (key cell border with rounded corners) + - Inline editing in Detail modal (text input, number input, or select dropdown) +- Added **Manage Columns modal** with horizontal row layout: drag handle, name input, type dropdown, color dot picker, options input (for select type), delete button — all aligned inline +- Added **Favorite Quotes** section in Detail modal: parsed from `## Quotes` in the auto-generated `.md` file, displayed as styled callout blocks with page/chapter reference; Add quote inline form +- Added **Auto-generated `.md` files** per title at `WatchLog/Reading/Books/.md` or `WatchLog/Reading/Manga/<Title>.md` with YAML frontmatter, `## Notes`, and `## Quotes` sections; frontmatter syncs on edit +- Added **Jikan manga lookup** in Add Manga modal: search by title or MAL ID, auto-fill title/author/chapters/volumes/cover/MAL ID +- Added default reading folder path setting (default `WatchLog/Reading`) + + +##### Improved +- Improved: Custom Lists; Edit Columns modal redesigned as horizontal cards +- Improved: # and Name columns shown as grayed-out/non-editable in Edit Columns modal +- Improved: Edit Columns modal wider to fit card layout +- Improved: Cards sub-tab is now the default Watchlist view +- Improved: "History" sub-tabs renamed to "Log" in Watchlist and Upcoming +- Improved: **API settings tab** restructured into three faded callout sections — Movies & TV Shows, Anime, Books — with consistent key/test/status layout +- Improved: **shared danger/success button colors** consolidated into reusable utility classes (red borders for delete/remove, green for add), with softened resting borders +- Improved: Backup/Restore now covers all three data files (watch, reading, history) via a versioned format, with legacy backups still restorable +- Improved: removed the Custom Lists mobile input modal; tapping a cell now uses inline editing directly on both mobile and desktop (the modal was a workaround for the now-fixed keyboard bug) +- + +##### Fixed +- Fixed: titles inside groups now appear correctly when filtering by status +- Fixed: priority is automatically cleared when a title is marked as Completed +- Fixed: mobile keyboard layout bug; when the keyboard opened on iOS and Android, the scrollable content area collapsed and a blank/dark region covered most of the view above the keyboard. Content was present but hidden behind it. +- Fixed: Custom Lists — adding a new column no longer resets row checkboxes +- Fixed: custom field fill mode text color now black for readability + +##### Performance +- Performance: optimized episode click handling; direct DOM updates with debounced save, eliminated redundant re-renders +- Performance: centralized save pipeline, batch CSV imports, debounced inputs, Map-based lookups, dead code removal, listener leak fixes +- Performance: the keyboard layout fix is debounced via requestAnimationFrame (one write per frame), with redundant-write skipping, no duplicate observers, and full teardown on view close + + +##### Removed +- Removed the **"Default view"** setting; the panel now always opens on Dashboard +- Removed the standalone **"Folders"** settings tab (its contents moved into the new "Watchlist" tab) + + ## [1.1.0] - 2026-05-12 ### Changed @@ -12,5 +106,6 @@ All notable changes to WatchLog are documented here. - Simplified `CsvModal.ts` CSV export to use `document.createElement` directly - File names with forbidden characters are now sanitized in the title property, fixing broken notes for titles containing characters such as `:` + ## [1.0.9] - 2026-04-26 ### Initial public release \ No newline at end of file diff --git a/LICENSE b/LICENSE index 5abf320..1aecb0d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2026 Shynkro(BogdanS) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +MIT License + +Copyright (c) 2026 Shynkro(BogdanS) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 1c74a14..02e855a 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -1,49 +1,49 @@ -import esbuild from "esbuild"; -import process from "process"; -import { builtinModules } from 'node:module'; - -const banner = -`/* -THIS IS A GENERATED/BUNDLED FILE BY ESBUILD -if you want to view the source, please visit the github repository of this plugin -*/ -`; - -const prod = (process.argv[2] === "production"); - -const context = await esbuild.context({ - banner: { - js: banner, - }, - entryPoints: ["src/main.ts"], - bundle: true, - external: [ - "obsidian", - "electron", - "@codemirror/autocomplete", - "@codemirror/collab", - "@codemirror/commands", - "@codemirror/language", - "@codemirror/lint", - "@codemirror/search", - "@codemirror/state", - "@codemirror/view", - "@lezer/common", - "@lezer/highlight", - "@lezer/lr", - ...builtinModules], - format: "cjs", - target: "es2018", - logLevel: "info", - sourcemap: prod ? false : "inline", - treeShaking: true, - outfile: "main.js", - minify: prod, -}); - -if (prod) { - await context.rebuild(); - process.exit(0); -} else { - await context.watch(); -} +import esbuild from "esbuild"; +import process from "process"; +import { builtinModules } from 'node:module'; + +const banner = +`/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository of this plugin +*/ +`; + +const prod = (process.argv[2] === "production"); + +const context = await esbuild.context({ + banner: { + js: banner, + }, + entryPoints: ["src/main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtinModules], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", + minify: prod, +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} diff --git a/eslint.config.mts b/eslint.config.mts index e7cc2a9..c665539 100644 --- a/eslint.config.mts +++ b/eslint.config.mts @@ -1,36 +1,43 @@ -import tseslint from 'typescript-eslint'; -import obsidianmd from "eslint-plugin-obsidianmd"; -import globals from "globals"; -import { globalIgnores } from "eslint/config"; - -export default tseslint.config( - { - languageOptions: { - globals: { - ...globals.browser, - activeDocument: 'readonly', - activeWindow: 'readonly', - }, - parserOptions: { - projectService: { - allowDefaultProject: [ - 'eslint.config.js', - 'manifest.json' - ] - }, - tsconfigRootDir: import.meta.dirname, - extraFileExtensions: ['.json'] - }, - }, - }, - ...obsidianmd.configs.recommended, - globalIgnores([ - "node_modules", - "dist", - "esbuild.config.mjs", - "eslint.config.js", - "version-bump.mjs", - "versions.json", - "main.js", - ]), -); +import tseslint from 'typescript-eslint'; +import obsidianmd from "eslint-plugin-obsidianmd"; +import globals from "globals"; +import { globalIgnores } from "eslint/config"; + +export default tseslint.config( + { + languageOptions: { + globals: { + ...globals.browser, + activeDocument: 'readonly', + activeWindow: 'readonly', + }, + parserOptions: { + projectService: { + allowDefaultProject: [ + 'eslint.config.js', + 'manifest.json' + ] + }, + tsconfigRootDir: import.meta.dirname, + extraFileExtensions: ['.json'] + }, + }, + }, + ...obsidianmd.configs.recommended, + { + // Cosmetic-only: keep UI sentence-case visible without failing the lint. + plugins: { obsidianmd }, + rules: { + 'obsidianmd/ui/sentence-case': 'warn', + }, + }, + globalIgnores([ + "node_modules", + "dist", + "esbuild.config.mjs", + "eslint.config.js", + "version-bump.mjs", + "versions.json", + "main.js", + ]), +); diff --git a/main.js b/main.js index b04561b..8cde58c 100644 --- a/main.js +++ b/main.js @@ -3,55 +3,128 @@ THIS IS A GENERATED/BUNDLED FILE BY ESBUILD if you want to view the source, please visit the github repository of this plugin */ -var ee=Object.defineProperty;var as=Object.getOwnPropertyDescriptor;var ns=Object.getOwnPropertyNames;var rs=Object.prototype.hasOwnProperty;var ls=(h,e)=>{for(var t in e)ee(h,t,{get:e[t],enumerable:!0})},os=(h,e,t,s)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of ns(e))!rs.call(h,i)&&i!==t&&ee(h,i,{get:()=>e[i],enumerable:!(s=as(e,i))||s.enumerable});return h};var cs=h=>os(ee({},"__esModule",{value:!0}),h);var Zs={};ls(Zs,{default:()=>te});module.exports=cs(Zs);var rt=require("obsidian");function Ae(h){var i,a,l;let e=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],t=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],s=h.releaseTime?` \xB7 ${h.releaseTime}`:"";if(h.recurrence==="once"){if(!h.releaseDate)return"No date set";let n=new Date(h.releaseDate+"T12:00:00");return`Release date \xB7 ${n.getDate()} ${(i=t[n.getMonth()])!=null?i:""} ${n.getFullYear()}${s}`}return h.recurrence==="daily"?`Every day${s}`:h.recurrence==="weekly"?`Every ${h.dayOfWeek!==void 0&&(a=e[h.dayOfWeek])!=null?a:"Unknown"}${s}`:h.recurrence==="monthly"?`Monthly on day ${(l=h.dayOfMonth)!=null?l:"?"}${s}`:"\u2014"}var it={colorTheme:"default",defaultView:"watchlist",autoCompleteOnLastEpisode:!0,setFinishDateAutomatically:!1,syncWidgetWithMainList:!0,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:!0,coloredTypeBadges:!0,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"]};function _(h,e,t){return e}function Z(h){if(!h)return"";let e=h.match(/^(\d{4})-(\d{2})-(\d{2})$/);return e?`${e[3]}/${e[2]}/${e[1]}`:h}function lt(h){let e=h.trim();if(!e)return null;let t=e.split("/");if(t.length!==3)return null;let[s,i,a]=t;if(!s||!i||!a||a.length!==4)return null;let l=parseInt(s,10),n=parseInt(i,10),r=parseInt(a,10);return isNaN(l)||isNaN(n)||isNaN(r)||n<1||n>12||l<1||l>31?null:`${a}-${i.padStart(2,"0")}-${s.padStart(2,"0")}`}function pt(h){let e=h.trim();if(!e)return null;if(/^\d{4}-\d{2}-\d{2}$/.test(e))return e;let t=e.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/);if(t){let s=t[1].padStart(2,"0"),i=t[2].padStart(2,"0"),a=t[3],l=parseInt(s,10),n=parseInt(i,10);return n<1||n>12||l<1||l>31?null:`${a}-${i}-${s}`}return null}function V(h){if(h<=0)return"0m";let e=Math.floor(h/60),t=h%60,s=e>=1e3?String(e).replace(/\B(?=(\d{3})+(?!\d))/g,"."):String(e);return e===0?`${t}m`:t===0?`${s}h`:`${s}h ${t}m`}var q=require("obsidian");var Re=require("obsidian"),ds=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];function ot(h){var r;let e=new Date(h),t=(r=ds[e.getDay()])!=null?r:"",s=String(e.getDate()).padStart(2,"0"),i=String(e.getMonth()+1).padStart(2,"0"),a=e.getFullYear(),l=String(e.getHours()).padStart(2,"0"),n=String(e.getMinutes()).padStart(2,"0");return`${t} ${s}/${i}/${a} at ${l}:${n}`}var bt=class{constructor(e){this.entries=[];this.MAX_ENTRIES=1e3;this.plugin=e}get filePath(){return(0,Re.normalizePath)(`${this.plugin.app.vault.configDir}/plugins/watchlog/history.json`)}async load(){try{if(await this.plugin.app.vault.adapter.exists(this.filePath)){let t=await this.plugin.app.vault.adapter.read(this.filePath),s=JSON.parse(t);this.entries=Array.isArray(s.entries)?s.entries:[]}}catch(e){this.entries=[]}}async log(e){let t={id:`${Date.now()}-${Math.random().toString(36).slice(2,7)}`,timestamp:new Date().toISOString(),message:e};this.entries.push(t),this.entries.length>this.MAX_ENTRIES&&(this.entries=this.entries.slice(-this.MAX_ENTRIES)),await this.save()}getEntries(){return[...this.entries].reverse()}async save(){try{await this.plugin.app.vault.adapter.write(this.filePath,JSON.stringify({entries:this.entries},null,2))}catch(e){}}};var Et=class{constructor(e){this.changeListeners=[];this.historyManager=null;this.plugin=e,this.app=e.app,this.data={titles:[],groups:[],settings:{}}}setHistoryManager(e){this.historyManager=e}async load(){let e=await this.plugin.loadData();e?this.data=e:this.data={titles:[],groups:[],settings:{}},this.migrateData()}migrateData(){Array.isArray(this.data.titles)||(this.data.titles=[]),Array.isArray(this.data.groups)||(this.data.groups=[]),Array.isArray(this.data.airtime)||(this.data.airtime=[]);for(let e of this.data.titles)if(e.dateAdded?e.dateAdded.includes("T")||(e.dateAdded=new Date(e.dateAdded).toISOString()):e.dateAdded=new Date().toISOString(),!e.dateModified){let s=e.lastInteracted;typeof s=="string"&&s?e.dateModified=s:e.dateModified=e.dateAdded}}async saveOnly(){await this.plugin.saveData(this.data)}async save(){await this.saveOnly(),this.notifyListeners()}getTitles(){var e;return(e=this.data.titles)!=null?e:[]}getTitle(e){var t;return((t=this.data.titles)!=null?t:[]).find(s=>s.id===e)}async addTitle(e){var i;let t=this.canAutoAddToUpcoming(e);t&&(e.status="To be released"),this.data.titles.push(e),await this.save(),t&&await this.autoAddToUpcoming(e),await this.updateMarkdownFile(e);let s=new Date().toISOString();(i=this.historyManager)==null||i.log(`${e.title} (${e.type}) was added on ${ot(s)}`)}async addTitleSilent(e){let t=this.canAutoAddToUpcoming(e);t&&(e.status="To be released"),this.data.titles.push(e),await this.saveOnly(),t&&await this.autoAddToUpcoming(e),await this.updateMarkdownFile(e)}async removeTitlesBatch(e){let s=[];for(let i of e){let a=this.getTitle(i);a&&s.push(a),this.data.titles=this.data.titles.filter(n=>n.id!==i);for(let n of this.data.groups)n.titleIds=n.titleIds.filter(r=>r!==i);this.data.airtime&&(this.data.airtime=this.data.airtime.filter(n=>n.titleId!==i));let l=this.data;l.collapsedSeasons&&delete l.collapsedSeasons[i]}await this.save();for(let i=0;i<s.length;i+=10){let a=s.slice(i,i+10);await Promise.all(a.map(l=>this.deleteMarkdownFile(l)))}}canAutoAddToUpcoming(e){if(!e.releaseDate||!/^\d{4}-\d{2}-\d{2}$/.test(e.releaseDate))return!1;let t=new Date(e.releaseDate+"T12:00:00"),s=new Date;return s.setHours(0,0,0,0),t>s}async autoAddToUpcoming(e){var s;if(this.data.airtime||(this.data.airtime=[]),this.data.airtime.some(i=>i.titleId===e.id))return;let t={id:this.generateAirtimeId(e.id),titleId:e.id,schedule:{recurrence:"once",releaseDate:(s=e.releaseDate)!=null?s:void 0},dateAdded:new Date().toISOString()};this.data.airtime.push(t),await this.plugin.saveData(this.data)}async removeAirtimeEntriesForTitle(e){var s,i,a;let t=((s=this.data.airtime)!=null?s:[]).length;this.data.airtime=((i=this.data.airtime)!=null?i:[]).filter(l=>l.titleId!==e),((a=this.data.airtime)!=null?a:[]).length!==t&&await this.save()}async updateTitle(e){var s,i;e.dateModified=new Date().toISOString();let t=this.data.titles.findIndex(a=>a.id===e.id);if(t>=0){let a=this.data.titles[t],l=new Date().toISOString();a.rating!==e.rating&&((s=this.historyManager)==null||s.log(`${e.title} (${e.type}) was reviewed on ${ot(l)}`)),a.status!==e.status&&e.status==="Completed"&&((i=this.historyManager)==null||i.log(`${e.title} (${e.type}) was marked as watched on ${ot(l)}`)),this.data.titles[t]=e,await this.save(),await this.updateMarkdownFile(e)}}async removeTitle(e){var i;let t=this.getTitle(e);if(t){let a=new Date().toISOString();(i=this.historyManager)==null||i.log(`${t.title} (${t.type}) was deleted on ${ot(a)}`)}this.data.titles=this.data.titles.filter(a=>a.id!==e);for(let a of this.data.groups)a.titleIds=a.titleIds.filter(l=>l!==e);this.data.airtime&&(this.data.airtime=this.data.airtime.filter(a=>a.titleId!==e));let s=this.data;s.collapsedSeasons&&delete s.collapsedSeasons[e],await this.save(),t&&await this.deleteMarkdownFile(t)}getGroups(){var e;return(e=this.data.groups)!=null?e:[]}getGroup(e){var t;return((t=this.data.groups)!=null?t:[]).find(s=>s.id===e)}async addGroup(e){this.data.groups.push(e),await this.save()}async updateGroup(e){let t=this.data.groups.findIndex(s=>s.id===e.id);t>=0&&(this.data.groups[t]=e,await this.save())}async removeGroup(e){this.data.groups=this.data.groups.filter(t=>t.id!==e),await this.save()}async addTitleToGroup(e,t){let s=this.getGroup(e);s&&(s.titleIds.includes(t)||(s.titleIds.push(t),await this.updateGroup(s)))}getGroupedTitleIds(){var t;let e=new Set;for(let s of(t=this.data.groups)!=null?t:[])for(let i of s.titleIds)e.add(i);return e}generateGroupId(e){var a;let t="group-"+e.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,""),s=((a=this.data.groups)!=null?a:[]).map(l=>l.id);if(!s.includes(t))return t;let i=2;for(;s.includes(`${t}-${i}`);)i++;return`${t}-${i}`}getPinnedGroupId(){var e;return(e=this.data.pinnedGroupId)!=null?e:null}async setPinnedGroupId(e){this.data.pinnedGroupId=e,await this.save()}getSavedFilterPreset(){var e;return(e=this.data.savedFilterPreset)!=null?e:null}async setSavedFilterPreset(e){this.data.savedFilterPreset=e,await this.save()}getAirtimeEntries(){var e;return(e=this.data.airtime)!=null?e:[]}async addAirtimeEntry(e){this.data.airtime||(this.data.airtime=[]),this.data.airtime.push(e),await this.save()}async updateAirtimeEntry(e){if(!this.data.airtime)return;let t=this.data.airtime.findIndex(s=>s.id===e.id);t>=0&&(this.data.airtime[t]=e,await this.save())}async removeAirtimeEntry(e){var t;this.data.airtime=((t=this.data.airtime)!=null?t:[]).filter(s=>s.id!==e),await this.save()}getMaybeTitles(){var e;return(e=this.data.maybe)!=null?e:[]}async addMaybeTitle(e){this.data.maybe||(this.data.maybe=[]),this.data.maybe.push(e),await this.save()}async removeMaybeTitle(e){var t;this.data.maybe=((t=this.data.maybe)!=null?t:[]).filter(s=>s.id!==e),await this.save()}generateMaybeId(e){var a;let t="maybe-"+e.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,""),s=((a=this.data.maybe)!=null?a:[]).map(l=>l.id);if(!s.includes(t))return t;let i=2;for(;s.includes(`${t}-${i}`);)i++;return`${t}-${i}`}generateAirtimeId(e){var a;let t=`airtime-${e}`,s=((a=this.data.airtime)!=null?a:[]).map(l=>l.id);if(!s.includes(t))return t;let i=2;for(;s.includes(`${t}-${i}`);)i++;return`${t}-${i}`}calcTimeWatched(e){return e.episodeDuration<=0||e.status==="To be released"?0:e.type==="Movie"?e.watchedEpisodes.includes(1)?e.episodeDuration:0:e.status==="Completed"?e.totalEpisodes*e.episodeDuration:e.watchedEpisodes.length*e.episodeDuration}calcTimeRemaining(e){return e.episodeDuration<=0||e.status==="Dropped"||e.status==="To be released"?0:e.type==="Movie"?e.watchedEpisodes.includes(1)?0:e.episodeDuration:e.status==="Plan to watch"||e.status==="Watching"?Math.max(0,e.totalEpisodes-e.watchedEpisodes.length)*e.episodeDuration:0}calcTimeRemainingForModal(e){return e.episodeDuration<=0||e.status==="To be released"?0:e.type==="Movie"?e.watchedEpisodes.includes(1)?0:e.episodeDuration:e.status==="Dropped"||e.status==="Plan to watch"||e.status==="Watching"?Math.max(0,e.totalEpisodes-e.watchedEpisodes.length)*e.episodeDuration:0}getTotalTimeWatched(){var e;return((e=this.data.titles)!=null?e:[]).reduce((t,s)=>t+this.calcTimeWatched(s),0)}getTotalTimeRemaining(){var e;return((e=this.data.titles)!=null?e:[]).reduce((t,s)=>t+this.calcTimeRemaining(s),0)}startWatchingExternalChanges(){let e=this.app.vault.on("raw",t=>{t.endsWith("watchlog/data.json")&&(async()=>(await this.load(),this.notifyListeners()))()});this.plugin.registerEvent(e)}onChange(e){this.changeListeners.push(e)}offChange(e){this.changeListeners=this.changeListeners.filter(t=>t!==e)}notifyChange(){this.notifyListeners()}notifyListeners(){for(let e of this.changeListeners)e();activeDocument.dispatchEvent(new CustomEvent("watchlog-data-changed"))}getCollapsedSeasonsForTitle(e){var s;let t=this.data.collapsedSeasons;return new Set((s=t==null?void 0:t[e])!=null?s:[])}async persistCollapsedSeasons(e,t){let s=this.data;s.collapsedSeasons||(s.collapsedSeasons={}),s.collapsedSeasons[e]=Array.from(t),await this.save()}getProgress(e){return e.totalEpisodes===0?0:Math.round(e.watchedEpisodes.length/e.totalEpisodes*100)}getNextUnwatchedEpisode(e){let t=new Set(e.watchedEpisodes);for(let s=1;s<=e.totalEpisodes;s++)if(!t.has(s))return s;return null}async markEpisodeWatched(e,t,s){var a,l,n;let i=this.getTitle(e);if(i){if(s){i.watchedEpisodes.includes(t)||(i.watchedEpisodes.push(t),i.watchedEpisodes.sort((o,c)=>o-c));let r=new Date().toISOString();i.totalEpisodes>1?(a=this.historyManager)==null||a.log(`${i.title} (${i.type}) episode ${t} was marked as watched on ${ot(r)}`):(l=this.historyManager)==null||l.log(`${i.title} (${i.type}) was marked as watched on ${ot(r)}`)}else i.watchedEpisodes=i.watchedEpisodes.filter(r=>r!==t);this.plugin.settings.autoCompleteOnLastEpisode&&i.totalEpisodes>0&&i.watchedEpisodes.length>=i.totalEpisodes?(i.status="Completed",this.plugin.settings.setFinishDateAutomatically&&!i.dateFinished&&(i.dateFinished=(n=new Date().toISOString().split("T")[0])!=null?n:null)):i.status==="Completed"&&i.watchedEpisodes.length<i.totalEpisodes&&(i.status="Watching",i.dateFinished=null),await this.updateTitle(i)}}async markSeasonWatched(e,t,s){var a;let i=this.getTitle(e);if(i){if(s){let l=new Set(i.watchedEpisodes);for(let n of t)l.add(n);i.watchedEpisodes=Array.from(l).sort((n,r)=>n-r)}else{let l=new Set(t);i.watchedEpisodes=i.watchedEpisodes.filter(n=>!l.has(n))}this.plugin.settings.autoCompleteOnLastEpisode&&i.totalEpisodes>0&&i.watchedEpisodes.length>=i.totalEpisodes?(i.status="Completed",this.plugin.settings.setFinishDateAutomatically&&!i.dateFinished&&(i.dateFinished=(a=new Date().toISOString().split("T")[0])!=null?a:null)):i.status==="Completed"&&i.watchedEpisodes.length<i.totalEpisodes&&(i.status="Watching",i.dateFinished=null),await this.updateTitle(i)}}async ensureFolders(){if(!this.plugin.settings.autoCreateFolders)return;let e=this.plugin.settings.rootFolder;await this.ensureFolder(e);for(let t of this.plugin.settings.types)await this.ensureFolder(`${e}/${t.name}`)}async ensureFolder(e){let t=(0,q.normalizePath)(e);if(!this.app.vault.getAbstractFileByPath(t))try{await this.app.vault.createFolder(t)}catch(s){}}async updateMarkdownFile(e){let t=this.plugin.settings.rootFolder,s=(0,q.normalizePath)(`${t}/${e.type}`);await this.ensureFolder(s);let i=e.title.replace(/[*"\\/<>:|?]/g,"-"),a=(0,q.normalizePath)(`${s}/${i}.md`),l=this.getProgress(e),n=this.buildMarkdownContent(e,l),r=this.app.vault.getAbstractFileByPath(a);r instanceof q.TFile?await this.app.vault.modify(r,n):await this.app.vault.create(a,n)}async createMarkdownFileIfMissing(e){let t=this.plugin.settings.rootFolder,s=(0,q.normalizePath)(`${t}/${e.type}`),i=e.title.replace(/[*"\\/<>:|?]/g,"-"),a=(0,q.normalizePath)(`${s}/${i}.md`);if(this.app.vault.getAbstractFileByPath(a)instanceof q.TFile)return!1;await this.ensureFolder((0,q.normalizePath)(t)),await this.ensureFolder(s);let l=this.buildMarkdownContent(e,this.getProgress(e));return await this.app.vault.create(a,l),!0}buildMarkdownContent(e,t){var s,i;return`--- -title: "${e.title.replace(/"/g,'\\"')}" -type: ${e.type} -status: ${e.status} -priority: ${e.priority} -rating: ${e.rating} -dateStarted: ${(s=e.dateStarted)!=null?s:"null"} -dateFinished: ${(i=e.dateFinished)!=null?i:"null"} +var Qe=Object.defineProperty;var Ii=Object.getOwnPropertyDescriptor;var Bi=Object.getOwnPropertyNames;var Pi=Object.prototype.hasOwnProperty;var Fi=(g,s)=>{for(var t in s)Qe(g,t,{get:s[t],enumerable:!0})},$i=(g,s,t,e)=>{if(s&&typeof s=="object"||typeof s=="function")for(let i of Bi(s))!Pi.call(g,i)&&i!==t&&Qe(g,i,{get:()=>s[i],enumerable:!(e=Ii(s,i))||e.enumerable});return g};var Wi=g=>$i(Qe({},"__esModule",{value:!0}),g);var mn={};Fi(mn,{default:()=>qe});module.exports=Wi(mn);var ot=require("obsidian");var wt=["Reading","Completed","Plan to Read","To be released","Dropped"],lt=wt.filter(g=>g!=="To be released"),Ye=[{color600:"#534AB7",color50:"#EEEDFE"},{color600:"#1D9E75",color50:"#E1F5EE"},{color600:"#EF9F27",color50:"#FAEEDA"},{color600:"#378ADD",color50:"#E6F1FB"},{color600:"#D85A30",color50:"#FAECE7"},{color600:"#993556",color50:"#FBEAF0"},{color600:"#639922",color50:"#EAF3DE"},{color600:"#5F5E5A",color50:"#F1EFE8"}],$s=Object.fromEntries(Ye.map(({color600:g,color50:s})=>[g,s])),It="#5F5E5A",Je={defaultFolder:"WatchLog/Reading",defaultStatus:"Plan to Read",bookCustomFieldStyle:"fill",mangaCustomFieldStyle:"fill"};function Ws(g){var i,a,n;let s=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],t=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],e=g.releaseTime?` \xB7 ${g.releaseTime}`:"";if(g.recurrence==="once"){if(!g.releaseDate)return"No date set";let r=new Date(g.releaseDate+"T12:00:00");return`Release date \xB7 ${r.getDate()} ${(i=t[r.getMonth()])!=null?i:""} ${r.getFullYear()}${e}`}return g.recurrence==="daily"?`Every day${e}`:g.recurrence==="weekly"?`Every ${g.dayOfWeek!==void 0&&(a=s[g.dayOfWeek])!=null?a:"Unknown"}${e}`:g.recurrence==="monthly"?`Monthly on day ${(n=g.dayOfMonth)!=null?n:"?"}${e}`:"\u2014"}var dt={colorTheme:"default",defaultWatchlistView:"cards",autoCompleteOnLastEpisode:!0,setFinishDateAutomatically:!1,omdbApiKey:"",tmdbApiKey:"",googleBooksApiKey:"",activeApi:"OMDb",animeApiSource:"jikan",typeApiMapping:{},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:!0,coloredTypeBadges:!0,dashboardCardStyle:"circles",episodeNumbering:"absolute",readingTypeColors:{manga:"#D4537E",book:"#D85A30"},customListsFolder:"WatchLog/CustomLists",defaultCustomColumns:[],draftsVaultTag:"#watchlog",draftsAfterAdding:"keep",customListTabOrder:[],showHintBanners:!0,showUpcomingStatusBar:!0,listFilters:{typeExclude:[],statusExclude:[],groupExclude:[],ratingExclude:[],priorityExclude:[],sort:"dateAdded",sortDir:"desc",secondSort:"none",secondSortDir:"asc"},seasonPalette:["#1D9E75","#BA7517","#378ADD","#7F77DD","#D85A30","#D4537E","#639922","#888780"]};function N(g,s,t){return s}function pe(g,s){var e;let t=(e=s.readingTypeColors)!=null?e:dt.readingTypeColors;return g==="manga"?t.manga:t.book}function Q(g){if(!g)return"";let s=g.match(/^(\d{4})-(\d{2})-(\d{2})$/);return s?`${s[3]}/${s[2]}/${s[1]}`:g}function q(g){let s=g.trim();if(!s)return null;let t=s.split("/");if(t.length!==3)return null;let[e,i,a]=t;if(!e||!i||!a||a.length!==4)return null;let n=parseInt(e,10),r=parseInt(i,10),o=parseInt(a,10);if(isNaN(n)||isNaN(r)||isNaN(o)||r<1||r>12||n<1||n>31)return null;let l=new Date(o,r-1,n);return l.getFullYear()!==o||l.getMonth()!==r-1||l.getDate()!==n?null:`${a}-${i.padStart(2,"0")}-${e.padStart(2,"0")}`}function Bt(g){let s=g.trim();if(!s)return null;if(/^\d{4}-\d{2}-\d{2}$/.test(s))return s;let t=s.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/);if(t){let e=t[1].padStart(2,"0"),i=t[2].padStart(2,"0"),a=t[3],n=parseInt(e,10),r=parseInt(i,10);return r<1||r>12||n<1||n>31?null:`${a}-${i}-${e}`}return null}function yt(g){if(!g||!/^\d{4}-\d{2}-\d{2}$/.test(g))return!1;let s=new Date;return s.setHours(0,0,0,0),new Date(g+"T12:00:00").getTime()>s.getTime()}function nt(g,s){var t;return g==="Anime"?"anime":g==="Movie"||g==="TV Show"||g==="TvShow"?"movie":(t=s==null?void 0:s[g])!=null?t:""}function ge(g){var s;return g.manualPosterUrl&&g.manualPosterUrl.trim()!==""?g.manualPosterUrl:(s=g.posterUrl)!=null?s:""}function Os(g){return!g||g<0?"0":g>=1e6?(g/1e6).toFixed(1)+"M":g>=1e3?(g/1e3).toFixed(1)+"K":g.toString()}function z(g){if(g<=0)return"0m";let s=Math.floor(g/60),t=g%60,e=s>=1e3?String(s).replace(/\B(?=(\d{3})+(?!\d))/g,"."):String(s);return s===0?`${t}m`:t===0?`${e}h`:`${e}h ${t}m`}var Z=require("obsidian");var Ns=require("obsidian"),Oi=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];function ct(g){var o;let s=new Date(g),t=(o=Oi[s.getDay()])!=null?o:"",e=String(s.getDate()).padStart(2,"0"),i=String(s.getMonth()+1).padStart(2,"0"),a=s.getFullYear(),n=String(s.getHours()).padStart(2,"0"),r=String(s.getMinutes()).padStart(2,"0");return`${t} ${e}/${i}/${a} at ${n}:${r}`}var Kt=class{constructor(s){this.entries=[];this.MAX_ENTRIES=1e3;this.plugin=s}get filePath(){return(0,Ns.normalizePath)(`${this.plugin.app.vault.configDir}/plugins/watchlog/history.json`)}async load(){try{if(await this.plugin.app.vault.adapter.exists(this.filePath)){let t=await this.plugin.app.vault.adapter.read(this.filePath),e=JSON.parse(t);this.entries=Array.isArray(e.entries)?e.entries:[]}}catch(s){console.warn("[WL] HistoryManager.load failed:",s),this.entries=[]}}async log(s,t){let e={id:`${Date.now()}-${Math.random().toString(36).slice(2,7)}`,timestamp:new Date().toISOString(),message:s,source:t==null?void 0:t.source,action:t==null?void 0:t.action,titleName:t==null?void 0:t.titleName};this.entries.push(e),this.entries.length>this.MAX_ENTRIES&&(this.entries=this.entries.slice(-this.MAX_ENTRIES)),await this.save()}getEntries(){return[...this.entries].reverse()}exportEntries(){return[...this.entries]}async restore(s){let t=Array.isArray(s)?s:[];this.entries=t.length>this.MAX_ENTRIES?t.slice(-this.MAX_ENTRIES):t,await this.save()}async save(){try{await this.plugin.app.vault.adapter.write(this.filePath,JSON.stringify({entries:this.entries},null,2))}catch(s){}}};function Ni(g){return g!==null&&typeof g=="object"&&Array.isArray(g.titles)}var zt=class{constructor(s){this.changeListeners=[];this.historyManager=null;this.pendingSaveTimer=null;this.pendingMdTitleIds=new Set;this.EPISODE_SAVE_DEBOUNCE_MS=500;this.lastSelfSaveTime=0;this.SELF_SAVE_ECHO_WINDOW_MS=2e3;this.posterSaveTimer=null;this.POSTER_SAVE_DEBOUNCE_MS=5e3;this.queuedSaveTimer=null;this.QUEUED_SAVE_DEBOUNCE_MS=100;this.lastMarkdownPathById=new Map;this.plugin=s,this.app=s.app,this.data={titles:[],groups:[],settings:{}}}setHistoryManager(s){this.historyManager=s}async load(){let s=await this.plugin.loadData();this.data=Ni(s)?s:{titles:[],groups:[],settings:{}},this.migrateData()&&await this.saveOnly()}migrateData(){var t;let s=!1;Array.isArray(this.data.titles)||(this.data.titles=[],s=!0),Array.isArray(this.data.groups)||(this.data.groups=[],s=!0),Array.isArray(this.data.airtime)||(this.data.airtime=[],s=!0);for(let e of this.data.titles){if(e.dateAdded?e.dateAdded.includes("T")||(e.dateAdded=new Date(e.dateAdded).toISOString(),s=!0):(e.dateAdded=new Date().toISOString(),s=!0),!e.dateModified){let a=e.lastInteracted;typeof a=="string"&&a?e.dateModified=a:e.dateModified=e.dateAdded,s=!0}for(let i of(t=e.seasons)!=null?t:[])Array.isArray(i.skippedEpisodes)||(i.skippedEpisodes=[],s=!0);e.posterUrl===void 0&&(e.posterUrl="",s=!0),e.manualPosterUrl===void 0&&(e.manualPosterUrl="",s=!0),e.anilistId===void 0&&(e.anilistId=0,s=!0),e.communityRating===void 0&&(e.communityRating=0,s=!0),e.communityVotes===void 0&&(e.communityVotes=0,s=!0),e.communitySource===void 0&&(e.communitySource="",s=!0),e.communityRatingLastFetched===void 0&&(e.communityRatingLastFetched="",s=!0)}if(!this.data.posterRetryDone){for(let e of this.data.titles)e.posterUrl==="none"&&(e.posterUrl="");this.data.posterRetryDone=!0,s=!0}return s}updatePosterUrl(s,t){let e=this.data.titles.find(i=>i.id===s);e&&(e.posterUrl=t,this.schedulePosterSave())}updateCommunityRating(s,t,e,i){let a=this.data.titles.find(n=>n.id===s);a&&(a.communityRating=t,a.communityVotes=e,a.communitySource=i,a.communityRatingLastFetched=new Date().toISOString(),this.saveOnly())}schedulePosterSave(){this.posterSaveTimer===null&&(this.posterSaveTimer=window.setTimeout(()=>{this.posterSaveTimer=null,this.saveOnly()},this.POSTER_SAVE_DEBOUNCE_MS))}flushPosterSave(){this.posterSaveTimer!==null&&(window.clearTimeout(this.posterSaveTimer),this.posterSaveTimer=null,this.saveOnly())}flushPosterSaveSync(){this.posterSaveTimer!==null&&(window.clearTimeout(this.posterSaveTimer),this.posterSaveTimer=null,this.lastSelfSaveTime=Date.now(),this.plugin.saveData(this.data))}getData(){return this.data}queueSave(){this.queuedSaveTimer!==null&&window.clearTimeout(this.queuedSaveTimer),this.queuedSaveTimer=window.setTimeout(()=>{this.queuedSaveTimer=null,this.saveOnly()},this.QUEUED_SAVE_DEBOUNCE_MS)}flushQueuedSaveSync(){this.queuedSaveTimer!==null&&(window.clearTimeout(this.queuedSaveTimer),this.queuedSaveTimer=null,this.lastSelfSaveTime=Date.now(),this.plugin.saveData(this.data))}async saveSettings(s){this.data.settings=s,await this.saveOnly()}async saveOnly(){this.lastSelfSaveTime=Date.now(),await this.plugin.saveData(this.data),this.lastSelfSaveTime=Date.now()}async save(){this.pendingSaveTimer!==null&&(window.clearTimeout(this.pendingSaveTimer),this.pendingSaveTimer=null,this.pendingMdTitleIds.clear()),await this.saveOnly(),this.notifyListeners()}scheduleEpisodeSave(s){this.pendingMdTitleIds.add(s),this.pendingSaveTimer!==null&&window.clearTimeout(this.pendingSaveTimer),this.pendingSaveTimer=window.setTimeout(()=>{this.flushPendingSave()},this.EPISODE_SAVE_DEBOUNCE_MS)}async flushPendingSave(){if(this.pendingSaveTimer!==null&&(window.clearTimeout(this.pendingSaveTimer),this.pendingSaveTimer=null),this.pendingMdTitleIds.size===0)return;let s=Array.from(this.pendingMdTitleIds);this.pendingMdTitleIds.clear(),await this.saveOnly();for(let t of s){let e=this.getTitle(t);e&&await this.updateMarkdownFile(e)}}flushPendingSaveSync(){if(this.pendingSaveTimer!==null&&(window.clearTimeout(this.pendingSaveTimer),this.pendingSaveTimer=null),this.pendingMdTitleIds.size===0)return;let s=Array.from(this.pendingMdTitleIds);this.pendingMdTitleIds.clear(),this.lastSelfSaveTime=Date.now(),this.plugin.saveData(this.data);for(let t of s){let e=this.getTitle(t);e&&this.updateMarkdownFile(e)}}getTitles(){var s;return(s=this.data.titles)!=null?s:[]}getTitle(s){var t;return((t=this.data.titles)!=null?t:[]).find(e=>e.id===s)}async addTitle(s){var i;let t=this.canAutoAddToUpcoming(s);t&&(s.status="To be released"),this.data.titles.push(s),await this.save(),t&&await this.autoAddToUpcoming(s),await this.updateMarkdownFile(s);let e=new Date().toISOString();(i=this.historyManager)==null||i.log(`${s.title} (${s.type}) was added on ${ct(e)}`,{source:"Watchlist",action:"added",titleName:s.title})}async addTitleSilent(s){let t=this.canAutoAddToUpcoming(s);t&&(s.status="To be released"),this.data.titles.push(s),await this.saveOnly(),t&&await this.autoAddToUpcoming(s),await this.updateMarkdownFile(s)}async addTitleBatch(s){let t=[];for(let e of s)this.canAutoAddToUpcoming(e)&&(e.status="To be released",t.push(e)),this.data.titles.push(e);await this.saveOnly();for(let e of t)await this.autoAddToUpcoming(e);for(let e of s)await this.updateMarkdownFile(e)}async batchUpdate(s,t){let e=[];for(let i of s){let a=this.getTitle(i);a&&(t(a),a.dateModified=new Date().toISOString(),e.push(a))}if(e.length!==0){await this.save();for(let i of e)await this.updateMarkdownFile(i)}}async removeTitlesBatch(s){let e=[];for(let i of s){let a=this.getTitle(i);a&&e.push(a),this.data.titles=this.data.titles.filter(r=>r.id!==i);for(let r of this.data.groups)r.titleIds=r.titleIds.filter(o=>o!==i);this.data.airtime&&(this.data.airtime=this.data.airtime.filter(r=>r.titleId!==i||r.source==="reading"));let n=this.data;n.collapsedSeasons&&delete n.collapsedSeasons[i]}await this.save();for(let i=0;i<e.length;i+=10){let a=e.slice(i,i+10);await Promise.all(a.map(n=>this.deleteMarkdownFile(n)))}}canAutoAddToUpcoming(s){if(!s.releaseDate||!/^\d{4}-\d{2}-\d{2}$/.test(s.releaseDate))return!1;let t=new Date(s.releaseDate+"T12:00:00"),e=new Date;return e.setHours(0,0,0,0),t>e}async autoAddToUpcoming(s){var e;if(this.data.airtime||(this.data.airtime=[]),this.data.airtime.some(i=>i.titleId===s.id))return;let t={id:this.generateAirtimeId(s.id),titleId:s.id,schedule:{recurrence:"once",releaseDate:(e=s.releaseDate)!=null?e:void 0},dateAdded:new Date().toISOString()};this.data.airtime.push(t),await this.plugin.saveData(this.data)}async removeAirtimeEntriesForTitle(s){var e,i,a;let t=((e=this.data.airtime)!=null?e:[]).length;this.data.airtime=((i=this.data.airtime)!=null?i:[]).filter(n=>n.titleId!==s||n.source==="reading"),((a=this.data.airtime)!=null?a:[]).length!==t&&await this.save()}async autoAddReadingToUpcoming(s,t){var i;if(this.data.airtime||(this.data.airtime=[]),this.data.airtime.some(a=>a.source==="reading"&&a.titleId===s.id))return;let e={id:this.generateReadingAirtimeId(s.id),titleId:s.id,source:"reading",readingKind:t,schedule:{recurrence:"once",releaseDate:(i=s.releaseDate)!=null?i:void 0},dateAdded:new Date().toISOString()};this.data.airtime.push(e),await this.plugin.saveData(this.data)}async removeReadingAirtimeEntries(s){var e,i,a;let t=((e=this.data.airtime)!=null?e:[]).length;this.data.airtime=((i=this.data.airtime)!=null?i:[]).filter(n=>!(n.source==="reading"&&n.titleId===s)),((a=this.data.airtime)!=null?a:[]).length!==t&&await this.save()}generateReadingAirtimeId(s){var a;let t=`airtime-reading-${s}`,e=((a=this.data.airtime)!=null?a:[]).map(n=>n.id);if(!e.includes(t))return t;let i=2;for(;e.includes(`${t}-${i}`);)i++;return`${t}-${i}`}async updateTitle(s){var e,i;s.dateModified=new Date().toISOString(),s.status==="Completed"&&(s.priority="");let t=this.data.titles.findIndex(a=>a.id===s.id);if(t>=0){let a=this.data.titles[t],n=new Date().toISOString();a.rating!==s.rating&&((e=this.historyManager)==null||e.log(`${s.title} (${s.type}) was reviewed on ${ct(n)}`,{source:"Watchlist",action:"rating",titleName:s.title})),a.status!==s.status&&s.status==="Completed"&&((i=this.historyManager)==null||i.log(`${s.title} (${s.type}) was marked as watched on ${ct(n)}`,{source:"Watchlist",action:"completed",titleName:s.title})),this.data.titles[t]=s,await this.save(),await this.updateMarkdownFile(s)}}updateTitleSilent(s){var e,i;s.dateModified=new Date().toISOString(),s.status==="Completed"&&(s.priority="");let t=this.data.titles.findIndex(a=>a.id===s.id);if(t>=0){let a=this.data.titles[t],n=new Date().toISOString();a.rating!==s.rating&&((e=this.historyManager)==null||e.log(`${s.title} (${s.type}) was reviewed on ${ct(n)}`,{source:"Watchlist",action:"rating",titleName:s.title})),a.status!==s.status&&s.status==="Completed"&&((i=this.historyManager)==null||i.log(`${s.title} (${s.type}) was marked as watched on ${ct(n)}`,{source:"Watchlist",action:"completed",titleName:s.title})),this.data.titles[t]=s}}async removeTitle(s){var i;let t=this.getTitle(s);if(t){let a=new Date().toISOString();(i=this.historyManager)==null||i.log(`${t.title} (${t.type}) was deleted on ${ct(a)}`,{source:"Watchlist",action:"deleted",titleName:t.title})}this.data.titles=this.data.titles.filter(a=>a.id!==s);for(let a of this.data.groups)a.titleIds=a.titleIds.filter(n=>n!==s);this.data.airtime&&(this.data.airtime=this.data.airtime.filter(a=>a.titleId!==s||a.source==="reading"));let e=this.data;e.collapsedSeasons&&delete e.collapsedSeasons[s],await this.save(),t&&await this.deleteMarkdownFile(t)}getGroups(){var s;return(s=this.data.groups)!=null?s:[]}getGroup(s){var t;return((t=this.data.groups)!=null?t:[]).find(e=>e.id===s)}async addGroup(s){this.data.groups.push(s),await this.save()}async updateGroup(s){let t=this.data.groups.findIndex(e=>e.id===s.id);t>=0&&(this.data.groups[t]=s,await this.save())}async removeGroup(s){this.data.groups=this.data.groups.filter(t=>t.id!==s),await this.save()}async addTitleToGroup(s,t){let e=this.getGroup(s);e&&(e.titleIds.includes(t)||(e.titleIds.push(t),await this.updateGroup(e)))}getGroupedTitleIds(){var t;let s=new Set;for(let e of(t=this.data.groups)!=null?t:[])for(let i of e.titleIds)s.add(i);return s}generateGroupId(s){var a;let t="group-"+s.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,""),e=((a=this.data.groups)!=null?a:[]).map(n=>n.id);if(!e.includes(t))return t;let i=2;for(;e.includes(`${t}-${i}`);)i++;return`${t}-${i}`}getPinnedGroupId(){var s;return(s=this.data.pinnedGroupId)!=null?s:null}async setPinnedGroupId(s){this.data.pinnedGroupId=s,await this.save()}getSavedFilterPreset(){var s;return(s=this.data.savedFilterPreset)!=null?s:null}async setSavedFilterPreset(s){this.data.savedFilterPreset=s,await this.save()}getAirtimeEntries(){var s;return(s=this.data.airtime)!=null?s:[]}async addAirtimeEntry(s){this.data.airtime||(this.data.airtime=[]),this.data.airtime.push(s),await this.save()}async updateAirtimeEntry(s){if(!this.data.airtime)return;let t=this.data.airtime.findIndex(e=>e.id===s.id);t>=0&&(this.data.airtime[t]=s,await this.save())}async removeAirtimeEntry(s){var t;this.data.airtime=((t=this.data.airtime)!=null?t:[]).filter(e=>e.id!==s),await this.save()}async removeAirtimeEntriesBatch(s){var e;if(s.length===0)return;let t=new Set(s);this.data.airtime=((e=this.data.airtime)!=null?e:[]).filter(i=>!t.has(i.id)),await this.save()}getMaybeTitles(){var s;return(s=this.data.maybe)!=null?s:[]}async addMaybeTitle(s){this.data.maybe||(this.data.maybe=[]),this.data.maybe.push(s),await this.save()}async removeMaybeTitle(s){var t;this.data.maybe=((t=this.data.maybe)!=null?t:[]).filter(e=>e.id!==s),await this.save()}generateMaybeId(s){var a;let t="maybe-"+s.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,""),e=((a=this.data.maybe)!=null?a:[]).map(n=>n.id);if(!e.includes(t))return t;let i=2;for(;e.includes(`${t}-${i}`);)i++;return`${t}-${i}`}generateAirtimeId(s){var a;let t=`airtime-${s}`,e=((a=this.data.airtime)!=null?a:[]).map(n=>n.id);if(!e.includes(t))return t;let i=2;for(;e.includes(`${t}-${i}`);)i++;return`${t}-${i}`}calcTimeWatched(s){return s.episodeDuration<=0||s.status==="To be released"?0:s.type==="Movie"?s.watchedEpisodes.includes(1)?s.episodeDuration:0:s.status==="Completed"?this.getEffectiveTotal(s)*s.episodeDuration:s.watchedEpisodes.length*s.episodeDuration}calcTimeRemaining(s){if(s.episodeDuration<=0||s.status==="Dropped"||s.status==="To be released")return 0;if(s.type==="Movie")return s.watchedEpisodes.includes(1)?0:s.episodeDuration;let t=this.getEffectiveTotal(s);return s.status==="Plan to watch"||s.status==="Watching"?Math.max(0,t-s.watchedEpisodes.length)*s.episodeDuration:0}calcTimeRemainingForModal(s){if(s.episodeDuration<=0||s.status==="To be released")return 0;if(s.type==="Movie")return s.watchedEpisodes.includes(1)?0:s.episodeDuration;let t=this.getEffectiveTotal(s);return s.status==="Dropped"||s.status==="Plan to watch"||s.status==="Watching"?Math.max(0,t-s.watchedEpisodes.length)*s.episodeDuration:0}getTotalTimeWatched(){var s;return((s=this.data.titles)!=null?s:[]).reduce((t,e)=>t+this.calcTimeWatched(e),0)}getTotalTimeRemaining(){var s;return((s=this.data.titles)!=null?s:[]).reduce((t,e)=>t+this.calcTimeRemaining(e),0)}startWatchingExternalChanges(){let s=this.app.vault.on("raw",t=>{if(t.endsWith("watchlog/data.json")){if(Date.now()-this.lastSelfSaveTime<this.SELF_SAVE_ECHO_WINDOW_MS)return;(async()=>(await this.load(),this.notifyListeners()))()}});this.plugin.registerEvent(s)}onChange(s){this.changeListeners.push(s)}offChange(s){this.changeListeners=this.changeListeners.filter(t=>t!==s)}notifyChange(){this.notifyListeners()}notifyListeners(){for(let s of this.changeListeners)s();activeDocument.dispatchEvent(new CustomEvent("watchlog-data-changed"))}getCollapsedSeasonsForTitle(s){var e;let t=this.data.collapsedSeasons;return new Set((e=t==null?void 0:t[s])!=null?e:[])}async persistCollapsedSeasons(s,t){let e=this.data;e.collapsedSeasons||(e.collapsedSeasons={}),e.collapsedSeasons[s]=Array.from(t),await this.save()}getTotalSkippedCount(s){return s.seasons.reduce((t,e)=>{var i,a;return t+((a=(i=e.skippedEpisodes)==null?void 0:i.length)!=null?a:0)},0)}getEffectiveTotal(s){return Math.max(0,s.totalEpisodes-this.getTotalSkippedCount(s))}isEpisodeSkipped(s,t){var e;for(let i of s.seasons){let a=t-i.offset;if(a>=1&&a<=i.episodes&&((e=i.skippedEpisodes)!=null?e:[]).includes(a))return!0}return!1}applyEpisodeWatchedToggle(s,t,e){var a,n;let i=this.getTitle(s);if(!i)return null;if(e){i.watchedEpisodes.includes(t)||(i.watchedEpisodes.push(t),i.watchedEpisodes.sort((o,l)=>o-l));let r=new Date().toISOString();i.totalEpisodes>1?(a=this.historyManager)==null||a.log(`${i.title} (${i.type}) episode ${t} was marked as watched on ${ct(r)}`,{source:"Watchlist",action:"watched",titleName:i.title}):(n=this.historyManager)==null||n.log(`${i.title} (${i.type}) was marked as watched on ${ct(r)}`,{source:"Watchlist",action:"watched",titleName:i.title})}else i.watchedEpisodes=i.watchedEpisodes.filter(r=>r!==t);return i.dateModified=new Date().toISOString(),this.applyAutoCompleteRules(i),this.scheduleEpisodeSave(s),i}applyAutoCompleteRules(s){var e;let t=this.getEffectiveTotal(s);this.plugin.settings.autoCompleteOnLastEpisode&&t>0&&s.watchedEpisodes.length>=t?(s.status!=="Completed"&&(s.status="Completed",s.priority=""),this.plugin.settings.setFinishDateAutomatically&&!s.dateFinished&&(s.dateFinished=(e=new Date().toISOString().split("T")[0])!=null?e:null)):s.status==="Completed"&&s.watchedEpisodes.length<t&&(s.status="Watching",s.dateFinished=null)}getProgress(s){let t=this.getEffectiveTotal(s);return t===0?0:Math.min(100,Math.round(s.watchedEpisodes.length/t*100))}getNextUnwatchedEpisode(s){let t=new Set(s.watchedEpisodes);for(let e=1;e<=s.totalEpisodes;e++)if(!t.has(e))return e;return null}async markEpisodeWatched(s,t,e){var n,r,o;let i=this.getTitle(s);if(!i)return;if(e){i.watchedEpisodes.includes(t)||(i.watchedEpisodes.push(t),i.watchedEpisodes.sort((c,d)=>c-d));let l=new Date().toISOString();i.totalEpisodes>1?(n=this.historyManager)==null||n.log(`${i.title} (${i.type}) episode ${t} was marked as watched on ${ct(l)}`,{source:"Watchlist",action:"watched",titleName:i.title}):(r=this.historyManager)==null||r.log(`${i.title} (${i.type}) was marked as watched on ${ct(l)}`,{source:"Watchlist",action:"watched",titleName:i.title})}else i.watchedEpisodes=i.watchedEpisodes.filter(l=>l!==t);let a=this.getEffectiveTotal(i);this.plugin.settings.autoCompleteOnLastEpisode&&a>0&&i.watchedEpisodes.length>=a?(i.status="Completed",this.plugin.settings.setFinishDateAutomatically&&!i.dateFinished&&(i.dateFinished=(o=new Date().toISOString().split("T")[0])!=null?o:null)):i.status==="Completed"&&i.watchedEpisodes.length<a&&(i.status="Watching",i.dateFinished=null),await this.updateTitle(i)}async markSeasonWatched(s,t,e){var n;let i=this.getTitle(s);if(!i)return;if(e){let r=new Set(i.watchedEpisodes);for(let o of t)r.add(o);i.watchedEpisodes=Array.from(r).sort((o,l)=>o-l)}else{let r=new Set(t);i.watchedEpisodes=i.watchedEpisodes.filter(o=>!r.has(o))}let a=this.getEffectiveTotal(i);this.plugin.settings.autoCompleteOnLastEpisode&&a>0&&i.watchedEpisodes.length>=a?(i.status="Completed",this.plugin.settings.setFinishDateAutomatically&&!i.dateFinished&&(i.dateFinished=(n=new Date().toISOString().split("T")[0])!=null?n:null)):i.status==="Completed"&&i.watchedEpisodes.length<a&&(i.status="Watching",i.dateFinished=null),await this.updateTitle(i)}async ensureFolders(){if(!this.plugin.settings.autoCreateFolders)return;let s=this.plugin.settings.rootFolder;await this.ensureFolder(s);for(let t of this.plugin.settings.types)await this.ensureFolder(`${s}/${t.name}`)}async ensureFolder(s){let t=(0,Z.normalizePath)(s);if(!this.app.vault.getAbstractFileByPath(t))try{await this.app.vault.createFolder(t)}catch(e){}}async updateMarkdownFile(s){let t=this.plugin.settings.rootFolder,e=(0,Z.normalizePath)(`${t}/${s.type}`);await this.ensureFolder(e);let i=s.title.replace(/[*"\\/<>:|?]/g,"-"),a=(0,Z.normalizePath)(`${e}/${i}.md`),n=this.getProgress(s),r=this.buildMarkdownContent(s,n),o=this.lastMarkdownPathById.get(s.id);if(o&&o!==a)try{let c=this.app.vault.getAbstractFileByPath(o);c instanceof Z.TFile&&await this.app.fileManager.trashFile(c)}catch(c){}let l=this.app.vault.getAbstractFileByPath(a);l instanceof Z.TFile?await this.app.vault.modify(l,r):await this.app.vault.create(a,r),this.lastMarkdownPathById.set(s.id,a)}async createMarkdownFileIfMissing(s){let t=this.plugin.settings.rootFolder,e=(0,Z.normalizePath)(`${t}/${s.type}`),i=s.title.replace(/[*"\\/<>:|?]/g,"-"),a=(0,Z.normalizePath)(`${e}/${i}.md`);if(this.app.vault.getAbstractFileByPath(a)instanceof Z.TFile)return!1;await this.ensureFolder((0,Z.normalizePath)(t)),await this.ensureFolder(e);let n=this.buildMarkdownContent(s,this.getProgress(s));return await this.app.vault.create(a,n),!0}buildMarkdownContent(s,t){var e,i;return`--- +title: ${s.title.replace(/[*"\\/<>:|?]/g,"-")} +type: ${s.type} +status: ${s.status} +priority: ${s.priority} +rating: ${s.rating} +dateStarted: ${(e=s.dateStarted)!=null?e:"null"} +dateFinished: ${(i=s.dateFinished)!=null?i:"null"} progress: ${t}% -totalEpisodes: ${e.totalEpisodes} -externalLink: ${e.externalLink} +totalEpisodes: ${s.totalEpisodes} +externalLink: ${s.externalLink} --- ## Notes -${e.notes} -`}async deleteMarkdownFile(e){let t=this.plugin.settings.rootFolder,s=e.title.replace(/[*"\\/<>:|?]/g,"-"),i=(0,q.normalizePath)(`${t}/${e.type}/${s}.md`),a=this.app.vault.getAbstractFileByPath(i);a instanceof q.TFile&&await this.app.fileManager.trashFile(a)}getRecentlyWatched(e){var t;return[...(t=this.data.titles)!=null?t:[]].filter(s=>s.watchedEpisodes.length>0||s.status==="Completed").sort((s,i)=>i.dateModified.localeCompare(s.dateModified)).slice(0,e)}getRecentlyAdded(e){var t;return[...(t=this.data.titles)!=null?t:[]].sort((s,i)=>i.dateAdded.localeCompare(s.dateAdded)).slice(0,e)}getStatsByType(e){var n;let t=["Dropped","To be released"],s=(n=this.data.titles)!=null?n:[],i=(e==="All"?s:s.filter(r=>r.type===e)).filter(r=>!t.includes(r.status)),a=i.length;return{watched:i.filter(r=>r.status==="Completed").length,total:a}}getCompletedCount(){var e;return((e=this.data.titles)!=null?e:[]).filter(t=>t.status==="Completed").length}generateId(e){var a;let t=e.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,""),s=((a=this.data.titles)!=null?a:[]).map(l=>l.id);if(!s.includes(t))return t;let i=2;for(;s.includes(`${t}-${i}`);)i++;return`${t}-${i}`}};var Pe=require("obsidian"),Ie="https://api.jikan.moe/v4",gt="https://www.omdbapi.com",ct="https://api.themoviedb.org/3",us=8e3,Dt=class{constructor(e,t=""){this.omdbApiKey=e,this.tmdbApiKey=t}setOmdbKey(e){this.omdbApiKey=e}setTmdbKey(e){this.tmdbApiKey=e}tmdbHeaders(){return{Authorization:`Bearer ${this.tmdbApiKey}`}}async fetchWithTimeout(e,t){let s=new Promise((a,l)=>window.setTimeout(()=>l(new Error("Request timed out")),us)),i=(0,Pe.requestUrl)({url:e,headers:t}).then(a=>a.json);return Promise.race([i,s])}async searchAnime(e){var s;let t=`${Ie}/anime?q=${encodeURIComponent(e)}&limit=10&sfw=false`;try{return((s=(await this.fetchWithTimeout(t)).data)!=null?s:[]).map(a=>this.mapJikanAnime(a))}catch(i){return[]}}mapJikanAnime(e){var n,r,o,c,d;let s=((n=e.duration)!=null?n:"24 min per ep").match(/(\d+)\s*min/),i=s&&s[1]?parseInt(s[1]):24,a=(r=e.episodes)!=null?r:0,l=a>0?[{name:"Season 1",episodes:a,offset:0}]:[];return{malId:e.mal_id,title:(o=e.title_english)!=null?o:e.title,episodes:a,duration:i,releaseDate:(c=e.aired)!=null&&c.from&&(d=e.aired.from.split("T")[0])!=null?d:"",url:e.url,seasons:l}}async searchOmdb(e,t){var i;if(!this.omdbApiKey)return[];let s=`${gt}/?s=${encodeURIComponent(e)}&type=${t}&apikey=${encodeURIComponent(this.omdbApiKey)}`;try{let a=await this.fetchWithTimeout(s);return a.Response==="False"?[]:((i=a.Search)!=null?i:[]).slice(0,10).map(l=>({imdbId:l.imdbID,title:l.Title,mediaType:t==="movie"?"movie":"tv",episodes:t==="movie"?1:0,episodeDuration:0,releaseDate:l.Year?`${l.Year}-01-01`:"",url:`https://www.imdb.com/title/${l.imdbID}`,seasons:[]}))}catch(a){return[]}}async getOmdbMovieDetails(e){var s;if(!this.omdbApiKey)return null;let t=`${gt}/?i=${encodeURIComponent(e)}&apikey=${encodeURIComponent(this.omdbApiKey)}`;try{let i=await this.fetchWithTimeout(t);if(i.Response==="False"||!i.imdbID)return null;let a=i.Runtime?parseInt(i.Runtime.replace(/[^0-9]/g,"")):120;return{imdbId:i.imdbID,title:i.Title,mediaType:"movie",episodes:1,episodeDuration:isNaN(a)?120:a,releaseDate:this.parseOmdbDate((s=i.Released)!=null?s:i.Year),url:`https://www.imdb.com/title/${i.imdbID}`,seasons:[{name:"Movie",episodes:1,offset:0}]}}catch(i){return null}}async getOmdbTvDetails(e){var s,i;if(!this.omdbApiKey)return null;let t=`${gt}/?i=${encodeURIComponent(e)}&apikey=${encodeURIComponent(this.omdbApiKey)}`;try{let a=await this.fetchWithTimeout(t);if(a.Response==="False"||!a.imdbID)return null;let l=parseInt((s=a.totalSeasons)!=null?s:"1")||1,n=[],r=0;for(let c=1;c<=l;c++){let d=await this.getOmdbSeasonEpisodeCount(e,c);if(d===null)break;n.push({name:`Season ${c}`,episodes:d,offset:r}),r+=d}let o=n.reduce((c,d)=>c+d.episodes,0);return{imdbId:a.imdbID,title:a.Title,mediaType:"tv",episodes:o,episodeDuration:45,releaseDate:this.parseOmdbDate((i=a.Released)!=null?i:a.Year),url:`https://www.imdb.com/title/${a.imdbID}`,seasons:n}}catch(a){return null}}async getOmdbSeasonEpisodeCount(e,t){var i;let s=`${gt}/?i=${encodeURIComponent(e)}&Season=${t}&apikey=${encodeURIComponent(this.omdbApiKey)}`;try{let a=await this.fetchWithTimeout(s);return a.Response==="False"?null:((i=a.Episodes)!=null?i:[]).length}catch(a){return null}}async getAnimeScheduleByMalId(e){var t;try{let s=`${Ie}/anime/${e}`,a=(t=(await this.fetchWithTimeout(s)).data)==null?void 0:t.broadcast;if(!(a!=null&&a.day)||!a.time)return null;let n={Mondays:1,Tuesdays:2,Wednesdays:3,Thursdays:4,Fridays:5,Saturdays:6,Sundays:0}[a.day];return n===void 0?null:{dayOfWeek:n,time:a.time}}catch(s){return null}}parseOmdbDate(e){let t={Jan:"01",Feb:"02",Mar:"03",Apr:"04",May:"05",Jun:"06",Jul:"07",Aug:"08",Sep:"09",Oct:"10",Nov:"11",Dec:"12"},s=e.match(/^(\d{2})\s+([A-Za-z]{3})\s+(\d{4})$/);if(s&&s[1]&&s[2]&&s[3]){let i=t[s[2]];if(i)return`${s[3]}-${i}-${s[1]}`}return e}async getOmdbByImdbId(e){if(!this.omdbApiKey)return null;let t=`${gt}/?i=${encodeURIComponent(e)}&apikey=${encodeURIComponent(this.omdbApiKey)}`;try{let s=await this.fetchWithTimeout(t);return s.Response==="False"||!s.imdbID?null:s.Type==="movie"?this.getOmdbMovieDetails(e):this.getOmdbTvDetails(e)}catch(s){return null}}async searchTmdb(e,t){var a;if(!this.tmdbApiKey)return[];let i=`${ct}/search/${t==="movie"?"movie":"tv"}?query=${encodeURIComponent(e)}&page=1`;try{return((a=(await this.fetchWithTimeout(i,this.tmdbHeaders())).results)!=null?a:[]).slice(0,10).map(n=>{var r,o,c,d;return{imdbId:String(n.id),title:(o=(r=n.title)!=null?r:n.name)!=null?o:"",mediaType:t==="movie"?"movie":"tv",episodes:0,episodeDuration:0,releaseDate:(d=(c=n.release_date)!=null?c:n.first_air_date)!=null?d:"",url:"",seasons:[]}})}catch(l){return[]}}async getTmdbMovieDetails(e){var t,s,i,a;if(!this.tmdbApiKey)return null;try{let[l,n]=await Promise.all([this.fetchWithTimeout(`${ct}/movie/${e}`,this.tmdbHeaders()),this.fetchWithTimeout(`${ct}/movie/${e}/external_ids`,this.tmdbHeaders())]),r=l,c=(s=(t=n.imdb_id)!=null?t:r.imdb_id)!=null?s:"";return{imdbId:String(r.id),title:r.title,mediaType:"movie",episodes:1,episodeDuration:(i=r.runtime)!=null?i:120,releaseDate:(a=r.release_date)!=null?a:"",url:c?`https://www.imdb.com/title/${c}`:"",seasons:[{name:"Movie",episodes:1,offset:0}]}}catch(l){return null}}async getTmdbTvDetails(e){var t,s,i,a,l,n;if(!this.tmdbApiKey)return null;try{let[r,o]=await Promise.all([this.fetchWithTimeout(`${ct}/tv/${e}`,this.tmdbHeaders()),this.fetchWithTimeout(`${ct}/tv/${e}/external_ids`,this.tmdbHeaders())]),c=r,u=(t=o.imdb_id)!=null?t:"",g=((s=c.seasons)!=null?s:[]).filter(v=>v.season_number!==0),p=0,m=g.map(v=>{let w={name:v.name,episodes:v.episode_count,offset:p};return p+=v.episode_count,w}),f=m.reduce((v,w)=>v+w.episodes,0)||((i=c.number_of_episodes)!=null?i:0);return{imdbId:String(c.id),title:c.name,mediaType:"tv",episodes:f,episodeDuration:(l=((a=c.episode_run_time)!=null?a:[])[0])!=null?l:45,releaseDate:(n=c.first_air_date)!=null?n:"",url:u?`https://www.imdb.com/title/${u}`:"",seasons:m}}catch(r){return null}}async getTmdbByImdbId(e){if(!this.tmdbApiKey)return null;let t=`${ct}/find/${encodeURIComponent(e)}?external_source=imdb_id`;try{let s=await this.fetchWithTimeout(t,this.tmdbHeaders());return s.movie_results&&s.movie_results.length>0?this.getTmdbMovieDetails(String(s.movie_results[0].id)):s.tv_results&&s.tv_results.length>0?this.getTmdbTvDetails(String(s.tv_results[0].id)):null}catch(s){return null}}async checkTmdbConnection(){if(!this.tmdbApiKey)return!1;try{let e=`${ct}/movie/550`;return(await this.fetchWithTimeout(e,this.tmdbHeaders())).id!==void 0}catch(e){return!1}}async checkOmdbConnection(){if(!this.omdbApiKey)return!1;try{let e=`${gt}/?i=tt0111161&apikey=${encodeURIComponent(this.omdbApiKey)}`;return(await this.fetchWithTimeout(e)).Response==="True"}catch(e){return!1}}};var Xe=require("obsidian");var se=2*Math.PI*45,It=class{constructor(e,t,s){this.container=e,this.plugin=t,this.dataManager=s}render(){this.container.empty(),this.container.addClass("wl-dashboard"),this.renderCards(),this.renderSummaryMetrics(),this.renderSuggestions(),this.renderRecentlyWatched(),this.renderRecentlyAdded()}renderCards(){let e=this.plugin.settings.dashboardCardStyle==="rectangles",t=this.container.createDiv({cls:"wl-rings-grid"}),s=this.dataManager.getStatsByType("All"),i=s.total===0?0:Math.round(s.watched/s.total*100);e?this.renderRect(t,"Total","#7F77DD",i,s):this.renderRing(t,"Total","#7F77DD",i,s);for(let n of this.plugin.settings.types){let r=this.dataManager.getStatsByType(n.name),o=r.total===0?0:Math.round(r.watched/r.total*100);e?this.renderRect(t,n.name,n.color,o,r):this.renderRing(t,n.name,n.color,o,r)}let a=this.dataManager.getTotalTimeWatched();e?this.renderTimeRect(t,"Time Watched","#1D9E75",V(a)):this.renderTimeRing(t,"Time Watched","#1D9E75",V(a));let l=this.dataManager.getTotalTimeRemaining();e?this.renderTimeRect(t,"Time Remaining","#BA7517",V(l)):this.renderTimeRing(t,"Time Remaining","#BA7517",V(l))}renderRing(e,t,s,i,a){let l=e.createDiv({cls:"wl-ring-item"}),n="http://www.w3.org/2000/svg",r=activeDocument.createElementNS(n,"svg");r.setAttribute("viewBox","0 0 120 120"),r.setAttribute("width","110"),r.setAttribute("height","110"),r.addClass("wl-ring-svg");let o=activeDocument.createElementNS(n,"circle");o.setAttribute("cx","60"),o.setAttribute("cy","60"),o.setAttribute("r","45"),o.setAttribute("fill","none"),o.setAttribute("stroke-width","10"),o.addClass("wl-ring-track"),r.appendChild(o);let c=activeDocument.createElementNS(n,"circle");c.setAttribute("cx","60"),c.setAttribute("cy","60"),c.setAttribute("r","45"),c.setAttribute("fill","none"),c.setAttribute("stroke",s),c.setAttribute("stroke-width","10"),c.setAttribute("stroke-linecap","round"),c.setAttribute("stroke-dasharray",String(se));let d=se*(1-i/100);c.setAttribute("stroke-dashoffset",String(d)),c.setAttribute("transform","rotate(-90 60 60)"),c.addClass("wl-ring-arc"),r.appendChild(c);let u=activeDocument.createElementNS(n,"text");u.setAttribute("x","60"),u.setAttribute("y","55"),u.setAttribute("text-anchor","middle"),u.setAttribute("dominant-baseline","middle"),u.addClass("wl-ring-percent-text"),u.textContent=`${i}%`,r.appendChild(u);let g=activeDocument.createElementNS(n,"text");g.setAttribute("x","60"),g.setAttribute("y","72"),g.setAttribute("text-anchor","middle"),g.addClass("wl-ring-sub-text"),g.textContent=`${a.watched}/${a.total}`,r.appendChild(g),l.appendChild(r),l.createDiv({cls:"wl-ring-label",text:t});let p=a.total-a.watched;l.createDiv({cls:"wl-ring-subtitle",text:`${p} unwatched`})}renderTimeRing(e,t,s,i){let a=e.createDiv({cls:"wl-ring-item"}),l="http://www.w3.org/2000/svg",n=activeDocument.createElementNS(l,"svg");n.setAttribute("viewBox","0 0 120 120"),n.setAttribute("width","110"),n.setAttribute("height","110"),n.addClass("wl-ring-svg");let r=activeDocument.createElementNS(l,"circle");r.setAttribute("cx","60"),r.setAttribute("cy","60"),r.setAttribute("r","45"),r.setAttribute("fill","none"),r.setAttribute("stroke-width","10"),r.addClass("wl-ring-track"),n.appendChild(r);let o=activeDocument.createElementNS(l,"circle");o.setAttribute("cx","60"),o.setAttribute("cy","60"),o.setAttribute("r","45"),o.setAttribute("fill","none"),o.setAttribute("stroke",s),o.setAttribute("stroke-width","10"),o.setAttribute("stroke-linecap","round"),o.setAttribute("stroke-dasharray",String(se)),o.setAttribute("stroke-dashoffset","0"),o.setAttribute("transform","rotate(-90 60 60)"),o.addClass("wl-ring-arc"),n.appendChild(o);let c=activeDocument.createElementNS(l,"text");c.setAttribute("x","60"),c.setAttribute("y","60"),c.setAttribute("text-anchor","middle"),c.setAttribute("dominant-baseline","middle"),c.addClass("wl-ring-time-text"),c.textContent=i,n.appendChild(c),a.appendChild(n),a.createDiv({cls:"wl-ring-label",text:t}),a.createDiv({cls:"wl-ring-subtitle",text:"total"})}renderRect(e,t,s,i,a){let l=e.createDiv({cls:"wl-rect-item"}),n=l.createDiv({cls:"wl-rect-top"});n.createSpan({cls:"wl-rect-label",text:t});let r=a.total-a.watched;n.createSpan({cls:"wl-rect-unwatched",text:`${r} left`}),l.createDiv({cls:"wl-rect-value",text:`${i}%`});let c=l.createDiv({cls:"wl-rect-bar-wrap"}).createDiv({cls:"wl-rect-bar"});c.style.width=`${i}%`,c.style.backgroundColor=s}renderTimeRect(e,t,s,i){let a=e.createDiv({cls:"wl-rect-item"}),l=a.createDiv({cls:"wl-rect-top"});l.createSpan({cls:"wl-rect-label",text:t}),l.createSpan({cls:"wl-rect-unwatched",text:"total"}),a.createDiv({cls:"wl-rect-value",text:i});let r=a.createDiv({cls:"wl-rect-bar-wrap"}).createDiv({cls:"wl-rect-bar wl-rect-bar-full"});r.style.backgroundColor=s}renderSummaryMetrics(){let e=this.container.createDiv({cls:"wl-summary-metrics"}),t=this.dataManager.getTitles(),s=this.dataManager.getCompletedCount();this.renderMetricRow(e,"Titles in library",String(t.length)),this.renderMetricRow(e,"Completed",String(s))}renderMetricRow(e,t,s){let i=e.createDiv({cls:"wl-metric-row"});i.createSpan({cls:"wl-metric-label",text:t}),i.createSpan({cls:"wl-metric-value",text:s})}renderSuggestions(){let e=this.dataManager.getTitles().filter(d=>d.status==="Plan to watch");if(e.length===0)return;let t=this.container.createDiv({cls:"wl-suggestions"});t.createDiv({cls:"wl-section-title",text:"Don't know what to watch next?"});let s=t.createDiv({cls:"wl-suggestions-grid"}),i=d=>{var g,p,m,f;let u=e.filter(v=>v.type===d).filter(v=>v.totalEpisodes>0||v.episodeDuration>0);return u.length===0?(p=(g=e.filter(w=>w.type===d)[0])==null?void 0:g.title)!=null?p:null:(u.sort((v,w)=>{let y=v.totalEpisodes>0?v.totalEpisodes*(v.episodeDuration||24):v.episodeDuration,T=w.totalEpisodes>0?w.totalEpisodes*(w.episodeDuration||24):w.episodeDuration;return y-T}),(f=(m=u[0])==null?void 0:m.title)!=null?f:null)},a=["Anime","Movie","TV Show"];for(let d of a){let u=i(d),g=s.createDiv({cls:"wl-suggestion-card"});g.createDiv({cls:"wl-suggestion-label",text:`Shortest ${d}`}),g.createDiv({cls:`wl-suggestion-title${u?"":" wl-suggestion-empty"}`,text:u!=null?u:"Nothing planned"})}let l=s.createDiv({cls:"wl-suggestion-card"}),n=l.createDiv({cls:"wl-suggestion-random-header"});n.createDiv({cls:"wl-suggestion-label",text:"Random"});let r=n.createEl("button",{cls:"wl-suggestion-shuffle",text:"\u{1F500}"});r.title="Pick another";let o=()=>{var d,u;return(u=(d=e[Math.floor(Math.random()*e.length)])==null?void 0:d.title)!=null?u:""},c=l.createDiv({cls:"wl-suggestion-title",text:o()});r.addEventListener("click",()=>{c.textContent=o()})}renderRecentlyWatched(){let e=this.container.createDiv({cls:"wl-recently-watched"});e.createDiv({cls:"wl-section-title",text:"Recently watched"});let t=this.dataManager.getRecentlyWatched(3);if(t.length===0){e.createDiv({cls:"wl-empty-state",text:"No titles watched yet."});return}for(let s of t){let i=e.createDiv({cls:"wl-rw-item"});i.createDiv({cls:"wl-rw-title",text:s.title});let a=this.getTagDef(s.type,this.plugin.settings.types),l=this.plugin.settings.coloredTypeBadges,r=i.createDiv({cls:"wl-rw-col-badge"}).createSpan({cls:l?"wl-badge wl-badge-sm":"wl-badge-plain",text:s.type});l&&a&&(r.style.backgroundColor=_(s.type,a.color,this.plugin.settings.colorTheme));let o=i.createDiv({cls:"wl-rw-col-ep"}),c=s.type==="Movie";if(!c){let u=this.dataManager.getNextUnwatchedEpisode(s);o.textContent=u!==null?`Ep ${u}`:"\u2713"}let d=i.createDiv({cls:"wl-rw-col-pct"});c?d.textContent=s.watchedEpisodes.includes(1)?"100%":"0%":d.textContent=`${this.dataManager.getProgress(s)}%`}}renderRecentlyAdded(){let e=this.container.createDiv({cls:"wl-recently-watched"});e.createDiv({cls:"wl-section-title",text:"Recently added"});let t=this.dataManager.getRecentlyAdded(3);if(t.length===0){e.createDiv({cls:"wl-empty-state",text:"No titles in your library yet."});return}for(let s of t){let i=e.createDiv({cls:"wl-rw-item"});i.createDiv({cls:"wl-rw-title",text:s.title});let a=this.getTagDef(s.type,this.plugin.settings.types),l=this.plugin.settings.coloredTypeBadges,r=i.createDiv({cls:"wl-rw-col-badge"}).createSpan({cls:l?"wl-badge wl-badge-sm":"wl-badge-plain",text:s.type});l&&a&&(r.style.backgroundColor=_(s.type,a.color,this.plugin.settings.colorTheme));let o=i.createDiv({cls:"wl-rw-col-ep"}),c=s.type==="Movie";if(!c){let u=this.dataManager.getNextUnwatchedEpisode(s);o.textContent=u!==null?`Ep ${u}`:"\u2713"}let d=i.createDiv({cls:"wl-rw-col-pct"});c?d.textContent=s.watchedEpisodes.includes(1)?"100%":"0%":d.textContent=`${this.dataManager.getProgress(s)}%`}}getTagDef(e,t){return t.find(s=>s.name===e)}};var mt=require("obsidian");function Tt(h){return"malId"in h}var tt=class extends mt.Modal{constructor(t,s,i,a,l){super(t);this.selectedType="Anime";this.searchQuery="";this.searchResults=[];this.isSearching=!1;this.autoSearch=!1;this.fieldTitle="";this.fieldEpisodes=0;this.fieldDuration=0;this.fieldReleaseDate="";this.fieldLink="";this.fieldSeasons=[];this.fieldStatus="Plan to watch";this.fieldPriority="Medium";this.fieldDateStarted="";this.fieldMalId=null;this.skipDuplicateCheck=!1;this.duplicateWarningEl=null;this.selectedGroupId="";this.newGroupName="";this.resultsEl=null;this.formEl=null;this.searchDebounce=null;this.plugin=s,this.dataManager=i,this.onAdded=a,l&&(this.selectedType=l.type,l.title&&(this.fieldTitle=l.title),l.searchQuery&&(this.searchQuery=l.searchQuery,this.autoSearch=!0),this.fieldEpisodes=l.episodes,this.fieldDuration=l.duration,this.fieldReleaseDate=l.releaseDate,this.fieldLink=l.link,this.fieldSeasons=l.seasons)}onOpen(){this.titleEl.setText("Add title"),this.contentEl.addClass("wl-add-modal"),this.buildUI(),this.autoSearch&&this.performSearch()}onClose(){this.contentEl.empty(),this.searchDebounce&&window.clearTimeout(this.searchDebounce)}buildUI(){let t=this.contentEl;t.empty();let s=t.createDiv({cls:"wl-modal-row"});s.createSpan({cls:"wl-modal-label",text:"Type"});let i=s.createEl("select",{cls:"wl-select"});for(let r of this.plugin.settings.types){let o=i.createEl("option",{text:r.name,value:r.name});r.name===this.selectedType&&(o.selected=!0)}i.addEventListener("change",()=>{this.selectedType=i.value,this.searchResults=[],this.renderResults(),this.renderForm()});let a=t.createDiv({cls:"wl-modal-row"});a.createSpan({cls:"wl-modal-label",text:"Search"});let l=a.createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Search for a title..."}});l.value=this.searchQuery,l.addEventListener("input",()=>{this.searchQuery=l.value,this.searchDebounce&&window.clearTimeout(this.searchDebounce),this.searchDebounce=window.setTimeout(()=>void this.performSearch(),600)}),a.createEl("button",{cls:"wl-btn",text:"Search"}).addEventListener("click",()=>void this.performSearch()),this.resultsEl=t.createDiv({cls:"wl-modal-results"}),this.formEl=t.createDiv({cls:"wl-modal-form"}),this.renderForm()}async performSearch(){var t;if(this.searchQuery.trim()){this.isSearching=!0,this.renderResults();try{let s=this.plugin.apiService,i=(t=this.plugin.settings.activeApi)!=null?t:"OMDb",a=this.selectedType==="Anime",l=this.selectedType==="Movie";a?this.searchResults=await s.searchAnime(this.searchQuery):i==="TMDB"?this.searchResults=await s.searchTmdb(this.searchQuery,l?"movie":"series"):this.searchResults=await s.searchOmdb(this.searchQuery,l?"movie":"series")}catch(s){new mt.Notice("Search failed. Check your connection and API settings."),this.searchResults=[]}finally{this.isSearching=!1,this.renderResults()}}}renderResults(){if(this.resultsEl){if(this.resultsEl.empty(),this.isSearching){this.resultsEl.createDiv({cls:"wl-modal-loading",text:"Searching..."});return}this.searchResults.length!==0&&this.searchResults.forEach((t,s)=>{let i=this.resultsEl.createDiv({cls:"wl-result-item"}),a=Tt(t)?`${t.episodes} eps`:t.episodes>0?`${t.episodes} eps`:t.mediaType;i.createDiv({cls:"wl-result-title",text:t.title}),i.createDiv({cls:"wl-result-meta",text:`${a} \xB7 ${t.releaseDate}`}),i.dataset.idx=String(s),i.addEventListener("click",()=>void this.selectResult(t,i))})}}async selectResult(t,s){var l;if(this.resultsEl)for(let n of Array.from(this.resultsEl.children))n.removeClass("is-selected");s.addClass("is-selected");let i=this.plugin.apiService,a=(l=this.plugin.settings.activeApi)!=null?l:"OMDb";try{if(!Tt(t)&&t.mediaType==="tv"){let n=a==="TMDB"?await i.getTmdbTvDetails(t.imdbId):await i.getOmdbTvDetails(t.imdbId);n&&(this.fieldTitle=n.title,this.fieldEpisodes=n.episodes,this.fieldDuration=n.episodeDuration,this.fieldReleaseDate=n.releaseDate,this.fieldLink=n.url,this.fieldSeasons=n.seasons)}else if(!Tt(t)&&t.mediaType==="movie"){let n=a==="TMDB"?await i.getTmdbMovieDetails(t.imdbId):await i.getOmdbMovieDetails(t.imdbId);n&&(this.fieldTitle=n.title,this.fieldEpisodes=n.episodes,this.fieldDuration=n.episodeDuration,this.fieldReleaseDate=n.releaseDate,this.fieldLink=n.url,this.fieldSeasons=n.seasons)}else{let n=t;this.fieldTitle=n.title,this.fieldEpisodes=n.episodes,this.fieldDuration=n.duration,this.fieldReleaseDate=n.releaseDate,this.fieldLink=n.url,this.fieldSeasons=n.seasons,this.fieldMalId=n.malId}}catch(n){this.fieldTitle=t.title,this.fieldEpisodes=(Tt(t),t.episodes),this.fieldDuration=Tt(t)?t.duration:t.episodeDuration,this.fieldReleaseDate=t.releaseDate,this.fieldLink=t.url,this.fieldSeasons=t.seasons}this.renderForm()}renderForm(){if(!this.formEl)return;this.formEl.empty();let t=C=>{let L=this.formEl.createDiv({cls:"wl-modal-row"});return L.createSpan({cls:"wl-modal-label",text:C}),L},i=t("Title").createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Title name"}});i.value=this.fieldTitle,i.addEventListener("input",()=>{this.fieldTitle=i.value});let l=t("Episodes").createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"0",placeholder:"0"}});l.value=String(this.fieldEpisodes),l.addEventListener("input",()=>{this.fieldEpisodes=parseInt(l.value)||0});let r=t("Ep. duration (min)").createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"0",placeholder:"24"}});r.value=String(this.fieldDuration),r.addEventListener("input",()=>{this.fieldDuration=parseInt(r.value)||0});let c=t("Release date").createDiv({cls:"wl-modal-input-stack"}),d=c.createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Date (dd-mm-yyyy or yyyy-mm-dd)"}});d.value=this.fieldReleaseDate;let u=c.createDiv({cls:"wl-modal-error wl-hidden"});d.addEventListener("change",()=>{let C=d.value.trim();if(!C){this.fieldReleaseDate="",u.addClass("wl-hidden");return}let L=pt(C);L?(this.fieldReleaseDate=L,d.value=L,u.addClass("wl-hidden")):(this.fieldReleaseDate=C,u.textContent="Unrecognised format. Expected dd/mm/yyyy or yyyy-mm-dd.",u.removeClass("wl-hidden"))});let p=t("External link").createEl("input",{cls:"wl-modal-input",attr:{type:"url",placeholder:"https://example.com"}});p.value=this.fieldLink,p.addEventListener("input",()=>{this.fieldLink=p.value});let f=t("Status").createEl("select",{cls:"wl-select"});for(let C of this.plugin.settings.statuses.filter(L=>L.name!=="To be released")){let L=f.createEl("option",{text:C.name,value:C.name});C.name===this.fieldStatus&&(L.selected=!0)}f.addEventListener("change",()=>{this.fieldStatus=f.value});let w=t("Priority").createEl("select",{cls:"wl-select"});for(let C of this.plugin.settings.priorities){let L=w.createEl("option",{text:C.name,value:C.name});C.name===this.fieldPriority&&(L.selected=!0)}w.addEventListener("change",()=>{this.fieldPriority=w.value});let T=t("Date started").createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"15/01/2024",maxlength:"10"}});T.value=this.fieldDateStarted?Z(this.fieldDateStarted):"",T.addEventListener("change",()=>{let C=lt(T.value);T.value.trim()&&!C?T.addClass("wl-input-error"):(T.removeClass("wl-input-error"),this.fieldDateStarted=C!=null?C:"")});let D=this.dataManager.getGroups(),E=t("Add to group").createEl("select",{cls:"wl-select"});E.createEl("option",{text:"\u2014 none \u2014",value:""});for(let C of D){let L=E.createEl("option",{text:C.name,value:C.id});C.id===this.selectedGroupId&&(L.selected=!0)}let x=t("Or create new group").createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"New group name..."}});x.value=this.newGroupName,E.addEventListener("change",()=>{this.selectedGroupId=E.value,this.selectedGroupId?(this.newGroupName="",x.value="",x.disabled=!0):x.disabled=!1}),x.addEventListener("input",()=>{this.newGroupName=x.value,this.newGroupName.trim()?(this.selectedGroupId="",E.value="",E.disabled=!0):E.disabled=!1}),this.selectedGroupId&&(x.disabled=!0),this.newGroupName.trim()&&(E.disabled=!0),this.fieldSeasons.length>0&&t("Seasons").createSpan({cls:"wl-modal-info",text:this.fieldSeasons.map(L=>`${L.name} (${L.episodes} eps)`).join(", ")}),this.formEl.createDiv({cls:"wl-modal-btn-row"}).createEl("button",{cls:"wl-btn wl-btn-primary",text:"Add to watchlog"}).addEventListener("click",()=>void this.addTitle())}showDuplicateWarning(t,s){if(this.duplicateWarningEl)return;let i=s.createDiv({cls:"wl-duplicate-warning"});this.duplicateWarningEl=i,i.createSpan({text:`"${t}" already exists. Add anyway?`});let a=i.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Continue"}),l=i.createEl("button",{cls:"wl-btn",text:"Cancel"});a.addEventListener("click",()=>{this.skipDuplicateCheck=!0,i.remove(),this.duplicateWarningEl=null,this.addTitle()}),l.addEventListener("click",()=>{i.remove(),this.duplicateWarningEl=null,this.skipDuplicateCheck=!1})}async addTitle(){var i;let t=this.fieldTitle.trim();if(!t){new mt.Notice("Please enter a title name.");return}if(!this.skipDuplicateCheck&&this.dataManager.getTitles().find(l=>l.title.toLowerCase()===t.toLowerCase())){let l=this.contentEl.querySelector(".wl-modal-btn-row");l&&this.showDuplicateWarning(t,l);return}this.skipDuplicateCheck=!1;let s={id:this.dataManager.generateId(t),title:t,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}:{}};if(s.status==="Completed"&&s.totalEpisodes>0&&(s.watchedEpisodes=Array.from({length:s.totalEpisodes},(a,l)=>l+1),this.plugin.settings.setFinishDateAutomatically&&(s.dateFinished=(i=new Date().toISOString().split("T")[0])!=null?i:null)),await this.dataManager.addTitle(s),this.selectedGroupId)await this.dataManager.addTitleToGroup(this.selectedGroupId,s.id);else if(this.newGroupName.trim()){let a=this.newGroupName.trim(),l={id:this.dataManager.generateGroupId(a),name:a,titleIds:[s.id],dateAdded:new Date().toISOString()};await this.dataManager.addGroup(l)}new mt.Notice(`"${t}" added to WatchLog.`),this.close(),this.onAdded()}};var $e=require("obsidian");var Pt=class extends $e.Modal{constructor(t,s,i,a){super(t);this.urlInput=null;this.errorEl=null;this.addBtn=null;this.plugin=s,this.dataManager=i,this.onAdded=a}onOpen(){this.titleEl.setText("Add from URL");let t=this.contentEl;t.addClass("wl-add-modal"),t.createDiv({cls:"wl-modal-info",text:"Please enter an IMDb URL (e.g. https://www.imdb.com/title/tt1375666/)"});let s=t.createDiv({cls:"wl-modal-row"});this.urlInput=s.createEl("input",{cls:"wl-modal-input",attr:{type:"url",placeholder:"https://www.imdb.com/title/ttXXXXXXX/"}}),this.urlInput.addEventListener("keydown",a=>{a.key==="Enter"&&this.handleAdd()}),this.errorEl=t.createDiv({cls:"wl-modal-error"}),this.errorEl.hide();let i=t.createDiv({cls:"wl-modal-btn-row"});this.addBtn=i.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Add"}),this.addBtn.addEventListener("click",()=>void this.handleAdd()),window.setTimeout(()=>{var a;return(a=this.urlInput)==null?void 0:a.focus()},50)}onClose(){this.contentEl.empty()}async handleAdd(){var n,r;let s=((r=(n=this.urlInput)==null?void 0:n.value.trim())!=null?r:"").match(/tt\d+/);if(!s){this.showError("Title not found. Please check the URL and try again.");return}let i=s[0];this.addBtn&&(this.addBtn.disabled=!0,this.addBtn.textContent="Loading\u2026"),this.errorEl&&this.errorEl.hide();let a=this.plugin.settings.activeApi==="TMDB"?await this.plugin.apiService.getTmdbByImdbId(i):await this.plugin.apiService.getOmdbByImdbId(i);if(!a){this.addBtn&&(this.addBtn.disabled=!1,this.addBtn.textContent="Add"),this.showError("Title not found. Please check the URL and try again.");return}this.close();let l=a.mediaType==="movie"?"Movie":"TV Show";new tt(this.app,this.plugin,this.dataManager,this.onAdded,{title:a.title,type:l,episodes:a.episodes,duration:a.episodeDuration,releaseDate:a.releaseDate,link:a.url,seasons:a.seasons}).open()}showError(t){this.errorEl&&(this.errorEl.textContent=t,this.errorEl.show())}};var St=require("obsidian");function Be(h){let e=h.split(` -`).map(i=>i.trim()).filter(Boolean),t=[],s=0;for(let i of e){let a=i.match(/^(.+?):\s*(\d+)/);if(a&&a[1]&&a[2]){let l=parseInt(a[2]);t.push({name:a[1].trim(),episodes:l,offset:s}),s+=l}}return t}function hs(h){return h.map(e=>`${e.name}: ${e.episodes}`).join(` -`)}var $t=class extends St.Modal{constructor(t,s,i,a,l){var n,r,o,c;super(t);this.skipDuplicateCheck=!1;this.duplicateWarningEl=null;this.plugin=s,this.dataManager=i,this.original=a,this.onSaved=l,this.fieldTitle=a.title,this.fieldType=a.type,this.fieldEpisodes=a.totalEpisodes,this.fieldDuration=a.episodeDuration,this.fieldReleaseDate=(n=a.releaseDate)!=null?n:"",this.fieldLink=a.externalLink,this.fieldSeasonsText=hs(a.seasons),this.fieldStatus=a.status,this.fieldPriority=a.priority,this.fieldReview=(r=a.review)!=null?r:"",this.fieldRating=a.rating,this.fieldNotes=a.notes,this.fieldDateStarted=(o=a.dateStarted)!=null?o:"",this.fieldDateFinished=(c=a.dateFinished)!=null?c:""}onOpen(){this.titleEl.setText(`Edit: ${this.original.title}`),this.contentEl.addClass("wl-add-modal"),this.buildUI()}onClose(){this.contentEl.empty()}buildUI(){let t=this.contentEl;t.empty();let s=B=>{let z=t.createDiv({cls:"wl-modal-row"});return z.createSpan({cls:"wl-modal-label",text:B}),z},a=s("Title").createEl("input",{cls:"wl-modal-input",attr:{type:"text"}});a.value=this.fieldTitle,a.addEventListener("input",()=>{this.fieldTitle=a.value});let n=s("Type").createEl("select",{cls:"wl-select"});for(let B of this.plugin.settings.types){let z=n.createEl("option",{text:B.name,value:B.name});B.name===this.fieldType&&(z.selected=!0)}n.addEventListener("change",()=>{this.fieldType=n.value});let o=s("Status").createEl("select",{cls:"wl-select"}),c=this.fieldStatus==="To be released";for(let B of this.plugin.settings.statuses){if(B.name==="To be released"&&!c)continue;let z=o.createEl("option",{text:B.name,value:B.name});B.name===this.fieldStatus&&(z.selected=!0)}o.addEventListener("change",()=>{this.fieldStatus=o.value});let u=s("Priority").createEl("select",{cls:"wl-select"});for(let B of this.plugin.settings.priorities){let z=u.createEl("option",{text:B.name,value:B.name});B.name===this.fieldPriority&&(z.selected=!0)}u.addEventListener("change",()=>{this.fieldPriority=u.value});let p=s("Rating").createDiv({cls:"wl-stars"}),m=()=>{p.empty();for(let B=1;B<=5;B++)p.createSpan({cls:`wl-star${this.fieldRating>=B?" is-active":""}`,text:"\u2605"}).addEventListener("click",()=>{this.fieldRating=this.fieldRating===B?0:B,m()})};m();let v=s("Total episodes").createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"0"}});v.value=String(this.fieldEpisodes),v.addEventListener("input",()=>{this.fieldEpisodes=parseInt(v.value)||0});let y=s("Ep. duration (min)").createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"0"}});y.value=String(this.fieldDuration),y.addEventListener("input",()=>{this.fieldDuration=parseInt(y.value)||0});let D=s("Release date").createDiv({cls:"wl-modal-input-stack"}),S=D.createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Date (dd-mm-yyyy or yyyy-mm-dd)"}});S.value=this.fieldReleaseDate;let E=D.createDiv({cls:"wl-modal-error wl-hidden"});S.addEventListener("change",()=>{let B=S.value.trim();if(!B){this.fieldReleaseDate="",E.addClass("wl-hidden");return}let z=pt(B);z?(this.fieldReleaseDate=z,S.value=z,E.addClass("wl-hidden")):(this.fieldReleaseDate=B,E.textContent="Unrecognised format. Expected dd/mm/yyyy or yyyy-mm-dd.",E.removeClass("wl-hidden"))});let x=s("External link").createEl("input",{cls:"wl-modal-input",attr:{type:"url",placeholder:"HTTPS://..."}});x.value=this.fieldLink,x.addEventListener("input",()=>{this.fieldLink=x.value});let M=s("Date started").createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Dd/mm/yyyy",maxlength:"10"}});M.value=Z(this.fieldDateStarted),M.addEventListener("change",()=>{let B=lt(M.value);M.value.trim()&&!B?M.addClass("wl-input-error"):(M.removeClass("wl-input-error"),this.fieldDateStarted=B!=null?B:"")});let L=s("Date finished").createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Dd/mm/yyyy",maxlength:"10"}});L.value=Z(this.fieldDateFinished),L.addEventListener("change",()=>{let B=lt(L.value);L.value.trim()&&!B?L.addClass("wl-input-error"):(L.removeClass("wl-input-error"),this.fieldDateFinished=B!=null?B:"")});let W=s("Notes").createEl("textarea",{cls:"wl-modal-textarea",attr:{rows:"3",placeholder:"Your notes..."}});W.value=this.fieldNotes,W.addEventListener("input",()=>{this.fieldNotes=W.value});let A=s("Seasons").createDiv({cls:"wl-modal-input-stack"}),I=A.createEl("textarea",{cls:"wl-modal-textarea",attr:{rows:"4",placeholder:`Season 1: 12 -season 2: 13`}});I.value=this.fieldSeasonsText;let U=A.createDiv({cls:"wl-modal-season-total"}),H=()=>{let z=Be(I.value).reduce((ss,is)=>ss+is.episodes,0);U.textContent=`Total episodes: ${z}`};H(),I.addEventListener("input",()=>{this.fieldSeasonsText=I.value,H()}),A.createDiv({cls:"wl-modal-info",text:'One per line: "Season Name: N" (e.g. "Season 1: 12")'}),A.createDiv({cls:"wl-modal-info",text:'Note: when adding a new season, remember to update "Total Episodes" so progress calculates correctly.'});let O=t.createDiv({cls:"wl-modal-btn-row"});O.createEl("button",{cls:"wl-btn",text:"Cancel"}).addEventListener("click",()=>this.close()),O.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Save changes"}).addEventListener("click",()=>void this.saveTitle())}showDuplicateWarning(t,s){if(this.duplicateWarningEl)return;let i=s.createDiv({cls:"wl-duplicate-warning"});this.duplicateWarningEl=i,i.createSpan({text:`"${t}" already exists. Save anyway?`});let a=i.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Continue"}),l=i.createEl("button",{cls:"wl-btn",text:"Cancel"});a.addEventListener("click",()=>{this.skipDuplicateCheck=!0,i.remove(),this.duplicateWarningEl=null,this.saveTitle()}),l.addEventListener("click",()=>{i.remove(),this.duplicateWarningEl=null,this.skipDuplicateCheck=!1})}async saveTitle(){let t=this.fieldTitle.trim();if(!t){new St.Notice("Title name cannot be empty.");return}if(!this.skipDuplicateCheck&&this.dataManager.getTitles().find(d=>d.id!==this.original.id&&d.title.toLowerCase()===t.toLowerCase())){let d=this.contentEl.querySelector(".wl-modal-btn-row");d&&this.showDuplicateWarning(t,d);return}this.skipDuplicateCheck=!1;let s=Be(this.fieldSeasonsText),i=this.fieldReleaseDate||null,a={...this.original,title:t,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:i,externalLink:this.fieldLink,seasons:s,dateStarted:this.fieldDateStarted||null,dateFinished:this.fieldDateFinished||null};a.status==="Completed"&&a.totalEpisodes>0&&(a.watchedEpisodes=Array.from({length:a.totalEpisodes},(c,d)=>d+1)),await this.dataManager.updateTitle(a);let n=this.dataManager.getAirtimeEntries().find(c=>c.titleId===a.id);if(n&&n.schedule.recurrence==="once"){let c=i!=null?i:void 0;n.schedule.releaseDate!==c&&(n.schedule.releaseDate=c,await this.dataManager.updateAirtimeEntry(n))}let r=new Date;r.setHours(0,0,0,0),(c=>c!==null&&/^\d{4}-\d{2}-\d{2}$/.test(c))(i)?new Date(i+"T12:00:00").getTime()>r.getTime()?(a.status!=="To be released"&&(a.status="To be released",await this.dataManager.updateTitle(a)),this.dataManager.getAirtimeEntries().some(p=>p.titleId===a.id)||await this.dataManager.autoAddToUpcoming(a)):a.status==="To be released"&&(a.status="Plan to watch",await this.dataManager.updateTitle(a),await this.dataManager.removeAirtimeEntriesForTitle(a.id)):!i&&a.status==="To be released"&&(a.status="Plan to watch",await this.dataManager.updateTitle(a),await this.dataManager.removeAirtimeEntriesForTitle(a.id)),new St.Notice(`"${t}" updated.`),this.close(),this.onSaved()}};var We=require("obsidian"),K=class extends We.Modal{constructor(e,t,s){super(e),this.message=t,this.onConfirm=s}onOpen(){let{contentEl:e}=this;e.createDiv({cls:"wl-confirm-msg",text:this.message});let t=e.createDiv({cls:"wl-modal-btn-row"});t.createEl("button",{cls:"wl-btn wl-btn-danger",text:"Confirm"}).addEventListener("click",()=>{this.close(),this.onConfirm()}),t.createEl("button",{cls:"wl-btn",text:"Cancel"}).addEventListener("click",()=>this.close())}onClose(){this.contentEl.empty()}};var xt=["High","Medium","Low"],J=class J{constructor(e,t,s){this.filterTypeExclude=new Set;this.filterStatusExclude=new Set;this.filterPriorityExclude=new Set;this.filterRatingExclude=new Set;this.filterGroupExclude=new Set;this.filterSort="dateAdded";this.filterSortDir="desc";this.filterSecondSort="none";this.filterSecondSortDir="asc";this.searchQuery="";this.expandedId=null;this.collapsedSeasons=new Set;this.expandedGroups=new Set;this.renamingGroupId=null;this.selectionMode=!1;this.selectedItems=new Set;this.pinnedGroupId=null;this.savedFilterActive=!1;this.savedFilterBtnEl=null;this.filterRatingEmptyOnly=!1;this.filterPriorityEmptyOnly=!1;this.filterRecentlyArrivedOnly=!1;this.currentSubTab="list";this.scrollCleanup=null;this._lastScrollTop=0;this.STATUS_ORDER=["Watching","Plan to watch","Completed","To be released","Dropped"];var l,n,r,o,c,d,u,g,p,m,f,v,w,y,T;this.container=e,this.plugin=t,this.dataManager=s,this.container.addEventListener("scroll",()=>{this._lastScrollTop=this.container.scrollTop},{passive:!0}),this.pinnedGroupId=s.getPinnedGroupId();let i=t.settings.listFilters;if(i){this.filterTypeExclude=new Set((l=i.typeExclude)!=null?l:[]),this.filterStatusExclude=new Set((n=i.statusExclude)!=null?n:[]),this.filterGroupExclude=new Set((r=i.groupExclude)!=null?r:[]),this.filterRatingExclude=new Set((o=i.ratingExclude)!=null?o:[]),this.filterPriorityExclude=new Set((c=i.priorityExclude)!=null?c:[]);let D=J.migrateSortKey((d=i.sort)!=null?d:"dateAdded-newest");this.filterSort=D.key,this.filterSortDir=(u=i.sortDir)!=null?u:D.dir;let S=J.migrateSortKey((g=i.secondSort)!=null?g:"none");this.filterSecondSort=S.key,this.filterSecondSortDir=(p=i.secondSortDir)!=null?p:S.dir,this.filterRatingEmptyOnly=(m=i.ratingEmptyOnly)!=null?m:!1,this.filterPriorityEmptyOnly=(f=i.priorityEmptyOnly)!=null?f:!1,this.filterRecentlyArrivedOnly=(v=i.recentlyArrivedOnly)!=null?v:!1}let a=s.getSavedFilterPreset();if(a){let D=(S,E)=>S.size===E.length&&E.every(b=>S.has(b));this.savedFilterActive=D(this.filterTypeExclude,a.typeExclude)&&D(this.filterStatusExclude,a.statusExclude)&&D(this.filterGroupExclude,a.groupExclude)&&D(this.filterRatingExclude,a.ratingExclude)&&D(this.filterPriorityExclude,a.priorityExclude)&&this.filterRatingEmptyOnly===((w=a.ratingEmptyOnly)!=null?w:!1)&&this.filterPriorityEmptyOnly===((y=a.priorityEmptyOnly)!=null?y:!1)&&this.filterRecentlyArrivedOnly===((T=a.recentlyArrivedOnly)!=null?T:!1)}}saveFiltersToSettings(){this.plugin.settings.listFilters={typeExclude:Array.from(this.filterTypeExclude),statusExclude:Array.from(this.filterStatusExclude),groupExclude:Array.from(this.filterGroupExclude),ratingExclude:Array.from(this.filterRatingExclude),priorityExclude:Array.from(this.filterPriorityExclude),sort:this.filterSort,sortDir:this.filterSortDir,secondSort:this.filterSecondSort,secondSortDir:this.filterSecondSortDir,ratingEmptyOnly:this.filterRatingEmptyOnly,priorityEmptyOnly:this.filterPriorityEmptyOnly,recentlyArrivedOnly:this.filterRecentlyArrivedOnly},this.plugin.saveSettings()}hasActiveFilter(){return this.filterTypeExclude.size>0||this.filterStatusExclude.size>0||this.filterPriorityExclude.size>0||this.filterRatingExclude.size>0||this.filterGroupExclude.size>0||this.filterRatingEmptyOnly||this.filterPriorityEmptyOnly||this.filterRecentlyArrivedOnly}clearAllFilters(){this.filterTypeExclude.clear(),this.filterStatusExclude.clear(),this.filterPriorityExclude.clear(),this.filterRatingExclude.clear(),this.filterGroupExclude.clear(),this.filterRatingEmptyOnly=!1,this.filterPriorityEmptyOnly=!1,this.filterRecentlyArrivedOnly=!1}applyPreset(e){var t,s,i;this.filterTypeExclude=new Set(e.typeExclude),this.filterStatusExclude=new Set(e.statusExclude),this.filterGroupExclude=new Set(e.groupExclude),this.filterRatingExclude=new Set(e.ratingExclude),this.filterPriorityExclude=new Set(e.priorityExclude),this.filterRatingEmptyOnly=(t=e.ratingEmptyOnly)!=null?t:!1,this.filterPriorityEmptyOnly=(s=e.priorityEmptyOnly)!=null?s:!1,this.filterRecentlyArrivedOnly=(i=e.recentlyArrivedOnly)!=null?i:!1}deactivateSavedFilter(){var e;this.savedFilterActive&&(this.savedFilterActive=!1,(e=this.savedFilterBtnEl)==null||e.removeClass("wl-btn-preset-active"))}static isRecentlyArrived(e){if(!e.releaseDate||!/^\d{4}-\d{2}-\d{2}$/.test(e.releaseDate))return!1;let t=new Date(e.releaseDate+"T00:00:00").getTime(),s=new Date;s.setHours(0,0,0,0);let i=(s.getTime()-t)/864e5;return i>=0&&i<=7}titlePassesFilters(e){if(this.filterTypeExclude.size>0&&this.filterTypeExclude.has(e.type))return!1;if(this.filterRecentlyArrivedOnly){if(!J.isRecentlyArrived(e))return!1}else if(this.filterStatusExclude.size>0&&this.filterStatusExclude.has(e.status))return!1;if(this.filterRatingEmptyOnly){if(e.rating!==0)return!1}else if(this.filterRatingExclude.size>0&&this.filterRatingExclude.has(`${e.rating}\u2605`))return!1;if(this.filterPriorityEmptyOnly){if(e.priority)return!1}else if(this.filterPriorityExclude.size>0&&this.filterPriorityExclude.has(e.priority))return!1;return!0}render(){this._lastScrollTop=this.container.scrollTop||this._lastScrollTop,this.container.empty(),this.container.addClass("wl-list"),this.renderSubTabBar(),this.currentSubTab==="list"?this.renderListContent():this.renderHistoryContent()}renderSubTabBar(){let e=this.container.createDiv({cls:"wl-inner-tab-bar"}),t=e.createEl("button",{cls:`wl-inner-tab-btn${this.currentSubTab==="list"?" is-active":""}`,text:"List"}),s=e.createEl("button",{cls:`wl-inner-tab-btn${this.currentSubTab==="history"?" is-active":""}`,text:"History"});t.addEventListener("click",()=>{this.currentSubTab!=="list"&&(this.currentSubTab="list",this.render())}),s.addEventListener("click",()=>{this.currentSubTab!=="history"&&(this.currentSubTab="history",this.render())})}renderListContent(){this.renderHeader(),this.renderSearch(),this.container.createDiv({cls:"wl-divider"}),this.renderTable(this._lastScrollTop)}renderHistoryContent(){var s,i;let e=(i=(s=this.plugin.historyManager)==null?void 0:s.getEntries())!=null?i:[];if(e.length===0){this.container.createDiv({cls:"wl-history-empty",text:"No history yet. Actions in the Watchlist will appear here."});return}let t=this.container.createDiv({cls:"wl-history-list"});for(let a of e){let l=t.createDiv({cls:"wl-history-entry"});l.textContent=a.message}}renderHeader(){let e=this.container.createDiv({cls:"wl-list-header"});if(this.plugin.importProgress){let{current:r,total:o,cancel:c}=this.plugin.importProgress,d=e.createDiv({cls:"wl-header-import-progress"}),g=d.createDiv({cls:"wl-header-import-track"}).createDiv({cls:"wl-header-import-fill"});g.style.width=`${o>0?Math.round(r/o*100):0}%`,d.createSpan({cls:"wl-header-import-text",text:`${r} / ${o}`}),d.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Cancel import"}).addEventListener("click",()=>c())}let t=e.createDiv({cls:"wl-header-controls"}),s=this.dataManager.getSavedFilterPreset();if(s){let r=t.createEl("button",{cls:`wl-btn wl-btn-sm${this.savedFilterActive?" wl-btn-preset-active":""}`,text:"Saved filter"});this.savedFilterBtnEl=r,r.addEventListener("click",()=>{this.applyPreset(s),this.savedFilterActive=!0,r.addClass("wl-btn-preset-active"),this.saveFiltersToSettings(),this.rerenderTable()})}else this.savedFilterBtnEl=null;if(this.renderFiltersDropdown(t),this.renderSortingDropdown(t),this.selectionMode&&this.selectedItems.size>0&&this.renderActionBar(t),this.selectionMode){let r=this.selectedItems.size>0,o=t.createEl("button",{cls:"wl-btn wl-btn-sm",text:r?"None":"All"});o.title=r?"Deselect all":"Select all visible",o.addEventListener("click",()=>{if(this.selectedItems.size>0)this.selectedItems.clear();else for(let c of this.getDisplayItems())this.selectedItems.add(c.data.id);this.render()})}t.createEl("button",{cls:`wl-btn wl-btn-sm${this.selectionMode?" is-active":""}`,text:"Select"}).addEventListener("click",()=>{this.selectionMode=!this.selectionMode,this.selectedItems.clear(),this.render()}),t.createEl("button",{cls:"wl-btn wl-btn-sm",text:"+add from URL"}).addEventListener("click",()=>{new Pt(this.plugin.app,this.plugin,this.dataManager,()=>{this.render()}).open()}),t.createDiv({cls:"wl-add-btn-wrap"}).createEl("button",{cls:"wl-add-btn",text:"+ add"}).addEventListener("click",r=>{r.stopPropagation(),this.openAddTitleModal()})}renderFiltersDropdown(e){let t=e.createDiv({cls:"wl-add-btn-wrap"}),s=t.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Filters \u25BC"}),i=s.createSpan({cls:"wl-filter-dot"});this.hasActiveFilter()?i.show():i.hide();let a=e.createEl("button",{cls:"wl-btn wl-btn-sm wl-btn-clear-filters",text:"\u2715 clear"});a.title="Clear all filters",this.hasActiveFilter()?a.show():a.hide(),a.addEventListener("click",()=>{this.clearAllFilters(),this.saveFiltersToSettings(),this.deactivateSavedFilter(),i.hide(),a.hide(),this.rerenderTable()}),s.addEventListener("click",l=>{l.stopPropagation();let n=activeDocument.querySelector(".wl-filters-panel");if(n){n.remove();return}let r=activeDocument.body.createDiv({cls:"wl-dropdown wl-filters-panel wl-filters-panel-popup"}),o=s.getBoundingClientRect();r.style.top=`${o.bottom+4}px`,r.style.left=`${o.left}px`;let c=this.plugin.settings.types.map(M=>M.name),d=this.plugin.settings.statuses.map(M=>M.name),u=["1\u2605","2\u2605","3\u2605","4\u2605","5\u2605"],g=this.plugin.settings.priorities.map(M=>M.name),p=this.dataManager.getGroups().map(M=>M.name),m={typeExclude:new Set(this.filterTypeExclude),statusExclude:new Set(this.filterStatusExclude),ratingExclude:new Set(this.filterRatingExclude),priorityExclude:new Set(this.filterPriorityExclude),groupExclude:new Set(this.filterGroupExclude),filterRatingEmptyOnly:this.filterRatingEmptyOnly,filterPriorityEmptyOnly:this.filterPriorityEmptyOnly,filterRecentlyArrivedOnly:this.filterRecentlyArrivedOnly},f=[{label:"Type",opts:c,excludeSet:m.typeExclude},{label:"Status",opts:d,excludeSet:m.statusExclude},{label:"Rating",opts:u,excludeSet:m.ratingExclude},{label:"Priority",opts:g,excludeSet:m.priorityExclude}];p.length>0&&f.push({label:"Group",opts:p,excludeSet:m.groupExclude});let v=[],w=r.createDiv({cls:"wl-filter-global-btns"}),y=null;w.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Select all"}).addEventListener("click",M=>{M.stopPropagation();for(let{excludeSet:C}of f)C.clear();for(let{cb:C}of v)C.checked=!0;m.filterRecentlyArrivedOnly=!1,y&&(y.checked=!1)}),w.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Deselect all"}).addEventListener("click",M=>{M.stopPropagation();for(let{opts:C,excludeSet:L}of f)for(let N of C)L.add(N);for(let{cb:C}of v)C.checked=!1;m.filterRecentlyArrivedOnly=!1,y&&(y.checked=!1)});let S=this.dataManager.getSavedFilterPreset(),E=w.createEl("button",{cls:"wl-btn wl-btn-sm",text:S?"Delete":"Save"});E.addEventListener("click",M=>{(async()=>(M.stopPropagation(),E.textContent==="Save"?await this.dataManager.setSavedFilterPreset({typeExclude:Array.from(m.typeExclude),statusExclude:Array.from(m.statusExclude),groupExclude:Array.from(m.groupExclude),ratingExclude:Array.from(m.ratingExclude),priorityExclude:Array.from(m.priorityExclude),ratingEmptyOnly:m.filterRatingEmptyOnly,priorityEmptyOnly:m.filterPriorityEmptyOnly,recentlyArrivedOnly:m.filterRecentlyArrivedOnly}):(await this.dataManager.setSavedFilterPreset(null),this.savedFilterActive=!1),r.remove(),this.render()))()});let b={};for(let{label:M}of f)b[M]=!0;for(let{label:M,opts:C,excludeSet:L}of f){let N=r.createDiv({cls:"wl-filter-section"}),W=N.createDiv({cls:"wl-filter-section-header"});W.createSpan({cls:"wl-filter-label",text:M});let $=W.createSpan({cls:"wl-filter-chevron",text:"\u25BC"}),A=N.createDiv({cls:"wl-filter-section-content wl-hidden"});if(W.addEventListener("click",O=>{var Y;O.stopPropagation(),b[M]=!b[M];let G=(Y=b[M])!=null?Y:!0;A.toggleClass("wl-hidden",G),$.textContent=G?"\u25BC":"\u25B2"}),M==="Status"){let O=A.createDiv({cls:"wl-filter-checkbox-row"}),G=O.createEl("input",{attr:{type:"checkbox"}});G.checked=m.filterRecentlyArrivedOnly,O.createSpan({cls:"wl-recently-arrived-filter",text:"\u2726 Recently arrived"}),y=G,G.addEventListener("change",()=>{if(m.filterRecentlyArrivedOnly=G.checked,G.checked){L.clear();for(let Y of H)Y.checked=!0}})}let I=null,U=[],H=[];if(M==="Rating"||M==="Priority"){let O=A.createDiv({cls:"wl-filter-checkbox-row"});I=O.createEl("input",{attr:{type:"checkbox"}}),I.checked=M==="Rating"?m.filterRatingEmptyOnly:m.filterPriorityEmptyOnly,O.createSpan({text:"Empty"});let G=I;I.addEventListener("change",()=>{if(M==="Rating"?m.filterRatingEmptyOnly=G.checked:m.filterPriorityEmptyOnly=G.checked,G.checked){for(let Y of U)Y.checked=!0;L.clear()}})}for(let O of C){let G=A.createDiv({cls:"wl-filter-checkbox-row"}),Y=G.createEl("input",{attr:{type:"checkbox"}});Y.checked=!L.has(O),G.createSpan({text:O}),U.push(Y),M==="Status"&&H.push(Y),v.push({cb:Y});let B=I;Y.addEventListener("change",()=>{Y.checked?L.delete(O):L.add(O),M==="Status"&&y&&(m.filterRecentlyArrivedOnly=!1,y.checked=!1),(M==="Rating"||M==="Priority")&&B&&(M==="Rating"?m.filterRatingEmptyOnly=!1:m.filterPriorityEmptyOnly=!1,B.checked=!1)})}}r.createEl("button",{cls:"wl-btn wl-btn-sm wl-filter-apply-btn",text:"Apply"}).addEventListener("click",M=>{M.stopPropagation(),this.filterTypeExclude=m.typeExclude,this.filterStatusExclude=m.statusExclude,this.filterRatingExclude=m.ratingExclude,this.filterPriorityExclude=m.priorityExclude,this.filterGroupExclude=m.groupExclude,this.filterRatingEmptyOnly=m.filterRatingEmptyOnly,this.filterPriorityEmptyOnly=m.filterPriorityEmptyOnly,this.filterRecentlyArrivedOnly=m.filterRecentlyArrivedOnly,this.saveFiltersToSettings(),this.deactivateSavedFilter(),this.hasActiveFilter()?(i.show(),a.show()):(i.hide(),a.hide()),this.rerenderTable(),r.remove(),activeDocument.removeEventListener("mousedown",k,!1)});let k=M=>{let C=M.target;r.contains(C)||t.contains(C)||(r.remove(),activeDocument.removeEventListener("mousedown",k,!1))};window.setTimeout(()=>activeDocument.addEventListener("mousedown",k,!1),0)})}renderSortingDropdown(e){let t=e.createDiv({cls:"wl-add-btn-wrap"}),s=t.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Sorting \u25BC"});s.addEventListener("click",i=>{i.stopPropagation();let a=t.querySelector(".wl-dropdown");if(a){a.remove();return}let l=t.createDiv({cls:"wl-dropdown wl-sorting-panel"});if(window.innerWidth<=600){let c=s.getBoundingClientRect();l.addClass("wl-sorting-panel-mobile"),l.style.top=`${c.bottom+4}px`}let n=["Title","Date added","Progress","Rating","Priority","Started","Status","Date watched","Time left","Time watched"],r=(c,d,u,g)=>{let p=l.createDiv({cls:"wl-filter-row"});p.createSpan({cls:"wl-filter-label",text:c});let m=p.createEl("select",{cls:"wl-select"});if(!g){let v=m.createEl("option",{text:"None",value:"none"});v.selected=d==="none"}for(let v of n){let w=m.createEl("option",{text:v,value:v});this.sortLabelToKey(v)===d&&(w.selected=!0)}let f=p.createEl("button",{cls:"wl-btn wl-btn-sm wl-sort-dir-btn",text:u==="asc"?"\u2191":"\u2193"});f.title=u==="asc"?"Ascending \u2014 click to switch to descending":"Descending \u2014 click to switch to ascending",m.addEventListener("change",()=>{var w,y;let v=m.value==="none"?"none":this.sortLabelToKey(m.value);g?(this.filterSort=v,this.filterSortDir=(w=J.SORT_DEFAULT_DIR[v])!=null?w:"asc"):(this.filterSecondSort=v,this.filterSecondSortDir=(y=J.SORT_DEFAULT_DIR[v])!=null?y:"asc"),l.remove(),activeDocument.removeEventListener("click",o,!0),this.saveFiltersToSettings(),this.render()}),f.addEventListener("click",v=>{v.stopPropagation(),g?this.filterSortDir=this.filterSortDir==="asc"?"desc":"asc":this.filterSecondSortDir=this.filterSecondSortDir==="asc"?"desc":"asc",l.remove(),activeDocument.removeEventListener("click",o,!0),this.saveFiltersToSettings(),this.render()})};r("Sort by",this.filterSort,this.filterSortDir,!0),r("Then by",this.filterSecondSort,this.filterSecondSortDir,!1);let o=c=>{t.contains(c.target)||(l.remove(),activeDocument.removeEventListener("click",o,!0))};window.setTimeout(()=>activeDocument.addEventListener("click",o,!0),0)})}renderActionBar(e){let t=e.createDiv({cls:"wl-action-bar"}),s=t.createEl("button",{cls:"wl-group-action-btn wl-group-action-btn-delete",text:"\u2715"});s.title="Delete selected",s.addEventListener("click",r=>{r.stopPropagation();let o=this.selectedItems.size;new K(this.plugin.app,`Delete ${o} selected item${o!==1?"s":""}? This cannot be undone.`,()=>{(async()=>{let c=new Set(this.dataManager.getGroups().map(u=>u.id)),d=[];for(let u of Array.from(this.selectedItems))c.has(u)?(await this.dataManager.removeGroup(u),this.expandedGroups.delete(u)):(d.push(u),this.expandedId===u&&(this.expandedId=null));d.length>0&&await this.dataManager.removeTitlesBatch(d),this.selectedItems.clear(),this.render()})()}).open()});let i=t.createEl("select",{cls:"wl-select wl-select-sm"});i.createEl("option",{text:"Status\u2026",value:""});for(let r of this.plugin.settings.statuses.filter(o=>o.name!=="To be released"))i.createEl("option",{text:r.name,value:r.name});i.addEventListener("change",()=>{(async()=>{let r=i.value;if(!r)return;let o=new Set(this.dataManager.getGroups().map(c=>c.id));for(let c of this.selectedItems){if(o.has(c))continue;let d=this.dataManager.getTitle(c);d&&(d.status=r,await this.dataManager.updateTitle(d))}i.value="",this.render()})()});let a=this.dataManager.getGroups(),l=Array.from(this.selectedItems).some(r=>this.dataManager.getGroups().some(o=>o.id===r)),n=t.createEl("select",{cls:"wl-select wl-select-sm"});l&&(n.disabled=!0,n.title="A group is selected \u2014 cannot move groups into other groups"),n.createEl("option",{text:"Group\u2026",value:""});for(let r of a)n.createEl("option",{text:r.name,value:r.id});n.createEl("option",{text:"Create new group\u2026",value:"__new__"}),n.addEventListener("change",()=>{(async()=>{let r=n.value;if(!r)return;let o=new Set(a.map(d=>d.id)),c=Array.from(this.selectedItems).filter(d=>!o.has(d));if(r==="__new__"){n.value="",n.hide();let d=t.createEl("input",{cls:"wl-modal-input wl-group-name-input",attr:{type:"text",placeholder:"Group name\u2026",maxlength:"64"}}),u=t.createEl("button",{cls:"wl-group-action-btn",text:"\u2713"});u.title="Create group";let g=t.createEl("button",{cls:"wl-group-action-btn",text:"\u2715"});g.title="Cancel";let p=async()=>{let m=d.value.trim();if(!m)return;let f={id:this.dataManager.generateGroupId(m),name:m,titleIds:c,dateAdded:new Date().toISOString()};await this.dataManager.addGroup(f),this.selectedItems.clear(),this.selectionMode=!1,this.render()};u.addEventListener("click",m=>{m.stopPropagation(),p()}),g.addEventListener("click",m=>{m.stopPropagation(),this.render()}),d.addEventListener("keydown",m=>{m.stopPropagation(),m.key==="Enter"&&p(),m.key==="Escape"&&this.render()}),d.addEventListener("click",m=>m.stopPropagation()),window.setTimeout(()=>d.focus(),0);return}for(let d of c)await this.dataManager.addTitleToGroup(r,d);n.value="",this.selectedItems.clear(),this.selectionMode=!1,this.render()})()})}openAddTitleModal(){new tt(this.plugin.app,this.plugin,this.dataManager,()=>{this.render()}).open()}renderSearch(){let t=this.container.createDiv({cls:"wl-search-wrap"}).createEl("input",{cls:"wl-search-input",attr:{type:"text",placeholder:"Search titles..."}});t.value=this.searchQuery;let s=0;t.addEventListener("input",()=>{this.searchQuery=t.value,window.clearTimeout(s),s=window.setTimeout(()=>{this.rerenderTable()},250)})}getSortLabelFor(e){var s;return(s={title:"Title",dateAdded:"Date added",progress:"Progress",rating:"Rating",priority:"Priority",started:"Started",status:"Status",dateWatched:"Date watched",timeLeft:"Time left",timeWatched:"Time watched"}[e])!=null?s:"Date added"}sortLabelToKey(e){var s;return(s={Title:"title","Date added":"dateAdded",Progress:"progress",Rating:"rating",Priority:"priority",Started:"started",Status:"status","Date watched":"dateWatched","Time left":"timeLeft","Time watched":"timeWatched"}[e])!=null?s:"dateAdded"}static migrateSortKey(e){var s;return(s={"title-asc":{key:"title",dir:"asc"},"title-desc":{key:"title",dir:"desc"},"dateAdded-newest":{key:"dateAdded",dir:"desc"},"dateAdded-oldest":{key:"dateAdded",dir:"asc"},"progress-high":{key:"progress",dir:"desc"},"progress-low":{key:"progress",dir:"asc"},"rating-high":{key:"rating",dir:"desc"},"rating-low":{key:"rating",dir:"asc"},priority:{key:"priority",dir:"asc"},"started-asc":{key:"started",dir:"asc"},"started-desc":{key:"started",dir:"desc"},"status-asc":{key:"status",dir:"asc"},"status-desc":{key:"status",dir:"desc"},"dateWatched-newest":{key:"dateWatched",dir:"desc"},"dateWatched-oldest":{key:"dateWatched",dir:"asc"},"timeLeft-high":{key:"timeLeft",dir:"desc"},"timeLeft-low":{key:"timeLeft",dir:"asc"},"timeWatched-high":{key:"timeWatched",dir:"desc"},"timeWatched-low":{key:"timeWatched",dir:"asc"},none:{key:"none",dir:"asc"}}[e])!=null?s:{key:e,dir:"desc"}}static sortBaseAndDirToKey(e,t){var i,a;return(a=(i={title:{asc:"title-asc",desc:"title-desc"},dateAdded:{asc:"dateAdded-oldest",desc:"dateAdded-newest"},progress:{asc:"progress-low",desc:"progress-high"},rating:{asc:"rating-low",desc:"rating-high"},priority:{asc:"priority",desc:"priority"},started:{asc:"started-asc",desc:"started-desc"},status:{asc:"status-asc",desc:"status-desc"},dateWatched:{asc:"dateWatched-oldest",desc:"dateWatched-newest"},timeLeft:{asc:"timeLeft-low",desc:"timeLeft-high"},timeWatched:{asc:"timeWatched-low",desc:"timeWatched-high"}}[e])==null?void 0:i[t])!=null?a:"dateAdded-newest"}rerenderTable(){let e=this._lastScrollTop,t=this.container.querySelector(".wl-table-section");t&&t.remove(),this.renderTable(e)}renderTable(e){this.scrollCleanup&&(this.scrollCleanup(),this.scrollCleanup=null);let t=this.container.createDiv({cls:"wl-table-section"}),s=this.getDisplayItems(),i=s.reduce((d,u)=>d+(u.kind==="title"?1:0),0),a=s.reduce((d,u)=>d+(u.kind==="group"?1:0),0),l=t.createDiv({cls:"wl-results-count"}),n=[];i>0&&n.push(`${i} title${i!==1?"s":""}`),a>0&&n.push(`${a} group${a!==1?"s":""}`),l.textContent=n.length>0?n.join(", "):"0 titles";let r=this.selectionMode?"wl-table wl-selection-mode":"wl-table",o=t.createDiv({cls:r}),c=o.createDiv({cls:"wl-table-header-row"});if(this.selectionMode&&c.createDiv({cls:"wl-col-select-h"}),c.createDiv({cls:"wl-col-title-h",text:"Title"}),c.createDiv({cls:"wl-col-priority-h",text:"Priority"}),c.createDiv({cls:"wl-col-started-h",text:"Started"}),c.createDiv({cls:"wl-col-rating-h",text:"Rating"}),c.createDiv({cls:"wl-col-status-h",text:"Status"}),s.length===0){o.createDiv({cls:"wl-empty-state",text:"No titles match your filters."});return}this.mountVirtualRows(o,s,e)}mountVirtualRows(e,t,s){var y;let r=t.map(()=>68);if(this.expandedId!==null){let T=t.findIndex(D=>D.kind==="title"&&D.data.id===this.expandedId);if(T!==-1){let D=t[T];if((D==null?void 0:D.kind)==="title"){let S=activeDocument.body.createDiv();S.style.cssText=`position:absolute;left:-9999px;top:0;width:${e.clientWidth}px;visibility:hidden;`,this.renderRow(S,D.data);let E=S.offsetHeight;S.remove(),E>0&&(r[T]=E)}}}for(let T=0;T<t.length;T++){let D=t[T];if((D==null?void 0:D.kind)!=="group"||!this.expandedGroups.has(D.data.id))continue;let S=activeDocument.body.createDiv();S.style.cssText=`position:absolute;left:-9999px;top:0;width:${e.clientWidth}px;visibility:hidden;`,this.renderGroupRow(S,D.data,D.members);let E=S.offsetHeight;S.remove(),E>0&&(r[T]=E)}let o=[],c=0;for(let T=0;T<t.length;T++)o.push(c),c+=(y=r[T])!=null?y:44,T<t.length-1&&(c+=8);let d=e.createDiv({cls:"wl-virt-container"});d.style.height=`${c}px`;let u=this.container,g=d.getBoundingClientRect().top-u.getBoundingClientRect().top+u.scrollTop;s!==void 0&&(u.scrollTop=s);let p=0,m=-1,f=-1,v=()=>{var N,W,$,A;let T=u.scrollTop,D=u.clientHeight||600,S=5*44,E=T-g,b=E-S,x=E+D+S,k=0,M=t.length-1;for(;k<=M;){let I=k+M>>>1;((N=o[I])!=null?N:0)+((W=r[I])!=null?W:44)<=b?k=I+1:M=I-1}let C=Math.max(0,k),L=C;for(let I=C;I<t.length&&!((($=o[I])!=null?$:0)>=x);I++)L=I;if(!(C===m&&L===f)){m=C,f=L,d.empty();for(let I=C;I<=L;I++){let U=t[I];if(!U)continue;let H=d.createDiv({cls:"wl-virt-item"});H.style.top=`${(A=o[I])!=null?A:0}px`,U.kind==="title"?this.renderRow(H,U.data):this.renderGroupRow(H,U.data,U.members)}}};v();let w=()=>{p===0&&(p=window.requestAnimationFrame(()=>{p=0,v()}))};u.addEventListener("scroll",w,{passive:!0}),this.scrollCleanup=()=>{u.removeEventListener("scroll",w),p!==0&&(cancelAnimationFrame(p),p=0)}}getDisplayItems(){let e=this.dataManager.getGroups(),t=this.dataManager.getGroupedTitleIds(),s=this.dataManager.getTitles(),i=this.searchQuery.trim().toLowerCase(),a=this.getFilteredSortedTitles().filter(r=>!t.has(r.id)),l=[];for(let r of e){if(this.filterGroupExclude.size>0&&this.filterGroupExclude.has(r.name))continue;let o=r.titleIds.map(d=>s.find(u=>u.id===d)).filter(d=>d!==void 0);if(i){let d=r.name.toLowerCase().includes(i),u=o.some(g=>g.title.toLowerCase().includes(i));if(!d&&!u)continue}(this.filterTypeExclude.size>0||this.filterStatusExclude.size>0||this.filterRatingExclude.size>0||this.filterPriorityExclude.size>0)&&!o.every(d=>this.titlePassesFilters(d))||l.push({kind:"group",data:r,members:o})}return[...l,...a.map(r=>({kind:"title",data:r}))].sort((r,o)=>{let c=r.kind==="title"&&r.data.pinned||r.kind==="group"&&r.data.id===this.pinnedGroupId,d=o.kind==="title"&&o.data.pinned||o.kind==="group"&&o.data.id===this.pinnedGroupId;return c&&!d?-1:!c&&d?1:this.compareDisplayItems(r,o)})}getStatusIndex(e){let t=this.STATUS_ORDER.indexOf(e);return t===-1?99:t}compareDisplayItemsByKey(e,t,s){let i=p=>p.kind==="title"?p.data.title:p.data.name,a=p=>p.kind==="title"?this.dataManager.getProgress(p.data):this.getGroupProgress(p.members),l=p=>{if(p.kind==="title"){let f=xt.indexOf(p.data.priority);return f===-1?99:f}let m=p.members.map(f=>xt.indexOf(f.priority)).filter(f=>f!==-1);return m.length>0?Math.min(...m):99},n=p=>{if(p.kind==="title")return new Date(p.data.dateAdded).getTime();let m=p.members.map(f=>new Date(f.dateAdded).getTime());return m.length>0?Math.max(...m):0},r=p=>{if(p.kind==="title")return p.data.dateStarted?new Date(p.data.dateStarted).getTime():null;let m=p.members.map(f=>f.dateStarted?new Date(f.dateStarted).getTime():null).filter(f=>f!==null);return m.length>0?Math.min(...m):null},o=p=>p.kind==="title"?this.getStatusIndex(p.data.status):this.getStatusIndex(this.getGroupStatus(p.members)),c=p=>{if(p.kind==="title")return p.data.rating;let m=p.members.filter(f=>f.rating>0);return m.length===0?0:m.reduce((f,v)=>f+v.rating,0)/m.length},d=p=>{if(p.kind==="title")return p.data.dateFinished?new Date(p.data.dateFinished).getTime():0;let m=p.members.map(f=>f.dateFinished?new Date(f.dateFinished).getTime():0).filter(f=>f>0);return m.length>0?Math.max(...m):0},u=p=>p.kind==="title"?this.dataManager.calcTimeRemaining(p.data):p.members.reduce((m,f)=>m+this.dataManager.calcTimeRemaining(f),0),g=p=>p.kind==="title"?this.dataManager.calcTimeWatched(p.data):p.members.reduce((m,f)=>m+this.dataManager.calcTimeWatched(f),0);switch(e){case"title-asc":return i(t).localeCompare(i(s));case"title-desc":return i(s).localeCompare(i(t));case"dateAdded-newest":return n(s)-n(t);case"dateAdded-oldest":return n(t)-n(s);case"progress-high":return a(s)-a(t);case"progress-low":return a(t)-a(s);case"rating-high":{let p=c(t),m=c(s);return p===0&&m===0?0:p===0?1:m===0?-1:m-p}case"rating-low":{let p=c(t),m=c(s);return p===0&&m===0?0:p===0?1:m===0?-1:p-m}case"priority":return l(t)-l(s);case"status-asc":return o(t)-o(s);case"status-desc":return o(s)-o(t);case"started-asc":{let p=r(t),m=r(s);return p==null&&m==null?0:p==null?1:m==null?-1:p-m}case"started-desc":{let p=r(t),m=r(s);return p==null&&m==null?0:p==null?1:m==null?-1:m-p}case"dateWatched-newest":return d(s)-d(t);case"dateWatched-oldest":{let p=d(t)||1/0,m=d(s)||1/0;return p-m}case"timeLeft-high":return u(s)-u(t);case"timeLeft-low":return u(t)-u(s);case"timeWatched-high":return g(s)-g(t);case"timeWatched-low":return g(t)-g(s);default:return 0}}compareDisplayItems(e,t){let s=J.sortBaseAndDirToKey(this.filterSort,this.filterSortDir),i=this.compareDisplayItemsByKey(s,e,t);if(i!==0||this.filterSecondSort==="none")return i;let a=J.sortBaseAndDirToKey(this.filterSecondSort,this.filterSecondSortDir);return this.compareDisplayItemsByKey(a,e,t)}renderRow(e,t,s=!1){let i=this.expandedId===t.id,a=this.selectedItems.has(t.id),l=["wl-row",i?"is-expanded":"",s?"wl-row-indented":"",a?"wl-row-selected":""].filter(Boolean).join(" "),n=e.createDiv({cls:l});if(n.dataset.titleId=t.id,this.selectionMode){let b=n.createDiv({cls:"wl-col-select"}).createEl("input",{attr:{type:"checkbox"}});b.checked=a,b.addEventListener("click",x=>x.stopPropagation()),b.addEventListener("change",()=>{b.checked?this.selectedItems.add(t.id):this.selectedItems.delete(t.id),this.render()})}let r=n.createDiv({cls:"wl-col-title"});r.createDiv({cls:"wl-row-title-text",text:t.title});let o=this.getTagDef(t.type,this.plugin.settings.types),c=this.plugin.settings.coloredTypeBadges,d=r.createDiv({cls:"wl-badge-row"}),u=d.createSpan({cls:c?"wl-badge wl-badge-sm":"wl-badge-plain",text:t.type});c&&o&&(u.style.backgroundColor=_(t.type,o.color,this.plugin.settings.colorTheme)),J.isRecentlyArrived(t)&&d.createSpan({cls:"wl-recently-arrived",text:"\xB7 Recently arrived"});let p=r.createDiv({cls:"wl-progress-wrap"}).createDiv({cls:"wl-progress-bar"});p.style.width=`${this.dataManager.getProgress(t)}%`;let m=n.createDiv({cls:"wl-col-priority"});if(t.priority){let E=this.getTagDef(t.priority,this.plugin.settings.priorities),b=m.createSpan({cls:"wl-priority-badge",text:t.priority});E&&(b.style.color=E.color)}let f=n.createDiv({cls:"wl-col-started"});f.textContent=t.dateStarted?Z(t.dateStarted):"\u2014";let v=n.createDiv({cls:"wl-col-rating"});t.rating>0?v.createSpan({cls:"wl-row-rating",text:`\u2605 ${t.rating}/5`}):v.createSpan({cls:"wl-row-rating wl-row-rating-empty",text:"\u2014"});let w=n.createDiv({cls:"wl-col-status"}),y=this.getTagDef(t.status,this.plugin.settings.statuses),T=w.createSpan({cls:c?"wl-badge":"wl-badge-plain",text:t.status});c&&y&&(T.style.backgroundColor=_(t.status,y.color,this.plugin.settings.colorTheme));let S=n.createDiv({cls:"wl-col-pin"}).createSpan({cls:`wl-pin-icon${t.pinned?" is-pinned":""}`,text:"\u{1F4CC}"});S.title=t.pinned?"Unpin":"Pin to top",S.addEventListener("click",E=>{E.stopPropagation(),(async()=>{let b=this.dataManager.getTitle(t.id);if(!b)return;let x=!b.pinned;if(x)for(let k of this.dataManager.getTitles())k.id!==b.id&&k.pinned&&(k.pinned=!1,await this.dataManager.updateTitle(k));b.pinned=x,await this.dataManager.updateTitle(b),this.rerenderTable()})()}),n.addEventListener("click",()=>{if(this.selectionMode){this.selectedItems.has(t.id)?this.selectedItems.delete(t.id):this.selectedItems.add(t.id),this.render();return}this.expandedId=i?null:t.id,i||(this.collapsedSeasons=this.dataManager.getCollapsedSeasonsForTitle(t.id)),this.rerenderTable()}),i&&!this.selectionMode&&this.renderAccordion(e,t)}renderGroupRow(e,t,s){var M,C,L,N,W;let i=this.expandedGroups.has(t.id),a=this.selectedItems.has(t.id),l=e.createDiv({cls:`wl-row wl-group-row${i?" is-expanded":""}${a?" wl-row-selected":""}`});if(l.dataset.type=(C=(M=s[0])==null?void 0:M.type)!=null?C:"",l.dataset.status=this.getGroupStatus(s),l.dataset.priority=(L=this.getGroupHighestPriority(s))!=null?L:"",l.dataset.rating=`${Math.round(this.getGroupRating(s))}\u2605`,l.dataset.group=t.name,this.selectionMode){let A=l.createDiv({cls:"wl-col-select"}).createEl("input",{attr:{type:"checkbox"}});A.checked=a,A.addEventListener("click",I=>I.stopPropagation()),A.addEventListener("change",()=>{A.checked?this.selectedItems.add(t.id):this.selectedItems.delete(t.id),this.render()})}let n=l.createDiv({cls:"wl-col-title"});if(this.renamingGroupId===t.id){let $=n.createDiv({cls:"wl-group-name-row"}),A=$.createEl("input",{cls:"wl-group-rename-input",attr:{type:"text",value:t.name}});A.addEventListener("click",H=>H.stopPropagation()),A.addEventListener("keydown",H=>{H.stopPropagation(),H.key==="Enter"?(async()=>{let O=A.value.trim();if(O&&O!==t.name){let G={...t,name:O};await this.dataManager.updateGroup(G)}this.renamingGroupId=null,this.rerenderTable()})():H.key==="Escape"&&(this.renamingGroupId=null,this.rerenderTable())});let I=$.createEl("button",{cls:"wl-group-action-btn",text:"\u2713"});I.title="Save",I.addEventListener("click",H=>{H.stopPropagation(),(async()=>{let O=A.value.trim();if(O&&O!==t.name){let G={...t,name:O};await this.dataManager.updateGroup(G)}this.renamingGroupId=null,this.rerenderTable()})()});let U=$.createEl("button",{cls:"wl-group-action-btn",text:"\u2715"});U.title="Cancel",U.addEventListener("click",H=>{H.stopPropagation(),this.renamingGroupId=null,this.rerenderTable()}),window.setTimeout(()=>A.focus(),0)}else{let $=n.createDiv({cls:"wl-group-name-row"});$.createSpan({cls:"wl-row-title-text wl-group-name",text:t.name});let A=$.createEl("button",{cls:"wl-group-action-btn",text:"\u270F"});A.title="Rename group",A.addEventListener("click",U=>{U.stopPropagation(),this.renamingGroupId=t.id,this.rerenderTable()});let I=$.createEl("button",{cls:"wl-group-action-btn wl-group-action-btn-delete",text:"\u2715"});I.title="Delete group (titles are kept)",I.addEventListener("click",U=>{U.stopPropagation(),new K(this.plugin.app,`Delete group "${t.name}"? All titles inside will be returned to the main list.`,()=>{this.dataManager.removeGroup(t.id).then(()=>{this.expandedGroups.delete(t.id),this.rerenderTable()})}).open()})}let r=(W=(N=s[0])==null?void 0:N.type)!=null?W:"";if(r){let $=this.getTagDef(r,this.plugin.settings.types),A=this.plugin.settings.coloredTypeBadges,I=n.createSpan({cls:A?"wl-badge wl-badge-sm":"wl-badge-plain",text:r});A&&$&&(I.style.backgroundColor=_(r,$.color,this.plugin.settings.colorTheme))}let o=n.createDiv({cls:"wl-group-stats"}),c=s.reduce(($,A)=>$+this.dataManager.calcTimeWatched(A),0),d=s.reduce(($,A)=>$+this.dataManager.calcTimeRemaining(A),0);o.createSpan({text:`${s.length} title${s.length!==1?"s":""}`}),o.createSpan({text:` \xB7 ${V(c)} watched`}),o.createSpan({text:` \xB7 ${V(d)} left`});let u=n.createDiv({cls:"wl-progress-wrap"});u.createDiv({cls:"wl-progress-bar"}).style.width=`${this.getGroupProgress(s)}%`;let g=l.createDiv({cls:"wl-col-priority"}),p=this.getGroupHighestPriority(s);if(p){let $=this.getTagDef(p,this.plugin.settings.priorities),A=g.createSpan({cls:"wl-priority-badge",text:p});$&&(A.style.color=$.color)}let m=l.createDiv({cls:"wl-col-started"}),f=this.getGroupStartedDate(s);m.textContent=f?Z(f):"\u2014";let v=l.createDiv({cls:"wl-col-rating"}),w=this.getGroupRating(s);w>0?v.createSpan({cls:"wl-row-rating",text:`\u2605 ${w.toFixed(1)}/5`}):v.createSpan({cls:"wl-row-rating wl-row-rating-empty",text:"\u2014"});let y=l.createDiv({cls:"wl-col-status"}),T=this.getGroupStatus(s),D=this.getTagDef(T,this.plugin.settings.statuses),S=this.plugin.settings.coloredTypeBadges,E=y.createSpan({cls:S?"wl-badge":"wl-badge-plain",text:T});S&&(D?E.style.backgroundColor=_(T,D.color,this.plugin.settings.colorTheme):T==="In Progress"&&(E.style.backgroundColor=_("In Progress","#724CF9",this.plugin.settings.colorTheme)));let b=this.pinnedGroupId===t.id,k=l.createDiv({cls:"wl-col-pin"}).createSpan({cls:`wl-pin-icon${b?" is-pinned":""}`,text:"\u{1F4CC}"});if(k.title=b?"Unpin":"Pin to top",k.addEventListener("click",$=>{if($.stopPropagation(),this.pinnedGroupId=b?null:t.id,this.dataManager.setPinnedGroupId(this.pinnedGroupId),this.pinnedGroupId)for(let A of this.dataManager.getTitles())A.pinned&&(A.pinned=!1,this.dataManager.updateTitle(A));this.rerenderTable()}),l.addEventListener("click",()=>{if(this.renamingGroupId!==t.id){if(this.selectionMode){this.selectedItems.has(t.id)?this.selectedItems.delete(t.id):this.selectedItems.add(t.id),this.render();return}i?this.expandedGroups.delete(t.id):this.expandedGroups.add(t.id),this.rerenderTable()}}),i)for(let $ of s)this.renderRow(e,$,!0)}getGroupProgress(e){let t=e.reduce((i,a)=>i+a.watchedEpisodes.length,0),s=e.reduce((i,a)=>i+a.totalEpisodes,0);return s===0?0:Math.round(t/s*100)}getGroupStatus(e){if(e.length===0)return"Plan to watch";let t=e.map(s=>s.status);return t.some(s=>s==="Watching")?"Watching":t.every(s=>s==="Completed")?"Completed":t.every(s=>s==="Plan to watch")?"Plan to watch":"In Progress"}getGroupStartedDate(e){var s;let t=e.map(i=>i.dateStarted).filter(i=>i!==null);return t.length===0?null:(s=t.sort()[0])!=null?s:null}getGroupRating(e){let t=e.filter(s=>s.rating>0);return t.length===0?0:t.reduce((s,i)=>s+i.rating,0)/t.length}getGroupHighestPriority(e){let t=null,s=1/0;for(let i of e){let a=xt.indexOf(i.priority);a!==-1&&a<s&&(s=a,t=i.priority)}return t}renderAccordion(e,t){let s=e.createDiv({cls:"wl-accordion"});s.addEventListener("click",y=>y.stopPropagation());let i=s.createDiv({cls:"wl-accordion-header"}),a=i.createDiv({cls:"wl-acc-header-left"}),l=t.totalEpisodes>0?` \xB7 ${t.totalEpisodes} eps total`:"",n=t.dateStarted?`Started ${Z(t.dateStarted)}`:"Not started",r=a.createDiv({cls:"wl-acc-subtitle"});if(r.createSpan({cls:"wl-acc-subtitle-text",text:`${n}${l}`}),t.externalLink){let y=r.createEl("a",{cls:"wl-acc-link-icon",text:"\u{1F310}"});y.href=t.externalLink,y.title="Open external link",y.target="_blank",y.rel="noopener noreferrer",y.addEventListener("click",T=>T.stopPropagation())}let o=t.watchedEpisodes.length,c=t.totalEpisodes,d=this.dataManager.calcTimeWatched(t),u=this.dataManager.calcTimeRemainingForModal(t),g=this.dataManager.getProgress(t),p=i.createDiv({cls:"wl-acc-right-group"}),m=(y,T)=>{let D=p.createDiv({cls:"wl-acc-stat-block"});D.createDiv({cls:"wl-acc-percent",text:y}),D.createDiv({cls:"wl-acc-progress-label",text:T})};m(V(u),"left"),m(V(d),"watched"),m(`${o} / ${c}`,"episodes");let f=p.createDiv({cls:"wl-acc-header-right"});f.createDiv({cls:"wl-acc-percent",text:`${g}%`}),f.createDiv({cls:"wl-acc-progress-label",text:"progress"});let v=f.createDiv({cls:"wl-acc-progress-wrap"});v.createDiv({cls:"wl-progress-bar"}).style.width=`${g}%`;let w=s.createDiv({cls:"wl-accordion-body"});t.type==="Movie"?this.renderMovieBody(w,t):this.renderEpisodesBody(w,t),this.renderAccordionFooter(s,t)}renderMovieBody(e,t){let s=e.createDiv({cls:"wl-movie-row"}),i=s.createEl("input",{cls:"wl-movie-checkbox",attr:{type:"checkbox"}});i.checked=t.watchedEpisodes.includes(1),s.createSpan({cls:"wl-movie-label",text:"Watched"}),i.addEventListener("change",a=>{a.stopPropagation(),this.dataManager.markEpisodeWatched(t.id,1,i.checked).then(()=>this.rerenderTable())})}renderEpisodesBody(e,t){if(t.seasons.length===0){t.totalEpisodes>0&&this.renderEpisodeGrid(e,t,t.totalEpisodes,0);return}t.seasons.forEach((s,i)=>{var d;let a=this.collapsedSeasons.has(i),l=e.createDiv({cls:"wl-season-wrap"}),n=l.createDiv({cls:"wl-season-header"}),r=n.createSpan({cls:"wl-season-badge"});r.textContent=s.name;let o=this.plugin.settings.seasonPalette;r.style.backgroundColor=(d=o[i%o.length])!=null?d:"#888780",n.createSpan({cls:"wl-season-ep-count",text:`${s.episodes} eps`});let c=n.createSpan({cls:`wl-chevron${a?"":" is-open"}`,text:"\u203A"});a||this.renderEpisodeGrid(l,t,s.episodes,s.offset),n.addEventListener("click",u=>{var g;u.stopPropagation(),a?(a=!1,this.collapsedSeasons.delete(i),c.classList.add("is-open"),this.renderEpisodeGrid(l,t,s.episodes,s.offset)):(a=!0,this.collapsedSeasons.add(i),c.classList.remove("is-open"),(g=l.querySelector(".wl-episode-grid"))==null||g.remove()),this.expandedId&&this.dataManager.persistCollapsedSeasons(this.expandedId,this.collapsedSeasons)})})}renderEpisodeGrid(e,t,s,i){let a=e.createDiv({cls:"wl-episode-grid"}),l=new Set(t.watchedEpisodes),n=Array.from({length:s},(d,u)=>i+u+1),r=n.length>0&&n.every(d=>l.has(d)),o=a.createDiv({cls:`wl-season-fill-btn${r?" is-clear":" is-fill"}`});o.textContent=r?"\u2717":"\u2713",o.title=r?"Clear all episodes in this season":"Mark all episodes in this season as watched",o.addEventListener("click",d=>{d.stopPropagation(),this.dataManager.markSeasonWatched(t.id,n,!r).then(()=>this.rerenderTable())});let c=this.plugin.settings.episodeNumbering==="per-season";for(let d=0;d<s;d++){let u=i+d+1,g=l.has(u),p=a.createDiv({cls:`wl-episode-box${g?" is-watched":""}`}),m=c?d+1:u;p.textContent=g?"\u2713":String(m),p.title=`Episode ${u}`,p.addEventListener("click",f=>{f.stopPropagation(),this.dataManager.markEpisodeWatched(t.id,u,!g).then(()=>this.rerenderTable())})}}renderAccordionFooter(e,t){let s=e.createDiv({cls:"wl-accordion-footer"}),i=s.createDiv({cls:"wl-stars-row"});i.createSpan({cls:"wl-stars-label",text:"Rating"});let a=i.createDiv({cls:"wl-stars"});for(let f=1;f<=5;f++)a.createSpan({cls:`wl-star${t.rating>=f?" is-active":""}`,text:"\u2605"}).addEventListener("click",w=>{w.stopPropagation(),(async()=>{let y=this.dataManager.getTitle(t.id);y&&(y.rating=y.rating===f?0:f,await this.dataManager.updateTitle(y),this.rerenderTable())})()});let n=s.createDiv({cls:"wl-footer-row"}).createEl("input",{cls:"wl-notes-input",attr:{type:"text",placeholder:"Add a note..."}});n.value=t.notes,n.addEventListener("click",f=>f.stopPropagation()),n.addEventListener("change",()=>{(async()=>{let f=this.dataManager.getTitle(t.id);f&&(f.notes=n.value,await this.dataManager.updateTitle(f),this.rerenderTable())})()});let r=s.createDiv({cls:"wl-footer-row"});r.createSpan({cls:"wl-footer-label",text:"Date watched"});let o=r.createEl("input",{cls:"wl-footer-date",attr:{type:"text",placeholder:"Dd/mm/yyyy",maxlength:"10"}});o.value=Z(t.dateFinished),o.addEventListener("click",f=>f.stopPropagation()),o.addEventListener("change",()=>{(async()=>{let f=this.dataManager.getTitle(t.id);if(!f)return;let v=lt(o.value);if(o.value.trim()&&!v){o.addClass("wl-input-error");return}o.removeClass("wl-input-error"),f.dateFinished=v,await this.dataManager.updateTitle(f),this.rerenderTable()})()});let c=s.createDiv({cls:"wl-footer-row"});c.createSpan({cls:"wl-footer-label",text:"Priority"});let d=c.createEl("select",{cls:"wl-select wl-select-sm"});for(let f of this.plugin.settings.priorities){let v=d.createEl("option",{text:f.name,value:f.name});f.name===t.priority&&(v.selected=!0)}d.addEventListener("click",f=>f.stopPropagation()),d.addEventListener("change",()=>{(async()=>{let f=this.dataManager.getTitle(t.id);f&&(f.priority=d.value,await this.dataManager.updateTitle(f),this.rerenderTable())})()});let u=s.createDiv({cls:"wl-footer-row wl-footer-action-row"});u.createEl("button",{cls:"wl-delete-btn",text:"Remove"}).addEventListener("click",f=>{f.stopPropagation(),new K(this.plugin.app,`Remove "${t.title}" from watchlog?`,()=>{this.dataManager.removeTitle(t.id).then(()=>{this.expandedId=null,this.rerenderTable()})}).open()}),u.createDiv({cls:"wl-add-btn-wrap"}).createEl("button",{cls:"wl-edit-btn",text:"Edit"}).addEventListener("click",f=>{f.stopPropagation();let v=this.dataManager.getTitle(t.id);v&&new $t(this.plugin.app,this.plugin,this.dataManager,v,()=>{this.rerenderTable()}).open()})}compareTitlesByKey(e,t,s){switch(e){case"title-asc":return t.title.localeCompare(s.title);case"title-desc":return s.title.localeCompare(t.title);case"dateAdded-newest":return new Date(s.dateAdded).getTime()-new Date(t.dateAdded).getTime();case"dateAdded-oldest":return new Date(t.dateAdded).getTime()-new Date(s.dateAdded).getTime();case"progress-high":return this.dataManager.getProgress(s)-this.dataManager.getProgress(t);case"progress-low":return this.dataManager.getProgress(t)-this.dataManager.getProgress(s);case"rating-high":{let i=t.rating===0?-1:t.rating;return(s.rating===0?-1:s.rating)-i}case"rating-low":return t.rating===0&&s.rating===0?0:t.rating===0?1:s.rating===0?-1:t.rating-s.rating;case"priority":{let i=xt.indexOf(t.priority),a=xt.indexOf(s.priority);return(i===-1?99:i)-(a===-1?99:a)}case"started-asc":{let i=t.dateStarted?new Date(t.dateStarted).getTime():1/0,a=s.dateStarted?new Date(s.dateStarted).getTime():1/0;return i-a}case"started-desc":{let i=t.dateStarted?new Date(t.dateStarted).getTime():null,a=s.dateStarted?new Date(s.dateStarted).getTime():null;return i==null&&a==null?0:i==null?1:a==null?-1:a-i}case"status-asc":return this.getStatusIndex(t.status)-this.getStatusIndex(s.status);case"status-desc":return this.getStatusIndex(s.status)-this.getStatusIndex(t.status);case"dateWatched-newest":{let i=t.dateFinished?new Date(t.dateFinished).getTime():0;return(s.dateFinished?new Date(s.dateFinished).getTime():0)-i}case"dateWatched-oldest":{let i=t.dateFinished?new Date(t.dateFinished).getTime():1/0,a=s.dateFinished?new Date(s.dateFinished).getTime():1/0;return i-a}case"timeLeft-high":return this.dataManager.calcTimeRemaining(s)-this.dataManager.calcTimeRemaining(t);case"timeLeft-low":return this.dataManager.calcTimeRemaining(t)-this.dataManager.calcTimeRemaining(s);case"timeWatched-high":return this.dataManager.calcTimeWatched(s)-this.dataManager.calcTimeWatched(t);case"timeWatched-low":return this.dataManager.calcTimeWatched(t)-this.dataManager.calcTimeWatched(s);default:return 0}}getFilteredSortedTitles(){let e=this.dataManager.getTitles();if(this.searchQuery.trim()){let t=this.searchQuery.toLowerCase();e=e.filter(s=>s.title.toLowerCase().includes(t))}return e=e.filter(t=>this.titlePassesFilters(t)),e=[...e].sort((t,s)=>{let i=this.compareTitlesByKey(this.filterSort,t,s);return i!==0||this.filterSecondSort==="none"?i:this.compareTitlesByKey(this.filterSecondSort,t,s)}),e}getTagDef(e,t){return t.find(s=>s.name===e)}};J.SORT_DEFAULT_DIR={title:"asc",dateAdded:"desc",progress:"desc",rating:"desc",priority:"asc",started:"asc",status:"asc",dateWatched:"desc",timeLeft:"desc",timeWatched:"desc"};var Bt=J;var et=require("obsidian");var Fe=require("obsidian"),dt=class extends Fe.FuzzySuggestModal{constructor(e,t,s){super(e),this.titles=t,this.onSelect=s,this.setPlaceholder("Search for a title to insert...")}getItems(){return this.titles}getItemText(e){return`${e.title} (${e.type})`}onChooseItem(e){this.onSelect(e)}};var ft=require("obsidian");function Mt(h){return"malId"in h}var Wt=class extends ft.Modal{constructor(t,s,i,a){super(t);this.selectedType="Anime";this.searchQuery="";this.searchResults=[];this.isSearching=!1;this.autoSearch=!1;this.fieldTitle="";this.fieldEpisodes=0;this.fieldDuration=0;this.fieldReleaseDate="";this.fieldLink="";this.fieldSeasons=[];this.resultsEl=null;this.formEl=null;this.searchDebounce=null;this.plugin=s,this.dataManager=i,this.onAdded=a}onOpen(){this.titleEl.setText("Add to maybe"),this.contentEl.addClass("wl-add-modal"),this.buildUI()}onClose(){this.contentEl.empty(),this.searchDebounce&&window.clearTimeout(this.searchDebounce)}buildUI(){let t=this.contentEl;t.empty();let s=t.createDiv({cls:"wl-modal-row"});s.createSpan({cls:"wl-modal-label",text:"Type"});let i=s.createEl("select",{cls:"wl-select"});for(let r of this.plugin.settings.types){let o=i.createEl("option",{text:r.name,value:r.name});r.name===this.selectedType&&(o.selected=!0)}i.addEventListener("change",()=>{this.selectedType=i.value,this.searchResults=[],this.renderResults(),this.renderForm()});let a=t.createDiv({cls:"wl-modal-row"});a.createSpan({cls:"wl-modal-label",text:"Search"});let l=a.createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Search for a title..."}});l.value=this.searchQuery,l.addEventListener("input",()=>{this.searchQuery=l.value,this.searchDebounce&&window.clearTimeout(this.searchDebounce),this.searchDebounce=window.setTimeout(()=>void this.performSearch(),600)}),a.createEl("button",{cls:"wl-btn",text:"Search"}).addEventListener("click",()=>void this.performSearch()),this.resultsEl=t.createDiv({cls:"wl-modal-results"}),this.formEl=t.createDiv({cls:"wl-modal-form"}),this.renderForm()}async performSearch(){var t;if(this.searchQuery.trim()){this.isSearching=!0,this.renderResults();try{let s=this.plugin.apiService,i=(t=this.plugin.settings.activeApi)!=null?t:"OMDb",a=this.selectedType==="Anime",l=this.selectedType==="Movie";a?this.searchResults=await s.searchAnime(this.searchQuery):i==="TMDB"?this.searchResults=await s.searchTmdb(this.searchQuery,l?"movie":"series"):this.searchResults=await s.searchOmdb(this.searchQuery,l?"movie":"series")}catch(s){new ft.Notice("Search failed. Check your connection and API settings."),this.searchResults=[]}finally{this.isSearching=!1,this.renderResults()}}}renderResults(){if(this.resultsEl){if(this.resultsEl.empty(),this.isSearching){this.resultsEl.createDiv({cls:"wl-modal-loading",text:"Searching..."});return}this.searchResults.length&&this.searchResults.forEach((t,s)=>{let i=this.resultsEl.createDiv({cls:"wl-result-item"}),a=Mt(t)?`${t.episodes} eps`:t.episodes>0?`${t.episodes} eps`:t.mediaType;i.createDiv({cls:"wl-result-title",text:t.title}),i.createDiv({cls:"wl-result-meta",text:`${a} \xB7 ${t.releaseDate}`}),i.dataset.idx=String(s),i.addEventListener("click",()=>void this.selectResult(t,i))})}}async selectResult(t,s){var l;if(this.resultsEl)for(let n of Array.from(this.resultsEl.children))n.removeClass("is-selected");s.addClass("is-selected");let i=this.plugin.apiService,a=(l=this.plugin.settings.activeApi)!=null?l:"OMDb";try{if(!Mt(t)&&t.mediaType==="tv"){let n=a==="TMDB"?await i.getTmdbTvDetails(t.imdbId):await i.getOmdbTvDetails(t.imdbId);n&&(this.fieldTitle=n.title,this.fieldEpisodes=n.episodes,this.fieldDuration=n.episodeDuration,this.fieldReleaseDate=n.releaseDate,this.fieldLink=n.url,this.fieldSeasons=n.seasons)}else if(!Mt(t)&&t.mediaType==="movie"){let n=a==="TMDB"?await i.getTmdbMovieDetails(t.imdbId):await i.getOmdbMovieDetails(t.imdbId);n&&(this.fieldTitle=n.title,this.fieldEpisodes=n.episodes,this.fieldDuration=n.episodeDuration,this.fieldReleaseDate=n.releaseDate,this.fieldLink=n.url,this.fieldSeasons=n.seasons)}else{let n=t;this.fieldTitle=n.title,this.fieldEpisodes=n.episodes,this.fieldDuration=n.duration,this.fieldReleaseDate=n.releaseDate,this.fieldLink=n.url,this.fieldSeasons=n.seasons}}catch(n){this.fieldTitle=t.title,this.fieldEpisodes=(Mt(t),t.episodes),this.fieldDuration=Mt(t)?t.duration:t.episodeDuration,this.fieldReleaseDate=t.releaseDate,this.fieldLink=t.url,this.fieldSeasons=t.seasons}this.renderForm()}renderForm(){if(!this.formEl)return;this.formEl.empty();let t=v=>{let w=this.formEl.createDiv({cls:"wl-modal-row"});return w.createSpan({cls:"wl-modal-label",text:v}),w},i=t("Title").createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Title name"}});i.value=this.fieldTitle,i.addEventListener("input",()=>{this.fieldTitle=i.value});let l=t("Episodes").createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"0",placeholder:"0"}});l.value=String(this.fieldEpisodes),l.addEventListener("input",()=>{this.fieldEpisodes=parseInt(l.value)||0});let r=t("Ep. duration (min)").createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"0",placeholder:"24"}});r.value=String(this.fieldDuration),r.addEventListener("input",()=>{this.fieldDuration=parseInt(r.value)||0});let c=t("Release date").createDiv({cls:"wl-modal-input-stack"}),d=c.createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Date (dd-mm-yyyy or yyyy-mm-dd)"}});d.value=this.fieldReleaseDate;let u=c.createDiv({cls:"wl-modal-error wl-hidden"});d.addEventListener("change",()=>{let v=d.value.trim();if(!v){this.fieldReleaseDate="",u.addClass("wl-hidden");return}let w=pt(v);w?(this.fieldReleaseDate=w,d.value=w,u.addClass("wl-hidden")):(this.fieldReleaseDate=v,u.textContent="Unrecognised format.",u.removeClass("wl-hidden"))});let p=t("External link").createEl("input",{cls:"wl-modal-input",attr:{type:"url",placeholder:"https://example.com"}});p.value=this.fieldLink,p.addEventListener("input",()=>{this.fieldLink=p.value}),this.formEl.createDiv({cls:"wl-modal-btn-row"}).createEl("button",{cls:"wl-btn wl-btn-primary",text:"Add to maybe"}).addEventListener("click",()=>void this.addMaybeTitle())}async addMaybeTitle(){let t=this.fieldTitle.trim();if(!t){new ft.Notice("Please enter a title name.");return}let s={id:this.dataManager.generateMaybeId(t),title:t,type:this.selectedType,releaseDate:this.fieldReleaseDate||null,externalLink:this.fieldLink,totalEpisodes:this.fieldEpisodes,episodeDuration:this.fieldDuration,dateAdded:new Date().toISOString()};await this.dataManager.addMaybeTitle(s),new ft.Notice(`"${t}" added to Maybe.`),this.close(),this.onAdded()}};function ie(h){if(h<30)return`in ${h} days`;let e=Math.round(h/30);return`in ${h} days (${e} month${e!==1?"s":""})`}function Ft(h){if(!h.releaseTime)return!0;let e=new Date,[t,s]=h.releaseTime.split(":"),i=parseInt(t!=null?t:"0"),a=parseInt(s!=null?s:"0");return e.getHours()>i||e.getHours()===i&&e.getMinutes()>=a}function ae(h,e,t){let s=new Date,i=new Date(s.getFullYear(),s.getMonth(),s.getDate()),a=`${s.getFullYear()}-${String(s.getMonth()+1).padStart(2,"0")}-${String(s.getDate()).padStart(2,"0")}`,l=t===a;if(h.recurrence==="once"){if(!h.releaseDate)return{kind:"future",label:"\u2014",daysUntil:9999};let n=new Date(h.releaseDate+"T00:00:00"),r=new Date(n.getFullYear(),n.getMonth(),n.getDate());if(r.getTime()<i.getTime())return e?{kind:"due",label:"Due",daysUntil:0}:{kind:"aired",label:"Aired",daysUntil:0};if(r.getTime()===i.getTime())return Ft(h)?e?{kind:"due",label:"Due",daysUntil:0}:{kind:"aired",label:"Aired",daysUntil:0}:{kind:"today-before",label:"Today",daysUntil:0};let o=Math.round((r.getTime()-i.getTime())/864e5);return o===1?{kind:"future",label:"Tomorrow",daysUntil:1}:{kind:"future",label:ie(o),daysUntil:o}}if(h.recurrence==="daily"){let n=Ft(h);return n&&l?{kind:"future",label:"Tomorrow",daysUntil:1}:n?{kind:"aired",label:"Aired",daysUntil:0}:{kind:"today-before",label:"Today",daysUntil:0}}if(h.recurrence==="weekly"&&h.dayOfWeek!==void 0){let n=s.getDay(),r=n===h.dayOfWeek;if(r&&!l)return Ft(h)?{kind:"aired",label:"Aired",daysUntil:0}:{kind:"today-before",label:"Today",daysUntil:0};let o=r&&l?7:(h.dayOfWeek-n+7)%7;return o===0&&(o=7),o===1?{kind:"future",label:"Tomorrow",daysUntil:1}:{kind:"future",label:ie(o),daysUntil:o}}if(h.recurrence==="monthly"&&h.dayOfMonth){let r=s.getDate()===h.dayOfMonth;if(r&&!l)return Ft(h)?{kind:"aired",label:"Aired",daysUntil:0}:{kind:"today-before",label:"Today",daysUntil:0};let o;r&&l?o=new Date(s.getFullYear(),s.getMonth()+1,h.dayOfMonth):(o=new Date(s.getFullYear(),s.getMonth(),h.dayOfMonth),o.getTime()<=i.getTime()&&(o=new Date(s.getFullYear(),s.getMonth()+1,h.dayOfMonth)));let c=Math.round((o.getTime()-i.getTime())/864e5);return c===1?{kind:"future",label:"Tomorrow",daysUntil:1}:{kind:"future",label:ie(c),daysUntil:c}}return{kind:"future",label:"\u2014",daysUntil:9999}}function ps(h){let e=new Date(h+"T00:00:00"),t=new Date;t.setHours(0,0,0,0);let s=Math.floor((t.getTime()-e.getTime())/864e5);return s<=0?"Today":s===1?"Yesterday":`${s} days ago`}var wt=class h{constructor(e,t,s,i){this.currentSubTab="tracker";this.selectionMode=!1;this.selectedItems=new Set;this.searchQuery="";this.container=e,this.plugin=t,this.dataManager=s,this.onCountChange=i}static getMaybeDueCount(e){let t=new Date;t.setHours(0,0,0,0);let s=t.toISOString().split("T")[0];return e.getMaybeTitles().filter(i=>!!i.releaseDate&&i.releaseDate<=s).length}static getAiredDueCount(e){let t=e.getAirtimeEntries(),s=e.getTitles(),i=0;for(let a of t){let l=s.find(o=>o.id===a.titleId);if(!l)continue;let n=l.totalEpisodes<=1,r=ae(a.schedule,n,a.lastAcknowledgedDate);(r.kind==="aired"||r.kind==="due")&&i++}return i}render(){this.container.empty(),this.container.addClass("wl-airtime"),this.onCountChange&&this.onCountChange(h.getAiredDueCount(this.dataManager)+h.getMaybeDueCount(this.dataManager)),this.renderInnerTabBar(),this.currentSubTab==="tracker"?this.renderTracker():this.currentSubTab==="history"?this.renderHistory():this.renderMaybe()}renderInnerTabBar(){let e=this.container.createDiv({cls:"wl-inner-tab-bar"}),t=h.getAiredDueCount(this.dataManager),s=h.getMaybeDueCount(this.dataManager),i=[{key:"tracker",label:"Tracker",badge:t},{key:"history",label:"History",badge:0},{key:"maybe",label:"Maybe",badge:s}];for(let{key:a,label:l,badge:n}of i){let r=n>0?`${l} (${n})`:l;e.createEl("button",{cls:`wl-inner-tab-btn${this.currentSubTab===a?" is-active":""}`,text:r}).addEventListener("click",()=>{this.currentSubTab!==a&&(this.currentSubTab=a,this.selectionMode=!1,this.selectedItems.clear(),this.searchQuery="",this.render())})}}renderTracker(){this.container.createDiv({cls:"wl-cl-draft-banner",text:'\u26A0 All titles with a release date in the future will be automatically marked as "To be released" in Watchlist and added here.'}),this.renderHeader(),this.renderSearch(),this.renderCards()}renderHistory(){let e=new Date;e.setHours(0,0,0,0);let t=e.toISOString().split("T")[0],s=new Date(e);s.setMonth(s.getMonth()-6);let i=s.toISOString().split("T")[0],a=this.dataManager.getTitles().filter(n=>!!n.releaseDate&&n.releaseDate<=t&&n.releaseDate>=i).sort((n,r)=>{var o,c;return((o=r.releaseDate)!=null?o:"").localeCompare((c=n.releaseDate)!=null?c:"")});if(a.length===0){this.container.createDiv({cls:"wl-empty-state",text:"No past releases yet."});return}let l=this.container.createDiv({cls:"wl-airtime-cards"});for(let n of a)this.renderHistoryCard(l,n)}renderHistoryCard(e,t){let s=e.createDiv({cls:"wl-airtime-card wl-airtime-history-card"}),i=s.createDiv({cls:"wl-airtime-card-left"});i.createDiv({cls:"wl-airtime-card-title",text:t.title});let a=i.createDiv({cls:"wl-airtime-card-meta"}),l=this.plugin.settings.types.find(g=>g.name===t.type),n=this.plugin.settings.coloredTypeBadges,r=a.createSpan({cls:n?"wl-badge wl-badge-sm":"wl-badge-plain",text:t.type});n&&l&&(r.style.backgroundColor=_(t.type,l.color,this.plugin.settings.colorTheme)),t.releaseDate&&a.createSpan({cls:"wl-airtime-schedule",text:t.releaseDate});let o=s.createDiv({cls:"wl-airtime-card-right"});t.releaseDate&&o.createDiv({cls:"wl-airtime-pill wl-airtime-pill-aired",text:ps(t.releaseDate)});let c=o.createDiv({cls:"wl-airtime-actions"}),d=c.createEl("button",{cls:"wl-airtime-action-btn",text:"\u{1F310}"});d.title="Open page",d.addEventListener("click",g=>{g.stopPropagation(),t.externalLink?window.open(t.externalLink,"_blank"):new et.Notice("No external link set for this title.")});let u=c.createEl("button",{cls:"wl-airtime-action-btn wl-airtime-action-btn-delete",text:"\u2715"});u.title="Remove from watchlist",u.addEventListener("click",g=>{g.stopPropagation(),new K(this.plugin.app,`Remove "${t.title}" from Watchlist?`,()=>{this.dataManager.removeTitle(t.id).then(()=>this.render())}).open()})}renderMaybe(){let e=this.dataManager.getMaybeTitles(),t=this.container.createDiv({cls:"wl-list-header"});if(e.length>0&&t.createSpan({cls:"wl-list-count",text:String(e.length)}),t.createDiv({cls:"wl-header-controls"}).createDiv({cls:"wl-add-btn-wrap"}).createEl("button",{cls:"wl-add-btn",text:"+ add"}).addEventListener("click",o=>{o.stopPropagation(),new Wt(this.plugin.app,this.plugin,this.dataManager,()=>this.render()).open()}),e.length===0){this.container.createDiv({cls:"wl-empty-state",text:`No "Maybe" titles yet. Click + Add to track something you're considering.`});return}let l=new Date;l.setHours(0,0,0,0);let n=this.container.createDiv({cls:"wl-airtime-cards"}),r=[...e].sort((o,c)=>{var g,p;let d=(g=o.releaseDate)!=null?g:"",u=(p=c.releaseDate)!=null?p:"";return!d&&!u?o.dateAdded.localeCompare(c.dateAdded):d?u?d.localeCompare(u):-1:1});for(let o of r)this.renderMaybeCard(n,o,l)}renderMaybeCard(e,t,s){var v;let i={recurrence:"once",releaseDate:(v=t.releaseDate)!=null?v:void 0},a=ae(i,!0),l="wl-maybe-card";a.kind==="due"&&(l+=" wl-airtime-card-aired is-due");let n=e.createDiv({cls:l}),r=n.createDiv({cls:"wl-maybe-card-left"});r.createSpan({cls:"wl-maybe-card-title",text:t.title});let o=this.plugin.settings.types.find(w=>w.name===t.type),c=this.plugin.settings.coloredTypeBadges,d=r.createSpan({cls:c?"wl-badge wl-badge-sm":"wl-badge-plain",text:t.type});c&&o&&(d.style.backgroundColor=_(t.type,o.color,this.plugin.settings.colorTheme));let u={"today-before":"wl-airtime-pill wl-airtime-pill-today-series",future:"wl-airtime-pill wl-airtime-pill-days",aired:"wl-airtime-pill wl-airtime-pill-aired",due:"wl-airtime-pill wl-airtime-pill-due"},g=a.kind==="future"&&a.label==="Tomorrow"?"wl-airtime-pill wl-airtime-pill-tomorrow":u[a.kind],p=n.createDiv({cls:"wl-maybe-card-right"});p.createDiv({cls:g,text:t.releaseDate?a.label:"\u2014"});let m=p.createEl("button",{cls:"wl-airtime-action-btn",text:"\u{1F310}"});m.title="Open page",m.addEventListener("click",w=>{w.stopPropagation(),t.externalLink?window.open(t.externalLink,"_blank"):new et.Notice("No external link set.")});let f=p.createEl("button",{cls:"wl-airtime-action-btn wl-airtime-action-btn-delete",text:"\u2715"});f.title="Remove from maybe",f.addEventListener("click",w=>{w.stopPropagation(),new K(this.plugin.app,`Remove "${t.title}" from Maybe?`,()=>{this.dataManager.removeMaybeTitle(t.id).then(()=>this.render())}).open()})}renderSearch(){let t=this.container.createDiv({cls:"wl-search-wrap"}).createEl("input",{cls:"wl-search-input",attr:{type:"text",placeholder:"Search upcoming..."}});t.value=this.searchQuery,t.addEventListener("input",()=>{this.searchQuery=t.value,this.rerenderCards()})}rerenderCards(){let e=this.container.querySelector(".wl-airtime-cards"),t=this.container.querySelector(".wl-empty-state");e&&e.remove(),t&&t.remove(),this.renderCards()}renderHeader(){let e=this.container.createDiv({cls:"wl-list-header"}),t=this.dataManager.getTitles(),s=this.dataManager.getAirtimeEntries().filter(r=>t.some(o=>o.id===r.titleId)).length;s>0&&e.createSpan({cls:"wl-list-count",text:String(s)});let i=e.createDiv({cls:"wl-header-controls"});if(this.selectionMode&&this.selectedItems.size>0&&this.renderActionBar(i),this.selectionMode){let r=this.selectedItems.size>0,o=i.createEl("button",{cls:"wl-btn wl-btn-sm",text:r?"None":"All"});o.title=r?"Deselect all":"Select all visible",o.addEventListener("click",()=>{if(this.selectedItems.size>0)this.selectedItems.clear();else for(let c of this.dataManager.getAirtimeEntries())this.selectedItems.add(c.id);this.render()})}i.createEl("button",{cls:`wl-btn wl-btn-sm${this.selectionMode?" is-active":""}`,text:"Select"}).addEventListener("click",()=>{this.selectionMode=!this.selectionMode,this.selectedItems.clear(),this.render()}),i.createDiv({cls:"wl-add-btn-wrap"}).createEl("button",{cls:"wl-add-btn",text:"+ add"}).addEventListener("click",r=>{r.stopPropagation(),this.openAddFlow()})}renderActionBar(e){let s=e.createDiv({cls:"wl-action-bar"}).createEl("button",{cls:"wl-group-action-btn wl-group-action-btn-delete",text:"\u2715"});s.title="Remove from upcoming",s.addEventListener("click",i=>{i.stopPropagation();let a=this.selectedItems.size;new K(this.plugin.app,`Remove ${a} selected item${a!==1?"s":""} from Upcoming? This cannot be undone.`,()=>{(async()=>{for(let l of Array.from(this.selectedItems))await this.dataManager.removeAirtimeEntry(l);this.selectedItems.clear(),this.render()})()}).open()})}openAddFlow(){let e=this.dataManager.getTitles();if(e.length===0){new et.Notice("No titles in your watchlog library yet.");return}let t=this.dataManager.getAirtimeEntries(),s=new Set(t.map(a=>a.titleId)),i=e.filter(a=>!s.has(a.id));if(i.length===0){new et.Notice("All titles are already in upcoming.");return}new dt(this.plugin.app,i,a=>{this.startAddWithTitle(a)}).open()}async startAddWithTitle(e){let t=null;if(e.type==="Anime"&&e.malId)try{let s=await this.plugin.apiService.getAnimeScheduleByMalId(e.malId);s&&(t={recurrence:"weekly",dayOfWeek:s.dayOfWeek,releaseTime:s.time},new et.Notice("Schedule auto-filled from myanimelist."))}catch(s){}!t&&e.releaseDate&&/^\d{4}-\d{2}-\d{2}$/.test(e.releaseDate)&&(t={recurrence:"once",releaseDate:e.releaseDate}),new Ot(this.plugin.app,e,t,null,null,null,null,async(s,i,a,l,n)=>{var c;let r={id:this.dataManager.generateAirtimeId(e.id),titleId:e.id,schedule:s,currentSeason:i!=null?i:void 0,currentEpisode:a!=null?a:void 0,totalSeasons:l!=null?l:void 0,totalEpisodes:n!=null?n:void 0,dateAdded:new Date().toISOString()};await this.dataManager.addAirtimeEntry(r);let o=this.dataManager.getTitle(e.id);if(o){let d=!1;if(n!==null&&n!==o.totalEpisodes&&(o.totalEpisodes=n,d=!0),s.recurrence==="once"){let u=(c=s.releaseDate)!=null?c:null;o.releaseDate!==u&&(o.releaseDate=u,d=!0)}d&&await this.dataManager.updateTitle(o)}this.render()}).open()}renderCards(){let e=this.dataManager.getAirtimeEntries();if(e.length===0){this.container.createDiv({cls:"wl-empty-state",text:"No titles in Upcoming. Click + Add to track airing schedules."});return}let t=this.dataManager.getTitles(),s=this.searchQuery.trim().toLowerCase(),i=e.map(l=>{let n=t.find(c=>c.id===l.titleId);if(!n||s&&!n.title.toLowerCase().includes(s))return null;let r=n.totalEpisodes<=1,o=ae(l.schedule,r,l.lastAcknowledgedDate);return{entry:l,title:n,countdown:o}}).filter(l=>l!==null).sort((l,n)=>{let r=d=>d==="aired"||d==="due"?0:d==="today-before"?1:2,o=r(l.countdown.kind),c=r(n.countdown.kind);return o!==c?o-c:l.countdown.daysUntil-n.countdown.daysUntil});if(i.length===0){this.container.createDiv({cls:"wl-empty-state",text:"No upcoming titles match your search."});return}let a=this.container.createDiv({cls:"wl-airtime-cards"});for(let{entry:l,title:n,countdown:r}of i)this.renderCard(a,l,n,r)}renderCard(e,t,s,i){var E;let a=s.totalEpisodes<=1,l=(E=t.totalEpisodes)!=null?E:s.totalEpisodes,n=!a&&t.currentEpisode!==void 0&&l>0&&t.currentEpisode>=l,r=a&&i.kind==="due"||!a&&i.kind==="aired",o="wl-airtime-card";(i.kind==="aired"||i.kind==="due")&&(o+=" wl-airtime-card-aired"),i.kind==="aired"&&(o+=" is-aired"),i.kind==="due"&&(o+=" is-due"),this.selectionMode&&this.selectedItems.has(t.id)&&(o+=" wl-row-selected");let c=e.createDiv({cls:o});if(this.selectionMode){let b=c.createEl("input",{attr:{type:"checkbox"}});b.addClass("wl-airtime-select-cb"),b.checked=this.selectedItems.has(t.id),b.addEventListener("click",x=>x.stopPropagation()),b.addEventListener("change",()=>{b.checked?this.selectedItems.add(t.id):this.selectedItems.delete(t.id),this.render()}),c.addEventListener("click",()=>{this.selectedItems.has(t.id)?this.selectedItems.delete(t.id):this.selectedItems.add(t.id),this.render()})}let d=c.createDiv({cls:"wl-airtime-card-left"});d.createDiv({cls:"wl-airtime-card-title",text:s.title});let u=d.createDiv({cls:"wl-airtime-card-meta"}),g=this.plugin.settings.types.find(b=>b.name===s.type),p=this.plugin.settings.coloredTypeBadges,m=u.createSpan({cls:p?"wl-badge wl-badge-sm":"wl-badge-plain",text:s.type});if(p&&g&&(m.style.backgroundColor=_(s.type,g.color,this.plugin.settings.colorTheme)),u.createSpan({cls:"wl-airtime-schedule",text:Ae(t.schedule)}),!a&&(t.currentSeason!==void 0||t.currentEpisode!==void 0)){let b=[];t.currentSeason!==void 0&&b.push(`Season ${t.currentSeason}`),t.currentEpisode!==void 0&&b.push(`Episode ${t.currentEpisode}`),b.push("Airing next");let x=n?"wl-ep-badge wl-ep-badge-final":"wl-ep-badge";d.createDiv({cls:x,text:b.join(" \xB7 ")})}let f=c.createDiv({cls:"wl-airtime-card-right"}),v={"today-before":"wl-airtime-pill wl-airtime-pill-today-series",future:"wl-airtime-pill wl-airtime-pill-days",aired:"wl-airtime-pill wl-airtime-pill-aired",due:"wl-airtime-pill wl-airtime-pill-due"},w=i.kind==="future"&&i.label==="Tomorrow"?"wl-airtime-pill wl-airtime-pill-tomorrow":v[i.kind];f.createDiv({cls:w,text:i.label});let y=f.createDiv({cls:"wl-airtime-actions"});if(r){let b=y.createEl("button",{cls:"wl-airtime-action-btn wl-airtime-action-btn-tick",text:"\u2713"});b.title=a?"Mark as released":n?"Final episode \u2014 mark done":"Mark episode as aired",b.addEventListener("click",x=>{var M;x.stopPropagation();let k=a?`"${s.title}" has been released. -Remove from Upcoming and set status to Plan to watch?`:n?`Final episode of "${s.title}" aired. -Remove from Upcoming? (Watchlist status unchanged)`:`Episode ${(M=t.currentEpisode)!=null?M:""} of "${s.title}" aired. -Mark and track next episode?`;new K(this.plugin.app,k,()=>{(async()=>{var N;let C=new Date,L=`${C.getFullYear()}-${String(C.getMonth()+1).padStart(2,"0")}-${String(C.getDate()).padStart(2,"0")}`;if(this.plugin.markAirtimeHandled(`${t.id}-${L}`),a){await this.dataManager.removeAirtimeEntry(t.id);let W=this.dataManager.getTitle(s.id);W&&W.status==="To be released"&&(W.status="Plan to watch",await this.dataManager.updateTitle(W))}else n?await this.dataManager.removeAirtimeEntry(t.id):(t.currentEpisode=((N=t.currentEpisode)!=null?N:1)+1,t.lastAcknowledgedDate=L,await this.dataManager.updateAirtimeEntry(t));this.render()})()}).open()})}let T=y.createEl("button",{cls:"wl-airtime-action-btn",text:"\u{1F310}"});T.title="Open page",T.addEventListener("click",b=>{b.stopPropagation(),s.externalLink?window.open(s.externalLink,"_blank"):new et.Notice("No external link set for this title.")});let D=y.createEl("button",{cls:"wl-airtime-action-btn",text:"\u270F"});D.title="Edit schedule",D.addEventListener("click",b=>{var x,k,M,C;b.stopPropagation(),new Ot(this.plugin.app,s,{...t.schedule},(x=t.currentSeason)!=null?x:null,(k=t.currentEpisode)!=null?k:null,(M=t.totalSeasons)!=null?M:null,(C=t.totalEpisodes)!=null?C:null,async(L,N,W,$,A)=>{var U;t.schedule=L,t.currentSeason=N!=null?N:void 0,t.currentEpisode=W!=null?W:void 0,t.totalSeasons=$!=null?$:void 0,t.totalEpisodes=A!=null?A:void 0,await this.dataManager.updateAirtimeEntry(t);let I=this.dataManager.getTitle(t.titleId);if(I){let H=!1;if(A!==null&&A!==I.totalEpisodes&&(I.totalEpisodes=A,H=!0),L.recurrence==="once"){let O=(U=L.releaseDate)!=null?U:null;I.releaseDate!==O&&(I.releaseDate=O,H=!0)}H&&await this.dataManager.updateTitle(I)}this.render()}).open()});let S=y.createEl("button",{cls:"wl-airtime-action-btn wl-airtime-action-btn-delete",text:"\u2715"});S.title="Remove from upcoming",S.addEventListener("click",b=>{b.stopPropagation(),new K(this.plugin.app,`Remove "${s.title}" from Upcoming?`,()=>{this.dataManager.removeAirtimeEntry(t.id).then(()=>this.render())}).open()})}},Ot=class extends et.Modal{constructor(e,t,s,i,a,l,n,r){super(e),this.title=t;let o=t.totalEpisodes<=1;this.schedule=s?{...s}:{recurrence:o?"once":"weekly"},!this.schedule.releaseDate&&t.releaseDate&&/^\d{4}-\d{2}-\d{2}$/.test(t.releaseDate)&&(this.schedule.releaseDate=t.releaseDate),this.currentSeason=i,this.currentEpisode=a,this.totalSeasons=l!=null?l:t.seasons.length>0?t.seasons.length:null,this.totalEpisodes=n!=null?n:t.totalEpisodes>0?t.totalEpisodes:null,this.onSave=r}onOpen(){this.titleEl.setText("Set schedule"),this.contentEl.addClass("wl-add-modal"),this.renderForm()}onClose(){this.contentEl.empty()}localHHMMtoJST(e){if(!e||!/^\d{2}:\d{2}$/.test(e))return"\u2014";let[t,s]=e.split(":"),i=parseInt(t!=null?t:"0"),a=parseInt(s!=null?s:"0"),l=new Date,r=new Date(l.getFullYear(),l.getMonth(),l.getDate(),i,a,0,0).getTime()+9*60*60*1e3,o=new Date(r),c=o.getUTCHours().toString().padStart(2,"0"),d=o.getUTCMinutes().toString().padStart(2,"0");return`${c}:${d}`}renderForm(){this.contentEl.empty(),this.contentEl.addClass("wl-add-modal");let e=this.contentEl,t=this.title.totalEpisodes<=1,s=r=>{let o=e.createDiv({cls:"wl-modal-row"});return o.createSpan({cls:"wl-modal-label",text:r}),o},i=(r,o)=>{var p;let c=o.createDiv({cls:"wl-modal-row wl-modal-row-mt"});c.createSpan({cls:"wl-modal-label",text:r});let d=c.createEl("input",{cls:"wl-modal-input wl-time-input",attr:{type:"text",placeholder:"Hh:mm",maxlength:"5"}});d.value=(p=this.schedule.releaseTime)!=null?p:"";let u=c.createSpan({cls:"wl-jst-clock"}),g=()=>{u.textContent=d.value?`JST ${this.localHHMMtoJST(d.value)}`:"JST \u2014"};return g(),d.addEventListener("input",()=>{let m=d.value.trim();this.schedule.releaseTime=m||void 0,g()}),d};if(t){let o=s("Release date").createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Dd/mm/yyyy",maxlength:"10"}});o.value=this.schedule.releaseDate?this.schedule.releaseDate.split("-").reverse().join("/"):"",o.addEventListener("change",()=>{let c=o.value.split("/");c.length===3&&(this.schedule.recurrence="once",this.schedule.releaseDate=`${c[2]}-${c[1]}-${c[0]}`)}),i("Time (optional)",e)}else{let o=s("Recurrence").createEl("select",{cls:"wl-select"}),c=[["once","Once"],["daily","Daily"],["weekly","Weekly"],["monthly","Monthly"]];for(let[D,S]of c){let E=o.createEl("option",{text:S,value:D});D===this.schedule.recurrence&&(E.selected=!0)}let d=e.createDiv(),u=()=>{var S;d.empty();let D=this.schedule.recurrence;if(D==="once"){let E=d.createDiv({cls:"wl-modal-row"});E.createSpan({cls:"wl-modal-label",text:"Date"});let b=E.createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Dd/mm/yyyy",maxlength:"10"}});b.value=this.schedule.releaseDate?this.schedule.releaseDate.split("-").reverse().join("/"):"",b.addEventListener("change",()=>{let x=b.value.split("/");x.length===3&&(this.schedule.releaseDate=`${x[2]}-${x[1]}-${x[0]}`)})}if(D==="weekly"){let E=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],b=d.createDiv({cls:"wl-modal-row"});b.createSpan({cls:"wl-modal-label",text:"Day of week"});let x=b.createEl("select",{cls:"wl-select"});E.forEach((k,M)=>{var L;let C=x.createEl("option",{text:k,value:String(M)});M===((L=this.schedule.dayOfWeek)!=null?L:6)&&(C.selected=!0)}),x.addEventListener("change",()=>{this.schedule.dayOfWeek=parseInt(x.value)})}if(D==="monthly"){let E=d.createDiv({cls:"wl-modal-row"});E.createSpan({cls:"wl-modal-label",text:"Day of month"});let b=E.createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"1",max:"31",placeholder:"1"}});b.value=String((S=this.schedule.dayOfMonth)!=null?S:1),b.addEventListener("input",()=>{this.schedule.dayOfMonth=parseInt(b.value)||1})}i("Time (HH:MM)",d)};o.addEventListener("change",()=>{this.schedule.recurrence=o.value,u()}),u();let p=s("Current season").createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"1",placeholder:"E.g. 2"}});this.currentSeason!==null&&(p.value=String(this.currentSeason)),p.addEventListener("input",()=>{this.currentSeason=parseInt(p.value)||null});let f=s("Current episode").createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"1",placeholder:"E.g. 7"}});this.currentEpisode!==null&&(f.value=String(this.currentEpisode)),f.addEventListener("input",()=>{this.currentEpisode=parseInt(f.value)||null});let w=s("Total seasons").createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"1",placeholder:"E.g. 3"}});this.totalSeasons!==null&&(w.value=String(this.totalSeasons)),w.addEventListener("input",()=>{this.totalSeasons=parseInt(w.value)||null});let T=s("Total episodes").createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"0",placeholder:"E.g. 13"}});this.totalEpisodes!==null&&(T.value=String(this.totalEpisodes)),T.addEventListener("input",()=>{this.totalEpisodes=parseInt(T.value)||null}),e.createDiv({cls:"wl-modal-info wl-schedule-hint",text:"Titles with 0 or 1 total episodes will be treated as a single release date, like a movie."})}let a=e.createDiv({cls:"wl-modal-btn-row"});a.createEl("button",{cls:"wl-btn wl-btn-mr",text:"Cancel"}).addEventListener("click",()=>this.close()),a.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Save"}).addEventListener("click",()=>{(async()=>{if(this.schedule.releaseTime&&!/^\d{2}:\d{2}$/.test(this.schedule.releaseTime)){new et.Notice("Time must be in 24h format: hh:mm (e.g. 14:50)");return}await this.onSave(this.schedule,this.currentSeason,this.currentEpisode,this.totalSeasons,this.totalEpisodes),this.close()})()})}};var P=require("obsidian");var vt=class{constructor(e,t){this.app=e;this.plugin=t}get folderPath(){return this.plugin.settings.customListsFolder||"WatchLog/CustomLists"}async ensureFolder(){let e=(0,P.normalizePath)(this.folderPath);if(!this.app.vault.getAbstractFileByPath(e))try{await this.app.vault.createFolder(e)}catch(t){}}listNames(){let e=(0,P.normalizePath)(this.folderPath);return this.app.vault.getFiles().filter(t=>{var i,a;return((a=(i=t.parent)==null?void 0:i.path)!=null?a:"")===e&&t.extension==="md"}).map(t=>t.basename).sort((t,s)=>t.localeCompare(s))}async loadList(e){let t=(0,P.normalizePath)(`${this.folderPath}/${e}.md`),s=this.app.vault.getAbstractFileByPath(t);if(!(s instanceof P.TFile))return null;try{let i=await this.app.vault.read(s);return this.parse(e,i)}catch(i){return null}}parse(e,t){var r;let s=t.match(/## Notes\n([\s\S]*?)(?=\n## |\s*$)/),i=((r=s==null?void 0:s[1])!=null?r:"").trim(),a=t.match(/## Data\n```json\n([\s\S]*?)\n```/),l=[],n=[];if(a!=null&&a[1])try{let o=JSON.parse(a[1]);Array.isArray(o.columns)&&(l=o.columns.filter(c=>typeof c=="object"&&c!==null&&"id"in c&&"label"in c&&"type"in c)),Array.isArray(o.rows)&&(n=o.rows.filter(c=>typeof c=="object"&&c!==null&&"id"in c))}catch(o){}return{name:e,columns:l,rows:n,notes:i}}async saveList(e){await this.ensureFolder();let t=(0,P.normalizePath)(`${this.folderPath}/${e.name}.md`),s=this.serialize(e),i=this.app.vault.getAbstractFileByPath(t);try{i instanceof P.TFile?await this.app.vault.modify(i,s):await this.app.vault.create(t,s)}catch(a){console.error("WatchLog: failed to save custom list",a)}}async saveNotes(e,t){let s=(0,P.normalizePath)(`${this.folderPath}/${e}.md`),i=this.app.vault.getAbstractFileByPath(s);if(i instanceof P.TFile)try{let a=await this.app.vault.read(i),l=`## Notes -`,n=` -## Data -`,r=a.indexOf(l);if(r===-1)return;let o=r+l.length,c=a.indexOf(n,o),d=c!==-1?a.slice(0,o)+t+a.slice(c):a.slice(0,o)+t+` -`;await this.app.vault.modify(i,d)}catch(a){console.error("WatchLog: failed to save notes",a)}}serialize(e){let t=JSON.stringify({columns:e.columns,rows:e.rows},null,2);return`# ${e.name} +${s.notes} +`}async deleteMarkdownFile(s){let t=this.plugin.settings.rootFolder,e=s.title.replace(/[*"\\/<>:|?]/g,"-"),i=(0,Z.normalizePath)(`${t}/${s.type}/${e}.md`),a=this.app.vault.getAbstractFileByPath(i);a instanceof Z.TFile&&await this.app.fileManager.trashFile(a)}getRecentlyWatched(s){var t;return[...(t=this.data.titles)!=null?t:[]].filter(e=>e.watchedEpisodes.length>0||e.status==="Completed").sort((e,i)=>i.dateModified.localeCompare(e.dateModified)).slice(0,s)}getRecentlyAdded(s){var t;return[...(t=this.data.titles)!=null?t:[]].sort((e,i)=>i.dateAdded.localeCompare(e.dateAdded)).slice(0,s)}getStatsByType(s){var r;let t=["Dropped","To be released"],e=(r=this.data.titles)!=null?r:[],i=(s==="All"?e:e.filter(o=>o.type===s)).filter(o=>!t.includes(o.status)),a=i.length;return{watched:i.filter(o=>o.status==="Completed").length,total:a}}getCompletedCount(){var s;return((s=this.data.titles)!=null?s:[]).filter(t=>t.status==="Completed").length}generateId(s){var a;let t=s.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,""),e=((a=this.data.titles)!=null?a:[]).map(n=>n.id);if(!e.includes(t))return t;let i=2;for(;e.includes(`${t}-${i}`);)i++;return`${t}-${i}`}};var tt=require("obsidian");function Hs(g){return g.replace(/[*"\\/<>:|?]/g,"-").trim()}function Pt(g){return g===""?'""':`"${g.replace(/\\/g,"\\\\").replace(/"/g,'\\"')}"`}function Xe(g){return g.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"")}var qt=class{constructor(s){this.changeListeners=[];this.historyManager=null;this.coverSaveTimer=null;this.COVER_SAVE_DEBOUNCE_MS=5e3;this.lastNotePathById=new Map;this.plugin=s,this.data=this.emptyData()}setHistoryManager(s){this.historyManager=s}emptyData(){return{books:[],manga:[],bookColumns:[],mangaColumns:[],settings:{...Je}}}get filePath(){return(0,tt.normalizePath)(`${this.plugin.app.vault.configDir}/plugins/watchlog/reading.json`)}async load(){var e;try{if(await this.plugin.app.vault.adapter.exists(this.filePath)){let a=await this.plugin.app.vault.adapter.read(this.filePath),n=JSON.parse(a);this.data={books:Array.isArray(n.books)?n.books:[],manga:Array.isArray(n.manga)?n.manga:[],bookColumns:Array.isArray(n.bookColumns)?n.bookColumns:[],mangaColumns:Array.isArray(n.mangaColumns)?n.mangaColumns:[],settings:{...Je,...(e=n.settings)!=null?e:{}}}}else this.data=this.emptyData()}catch(i){console.warn("[WL] ReadingDataManager.load failed:",i),this.data=this.emptyData()}this.migrate()&&await this.saveOnly();let t=this.plugin.dataManager;if(t){for(let i of this.data.books)yt(i.releaseDate)&&await t.autoAddReadingToUpcoming(i,"book");for(let i of this.data.manga)yt(i.releaseDate)&&await t.autoAddReadingToUpcoming(i,"manga")}}migrate(){let s=!1;this.data.settings.defaultStatus==="On Hold"&&(this.data.settings.defaultStatus="Plan to Read",s=!0);for(let t of this.data.books)typeof t.author!="string"&&(t.author="",s=!0),typeof t.rating!="number"&&(t.rating=0,s=!0),typeof t.pagesRead!="number"&&(t.pagesRead=0,s=!0),typeof t.totalPages!="number"&&(t.totalPages=0,s=!0),typeof t.chaptersRead!="number"&&(t.chaptersRead=0,s=!0),typeof t.totalChapters!="number"&&(t.totalChapters=0,s=!0),typeof t.coverUrl!="string"&&(t.coverUrl="",s=!0),typeof t.googleBooksId!="string"&&(t.googleBooksId="",s=!0),typeof t.vaultPage!="string"&&(t.vaultPage="",s=!0),t.dateStarted===void 0&&(t.dateStarted=null,s=!0),t.dateFinished===void 0&&(t.dateFinished=null,s=!0),t.releaseDate===void 0&&(t.releaseDate=null,s=!0),t.status==="On Hold"&&(t.status="Plan to Read",s=!0),t.dateAdded||(t.dateAdded=new Date().toISOString(),s=!0),t.dateModified||(t.dateModified=t.dateAdded,s=!0),(!t.customFields||typeof t.customFields!="object")&&(t.customFields={},s=!0);for(let t of this.data.manga)typeof t.author!="string"&&(t.author="",s=!0),typeof t.rating!="number"&&(t.rating=0,s=!0),typeof t.chaptersRead!="number"&&(t.chaptersRead=0,s=!0),typeof t.totalChapters!="number"&&(t.totalChapters=0,s=!0),typeof t.volumesRead!="number"&&(t.volumesRead=0,s=!0),typeof t.totalVolumes!="number"&&(t.totalVolumes=0,s=!0),typeof t.coverUrl!="string"&&(t.coverUrl="",s=!0),typeof t.malId!="string"&&(t.malId="",s=!0),typeof t.vaultPage!="string"&&(t.vaultPage="",s=!0),t.dateStarted===void 0&&(t.dateStarted=null,s=!0),t.dateFinished===void 0&&(t.dateFinished=null,s=!0),t.releaseDate===void 0&&(t.releaseDate=null,s=!0),t.status==="On Hold"&&(t.status="Plan to Read",s=!0),t.dateAdded||(t.dateAdded=new Date().toISOString(),s=!0),t.dateModified||(t.dateModified=t.dateAdded,s=!0),(!t.customFields||typeof t.customFields!="object")&&(t.customFields={},s=!0);for(let t of this.data.bookColumns)Array.isArray(t.options)||(t.options=[],s=!0),t.color||(t.color="#5F5E5A",s=!0);for(let t of this.data.mangaColumns)Array.isArray(t.options)||(t.options=[],s=!0),t.color||(t.color="#5F5E5A",s=!0);for(let t of this.data.books)this.applyReleaseStatus(t)&&(s=!0);for(let t of this.data.manga)this.applyReleaseStatus(t)&&(s=!0);return s}applyReleaseStatus(s){if(yt(s.releaseDate)){if(s.status!=="To be released")return s.status="To be released",!0}else if(s.status==="To be released")return s.status="Plan to Read",!0;return!1}async syncUpcoming(s,t){let e=this.plugin.dataManager;e&&(yt(s.releaseDate)?await e.autoAddReadingToUpcoming(s,t):await e.removeReadingAirtimeEntries(s.id))}getData(){return this.data}getSettings(){return this.data.settings}async updateSettings(s){this.data.settings={...this.data.settings,...s},await this.save()}async saveOnly(){try{await this.plugin.app.vault.adapter.write(this.filePath,JSON.stringify(this.data,null,2))}catch(s){console.warn("[WL] ReadingDataManager.save failed:",s)}}async save(){await this.saveOnly(),this.notifyListeners()}onChange(s){this.changeListeners.push(s)}offChange(s){this.changeListeners=this.changeListeners.filter(t=>t!==s)}notifyChange(){this.notifyListeners()}async saveAndNotify(){await this.saveOnly(),this.notifyListeners()}async restore(s){await this.plugin.app.vault.adapter.write(this.filePath,JSON.stringify(s,null,2)),await this.load(),this.notifyListeners()}notifyListeners(){for(let s of this.changeListeners)s()}updateCoverUrl(s,t,e){let i=s==="book"?this.getBook(t):this.getManga(t);i&&(i.coverUrl=e,this.coverSaveTimer===null&&(this.coverSaveTimer=window.setTimeout(()=>{this.coverSaveTimer=null,this.saveOnly()},this.COVER_SAVE_DEBOUNCE_MS)))}updateCoverAndSource(s,t,e,i){let a=s==="book"?this.getBook(t):this.getManga(t);a&&(a.coverUrl=e,s==="book"?a.googleBooksId=i:a.malId=i,this.coverSaveTimer===null&&(this.coverSaveTimer=window.setTimeout(()=>{this.coverSaveTimer=null,this.saveOnly()},this.COVER_SAVE_DEBOUNCE_MS)))}flushCoverSave(){this.coverSaveTimer!==null&&(window.clearTimeout(this.coverSaveTimer),this.coverSaveTimer=null,this.saveOnly())}getBooks(){return this.data.books}getBook(s){return this.data.books.find(t=>t.id===s)}async addBook(s){var t;this.applyReleaseStatus(s),this.data.books.push(s),await this.save(),await this.syncUpcoming(s,"book");try{await this.writeReadingNote("book",s)}catch(e){console.warn("[WL] writeReadingNote failed:",e)}(t=this.historyManager)==null||t.log(`${s.title} (Book) was added`,{source:"Reading",action:"added",titleName:s.title})}async addBookSilent(s){this.applyReleaseStatus(s),this.data.books.push(s),await this.saveOnly()}async updateBook(s){var e,i,a;s.dateModified=new Date().toISOString(),this.applyReleaseStatus(s);let t=this.data.books.findIndex(n=>n.id===s.id);if(t>=0){let n=this.data.books[t];if(n.status!==s.status){let r=s.status==="Completed"?"completed":"status";s.status==="Completed"&&this.plugin.settings.setFinishDateAutomatically&&!s.dateFinished&&(s.dateFinished=(e=new Date().toISOString().split("T")[0])!=null?e:null),(i=this.historyManager)==null||i.log(`${s.title} (Book) status changed to ${s.status}`,{source:"Reading",action:r,titleName:s.title})}n.rating!==s.rating&&((a=this.historyManager)==null||a.log(`${s.title} (Book) Rating \u2192 ${s.rating}/5`,{source:"Reading",action:"rating",titleName:s.title})),this.data.books[t]=s,await this.save(),n.releaseDate!==s.releaseDate&&await this.syncUpcoming(s,"book");try{await this.writeReadingNote("book",s)}catch(r){console.warn("[WL] writeReadingNote failed:",r)}}}updateBookSilent(s){s.dateModified=new Date().toISOString(),this.applyReleaseStatus(s);let t=this.data.books.findIndex(e=>e.id===s.id);t>=0&&(this.data.books[t]=s)}async removeBook(s){var e,i;let t=this.getBook(s);if(this.data.books=this.data.books.filter(a=>a.id!==s),await this.save(),await((e=this.plugin.dataManager)==null?void 0:e.removeReadingAirtimeEntries(s)),t){(i=this.historyManager)==null||i.log(`${t.title} (Book) was deleted`,{source:"Reading",action:"deleted",titleName:t.title});try{await this.deleteReadingNote("book",t)}catch(a){console.warn("[WL] deleteReadingNote failed:",a)}}}async addBookBatch(s){var t;for(let e of s)this.applyReleaseStatus(e),this.data.books.push(e);await this.saveOnly();for(let e of s){await this.syncUpcoming(e,"book");try{await this.writeReadingNote("book",e)}catch(i){console.warn("[WL] writeReadingNote failed:",i)}(t=this.historyManager)==null||t.log(`${e.title} (Book) was added`,{source:"Reading",action:"added",titleName:e.title})}}generateBookId(s){let t=Xe(s)||"book",e=new Set(this.data.books.map(a=>a.id));if(!e.has(t))return t;let i=2;for(;e.has(`${t}-${i}`);)i++;return`${t}-${i}`}getMangaList(){return this.data.manga}getManga(s){return this.data.manga.find(t=>t.id===s)}async addManga(s){var t;this.applyReleaseStatus(s),this.data.manga.push(s),await this.save(),await this.syncUpcoming(s,"manga");try{await this.writeReadingNote("manga",s)}catch(e){console.warn("[WL] writeReadingNote failed:",e)}(t=this.historyManager)==null||t.log(`${s.title} (Manga) was added`,{source:"Reading",action:"added",titleName:s.title})}async addMangaSilent(s){this.applyReleaseStatus(s),this.data.manga.push(s),await this.saveOnly()}async updateManga(s){var e,i,a;s.dateModified=new Date().toISOString(),this.applyReleaseStatus(s);let t=this.data.manga.findIndex(n=>n.id===s.id);if(t>=0){let n=this.data.manga[t];if(n.status!==s.status){let r=s.status==="Completed"?"completed":"status";s.status==="Completed"&&this.plugin.settings.setFinishDateAutomatically&&!s.dateFinished&&(s.dateFinished=(e=new Date().toISOString().split("T")[0])!=null?e:null),(i=this.historyManager)==null||i.log(`${s.title} (Manga) status changed to ${s.status}`,{source:"Reading",action:r,titleName:s.title})}n.rating!==s.rating&&((a=this.historyManager)==null||a.log(`${s.title} (Manga) Rating \u2192 ${s.rating}/5`,{source:"Reading",action:"rating",titleName:s.title})),this.data.manga[t]=s,await this.save(),n.releaseDate!==s.releaseDate&&await this.syncUpcoming(s,"manga");try{await this.writeReadingNote("manga",s)}catch(r){console.warn("[WL] writeReadingNote failed:",r)}}}updateMangaSilent(s){s.dateModified=new Date().toISOString(),this.applyReleaseStatus(s);let t=this.data.manga.findIndex(e=>e.id===s.id);t>=0&&(this.data.manga[t]=s)}async removeManga(s){var e,i;let t=this.getManga(s);if(this.data.manga=this.data.manga.filter(a=>a.id!==s),await this.save(),await((e=this.plugin.dataManager)==null?void 0:e.removeReadingAirtimeEntries(s)),t){(i=this.historyManager)==null||i.log(`${t.title} (Manga) was deleted`,{source:"Reading",action:"deleted",titleName:t.title});try{await this.deleteReadingNote("manga",t)}catch(a){console.warn("[WL] deleteReadingNote failed:",a)}}}async removeBooksBatch(s){var i,a;let e=[];for(let n of s){let r=this.getBook(n);r&&e.push(r)}this.data.books=this.data.books.filter(n=>!s.includes(n.id)),await this.save();for(let n of s)await((i=this.plugin.dataManager)==null?void 0:i.removeReadingAirtimeEntries(n));for(let n of e)(a=this.historyManager)==null||a.log(`${n.title} (Book) was deleted`,{source:"Reading",action:"deleted",titleName:n.title});for(let n=0;n<e.length;n+=10){let r=e.slice(n,n+10);await Promise.all(r.map(o=>this.deleteReadingNote("book",o).catch(()=>{})))}}async removeMangaBatch(s){var i,a;let e=[];for(let n of s){let r=this.getManga(n);r&&e.push(r)}this.data.manga=this.data.manga.filter(n=>!s.includes(n.id)),await this.save();for(let n of s)await((i=this.plugin.dataManager)==null?void 0:i.removeReadingAirtimeEntries(n));for(let n of e)(a=this.historyManager)==null||a.log(`${n.title} (Manga) was deleted`,{source:"Reading",action:"deleted",titleName:n.title});for(let n=0;n<e.length;n+=10){let r=e.slice(n,n+10);await Promise.all(r.map(o=>this.deleteReadingNote("manga",o).catch(()=>{})))}}async addMangaBatch(s){var t;for(let e of s)this.applyReleaseStatus(e),this.data.manga.push(e);await this.saveOnly();for(let e of s){await this.syncUpcoming(e,"manga");try{await this.writeReadingNote("manga",e)}catch(i){console.warn("[WL] writeReadingNote failed:",i)}(t=this.historyManager)==null||t.log(`${e.title} (Manga) was added`,{source:"Reading",action:"added",titleName:e.title})}}generateMangaId(s){let t=Xe(s)||"manga",e=new Set(this.data.manga.map(a=>a.id));if(!e.has(t))return t;let i=2;for(;e.has(`${t}-${i}`);)i++;return`${t}-${i}`}getBookColumns(){return this.data.bookColumns}getMangaColumns(){return this.data.mangaColumns}columnsFor(s){return s==="book"?this.data.bookColumns:this.data.mangaColumns}async addColumn(s,t){this.columnsFor(s).push(t),await this.save()}async updateColumn(s,t){let e=this.columnsFor(s),i=e.findIndex(a=>a.id===t.id);i>=0&&(e[i]=t,await this.save())}async removeColumn(s,t){if(s==="book"){this.data.bookColumns=this.data.bookColumns.filter(e=>e.id!==t);for(let e of this.data.books)e.customFields&&Object.prototype.hasOwnProperty.call(e.customFields,t)&&delete e.customFields[t]}else{this.data.mangaColumns=this.data.mangaColumns.filter(e=>e.id!==t);for(let e of this.data.manga)e.customFields&&Object.prototype.hasOwnProperty.call(e.customFields,t)&&delete e.customFields[t]}await this.save()}generateColumnId(s,t){let e=`col-${Xe(t)||"field"}`,i=new Set(this.columnsFor(s).map(n=>n.id));if(!i.has(e))return e;let a=2;for(;i.has(`${e}-${a}`);)a++;return`${e}-${a}`}async reorderColumns(s,t,e){let i=this.columnsFor(s);if(t<0||t>=i.length||e<0||e>=i.length)return;let[a]=i.splice(t,1);a&&(i.splice(e,0,a),await this.save())}async setCustomField(s,t,e,i){let a=s==="book"?this.getBook(t):this.getManga(t);a&&(a.customFields||(a.customFields={}),i===null||i===""?delete a.customFields[e]:a.customFields[e]=i,a.dateModified=new Date().toISOString(),await this.save())}kindFolder(s){let t=this.data.settings.defaultFolder||"WatchLog/Reading";return(0,tt.normalizePath)(`${t}/${s==="book"?"Books":"Manga"}`)}noteFilePath(s,t){let e=Hs(t)||(s==="book"?"book":"manga");return(0,tt.normalizePath)(`${this.kindFolder(s)}/${e}.md`)}async ensureFolder(s){let t=(0,tt.normalizePath)(s);if(!this.plugin.app.vault.getAbstractFileByPath(t))try{await this.plugin.app.vault.createFolder(t)}catch(e){}}buildFrontmatter(s,t){var i,a,n,r;let e=["---"];if(e.push(`title: ${Pt(Hs(t.title))}`),e.push(`author: ${Pt((i=t.author)!=null?i:"")}`),e.push(`status: ${Pt(t.status)}`),e.push(`rating: ${t.rating|0}`),s==="book"){let o=t;e.push(`pagesRead: ${o.pagesRead|0}`),e.push(`totalPages: ${o.totalPages|0}`),e.push(`chaptersRead: ${o.chaptersRead|0}`),e.push(`totalChapters: ${o.totalChapters|0}`),o.googleBooksId&&e.push(`googleBooksId: ${Pt(o.googleBooksId)}`)}else{let o=t;e.push(`chaptersRead: ${o.chaptersRead|0}`),e.push(`totalChapters: ${o.totalChapters|0}`),e.push(`volumesRead: ${o.volumesRead|0}`),e.push(`totalVolumes: ${o.totalVolumes|0}`),o.malId&&e.push(`malId: ${Pt(o.malId)}`)}return e.push(`dateStarted: ${(a=t.dateStarted)!=null?a:"null"}`),e.push(`dateFinished: ${(n=t.dateFinished)!=null?n:"null"}`),e.push(`releaseDate: ${(r=t.releaseDate)!=null?r:"null"}`),e.push(`dateAdded: ${Pt(t.dateAdded)}`),e.push(`type: ${s==="book"?"book":"manga"}`),e.push("---"),e.join(` +`)}buildInitialNoteContent(s,t){return`${this.buildFrontmatter(s,t)} ## Notes -${e.notes} + + +## Quotes +`}rebuildWithFrontmatter(s,t){let e=s.match(/^---\n[\s\S]*?\n---\n?/);return e?t+` +`+s.slice(e[0].length):t+` + +`+s}async writeReadingNote(s,t){await this.ensureFolder(this.kindFolder(s));let e=this.noteFilePath(s,t.title),i=this.buildFrontmatter(s,t),a=`${s}:${t.id}`,n=this.lastNotePathById.get(a);if(n&&n!==e){let o=this.plugin.app.vault.getAbstractFileByPath(n);if(o instanceof tt.TFile)try{await this.plugin.app.fileManager.trashFile(o)}catch(l){}}let r=this.plugin.app.vault.getAbstractFileByPath(e);if(r instanceof tt.TFile){let o=await this.plugin.app.vault.read(r),l=this.rebuildWithFrontmatter(o,i);l!==o&&await this.plugin.app.vault.modify(r,l)}else await this.plugin.app.vault.create(e,this.buildInitialNoteContent(s,t));return this.lastNotePathById.set(a,e),e}async ensureReadingNote(s,t){let e=this.noteFilePath(s,t.title);return this.plugin.app.vault.getAbstractFileByPath(e)instanceof tt.TFile?(this.lastNotePathById.set(`${s}:${t.id}`,e),e):this.writeReadingNote(s,t)}async createReadingNoteIfMissing(s,t){let e=this.noteFilePath(s,t.title);return this.plugin.app.vault.getAbstractFileByPath(e)instanceof tt.TFile?!1:(await this.writeReadingNote(s,t),!0)}async readReadingNote(s,t){let e=this.noteFilePath(s,t.title),i=this.plugin.app.vault.getAbstractFileByPath(e);return i instanceof tt.TFile?this.plugin.app.vault.read(i):null}async appendQuote(s,t,e,i){await this.ensureReadingNote(s,t);let a=this.noteFilePath(s,t.title),n=this.plugin.app.vault.getAbstractFileByPath(a);if(!(n instanceof tt.TFile))return;let r=await this.plugin.app.vault.read(n),o=i.trim(),l=o?` ${o}`:"",c=e.split(/\r?\n/).map(p=>`> ${p}`).join(` +`),d=`> [!quote]${l} +${c}`,u,h=r.match(/(^|\n)## Quotes[ \t]*\r?\n/);if(h&&h.index!==void 0){let p=h.index+h[0].length,m=r.slice(0,p),v=r.slice(p),f=m.length>0&&!m.endsWith(` + +`);u=`${m}${f?` +`:""}${d} +${v.startsWith(` +`)?"":` +`}${v}`}else{let p=r.endsWith(` +`)?"":` +`;u=`${r}${p} +## Quotes + +${d} +`}await this.plugin.app.vault.modify(n,u)}async deleteReadingNote(s,t){let e=this.noteFilePath(s,t.title),i=this.plugin.app.vault.getAbstractFileByPath(e);if(i instanceof tt.TFile)try{await this.plugin.app.fileManager.trashFile(i)}catch(a){}this.lastNotePathById.delete(`${s}:${t.id}`)}};var me=require("obsidian"),jt="https://api.jikan.moe/v4",Lt="https://www.omdbapi.com",bt="https://api.themoviedb.org/3",_s="https://graphql.anilist.co",Us=700,Hi="https://www.googleapis.com/books/v1",Vs=400,Ze=8e3,ut=class extends Error{constructor(s,t,e){super(t),this.name="GoogleBooksError",this.reason=s,this.status=e}};function Et(g){var s;if(g instanceof ut)switch(g.reason){case"no-key":return"Google Books API key required \u2014 add one in Settings \u2192 API \u2192 Books.";case"rate-limited":return"Google Books rate limit reached \u2014 your API key is missing or over quota.";case"http":return`Google Books request failed (HTTP ${(s=g.status)!=null?s:"?"}).`;case"parse":return"Google Books returned an unreadable response.";default:return"Google Books request failed \u2014 check your connection."}return"Google Books request failed \u2014 check your connection."}var Qt=class{constructor(s,t="",e=""){this.anilistLastRequest=0;this.anilistQueue=Promise.resolve();this.jikanLastRequest=0;this.jikanQueue=Promise.resolve();this.omdbApiKey=s,this.tmdbApiKey=t,this.googleBooksApiKey=e}throttleJikan(s){let t=async()=>{let a=Date.now()-this.jikanLastRequest;return a<Vs&&await new Promise(n=>window.setTimeout(n,Vs-a)),this.jikanLastRequest=Date.now(),s()},e=this.jikanQueue.then(t,t);return this.jikanQueue=e.then(()=>{},()=>{}),e}setOmdbKey(s){this.omdbApiKey=s}setTmdbKey(s){this.tmdbApiKey=s}setGoogleBooksKey(s){this.googleBooksApiKey=s}hasGoogleBooksKey(){return this.googleBooksApiKey.trim().length>0}tmdbHeaders(){return{Authorization:`Bearer ${this.tmdbApiKey}`}}async fetchWithTimeout(s,t){let e=null,i=new Promise((a,n)=>{e=window.setTimeout(()=>n(new Error("Request timed out")),Ze)});try{let a=(0,me.requestUrl)({url:s,headers:t}).then(n=>n.json);return await Promise.race([a,i])}finally{e!==null&&window.clearTimeout(e)}}async postJsonWithTimeout(s,t,e){let i=null,a=new Promise((n,r)=>{i=window.setTimeout(()=>r(new Error("Request timed out")),Ze)});try{let n=(0,me.requestUrl)({url:s,method:"POST",headers:{"Content-Type":"application/json",...e!=null?e:{}},body:JSON.stringify(t)}).then(r=>r.json);return await Promise.race([n,a])}finally{i!==null&&window.clearTimeout(i)}}throttleAniList(s){let t=async()=>{let a=Date.now()-this.anilistLastRequest;return a<Us&&await new Promise(n=>window.setTimeout(n,Us-a)),this.anilistLastRequest=Date.now(),s()},e=this.anilistQueue.then(t,t);return this.anilistQueue=e.then(()=>{},()=>{}),e}stripHtml(s){return s.replace(/<br\s*\/?>/gi,` +`).replace(/<[^>]+>/g,"").replace(/ /g," ").replace(/&/g,"&").replace(/"/g,'"').replace(/'/g,"'").replace(/</g,"<").replace(/>/g,">").trim()}formatAniListDate(s){var a,n;if(!(s!=null&&s.year))return"";let t=String(s.year),e=String((a=s.month)!=null?a:1).padStart(2,"0"),i=String((n=s.day)!=null?n:1).padStart(2,"0");return`${t}-${e}-${i}`}mapAniListMedia(s){var r,o,l,c,d,u,h,p,m,v,f;let t=((r=s.title)==null?void 0:r.english)||((o=s.title)==null?void 0:o.romaji)||((l=s.title)==null?void 0:l.native)||"",e=(c=s.episodes)!=null?c:0,i=(d=s.duration)!=null?d:24,a=e>0?[{name:"Season 1",episodes:e,offset:0}]:[],n=s.description?this.stripHtml(s.description):"";return{malId:0,anilistId:s.id,title:t,episodes:e,duration:i,releaseDate:this.formatAniListDate(s.startDate),url:`https://anilist.co/anime/${s.id}`,seasons:a,description:n,averageScore:(u=s.averageScore)!=null?u:void 0,genres:(h=s.genres)!=null?h:void 0,posterUrl:(f=(v=(p=s.coverImage)==null?void 0:p.large)!=null?v:(m=s.coverImage)==null?void 0:m.medium)!=null?f:void 0}}async searchAniList(s){var e,i,a;let t=` + query ($search: String) { + Page(page: 1, perPage: 10) { + media(search: $search, type: ANIME) { + id + title { romaji english native } + episodes + duration + status + season + seasonYear + startDate { year month day } + averageScore + popularity + coverImage { large medium } + description + genres + } + } + } + `;try{return((a=(i=(e=(await this.throttleAniList(()=>this.postJsonWithTimeout(_s,{query:t,variables:{search:s}}))).data)==null?void 0:e.Page)==null?void 0:i.media)!=null?a:[]).map(l=>this.mapAniListMedia(l))}catch(n){return[]}}async getAniListById(s){var e,i;let t=` + query ($id: Int) { + Media(id: $id, type: ANIME) { + id + title { romaji english native } + episodes + duration + status + averageScore + popularity + coverImage { large } + nextAiringEpisode { + airingAt + episode + timeUntilAiring + } + } + } + `;try{return(i=(e=(await this.throttleAniList(()=>this.postJsonWithTimeout(_s,{query:t,variables:{id:s}}))).data)==null?void 0:e.Media)!=null?i:null}catch(a){return null}}async searchAnime(s){var e;let t=`${jt}/anime?q=${encodeURIComponent(s)}&limit=10&sfw=false`;try{return((e=(await this.fetchWithTimeout(t)).data)!=null?e:[]).map(a=>this.mapJikanAnime(a))}catch(i){return[]}}mapJikanAnime(s){var r,o,l,c,d;let e=((r=s.duration)!=null?r:"24 min per ep").match(/(\d+)\s*min/),i=e&&e[1]?parseInt(e[1]):24,a=(o=s.episodes)!=null?o:0,n=a>0?[{name:"Season 1",episodes:a,offset:0}]:[];return{malId:s.mal_id,title:(l=s.title_english)!=null?l:s.title,episodes:a,duration:i,releaseDate:(c=s.aired)!=null&&c.from&&(d=s.aired.from.split("T")[0])!=null?d:"",url:s.url,seasons:n}}async searchOmdb(s,t){var i;if(!this.omdbApiKey)return[];let e=`${Lt}/?s=${encodeURIComponent(s)}&type=${t}&apikey=${encodeURIComponent(this.omdbApiKey)}`;try{let a=await this.fetchWithTimeout(e);return a.Response==="False"?[]:((i=a.Search)!=null?i:[]).slice(0,10).map(n=>({imdbId:n.imdbID,title:n.Title,mediaType:t==="movie"?"movie":"tv",episodes:t==="movie"?1:0,episodeDuration:0,releaseDate:n.Year?`${n.Year}-01-01`:"",url:`https://www.imdb.com/title/${n.imdbID}`,seasons:[]}))}catch(a){return[]}}async getOmdbMovieDetails(s){var e;if(!this.omdbApiKey)return null;let t=`${Lt}/?i=${encodeURIComponent(s)}&apikey=${encodeURIComponent(this.omdbApiKey)}`;try{let i=await this.fetchWithTimeout(t);if(i.Response==="False"||!i.imdbID)return null;let a=i.Runtime?parseInt(i.Runtime.replace(/[^0-9]/g,"")):120;return{imdbId:i.imdbID,title:i.Title,mediaType:"movie",episodes:1,episodeDuration:isNaN(a)?120:a,releaseDate:this.parseOmdbDate((e=i.Released)!=null?e:i.Year),url:`https://www.imdb.com/title/${i.imdbID}`,seasons:[{name:"Movie",episodes:1,offset:0}]}}catch(i){return null}}async getOmdbTvDetails(s){var e,i;if(!this.omdbApiKey)return null;let t=`${Lt}/?i=${encodeURIComponent(s)}&apikey=${encodeURIComponent(this.omdbApiKey)}`;try{let a=await this.fetchWithTimeout(t);if(a.Response==="False"||!a.imdbID)return null;let n=parseInt((e=a.totalSeasons)!=null?e:"1")||1,r=[],o=0;for(let c=1;c<=n;c++){let d=await this.getOmdbSeasonEpisodeCount(s,c);if(d===null)break;r.push({name:`Season ${c}`,episodes:d,offset:o}),o+=d}let l=r.reduce((c,d)=>c+d.episodes,0);return{imdbId:a.imdbID,title:a.Title,mediaType:"tv",episodes:l,episodeDuration:45,releaseDate:this.parseOmdbDate((i=a.Released)!=null?i:a.Year),url:`https://www.imdb.com/title/${a.imdbID}`,seasons:r}}catch(a){return null}}async getOmdbSeasonEpisodeCount(s,t){var i;let e=`${Lt}/?i=${encodeURIComponent(s)}&Season=${t}&apikey=${encodeURIComponent(this.omdbApiKey)}`;try{let a=await this.fetchWithTimeout(e);return a.Response==="False"?null:((i=a.Episodes)!=null?i:[]).length}catch(a){return null}}async getAnimeScheduleByMalId(s){var t;try{let e=`${jt}/anime/${s}`,a=(t=(await this.fetchWithTimeout(e)).data)==null?void 0:t.broadcast;if(!(a!=null&&a.day)||!a.time)return null;let r={Mondays:1,Tuesdays:2,Wednesdays:3,Thursdays:4,Fridays:5,Saturdays:6,Sundays:0}[a.day];return r===void 0?null:{dayOfWeek:r,time:a.time}}catch(e){return null}}parseOmdbDate(s){let t={Jan:"01",Feb:"02",Mar:"03",Apr:"04",May:"05",Jun:"06",Jul:"07",Aug:"08",Sep:"09",Oct:"10",Nov:"11",Dec:"12"},e=s.match(/^(\d{2})\s+([A-Za-z]{3})\s+(\d{4})$/);if(e&&e[1]&&e[2]&&e[3]){let i=t[e[2]];if(i)return`${e[3]}-${i}-${e[1]}`}return s}async getOmdbByImdbId(s){if(!this.omdbApiKey)return null;let t=`${Lt}/?i=${encodeURIComponent(s)}&apikey=${encodeURIComponent(this.omdbApiKey)}`;try{let e=await this.fetchWithTimeout(t);return e.Response==="False"||!e.imdbID?null:e.Type==="movie"?this.getOmdbMovieDetails(s):this.getOmdbTvDetails(s)}catch(e){return null}}async searchTmdb(s,t){var a;if(!this.tmdbApiKey)return[];let i=`${bt}/search/${t==="movie"?"movie":"tv"}?query=${encodeURIComponent(s)}&page=1`;try{return((a=(await this.fetchWithTimeout(i,this.tmdbHeaders())).results)!=null?a:[]).slice(0,10).map(r=>{var o,l,c,d;return{imdbId:String(r.id),title:(l=(o=r.title)!=null?o:r.name)!=null?l:"",mediaType:t==="movie"?"movie":"tv",episodes:0,episodeDuration:0,releaseDate:(d=(c=r.release_date)!=null?c:r.first_air_date)!=null?d:"",url:"",seasons:[]}})}catch(n){return[]}}async getTmdbMovieDetails(s){var t,e,i,a;if(!this.tmdbApiKey)return null;try{let[n,r]=await Promise.all([this.fetchWithTimeout(`${bt}/movie/${s}`,this.tmdbHeaders()),this.fetchWithTimeout(`${bt}/movie/${s}/external_ids`,this.tmdbHeaders())]),o=n,c=(e=(t=r.imdb_id)!=null?t:o.imdb_id)!=null?e:"";return{imdbId:String(o.id),title:o.title,mediaType:"movie",episodes:1,episodeDuration:(i=o.runtime)!=null?i:120,releaseDate:(a=o.release_date)!=null?a:"",url:c?`https://www.imdb.com/title/${c}`:"",seasons:[{name:"Movie",episodes:1,offset:0}]}}catch(n){return null}}async getTmdbTvDetails(s){var t,e,i,a,n,r;if(!this.tmdbApiKey)return null;try{let[o,l]=await Promise.all([this.fetchWithTimeout(`${bt}/tv/${s}`,this.tmdbHeaders()),this.fetchWithTimeout(`${bt}/tv/${s}/external_ids`,this.tmdbHeaders())]),c=o,u=(t=l.imdb_id)!=null?t:"",h=((e=c.seasons)!=null?e:[]).filter(f=>f.season_number!==0),p=0,m=h.map(f=>{let y={name:f.name,episodes:f.episode_count,offset:p};return p+=f.episode_count,y}),v=m.reduce((f,y)=>f+y.episodes,0)||((i=c.number_of_episodes)!=null?i:0);return{imdbId:String(c.id),title:c.name,mediaType:"tv",episodes:v,episodeDuration:(n=((a=c.episode_run_time)!=null?a:[])[0])!=null?n:45,releaseDate:(r=c.first_air_date)!=null?r:"",url:u?`https://www.imdb.com/title/${u}`:"",seasons:m}}catch(o){return null}}async getTmdbByImdbId(s){if(!this.tmdbApiKey)return null;let t=`${bt}/find/${encodeURIComponent(s)}?external_source=imdb_id`;try{let e=await this.fetchWithTimeout(t,this.tmdbHeaders());return e.movie_results&&e.movie_results.length>0?this.getTmdbMovieDetails(String(e.movie_results[0].id)):e.tv_results&&e.tv_results.length>0?this.getTmdbTvDetails(String(e.tv_results[0].id)):null}catch(e){return null}}parseOmdbVotes(s){if(!s||s==="N/A")return 0;let t=parseInt(s.replace(/,/g,""),10);return isNaN(t)?0:t}parseOmdbRating(s){if(!s||s==="N/A")return 0;let t=parseFloat(s);return isNaN(t)?0:t}extractImdbId(s){let t=s.match(/tt\d+/);return t?t[0]:""}async fetchMalRating(s){var t,e,i,a;try{let n=`${jt}/anime/${s}`,r=await this.fetchWithTimeout(n),o=(e=(t=r.data)==null?void 0:t.score)!=null?e:0,l=(a=(i=r.data)==null?void 0:i.scored_by)!=null?a:0;return!o&&!l?null:{rating:o,votes:l}}catch(n){return null}}async fetchAniListRating(s){var a,n;let t=await this.getAniListById(s);if(!t)return null;let e=(a=t.averageScore)!=null?a:0,i=(n=t.popularity)!=null?n:0;return!e&&!i?null:{rating:e,votes:i}}async fetchOmdbRating(s){if(!this.omdbApiKey)return null;try{let t=`${Lt}/?i=${encodeURIComponent(s)}&apikey=${encodeURIComponent(this.omdbApiKey)}`,e=await this.fetchWithTimeout(t);if(e.Response==="False")return null;let i=this.parseOmdbRating(e.imdbRating),a=this.parseOmdbVotes(e.imdbVotes);return!i&&!a?null:{rating:i,votes:a}}catch(t){return null}}async fetchTmdbRatingByImdb(s){var t,e;if(!this.tmdbApiKey)return null;try{let i=`${bt}/find/${encodeURIComponent(s)}?external_source=imdb_id`,a=await this.fetchWithTimeout(i,this.tmdbHeaders()),n=a.movie_results&&a.movie_results[0]||a.tv_results&&a.tv_results[0]||null;if(!n)return null;let r=(t=n.vote_average)!=null?t:0,o=(e=n.vote_count)!=null?e:0;return!r&&!o?null:{rating:r,votes:o}}catch(i){return null}}async fetchCommunityRating(s,t="jikan",e=""){var o,l,c;let i=((o=s.malId)!=null?o:0)>0,a=((l=s.anilistId)!=null?l:0)>0,n=e||(s.type==="Anime"?"anime":s.type==="Movie"||s.type==="TV Show"||s.type==="TvShow"?"movie":"");if(n==="")return null;if(n==="anime")if(t==="anilist"){if(a){let d=await this.fetchAniListRating(s.anilistId);if(d)return{...d,source:"anilist"}}if(i){let d=await this.fetchMalRating(s.malId);if(d)return{...d,source:"mal"}}}else{if(i){let d=await this.fetchMalRating(s.malId);if(d)return{...d,source:"mal"}}if(a){let d=await this.fetchAniListRating(s.anilistId);if(d)return{...d,source:"anilist"}}}let r=this.extractImdbId((c=s.externalLink)!=null?c:"");if(r){if(this.omdbApiKey){let d=await this.fetchOmdbRating(r);if(d)return{...d,source:"imdb"}}if(this.tmdbApiKey){let d=await this.fetchTmdbRatingByImdb(r);if(d)return{...d,source:"tmdb"}}}return null}googleCoverUrl(s){var i,a,n,r,o,l;let t=s.imageLinks;if(!t)return"";let e=(l=(o=(r=(n=(a=(i=t.extraLarge)!=null?i:t.large)!=null?a:t.medium)!=null?n:t.small)!=null?r:t.thumbnail)!=null?o:t.smallThumbnail)!=null?l:"";return e?e.replace(/^http:/,"https:").replace(/&edge=curl/g,""):""}googleReleaseDate(s){return s?/^\d{4}-\d{2}-\d{2}$/.test(s)?s:/^\d{4}-\d{2}$/.test(s)?`${s}-01`:/^\d{4}$/.test(s)?`${s}-01-01`:s:""}mapGoogleVolume(s){var n,r,o,l,c,d,u,h,p;let t=(n=s.volumeInfo)!=null?n:{},e=(r=t.publishedDate)!=null?r:"",i=e&&parseInt(e.slice(0,4),10)||0;return{title:t.subtitle?`${(o=t.title)!=null?o:""}: ${t.subtitle}`:(l=t.title)!=null?l:"",author:((c=t.authors)!=null?c:[]).join(", "),year:i,totalPages:(d=t.pageCount)!=null?d:0,coverUrl:this.googleCoverUrl(t),googleBooksId:(u=s.id)!=null?u:"",releaseDate:this.googleReleaseDate(e),url:(p=(h=t.infoLink)!=null?h:t.canonicalVolumeLink)!=null?p:""}}async googleBooksFetch(s){var r;let t=this.googleBooksApiKey.trim();if(!t)throw new ut("no-key","Google Books API key not set");let e=s.includes("?")?"&":"?",i=`${Hi}${s}${e}key=${encodeURIComponent(t)}`,a=null,n=new Promise((o,l)=>{a=window.setTimeout(()=>l(new ut("network","Request timed out")),Ze)});try{let o=(0,me.requestUrl)({url:i,throw:!1}).then(l=>{if(l.status===429||l.status===403)throw new ut("rate-limited",`Google Books returned ${l.status}`,l.status);if(l.status<200||l.status>=300)throw new ut("http",`Google Books returned ${l.status}`,l.status);try{return l.json}catch(c){throw new ut("parse","Failed to parse Google Books response")}});return await Promise.race([o,n])}catch(o){throw o instanceof ut?o:new ut("network",(r=o==null?void 0:o.message)!=null?r:"Network error")}finally{a!==null&&window.clearTimeout(a)}}async searchGoogleBooks(s){var e;return((e=(await this.googleBooksFetch(`/volumes?q=${encodeURIComponent(s)}&maxResults=10`)).items)!=null?e:[]).slice(0,10).map(i=>this.mapGoogleVolume(i))}async checkGoogleBooksConnection(){let s=await this.googleBooksFetch("/volumes?q=tolkien&maxResults=1");return Array.isArray(s.items)}mapJikanManga(s){var i,a,n,r,o,l,c,d,u,h,p,m;let t=(a=(i=s.published)==null?void 0:i.from)!=null?a:"",e=t&&parseInt(t.slice(0,4),10)||0;return{malId:s.mal_id,title:(n=s.title_english)!=null?n:s.title,author:((r=s.authors)!=null?r:[]).map(v=>v.name).join(", "),year:e,totalChapters:(o=s.chapters)!=null?o:0,totalVolumes:(l=s.volumes)!=null?l:0,coverUrl:(u=(d=(c=s.images)==null?void 0:c.jpg)==null?void 0:d.image_url)!=null?u:"",releaseDate:(h=s.published)!=null&&h.from&&(p=s.published.from.split("T")[0])!=null?p:"",url:(m=s.url)!=null?m:`https://myanimelist.net/manga/${s.mal_id}`}}async searchManga(s){var e;let t=`${jt}/manga?q=${encodeURIComponent(s)}&limit=10`;try{return((e=(await this.throttleJikan(()=>this.fetchWithTimeout(t))).data)!=null?e:[]).map(a=>this.mapJikanManga(a))}catch(i){return[]}}async getMangaByMalId(s){let t=`${jt}/manga/${s}`;try{let e=await this.throttleJikan(()=>this.fetchWithTimeout(t));return e.data?this.mapJikanManga(e.data):null}catch(e){return null}}async getGoogleBookById(s){if(!s)return null;let t=await this.googleBooksFetch(`/volumes/${encodeURIComponent(s)}`);return!t||!t.id?null:this.mapGoogleVolume(t)}async checkTmdbConnection(){if(!this.tmdbApiKey)return!1;try{let s=`${bt}/movie/550`;return(await this.fetchWithTimeout(s,this.tmdbHeaders())).id!==void 0}catch(s){return!1}}async checkOmdbConnection(){if(!this.omdbApiKey)return!1;try{let s=`${Lt}/?i=tt0111161&apikey=${encodeURIComponent(this.omdbApiKey)}`;return(await this.fetchWithTimeout(s)).Response==="True"}catch(s){return!1}}};var ts=require("obsidian");var Gs="https://api.jikan.moe/v4",_i="https://www.omdbapi.com",Ui="https://api.themoviedb.org/3",Vi="https://image.tmdb.org/t/p/w300",Gi="https://graphql.anilist.co",Ks=8e3,Ki=400,zi=700,qi=30,ji=100,ve=class{constructor(s,t){this.queue=[];this.isProcessing=!1;this.disposed=!1;this.dataManager=s,this.getSettings=t}destroy(){this.disposed=!0,this.clearQueue()}enqueue(s){return new Promise(t=>{if(this.disposed){t(null);return}this.queue.push({title:s,resolve:t}),this.processQueue()})}clearQueue(){let s=this.queue.splice(0);for(let t of s)t.resolve(null)}async processQueue(){if(!this.isProcessing){for(this.isProcessing=!0;this.queue.length>0&&!this.disposed;){let s=this.queue.shift();if(!s)break;if(s.title.manualPosterUrl&&s.title.manualPosterUrl.trim()!==""){s.resolve(s.title.manualPosterUrl);continue}try{let e=await this.fetchPosterForTitle(s.title);if(this.disposed){s.resolve(null);break}let i=e||"none";this.dataManager.updatePosterUrl(s.title.id,i),s.resolve(e)}catch(e){this.disposed||this.dataManager.updatePosterUrl(s.title.id,"none"),s.resolve(null)}let t=this.getDelayForTitle(s.title);t>0&&await new Promise(e=>window.setTimeout(e,t))}this.isProcessing=!1}}getDelayForTitle(s){let t=this.getSettings();return nt(s.type,t.typeApiMapping)==="anime"?s.anilistId&&s.anilistId>0?zi:Ki:t.tmdbApiKey?qi:t.omdbApiKey?ji:0}async fetchPosterForTitle(s){if(s.posterUrl&&s.posterUrl.startsWith("http"))return s.posterUrl;if(s.posterUrl==="none")return null;let t=this.getSettings(),e=nt(s.type,t.typeApiMapping);return e==="anime"?this.fetchAnimePoster(s):e==="movie"?this.fetchMediaPoster(s):null}cleanTitleForSearch(s){return s.replace(/\s*-?\s*[Ss]eason\s*\d+/gi,"").replace(/\s*-?\s*[Ss]eries\s*\d+/gi,"").replace(/\s*-?\s*[Pp]art\s*\d+/gi,"").replace(/\s*-?\s*[Vv]ol(ume)?\.?\s*\d+/gi,"").replace(/\s*\(\d{4}\)/g,"").replace(/\s*-?\s*[Ss]\d+/gi,"").replace(/\s+/g," ").trim()||s}async fetchAnimePoster(s){var r,o,l,c,d,u,h,p,m,v,f,y;if(s.anilistId&&s.anilistId>0){let b=await this.postJson(Gi,{query:"query ($id: Int) { Media(id: $id, type: ANIME) { coverImage { large medium } } }",variables:{id:s.anilistId}}),S=(o=(r=b==null?void 0:b.data)==null?void 0:r.Media)==null?void 0:o.coverImage;return(c=(l=S==null?void 0:S.large)!=null?l:S==null?void 0:S.medium)!=null?c:null}if(s.malId){let w=`${Gs}/anime/${s.malId}`,b=await this.fetchJson(w),S=(u=(d=b==null?void 0:b.data)==null?void 0:d.images)==null?void 0:u.jpg;return(p=(h=S==null?void 0:S.large_image_url)!=null?h:S==null?void 0:S.image_url)!=null?p:null}let t=encodeURIComponent(this.cleanTitleForSearch(s.title)),e=`${Gs}/anime?q=${t}&limit=1`,i=await this.fetchJson(e),a=(m=i==null?void 0:i.data)==null?void 0:m[0],n=(v=a==null?void 0:a.images)==null?void 0:v.jpg;return(y=(f=n==null?void 0:n.large_image_url)!=null?f:n==null?void 0:n.image_url)!=null?y:null}async fetchMediaPoster(s){var i,a;let t=this.getSettings(),e=this.cleanTitleForSearch(s.title);if(t.tmdbApiKey){let n=encodeURIComponent(e),r=`${Ui}/search/multi?query=${n}&page=1`,o=await this.fetchJson(r,{Authorization:`Bearer ${t.tmdbApiKey}`}),l=(a=(i=o==null?void 0:o.results)==null?void 0:i[0])==null?void 0:a.poster_path;return l?`${Vi}${l}`:null}if(t.omdbApiKey){let n=encodeURIComponent(e),r=`${_i}/?t=${n}&apikey=${encodeURIComponent(t.omdbApiKey)}`,o=await this.fetchJson(r),l=o==null?void 0:o.Poster;return l&&l!=="N/A"?l:null}return null}async fetchJson(s,t){let e=null,i=new Promise((a,n)=>{e=window.setTimeout(()=>n(new Error("Request timed out")),Ks)});try{let a=(0,ts.requestUrl)({url:s,headers:t}).then(n=>n.json);return await Promise.race([a,i])}finally{e!==null&&window.clearTimeout(e)}}async postJson(s,t,e){let i=null,a=new Promise((n,r)=>{i=window.setTimeout(()=>r(new Error("Request timed out")),Ks)});try{let n=(0,ts.requestUrl)({url:s,method:"POST",headers:{"Content-Type":"application/json",...e!=null?e:{}},body:JSON.stringify(t)}).then(r=>r.json);return await Promise.race([n,a])}finally{i!==null&&window.clearTimeout(i)}}};var Ht=require("obsidian");var zs=2*Math.PI*45,fe=class{constructor(s,t,e,i){this.container=s,this.plugin=t,this.dataManager=e,this.readingData=i}render(){this.container.empty(),this.container.addClass("wl-dashboard");let s=this.dataManager.getTitles(),t=new Map;for(let c of s){let d=t.get(c.type);d?d.push(c):t.set(c.type,[c])}let e=s.filter(c=>c.status==="Plan to watch"),i=this.readingData.getBooks(),a=this.readingData.getMangaList(),n=s.length+i.length+a.length,r=s.reduce((c,d)=>c+(d.status==="Completed"?1:0),0)+i.reduce((c,d)=>c+(d.status==="Completed"?1:0),0)+a.reduce((c,d)=>c+(d.status==="Completed"?1:0),0),o=this.aggregateBooks(i),l=this.aggregateManga(a);this.renderCards(s,t,o,l),this.renderSummaryMetrics(n,r),this.renderSuggestions(e),this.renderRecentlyWatched(),this.renderRecentlyAdded()}statsFor(s){let t=new Set(["Dropped","To be released"]),e=0,i=0;for(let a of s)t.has(a.status)||(e++,a.status==="Completed"&&i++);return{watched:i,total:e}}renderCards(s,t,e,i){var d;let a=this.plugin.settings.dashboardCardStyle==="rectangles",n=this.container.createDiv({cls:"wl-rings-grid"}),r=this.statsFor(s),o=r.total===0?0:Math.round(r.watched/r.total*100),l=this.dataManager.getTotalTimeWatched(),c=this.dataManager.getTotalTimeRemaining();this.renderUnifiedCard(n,a,o,r,z(l),z(c));for(let u of this.plugin.settings.types){let h=this.statsFor((d=t.get(u.name))!=null?d:[]),p=h.total===0?0:Math.round(h.watched/h.total*100);if(a){let m=n.createDiv({cls:"wl-rect-item"});this.fillRect(m,u.name,u.color,p,h),m.createDiv({cls:"wl-rect-subline",text:"\xA0"})}else{let m=n.createDiv({cls:"wl-ring-item"});this.fillRing(m,u.name,u.color,p,h),m.createDiv({cls:"wl-ring-subtitle wl-ring-subline",text:"\xA0"})}}this.renderReadingCard(n,a,"Books",e),this.renderReadingCard(n,a,"Manga",i)}renderUnifiedCard(s,t,e,i,a,n){let r=s.createDiv({cls:t?"wl-dash-unified wl-dash-unified-bordered":"wl-dash-unified"}),o=r.createDiv({cls:t?"wl-dash-seg":"wl-dash-seg wl-dash-seg-center"});t?this.fillRect(o,"Total","#7F77DD",e,i):this.fillRing(o.createDiv({cls:"wl-ring-item"}),"Total","#7F77DD",e,i),this.renderTimeSegment(r,"Time Watched",a),this.renderTimeSegment(r,"Time Remaining",n)}renderTimeSegment(s,t,e){let i=s.createDiv({cls:"wl-dash-seg wl-dash-seg-center"});i.createDiv({cls:"wl-dash-seg-label",text:t}),i.createDiv({cls:"wl-dash-seg-value",text:e})}aggregateBooks(s){let t={left:0,read:0,total:0,volumesRead:0,totalVolumes:0};for(let e of s)e.status!=="To be released"&&((e.status==="Reading"||e.status==="Plan to Read")&&t.left++,t.read+=e.pagesRead,t.total+=e.totalPages);return t}aggregateManga(s){let t={left:0,read:0,total:0,volumesRead:0,totalVolumes:0};for(let e of s)e.status!=="To be released"&&((e.status==="Reading"||e.status==="Plan to Read")&&t.left++,t.read+=e.chaptersRead,t.total+=e.totalChapters,t.volumesRead+=e.volumesRead,t.totalVolumes+=e.totalVolumes);return t}renderReadingCard(s,t,e,i){let a=i.total>0?Math.round(i.read/i.total*100):0,n=pe(e==="Books"?"book":"manga",this.plugin.settings),r=e==="Books"?`${i.read} / ${i.total} pages`:`${i.read} / ${i.total} chapters \xB7 ${i.volumesRead} / ${i.totalVolumes} vol`;if(t){let o=s.createDiv({cls:"wl-rect-item"}),l=o.createDiv({cls:"wl-rect-top"});l.createSpan({cls:"wl-rect-label",text:e}),l.createSpan({cls:"wl-rect-unwatched",text:`${i.left} left`}),o.createDiv({cls:"wl-rect-value",text:`${a}%`});let d=o.createDiv({cls:"wl-rect-bar-wrap"}).createDiv({cls:"wl-rect-bar"});d.style.width=`${a}%`,d.style.backgroundColor=n,o.createDiv({cls:"wl-rect-subline",text:r})}else{let o=s.createDiv({cls:"wl-ring-item"}),l=this.makeRingSvg(a,n,`${a}%`,"",!0);o.appendChild(l),o.createDiv({cls:"wl-ring-label",text:e}),o.createDiv({cls:"wl-ring-subtitle",text:`${i.left} left`}),o.createDiv({cls:"wl-ring-subtitle wl-ring-subline",text:r})}}makeRingSvg(s,t,e,i,a=!1){let n="http://www.w3.org/2000/svg",r=activeDocument.createElementNS(n,"svg");r.setAttribute("viewBox","0 0 120 120"),r.setAttribute("width","110"),r.setAttribute("height","110"),r.addClass("wl-ring-svg");let o=activeDocument.createElementNS(n,"circle");o.setAttribute("cx","60"),o.setAttribute("cy","60"),o.setAttribute("r","45"),o.setAttribute("fill","none"),o.setAttribute("stroke-width","10"),o.addClass("wl-ring-track"),r.appendChild(o);let l=activeDocument.createElementNS(n,"circle");l.setAttribute("cx","60"),l.setAttribute("cy","60"),l.setAttribute("r","45"),l.setAttribute("fill","none"),a?l.style.stroke=t:l.setAttribute("stroke",t),l.setAttribute("stroke-width","10"),l.setAttribute("stroke-linecap","round"),l.setAttribute("stroke-dasharray",String(zs)),l.setAttribute("stroke-dashoffset",String(zs*(1-s/100))),l.setAttribute("transform","rotate(-90 60 60)"),l.addClass("wl-ring-arc"),r.appendChild(l);let c=activeDocument.createElementNS(n,"text");if(c.setAttribute("x","60"),c.setAttribute("y",i?"55":"60"),c.setAttribute("text-anchor","middle"),c.setAttribute("dominant-baseline","middle"),c.addClass("wl-ring-percent-text"),c.textContent=e,r.appendChild(c),i){let d=activeDocument.createElementNS(n,"text");d.setAttribute("x","60"),d.setAttribute("y","72"),d.setAttribute("text-anchor","middle"),d.addClass("wl-ring-sub-text"),d.textContent=i,r.appendChild(d)}return r}fillRing(s,t,e,i,a){let n=this.makeRingSvg(i,e,`${i}%`,`${a.watched}/${a.total}`);s.appendChild(n),s.createDiv({cls:"wl-ring-label",text:t});let r=a.total-a.watched;s.createDiv({cls:"wl-ring-subtitle",text:`${r} unwatched`})}fillRect(s,t,e,i,a){let n=s.createDiv({cls:"wl-rect-top"});n.createSpan({cls:"wl-rect-label",text:t});let r=a.total-a.watched;n.createSpan({cls:"wl-rect-unwatched",text:`${r} left`}),s.createDiv({cls:"wl-rect-value",text:`${i}%`});let l=s.createDiv({cls:"wl-rect-bar-wrap"}).createDiv({cls:"wl-rect-bar"});l.style.width=`${i}%`,l.style.backgroundColor=e}renderSummaryMetrics(s,t){let e=this.container.createDiv({cls:"wl-summary-metrics"});this.renderMetricRow(e,"Titles in library",String(s)),this.renderMetricRow(e,"Completed",String(t))}renderMetricRow(s,t,e){let i=s.createDiv({cls:"wl-metric-row"});i.createSpan({cls:"wl-metric-label",text:t}),i.createSpan({cls:"wl-metric-value",text:e})}renderSuggestions(s){if(s.length===0)return;let t=this.container.createDiv({cls:"wl-suggestions"});t.createDiv({cls:"wl-section-title",text:"Don't know what to watch next?"});let e=t.createDiv({cls:"wl-suggestions-grid"}),i=d=>{var h,p,m,v;let u=s.filter(f=>f.type===d).filter(f=>f.totalEpisodes>0||f.episodeDuration>0);return u.length===0?(p=(h=s.filter(y=>y.type===d)[0])==null?void 0:h.title)!=null?p:null:(u.sort((f,y)=>{let w=f.totalEpisodes>0?f.totalEpisodes*(f.episodeDuration||24):f.episodeDuration,b=y.totalEpisodes>0?y.totalEpisodes*(y.episodeDuration||24):y.episodeDuration;return w-b}),(v=(m=u[0])==null?void 0:m.title)!=null?v:null)},a=["Anime","Movie","TV Show"];for(let d of a){let u=i(d),h=e.createDiv({cls:"wl-suggestion-card"});h.createDiv({cls:"wl-suggestion-label",text:`Shortest ${d}`}),h.createDiv({cls:`wl-suggestion-title${u?"":" wl-suggestion-empty"}`,text:u!=null?u:"Nothing planned"})}let n=e.createDiv({cls:"wl-suggestion-card"}),r=n.createDiv({cls:"wl-suggestion-random-header"});r.createDiv({cls:"wl-suggestion-label",text:"Random"});let o=r.createEl("button",{cls:"wl-suggestion-shuffle",text:"\u{1F500}"});o.title="Pick another";let l=()=>{var d,u;return(u=(d=s[Math.floor(Math.random()*s.length)])==null?void 0:d.title)!=null?u:""},c=n.createDiv({cls:"wl-suggestion-title",text:l()});o.addEventListener("click",()=>{c.textContent=l()})}renderRecentlyWatched(){let s=this.container.createDiv({cls:"wl-recently-watched"});s.createDiv({cls:"wl-section-title",text:"Recently watched"});let t=this.dataManager.getRecentlyWatched(3);if(t.length===0){s.createDiv({cls:"wl-empty-state",text:"No titles watched yet."});return}for(let e of t){let i=s.createDiv({cls:"wl-rw-item"});i.createDiv({cls:"wl-rw-title",text:e.title});let a=this.getTagDef(e.type,this.plugin.settings.types),n=this.plugin.settings.coloredTypeBadges,o=i.createDiv({cls:"wl-rw-col-badge"}).createSpan({cls:n?"wl-badge wl-badge-sm":"wl-badge-plain",text:e.type});n&&a&&(o.style.backgroundColor=N(e.type,a.color,this.plugin.settings.colorTheme));let l=i.createDiv({cls:"wl-rw-col-ep"}),c=e.type==="Movie";if(!c){let u=this.dataManager.getNextUnwatchedEpisode(e);l.textContent=u!==null?`Ep ${u}`:"\u2713"}let d=i.createDiv({cls:"wl-rw-col-pct"});c?d.textContent=e.watchedEpisodes.includes(1)?"100%":"0%":d.textContent=`${this.dataManager.getProgress(e)}%`}}renderRecentlyAdded(){let s=this.container.createDiv({cls:"wl-recently-watched"});s.createDiv({cls:"wl-section-title",text:"Recently added"});let t=this.dataManager.getRecentlyAdded(3);if(t.length===0){s.createDiv({cls:"wl-empty-state",text:"No titles in your library yet."});return}for(let e of t){let i=s.createDiv({cls:"wl-rw-item"});i.createDiv({cls:"wl-rw-title",text:e.title});let a=this.getTagDef(e.type,this.plugin.settings.types),n=this.plugin.settings.coloredTypeBadges,o=i.createDiv({cls:"wl-rw-col-badge"}).createSpan({cls:n?"wl-badge wl-badge-sm":"wl-badge-plain",text:e.type});n&&a&&(o.style.backgroundColor=N(e.type,a.color,this.plugin.settings.colorTheme));let l=i.createDiv({cls:"wl-rw-col-ep"}),c=e.type==="Movie";if(!c){let u=this.dataManager.getNextUnwatchedEpisode(e);l.textContent=u!==null?`Ep ${u}`:"\u2713"}let d=i.createDiv({cls:"wl-rw-col-pct"});c?d.textContent=e.watchedEpisodes.includes(1)?"100%":"0%":d.textContent=`${this.dataManager.getProgress(e)}%`}}getTagDef(s,t){return t.find(e=>e.name===s)}};var Xs=require("obsidian");var Yt=require("obsidian");var Qi=30*24*60*60*1e3,Qs={imdb:"IMDb",mal:"MAL",anilist:"AniList",tmdb:"TMDB"};function Yi(g,s){return s==="anilist"?`${Math.round(g)}%`:g.toFixed(g>=10?0:1)}function Ft(g,s,t,e){var o,l,c,d,u,h;let i=s.dataManager.getTitle(t);if(!i)return;g.createSpan({cls:"wl-rating-divider"});let a=g.createDiv({cls:"wl-community-badge"});if(!!i.communitySource&&((o=i.communityRating)!=null?o:0)>0){let p=(l=i.communitySource)!=null?l:"",m=(c=Qs[p])!=null?c:p.toUpperCase();a.createSpan({cls:`wl-community-source wl-community-source--${p}`,text:m}),a.createSpan({cls:"wl-community-score",text:Yi((d=i.communityRating)!=null?d:0,p)}),((u=i.communityVotes)!=null?u:0)>0&&a.createSpan({cls:"wl-community-votes",text:`(${Os((h=i.communityVotes)!=null?h:0)})`})}else a.createSpan({cls:"wl-community-empty",text:"No community rating"});let r=a.createEl("button",{cls:"wl-community-refresh",text:"\u27F3",attr:{title:"Refresh community rating",type:"button"}});r.addEventListener("click",p=>{p.stopPropagation(),ss(s,t,r,!0).then(()=>{e&&e()})})}var es=new Set;function we(g,s,t){var a,n;let e=g.dataManager.getTitle(s);if(!e)return;let i=(a=e.communityRatingLastFetched)!=null?a:"";if(i){let r=Date.parse(i);if(!isNaN(r)&&Date.now()-r<Qi)return}Ji(e,(n=g.settings.animeApiSource)!=null?n:"jikan",g.settings.typeApiMapping)&&(es.has(s)||(es.add(s),Xi(g,s).then(r=>{r&&t&&t()}).finally(()=>{es.delete(s)})))}function Ji(g,s,t){var i,a,n;let e=nt(g.type,t);return e===""?!1:e==="anime"?s==="anilist"?((i=g.anilistId)!=null?i:0)>0:((a=g.malId)!=null?a:0)>0:!!((n=g.externalLink)!=null?n:"").match(/tt\d+/)}async function Xi(g,s){var n;let t=g.dataManager.getTitle(s);if(!t)return!1;let e=(n=g.settings.animeApiSource)!=null?n:"jikan",i=nt(t.type,g.settings.typeApiMapping),a=await g.apiService.fetchCommunityRating(t,e,i);return a?(g.dataManager.updateCommunityRating(s,a.rating,a.votes,a.source),!0):!1}async function ss(g,s,t,e){var a,n,r,o,l;let i=g.dataManager.getTitle(s);if(!i)return!1;t&&t.addClass("is-loading");try{let c=(a=g.settings.animeApiSource)!=null?a:"jikan",d=nt(i.type,g.settings.typeApiMapping);if(d==="")return e&&new Yt.Notice(`No API configured for type "${i.type}". Configure it in Settings \u2192 API.`),!1;if(d==="anime"){let h=c==="anilist";if(h?((n=i.anilistId)!=null?n:0)>0:((r=i.malId)!=null?r:0)>0){let m=h?`https://anilist.co/anime/${i.anilistId}`:`https://myanimelist.net/anime/${i.malId}`;i.externalLink!==m&&(i.externalLink=m,await g.dataManager.updateTitle(i))}else{let m=await Zi(g,i,h);if(!m)return e&&new Yt.Notice(`Could not find matching title on ${h?"AniList":"MAL"}.`),!1;let v=g.dataManager.getTitle(s);if(!v)return!1;h?(v.anilistId=(o=m.anilistId)!=null?o:0,v.externalLink=`https://anilist.co/anime/${v.anilistId}`):(v.malId=m.malId,v.externalLink=`https://myanimelist.net/anime/${v.malId}`),await g.dataManager.updateTitle(v),i=v}}let u=await g.apiService.fetchCommunityRating(i,c,d);if(!u)return e&&new Yt.Notice("Could not fetch community rating."),!1;if(g.dataManager.updateCommunityRating(s,u.rating,u.votes,u.source),e){let h=(l=Qs[u.source])!=null?l:u.source;new Yt.Notice(`Rating updated from ${h}.`)}return!0}finally{t&&t.removeClass("is-loading")}}function qs(g){return g.toLowerCase().replace(/[^a-z0-9]+/g," ").trim()}function js(g,s){let t=qs(g),e=qs(s);if(!t||!e)return!1;if(t===e||t.includes(e)||e.includes(t))return!0;let i=new Set(t.split(" ").filter(o=>o.length>2)),a=new Set(e.split(" ").filter(o=>o.length>2));if(i.size===0||a.size===0)return!1;let n=0;for(let o of i)a.has(o)&&n++;let r=Math.min(i.size,a.size);return n/r>=.5}async function Zi(g,s,t){let e=t?await g.apiService.searchAniList(s.title):await g.apiService.searchAnime(s.title);if(!e.length)return null;let i=e[0];if(js(s.title,i.title))return i;for(let a=1;a<Math.min(e.length,5);a++)if(js(s.title,e[a].title))return e[a];return null}var $t=require("obsidian");function Jt(g){return"malId"in g}var ht=class extends $t.Modal{constructor(t,e,i,a,n){super(t);this.selectedType="Anime";this.searchQuery="";this.searchResults=[];this.isSearching=!1;this.searchGeneration=0;this.autoSearch=!1;this.noApiMessage="";this.fieldTitle="";this.fieldEpisodes=0;this.fieldDuration=0;this.fieldReleaseDate="";this.fieldLink="";this.fieldSeasons=[];this.fieldStatus="Plan to watch";this.fieldPriority="Medium";this.fieldDateStarted="";this.fieldMalId=null;this.fieldAnilistId=null;this.skipDuplicateCheck=!1;this.duplicateWarningEl=null;this.selectedGroupId="";this.newGroupName="";this.resultsEl=null;this.formEl=null;this.searchDebounce=null;this.plugin=e,this.dataManager=i,this.onAdded=a,n&&(this.selectedType=n.type,n.title&&(this.fieldTitle=n.title),n.searchQuery&&(this.searchQuery=n.searchQuery,this.autoSearch=!0),this.fieldEpisodes=n.episodes,this.fieldDuration=n.duration,this.fieldReleaseDate=n.releaseDate,this.fieldLink=n.link,this.fieldSeasons=n.seasons)}onOpen(){this.titleEl.setText("Add title"),this.contentEl.addClass("wl-add-modal"),this.buildUI(),this.autoSearch&&this.performSearch()}onClose(){this.contentEl.empty(),this.searchDebounce&&window.clearTimeout(this.searchDebounce)}buildUI(){let t=this.contentEl;t.empty();let e=t.createDiv({cls:"wl-modal-row"});e.createSpan({cls:"wl-modal-label",text:"Type"});let i=e.createEl("select",{cls:"wl-select"});for(let o of this.plugin.settings.types){let l=i.createEl("option",{text:o.name,value:o.name});o.name===this.selectedType&&(l.selected=!0)}i.addEventListener("change",()=>{this.selectedType=i.value,this.searchResults=[],this.noApiMessage="",this.renderResults(),this.renderForm()});let a=t.createDiv({cls:"wl-modal-row"});a.createSpan({cls:"wl-modal-label",text:"Search"});let n=a.createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Search for a title..."}});n.value=this.searchQuery,n.addEventListener("input",()=>{this.searchQuery=n.value,this.searchDebounce&&window.clearTimeout(this.searchDebounce),this.searchDebounce=window.setTimeout(()=>void this.performSearch(),600)}),a.createEl("button",{cls:"wl-btn",text:"Search"}).addEventListener("click",()=>void this.performSearch()),this.resultsEl=t.createDiv({cls:"wl-modal-results"}),this.formEl=t.createDiv({cls:"wl-modal-form"}),this.renderForm()}async performSearch(){var e,i;if(!this.searchQuery.trim())return;let t=++this.searchGeneration;this.isSearching=!0,this.renderResults();try{let a=this.plugin.apiService,n=(e=this.plugin.settings.activeApi)!=null?e:"OMDb",r=nt(this.selectedType,this.plugin.settings.typeApiMapping),o=this.selectedType==="Movie",l=[],c="";if(r===""?c=`No API configured for type "${this.selectedType}". Go to Settings \u2192 API to set one up.`:r==="anime"?l=((i=this.plugin.settings.animeApiSource)!=null?i:"jikan")==="anilist"?await a.searchAniList(this.searchQuery):await a.searchAnime(this.searchQuery):n==="TMDB"?l=await a.searchTmdb(this.searchQuery,o?"movie":"series"):l=await a.searchOmdb(this.searchQuery,o?"movie":"series"),t!==this.searchGeneration)return;this.noApiMessage=c,this.searchResults=l}catch(a){if(t!==this.searchGeneration)return;new $t.Notice("Search failed. Check your connection and API settings."),this.searchResults=[]}finally{t===this.searchGeneration&&(this.isSearching=!1,this.renderResults())}}renderResults(){if(this.resultsEl){if(this.resultsEl.empty(),this.isSearching){this.resultsEl.createDiv({cls:"wl-modal-loading",text:"Searching..."});return}if(this.noApiMessage){let t=this.resultsEl.createDiv({cls:"wl-modal-no-api"});t.createSpan({cls:"wl-modal-no-api-icon",text:"\u2139"}),t.createSpan({cls:"wl-modal-no-api-text",text:this.noApiMessage});return}this.searchResults.length!==0&&this.searchResults.forEach((t,e)=>{let i=this.resultsEl.createDiv({cls:"wl-result-item"}),a=Jt(t)?`${t.episodes} eps`:t.episodes>0?`${t.episodes} eps`:t.mediaType;i.createDiv({cls:"wl-result-title",text:t.title}),i.createDiv({cls:"wl-result-meta",text:`${a} \xB7 ${t.releaseDate}`}),i.dataset.idx=String(e),i.addEventListener("click",()=>void this.selectResult(t,i))})}}async selectResult(t,e){var r;if(this.resultsEl)for(let o of Array.from(this.resultsEl.children))o.removeClass("is-selected");e.addClass("is-selected");let i=++this.searchGeneration,a=this.plugin.apiService,n=(r=this.plugin.settings.activeApi)!=null?r:"OMDb";try{if(!Jt(t)&&t.mediaType==="tv"){let o=n==="TMDB"?await a.getTmdbTvDetails(t.imdbId):await a.getOmdbTvDetails(t.imdbId);if(i!==this.searchGeneration)return;o&&(this.fieldTitle=o.title,this.fieldEpisodes=o.episodes,this.fieldDuration=o.episodeDuration,this.fieldReleaseDate=o.releaseDate,this.fieldLink=o.url,this.fieldSeasons=o.seasons)}else if(!Jt(t)&&t.mediaType==="movie"){let o=n==="TMDB"?await a.getTmdbMovieDetails(t.imdbId):await a.getOmdbMovieDetails(t.imdbId);if(i!==this.searchGeneration)return;o&&(this.fieldTitle=o.title,this.fieldEpisodes=o.episodes,this.fieldDuration=o.episodeDuration,this.fieldReleaseDate=o.releaseDate,this.fieldLink=o.url,this.fieldSeasons=o.seasons)}else{let o=t;this.fieldTitle=o.title,this.fieldEpisodes=o.episodes,this.fieldDuration=o.duration,this.fieldReleaseDate=o.releaseDate,this.fieldLink=o.url,this.fieldSeasons=o.seasons,o.anilistId&&o.anilistId>0?(this.fieldAnilistId=o.anilistId,this.fieldMalId=null):(this.fieldMalId=o.malId,this.fieldAnilistId=null)}}catch(o){this.fieldTitle=t.title,this.fieldEpisodes=(Jt(t),t.episodes),this.fieldDuration=Jt(t)?t.duration:t.episodeDuration,this.fieldReleaseDate=t.releaseDate,this.fieldLink=t.url,this.fieldSeasons=t.seasons}this.renderForm()}renderForm(){if(!this.formEl)return;this.formEl.empty();let t=M=>{let L=this.formEl.createDiv({cls:"wl-modal-row"});return L.createSpan({cls:"wl-modal-label",text:M}),L},i=t("Title").createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Title name"}});i.value=this.fieldTitle,i.addEventListener("input",()=>{this.fieldTitle=i.value});let n=t("Episodes").createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"0",placeholder:"0"}});n.value=String(this.fieldEpisodes),n.addEventListener("input",()=>{this.fieldEpisodes=parseInt(n.value)||0});let o=t("Ep. duration (min)").createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"0",placeholder:"24"}});o.value=String(this.fieldDuration),o.addEventListener("input",()=>{this.fieldDuration=parseInt(o.value)||0});let c=t("Release date").createDiv({cls:"wl-modal-input-stack"}),d=c.createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Date (dd-mm-yyyy or yyyy-mm-dd)"}});d.value=this.fieldReleaseDate;let u=c.createDiv({cls:"wl-modal-error wl-hidden"});d.addEventListener("change",()=>{let M=d.value.trim();if(!M){this.fieldReleaseDate="",u.addClass("wl-hidden");return}let L=Bt(M);L?(this.fieldReleaseDate=L,d.value=L,u.addClass("wl-hidden")):(this.fieldReleaseDate=M,u.textContent="Unrecognised format. Expected dd/mm/yyyy or yyyy-mm-dd.",u.removeClass("wl-hidden"))});let p=t("External link").createEl("input",{cls:"wl-modal-input",attr:{type:"url",placeholder:"https://example.com"}});p.value=this.fieldLink,p.addEventListener("input",()=>{this.fieldLink=p.value});let v=t("Status").createEl("select",{cls:"wl-select"});for(let M of this.plugin.settings.statuses.filter(L=>L.name!=="To be released")){let L=v.createEl("option",{text:M.name,value:M.name});M.name===this.fieldStatus&&(L.selected=!0)}v.addEventListener("change",()=>{this.fieldStatus=v.value});let y=t("Priority").createEl("select",{cls:"wl-select"});for(let M of this.plugin.settings.priorities){let L=y.createEl("option",{text:M.name,value:M.name});M.name===this.fieldPriority&&(L.selected=!0)}y.addEventListener("change",()=>{this.fieldPriority=y.value});let b=t("Date started").createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"15/01/2024",maxlength:"10"}});b.value=this.fieldDateStarted?Q(this.fieldDateStarted):"",b.addEventListener("change",()=>{let M=q(b.value);b.value.trim()&&!M?b.addClass("wl-input-error"):(b.removeClass("wl-input-error"),this.fieldDateStarted=M!=null?M:"")});let S=this.dataManager.getGroups(),E=t("Add to group").createEl("select",{cls:"wl-select"});E.createEl("option",{text:"\u2014 none \u2014",value:""});for(let M of S){let L=E.createEl("option",{text:M.name,value:M.id});M.id===this.selectedGroupId&&(L.selected=!0)}let C=t("Or create new group").createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"New group name..."}});C.value=this.newGroupName,E.addEventListener("change",()=>{this.selectedGroupId=E.value,this.selectedGroupId?(this.newGroupName="",C.value="",C.disabled=!0):C.disabled=!1}),C.addEventListener("input",()=>{this.newGroupName=C.value,this.newGroupName.trim()?(this.selectedGroupId="",E.value="",E.disabled=!0):E.disabled=!1}),this.selectedGroupId&&(C.disabled=!0),this.newGroupName.trim()&&(E.disabled=!0),this.fieldSeasons.length>0&&t("Seasons").createSpan({cls:"wl-modal-info",text:this.fieldSeasons.map(L=>`${L.name} (${L.episodes} eps)`).join(", ")}),this.formEl.createDiv({cls:"wl-modal-btn-row"}).createEl("button",{cls:"wl-btn wl-btn-primary",text:"Add to watchlog"}).addEventListener("click",()=>void this.addTitle())}showDuplicateWarning(t,e){if(this.duplicateWarningEl)return;let i=e.createDiv({cls:"wl-duplicate-warning"});this.duplicateWarningEl=i,i.createSpan({text:`"${t}" already exists. Add anyway?`});let a=i.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Continue"}),n=i.createEl("button",{cls:"wl-btn",text:"Cancel"});a.addEventListener("click",()=>{this.skipDuplicateCheck=!0,i.remove(),this.duplicateWarningEl=null,this.addTitle()}),n.addEventListener("click",()=>{i.remove(),this.duplicateWarningEl=null,this.skipDuplicateCheck=!1})}async addTitle(){var i;let t=this.fieldTitle.trim();if(!t){new $t.Notice("Please enter a title name.");return}if(!this.skipDuplicateCheck&&this.dataManager.getTitles().find(n=>n.title.toLowerCase()===t.toLowerCase())){let n=this.contentEl.querySelector(".wl-modal-btn-row");n&&this.showDuplicateWarning(t,n);return}this.skipDuplicateCheck=!1;let e={id:this.dataManager.generateId(t),title:t,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}:{},...this.fieldAnilistId!==null?{anilistId:this.fieldAnilistId}:{}};if(e.status==="Completed"&&e.totalEpisodes>0&&(e.watchedEpisodes=Array.from({length:e.totalEpisodes},(a,n)=>n+1),this.plugin.settings.setFinishDateAutomatically&&(e.dateFinished=(i=new Date().toISOString().split("T")[0])!=null?i:null)),await this.dataManager.addTitle(e),(async()=>{var o;let a=(o=this.plugin.settings.animeApiSource)!=null?o:"jikan",n=nt(e.type,this.plugin.settings.typeApiMapping);if(n==="")return;let r=await this.plugin.apiService.fetchCommunityRating(e,a,n);r&&this.dataManager.updateCommunityRating(e.id,r.rating,r.votes,r.source)})(),this.selectedGroupId)await this.dataManager.addTitleToGroup(this.selectedGroupId,e.id);else if(this.newGroupName.trim()){let a=this.newGroupName.trim(),n={id:this.dataManager.generateGroupId(a),name:a,titleIds:[e.id],dateAdded:new Date().toISOString()};await this.dataManager.addGroup(n)}new $t.Notice(`"${t}" added to WatchLog.`),this.close(),this.onAdded()}};var Ys=require("obsidian");var ye=class extends Ys.Modal{constructor(t,e,i,a){super(t);this.urlInput=null;this.errorEl=null;this.addBtn=null;this.plugin=e,this.dataManager=i,this.onAdded=a}onOpen(){this.titleEl.setText("Add from URL");let t=this.contentEl;t.addClass("wl-add-modal"),t.createDiv({cls:"wl-modal-info",text:"Please enter an IMDb URL (e.g. https://www.imdb.com/title/tt1375666/)"});let e=t.createDiv({cls:"wl-modal-row"});this.urlInput=e.createEl("input",{cls:"wl-modal-input",attr:{type:"url",placeholder:"https://www.imdb.com/title/ttXXXXXXX/"}}),this.urlInput.addEventListener("keydown",a=>{a.key==="Enter"&&this.handleAdd()}),this.errorEl=t.createDiv({cls:"wl-modal-error"}),this.errorEl.hide();let i=t.createDiv({cls:"wl-modal-btn-row"});this.addBtn=i.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Add"}),this.addBtn.addEventListener("click",()=>void this.handleAdd()),window.setTimeout(()=>{var a;return(a=this.urlInput)==null?void 0:a.focus()},50)}onClose(){this.contentEl.empty()}async handleAdd(){var a,n;let e=((n=(a=this.urlInput)==null?void 0:a.value.trim())!=null?n:"").match(/tt\d+/);if(!e){this.showError("Title not found. Please check the URL and try again.");return}let i=e[0];this.addBtn&&(this.addBtn.disabled=!0,this.addBtn.textContent="Loading\u2026"),this.errorEl&&this.errorEl.hide();try{let r=this.plugin.settings.activeApi==="TMDB"?await this.plugin.apiService.getTmdbByImdbId(i):await this.plugin.apiService.getOmdbByImdbId(i);if(!r){this.showError("Title not found. Please check the URL and try again.");return}this.close();let o=r.mediaType==="movie"?"Movie":"TV Show";new ht(this.app,this.plugin,this.dataManager,this.onAdded,{title:r.title,type:o,episodes:r.episodes,duration:r.episodeDuration,releaseDate:r.releaseDate,link:r.url,seasons:r.seasons}).open()}finally{this.addBtn&&(this.addBtn.disabled=!1,this.addBtn.textContent="Add")}}showError(t){this.errorEl&&(this.errorEl.textContent=t,this.errorEl.show())}};var Xt=require("obsidian");function ta(g){var t,e;let s=[];for(let i of g.split(",").map(a=>a.trim()).filter(Boolean)){let a=i.split("-");if(a.length===2){let n=parseInt((t=a[0])!=null?t:"0",10),r=parseInt((e=a[1])!=null?e:"0",10);if(!isNaN(n)&&!isNaN(r)&&n<=r)for(let o=n;o<=r;o++)s.push(o)}else{let n=parseInt(i,10);isNaN(n)||s.push(n)}}return[...new Set(s)].sort((i,a)=>i-a)}function ea(g){if(g.length===0)return"";let s=[...new Set(g)].sort((i,a)=>i-a),t=[],e=0;for(;e<s.length;){let i=e;for(;i+1<s.length&&s[i+1]===s[i]+1;)i++;i>e?t.push(`${s[e]}-${s[i]}`):t.push(String(s[e])),e=i+1}return t.join(",")}function is(g){let s=g.split(` +`).map(i=>i.trim()).filter(Boolean),t=[],e=0;for(let i of s){let a=i.match(/^(.+?):\s*(\d+)(?:\s*\(([^)]+)\))?/);if(a&&a[1]&&a[2]){let n=parseInt(a[2],10),r=a[3]?ta(a[3]):[];t.push({name:a[1].trim(),episodes:n,offset:e,skippedEpisodes:r}),e+=n}}return t}function sa(g){return g.map(s=>{let t=`${s.name}: ${s.episodes}`;return s.skippedEpisodes&&s.skippedEpisodes.length>0?`${t} (${ea(s.skippedEpisodes)})`:t}).join(` +`)}var St=class extends Xt.Modal{constructor(t,e,i,a,n){var r,o,l,c,d;super(t);this.skipDuplicateCheck=!1;this.duplicateWarningEl=null;this.plugin=e,this.dataManager=i,this.original=a,this.onSaved=n,this.fieldTitle=a.title,this.fieldType=a.type,this.fieldEpisodes=a.totalEpisodes,this.fieldDuration=a.episodeDuration,this.fieldReleaseDate=(r=a.releaseDate)!=null?r:"",this.fieldLink=a.externalLink,this.fieldSeasonsText=sa(a.seasons),this.fieldStatus=a.status,this.fieldPriority=a.priority,this.fieldReview=(o=a.review)!=null?o:"",this.fieldRating=a.rating,this.fieldNotes=a.notes,this.fieldDateStarted=(l=a.dateStarted)!=null?l:"",this.fieldDateFinished=(c=a.dateFinished)!=null?c:"",this.fieldPosterUrl=(d=a.manualPosterUrl)!=null?d:""}onOpen(){this.titleEl.setText(`Edit: ${this.original.title}`),this.contentEl.addClass("wl-add-modal"),this.buildUI()}onClose(){this.contentEl.empty()}buildUI(){let t=this.contentEl;t.empty();let e=$=>{let K=t.createDiv({cls:"wl-modal-row"});return K.createSpan({cls:"wl-modal-label",text:$}),K},a=e("Title").createEl("input",{cls:"wl-modal-input",attr:{type:"text"}});a.value=this.fieldTitle,a.addEventListener("input",()=>{this.fieldTitle=a.value});let r=e("Type").createEl("select",{cls:"wl-select"});for(let $ of this.plugin.settings.types){let K=r.createEl("option",{text:$.name,value:$.name});$.name===this.fieldType&&(K.selected=!0)}r.addEventListener("change",()=>{this.fieldType=r.value});let l=e("Status").createEl("select",{cls:"wl-select"}),c=this.fieldStatus==="To be released";for(let $ of this.plugin.settings.statuses){if($.name==="To be released"&&!c)continue;let K=l.createEl("option",{text:$.name,value:$.name});$.name===this.fieldStatus&&(K.selected=!0)}l.addEventListener("change",()=>{this.fieldStatus=l.value});let u=e("Priority").createEl("select",{cls:"wl-select"});for(let $ of this.plugin.settings.priorities){let K=u.createEl("option",{text:$.name,value:$.name});$.name===this.fieldPriority&&(K.selected=!0)}u.addEventListener("change",()=>{this.fieldPriority=u.value});let p=e("Rating").createDiv({cls:"wl-stars"}),m=()=>{p.empty();for(let $=1;$<=5;$++)p.createSpan({cls:`wl-star${this.fieldRating>=$?" is-active":""}`,text:"\u2605"}).addEventListener("click",()=>{this.fieldRating=this.fieldRating===$?0:$,m()})};m();let v=e("Total episodes"),f=v.createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"0"}});f.value=String(this.fieldEpisodes);let y=v.createSpan({cls:"wl-modal-skip-inline"}),w=v.createSpan({cls:"wl-modal-skip-inline"}),b=()=>{let K=is(this.fieldSeasonsText).reduce((_t,Ut)=>{var Vt,Gt;return _t+((Gt=(Vt=Ut.skippedEpisodes)==null?void 0:Vt.length)!=null?Gt:0)},0);if(K===0){y.textContent="",w.textContent="";return}let he=Math.max(0,this.fieldEpisodes-K);y.textContent=`\xB7 ${K} to skip`,w.textContent=`\xB7 ${he} to watch`};f.addEventListener("input",()=>{this.fieldEpisodes=parseInt(f.value)||0,b()});let D=e("Ep. duration (min)").createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"0"}});D.value=String(this.fieldDuration),D.addEventListener("input",()=>{this.fieldDuration=parseInt(D.value)||0});let k=e("Release date").createDiv({cls:"wl-modal-input-stack"}),C=k.createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Date (dd-mm-yyyy or yyyy-mm-dd)"}});C.value=this.fieldReleaseDate;let x=k.createDiv({cls:"wl-modal-error wl-hidden"});C.addEventListener("change",()=>{let $=C.value.trim();if(!$){this.fieldReleaseDate="",x.addClass("wl-hidden");return}let K=Bt($);K?(this.fieldReleaseDate=K,C.value=K,x.addClass("wl-hidden")):(this.fieldReleaseDate=$,x.textContent="Unrecognised format. Expected dd/mm/yyyy or yyyy-mm-dd.",x.removeClass("wl-hidden"))});let M=e("External link").createEl("input",{cls:"wl-modal-input",attr:{type:"url",placeholder:"HTTPS://..."}});M.value=this.fieldLink,M.addEventListener("input",()=>{this.fieldLink=M.value});let j=e("Poster URL").createDiv({cls:"wl-modal-input-stack"}),A=j.createEl("input",{cls:"wl-modal-input",attr:{type:"url",placeholder:"https://example.com/poster.jpg"}});A.value=this.fieldPosterUrl,A.addEventListener("input",()=>{this.fieldPosterUrl=A.value}),j.createDiv({cls:"wl-modal-info",text:"Override the auto-fetched cover. Leave blank to let WatchLog fetch one again."});let I=e("Date started").createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Dd/mm/yyyy",maxlength:"10"}});I.value=Q(this.fieldDateStarted),I.addEventListener("change",()=>{let $=q(I.value);I.value.trim()&&!$?I.addClass("wl-input-error"):(I.removeClass("wl-input-error"),this.fieldDateStarted=$!=null?$:"")});let _=e("Date finished").createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Dd/mm/yyyy",maxlength:"10"}});_.value=Q(this.fieldDateFinished),_.addEventListener("change",()=>{let $=q(_.value);_.value.trim()&&!$?_.addClass("wl-input-error"):(_.removeClass("wl-input-error"),this.fieldDateFinished=$!=null?$:"")});let G=e("Notes").createEl("textarea",{cls:"wl-modal-textarea",attr:{rows:"3",placeholder:"Your notes..."}});G.value=this.fieldNotes,G.addEventListener("input",()=>{this.fieldNotes=G.value});let ft=e("Seasons").createDiv({cls:"wl-modal-input-stack"}),xt=ft.createEl("textarea",{cls:"wl-modal-textarea",attr:{rows:"4",placeholder:`Season 1: 12 +Season 2: 13 (5,8,33-37)`}});xt.value=this.fieldSeasonsText;let U=ft.createDiv({cls:"wl-modal-season-total"}),O=()=>{let $=is(xt.value),K=$.reduce((_t,Ut)=>_t+Ut.episodes,0),he=$.reduce((_t,Ut)=>{var Vt,Gt;return _t+((Gt=(Vt=Ut.skippedEpisodes)==null?void 0:Vt.length)!=null?Gt:0)},0);U.textContent=he>0?`Total episodes: ${K} \xB7 ${he} to skip`:`Total episodes: ${K}`};O(),b(),xt.addEventListener("input",()=>{this.fieldSeasonsText=xt.value,O(),b()}),ft.createDiv({cls:"wl-modal-info",text:'Format: "Season Name: N" (e.g. "Season 1: 12")'}),ft.createDiv({cls:"wl-modal-info",text:'Skip episodes: add "(1,3,5-10)" after count (e.g. "Season 1: 48 (33-37)")'}),ft.createDiv({cls:"wl-modal-info",text:'Note: when adding a new season, remember to update "Total Episodes" so progress calculates correctly.'});let Y=t.createDiv({cls:"wl-modal-btn-row"});Y.createEl("button",{cls:"wl-btn",text:"Cancel"}).addEventListener("click",()=>this.close()),Y.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Save changes"}).addEventListener("click",()=>void this.saveTitle())}showDuplicateWarning(t,e){if(this.duplicateWarningEl)return;let i=e.createDiv({cls:"wl-duplicate-warning"});this.duplicateWarningEl=i,i.createSpan({text:`"${t}" already exists. Save anyway?`});let a=i.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Continue"}),n=i.createEl("button",{cls:"wl-btn",text:"Cancel"});a.addEventListener("click",()=>{this.skipDuplicateCheck=!0,i.remove(),this.duplicateWarningEl=null,this.saveTitle()}),n.addEventListener("click",()=>{i.remove(),this.duplicateWarningEl=null,this.skipDuplicateCheck=!1})}async saveTitle(){let t=this.fieldTitle.trim();if(!t){new Xt.Notice("Title name cannot be empty.");return}if(!this.skipDuplicateCheck&&this.dataManager.getTitles().find(d=>d.id!==this.original.id&&d.title.toLowerCase()===t.toLowerCase())){let d=this.contentEl.querySelector(".wl-modal-btn-row");d&&this.showDuplicateWarning(t,d);return}this.skipDuplicateCheck=!1;let e=is(this.fieldSeasonsText),i=this.fieldReleaseDate||null,a={...this.original,title:t,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:i,externalLink:this.fieldLink,seasons:e,dateStarted:this.fieldDateStarted||null,dateFinished:this.fieldDateFinished||null,manualPosterUrl:this.fieldPosterUrl.trim()};a.status==="Completed"&&a.totalEpisodes>0&&(a.watchedEpisodes=Array.from({length:a.totalEpisodes},(c,d)=>d+1)),await this.dataManager.updateTitle(a);let r=this.dataManager.getAirtimeEntries().find(c=>c.titleId===a.id);if(r&&r.schedule.recurrence==="once"){let c=i!=null?i:void 0;r.schedule.releaseDate!==c&&(r.schedule.releaseDate=c,await this.dataManager.updateAirtimeEntry(r))}let o=new Date;o.setHours(0,0,0,0),(c=>c!==null&&/^\d{4}-\d{2}-\d{2}$/.test(c))(i)?new Date(i+"T12:00:00").getTime()>o.getTime()?(a.status!=="To be released"&&(a.status="To be released",await this.dataManager.updateTitle(a)),this.dataManager.getAirtimeEntries().some(p=>p.titleId===a.id)||await this.dataManager.autoAddToUpcoming(a)):a.status==="To be released"&&(a.status="Plan to watch",await this.dataManager.updateTitle(a),await this.dataManager.removeAirtimeEntriesForTitle(a.id)):!i&&a.status==="To be released"&&(a.status="Plan to watch",await this.dataManager.updateTitle(a),await this.dataManager.removeAirtimeEntriesForTitle(a.id)),new Xt.Notice(`"${t}" updated.`),this.close(),this.onSaved()}};var Js=require("obsidian"),H=class extends Js.Modal{constructor(s,t,e){super(s),this.message=t,this.onConfirm=e}onOpen(){let{contentEl:s}=this;s.createDiv({cls:"wl-confirm-msg",text:this.message});let t=s.createDiv({cls:"wl-modal-btn-row"});t.createEl("button",{cls:"wl-btn wl-btn-danger",text:"Confirm"}).addEventListener("click",()=>{this.close(),this.onConfirm()}),t.createEl("button",{cls:"wl-btn",text:"Cancel"}).addEventListener("click",()=>this.close())}onClose(){this.contentEl.empty()}};var as=require("obsidian");var Zt=class extends as.Modal{constructor(t,e,i,a){super(t);this.changed=!1;this.collapsedSeasons=new Set;this.statsBoxEl=null;this.episodesSectionEl=null;this.starsWrapEl=null;this.plugin=e,this.dataManager=e.dataManager,this.title=i,this.onChanged=a,this.collapsedSeasons=this.dataManager.getCollapsedSeasonsForTitle(i.id)}onOpen(){var e;let t=(e=this.plugin.settings.colorTheme)!=null?e:"default";this.modalEl.setAttribute("data-theme",t),this.contentEl.setAttribute("data-theme",t),this.contentEl.addClass("wl-view"),this.contentEl.addClass("wl-detail-modal"),this.renderAll()}onClose(){this.contentEl.empty(),this.changed&&this.onChanged&&this.onChanged()}getTagDef(t,e){return e.find(i=>i.name===t)}refreshTitle(){let t=this.dataManager.getTitle(this.title.id);t&&(this.title=t)}markChanged(){this.changed=!0}renderAll(){this.contentEl.empty(),this.renderHeader(this.contentEl),this.renderEpisodesSection(this.contentEl),this.renderRatingSection(this.contentEl),this.renderNotesSection(this.contentEl),this.renderDateSection(this.contentEl),this.renderFooter(this.contentEl)}renderHeader(t){let e=t.createDiv({cls:"wl-detail-header"}),i=e.createDiv({cls:"wl-detail-header-left"});i.createEl("h2",{cls:"wl-detail-title",text:this.title.title});let a=i.createDiv({cls:"wl-detail-badge-row"}),n=this.getTagDef(this.title.type,this.plugin.settings.types),r=n?N(this.title.type,n.color,this.plugin.settings.colorTheme):"#888780",o=a.createSpan({cls:"wl-card-type-badge",text:this.title.type});if(o.style.backgroundColor=r,this.title.externalLink){let c=a.createEl("a",{cls:"wl-acc-link-icon",text:"\u{1F310}"});c.href=this.title.externalLink,c.title="Open external link",c.target="_blank",c.rel="noopener noreferrer"}let l=a.createSpan({cls:"wl-reading-detail-status-wrap"});this.renderStatusBadge(l),this.statsBoxEl=e.createDiv({cls:"wl-detail-stats-box"}),this.renderStatsBox()}renderStatsBox(){if(!this.statsBoxEl)return;this.statsBoxEl.empty();let t=this.title,e=this.dataManager.calcTimeRemainingForModal(t),i=this.dataManager.calcTimeWatched(t),a=t.watchedEpisodes.length,n=this.dataManager.getEffectiveTotal(t),r=this.dataManager.getProgress(t),o=(d,u)=>{let h=this.statsBoxEl.createDiv({cls:"wl-acc-stat-block"});h.createDiv({cls:"wl-acc-percent",text:d}),h.createDiv({cls:"wl-acc-progress-label",text:u})};o(z(e),"left"),o(z(i),"watched"),o(`${a} / ${n}`,"episodes");let l=this.statsBoxEl.createDiv({cls:"wl-acc-header-right"});l.createDiv({cls:"wl-acc-percent",text:`${r}%`}),l.createDiv({cls:"wl-acc-progress-label",text:"progress"});let c=l.createDiv({cls:"wl-acc-progress-wrap"});c.createDiv({cls:"wl-progress-bar"}).style.width=`${r}%`}renderEpisodesSection(t){this.episodesSectionEl=t.createDiv({cls:"wl-detail-episodes"}),this.renderEpisodesBody()}renderEpisodesBody(){if(!this.episodesSectionEl)return;this.episodesSectionEl.empty();let t=this.title;if(t.type==="Movie"){let e=this.episodesSectionEl.createDiv({cls:"wl-movie-row"}),i=e.createEl("input",{cls:"wl-movie-checkbox",attr:{type:"checkbox"}});i.checked=t.watchedEpisodes.includes(1),e.createSpan({cls:"wl-movie-label",text:"Watched"}),i.addEventListener("change",()=>{this.dataManager.markEpisodeWatched(t.id,1,i.checked).then(()=>{this.refreshTitle(),this.markChanged(),this.renderStatsBox()})});return}if(t.seasons.length===0){t.totalEpisodes>0&&this.renderEpisodeGrid(this.episodesSectionEl,null);return}t.seasons.forEach((e,i)=>{var h,p;let a=this.collapsedSeasons.has(i),n=this.episodesSectionEl.createDiv({cls:"wl-season-wrap"}),r=n.createDiv({cls:"wl-season-header"}),o=r.createSpan({cls:"wl-season-badge"});o.textContent=e.name;let l=this.plugin.settings.seasonPalette;o.style.backgroundColor=(h=l[i%l.length])!=null?h:"#888780";let c=((p=e.skippedEpisodes)!=null?p:[]).length,d=c>0?` (${c} to skip)`:"";r.createSpan({cls:"wl-season-ep-count",text:`${e.episodes} eps${d}`});let u=r.createSpan({cls:`wl-chevron${a?"":" is-open"}`,text:"\u203A"});a||this.renderEpisodeGrid(n,e),r.addEventListener("click",()=>{var m;a?(a=!1,this.collapsedSeasons.delete(i),u.classList.add("is-open"),this.renderEpisodeGrid(n,e)):(a=!0,this.collapsedSeasons.add(i),u.classList.remove("is-open"),(m=n.querySelector(".wl-episode-grid"))==null||m.remove()),this.dataManager.persistCollapsedSeasons(this.title.id,this.collapsedSeasons)})})}renderEpisodeGrid(t,e){let i=this.title,a=t.createDiv({cls:"wl-episode-grid"}),n=e?e.episodes:i.totalEpisodes,r=e?e.offset:0,o=Array.from({length:n},(u,h)=>r+h+1),l=a.createDiv({cls:"wl-season-fill-btn"}),c=()=>{let u=new Set(this.title.watchedEpisodes),h=o.length>0&&o.every(p=>u.has(p));l.classList.toggle("is-clear",h),l.classList.toggle("is-fill",!h),l.textContent=h?"\u2717":"\u2713",l.title=h?"Clear all episodes in this season":"Mark all episodes in this season as watched"};c(),l.addEventListener("click",()=>{let u=new Set(this.title.watchedEpisodes),h=o.length>0&&o.every(p=>u.has(p));this.dataManager.markSeasonWatched(this.title.id,o,!h).then(()=>{this.refreshTitle(),this.markChanged(),this.renderEpisodesBody(),this.renderStatsBox()})});let d=this.plugin.settings.episodeNumbering==="per-season";for(let u=0;u<n;u++){let h=r+u+1,p=u+1,m=d?p:h,v=a.createDiv({cls:"wl-episode-box"}),f=()=>{var b;let y=this.title.watchedEpisodes.includes(h),w=e?((b=e.skippedEpisodes)!=null?b:[]).includes(p):!1;v.classList.toggle("is-watched",y),v.classList.toggle("is-skipped",w),v.textContent=w&&!y?"\u2014":y?"\u2713":String(m),v.title=`Episode ${h}${w?" (skipped)":""}`};f(),v.addEventListener("click",()=>{let y=this.title.watchedEpisodes.includes(h);this.dataManager.applyEpisodeWatchedToggle(this.title.id,h,!y),this.refreshTitle(),this.markChanged(),f(),c(),this.renderStatsBox()})}}renderRatingSection(t){let e=t.createDiv({cls:"wl-detail-rating"});e.createSpan({cls:"wl-stars-label",text:"Rating"}),this.starsWrapEl=e.createDiv({cls:"wl-stars wl-detail-stars"}),this.renderStars();let i=()=>{var r;let n=e.querySelector(".wl-rating-divider");for(;n;){let o=n;n=n.nextSibling,(r=o.parentNode)==null||r.removeChild(o)}Ft(e,this.plugin,this.title.id,i),this.refreshTitle()};Ft(e,this.plugin,this.title.id,i),we(this.plugin,this.title.id,i)}renderStars(){if(this.starsWrapEl){this.starsWrapEl.empty();for(let t=1;t<=5;t++)this.starsWrapEl.createSpan({cls:`wl-star${this.title.rating>=t?" is-active":""}`,text:"\u2605"}).addEventListener("click",()=>{(async()=>{let i=this.dataManager.getTitle(this.title.id);i&&(i.rating=i.rating===t?0:t,await this.dataManager.updateTitle(i),this.refreshTitle(),this.markChanged(),this.renderStars())})()})}}renderNotesSection(t){let e=t.createDiv({cls:"wl-detail-notes"});e.createSpan({cls:"wl-stars-label",text:"Notes"});let i=e.createEl("textarea",{cls:"wl-detail-notes-input",attr:{placeholder:"Add notes...",rows:"3"}});i.value=this.title.notes;let a=()=>{i.style.height="auto",i.style.height=`${i.scrollHeight}px`};i.addEventListener("input",a),window.setTimeout(a,0),i.addEventListener("blur",()=>{i.value!==this.title.notes&&(async()=>{let n=this.dataManager.getTitle(this.title.id);n&&(n.notes=i.value,await this.dataManager.updateTitle(n),this.refreshTitle(),this.markChanged())})()})}renderDateSection(t){let e=t.createDiv({cls:"wl-detail-date"});e.createSpan({cls:"wl-stars-label",text:"Date watched"});let i=e.createEl("button",{cls:"wl-btn wl-btn-sm wl-footer-today-btn",text:"Today"}),a=e.createEl("input",{cls:"wl-footer-date",attr:{type:"text",placeholder:"Dd/mm/yyyy",maxlength:"10"}});a.value=Q(this.title.dateFinished);let n=()=>{i.toggleClass("is-dimmed",!!a.value.trim())};n(),a.addEventListener("change",()=>{(async()=>{let r=this.dataManager.getTitle(this.title.id);if(!r)return;let o=q(a.value);if(a.value.trim()&&!o){a.addClass("wl-input-error");return}a.removeClass("wl-input-error"),r.dateFinished=o,await this.dataManager.updateTitle(r),this.refreshTitle(),this.markChanged(),n()})()}),i.addEventListener("click",()=>{if(a.value.trim())return;let r=new Date,o=String(r.getDate()).padStart(2,"0"),l=String(r.getMonth()+1).padStart(2,"0"),c=r.getFullYear();a.value=`${o}/${l}/${c}`,n(),a.dispatchEvent(new Event("change"))})}statusColor(t){let e=this.getTagDef(t,this.plugin.settings.statuses);return e?N(t,e.color,this.plugin.settings.colorTheme):"#888780"}renderStatusBadge(t){t.empty();let e=this.title.status,i=t.createSpan({cls:"wl-reading-detail-status",text:e});i.style.backgroundColor=this.statusColor(e),i.title="Click to change status",i.addEventListener("click",a=>{a.stopPropagation(),this.openStatusDropdown(i,t)})}openStatusDropdown(t,e){this.contentEl.querySelectorAll(".wl-reading-status-dropdown").forEach(o=>o.remove());let i=t.getBoundingClientRect(),a=this.contentEl.createDiv({cls:"wl-reading-status-dropdown"});a.style.top=`${i.bottom+4}px`,a.style.left=`${i.left}px`;let n=this.contentEl.ownerDocument;for(let o of this.plugin.settings.statuses){if(o.name==="To be released")continue;let l=a.createDiv({cls:"wl-reading-status-option"}),c=l.createSpan({cls:"wl-reading-status-option-dot"});c.style.backgroundColor=this.statusColor(o.name),l.createSpan({text:o.name}),l.addEventListener("click",()=>{a.remove(),n.removeEventListener("mousedown",r,!0),this.saveStatus(o.name).then(()=>this.renderStatusBadge(e))})}let r=o=>{a.contains(o.target)||(a.remove(),n.removeEventListener("mousedown",r,!0))};window.setTimeout(()=>n.addEventListener("mousedown",r,!0),0)}async saveStatus(t){let e=this.dataManager.getTitle(this.title.id);e&&(e.status=t,await this.dataManager.updateTitle(e),this.refreshTitle(),this.markChanged())}renderFooter(t){let e=t.createDiv({cls:"wl-detail-footer"});e.createEl("button",{cls:"wl-delete-btn wl-btn-danger wl-detail-remove",text:"Remove"}).addEventListener("click",()=>{new H(this.plugin.app,`Remove "${this.title.title}" from watchlog?`,()=>{this.dataManager.removeTitle(this.title.id).then(()=>{this.markChanged(),this.close()})}).open()}),e.createEl("button",{cls:"wl-edit-btn wl-detail-edit",text:"Edit"}).addEventListener("click",()=>{let n=this.dataManager.getTitle(this.title.id);n&&(this.close(),new St(this.plugin.app,this.plugin,this.dataManager,n,()=>{this.onChanged&&this.onChanged()}).open())})}},be=class extends as.Modal{constructor(t,e,i,a,n){super(t);this.changed=!1;this.plugin=e,this.dataManager=e.dataManager,this.group=i,this.members=a,this.onChanged=n}onOpen(){var e;let t=(e=this.plugin.settings.colorTheme)!=null?e:"default";this.modalEl.setAttribute("data-theme",t),this.contentEl.setAttribute("data-theme",t),this.contentEl.addClass("wl-view"),this.contentEl.addClass("wl-detail-modal"),this.renderAll()}onClose(){this.contentEl.empty(),this.changed&&this.onChanged&&this.onChanged()}renderAll(){this.contentEl.empty();let t=this.contentEl.createDiv({cls:"wl-detail-header"}),e=t.createDiv({cls:"wl-detail-header-left"});e.createEl("h2",{cls:"wl-detail-title",text:this.group.name}),e.createDiv({cls:"wl-detail-group-meta",text:`${this.members.length} title${this.members.length!==1?"s":""}`});let i=this.members.reduce((d,u)=>d+this.dataManager.getEffectiveTotal(u),0),a=this.members.reduce((d,u)=>d+u.watchedEpisodes.length,0),n=i>0?Math.round(a/i*100):0,o=t.createDiv({cls:"wl-detail-stats-box"}).createDiv({cls:"wl-acc-header-right"});o.createDiv({cls:"wl-acc-percent",text:`${n}%`}),o.createDiv({cls:"wl-acc-progress-label",text:"progress"});let l=o.createDiv({cls:"wl-acc-progress-wrap"});l.createDiv({cls:"wl-progress-bar"}).style.width=`${n}%`;let c=this.contentEl.createDiv({cls:"wl-cards-grid wl-detail-group-grid"});for(let d of this.members){let u=ia(this.plugin,d,{onOpenDetail:()=>{this.close(),new Zt(this.plugin.app,this.plugin,d,()=>{this.changed=!0,this.onChanged&&this.onChanged()}).open()},onEdit:()=>{this.close(),new St(this.plugin.app,this.plugin,this.dataManager,d,()=>{this.changed=!0,this.onChanged&&this.onChanged()}).open()}});c.appendChild(u)}}};function ia(g,s,t){var b;let e=g.settings,i=e.types.find(S=>S.name===s.type),a=e.statuses.find(S=>S.name===s.status),n=i?N(s.type,i.color,e.colorTheme):"#888780",o=activeDocument.createElement("div").createDiv({cls:"wl-card"});o.dataset.titleId=s.id;let l=o.createDiv({cls:"wl-card-poster-placeholder"});l.style.backgroundColor=n;let c=(s.title.trim().charAt(0)||"?").toUpperCase();l.createSpan({text:c});let d=o.createEl("img",{cls:"wl-card-poster"});d.alt=s.title;let u=S=>{d.src=S,d.style.display="block",l.style.display="none"},h=()=>{d.style.display="none",l.style.display=""},p=ge(s),m=!!(s.manualPosterUrl&&s.manualPosterUrl.trim()!=="");if(p&&p.startsWith("http")?(u(p),d.onerror=()=>{h(),m||g.dataManager.updatePosterUrl(s.id,"none")}):!m&&s.posterUrl===""&&(l.addClass("is-loading"),(b=g.posterService)==null||b.enqueue(s).then(S=>{l.removeClass("is-loading"),S&&u(S)})),a){let S=o.createSpan({cls:"wl-card-status-badge",text:s.status});S.style.backgroundColor=N(s.status,a.color,e.colorTheme)}let v=o.createDiv({cls:"wl-card-overlay"});v.createSpan({cls:"wl-card-title",text:s.title});let f=v.createSpan({cls:"wl-card-type-badge",text:s.type});f.style.backgroundColor=n;let y=s.totalEpisodes;if(y&&y>0){let D=s.status==="Completed"?1:Math.max(0,Math.min(1,s.watchedEpisodes.length/y)),k=v.createDiv({cls:"wl-card-progress-bar"}).createDiv({cls:"wl-card-progress-fill"});k.style.width=`${D*100}%`}let w=o.createEl("button",{cls:"wl-card-menu-btn",text:"\u22EE"});return w.setAttr("aria-label","Edit"),w.addEventListener("click",S=>{S.stopPropagation(),t.onEdit()}),o.addEventListener("click",()=>t.onOpenDetail()),o}var te=["High","Medium","Low"],et=class et{constructor(s,t,e){this.filterTypeExclude=new Set;this.filterStatusExclude=new Set;this.filterPriorityExclude=new Set;this.filterRatingExclude=new Set;this.filterGroupExclude=new Set;this.filterSort="dateAdded";this.filterSortDir="desc";this.filterSecondSort="none";this.filterSecondSortDir="asc";this.searchQuery="";this.expandedId=null;this.collapsedSeasons=new Set;this.expandedGroups=new Set;this.renamingGroupId=null;this.selectionMode=!1;this.selectedItems=new Set;this.pinnedGroupId=null;this.savedFilterActive=!1;this.savedFilterBtnEl=null;this.filterRatingEmptyOnly=!1;this.filterPriorityEmptyOnly=!1;this.filterRecentlyArrivedOnly=!1;this.filterGroupsOnly=!1;this.currentSubTab="list";this.scrollCleanup=null;this.cardsObserver=null;this.observedCards=new Set;this.cardsScrollContainer=null;this.cardsScrollSpacer=null;this.cardsGridEl=null;this.cardsDisplayItems=[];this.cardsScrollHandler=null;this.cardsScrollRAF=null;this.cardsResizeObserver=null;this.cardsLastFirst=-1;this.cardsLastLast=-1;this.cardsLastScrollTop=0;this._cardsPersistentScrollTop=0;this.cardsRowHeight=0;this._lastScrollTop=0;this.scrollPositionListener=null;this.activeCleanups=[];this.STATUS_ORDER=["Watching","Plan to watch","Completed","To be released","Dropped"];var n,r,o,l,c,d,u,h,p,m,v,f,y,w,b,S,D;this.container=s,this.plugin=t,this.dataManager=e,this.currentSubTab=t.settings.defaultWatchlistView==="list"?"list":"cards",this.scrollPositionListener=()=>{this._lastScrollTop=this.container.scrollTop},this.container.addEventListener("scroll",this.scrollPositionListener,{passive:!0}),this.pinnedGroupId=e.getPinnedGroupId();let i=t.settings.listFilters;if(i){this.filterTypeExclude=new Set((n=i.typeExclude)!=null?n:[]),this.filterStatusExclude=new Set((r=i.statusExclude)!=null?r:[]),this.filterGroupExclude=new Set((o=i.groupExclude)!=null?o:[]),this.filterRatingExclude=new Set((l=i.ratingExclude)!=null?l:[]),this.filterPriorityExclude=new Set((c=i.priorityExclude)!=null?c:[]);let E=et.migrateSortKey((d=i.sort)!=null?d:"dateAdded-newest");this.filterSort=E.key,this.filterSortDir=(u=i.sortDir)!=null?u:E.dir;let k=et.migrateSortKey((h=i.secondSort)!=null?h:"none");this.filterSecondSort=k.key,this.filterSecondSortDir=(p=i.secondSortDir)!=null?p:k.dir,this.filterRatingEmptyOnly=(m=i.ratingEmptyOnly)!=null?m:!1,this.filterPriorityEmptyOnly=(v=i.priorityEmptyOnly)!=null?v:!1,this.filterRecentlyArrivedOnly=(f=i.recentlyArrivedOnly)!=null?f:!1,this.filterGroupsOnly=(y=i.groupsOnly)!=null?y:!1}let a=e.getSavedFilterPreset();if(a){let E=(k,C)=>k.size===C.length&&C.every(x=>k.has(x));this.savedFilterActive=E(this.filterTypeExclude,a.typeExclude)&&E(this.filterStatusExclude,a.statusExclude)&&E(this.filterGroupExclude,a.groupExclude)&&E(this.filterRatingExclude,a.ratingExclude)&&E(this.filterPriorityExclude,a.priorityExclude)&&this.filterRatingEmptyOnly===((w=a.ratingEmptyOnly)!=null?w:!1)&&this.filterPriorityEmptyOnly===((b=a.priorityEmptyOnly)!=null?b:!1)&&this.filterRecentlyArrivedOnly===((S=a.recentlyArrivedOnly)!=null?S:!1)&&this.filterGroupsOnly===((D=a.groupsOnly)!=null?D:!1)}}saveFiltersToSettings(){this.plugin.settings.listFilters={typeExclude:Array.from(this.filterTypeExclude),statusExclude:Array.from(this.filterStatusExclude),groupExclude:Array.from(this.filterGroupExclude),ratingExclude:Array.from(this.filterRatingExclude),priorityExclude:Array.from(this.filterPriorityExclude),sort:this.filterSort,sortDir:this.filterSortDir,secondSort:this.filterSecondSort,secondSortDir:this.filterSecondSortDir,ratingEmptyOnly:this.filterRatingEmptyOnly,priorityEmptyOnly:this.filterPriorityEmptyOnly,recentlyArrivedOnly:this.filterRecentlyArrivedOnly,groupsOnly:this.filterGroupsOnly},this.plugin.saveSettings()}hasActiveFilter(){return this.filterTypeExclude.size>0||this.filterStatusExclude.size>0||this.filterPriorityExclude.size>0||this.filterRatingExclude.size>0||this.filterGroupExclude.size>0||this.filterRatingEmptyOnly||this.filterPriorityEmptyOnly||this.filterRecentlyArrivedOnly||this.filterGroupsOnly}clearAllFilters(){this.filterTypeExclude.clear(),this.filterStatusExclude.clear(),this.filterPriorityExclude.clear(),this.filterRatingExclude.clear(),this.filterGroupExclude.clear(),this.filterRatingEmptyOnly=!1,this.filterPriorityEmptyOnly=!1,this.filterRecentlyArrivedOnly=!1,this.filterGroupsOnly=!1}applyPreset(s){var t,e,i,a;this.filterTypeExclude=new Set(s.typeExclude),this.filterStatusExclude=new Set(s.statusExclude),this.filterGroupExclude=new Set(s.groupExclude),this.filterRatingExclude=new Set(s.ratingExclude),this.filterPriorityExclude=new Set(s.priorityExclude),this.filterRatingEmptyOnly=(t=s.ratingEmptyOnly)!=null?t:!1,this.filterPriorityEmptyOnly=(e=s.priorityEmptyOnly)!=null?e:!1,this.filterRecentlyArrivedOnly=(i=s.recentlyArrivedOnly)!=null?i:!1,this.filterGroupsOnly=(a=s.groupsOnly)!=null?a:!1}deactivateSavedFilter(){var s;this.savedFilterActive&&(this.savedFilterActive=!1,(s=this.savedFilterBtnEl)==null||s.removeClass("wl-btn-preset-active"))}static isRecentlyArrived(s){if(!s.releaseDate||!/^\d{4}-\d{2}-\d{2}$/.test(s.releaseDate))return!1;let t=new Date(s.releaseDate+"T00:00:00").getTime(),e=new Date;e.setHours(0,0,0,0);let i=(e.getTime()-t)/864e5;return i>=0&&i<=7}titlePassesFilters(s){if(this.filterTypeExclude.size>0&&this.filterTypeExclude.has(s.type))return!1;if(this.filterRecentlyArrivedOnly){if(!et.isRecentlyArrived(s))return!1}else if(this.filterStatusExclude.size>0&&this.filterStatusExclude.has(s.status))return!1;if(this.filterRatingEmptyOnly){if(s.rating!==0)return!1}else if(this.filterRatingExclude.size>0&&this.filterRatingExclude.has(`${s.rating}\u2605`))return!1;if(this.filterPriorityEmptyOnly){if(s.priority)return!1}else if(this.filterPriorityExclude.size>0&&this.filterPriorityExclude.has(s.priority))return!1;return!0}destroy(){this.scrollPositionListener&&(this.container.removeEventListener("scroll",this.scrollPositionListener),this.scrollPositionListener=null),this.scrollCleanup&&(this.scrollCleanup(),this.scrollCleanup=null),this.destroyVirtualScroll(),this.dataManager.flushPendingSave()}render(){this._lastScrollTop=this.container.scrollTop||this._lastScrollTop,this.activeCleanups.forEach(s=>s()),this.activeCleanups=[],this.container.empty(),this.container.addClass("wl-list"),this.renderSubTabBar(),this.currentSubTab==="list"?this.renderListContent():this.renderCardsContent()}renderSubTabBar(){let s=this.container.createDiv({cls:"wl-inner-tab-bar"}),t=s.createEl("button",{cls:`wl-inner-tab-btn${this.currentSubTab==="cards"?" is-active":""}`,text:"Cards"});s.createEl("button",{cls:`wl-inner-tab-btn${this.currentSubTab==="list"?" is-active":""}`,text:"List"}).addEventListener("click",()=>{this.currentSubTab!=="list"&&(this.destroyVirtualScroll(),this.currentSubTab="list",this.render())}),t.addEventListener("click",()=>{this.currentSubTab!=="cards"&&(this.currentSubTab="cards",this.render())})}renderListContent(){this.renderHeader(),this.container.createDiv({cls:"wl-divider"}),this.renderTable(this._lastScrollTop)}renderCardsContent(){this.renderHeader(),this.container.createDiv({cls:"wl-divider"}),this.renderCardsView()}renderCardsView(){this.destroyVirtualScroll();let s=this.getDisplayItems();if(this.cardsDisplayItems=s,this.renderResultsCount(this.container,s),s.length===0){let a=this.container.createDiv({cls:"wl-cards-empty"});a.createSpan({cls:"wl-cards-empty-icon",text:"\u{1F3AC}"}),a.createEl("p",{cls:"wl-cards-empty-msg",text:"No titles match your filters"});return}let t=this.container.createDiv({cls:"wl-cards-scroll-container"}),e=t.createDiv({cls:"wl-cards-scroll-spacer"}),i=e.createDiv({cls:"wl-cards-grid wl-cards-grid-virtual"});this.cardsScrollContainer=t,this.cardsScrollSpacer=e,this.cardsGridEl=i,this.cardsLastFirst=-1,this.cardsLastLast=-1,this.cardsScrollHandler=()=>{let a=t.scrollTop;this._cardsPersistentScrollTop=a;let n=this.cardsRowHeight>0?this.cardsRowHeight/2:50;Math.abs(a-this.cardsLastScrollTop)<n||(this.cardsLastScrollTop=a,this.cardsScrollRAF===null&&(this.cardsScrollRAF=window.requestAnimationFrame(()=>{this.cardsScrollRAF=null,this.renderVisibleCards()})))},t.addEventListener("scroll",this.cardsScrollHandler,{passive:!0}),this.cardsResizeObserver=new ResizeObserver(()=>{this.renderVisibleCards()}),this.cardsResizeObserver.observe(t),this._cardsPersistentScrollTop>0&&(this.renderVisibleCards(),t.scrollTop=this._cardsPersistentScrollTop,this.cardsLastScrollTop=this._cardsPersistentScrollTop,this.renderVisibleCards())}getCardsGridMetrics(s){let i=Math.max(1,Math.floor((s+12)/152)),a=(s-12*(i-1))/i,n=a*1.5,r=n+12;return{cols:i,cardWidth:a,cardHeight:n,rowHeight:r}}renderVisibleCards(){let s=this.cardsScrollContainer,t=this.cardsScrollSpacer,e=this.cardsGridEl;if(!s||!t||!e)return;let i=s.scrollTop,a=s.clientHeight,n=s.clientWidth;if(n<=0||a<=0)return;let{cols:r,cardWidth:o,cardHeight:l,rowHeight:c}=this.getCardsGridMetrics(n);this.cardsRowHeight=c;let d=this.cardsDisplayItems.length,u=Math.ceil(d/r),h=Math.max(0,Math.floor(i/c)-2),p=Math.min(u-1,Math.ceil((i+a)/c)+2),m=h*r,v=Math.min(d-1,(p+1)*r-1);t.style.height=`${u*c}px`,e.style.transform=`translateY(${h*c}px)`,e.style.setProperty("--wl-cards-cols",String(r)),e.style.setProperty("--wl-card-height",`${l}px`);let f=this.cardsLastFirst,y=this.cardsLastLast;if(m===f&&v===y)return;let w=f===-1||y===-1,b=!w&&m<=y&&v>=f;if(this.cardsLastFirst=m,this.cardsLastLast=v,w||!b){this.fullRenderCards(e,m,v,o,l),this.setupCardsObserver(s);return}if(m>f){let S=m-f;for(let D=0;D<S;D++){let E=e.firstElementChild;if(!E)break;this.unobserveAndRemove(E)}}if(v<y){let S=y-v;for(let D=0;D<S;D++){let E=e.lastElementChild;if(!E)break;this.unobserveAndRemove(E)}}if(m<f){let S=Math.min(f-1,v),D=activeDocument.createDocumentFragment();for(let E=m;E<=S;E++){let k=this.buildCardElement(E,o,l);k&&D.appendChild(k)}e.insertBefore(D,e.firstChild)}if(v>y){let S=Math.max(y+1,m),D=activeDocument.createDocumentFragment();for(let E=S;E<=v;E++){let k=this.buildCardElement(E,o,l);k&&D.appendChild(k)}e.appendChild(D)}this.setupCardsObserver(s)}fullRenderCards(s,t,e,i,a){this.observedCards.clear(),s.empty();let n=activeDocument.createDocumentFragment();for(let r=t;r<=e;r++){let o=this.buildCardElement(r,i,a);o&&n.appendChild(o)}s.appendChild(n)}buildCardElement(s,t,e){let i=this.cardsDisplayItems[s];if(!i)return null;let a=activeDocument.createElement("div");return i.kind==="title"?this.renderTitleCard(a,i.data,t,e):this.renderGroupCard(a,i.data,i.members,t,e),a.firstElementChild}unobserveAndRemove(s){this.cardsObserver&&this.observedCards.has(s)&&(this.cardsObserver.unobserve(s),this.observedCards.delete(s)),s.remove()}setupCardsObserver(s){var e;this.cardsObserver||(this.cardsObserver=new IntersectionObserver(i=>this.handleCardIntersection(i),{root:s,rootMargin:"0px",threshold:0})),((e=this.cardsGridEl)!=null?e:s).querySelectorAll('.wl-card[data-needs-poster="true"]').forEach(i=>{let a=i;this.observedCards.has(a)||(this.cardsObserver.observe(a),this.observedCards.add(a))})}handleCardIntersection(s){var t;for(let e of s){if(!e.isIntersecting)continue;let i=e.target,a=i.dataset.titleId;if(!a)continue;(t=this.cardsObserver)==null||t.unobserve(i);let n=this.dataManager.getTitles().find(o=>o.id===a);if(!n||n.manualPosterUrl&&n.manualPosterUrl.trim()!==""||n.posterUrl!=="")continue;let r=i.querySelector(".wl-card-poster-placeholder");r==null||r.addClass("is-loading"),this.plugin.posterService.enqueue(n).then(o=>{r==null||r.removeClass("is-loading"),o&&this.applyPosterToCard(i,o)})}}applyPosterToCard(s,t){let e=s.querySelector(".wl-card-poster"),i=s.querySelector(".wl-card-poster-placeholder");e&&(e.onload=()=>{e.style.display="block",i&&(i.style.display="none")},e.onerror=()=>{let a=s.dataset.titleId;a&&this.dataManager.updatePosterUrl(a,"none")},e.src=t)}destroyCardsObserver(){var s;this.cardsObserver&&(this.cardsObserver.disconnect(),this.cardsObserver=null),this.observedCards.clear(),(s=this.plugin.posterService)==null||s.clearQueue()}destroyVirtualScroll(){this.cardsScrollRAF!==null&&(window.cancelAnimationFrame(this.cardsScrollRAF),this.cardsScrollRAF=null),this.cardsResizeObserver&&(this.cardsResizeObserver.disconnect(),this.cardsResizeObserver=null),this.cardsScrollContainer&&this.cardsScrollHandler&&this.cardsScrollContainer.removeEventListener("scroll",this.cardsScrollHandler),this.cardsScrollHandler=null,this.cardsScrollContainer=null,this.cardsScrollSpacer=null,this.cardsGridEl=null,this.cardsDisplayItems=[],this.cardsLastFirst=-1,this.cardsLastLast=-1,this.cardsLastScrollTop=0,this.cardsRowHeight=0,this.destroyCardsObserver()}renderTitleCard(s,t,e,i){let a=this.getTagDef(t.type,this.plugin.settings.types),n=this.getTagDef(t.status,this.plugin.settings.statuses),r=a?N(t.type,a.color,this.plugin.settings.colorTheme):"#888780",o=s.createDiv({cls:"wl-card"});o.dataset.titleId=t.id,i!==void 0&&(o.style.height=`${i}px`);let l=o.createDiv({cls:"wl-card-poster-placeholder"});l.style.backgroundColor=r;let c=(t.title.trim().charAt(0)||"?").toUpperCase();l.createSpan({text:c});let d=o.createEl("img",{cls:"wl-card-poster"});d.alt=t.title;let u=ge(t),h=!!(t.manualPosterUrl&&t.manualPosterUrl.trim()!=="");if(u&&u.startsWith("http")?(d.src=u,d.style.display="block",l.style.display="none",d.onerror=()=>{d.style.display="none",l.style.display="",h||this.dataManager.updatePosterUrl(t.id,"none")}):!h&&t.posterUrl===""&&(o.dataset.needsPoster="true"),n){let y=o.createSpan({cls:"wl-card-status-badge",text:t.status});y.style.backgroundColor=N(t.status,n.color,this.plugin.settings.colorTheme)}let p=o.createDiv({cls:"wl-card-overlay"});p.createSpan({cls:"wl-card-title",text:t.title});let m=p.createSpan({cls:"wl-card-type-badge",text:t.type});m.style.backgroundColor=r;let v=t.totalEpisodes;if(v&&v>0){let w=t.status==="Completed"?1:Math.max(0,Math.min(1,t.watchedEpisodes.length/v)),S=p.createDiv({cls:"wl-card-progress-bar"}).createDiv({cls:"wl-card-progress-fill"});S.style.width=`${w*100}%`}let f=o.createEl("button",{cls:"wl-card-menu-btn",text:"\u22EE"});f.setAttr("aria-label","More actions"),f.addEventListener("click",y=>{y.stopPropagation(),this.openCardContextMenu(o,f,t)}),o.addEventListener("click",()=>{this.openDetailModalForTitle(t.id)})}openCardContextMenu(s,t,e){this.container.querySelectorAll(".wl-card-context-menu").forEach(h=>h.remove());let i=s.createDiv({cls:"wl-card-context-menu"}),a=i.createDiv({cls:"wl-card-context-item",text:"Edit title"}),n=i.createDiv({cls:"wl-card-context-item",text:"Refresh poster"}),r=i.createDiv({cls:"wl-card-context-item",text:"Refresh rating"}),o=s.getBoundingClientRect(),l=this.container.getBoundingClientRect();o.right>l.right-180&&i.classList.add("is-right-aligned");let c=s.ownerDocument,d=()=>{i.remove(),c.removeEventListener("click",u,!0)},u=h=>{!i.contains(h.target)&&h.target!==t&&d()};window.setTimeout(()=>c.addEventListener("click",u,!0),0),this.activeCleanups.push(()=>c.removeEventListener("click",u,!0)),a.addEventListener("click",h=>{h.stopPropagation(),d(),this.openEditModalForTitle(e.id)}),n.addEventListener("click",h=>{h.stopPropagation(),d(),this.refreshPosterForCard(s,e.id)}),r.addEventListener("click",h=>{h.stopPropagation(),d(),ss(this.plugin,e.id,null,!0)})}refreshPosterForCard(s,t){let e=this.dataManager.getTitle(t);if(!e)return;this.dataManager.updatePosterUrl(t,""),s.dataset.needsPoster="true";let i=s.querySelector(".wl-card-poster"),a=s.querySelector(".wl-card-poster-placeholder");i&&(i.removeAttribute("src"),i.style.display="none"),a&&(a.style.display="",a.addClass("is-loading")),this.plugin.posterService.enqueue(e).then(n=>{a==null||a.removeClass("is-loading"),n&&this.applyPosterToCard(s,n)})}renderGroupCard(s,t,e,i,a){var m,v;let n=(v=(m=e[0])==null?void 0:m.type)!=null?v:"",r=n?this.getTagDef(n,this.plugin.settings.types):void 0,o=r?N(n,r.color,this.plugin.settings.colorTheme):"#888780",l=s.createDiv({cls:"wl-card wl-card-group"});a!==void 0&&(l.style.height=`${a}px`);let c=l.createDiv({cls:"wl-card-poster-placeholder"});c.style.backgroundColor=o;let d=(t.name.trim().charAt(0)||"?").toUpperCase();c.createSpan({text:d});let u=l.createDiv({cls:"wl-card-overlay"});u.createSpan({cls:"wl-card-title",text:t.name});let h=u.createSpan({cls:"wl-card-type-badge"});h.style.backgroundColor=o,h.textContent=`${e.length} title${e.length!==1?"s":""}`;let p=l.createEl("button",{cls:"wl-card-menu-btn",text:"\u22EE"});p.setAttr("aria-label","Group actions"),p.addEventListener("click",f=>{f.stopPropagation(),this.openGroupCardContextMenu(l,p,t)}),l.addEventListener("click",()=>{new be(this.plugin.app,this.plugin,t,e,()=>{this.render()}).open()})}openGroupCardContextMenu(s,t,e){this.container.querySelectorAll(".wl-card-context-menu").forEach(u=>u.remove());let i=s.createDiv({cls:"wl-card-context-menu"}),a=i.createDiv({cls:"wl-card-context-item",text:"Edit name"}),n=i.createDiv({cls:"wl-card-context-item",text:"Delete group"}),r=s.getBoundingClientRect(),o=this.container.getBoundingClientRect();r.right>o.right-180&&i.classList.add("is-right-aligned");let l=s.ownerDocument,c=()=>{i.remove(),l.removeEventListener("click",d,!0)},d=u=>{!i.contains(u.target)&&u.target!==t&&c()};window.setTimeout(()=>l.addEventListener("click",d,!0),0),this.activeCleanups.push(()=>l.removeEventListener("click",d,!0)),a.addEventListener("click",u=>{u.stopPropagation(),c(),this.openRenameGroupPrompt(e)}),n.addEventListener("click",u=>{u.stopPropagation(),c(),new H(this.plugin.app,`Delete group "${e.name}"? All titles inside will be returned to the main list.`,()=>{this.dataManager.removeGroup(e.id).then(()=>{this.expandedGroups.delete(e.id),this.render()})}).open()})}openRenameGroupPrompt(s){let t=new Xs.Modal(this.plugin.app);t.titleEl.setText("Rename group");let e=t.contentEl.createEl("input",{cls:"wl-input",attr:{type:"text",value:s.name,placeholder:"Group name"}});e.style.width="100%";let i=t.contentEl.createDiv({cls:"wl-modal-btn-row"}),a=()=>{let n=e.value.trim();n&&n!==s.name&&this.dataManager.updateGroup({...s,name:n}).then(()=>this.render()),t.close()};i.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Save"}).addEventListener("click",a),i.createEl("button",{cls:"wl-btn",text:"Cancel"}).addEventListener("click",()=>t.close()),e.addEventListener("keydown",n=>{n.key==="Enter"?a():n.key==="Escape"&&t.close()}),t.open(),window.setTimeout(()=>{e.focus(),e.select()},0)}openEditModalForTitle(s){let t=this.dataManager.getTitle(s);t&&new St(this.plugin.app,this.plugin,this.dataManager,t,()=>{this.render()}).open()}openDetailModalForTitle(s){let t=this.dataManager.getTitle(s);t&&new Zt(this.plugin.app,this.plugin,t,()=>{this.render()}).open()}renderHeader(){let s=this.container.createDiv({cls:"wl-list-header"});if(this.plugin.importProgress){let{current:l,total:c,cancel:d}=this.plugin.importProgress,u=s.createDiv({cls:"wl-header-import-progress"}),p=u.createDiv({cls:"wl-header-import-track"}).createDiv({cls:"wl-header-import-fill"});p.style.width=`${c>0?Math.round(l/c*100):0}%`,u.createSpan({cls:"wl-header-import-text",text:`${l} / ${c}`}),u.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Cancel import"}).addEventListener("click",()=>d())}let t=s.createDiv({cls:"wl-header-controls"});this.renderSearch(t);let e=this.dataManager.getSavedFilterPreset();if(e){let l=t.createEl("button",{cls:`wl-btn wl-btn-sm${this.savedFilterActive?" wl-btn-preset-active":""}`,text:"Saved filter"});this.savedFilterBtnEl=l,l.addEventListener("click",()=>{this.applyPreset(e),this.savedFilterActive=!0,l.addClass("wl-btn-preset-active"),this.saveFiltersToSettings(),this.render()})}else this.savedFilterBtnEl=null;if(this.renderFiltersDropdown(t),this.renderSortingDropdown(t),this.selectionMode&&this.selectedItems.size>0&&this.renderActionBar(t),this.selectionMode){let l=this.selectedItems.size>0,c=t.createEl("button",{cls:"wl-btn wl-btn-sm",text:l?"None":"All"});c.title=l?"Deselect all":"Select all visible",c.addEventListener("click",()=>{if(this.selectedItems.size>0)this.selectedItems.clear();else for(let d of this.getDisplayItems())this.selectedItems.add(d.data.id);this.render()})}t.createEl("button",{cls:`wl-btn wl-btn-sm${this.selectionMode?" is-active":""}`,text:"Select"}).addEventListener("click",()=>{this.selectionMode=!this.selectionMode,this.selectedItems.clear(),this.render()});let a=t.createDiv({cls:"wl-header-controls-right"});a.createEl("button",{cls:"wl-btn wl-btn-sm wl-btn-success",text:"+add from URL"}).addEventListener("click",()=>{new ye(this.plugin.app,this.plugin,this.dataManager,()=>{this.render()}).open()}),a.createDiv({cls:"wl-add-btn-wrap"}).createEl("button",{cls:"wl-add-btn wl-btn-success",text:"+ add"}).addEventListener("click",l=>{l.stopPropagation(),this.openAddTitleModal()})}renderFiltersDropdown(s){let t=s.createDiv({cls:"wl-add-btn-wrap"}),e=t.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Filters \u25BC"}),i=e.createSpan({cls:"wl-filter-dot"});this.hasActiveFilter()?i.show():i.hide();let a=s.createEl("button",{cls:"wl-btn wl-btn-sm wl-btn-clear-filters",text:"\u2715 clear"});a.title="Clear all filters",this.hasActiveFilter()?a.show():a.hide(),a.addEventListener("click",()=>{this.clearAllFilters(),this.saveFiltersToSettings(),this.deactivateSavedFilter(),i.hide(),a.hide(),this.rerenderTable()}),e.addEventListener("click",n=>{n.stopPropagation();let r=activeDocument.querySelector(".wl-filters-panel");if(r){r.remove();return}let o=activeDocument.body.createDiv({cls:"wl-dropdown wl-filters-panel wl-filters-panel-popup"}),l=e.getBoundingClientRect();o.style.top=`${l.bottom+4}px`,o.style.left=`${l.left}px`;let c=this.plugin.settings.types.map(T=>T.name),d=this.plugin.settings.statuses.map(T=>T.name),u=["1\u2605","2\u2605","3\u2605","4\u2605","5\u2605"],h=this.plugin.settings.priorities.map(T=>T.name),p=this.dataManager.getGroups().map(T=>T.name),m={typeExclude:new Set(this.filterTypeExclude),statusExclude:new Set(this.filterStatusExclude),ratingExclude:new Set(this.filterRatingExclude),priorityExclude:new Set(this.filterPriorityExclude),groupExclude:new Set(this.filterGroupExclude),filterRatingEmptyOnly:this.filterRatingEmptyOnly,filterPriorityEmptyOnly:this.filterPriorityEmptyOnly,filterRecentlyArrivedOnly:this.filterRecentlyArrivedOnly,filterGroupsOnly:this.filterGroupsOnly},v=[{label:"Type",opts:c,excludeSet:m.typeExclude},{label:"Status",opts:d,excludeSet:m.statusExclude},{label:"Rating",opts:u,excludeSet:m.ratingExclude},{label:"Priority",opts:h,excludeSet:m.priorityExclude}];p.length>0&&v.push({label:"Group",opts:p,excludeSet:m.groupExclude});let f=[],y=o.createDiv({cls:"wl-filter-global-btns"}),w=null;y.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Select all"}).addEventListener("click",T=>{T.stopPropagation();for(let{excludeSet:M}of v)M.clear();for(let{cb:M}of f)M.checked=!0;m.filterRecentlyArrivedOnly=!1,w&&(w.checked=!1)}),y.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Deselect all"}).addEventListener("click",T=>{T.stopPropagation();for(let{opts:M,excludeSet:L}of v)for(let j of M)L.add(j);for(let{cb:M}of f)M.checked=!1;m.filterRecentlyArrivedOnly=!1,w&&(w.checked=!1)});let D=this.dataManager.getSavedFilterPreset(),E=y.createEl("button",{cls:"wl-btn wl-btn-sm",text:D?"Delete":"Save"});E.addEventListener("click",T=>{(async()=>(T.stopPropagation(),E.textContent==="Save"?await this.dataManager.setSavedFilterPreset({typeExclude:Array.from(m.typeExclude),statusExclude:Array.from(m.statusExclude),groupExclude:Array.from(m.groupExclude),ratingExclude:Array.from(m.ratingExclude),priorityExclude:Array.from(m.priorityExclude),ratingEmptyOnly:m.filterRatingEmptyOnly,priorityEmptyOnly:m.filterPriorityEmptyOnly,recentlyArrivedOnly:m.filterRecentlyArrivedOnly,groupsOnly:m.filterGroupsOnly}):(await this.dataManager.setSavedFilterPreset(null),this.savedFilterActive=!1),o.remove(),this.render()))()});let k={};for(let{label:T}of v)k[T]=!0;for(let{label:T,opts:M,excludeSet:L}of v){let j=o.createDiv({cls:"wl-filter-section"}),A=j.createDiv({cls:"wl-filter-section-header"});A.createSpan({cls:"wl-filter-label",text:T});let P=A.createSpan({cls:"wl-filter-chevron",text:"\u25BC"}),I=j.createDiv({cls:"wl-filter-section-content wl-hidden"});A.addEventListener("click",U=>{var Y;U.stopPropagation(),k[T]=!k[T];let O=(Y=k[T])!=null?Y:!0;I.toggleClass("wl-hidden",O),P.textContent=O?"\u25BC":"\u25B2"});let F=I.createDiv({cls:"wl-filter-checkbox-row"}),_=F.createEl("input",{attr:{type:"checkbox"}});if(F.createSpan({cls:"wl-filter-all-toggle",text:"\u25C6 All"}),T==="Status"){let U=I.createDiv({cls:"wl-filter-checkbox-row"}),O=U.createEl("input",{attr:{type:"checkbox"}});O.checked=m.filterRecentlyArrivedOnly,U.createSpan({cls:"wl-recently-arrived-filter",text:"\u2726 Recently arrived"}),w=O,O.addEventListener("change",()=>{if(m.filterRecentlyArrivedOnly=O.checked,O.checked){L.clear();for(let Y of Mt)Y.checked=!0}})}let V=null,G=[],Mt=[];if(T==="Rating"||T==="Priority"){let U=I.createDiv({cls:"wl-filter-checkbox-row"});V=U.createEl("input",{attr:{type:"checkbox"}}),V.checked=T==="Rating"?m.filterRatingEmptyOnly:m.filterPriorityEmptyOnly,U.createSpan({text:"Empty"});let O=V;V.addEventListener("change",()=>{if(T==="Rating"?m.filterRatingEmptyOnly=O.checked:m.filterPriorityEmptyOnly=O.checked,O.checked){for(let Y of G)Y.checked=!0;L.clear()}})}if(T==="Group"){let U=I.createDiv({cls:"wl-filter-checkbox-row"}),O=U.createEl("input",{attr:{type:"checkbox"}});O.checked=m.filterGroupsOnly,U.createSpan({cls:"wl-recently-arrived-filter",text:"\u25C7 Groups only"}),O.addEventListener("change",()=>{m.filterGroupsOnly=O.checked}),I.createDiv({cls:"wl-filter-section-divider"})}for(let U of M){let O=I.createDiv({cls:"wl-filter-checkbox-row"}),Y=O.createEl("input",{attr:{type:"checkbox"}});Y.checked=!L.has(U),O.createSpan({text:U}),G.push(Y),T==="Status"&&Mt.push(Y),f.push({cb:Y});let je=V;Y.addEventListener("change",()=>{Y.checked?L.delete(U):L.add(U),T==="Status"&&w&&(m.filterRecentlyArrivedOnly=!1,w.checked=!1),(T==="Rating"||T==="Priority")&&je&&(T==="Rating"?m.filterRatingEmptyOnly=!1:m.filterPriorityEmptyOnly=!1,je.checked=!1)})}let ft=()=>{_.checked=G.length>0&&G.every(U=>U.checked)};ft();let xt=()=>{if(G.length>0&&G.every(O=>O.checked)){for(let O of G)O.checked=!1;for(let O of M)L.add(O)}else{for(let O of G)O.checked=!0;L.clear()}ft()};_.addEventListener("click",U=>{U.stopPropagation(),xt()}),F.addEventListener("click",U=>{U.target!==_&&(U.stopPropagation(),xt())})}o.createEl("button",{cls:"wl-btn wl-btn-sm wl-filter-apply-btn",text:"Apply"}).addEventListener("click",T=>{T.stopPropagation(),this.filterTypeExclude=m.typeExclude,this.filterStatusExclude=m.statusExclude,this.filterRatingExclude=m.ratingExclude,this.filterPriorityExclude=m.priorityExclude,this.filterGroupExclude=m.groupExclude,this.filterRatingEmptyOnly=m.filterRatingEmptyOnly,this.filterPriorityEmptyOnly=m.filterPriorityEmptyOnly,this.filterRecentlyArrivedOnly=m.filterRecentlyArrivedOnly,this.filterGroupsOnly=m.filterGroupsOnly,this.saveFiltersToSettings(),this.deactivateSavedFilter(),this.hasActiveFilter()?(i.show(),a.show()):(i.hide(),a.hide()),this.rerenderTable(),o.remove(),activeDocument.removeEventListener("mousedown",x,!1)});let x=T=>{let M=T.target;o.contains(M)||t.contains(M)||(o.remove(),activeDocument.removeEventListener("mousedown",x,!1))};window.setTimeout(()=>activeDocument.addEventListener("mousedown",x,!1),0)})}renderSortingDropdown(s){let t=s.createDiv({cls:"wl-add-btn-wrap"}),e=t.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Sorting \u25BC"});e.addEventListener("click",i=>{i.stopPropagation();let a=t.querySelector(".wl-dropdown");if(a){a.remove();return}let n=t.createDiv({cls:"wl-dropdown wl-sorting-panel"});if(window.innerWidth<=600){let c=e.getBoundingClientRect();n.addClass("wl-sorting-panel-mobile"),n.style.top=`${c.bottom+4}px`}let r=["Title","Date added","Progress","Rating","Priority","Started","Status","Date watched","Time left","Time watched"],o=(c,d,u,h)=>{let p=n.createDiv({cls:"wl-filter-row"});p.createSpan({cls:"wl-filter-label",text:c});let m=p.createEl("select",{cls:"wl-select"});if(!h){let f=m.createEl("option",{text:"None",value:"none"});f.selected=d==="none"}for(let f of r){let y=m.createEl("option",{text:f,value:f});this.sortLabelToKey(f)===d&&(y.selected=!0)}let v=p.createEl("button",{cls:"wl-btn wl-btn-sm wl-sort-dir-btn",text:u==="asc"?"\u2191":"\u2193"});v.title=u==="asc"?"Ascending \u2014 click to switch to descending":"Descending \u2014 click to switch to ascending",m.addEventListener("change",()=>{var y,w;let f=m.value==="none"?"none":this.sortLabelToKey(m.value);h?(this.filterSort=f,this.filterSortDir=(y=et.SORT_DEFAULT_DIR[f])!=null?y:"asc"):(this.filterSecondSort=f,this.filterSecondSortDir=(w=et.SORT_DEFAULT_DIR[f])!=null?w:"asc"),n.remove(),activeDocument.removeEventListener("click",l,!0),this.saveFiltersToSettings(),this.render()}),v.addEventListener("click",f=>{f.stopPropagation(),h?this.filterSortDir=this.filterSortDir==="asc"?"desc":"asc":this.filterSecondSortDir=this.filterSecondSortDir==="asc"?"desc":"asc",n.remove(),activeDocument.removeEventListener("click",l,!0),this.saveFiltersToSettings(),this.render()})};o("Sort by",this.filterSort,this.filterSortDir,!0),o("Then by",this.filterSecondSort,this.filterSecondSortDir,!1);let l=c=>{t.contains(c.target)||(n.remove(),activeDocument.removeEventListener("click",l,!0))};window.setTimeout(()=>activeDocument.addEventListener("click",l,!0),0)})}renderActionBar(s){let t=s.createDiv({cls:"wl-action-bar"}),e=t.createEl("button",{cls:"wl-group-action-btn wl-group-action-btn-delete wl-btn-danger",text:"\u2715"});e.title="Delete selected",e.addEventListener("click",o=>{o.stopPropagation();let l=this.selectedItems.size;new H(this.plugin.app,`Delete ${l} selected item${l!==1?"s":""}? This cannot be undone.`,()=>{(async()=>{let c=new Set(this.dataManager.getGroups().map(u=>u.id)),d=[];for(let u of Array.from(this.selectedItems))c.has(u)?(await this.dataManager.removeGroup(u),this.expandedGroups.delete(u)):(d.push(u),this.expandedId===u&&(this.expandedId=null));d.length>0&&await this.dataManager.removeTitlesBatch(d),this.selectedItems.clear(),this.render()})()}).open()});let i=t.createEl("select",{cls:"wl-select wl-select-sm"});i.createEl("option",{text:"Status\u2026",value:""});for(let o of this.plugin.settings.statuses.filter(l=>l.name!=="To be released"))i.createEl("option",{text:o.name,value:o.name});i.addEventListener("change",()=>{(async()=>{let o=i.value;if(!o)return;let l=new Set(this.dataManager.getGroups().map(d=>d.id)),c=[];for(let d of this.selectedItems){if(l.has(d))continue;let u=this.dataManager.getTitle(d);u&&(u.status=o,this.dataManager.updateTitleSilent(u),c.push(u))}if(c.length>0){await this.dataManager.save();for(let d of c)await this.dataManager.updateMarkdownFile(d)}i.value="",this.render()})()});let a=this.dataManager.getGroups(),n=Array.from(this.selectedItems).some(o=>this.dataManager.getGroups().some(l=>l.id===o)),r=t.createEl("select",{cls:"wl-select wl-select-sm"});n&&(r.disabled=!0,r.title="A group is selected \u2014 cannot move groups into other groups"),r.createEl("option",{text:"Group\u2026",value:""});for(let o of a)r.createEl("option",{text:o.name,value:o.id});r.createEl("option",{text:"Create new group\u2026",value:"__new__"}),r.addEventListener("change",()=>{(async()=>{let o=r.value;if(!o)return;let l=new Set(a.map(d=>d.id)),c=Array.from(this.selectedItems).filter(d=>!l.has(d));if(o==="__new__"){r.value="",r.hide();let d=t.createEl("input",{cls:"wl-modal-input wl-group-name-input",attr:{type:"text",placeholder:"Group name\u2026",maxlength:"64"}}),u=t.createEl("button",{cls:"wl-group-action-btn",text:"\u2713"});u.title="Create group";let h=t.createEl("button",{cls:"wl-group-action-btn",text:"\u2715"});h.title="Cancel";let p=async()=>{let m=d.value.trim();if(!m)return;let v={id:this.dataManager.generateGroupId(m),name:m,titleIds:c,dateAdded:new Date().toISOString()};await this.dataManager.addGroup(v),this.selectedItems.clear(),this.selectionMode=!1,this.render()};u.addEventListener("click",m=>{m.stopPropagation(),p()}),h.addEventListener("click",m=>{m.stopPropagation(),this.render()}),d.addEventListener("keydown",m=>{m.stopPropagation(),m.key==="Enter"&&p(),m.key==="Escape"&&this.render()}),d.addEventListener("click",m=>m.stopPropagation()),window.setTimeout(()=>d.focus(),0);return}for(let d of c)await this.dataManager.addTitleToGroup(o,d);r.value="",this.selectedItems.clear(),this.selectionMode=!1,this.render()})()})}openAddTitleModal(){new ht(this.plugin.app,this.plugin,this.dataManager,()=>{this.render()}).open()}renderSearch(s){let e=s.createDiv({cls:"wl-reading-search-wrap"}).createEl("input",{cls:"wl-reading-search-input",attr:{type:"text",placeholder:"Search titles..."}});e.value=this.searchQuery;let i=0;e.addEventListener("input",()=>{this.searchQuery=e.value,window.clearTimeout(i),i=window.setTimeout(()=>{this.rerenderActiveSubTab()},250)})}sortLabelToKey(s){var e;return(e={Title:"title","Date added":"dateAdded",Progress:"progress",Rating:"rating",Priority:"priority",Started:"started",Status:"status","Date watched":"dateWatched","Time left":"timeLeft","Time watched":"timeWatched"}[s])!=null?e:"dateAdded"}static migrateSortKey(s){var e;return(e={"title-asc":{key:"title",dir:"asc"},"title-desc":{key:"title",dir:"desc"},"dateAdded-newest":{key:"dateAdded",dir:"desc"},"dateAdded-oldest":{key:"dateAdded",dir:"asc"},"progress-high":{key:"progress",dir:"desc"},"progress-low":{key:"progress",dir:"asc"},"rating-high":{key:"rating",dir:"desc"},"rating-low":{key:"rating",dir:"asc"},priority:{key:"priority",dir:"asc"},"started-asc":{key:"started",dir:"asc"},"started-desc":{key:"started",dir:"desc"},"status-asc":{key:"status",dir:"asc"},"status-desc":{key:"status",dir:"desc"},"dateWatched-newest":{key:"dateWatched",dir:"desc"},"dateWatched-oldest":{key:"dateWatched",dir:"asc"},"timeLeft-high":{key:"timeLeft",dir:"desc"},"timeLeft-low":{key:"timeLeft",dir:"asc"},"timeWatched-high":{key:"timeWatched",dir:"desc"},"timeWatched-low":{key:"timeWatched",dir:"asc"},none:{key:"none",dir:"asc"}}[s])!=null?e:{key:s,dir:"desc"}}static sortBaseAndDirToKey(s,t){var i,a;return(a=(i={title:{asc:"title-asc",desc:"title-desc"},dateAdded:{asc:"dateAdded-oldest",desc:"dateAdded-newest"},progress:{asc:"progress-low",desc:"progress-high"},rating:{asc:"rating-low",desc:"rating-high"},priority:{asc:"priority",desc:"priority"},started:{asc:"started-asc",desc:"started-desc"},status:{asc:"status-asc",desc:"status-desc"},dateWatched:{asc:"dateWatched-oldest",desc:"dateWatched-newest"},timeLeft:{asc:"timeLeft-low",desc:"timeLeft-high"},timeWatched:{asc:"timeWatched-low",desc:"timeWatched-high"}}[s])==null?void 0:i[t])!=null?a:"dateAdded-newest"}rerenderTable(){if(this.currentSubTab!=="list"){this.rerenderActiveSubTab();return}let s=this._lastScrollTop,t=this.container.querySelector(".wl-table-section");t&&t.remove(),this.renderTable(s)}rerenderActiveSubTab(){if(this.currentSubTab==="cards")this.destroyVirtualScroll(),this.container.querySelectorAll(".wl-cards-scroll-container, .wl-cards-empty, .wl-table-section, .wl-results-count").forEach(s=>s.remove()),this.renderCardsView();else{let s=this._lastScrollTop,t=this.container.querySelector(".wl-table-section");t&&t.remove(),this.renderTable(s)}}renderResultsCount(s,t){let e=t.reduce((r,o)=>r+(o.kind==="title"?1:0),0),i=t.reduce((r,o)=>r+(o.kind==="group"?1:0),0),a=s.createDiv({cls:"wl-results-count"}),n=[];e>0&&n.push(`${e} title${e!==1?"s":""}`),i>0&&n.push(`${i} group${i!==1?"s":""}`),a.textContent=n.length>0?n.join(", "):"0 titles"}renderTable(s){this.scrollCleanup&&(this.scrollCleanup(),this.scrollCleanup=null);let t=this.container.createDiv({cls:"wl-table-section"}),e=this.getDisplayItems();this.renderResultsCount(t,e);let i=this.selectionMode?"wl-table wl-selection-mode":"wl-table",a=t.createDiv({cls:i}),n=a.createDiv({cls:"wl-table-header-row"});if(this.selectionMode&&n.createDiv({cls:"wl-col-select-h"}),n.createDiv({cls:"wl-col-title-h",text:"Title"}),n.createDiv({cls:"wl-col-priority-h",text:"Priority"}),n.createDiv({cls:"wl-col-started-h",text:"Started"}),n.createDiv({cls:"wl-col-rating-h",text:"Rating"}),n.createDiv({cls:"wl-col-status-h",text:"Status"}),e.length===0){a.createDiv({cls:"wl-empty-state",text:"No titles match your filters."});return}this.mountVirtualRows(a,e,s)}mountVirtualRows(s,t,e){var w;let o=t.map(()=>68);if(this.expandedId!==null){let b=t.findIndex(S=>S.kind==="title"&&S.data.id===this.expandedId);if(b!==-1){let S=t[b];if((S==null?void 0:S.kind)==="title"){let D=activeDocument.body.createDiv();D.style.cssText=`position:absolute;left:-9999px;top:0;width:${s.clientWidth}px;visibility:hidden;`,this.renderRow(D,S.data);let E=D.offsetHeight;D.remove(),E>0&&(o[b]=E)}}}for(let b=0;b<t.length;b++){let S=t[b];if((S==null?void 0:S.kind)!=="group"||!this.expandedGroups.has(S.data.id))continue;let D=activeDocument.body.createDiv();D.style.cssText=`position:absolute;left:-9999px;top:0;width:${s.clientWidth}px;visibility:hidden;`,this.renderGroupRow(D,S.data,S.members);let E=D.offsetHeight;D.remove(),E>0&&(o[b]=E)}let l=[],c=0;for(let b=0;b<t.length;b++)l.push(c),c+=(w=o[b])!=null?w:44,b<t.length-1&&(c+=8);let d=s.createDiv({cls:"wl-virt-container"});d.style.height=`${c}px`;let u=this.container,h=d.getBoundingClientRect().top-u.getBoundingClientRect().top+u.scrollTop;e!==void 0&&(u.scrollTop=e);let p=0,m=-1,v=-1,f=()=>{var j,A,P,I;let b=u.scrollTop,S=u.clientHeight||600,D=5*44,E=b-h,k=E-D,C=E+S+D,x=0,T=t.length-1;for(;x<=T;){let F=x+T>>>1;((j=l[F])!=null?j:0)+((A=o[F])!=null?A:44)<=k?x=F+1:T=F-1}let M=Math.max(0,x),L=M;for(let F=M;F<t.length&&!(((P=l[F])!=null?P:0)>=C);F++)L=F;if(!(M===m&&L===v)){m=M,v=L,d.empty();for(let F=M;F<=L;F++){let _=t[F];if(!_)continue;let V=d.createDiv({cls:"wl-virt-item"});V.style.top=`${(I=l[F])!=null?I:0}px`,_.kind==="title"?this.renderRow(V,_.data):this.renderGroupRow(V,_.data,_.members)}}};f();let y=()=>{p===0&&(p=window.requestAnimationFrame(()=>{p=0,f()}))};u.addEventListener("scroll",y,{passive:!0}),this.scrollCleanup=()=>{u.removeEventListener("scroll",y),p!==0&&(window.cancelAnimationFrame(p),p=0)}}getDisplayItems(){let s=this.dataManager.getGroups(),t=this.dataManager.getGroupedTitleIds(),e=this.dataManager.getTitles(),i=new Map(e.map(l=>[l.id,l])),a=this.searchQuery.trim().toLowerCase(),n=this.filterGroupsOnly?[]:this.getFilteredSortedTitles().filter(l=>!t.has(l.id)),r=[];for(let l of s){if(this.filterGroupExclude.size>0&&this.filterGroupExclude.has(l.name))continue;let c=l.titleIds.map(u=>i.get(u)).filter(u=>u!==void 0);if(a){let u=l.name.toLowerCase().includes(a),h=c.some(p=>p.title.toLowerCase().includes(a));if(!u&&!h)continue}(this.filterTypeExclude.size>0||this.filterStatusExclude.size>0||this.filterRatingExclude.size>0||this.filterPriorityExclude.size>0)&&!c.some(u=>this.titlePassesFilters(u))||r.push({kind:"group",data:l,members:c})}return[...r,...n.map(l=>({kind:"title",data:l}))].sort((l,c)=>{let d=l.kind==="title"&&l.data.pinned||l.kind==="group"&&l.data.id===this.pinnedGroupId,u=c.kind==="title"&&c.data.pinned||c.kind==="group"&&c.data.id===this.pinnedGroupId;return d&&!u?-1:!d&&u?1:this.compareDisplayItems(l,c)})}getStatusIndex(s){let t=this.STATUS_ORDER.indexOf(s);return t===-1?99:t}compareDisplayItemsByKey(s,t,e){let i=p=>p.kind==="title"?p.data.title:p.data.name,a=p=>p.kind==="title"?this.dataManager.getProgress(p.data):this.getGroupProgress(p.members),n=p=>{if(p.kind==="title"){let v=te.indexOf(p.data.priority);return v===-1?99:v}let m=p.members.map(v=>te.indexOf(v.priority)).filter(v=>v!==-1);return m.length>0?Math.min(...m):99},r=p=>{if(p.kind==="title")return new Date(p.data.dateAdded).getTime();let m=p.members.map(v=>new Date(v.dateAdded).getTime());return m.length>0?Math.max(...m):0},o=p=>{if(p.kind==="title")return p.data.dateStarted?new Date(p.data.dateStarted).getTime():null;let m=p.members.map(v=>v.dateStarted?new Date(v.dateStarted).getTime():null).filter(v=>v!==null);return m.length>0?Math.min(...m):null},l=p=>p.kind==="title"?this.getStatusIndex(p.data.status):this.getStatusIndex(this.getGroupStatus(p.members)),c=p=>{if(p.kind==="title")return p.data.rating;let m=p.members.filter(v=>v.rating>0);return m.length===0?0:m.reduce((v,f)=>v+f.rating,0)/m.length},d=p=>{if(p.kind==="title")return p.data.dateFinished?new Date(p.data.dateFinished).getTime():0;let m=p.members.map(v=>v.dateFinished?new Date(v.dateFinished).getTime():0).filter(v=>v>0);return m.length>0?Math.max(...m):0},u=p=>p.kind==="title"?this.dataManager.calcTimeRemaining(p.data):p.members.reduce((m,v)=>m+this.dataManager.calcTimeRemaining(v),0),h=p=>p.kind==="title"?this.dataManager.calcTimeWatched(p.data):p.members.reduce((m,v)=>m+this.dataManager.calcTimeWatched(v),0);switch(s){case"title-asc":return i(t).localeCompare(i(e));case"title-desc":return i(e).localeCompare(i(t));case"dateAdded-newest":return r(e)-r(t);case"dateAdded-oldest":return r(t)-r(e);case"progress-high":return a(e)-a(t);case"progress-low":return a(t)-a(e);case"rating-high":{let p=c(t),m=c(e);return p===0&&m===0?0:p===0?1:m===0?-1:m-p}case"rating-low":{let p=c(t),m=c(e);return p===0&&m===0?0:p===0?1:m===0?-1:p-m}case"priority":return n(t)-n(e);case"status-asc":return l(t)-l(e);case"status-desc":return l(e)-l(t);case"started-asc":{let p=o(t),m=o(e);return p==null&&m==null?0:p==null?1:m==null?-1:p-m}case"started-desc":{let p=o(t),m=o(e);return p==null&&m==null?0:p==null?1:m==null?-1:m-p}case"dateWatched-newest":return d(e)-d(t);case"dateWatched-oldest":{let p=d(t)||1/0,m=d(e)||1/0;return p-m}case"timeLeft-high":return u(e)-u(t);case"timeLeft-low":return u(t)-u(e);case"timeWatched-high":return h(e)-h(t);case"timeWatched-low":return h(t)-h(e);default:return 0}}compareDisplayItems(s,t){let e=et.sortBaseAndDirToKey(this.filterSort,this.filterSortDir),i=this.compareDisplayItemsByKey(e,s,t);if(i!==0||this.filterSecondSort==="none")return i;let a=et.sortBaseAndDirToKey(this.filterSecondSort,this.filterSecondSortDir);return this.compareDisplayItemsByKey(a,s,t)}renderRow(s,t,e=!1){let i=this.expandedId===t.id,a=this.selectedItems.has(t.id),n=["wl-row",i?"is-expanded":"",e?"wl-row-indented":"",a?"wl-row-selected":""].filter(Boolean).join(" "),r=s.createDiv({cls:n});if(r.dataset.titleId=t.id,this.selectionMode){let k=r.createDiv({cls:"wl-col-select"}).createEl("input",{attr:{type:"checkbox"}});k.checked=a,k.addEventListener("click",C=>C.stopPropagation()),k.addEventListener("change",()=>{k.checked?this.selectedItems.add(t.id):this.selectedItems.delete(t.id),this.render()})}let o=r.createDiv({cls:"wl-col-title"});o.createDiv({cls:"wl-row-title-text",text:t.title});let l=this.getTagDef(t.type,this.plugin.settings.types),c=this.plugin.settings.coloredTypeBadges,d=o.createDiv({cls:"wl-badge-row"}),u=d.createSpan({cls:c?"wl-badge wl-badge-sm":"wl-badge-plain",text:t.type});c&&l&&(u.style.backgroundColor=N(t.type,l.color,this.plugin.settings.colorTheme)),et.isRecentlyArrived(t)&&d.createSpan({cls:"wl-recently-arrived",text:"\xB7 Recently arrived"});let p=o.createDiv({cls:"wl-progress-wrap"}).createDiv({cls:"wl-progress-bar"});p.style.width=`${this.dataManager.getProgress(t)}%`;let m=r.createDiv({cls:"wl-col-priority"});if(t.priority){let E=this.getTagDef(t.priority,this.plugin.settings.priorities),k=m.createSpan({cls:"wl-priority-badge",text:t.priority});E&&(k.style.color=E.color)}let v=r.createDiv({cls:"wl-col-started"});v.textContent=t.dateStarted?Q(t.dateStarted):"\u2014";let f=r.createDiv({cls:"wl-col-rating"});t.rating>0?f.createSpan({cls:"wl-row-rating",text:`\u2605 ${t.rating}/5`}):f.createSpan({cls:"wl-row-rating wl-row-rating-empty",text:"\u2014"});let y=r.createDiv({cls:"wl-col-status"}),w=this.getTagDef(t.status,this.plugin.settings.statuses),b=y.createSpan({cls:c?"wl-badge":"wl-badge-plain",text:t.status});c&&w&&(b.style.backgroundColor=N(t.status,w.color,this.plugin.settings.colorTheme));let D=r.createDiv({cls:"wl-col-pin"}).createSpan({cls:`wl-pin-icon${t.pinned?" is-pinned":""}`,text:"\u{1F4CC}"});D.title=t.pinned?"Unpin":"Pin to top",D.addEventListener("click",E=>{E.stopPropagation(),(async()=>{let k=this.dataManager.getTitle(t.id);if(!k)return;let C=!k.pinned;if(C)for(let x of this.dataManager.getTitles())x.id!==k.id&&x.pinned&&(x.pinned=!1,await this.dataManager.updateTitle(x));k.pinned=C,await this.dataManager.updateTitle(k),this.rerenderTable()})()}),r.addEventListener("click",()=>{if(this.selectionMode){this.selectedItems.has(t.id)?this.selectedItems.delete(t.id):this.selectedItems.add(t.id),this.render();return}this.expandedId=i?null:t.id,i||(this.collapsedSeasons=this.dataManager.getCollapsedSeasonsForTitle(t.id)),this.rerenderTable()}),i&&!this.selectionMode&&this.renderAccordion(s,t)}renderGroupRow(s,t,e){var T,M,L,j,A;let i=this.expandedGroups.has(t.id),a=this.selectedItems.has(t.id),n=s.createDiv({cls:`wl-row wl-group-row${i?" is-expanded":""}${a?" wl-row-selected":""}`});if(n.dataset.type=(M=(T=e[0])==null?void 0:T.type)!=null?M:"",n.dataset.status=this.getGroupStatus(e),n.dataset.priority=(L=this.getGroupHighestPriority(e))!=null?L:"",n.dataset.rating=`${Math.round(this.getGroupRating(e))}\u2605`,n.dataset.group=t.name,this.selectionMode){let I=n.createDiv({cls:"wl-col-select"}).createEl("input",{attr:{type:"checkbox"}});I.checked=a,I.addEventListener("click",F=>F.stopPropagation()),I.addEventListener("change",()=>{I.checked?this.selectedItems.add(t.id):this.selectedItems.delete(t.id),this.render()})}let r=n.createDiv({cls:"wl-col-title"});if(this.renamingGroupId===t.id){let P=r.createDiv({cls:"wl-group-name-row"}),I=P.createEl("input",{cls:"wl-group-rename-input",attr:{type:"text",value:t.name}});I.addEventListener("click",V=>V.stopPropagation()),I.addEventListener("keydown",V=>{V.stopPropagation(),V.key==="Enter"?(async()=>{let G=I.value.trim();if(G&&G!==t.name){let Mt={...t,name:G};await this.dataManager.updateGroup(Mt)}this.renamingGroupId=null,this.rerenderTable()})():V.key==="Escape"&&(this.renamingGroupId=null,this.rerenderTable())});let F=P.createEl("button",{cls:"wl-group-action-btn",text:"\u2713"});F.title="Save",F.addEventListener("click",V=>{V.stopPropagation(),(async()=>{let G=I.value.trim();if(G&&G!==t.name){let Mt={...t,name:G};await this.dataManager.updateGroup(Mt)}this.renamingGroupId=null,this.rerenderTable()})()});let _=P.createEl("button",{cls:"wl-group-action-btn",text:"\u2715"});_.title="Cancel",_.addEventListener("click",V=>{V.stopPropagation(),this.renamingGroupId=null,this.rerenderTable()}),window.setTimeout(()=>I.focus(),0)}else{let P=r.createDiv({cls:"wl-group-name-row"});P.createSpan({cls:"wl-row-title-text wl-group-name",text:t.name});let I=P.createEl("button",{cls:"wl-group-action-btn",text:"\u270F"});I.title="Rename group",I.addEventListener("click",_=>{_.stopPropagation(),this.renamingGroupId=t.id,this.rerenderTable()});let F=P.createEl("button",{cls:"wl-group-action-btn wl-group-action-btn-delete wl-btn-danger",text:"\u2715"});F.title="Delete group (titles are kept)",F.addEventListener("click",_=>{_.stopPropagation(),new H(this.plugin.app,`Delete group "${t.name}"? All titles inside will be returned to the main list.`,()=>{this.dataManager.removeGroup(t.id).then(()=>{this.expandedGroups.delete(t.id),this.rerenderTable()})}).open()})}let o=(A=(j=e[0])==null?void 0:j.type)!=null?A:"";if(o){let P=this.getTagDef(o,this.plugin.settings.types),I=this.plugin.settings.coloredTypeBadges,F=r.createSpan({cls:I?"wl-badge wl-badge-sm":"wl-badge-plain",text:o});I&&P&&(F.style.backgroundColor=N(o,P.color,this.plugin.settings.colorTheme))}let l=r.createDiv({cls:"wl-group-stats"}),c=e.reduce((P,I)=>P+this.dataManager.calcTimeWatched(I),0),d=e.reduce((P,I)=>P+this.dataManager.calcTimeRemaining(I),0);l.createSpan({text:`${e.length} title${e.length!==1?"s":""}`}),l.createSpan({text:` \xB7 ${z(c)} watched`}),l.createSpan({text:` \xB7 ${z(d)} left`});let u=r.createDiv({cls:"wl-progress-wrap"});u.createDiv({cls:"wl-progress-bar"}).style.width=`${this.getGroupProgress(e)}%`;let h=n.createDiv({cls:"wl-col-priority"}),p=this.getGroupHighestPriority(e);if(p){let P=this.getTagDef(p,this.plugin.settings.priorities),I=h.createSpan({cls:"wl-priority-badge",text:p});P&&(I.style.color=P.color)}let m=n.createDiv({cls:"wl-col-started"}),v=this.getGroupStartedDate(e);m.textContent=v?Q(v):"\u2014";let f=n.createDiv({cls:"wl-col-rating"}),y=this.getGroupRating(e);y>0?f.createSpan({cls:"wl-row-rating",text:`\u2605 ${y.toFixed(1)}/5`}):f.createSpan({cls:"wl-row-rating wl-row-rating-empty",text:"\u2014"});let w=n.createDiv({cls:"wl-col-status"}),b=this.getGroupStatus(e),S=this.getTagDef(b,this.plugin.settings.statuses),D=this.plugin.settings.coloredTypeBadges,E=w.createSpan({cls:D?"wl-badge":"wl-badge-plain",text:b});D&&(S?E.style.backgroundColor=N(b,S.color,this.plugin.settings.colorTheme):b==="In Progress"&&(E.style.backgroundColor=N("In Progress","#724CF9",this.plugin.settings.colorTheme)));let k=this.pinnedGroupId===t.id,x=n.createDiv({cls:"wl-col-pin"}).createSpan({cls:`wl-pin-icon${k?" is-pinned":""}`,text:"\u{1F4CC}"});if(x.title=k?"Unpin":"Pin to top",x.addEventListener("click",P=>{if(P.stopPropagation(),this.pinnedGroupId=k?null:t.id,this.dataManager.setPinnedGroupId(this.pinnedGroupId),this.pinnedGroupId)for(let I of this.dataManager.getTitles())I.pinned&&(I.pinned=!1,this.dataManager.updateTitle(I));this.rerenderTable()}),n.addEventListener("click",()=>{if(this.renamingGroupId!==t.id){if(this.selectionMode){this.selectedItems.has(t.id)?this.selectedItems.delete(t.id):this.selectedItems.add(t.id),this.render();return}i?this.expandedGroups.delete(t.id):this.expandedGroups.add(t.id),this.rerenderTable()}}),i)for(let P of e)this.renderRow(s,P,!0)}getGroupProgress(s){let t=s.reduce((i,a)=>i+a.watchedEpisodes.length,0),e=s.reduce((i,a)=>i+this.dataManager.getEffectiveTotal(a),0);return e===0?0:Math.min(100,Math.round(t/e*100))}getGroupStatus(s){if(s.length===0)return"Plan to watch";let t=s.map(e=>e.status);return t.some(e=>e==="Watching")?"Watching":t.every(e=>e==="Completed")?"Completed":t.every(e=>e==="Plan to watch")?"Plan to watch":"In Progress"}getGroupStartedDate(s){var e;let t=s.map(i=>i.dateStarted).filter(i=>i!==null);return t.length===0?null:(e=t.sort()[0])!=null?e:null}getGroupRating(s){let t=s.filter(e=>e.rating>0);return t.length===0?0:t.reduce((e,i)=>e+i.rating,0)/t.length}getGroupHighestPriority(s){let t=null,e=1/0;for(let i of s){let a=te.indexOf(i.priority);a!==-1&&a<e&&(e=a,t=i.priority)}return t}renderAccordion(s,t){let e=s.createDiv({cls:"wl-accordion"});e.addEventListener("click",w=>w.stopPropagation());let i=e.createDiv({cls:"wl-accordion-header"}),a=i.createDiv({cls:"wl-acc-header-left"}),n=t.totalEpisodes>0?` \xB7 ${t.totalEpisodes} eps total`:"",r=t.dateStarted?`Started ${Q(t.dateStarted)}`:"Not started",o=a.createDiv({cls:"wl-acc-subtitle"});if(o.createSpan({cls:"wl-acc-subtitle-text",text:`${r}${n}`}),t.externalLink){let w=o.createEl("a",{cls:"wl-acc-link-icon",text:"\u{1F310}"});w.href=t.externalLink,w.title="Open external link",w.target="_blank",w.rel="noopener noreferrer",w.addEventListener("click",b=>b.stopPropagation())}let l=t.watchedEpisodes.length,c=this.dataManager.getEffectiveTotal(t),d=this.dataManager.calcTimeWatched(t),u=this.dataManager.calcTimeRemainingForModal(t),h=this.dataManager.getProgress(t),p=i.createDiv({cls:"wl-acc-right-group"}),m=(w,b)=>{let S=p.createDiv({cls:"wl-acc-stat-block"});S.createDiv({cls:"wl-acc-percent",text:w}),S.createDiv({cls:"wl-acc-progress-label",text:b})};m(z(u),"left"),m(z(d),"watched"),m(`${l} / ${c}`,"episodes");let v=p.createDiv({cls:"wl-acc-header-right"});v.createDiv({cls:"wl-acc-percent",text:`${h}%`}),v.createDiv({cls:"wl-acc-progress-label",text:"progress"});let f=v.createDiv({cls:"wl-acc-progress-wrap"});f.createDiv({cls:"wl-progress-bar"}).style.width=`${h}%`;let y=e.createDiv({cls:"wl-accordion-body"});t.type==="Movie"?this.renderMovieBody(y,t):this.renderEpisodesBody(y,t),this.renderAccordionFooter(e,t)}renderMovieBody(s,t){let e=s.createDiv({cls:"wl-movie-row"}),i=e.createEl("input",{cls:"wl-movie-checkbox",attr:{type:"checkbox"}});i.checked=t.watchedEpisodes.includes(1),e.createSpan({cls:"wl-movie-label",text:"Watched"}),i.addEventListener("change",a=>{a.stopPropagation(),this.dataManager.markEpisodeWatched(t.id,1,i.checked).then(()=>this.rerenderTable())})}renderEpisodesBody(s,t){if(t.seasons.length===0){t.totalEpisodes>0&&this.renderEpisodeGrid(s,t,null);return}t.seasons.forEach((e,i)=>{var h,p;let a=this.collapsedSeasons.has(i),n=s.createDiv({cls:"wl-season-wrap"}),r=n.createDiv({cls:"wl-season-header"}),o=r.createSpan({cls:"wl-season-badge"});o.textContent=e.name;let l=this.plugin.settings.seasonPalette;o.style.backgroundColor=(h=l[i%l.length])!=null?h:"#888780";let c=((p=e.skippedEpisodes)!=null?p:[]).length,d=c>0?` (${c} to skip)`:"";r.createSpan({cls:"wl-season-ep-count",text:`${e.episodes} eps${d}`});let u=r.createSpan({cls:`wl-chevron${a?"":" is-open"}`,text:"\u203A"});a||this.renderEpisodeGrid(n,t,e),r.addEventListener("click",m=>{var v;m.stopPropagation(),a?(a=!1,this.collapsedSeasons.delete(i),u.classList.add("is-open"),this.renderEpisodeGrid(n,t,e)):(a=!0,this.collapsedSeasons.add(i),u.classList.remove("is-open"),(v=n.querySelector(".wl-episode-grid"))==null||v.remove()),this.expandedId&&this.dataManager.persistCollapsedSeasons(this.expandedId,this.collapsedSeasons)})})}renderEpisodeGrid(s,t,e){let i=s.createDiv({cls:"wl-episode-grid"}),a=e?e.episodes:t.totalEpisodes,n=e?e.offset:0,r=Array.from({length:a},(d,u)=>n+u+1),o=i.createDiv({cls:"wl-season-fill-btn"}),l=()=>{let d=new Set(t.watchedEpisodes),u=r.length>0&&r.every(h=>d.has(h));o.classList.toggle("is-clear",u),o.classList.toggle("is-fill",!u),o.textContent=u?"\u2717":"\u2713",o.title=u?"Clear all episodes in this season":"Mark all episodes in this season as watched"};l(),o.addEventListener("click",d=>{d.stopPropagation();let u=new Set(t.watchedEpisodes),h=r.length>0&&r.every(p=>u.has(p));this.dataManager.markSeasonWatched(t.id,r,!h).then(()=>this.rerenderTable())});let c=this.plugin.settings.episodeNumbering==="per-season";for(let d=0;d<a;d++){let u=n+d+1,h=d+1,p=c?h:u,m=i.createDiv({cls:"wl-episode-box"}),v=()=>{var w;let f=t.watchedEpisodes.includes(u),y=e?((w=e.skippedEpisodes)!=null?w:[]).includes(h):!1;m.classList.toggle("is-watched",f),m.classList.toggle("is-skipped",y),m.textContent=y&&!f?"\u2014":f?"\u2713":String(p),m.title=`Episode ${u}${y?" (skipped)":""}`};v(),m.addEventListener("click",f=>{f.stopPropagation();let y=t.watchedEpisodes.includes(u);this.dataManager.applyEpisodeWatchedToggle(t.id,u,!y),v(),l(),this.updateAccordionStats(m,t,e)})}}updateAccordionStats(s,t,e){var h;let i=s.closest(".wl-accordion");if(!i)return;let a=this.dataManager.calcTimeRemainingForModal(t),n=this.dataManager.calcTimeWatched(t),r=t.watchedEpisodes.length,o=this.dataManager.getEffectiveTotal(t),l=this.dataManager.getProgress(t),c=i.querySelectorAll(".wl-acc-stat-block .wl-acc-percent");c[0]&&(c[0].textContent=z(a)),c[1]&&(c[1].textContent=z(n)),c[2]&&(c[2].textContent=`${r} / ${o}`);let d=i.querySelector(".wl-acc-header-right .wl-acc-percent");d&&(d.textContent=`${l}%`);let u=i.querySelector(".wl-acc-header-right .wl-progress-bar");if(u&&(u.style.width=`${l}%`),e){let p=s.closest(".wl-episode-grid"),m=p==null?void 0:p.parentElement,v=m==null?void 0:m.querySelector(".wl-season-header .wl-season-ep-count");if(v){let f=((h=e.skippedEpisodes)!=null?h:[]).length,y=f>0?` (${f} to skip)`:"";v.textContent=`${e.episodes} eps${y}`}}}renderAccordionFooter(s,t){let e=s.createDiv({cls:"wl-accordion-footer"}),i=e.createDiv({cls:"wl-stars-row"});i.createSpan({cls:"wl-stars-label",text:"Rating"});let a=i.createDiv({cls:"wl-stars"});for(let w=1;w<=5;w++)a.createSpan({cls:`wl-star${t.rating>=w?" is-active":""}`,text:"\u2605"}).addEventListener("click",S=>{S.stopPropagation(),(async()=>{let D=this.dataManager.getTitle(t.id);D&&(D.rating=D.rating===w?0:w,await this.dataManager.updateTitle(D),this.rerenderTable())})()});let n=()=>{var S;let b=i.querySelector(".wl-rating-divider");for(;b;){let D=b;b=b.nextSibling,(S=D.parentNode)==null||S.removeChild(D)}Ft(i,this.plugin,t.id,n)};Ft(i,this.plugin,t.id,n),we(this.plugin,t.id,n);let o=e.createDiv({cls:"wl-footer-row"}).createEl("input",{cls:"wl-notes-input",attr:{type:"text",placeholder:"Add a note..."}});o.value=t.notes,o.addEventListener("click",w=>w.stopPropagation()),o.addEventListener("change",()=>{(async()=>{let w=this.dataManager.getTitle(t.id);w&&(w.notes=o.value,await this.dataManager.updateTitle(w),this.rerenderTable())})()});let l=e.createDiv({cls:"wl-footer-row"});l.createSpan({cls:"wl-footer-label",text:"Date watched"});let c=l.createEl("button",{cls:"wl-btn wl-btn-sm wl-footer-today-btn",text:"Today",attr:{title:"Fill with today\u2019s date"}}),d=l.createEl("input",{cls:"wl-footer-date",attr:{type:"text",placeholder:"Dd/mm/yyyy",maxlength:"10"}});d.value=Q(t.dateFinished);let u=()=>{c.toggleClass("is-dimmed",!!d.value.trim())};u(),d.addEventListener("click",w=>w.stopPropagation()),d.addEventListener("change",()=>{(async()=>{let w=this.dataManager.getTitle(t.id);if(!w)return;let b=q(d.value);if(d.value.trim()&&!b){d.addClass("wl-input-error");return}d.removeClass("wl-input-error"),w.dateFinished=b,await this.dataManager.updateTitle(w),this.rerenderTable()})()}),c.addEventListener("click",w=>{if(w.stopPropagation(),d.value.trim())return;let b=new Date,S=String(b.getDate()).padStart(2,"0"),D=String(b.getMonth()+1).padStart(2,"0"),E=b.getFullYear();d.value=`${S}/${D}/${E}`,u(),d.dispatchEvent(new Event("change"))});let h=e.createDiv({cls:"wl-footer-row"});h.createSpan({cls:"wl-footer-label",text:"Status"});let p=h.createEl("select",{cls:"wl-select wl-select-sm"});for(let w of this.plugin.settings.statuses){if(w.name==="To be released")continue;let b=p.createEl("option",{text:w.name,value:w.name});w.name===t.status&&(b.selected=!0)}p.addEventListener("click",w=>w.stopPropagation()),p.addEventListener("change",()=>{(async()=>{let w=this.dataManager.getTitle(t.id);w&&(w.status=p.value,await this.dataManager.updateTitle(w),this.rerenderTable())})()});let m=e.createDiv({cls:"wl-footer-row wl-footer-action-row"});m.createEl("button",{cls:"wl-delete-btn wl-btn-danger",text:"Remove"}).addEventListener("click",w=>{w.stopPropagation(),new H(this.plugin.app,`Remove "${t.title}" from watchlog?`,()=>{this.dataManager.removeTitle(t.id).then(()=>{this.expandedId=null,this.rerenderTable()})}).open()}),m.createDiv({cls:"wl-add-btn-wrap"}).createEl("button",{cls:"wl-edit-btn",text:"Edit"}).addEventListener("click",w=>{w.stopPropagation();let b=this.dataManager.getTitle(t.id);b&&new St(this.plugin.app,this.plugin,this.dataManager,b,()=>{this.rerenderTable()}).open()})}compareTitlesByKey(s,t,e){switch(s){case"title-asc":return t.title.localeCompare(e.title);case"title-desc":return e.title.localeCompare(t.title);case"dateAdded-newest":return new Date(e.dateAdded).getTime()-new Date(t.dateAdded).getTime();case"dateAdded-oldest":return new Date(t.dateAdded).getTime()-new Date(e.dateAdded).getTime();case"progress-high":return this.dataManager.getProgress(e)-this.dataManager.getProgress(t);case"progress-low":return this.dataManager.getProgress(t)-this.dataManager.getProgress(e);case"rating-high":{let i=t.rating===0?-1:t.rating;return(e.rating===0?-1:e.rating)-i}case"rating-low":return t.rating===0&&e.rating===0?0:t.rating===0?1:e.rating===0?-1:t.rating-e.rating;case"priority":{let i=te.indexOf(t.priority),a=te.indexOf(e.priority);return(i===-1?99:i)-(a===-1?99:a)}case"started-asc":{let i=t.dateStarted?new Date(t.dateStarted).getTime():1/0,a=e.dateStarted?new Date(e.dateStarted).getTime():1/0;return i-a}case"started-desc":{let i=t.dateStarted?new Date(t.dateStarted).getTime():null,a=e.dateStarted?new Date(e.dateStarted).getTime():null;return i==null&&a==null?0:i==null?1:a==null?-1:a-i}case"status-asc":return this.getStatusIndex(t.status)-this.getStatusIndex(e.status);case"status-desc":return this.getStatusIndex(e.status)-this.getStatusIndex(t.status);case"dateWatched-newest":{let i=t.dateFinished?new Date(t.dateFinished).getTime():0;return(e.dateFinished?new Date(e.dateFinished).getTime():0)-i}case"dateWatched-oldest":{let i=t.dateFinished?new Date(t.dateFinished).getTime():1/0,a=e.dateFinished?new Date(e.dateFinished).getTime():1/0;return i-a}case"timeLeft-high":return this.dataManager.calcTimeRemaining(e)-this.dataManager.calcTimeRemaining(t);case"timeLeft-low":return this.dataManager.calcTimeRemaining(t)-this.dataManager.calcTimeRemaining(e);case"timeWatched-high":return this.dataManager.calcTimeWatched(e)-this.dataManager.calcTimeWatched(t);case"timeWatched-low":return this.dataManager.calcTimeWatched(t)-this.dataManager.calcTimeWatched(e);default:return 0}}getFilteredSortedTitles(){let s=this.dataManager.getTitles();if(this.searchQuery.trim()){let i=this.searchQuery.toLowerCase();s=s.filter(a=>a.title.toLowerCase().includes(i))}s=s.filter(i=>this.titlePassesFilters(i));let t=et.sortBaseAndDirToKey(this.filterSort,this.filterSortDir),e=this.filterSecondSort==="none"?"none":et.sortBaseAndDirToKey(this.filterSecondSort,this.filterSecondSortDir);return s=[...s].sort((i,a)=>{let n=this.compareTitlesByKey(t,i,a);return n!==0||e==="none"?n:this.compareTitlesByKey(e,i,a)}),s}getTagDef(s,t){return t.find(e=>e.name===s)}};et.SORT_DEFAULT_DIR={title:"asc",dateAdded:"desc",progress:"desc",rating:"desc",priority:"asc",started:"asc",status:"asc",dateWatched:"desc",timeLeft:"desc",timeWatched:"desc"};var Ee=et;var pt=require("obsidian");var Rt=require("obsidian");function Se(g){return"malId"in g}var De=class extends Rt.Modal{constructor(t,e,i,a){super(t);this.selectedType="Anime";this.searchQuery="";this.searchResults=[];this.isSearching=!1;this.searchGeneration=0;this.fieldTitle="";this.fieldEpisodes=0;this.fieldDuration=0;this.fieldReleaseDate="";this.fieldLink="";this.resultsEl=null;this.formEl=null;this.searchDebounce=null;this.plugin=e,this.dataManager=i,this.onAdded=a}onOpen(){this.titleEl.setText("Add to maybe"),this.contentEl.addClass("wl-add-modal"),this.buildUI()}onClose(){this.contentEl.empty(),this.searchDebounce&&window.clearTimeout(this.searchDebounce)}buildUI(){let t=this.contentEl;t.empty();let e=t.createDiv({cls:"wl-modal-row"});e.createSpan({cls:"wl-modal-label",text:"Type"});let i=e.createEl("select",{cls:"wl-select"});for(let o of this.plugin.settings.types){let l=i.createEl("option",{text:o.name,value:o.name});o.name===this.selectedType&&(l.selected=!0)}i.addEventListener("change",()=>{this.selectedType=i.value,this.searchResults=[],this.renderResults(),this.renderForm()});let a=t.createDiv({cls:"wl-modal-row"});a.createSpan({cls:"wl-modal-label",text:"Search"});let n=a.createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Search for a title..."}});n.value=this.searchQuery,n.addEventListener("input",()=>{this.searchQuery=n.value,this.searchDebounce&&window.clearTimeout(this.searchDebounce),this.searchDebounce=window.setTimeout(()=>void this.performSearch(),600)}),a.createEl("button",{cls:"wl-btn",text:"Search"}).addEventListener("click",()=>void this.performSearch()),this.resultsEl=t.createDiv({cls:"wl-modal-results"}),this.formEl=t.createDiv({cls:"wl-modal-form"}),this.renderForm()}async performSearch(){var e,i;if(!this.searchQuery.trim())return;let t=++this.searchGeneration;this.isSearching=!0,this.renderResults();try{let a=this.plugin.apiService,n=(e=this.plugin.settings.activeApi)!=null?e:"OMDb",r=nt(this.selectedType,this.plugin.settings.typeApiMapping),o=this.selectedType==="Movie",l=[];if(r===""?new Rt.Notice(`No API configured for type "${this.selectedType}". Configure it in Settings \u2192 API.`):r==="anime"?l=((i=this.plugin.settings.animeApiSource)!=null?i:"jikan")==="anilist"?await a.searchAniList(this.searchQuery):await a.searchAnime(this.searchQuery):n==="TMDB"?l=await a.searchTmdb(this.searchQuery,o?"movie":"series"):l=await a.searchOmdb(this.searchQuery,o?"movie":"series"),t!==this.searchGeneration)return;this.searchResults=l}catch(a){if(t!==this.searchGeneration)return;new Rt.Notice("Search failed. Check your connection and API settings."),this.searchResults=[]}finally{t===this.searchGeneration&&(this.isSearching=!1,this.renderResults())}}renderResults(){if(this.resultsEl){if(this.resultsEl.empty(),this.isSearching){this.resultsEl.createDiv({cls:"wl-modal-loading",text:"Searching..."});return}this.searchResults.length&&this.searchResults.forEach((t,e)=>{let i=this.resultsEl.createDiv({cls:"wl-result-item"}),a=Se(t)?`${t.episodes} eps`:t.episodes>0?`${t.episodes} eps`:t.mediaType;i.createDiv({cls:"wl-result-title",text:t.title}),i.createDiv({cls:"wl-result-meta",text:`${a} \xB7 ${t.releaseDate}`}),i.dataset.idx=String(e),i.addEventListener("click",()=>void this.selectResult(t,i))})}}async selectResult(t,e){var r;if(this.resultsEl)for(let o of Array.from(this.resultsEl.children))o.removeClass("is-selected");e.addClass("is-selected");let i=++this.searchGeneration,a=this.plugin.apiService,n=(r=this.plugin.settings.activeApi)!=null?r:"OMDb";try{if(!Se(t)&&t.mediaType==="tv"){let o=n==="TMDB"?await a.getTmdbTvDetails(t.imdbId):await a.getOmdbTvDetails(t.imdbId);if(i!==this.searchGeneration)return;o&&(this.fieldTitle=o.title,this.fieldEpisodes=o.episodes,this.fieldDuration=o.episodeDuration,this.fieldReleaseDate=o.releaseDate,this.fieldLink=o.url)}else if(!Se(t)&&t.mediaType==="movie"){let o=n==="TMDB"?await a.getTmdbMovieDetails(t.imdbId):await a.getOmdbMovieDetails(t.imdbId);if(i!==this.searchGeneration)return;o&&(this.fieldTitle=o.title,this.fieldEpisodes=o.episodes,this.fieldDuration=o.episodeDuration,this.fieldReleaseDate=o.releaseDate,this.fieldLink=o.url)}else{let o=t;this.fieldTitle=o.title,this.fieldEpisodes=o.episodes,this.fieldDuration=o.duration,this.fieldReleaseDate=o.releaseDate,this.fieldLink=o.url}}catch(o){if(i!==this.searchGeneration)return;this.fieldTitle=t.title,this.fieldEpisodes=t.episodes,this.fieldDuration=Se(t)?t.duration:t.episodeDuration,this.fieldReleaseDate=t.releaseDate,this.fieldLink=t.url}i===this.searchGeneration&&this.renderForm()}renderForm(){if(!this.formEl)return;this.formEl.empty();let t=f=>{let y=this.formEl.createDiv({cls:"wl-modal-row"});return y.createSpan({cls:"wl-modal-label",text:f}),y},i=t("Title").createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Title name"}});i.value=this.fieldTitle,i.addEventListener("input",()=>{this.fieldTitle=i.value});let n=t("Episodes").createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"0",placeholder:"0"}});n.value=String(this.fieldEpisodes),n.addEventListener("input",()=>{this.fieldEpisodes=parseInt(n.value)||0});let o=t("Ep. duration (min)").createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"0",placeholder:"24"}});o.value=String(this.fieldDuration),o.addEventListener("input",()=>{this.fieldDuration=parseInt(o.value)||0});let c=t("Release date").createDiv({cls:"wl-modal-input-stack"}),d=c.createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Date (dd-mm-yyyy or yyyy-mm-dd)"}});d.value=this.fieldReleaseDate;let u=c.createDiv({cls:"wl-modal-error wl-hidden"});d.addEventListener("change",()=>{let f=d.value.trim();if(!f){this.fieldReleaseDate="",u.addClass("wl-hidden");return}let y=Bt(f);y?(this.fieldReleaseDate=y,d.value=y,u.addClass("wl-hidden")):(this.fieldReleaseDate=f,u.textContent="Unrecognised format.",u.removeClass("wl-hidden"))});let p=t("External link").createEl("input",{cls:"wl-modal-input",attr:{type:"url",placeholder:"https://example.com"}});p.value=this.fieldLink,p.addEventListener("input",()=>{this.fieldLink=p.value}),this.formEl.createDiv({cls:"wl-modal-btn-row"}).createEl("button",{cls:"wl-btn wl-btn-primary",text:"Add to maybe"}).addEventListener("click",()=>void this.addMaybeTitle())}async addMaybeTitle(){let t=this.fieldTitle.trim();if(!t){new Rt.Notice("Please enter a title name.");return}let e={id:this.dataManager.generateMaybeId(t),title:t,type:this.selectedType,releaseDate:this.fieldReleaseDate||null,externalLink:this.fieldLink,totalEpisodes:this.fieldEpisodes,episodeDuration:this.fieldDuration,dateAdded:new Date().toISOString()};await this.dataManager.addMaybeTitle(e),new Rt.Notice(`"${t}" added to Maybe.`),this.close(),this.onAdded()}};var ke=require("obsidian");var ee=class extends ke.Modal{constructor(s,t,e,i,a,n,r,o,l){var u,h;super(s),this.item=t,this.kind=e,this.schedule=i?{...i}:{recurrence:"once"},this.schedule.releaseTime=void 0,!this.schedule.releaseDate&&t.releaseDate&&/^\d{4}-\d{2}-\d{2}$/.test(t.releaseDate)&&(this.schedule.releaseDate=t.releaseDate),this.currentVolume=a,this.currentChapter=n;let c=(u=t.totalChapters)!=null?u:0,d=e==="manga"&&(h=t.totalVolumes)!=null?h:0;this.totalChapters=o!=null?o:c>0?c:null,this.totalVolumes=r!=null?r:d>0?d:null,this.onSave=l}onOpen(){this.titleEl.setText("Set reading schedule"),this.contentEl.addClass("wl-add-modal"),this.renderForm()}onClose(){this.contentEl.empty()}renderForm(){this.contentEl.empty(),this.contentEl.addClass("wl-add-modal");let s=this.contentEl,t=u=>{let h=s.createDiv({cls:"wl-modal-row"});return h.createSpan({cls:"wl-modal-label",text:u}),h},e=(u,h,p,m)=>{let f=t(u).createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"0",placeholder:p}});h!==null&&(f.value=String(h)),f.addEventListener("input",()=>{m(parseInt(f.value)||null)})},a=t("Recurrence").createEl("select",{cls:"wl-select"}),n=[["once","Once"],["daily","Daily"],["weekly","Weekly"],["monthly","Monthly"]];for(let[u,h]of n){let p=a.createEl("option",{text:h,value:u});u===this.schedule.recurrence&&(p.selected=!0)}let r=s.createDiv(),o=()=>{var h;r.empty();let u=this.schedule.recurrence;if(u==="once"){let p=r.createDiv({cls:"wl-modal-row"});p.createSpan({cls:"wl-modal-label",text:"Date"});let m=p.createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Dd/mm/yyyy",maxlength:"10"}});m.value=this.schedule.releaseDate?this.schedule.releaseDate.split("-").reverse().join("/"):"",m.addEventListener("change",()=>{let v=q(m.value);v&&(this.schedule.releaseDate=v)})}if(u==="weekly"){let p=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],m=r.createDiv({cls:"wl-modal-row"});m.createSpan({cls:"wl-modal-label",text:"Day of week"});let v=m.createEl("select",{cls:"wl-select"});p.forEach((f,y)=>{var b;let w=v.createEl("option",{text:f,value:String(y)});y===((b=this.schedule.dayOfWeek)!=null?b:6)&&(w.selected=!0)}),v.addEventListener("change",()=>{this.schedule.dayOfWeek=parseInt(v.value)})}if(u==="monthly"){let p=r.createDiv({cls:"wl-modal-row"});p.createSpan({cls:"wl-modal-label",text:"Day of month"});let m=p.createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"1",max:"31",placeholder:"1"}});m.value=String((h=this.schedule.dayOfMonth)!=null?h:1),m.addEventListener("input",()=>{this.schedule.dayOfMonth=parseInt(m.value)||1})}};a.addEventListener("change",()=>{this.schedule.recurrence=a.value,o()}),o(),e("Current chapter",this.currentChapter,"E.g. 12",u=>{this.currentChapter=u}),e("Current volume",this.currentVolume,"E.g. 2",u=>{this.currentVolume=u}),e("Total chapters",this.totalChapters,"E.g. 120",u=>{this.totalChapters=u}),e("Total volumes",this.totalVolumes,"E.g. 12",u=>{this.totalVolumes=u}),s.createDiv({cls:"wl-modal-info wl-schedule-hint",text:"Titles with 0 or 1 total chapters will be treated as a single release date, like a book."});let l=s.createDiv({cls:"wl-modal-btn-row"});l.createEl("button",{cls:"wl-btn wl-btn-mr",text:"Cancel"}).addEventListener("click",()=>this.close()),l.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Save"}).addEventListener("click",()=>{(async()=>{if(this.schedule.recurrence==="once"&&!this.schedule.releaseDate){new ke.Notice("Set a release date.");return}await this.onSave(this.schedule,this.currentVolume,this.currentChapter,this.totalVolumes,this.totalChapters),this.close()})()})}};var Zs=require("obsidian"),Te=class extends Zs.FuzzySuggestModal{constructor(s,t,e){super(s),this.items=t,this.onSelect=e,this.setPlaceholder("Search a title to add to Upcoming...")}getItems(){return this.items}getItemText(s){return`${s.title} (${s.typeLabel})`}onChooseItem(s){this.onSelect(s)}};function ns(g){if(g<30)return`in ${g} days`;let s=Math.round(g/30);return`in ${g} days (${s} month${s!==1?"s":""})`}function Ce(g){if(!g.releaseTime)return!0;let s=new Date,[t,e]=g.releaseTime.split(":"),i=parseInt(t!=null?t:"0"),a=parseInt(e!=null?e:"0");return s.getHours()>i||s.getHours()===i&&s.getMinutes()>=a}function rs(g,s,t){let e=new Date,i=new Date(e.getFullYear(),e.getMonth(),e.getDate()),a=`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")}`,n=t===a;if(g.recurrence==="once"){if(!g.releaseDate)return{kind:"future",label:"\u2014",daysUntil:9999};let r=new Date(g.releaseDate+"T00:00:00"),o=new Date(r.getFullYear(),r.getMonth(),r.getDate());if(o.getTime()<i.getTime())return s?{kind:"due",label:"Due",daysUntil:0}:{kind:"aired",label:"Aired",daysUntil:0};if(o.getTime()===i.getTime())return Ce(g)?s?{kind:"due",label:"Due",daysUntil:0}:{kind:"aired",label:"Aired",daysUntil:0}:{kind:"today-before",label:"Today",daysUntil:0};let l=Math.round((o.getTime()-i.getTime())/864e5);return l===1?{kind:"future",label:"Tomorrow",daysUntil:1}:{kind:"future",label:ns(l),daysUntil:l}}if(g.recurrence==="daily"){let r=Ce(g);return r&&n?{kind:"future",label:"Tomorrow",daysUntil:1}:r?{kind:"aired",label:"Aired",daysUntil:0}:{kind:"today-before",label:"Today",daysUntil:0}}if(g.recurrence==="weekly"&&g.dayOfWeek!==void 0){let r=e.getDay(),o=r===g.dayOfWeek;if(o&&!n)return Ce(g)?{kind:"aired",label:"Aired",daysUntil:0}:{kind:"today-before",label:"Today",daysUntil:0};let l=o&&n?7:(g.dayOfWeek-r+7)%7;return l===0&&(l=7),l===1?{kind:"future",label:"Tomorrow",daysUntil:1}:{kind:"future",label:ns(l),daysUntil:l}}if(g.recurrence==="monthly"&&g.dayOfMonth){let o=e.getDate()===g.dayOfMonth;if(o&&!n)return Ce(g)?{kind:"aired",label:"Aired",daysUntil:0}:{kind:"today-before",label:"Today",daysUntil:0};let l;o&&n?l=new Date(e.getFullYear(),e.getMonth()+1,g.dayOfMonth):(l=new Date(e.getFullYear(),e.getMonth(),g.dayOfMonth),l.getTime()<=i.getTime()&&(l=new Date(e.getFullYear(),e.getMonth()+1,g.dayOfMonth)));let c=Math.round((l.getTime()-i.getTime())/864e5);return c===1?{kind:"future",label:"Tomorrow",daysUntil:1}:{kind:"future",label:ns(c),daysUntil:c}}return{kind:"future",label:"\u2014",daysUntil:9999}}function aa(g){let s=new Date(g+"T00:00:00"),t=new Date;t.setHours(0,0,0,0);let e=Math.floor((t.getTime()-s.getTime())/864e5);return e<=0?"Today":e===1?"Yesterday":`${e} days ago`}var vt=class g{constructor(s,t,e,i){this.currentSubTab="tracker";this.selectionMode=!1;this.selectedItems=new Set;this.searchQuery="";this.container=s,this.plugin=t,this.dataManager=e,this.readingDataManager=t.readingDataManager,this.onCountChange=i}resolveEntry(s){var a,n,r,o,l;if(s.source==="reading"){let c=(a=s.readingKind)!=null?a:"book",d=c==="book"?this.readingDataManager.getBook(s.titleId):this.readingDataManager.getManga(s.titleId);if(!d)return null;let u=(r=(n=s.totalEpisodes)!=null?n:d.totalChapters)!=null?r:0;return{source:"reading",id:d.id,title:d.title,typeName:c==="book"?"Book":"Manga",typeColor:this.plugin.settings.coloredTypeBadges?pe(c,this.plugin.settings):null,externalLink:(o=d.externalLink)!=null?o:"",isSingle:u<=1,totalUnits:u,unitNoun:"chapter",unitNounCap:"Chapter",groupNounCap:"Volume",nextLabel:"Reading next",planStatus:"Plan to Read"}}let t=this.dataManager.getTitle(s.titleId);if(!t)return null;let e=this.plugin.settings.types.find(c=>c.name===t.type),i=this.plugin.settings.coloredTypeBadges;return{source:"watchlist",id:t.id,title:t.title,typeName:t.type,typeColor:i&&e?N(t.type,e.color,this.plugin.settings.colorTheme):null,externalLink:t.externalLink,isSingle:t.totalEpisodes<=1,totalUnits:(l=s.totalEpisodes)!=null?l:t.totalEpisodes,unitNoun:"episode",unitNounCap:"Episode",groupNounCap:"Season",nextLabel:"Airing next",planStatus:"Plan to watch"}}static getMaybeDueCount(s){let t=new Date;t.setHours(0,0,0,0);let e=t.toISOString().split("T")[0];return s.getMaybeTitles().filter(i=>!!i.releaseDate&&i.releaseDate<=e).length}static getAiredDueCount(s,t){var r,o;let e=s.getAirtimeEntries(),i=s.getTitles(),a=new Map(i.map(l=>[l.id,l])),n=0;for(let l of e){let c;if(l.source==="reading"){if(t&&!(((r=l.readingKind)!=null?r:"book")==="book"?t.getBook(l.titleId):t.getManga(l.titleId)))continue;c=((o=l.totalEpisodes)!=null?o:0)<=1}else{let u=a.get(l.titleId);if(!u)continue;c=u.totalEpisodes<=1}let d=rs(l.schedule,c,l.lastAcknowledgedDate);(d.kind==="aired"||d.kind==="due")&&n++}return n}render(){this.container.empty(),this.container.addClass("wl-airtime"),this.onCountChange&&this.onCountChange(g.getAiredDueCount(this.dataManager,this.readingDataManager)+g.getMaybeDueCount(this.dataManager)),this.renderInnerTabBar(),this.currentSubTab==="tracker"?this.renderTracker():this.currentSubTab==="history"?this.renderHistory():this.renderMaybe()}renderInnerTabBar(){let s=this.container.createDiv({cls:"wl-inner-tab-bar"}),t=g.getAiredDueCount(this.dataManager,this.readingDataManager),e=g.getMaybeDueCount(this.dataManager),i=[{key:"tracker",label:"Tracker",badge:t},{key:"history",label:"Log",badge:0},{key:"maybe",label:"Maybe",badge:e}];for(let{key:a,label:n,badge:r}of i){let o=r>0?`${n} (${r})`:n;s.createEl("button",{cls:`wl-inner-tab-btn${this.currentSubTab===a?" is-active":""}`,text:o}).addEventListener("click",()=>{this.currentSubTab!==a&&(this.currentSubTab=a,this.selectionMode=!1,this.selectedItems.clear(),this.searchQuery="",this.render())})}}renderTracker(){this.plugin.settings.showHintBanners&&this.container.createDiv({cls:"wl-cl-draft-banner",text:'\u26A0 All titles with a release date in the future will be automatically marked as "To be released" in Watchlist and added here.'}),this.renderHeader(),this.renderSearch(),this.renderCards()}renderHistory(){let s=new Date;s.setHours(0,0,0,0);let t=s.toISOString().split("T")[0],e=new Date(s);e.setMonth(e.getMonth()-6);let i=e.toISOString().split("T")[0],a=this.dataManager.getTitles().filter(r=>!!r.releaseDate&&r.releaseDate<=t&&r.releaseDate>=i).sort((r,o)=>{var l,c;return((l=o.releaseDate)!=null?l:"").localeCompare((c=r.releaseDate)!=null?c:"")});if(a.length===0){this.container.createDiv({cls:"wl-empty-state",text:"No past releases yet."});return}let n=this.container.createDiv({cls:"wl-airtime-cards"});for(let r of a)this.renderHistoryCard(n,r)}renderHistoryCard(s,t){let e=s.createDiv({cls:"wl-airtime-card wl-airtime-history-card"}),i=e.createDiv({cls:"wl-airtime-card-left"});i.createDiv({cls:"wl-airtime-card-title",text:t.title});let a=i.createDiv({cls:"wl-airtime-card-meta"}),n=this.plugin.settings.types.find(u=>u.name===t.type),r=this.plugin.settings.coloredTypeBadges,o=a.createSpan({cls:r?"wl-badge wl-badge-sm":"wl-badge-plain",text:t.type});r&&n&&(o.style.backgroundColor=N(t.type,n.color,this.plugin.settings.colorTheme)),t.releaseDate&&a.createSpan({cls:"wl-airtime-schedule",text:t.releaseDate});let l=e.createDiv({cls:"wl-airtime-card-right wl-airtime-history-right"});t.releaseDate&&l.createDiv({cls:"wl-airtime-pill wl-airtime-pill-aired",text:aa(t.releaseDate)});let c=l.createEl("button",{cls:"wl-airtime-action-btn",text:"\u{1F310}"});c.title="Open page",c.addEventListener("click",u=>{u.stopPropagation(),t.externalLink?activeWindow.open(t.externalLink,"_blank"):new pt.Notice("No external link set for this title.")});let d=l.createEl("button",{cls:"wl-airtime-action-btn wl-airtime-action-btn-delete wl-btn-danger",text:"\u2715"});d.title="Remove from watchlist",d.addEventListener("click",u=>{u.stopPropagation(),new H(this.plugin.app,`Remove "${t.title}" from Watchlist?`,()=>{this.dataManager.removeTitle(t.id).then(()=>this.render())}).open()})}renderMaybe(){let s=this.dataManager.getMaybeTitles(),t=this.container.createDiv({cls:"wl-list-header"});if(s.length>0&&t.createSpan({cls:"wl-list-count",text:String(s.length)}),t.createDiv({cls:"wl-header-controls"}).createDiv({cls:"wl-header-controls-right"}).createDiv({cls:"wl-add-btn-wrap"}).createEl("button",{cls:"wl-add-btn wl-btn-success",text:"+ add"}).addEventListener("click",c=>{c.stopPropagation(),new De(this.plugin.app,this.plugin,this.dataManager,()=>this.render()).open()}),s.length===0){this.container.createDiv({cls:"wl-empty-state",text:`No "Maybe" titles yet. Click + Add to track something you're considering.`});return}let r=new Date;r.setHours(0,0,0,0);let o=this.container.createDiv({cls:"wl-airtime-cards"}),l=[...s].sort((c,d)=>{var p,m;let u=(p=c.releaseDate)!=null?p:"",h=(m=d.releaseDate)!=null?m:"";return!u&&!h?c.dateAdded.localeCompare(d.dateAdded):u?h?u.localeCompare(h):-1:1});for(let c of l)this.renderMaybeCard(o,c,r)}renderMaybeCard(s,t,e){var f;let i={recurrence:"once",releaseDate:(f=t.releaseDate)!=null?f:void 0},a=rs(i,!0),n="wl-maybe-card";a.kind==="due"&&(n+=" wl-airtime-card-aired is-due");let r=s.createDiv({cls:n}),o=r.createDiv({cls:"wl-maybe-card-left"});o.createSpan({cls:"wl-maybe-card-title",text:t.title});let l=this.plugin.settings.types.find(y=>y.name===t.type),c=this.plugin.settings.coloredTypeBadges,d=o.createSpan({cls:c?"wl-badge wl-badge-sm":"wl-badge-plain",text:t.type});c&&l&&(d.style.backgroundColor=N(t.type,l.color,this.plugin.settings.colorTheme));let u={"today-before":"wl-airtime-pill wl-airtime-pill-today-series",future:"wl-airtime-pill wl-airtime-pill-days",aired:"wl-airtime-pill wl-airtime-pill-aired",due:"wl-airtime-pill wl-airtime-pill-due"},h=a.kind==="future"&&a.label==="Tomorrow"?"wl-airtime-pill wl-airtime-pill-tomorrow":u[a.kind],p=r.createDiv({cls:"wl-maybe-card-right"});p.createDiv({cls:h,text:t.releaseDate?a.label:"\u2014"});let m=p.createEl("button",{cls:"wl-airtime-action-btn",text:"\u{1F310}"});m.title="Open page",m.addEventListener("click",y=>{y.stopPropagation(),t.externalLink?activeWindow.open(t.externalLink,"_blank"):new pt.Notice("No external link set.")});let v=p.createEl("button",{cls:"wl-airtime-action-btn wl-airtime-action-btn-delete wl-btn-danger",text:"\u2715"});v.title="Remove from maybe",v.addEventListener("click",y=>{y.stopPropagation(),new H(this.plugin.app,`Remove "${t.title}" from Maybe?`,()=>{this.dataManager.removeMaybeTitle(t.id).then(()=>this.render())}).open()})}renderSearch(){let t=this.container.createDiv({cls:"wl-search-wrap"}).createEl("input",{cls:"wl-search-input",attr:{type:"text",placeholder:"Search upcoming..."}});t.value=this.searchQuery;let e=null;t.addEventListener("input",()=>{this.searchQuery=t.value,e!==null&&window.clearTimeout(e),e=window.setTimeout(()=>{e=null,this.rerenderCards()},250)})}rerenderCards(){let s=this.container.querySelector(".wl-airtime-cards"),t=this.container.querySelector(".wl-empty-state");s&&s.remove(),t&&t.remove(),this.renderCards()}renderHeader(){let s=this.container.createDiv({cls:"wl-list-header"}),t=this.dataManager.getAirtimeEntries().filter(o=>this.resolveEntry(o)!==null).length;t>0&&s.createSpan({cls:"wl-list-count",text:String(t)});let e=s.createDiv({cls:"wl-header-controls"});if(this.selectionMode&&this.selectedItems.size>0&&this.renderActionBar(e),this.selectionMode){let o=this.selectedItems.size>0,l=e.createEl("button",{cls:"wl-btn wl-btn-sm",text:o?"None":"All"});l.title=o?"Deselect all":"Select all visible",l.addEventListener("click",()=>{if(this.selectedItems.size>0)this.selectedItems.clear();else for(let c of this.dataManager.getAirtimeEntries())this.selectedItems.add(c.id);this.render()})}let i=e.createDiv({cls:"wl-header-controls-right"});i.createEl("button",{cls:`wl-btn wl-btn-sm${this.selectionMode?" is-active":""}`,text:"Select"}).addEventListener("click",()=>{this.selectionMode=!this.selectionMode,this.selectedItems.clear(),this.render()}),i.createDiv({cls:"wl-add-btn-wrap"}).createEl("button",{cls:"wl-add-btn wl-btn-success",text:"+ add"}).addEventListener("click",o=>{o.stopPropagation(),this.openAddFlow()})}renderActionBar(s){let e=s.createDiv({cls:"wl-action-bar"}).createEl("button",{cls:"wl-group-action-btn wl-group-action-btn-delete wl-btn-danger",text:"\u2715"});e.title="Remove from upcoming",e.addEventListener("click",i=>{i.stopPropagation();let a=this.selectedItems.size;new H(this.plugin.app,`Remove ${a} selected item${a!==1?"s":""} from Upcoming? This cannot be undone.`,()=>{(async()=>(await this.dataManager.removeAirtimeEntriesBatch(Array.from(this.selectedItems)),this.selectedItems.clear(),this.render()))()}).open()})}openAddFlow(){let s=this.dataManager.getAirtimeEntries(),t=new Set(s.filter(a=>a.source!=="reading").map(a=>a.titleId)),e=new Set(s.filter(a=>a.source==="reading").map(a=>a.titleId)),i=[];for(let a of this.dataManager.getTitles())t.has(a.id)||i.push({source:"watchlist",id:a.id,title:a.title,typeLabel:a.type});for(let a of this.readingDataManager.getBooks())e.has(a.id)||i.push({source:"reading",kind:"book",id:a.id,title:a.title,typeLabel:"Book"});for(let a of this.readingDataManager.getMangaList())e.has(a.id)||i.push({source:"reading",kind:"manga",id:a.id,title:a.title,typeLabel:"Manga"});if(this.dataManager.getTitles().length===0&&this.readingDataManager.getBooks().length===0&&this.readingDataManager.getMangaList().length===0){new pt.Notice("No titles in your watchlog or reading library yet.");return}if(i.length===0){new pt.Notice("Everything is already in upcoming.");return}new Te(this.plugin.app,i,a=>{var n;if(a.source==="reading"){let r=(n=a.kind)!=null?n:"book",o=r==="book"?this.readingDataManager.getBook(a.id):this.readingDataManager.getManga(a.id);o&&this.startAddWithReading(o,r)}else{let r=this.dataManager.getTitle(a.id);r&&this.startAddWithTitle(r)}}).open()}startAddWithReading(s,t){let e=s.releaseDate&&/^\d{4}-\d{2}-\d{2}$/.test(s.releaseDate)?{recurrence:"once",releaseDate:s.releaseDate}:null;new ee(this.plugin.app,s,t,e,null,null,null,null,async(i,a,n,r,o)=>{let l={id:this.dataManager.generateReadingAirtimeId(s.id),titleId:s.id,source:"reading",readingKind:t,schedule:i,currentSeason:a!=null?a:void 0,currentEpisode:n!=null?n:void 0,totalSeasons:r!=null?r:void 0,totalEpisodes:o!=null?o:void 0,dateAdded:new Date().toISOString()};await this.dataManager.addAirtimeEntry(l),await this.syncReadingItemFromSchedule(s.id,t,i,r,o),this.render()}).open()}async syncReadingItemFromSchedule(s,t,e,i,a){var n,r;if(t==="book"){let o=this.readingDataManager.getBook(s);if(!o)return;let l=!1;if(a!==null&&a!==o.totalChapters&&(o.totalChapters=a,l=!0),e.recurrence==="once"&&yt(e.releaseDate)){let c=(n=e.releaseDate)!=null?n:null;o.releaseDate!==c&&(o.releaseDate=c,l=!0)}l&&await this.readingDataManager.updateBook(o)}else{let o=this.readingDataManager.getManga(s);if(!o)return;let l=!1;if(a!==null&&a!==o.totalChapters&&(o.totalChapters=a,l=!0),i!==null&&i!==o.totalVolumes&&(o.totalVolumes=i,l=!0),e.recurrence==="once"&&yt(e.releaseDate)){let c=(r=e.releaseDate)!=null?r:null;o.releaseDate!==c&&(o.releaseDate=c,l=!0)}l&&await this.readingDataManager.updateManga(o)}}async revertToBeReleasedStatus(s,t){var e;if(t.source==="reading")if(((e=s.readingKind)!=null?e:"book")==="book"){let a=this.readingDataManager.getBook(s.titleId);a&&a.status==="To be released"&&(a.status="Plan to Read",await this.readingDataManager.updateBook(a))}else{let a=this.readingDataManager.getManga(s.titleId);a&&a.status==="To be released"&&(a.status="Plan to Read",await this.readingDataManager.updateManga(a))}else{let i=this.dataManager.getTitle(s.titleId);i&&i.status==="To be released"&&(i.status="Plan to watch",await this.dataManager.updateTitle(i))}}async startAddWithTitle(s){let t=null;if(s.type==="Anime"&&s.malId)try{let e=await this.plugin.apiService.getAnimeScheduleByMalId(s.malId);e&&(t={recurrence:"weekly",dayOfWeek:e.dayOfWeek,releaseTime:e.time},new pt.Notice("Schedule auto-filled from myanimelist."))}catch(e){}!t&&s.releaseDate&&/^\d{4}-\d{2}-\d{2}$/.test(s.releaseDate)&&(t={recurrence:"once",releaseDate:s.releaseDate}),new Me(this.plugin.app,s,t,null,null,null,null,async(e,i,a,n,r)=>{var c;let o={id:this.dataManager.generateAirtimeId(s.id),titleId:s.id,schedule:e,currentSeason:i!=null?i:void 0,currentEpisode:a!=null?a:void 0,totalSeasons:n!=null?n:void 0,totalEpisodes:r!=null?r:void 0,dateAdded:new Date().toISOString()};await this.dataManager.addAirtimeEntry(o);let l=this.dataManager.getTitle(s.id);if(l){let d=!1;if(r!==null&&r!==l.totalEpisodes&&(l.totalEpisodes=r,d=!0),e.recurrence==="once"){let u=(c=e.releaseDate)!=null?c:null;l.releaseDate!==u&&(l.releaseDate=u,d=!0)}d&&await this.dataManager.updateTitle(l)}this.render()}).open()}renderCards(){let s=this.dataManager.getAirtimeEntries();if(s.length===0){this.container.createDiv({cls:"wl-empty-state",text:"No titles in Upcoming. Click + Add to track airing schedules."});return}let t=this.searchQuery.trim().toLowerCase(),e=s.map(a=>{let n=this.resolveEntry(a);if(!n||t&&!n.title.toLowerCase().includes(t))return null;let r=rs(a.schedule,n.isSingle,a.lastAcknowledgedDate);return{entry:a,r:n,countdown:r}}).filter(a=>a!==null).sort((a,n)=>{let r=c=>c==="aired"||c==="due"?0:c==="today-before"?1:2,o=r(a.countdown.kind),l=r(n.countdown.kind);return o!==l?o-l:a.countdown.daysUntil-n.countdown.daysUntil});if(e.length===0){this.container.createDiv({cls:"wl-empty-state",text:"No upcoming titles match your search."});return}let i=this.container.createDiv({cls:"wl-airtime-cards"});for(let{entry:a,r:n,countdown:r}of e)this.renderCard(i,a,n,r)}renderCard(s,t,e,i){let a=e.isSingle,n=e.totalUnits,r=!a&&t.currentEpisode!==void 0&&n>0&&t.currentEpisode>=n,o=a&&i.kind==="due"||!a&&i.kind==="aired",l="wl-airtime-card";(i.kind==="aired"||i.kind==="due")&&(l+=" wl-airtime-card-aired"),i.kind==="aired"&&(l+=" is-aired"),i.kind==="due"&&(l+=" is-due"),this.selectionMode&&this.selectedItems.has(t.id)&&(l+=" wl-row-selected");let c=s.createDiv({cls:l});if(this.selectionMode){let S=c.createEl("input",{attr:{type:"checkbox"}});S.addClass("wl-airtime-select-cb"),S.checked=this.selectedItems.has(t.id),S.addEventListener("click",D=>D.stopPropagation()),S.addEventListener("change",()=>{S.checked?this.selectedItems.add(t.id):this.selectedItems.delete(t.id),this.render()}),c.addEventListener("click",()=>{this.selectedItems.has(t.id)?this.selectedItems.delete(t.id):this.selectedItems.add(t.id),this.render()})}let d=c.createDiv({cls:"wl-airtime-card-left"});d.createDiv({cls:"wl-airtime-card-title",text:e.title});let u=d.createDiv({cls:"wl-airtime-card-meta"}),h=u.createSpan({cls:e.typeColor?"wl-badge wl-badge-sm":"wl-badge-plain",text:e.typeName});if(e.typeColor&&(h.style.backgroundColor=e.typeColor),u.createSpan({cls:"wl-airtime-schedule",text:Ws(t.schedule)}),!a&&(t.currentSeason!==void 0||t.currentEpisode!==void 0)){let S=[];t.currentSeason!==void 0&&S.push(`${e.groupNounCap} ${t.currentSeason}`),t.currentEpisode!==void 0&&S.push(`${e.unitNounCap} ${t.currentEpisode}`),S.push(e.nextLabel);let D=r?"wl-ep-badge wl-ep-badge-final":"wl-ep-badge";d.createDiv({cls:D,text:S.join(" \xB7 ")})}let p=c.createDiv({cls:"wl-airtime-card-right"}),m={"today-before":"wl-airtime-pill wl-airtime-pill-today-series",future:"wl-airtime-pill wl-airtime-pill-days",aired:"wl-airtime-pill wl-airtime-pill-aired",due:"wl-airtime-pill wl-airtime-pill-due"},v=i.kind==="future"&&i.label==="Tomorrow"?"wl-airtime-pill wl-airtime-pill-tomorrow":m[i.kind];p.createDiv({cls:v,text:i.label});let f=p.createDiv({cls:"wl-airtime-actions"});if(o){let S=f.createEl("button",{cls:"wl-airtime-action-btn wl-airtime-action-btn-tick",text:"\u2713"});S.title=a?"Mark as released":r?`Final ${e.unitNoun} \u2014 mark done`:`Mark ${e.unitNoun} as aired`,S.addEventListener("click",D=>{var k;D.stopPropagation();let E=a?`"${e.title}" has been released. +Remove from Upcoming and set status to ${e.planStatus}?`:r?`Final ${e.unitNoun} of "${e.title}". +Remove from Upcoming? (status unchanged)`:`${e.unitNounCap} ${(k=t.currentEpisode)!=null?k:""} of "${e.title}". +Mark and track next ${e.unitNoun}?`;new H(this.plugin.app,E,()=>{(async()=>{var T;let C=new Date,x=`${C.getFullYear()}-${String(C.getMonth()+1).padStart(2,"0")}-${String(C.getDate()).padStart(2,"0")}`;this.plugin.markAirtimeHandled(`${t.id}-${x}`),a?(await this.dataManager.removeAirtimeEntry(t.id),await this.revertToBeReleasedStatus(t,e)):r?await this.dataManager.removeAirtimeEntry(t.id):(t.currentEpisode=((T=t.currentEpisode)!=null?T:1)+1,t.lastAcknowledgedDate=x,await this.dataManager.updateAirtimeEntry(t)),this.render()})()}).open()})}let y=f.createEl("button",{cls:"wl-airtime-action-btn",text:"\u{1F310}"});y.title="Open page",y.addEventListener("click",S=>{S.stopPropagation(),e.externalLink?activeWindow.open(e.externalLink,"_blank"):new pt.Notice("No external link set for this title.")});let w=f.createEl("button",{cls:"wl-airtime-action-btn",text:"\u270F"});w.title="Edit schedule",w.addEventListener("click",S=>{S.stopPropagation(),e.source==="reading"?this.openEditReadingSchedule(t):this.openEditWatchSchedule(t)});let b=f.createEl("button",{cls:"wl-airtime-action-btn wl-airtime-action-btn-delete wl-btn-danger",text:"\u2715"});b.title="Remove from upcoming",b.addEventListener("click",S=>{S.stopPropagation(),new H(this.plugin.app,`Remove "${e.title}" from Upcoming?`,()=>{this.dataManager.removeAirtimeEntry(t.id).then(()=>this.render())}).open()})}openEditWatchSchedule(s){var e,i,a,n;let t=this.dataManager.getTitle(s.titleId);t&&new Me(this.plugin.app,t,{...s.schedule},(e=s.currentSeason)!=null?e:null,(i=s.currentEpisode)!=null?i:null,(a=s.totalSeasons)!=null?a:null,(n=s.totalEpisodes)!=null?n:null,async(r,o,l,c,d)=>{var h;s.schedule=r,s.currentSeason=o!=null?o:void 0,s.currentEpisode=l!=null?l:void 0,s.totalSeasons=c!=null?c:void 0,s.totalEpisodes=d!=null?d:void 0,await this.dataManager.updateAirtimeEntry(s);let u=this.dataManager.getTitle(s.titleId);if(u){let p=!1;if(d!==null&&d!==u.totalEpisodes&&(u.totalEpisodes=d,p=!0),r.recurrence==="once"){let m=(h=r.releaseDate)!=null?h:null;u.releaseDate!==m&&(u.releaseDate=m,p=!0)}p&&await this.dataManager.updateTitle(u)}this.render()}).open()}openEditReadingSchedule(s){var i,a,n,r,o;let t=(i=s.readingKind)!=null?i:"book",e=t==="book"?this.readingDataManager.getBook(s.titleId):this.readingDataManager.getManga(s.titleId);e&&new ee(this.plugin.app,e,t,{...s.schedule},(a=s.currentSeason)!=null?a:null,(n=s.currentEpisode)!=null?n:null,(r=s.totalSeasons)!=null?r:null,(o=s.totalEpisodes)!=null?o:null,async(l,c,d,u,h)=>{s.schedule=l,s.currentSeason=c!=null?c:void 0,s.currentEpisode=d!=null?d:void 0,s.totalSeasons=u!=null?u:void 0,s.totalEpisodes=h!=null?h:void 0,await this.dataManager.updateAirtimeEntry(s),await this.syncReadingItemFromSchedule(s.titleId,t,l,u,h),this.render()}).open()}},Me=class extends pt.Modal{constructor(s,t,e,i,a,n,r,o){super(s),this.title=t;let l=t.totalEpisodes<=1;this.schedule=e?{...e}:{recurrence:l?"once":"weekly"},!this.schedule.releaseDate&&t.releaseDate&&/^\d{4}-\d{2}-\d{2}$/.test(t.releaseDate)&&(this.schedule.releaseDate=t.releaseDate),this.currentSeason=i,this.currentEpisode=a,this.totalSeasons=n!=null?n:t.seasons.length>0?t.seasons.length:null,this.totalEpisodes=r!=null?r:t.totalEpisodes>0?t.totalEpisodes:null,this.onSave=o}onOpen(){this.titleEl.setText("Set schedule"),this.contentEl.addClass("wl-add-modal"),this.renderForm()}onClose(){this.contentEl.empty()}localHHMMtoJST(s){if(!s||!/^\d{2}:\d{2}$/.test(s))return"\u2014";let[t,e]=s.split(":"),i=parseInt(t!=null?t:"0"),a=parseInt(e!=null?e:"0"),n=new Date,o=new Date(n.getFullYear(),n.getMonth(),n.getDate(),i,a,0,0).getTime()+9*60*60*1e3,l=new Date(o),c=l.getUTCHours().toString().padStart(2,"0"),d=l.getUTCMinutes().toString().padStart(2,"0");return`${c}:${d}`}renderForm(){this.contentEl.empty(),this.contentEl.addClass("wl-add-modal");let s=this.contentEl,t=this.title.totalEpisodes<=1,e=o=>{let l=s.createDiv({cls:"wl-modal-row"});return l.createSpan({cls:"wl-modal-label",text:o}),l},i=(o,l)=>{var p;let c=l.createDiv({cls:"wl-modal-row wl-modal-row-mt"});c.createSpan({cls:"wl-modal-label",text:o});let d=c.createEl("input",{cls:"wl-modal-input wl-time-input",attr:{type:"text",placeholder:"Hh:mm",maxlength:"5"}});d.value=(p=this.schedule.releaseTime)!=null?p:"";let u=c.createSpan({cls:"wl-jst-clock"}),h=()=>{u.textContent=d.value?`JST ${this.localHHMMtoJST(d.value)}`:"JST \u2014"};return h(),d.addEventListener("input",()=>{let m=d.value.trim();this.schedule.releaseTime=m||void 0,h()}),d};if(t){let l=e("Release date").createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Dd/mm/yyyy",maxlength:"10"}});l.value=this.schedule.releaseDate?this.schedule.releaseDate.split("-").reverse().join("/"):"",l.addEventListener("change",()=>{let c=q(l.value);c&&(this.schedule.recurrence="once",this.schedule.releaseDate=c)}),i("Time (optional)",s)}else{let l=e("Recurrence").createEl("select",{cls:"wl-select"}),c=[["once","Once"],["daily","Daily"],["weekly","Weekly"],["monthly","Monthly"]];for(let[S,D]of c){let E=l.createEl("option",{text:D,value:S});S===this.schedule.recurrence&&(E.selected=!0)}let d=s.createDiv(),u=()=>{var D;d.empty();let S=this.schedule.recurrence;if(S==="once"){let E=d.createDiv({cls:"wl-modal-row"});E.createSpan({cls:"wl-modal-label",text:"Date"});let k=E.createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Dd/mm/yyyy",maxlength:"10"}});k.value=this.schedule.releaseDate?this.schedule.releaseDate.split("-").reverse().join("/"):"",k.addEventListener("change",()=>{let C=q(k.value);C&&(this.schedule.releaseDate=C)})}if(S==="weekly"){let E=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],k=d.createDiv({cls:"wl-modal-row"});k.createSpan({cls:"wl-modal-label",text:"Day of week"});let C=k.createEl("select",{cls:"wl-select"});E.forEach((x,T)=>{var L;let M=C.createEl("option",{text:x,value:String(T)});T===((L=this.schedule.dayOfWeek)!=null?L:6)&&(M.selected=!0)}),C.addEventListener("change",()=>{this.schedule.dayOfWeek=parseInt(C.value)})}if(S==="monthly"){let E=d.createDiv({cls:"wl-modal-row"});E.createSpan({cls:"wl-modal-label",text:"Day of month"});let k=E.createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"1",max:"31",placeholder:"1"}});k.value=String((D=this.schedule.dayOfMonth)!=null?D:1),k.addEventListener("input",()=>{this.schedule.dayOfMonth=parseInt(k.value)||1})}i("Time (HH:MM)",d)};l.addEventListener("change",()=>{this.schedule.recurrence=l.value,u()}),u();let p=e("Current season").createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"1",placeholder:"E.g. 2"}});this.currentSeason!==null&&(p.value=String(this.currentSeason)),p.addEventListener("input",()=>{this.currentSeason=parseInt(p.value)||null});let v=e("Current episode").createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"1",placeholder:"E.g. 7"}});this.currentEpisode!==null&&(v.value=String(this.currentEpisode)),v.addEventListener("input",()=>{this.currentEpisode=parseInt(v.value)||null});let y=e("Total seasons").createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"1",placeholder:"E.g. 3"}});this.totalSeasons!==null&&(y.value=String(this.totalSeasons)),y.addEventListener("input",()=>{this.totalSeasons=parseInt(y.value)||null});let b=e("Total episodes").createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"0",placeholder:"E.g. 13"}});this.totalEpisodes!==null&&(b.value=String(this.totalEpisodes)),b.addEventListener("input",()=>{this.totalEpisodes=parseInt(b.value)||null}),s.createDiv({cls:"wl-modal-info wl-schedule-hint",text:"Titles with 0 or 1 total episodes will be treated as a single release date, like a movie."})}let a=s.createDiv({cls:"wl-modal-btn-row"});a.createEl("button",{cls:"wl-btn wl-btn-mr",text:"Cancel"}).addEventListener("click",()=>this.close()),a.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Save"}).addEventListener("click",()=>{(async()=>{if(this.schedule.releaseTime&&!/^\d{2}:\d{2}$/.test(this.schedule.releaseTime)){new pt.Notice("Time must be in 24h format: hh:mm (e.g. 14:50)");return}await this.onSave(this.schedule,this.currentSeason,this.currentEpisode,this.totalSeasons,this.totalEpisodes),this.close()})()})}};var W=require("obsidian");var Wt=class{constructor(s,t){this.app=s;this.plugin=t;this.corruptLists=new Set;this.saveQueues=new Map}async saveSerialized(s,t){var a;let i=((a=this.saveQueues.get(s))!=null?a:Promise.resolve()).then(t).catch(n=>console.error("[WL] Save error for list:",s,n));this.saveQueues.set(s,i),await i}get folderPath(){return this.plugin.settings.customListsFolder||"WatchLog/CustomLists"}async ensureFolder(){let s=(0,W.normalizePath)(this.folderPath);if(!this.app.vault.getAbstractFileByPath(s))try{await this.app.vault.createFolder(s)}catch(t){}}listNames(){let s=(0,W.normalizePath)(this.folderPath);return this.app.vault.getFiles().filter(t=>{var i,a;return((a=(i=t.parent)==null?void 0:i.path)!=null?a:"")===s&&t.extension==="md"}).map(t=>t.basename).sort((t,e)=>t.localeCompare(e))}async loadList(s){let t=(0,W.normalizePath)(`${this.folderPath}/${s}.md`),e=this.app.vault.getAbstractFileByPath(t);if(!(e instanceof W.TFile))return null;try{let i=await this.app.vault.read(e);return this.parse(s,i)}catch(i){return console.warn("[WL] Failed to read custom list",s,i),null}}parse(s,t){var o;let e=t.match(/## Notes\n([\s\S]*?)(?=\n## |\s*$)/),i=((o=e==null?void 0:e[1])!=null?o:"").trim(),a=t.match(/## Data\n```json\n([\s\S]*?)\n```/),n=[],r=[];if(a!=null&&a[1])try{let l=JSON.parse(a[1]);Array.isArray(l.columns)&&(n=l.columns.filter(c=>typeof c=="object"&&c!==null&&"id"in c&&"label"in c&&"type"in c)),Array.isArray(l.rows)&&(r=l.rows.filter(c=>typeof c=="object"&&c!==null&&"id"in c))}catch(l){return console.warn("[WL] Custom list JSON parse failed for",s,l),new W.Notice(`Custom list "${s}" has corrupt data and cannot be loaded.`),this.corruptLists.add(s),null}return{name:s,columns:n,rows:r,notes:i}}async saveList(s){await this.saveSerialized(s.name,async()=>{if(this.corruptLists.has(s.name)){console.warn("[WL] Refusing to save corrupt custom list",s.name);return}await this.ensureFolder();let t=(0,W.normalizePath)(`${this.folderPath}/${s.name}.md`),e=this.serialize(s),i=this.app.vault.getAbstractFileByPath(t);try{i instanceof W.TFile?await this.app.vault.modify(i,e):await this.app.vault.create(t,e)}catch(a){console.error("WatchLog: failed to save custom list",a)}})}async saveNotes(s,t){await this.saveSerialized(s,async()=>{let e=(0,W.normalizePath)(`${this.folderPath}/${s}.md`),i=this.app.vault.getAbstractFileByPath(e);if(i instanceof W.TFile)try{let a=await this.app.vault.read(i),n=`## Notes +`,r=` +## Data +`,o=a.indexOf(n);if(o===-1)return;let l=o+n.length,c=a.indexOf(r,l),d=c!==-1?a.slice(0,l)+t+a.slice(c):a.slice(0,l)+t+` +`;await this.app.vault.modify(i,d)}catch(a){console.error("WatchLog: failed to save notes",a)}})}serialize(s){let t=JSON.stringify({columns:s.columns,rows:s.rows},null,2);return`# ${s.name} + +## Notes +${s.notes} ## Data \`\`\`json ${t} \`\`\` -`}async deleteList(e){let t=(0,P.normalizePath)(`${this.folderPath}/${e}.md`),s=this.app.vault.getAbstractFileByPath(t);if(s instanceof P.TFile)try{await this.app.fileManager.trashFile(s)}catch(i){}}async renameList(e,t){let s=(0,P.normalizePath)(`${this.folderPath}/${t}.md`),i=(0,P.normalizePath)(`${this.folderPath}/${e}.md`),a=this.app.vault.getAbstractFileByPath(i);if(a instanceof P.TFile)try{await this.app.vault.rename(a,s)}catch(l){}}generateColId(e){let t=new Set(e.map(i=>i.id)),s=1;for(;t.has(`col_${s}`);)s++;return`col_${s}`}generateRowId(e){let t=new Set(e.map(i=>i.id)),s=1;for(;t.has(`row_${s}`);)s++;return`row_${s}`}};function Ne(h,e,t,s,i,a,l,n){h.empty();let r=-1;if(e.length===0){h.createDiv({cls:"wl-empty-state",text:'No custom columns. Click "+ Add column" to add one.'});return}e.forEach((o,c)=>{let d=h.createDiv({cls:"wl-editcols-row"}),u=d.createDiv({cls:"wl-editcols-row-header"});u.createDiv({cls:"wl-editcols-handle",text:"\u283F"}).addEventListener("mousedown",()=>d.setAttribute("draggable","true")),d.addEventListener("dragstart",v=>{var w;r=c,(w=v.dataTransfer)==null||w.setData("text/plain",String(c)),d.addClass("wl-cl-dragging")}),d.addEventListener("dragend",()=>{d.removeClass("wl-cl-dragging"),d.setAttribute("draggable","false")}),d.addEventListener("dragover",v=>{v.preventDefault(),d.addClass("wl-cl-drag-over")}),d.addEventListener("dragleave",()=>d.removeClass("wl-cl-drag-over")),d.addEventListener("drop",v=>{v.preventDefault(),d.removeClass("wl-cl-drag-over");let w=r,y=c;w!==y&&w>=0&&a(w,y)});let p=u.createEl("input",{cls:"wl-modal-input wl-editcols-name",attr:{type:"text",placeholder:"Column name",value:o.label}});p.addEventListener("input",()=>{o.label=p.value});let m=u.createEl("select",{cls:"wl-select wl-editcols-type"});for(let v of["text","number","select"]){let w=m.createEl("option",{value:v,text:v.charAt(0).toUpperCase()+v.slice(1)});o.type===v&&(w.selected=!0)}if(m.addEventListener("change",()=>{o.type=m.value,n()}),o.type==="text"||o.type==="number"){let v=u.createDiv({cls:"wl-editcols-toggles"}),w=v.createEl("button",{cls:`wl-btn wl-btn-sm wl-editcols-toggle wl-editcols-bold-btn${o.bold?" is-active":""}`,text:"B"});w.addEventListener("click",()=>{o.bold=!o.bold,w.toggleClass("is-active",!!o.bold)});let y=v.createEl("button",{cls:`wl-btn wl-btn-sm wl-editcols-toggle wl-editcols-italic-btn${o.italic?" is-active":""}`,text:"I"});y.addEventListener("click",()=>{o.italic=!o.italic,y.toggleClass("is-active",!!o.italic)})}let f=u.createEl("button",{cls:"wl-btn wl-btn-sm wl-editcols-del",text:"\xD7"});if(f.title="Delete this column",f.addEventListener("click",()=>{t.some(w=>{let y=w[o.id];return y!==void 0&&y!==""&&y!==null})?new K(i,`Delete column "${o.label}"? This will remove all data in this column.`,()=>{l(c)}).open():l(c)}),o.type==="select"){let v=d.createDiv({cls:"wl-editcols-opts-area"}),w=()=>{var T;v.empty(),((T=o.options)!=null?T:[]).forEach((D,S)=>{let E=v.createDiv({cls:"wl-editcols-opt-row"}),b=E.createEl("input",{cls:"wl-modal-input",attr:{type:"text",value:D,placeholder:"Option label"}});b.addEventListener("input",()=>{o.options||(o.options=[]),o.options[S]=b.value}),E.createEl("button",{cls:"wl-btn wl-btn-sm wl-editcols-opt-del",text:"\xD7",attr:{title:"Remove this option"}}).addEventListener("click",()=>{var k;(k=o.options)==null||k.splice(S,1),w()})}),v.createEl("button",{cls:"wl-btn wl-btn-sm",text:"+ add option"}).addEventListener("click",()=>{o.options||(o.options=[]),o.options.push(""),w()})};w()}})}var Nt=class extends P.Modal{constructor(e,t,s,i){super(e),this.list=t,this.manager=s,this.onSave=i,this.cols=t.columns.filter(a=>!a.locked).map(a=>({...a,options:a.options?[...a.options]:void 0}))}onOpen(){this.titleEl.setText(`Edit Columns \u2014 ${this.list.name}`),this.contentEl.addClass("wl-editcols-modal"),this.renderBody()}renderBody(){this.contentEl.empty(),this.contentEl.addClass("wl-editcols-modal"),this.listEl=this.contentEl.createDiv({cls:"wl-editcols-list"}),this.renderCols(),this.contentEl.createEl("button",{cls:"wl-btn wl-editcols-add-btn",text:"+ add column"}).addEventListener("click",()=>{this.cols.push({id:this.manager.generateColId([...this.list.columns.filter(a=>!a.locked),...this.cols]),label:"",type:"text",bold:!1,italic:!1}),this.renderBody()});let t=this.contentEl.createDiv({cls:"wl-modal-btn-row wl-editcols-footer"});t.createEl("button",{cls:"wl-btn",text:"Cancel"}).addEventListener("click",()=>this.close()),t.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Save"}).addEventListener("click",()=>void this.handleSave())}renderCols(){Ne(this.listEl,this.cols,this.list.rows,this.manager,this.app,(e,t)=>{let[s]=this.cols.splice(e,1);this.cols.splice(e<t?t-1:t,0,s),this.renderCols()},e=>{this.cols.splice(e,1),this.renderBody()},()=>this.renderBody())}handleSave(){for(let e of this.cols){if(!e.label.trim()){new P.Notice("Column names cannot be empty.");return}if(e.type==="select"&&(!e.options||e.options.length===0)){new P.Notice(`Column "${e.label}" must have at least one option.`);return}}for(let e of this.cols)e.label=e.label.trim(),e.options&&(e.options=e.options.map(t=>t.trim()).filter(t=>t));this.onSave(this.cols).then(()=>this.close())}},Ht=class extends P.Modal{constructor(e,t){var s;super(e),this.plugin=t,this.manager=new vt(e,t),this.cols=((s=t.settings.defaultCustomColumns)!=null?s:[]).map(i=>({...i,options:i.options?[...i.options]:void 0}))}onOpen(){this.titleEl.setText("Default columns"),this.contentEl.addClass("wl-editcols-modal"),this.renderBody()}renderBody(){this.contentEl.empty(),this.contentEl.addClass("wl-editcols-modal"),this.contentEl.createDiv({cls:"wl-settings-info",text:"These columns are pre-populated when creating a new list. The Name column is always added automatically."}),this.listEl=this.contentEl.createDiv({cls:"wl-editcols-list"}),this.renderCols(),this.contentEl.createEl("button",{cls:"wl-btn wl-editcols-add-btn",text:"+ add column"}).addEventListener("click",()=>{this.cols.push({id:this.manager.generateColId(this.cols),label:"",type:"text",bold:!1,italic:!1}),this.renderBody()});let t=this.contentEl.createDiv({cls:"wl-modal-btn-row wl-editcols-footer"});t.createEl("button",{cls:"wl-btn",text:"Cancel"}).addEventListener("click",()=>this.close()),t.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Save"}).addEventListener("click",()=>void this.handleSave())}renderCols(){Ne(this.listEl,this.cols,[],this.manager,this.app,(e,t)=>{let[s]=this.cols.splice(e,1);this.cols.splice(e<t?t-1:t,0,s),this.renderCols()},e=>{this.cols.splice(e,1),this.renderBody()},()=>this.renderBody())}async handleSave(){for(let e of this.cols){if(!e.label.trim()){new P.Notice("Column names cannot be empty.");return}if(e.type==="select"&&(!e.options||e.options.length===0)){new P.Notice(`Column "${e.label}" must have at least one option.`);return}}for(let e of this.cols)e.label=e.label.trim(),e.options&&(e.options=e.options.map(t=>t.trim()).filter(t=>t));this.plugin.settings.defaultCustomColumns=this.cols,await this.plugin.saveSettings(),new P.Notice("Default columns saved."),this.close()}},ne=class extends P.Modal{constructor(t,s,i,a){super(t);this.listName=s;this.initialNotes=i;this.onSave=a;this.previewComponent=new P.Component}onOpen(){this.previewComponent.load(),this.titleEl.setText(`Notes \u2014 ${this.listName}`);let{contentEl:t}=this;t.addClass("wl-notes-modal");let s=t.createDiv({cls:"wl-notes-mode-bar"}),i=s.createEl("button",{cls:"wl-btn wl-btn-sm is-active",text:"Edit"}),a=s.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Preview"}),l=t.createDiv({cls:"wl-notes-edit-area"}),n=l.createEl("textarea",{cls:"wl-modal-textarea wl-notes-textarea",attr:{placeholder:"Add notes, links, or any text here..."}});n.value=this.initialNotes,window.setTimeout(()=>{n.focus(),n.setSelectionRange(n.value.length,n.value.length)},0);let r=t.createDiv({cls:"wl-notes-preview-area"});r.hide();let o=()=>{l.show(),r.hide(),i.addClass("is-active"),a.removeClass("is-active"),n.focus()},c=async()=>{l.hide(),r.show(),r.empty(),i.removeClass("is-active"),a.addClass("is-active");let p=n.value.trim()||"*No notes yet.*";await P.MarkdownRenderer.render(this.app,p,r,"",this.previewComponent)};i.addEventListener("click",o),a.addEventListener("click",()=>void c()),n.addEventListener("keydown",p=>{(p.ctrlKey||p.metaKey)&&p.key==="b"?(p.preventDefault(),Oe(n,"**","**")):(p.ctrlKey||p.metaKey)&&p.key==="i"&&(p.preventDefault(),Oe(n,"*","*"))});let d=t.createDiv({cls:"wl-modal-btn-row"});d.createEl("button",{cls:"wl-btn",text:"Cancel"}).addEventListener("click",()=>this.close()),d.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Save"}).addEventListener("click",()=>void this.onSave(n.value).then(()=>this.close()))}onClose(){this.previewComponent.unload(),this.contentEl.empty()}};function Oe(h,e,t){let s=h.selectionStart,i=h.selectionEnd,a=h.value.slice(s,i),l=e+a+t;h.setRangeText(l,s,i,"select"),s===i&&(h.selectionStart=s+e.length,h.selectionEnd=s+e.length)}var re=class extends P.Modal{constructor(t,s,i){super(t);this.existingNames=s;this.onSubmit=i}onOpen(){this.titleEl.setText("New custom list");let{contentEl:t}=this,s="",i=t.createDiv({cls:"wl-cl-name-error"});i.hide(),new P.Setting(t).setName("List name").addText(n=>{n.setPlaceholder("E.g. Marvel movies").onChange(r=>{s=r,i.hide()}),window.setTimeout(()=>n.inputEl.focus(),0),n.inputEl.addEventListener("keydown",r=>{r.key==="Enter"&&(r.preventDefault(),a())})});let a=()=>{let n=s.trim();if(!n){i.textContent="Please enter a name.",i.show();return}if(this.existingNames.includes(n)){i.textContent=`A list named "${n}" already exists.`,i.show();return}this.close(),this.onSubmit(n)},l=t.createDiv({cls:"wl-modal-btn-row"});l.createEl("button",{cls:"wl-btn",text:"Cancel"}).addEventListener("click",()=>this.close()),l.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Create"}).addEventListener("click",a)}onClose(){this.contentEl.empty()}},_t=class extends P.Modal{constructor(e,t,s,i,a,l){super(e),this.label=t,this.inputType=s,this.initialValue=i,this.onSave=a,this.getSuggestions=l}onOpen(){this.titleEl.setText(this.label);let{contentEl:e}=this,t=e.createEl("input",{cls:"wl-cl-cell-input wl-mobile-edit-input",attr:{type:this.inputType,value:this.initialValue}});if(this.getSuggestions){let i=e.createDiv({cls:"wl-cl-autofill-dropdown wl-mobile-suggestions"});t.addEventListener("input",()=>{i.empty();let a=t.value.toLowerCase();if(!a)return;let l=this.getSuggestions().filter(n=>n.title.toLowerCase().includes(a)).slice(0,10);for(let n of l){let r=i.createDiv({cls:"wl-result-item"});r.createDiv({cls:"wl-result-title",text:n.title}),r.createDiv({cls:"wl-result-meta",text:`${n.type} \xB7 ${n.status}`}),r.addEventListener("mousedown",o=>{o.preventDefault(),t.value=n.title,i.empty()})}})}e.createDiv({cls:"wl-modal-btn-row"}).createEl("button",{cls:"wl-btn mod-cta",text:"Save"}).addEventListener("click",()=>{this.onSave(t.value),this.close()}),t.addEventListener("keydown",i=>{i.key==="Enter"&&(i.preventDefault(),this.onSave(t.value),this.close())}),window.setTimeout(()=>{t.focus(),t.select()},50)}onClose(){this.contentEl.empty()}},Ut=class{constructor(e,t,s){this.listNames=[];this.activeListName=null;this.currentList=null;this.sortCol=null;this.sortDir="asc";this.searchQuery="";this.duplicatedRowIds=new Set;this._escapeKeyHandler=null;this._countEl=null;this._tableContainer=null;this.container=e,this.plugin=t,this.dataManager=s,this.manager=new vt(t.app,t)}applyTabOrder(e){var a;let s=((a=this.plugin.settings.customListTabOrder)!=null?a:[]).filter(l=>e.includes(l)),i=e.filter(l=>!s.includes(l));return[...s,...i]}async render(){var t;this.container.empty(),this.container.addClass("wl-custom-lists");let e=this.manager.listNames();if(this.listNames=this.applyTabOrder(e),this.listNames.length===0){this.buildEmptyState();return}(!this.activeListName||!this.listNames.includes(this.activeListName))&&(this.activeListName=(t=this.listNames[0])!=null?t:null),this.activeListName&&(this.currentList=await this.manager.loadList(this.activeListName)),this.container.empty(),this.buildSubTabs(),this.currentList&&this.buildListView(this.currentList)}buildEmptyState(){let e=this.container.createDiv({cls:"wl-cl-empty-state"});e.createDiv({cls:"wl-cl-empty-text",text:"No custom lists yet."}),e.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Create your first list"}).addEventListener("click",()=>this.promptCreateList())}buildSubTabs(){let e=this.container.createDiv({cls:"wl-cl-sub-tabs"}),t=null;for(let i of this.listNames){let a=e.createDiv({cls:`wl-cl-sub-tab${i===this.activeListName?" is-active":""}`});a.setAttribute("draggable","true");let l=a.createSpan({cls:"wl-cl-sub-tab-name",text:i});l.addEventListener("dblclick",r=>{r.stopPropagation(),this.startRenameTab(a,l,i)}),a.createSpan({cls:"wl-cl-sub-tab-del",text:"\xD7"}).addEventListener("click",r=>{r.stopPropagation(),this.deleteList(i)}),a.addEventListener("click",()=>{this.activeListName!==i&&(this.activeListName=i,this.sortCol=null,this.sortDir="asc",this.searchQuery="",this.duplicatedRowIds.clear(),this.render())}),a.addEventListener("dragstart",r=>{var o;t=i,(o=r.dataTransfer)==null||o.setData("text/plain",i),a.addClass("wl-cl-dragging")}),a.addEventListener("dragend",()=>a.removeClass("wl-cl-dragging")),a.addEventListener("dragover",r=>{r.preventDefault(),a.addClass("wl-cl-drag-over")}),a.addEventListener("dragleave",()=>a.removeClass("wl-cl-drag-over")),a.addEventListener("drop",r=>{if(r.preventDefault(),a.removeClass("wl-cl-drag-over"),!t||t===i)return;let o=this.listNames.indexOf(t),c=this.listNames.indexOf(i);o<0||c<0||(this.listNames.splice(o,1),this.listNames.splice(c,0,t),this.plugin.settings.customListTabOrder=[...this.listNames],this.plugin.saveSettings().then(()=>void this.render()))})}e.createEl("button",{cls:"wl-cl-sub-tab-add",text:"+"}).addEventListener("click",()=>this.promptCreateList())}startRenameTab(e,t,s){t.hide();let i=e.createEl("input",{cls:"wl-cl-sub-tab-rename",attr:{type:"text",value:s}});i.focus(),i.select();let a=!1,l=async n=>{if(a||(a=!0,i.remove(),t.show(),!n))return;let r=i.value.trim();if(!(!r||r===s)){if(this.listNames.includes(r)){new P.Notice(`A list named "${r}" already exists.`);return}await this.manager.renameList(s,r),this.activeListName===s&&(this.activeListName=r),await this.render()}};i.addEventListener("keydown",n=>{n.key==="Enter"&&(n.preventDefault(),l(!0)),n.key==="Escape"&&l(!1)}),i.addEventListener("blur",()=>void l(!0))}promptCreateList(){new re(this.plugin.app,this.listNames,e=>{var i;let t=((i=this.plugin.settings.defaultCustomColumns)!=null?i:[]).map(a=>({...a,id:a.id,options:a.options?[...a.options]:void 0})),s={name:e,columns:t,rows:[],notes:""};new Nt(this.plugin.app,s,this.manager,async a=>{s.columns=a,await this.manager.saveList(s),this.activeListName=e,this.sortCol=null,this.sortDir="asc",this.searchQuery="",this.duplicatedRowIds.clear(),await this.render()}).open()}).open()}deleteList(e){new K(this.plugin.app,`Delete list "${e}"? This cannot be undone.`,()=>{(async()=>(await this.manager.deleteList(e),this.activeListName===e&&(this.activeListName=null,this.currentList=null),await this.render()))()}).open()}buildListView(e){let t=this.container.createDiv({cls:"wl-cl-list-view"}),s=t.createDiv({cls:"wl-cl-header"}),i=s.createSpan({cls:"wl-results-count wl-cl-count"}),a=s.createDiv({cls:"wl-cl-toolbar"});a.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Notes"}).addEventListener("click",()=>{new ne(this.plugin.app,e.name,e.notes,async u=>{await this.manager.saveNotes(e.name,u),e.notes=u}).open()});let n=a.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Sort"}),r=null;n.addEventListener("click",u=>{if(u.stopPropagation(),r){r.remove(),r=null;return}r=this.buildSortPanel(e,a,()=>{r=null}),activeDocument.addEventListener("click",()=>{r==null||r.remove(),r=null},{once:!0})}),a.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Export"}).addEventListener("click",()=>void this.exportToClipboard(e)),a.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Edit columns"}).addEventListener("click",()=>{new Nt(this.plugin.app,e,this.manager,async u=>{let g=new Set(u.map(p=>p.id));for(let p of e.rows)for(let m of Object.keys(p))m!=="id"&&m!=="name"&&!g.has(m)&&delete p[m];e.columns=u,await this.manager.saveList(e),await this.render()}).open()});let c=t.createDiv({cls:"wl-search-wrap"}).createEl("input",{cls:"wl-search-input",attr:{type:"text",placeholder:"Search...",value:this.searchQuery}});c.addEventListener("input",()=>{this.searchQuery=c.value,this._tableContainer&&this._countEl&&(this._tableContainer.empty(),this.buildTable(this._tableContainer,e,this._countEl))}),t.createDiv({cls:"wl-cl-draft-banner",text:"\u26A0 This is a draft list. Titles here are not included in any stats or counts."});let d=t.createDiv({cls:"wl-cl-table-wrap"});this.buildTable(d,e,i)}buildSortPanel(e,t,s){let i=t.createDiv({cls:"wl-cl-sort-panel"}),a=i.createDiv({cls:"wl-filter-row"});a.createSpan({cls:"wl-filter-label",text:"Sort by"});let l=a.createEl("select",{cls:"wl-select"});l.createEl("option",{value:"name",text:"Name"});for(let c of e.columns.filter(d=>!d.locked))l.createEl("option",{value:c.id,text:c.label});this.sortCol&&(l.value=this.sortCol);let n=i.createDiv({cls:"wl-filter-row"});n.createSpan({cls:"wl-filter-label",text:"Direction"});let r=n.createEl("select",{cls:"wl-select"});r.createEl("option",{value:"asc",text:"A \u2192 z"}),r.createEl("option",{value:"desc",text:"Z \u2192 a"}),r.value=this.sortDir;let o=i.createDiv({cls:"wl-modal-btn-row"});return o.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Clear sort"}).addEventListener("click",c=>{c.stopPropagation(),this.sortCol=null,this.sortDir="asc",s(),this.render()}),o.createEl("button",{cls:"wl-btn wl-btn-sm wl-btn-primary",text:"Apply"}).addEventListener("click",c=>{c.stopPropagation(),this.sortCol=l.value,this.sortDir=r.value,s(),this.render()}),i.addEventListener("click",c=>c.stopPropagation()),i}async exportToClipboard(e){let t=e.columns.filter(n=>!n.locked),s=[{id:"#",label:"#"},{id:"name",label:"Name"},...t],i="| "+s.map(n=>n.label).join(" | ")+" |",a="| "+s.map(()=>"---").join(" | ")+" |",l=this.getFilteredSortedRows(e).map((n,r)=>"| "+s.map(c=>{if(c.id==="#")return String(r+1);let d=n[c.id];return d!=null?String(d):""}).join(" | ")+" |");try{await navigator.clipboard.writeText([i,a,...l].join(` -`)),new P.Notice("Copied to clipboard!")}catch(n){new P.Notice("Failed to copy to clipboard.")}}getFilteredSortedRows(e){let t=[...e.rows];if(this.searchQuery.trim()){let s=this.searchQuery.toLowerCase();t=t.filter(i=>{var a;return String((a=i.name)!=null?a:"").toLowerCase().includes(s)})}if(this.sortCol){let s=this.sortCol,i=this.sortDir;t.sort((a,l)=>{var o,c;let n=String((o=a[s])!=null?o:"").toLowerCase(),r=String((c=l[s])!=null?c:"").toLowerCase();return i==="asc"?n.localeCompare(r):r.localeCompare(n)})}return t}buildTable(e,t,s){this._countEl=s,this._tableContainer=e;let i=this.getFilteredSortedRows(t);s.textContent=`${i.length} title${i.length!==1?"s":""}`;let a=t.columns.filter(u=>!u.locked),l=e.createDiv({cls:"wl-cl-table"}),r=l.createDiv({cls:"wl-cl-thead"}).createDiv({cls:"wl-cl-tr wl-cl-tr-header"});r.createDiv({cls:"wl-cl-th wl-cl-th-tick"}),r.createDiv({cls:"wl-cl-th wl-cl-th-num",text:"#"}),r.createDiv({cls:"wl-cl-th",text:"Name"});for(let u of a)r.createDiv({cls:"wl-cl-th",text:u.label});r.createDiv({cls:"wl-cl-th wl-cl-th-actions"});let o=l.createDiv({cls:"wl-cl-tbody"}),c=-1;i.forEach((u,g)=>{let p=u.checked===!0,m=o.createDiv({cls:`wl-cl-tr wl-cl-tr-body${p?" wl-cl-tr-checked":""}`});m.dataset.rowId=u.id;let v=m.createDiv({cls:"wl-cl-td wl-cl-td-tick"}).createDiv({cls:`wl-cl-tick-btn${p?" is-checked":""}`});v.title=p?"Uncheck row":"Check row",v.textContent=p?"\u2713":"\u25CB",v.addEventListener("click",()=>{let E=t.rows.find(b=>b.id===u.id);E&&(E.checked=!E.checked,this.manager.saveList(t).then(()=>{e.empty(),this.buildTable(e,t,s)}))}),m.createDiv({cls:"wl-cl-td wl-cl-td-num",text:String(g+1)});let w=m.createDiv({cls:"wl-cl-td wl-cl-td-name"});this.renderNameCell(w,u,t,s,a,e);for(let E of a){let b=m.createDiv({cls:"wl-cl-td"});this.renderCustomCell(b,u,E,t)}let y=m.createDiv({cls:"wl-cl-td wl-cl-td-actions"}),T=y.createDiv({cls:"wl-cl-row-action wl-cl-drag-handle",text:"\u283F"});T.title="Drag to reorder";let D=y.createDiv({cls:"wl-cl-row-action wl-cl-dup-btn",text:"\u29C9"});D.title="Duplicate row",D.addEventListener("click",()=>{let E=t.rows.findIndex(x=>x.id===u.id);if(E<0)return;let b={...t.rows[E],id:this.manager.generateRowId(t.rows)};t.rows.splice(E+1,0,b),this.duplicatedRowIds.add(b.id),this.manager.saveList(t).then(()=>{e.empty(),this.buildTable(e,t,s)})});let S=y.createDiv({cls:"wl-cl-row-action wl-cl-row-del",text:"\xD7"});S.title="Delete row",S.addEventListener("click",()=>{t.rows=t.rows.filter(E=>E.id!==u.id),this.duplicatedRowIds.delete(u.id),this.manager.saveList(t).then(()=>{e.empty(),this.buildTable(e,t,s)})}),m.setAttribute("draggable","false"),T.addEventListener("mousedown",()=>m.setAttribute("draggable","true")),m.addEventListener("dragstart",E=>{var b;c=t.rows.findIndex(x=>x.id===u.id),(b=E.dataTransfer)==null||b.setData("text/plain",u.id),m.addClass("wl-cl-dragging")}),m.addEventListener("dragend",()=>{m.removeClass("wl-cl-dragging"),m.setAttribute("draggable","false")}),m.addEventListener("dragover",E=>{E.preventDefault(),m.addClass("wl-cl-drag-over")}),m.addEventListener("dragleave",()=>m.removeClass("wl-cl-drag-over")),m.addEventListener("drop",E=>{E.preventDefault(),m.removeClass("wl-cl-drag-over");let b=t.rows.findIndex(x=>x.id===u.id);if(c!==b&&c>=0){let[x]=t.rows.splice(c,1);t.rows.splice(c<b?b-1:b,0,x),this.manager.saveList(t).then(()=>{e.empty(),this.buildTable(e,t,s)})}})}),e.createDiv({cls:"wl-cl-add-row"}).createEl("button",{cls:"wl-btn wl-btn-sm",text:"+ add row"}).addEventListener("click",()=>{let u={id:this.manager.generateRowId(t.rows),name:""};t.rows.push(u),this.manager.saveList(t).then(()=>{e.empty(),this.buildTable(e,t,s),window.setTimeout(()=>{var m;let g=e.querySelector(".wl-cl-tbody"),p=g==null?void 0:g.querySelector(".wl-cl-tr-body:last-child");(m=p==null?void 0:p.querySelector(".wl-cl-td-name"))==null||m.click()},0)})})}renderNameCell(e,t,s,i,a,l){var o;e.empty(),e.removeClass("wl-cl-editing");let n=String((o=t.name)!=null?o:""),r=this.duplicatedRowIds.has(t.id);if(n){let c=e.createSpan({cls:"wl-cl-cell-text",text:n});r&&c.addClass("wl-cl-dup-name")}else e.createSpan({cls:"wl-cl-cell-empty",text:"\u2014"});e.addEventListener("click",()=>{this.startNameEdit(e,t,s,i,a,l)},{once:!0})}startNameEdit(e,t,s,i,a,l){var v;let n=String((v=t.name)!=null?v:"");if(P.Platform.isMobile){new _t(this.plugin.app,"Edit Name","text",n,async w=>{let y=w.trim(),T=this.duplicatedRowIds.has(t.id);t.name=y,T&&y!==n&&this.duplicatedRowIds.delete(t.id),await this.manager.saveList(s),this.renderNameCell(e,t,s,i,a,l)},()=>this.dataManager.getTitles()).open();return}e.empty(),e.addClass("wl-cl-editing"),this._escapeKeyHandler=w=>{w.key==="Escape"&&(w.stopPropagation(),w.stopImmediatePropagation(),w.preventDefault())},activeDocument.addEventListener("keydown",this._escapeKeyHandler,!0),activeDocument.addEventListener("keyup",this._escapeKeyHandler,!0);let r=()=>{let w=activeDocument.activeElement;w&&w.scrollIntoView({behavior:"smooth",block:"center"})};window.visualViewport&&window.visualViewport.addEventListener("resize",r),window.setTimeout(()=>e.scrollIntoView({block:"nearest",behavior:"smooth"}),50);let c=e.createDiv({cls:"wl-cl-autofill-wrap"}).createEl("input",{cls:"wl-cl-cell-input",attr:{type:"text",value:n}});c.focus(),c.select();let d=null,u=-1,g=()=>{d==null||d.remove(),d=null,u=-1};c.addEventListener("input",()=>{g();let w=c.value.toLowerCase();if(!w)return;let y=this.dataManager.getTitles().filter(D=>D.title.toLowerCase().includes(w)).slice(0,10);if(!y.length)return;let T=c.getBoundingClientRect();d=activeDocument.body.createDiv({cls:"wl-cl-autofill-dropdown wl-pos-fixed"}),d.style.top=`${T.bottom}px`,d.style.left=`${T.left}px`,d.style.width=`${T.width}px`;for(let D of y){let S=d.createDiv({cls:"wl-result-item"});S.createDiv({cls:"wl-result-title",text:D.title}),S.createDiv({cls:"wl-result-meta",text:`${D.type} \xB7 ${D.status}`}),S.addEventListener("mousedown",E=>{E.preventDefault(),c.value=D.title,g()})}});let p=!1,m=null,f=async w=>{if(p)return;p=!0,window.visualViewport&&window.visualViewport.removeEventListener("resize",r),this._escapeKeyHandler&&(activeDocument.removeEventListener("keydown",this._escapeKeyHandler,!0),activeDocument.removeEventListener("keyup",this._escapeKeyHandler,!0),this._escapeKeyHandler=null),g();let y=c.value.trim();if(!w){let T=this.duplicatedRowIds.has(t.id);t.name=y,T&&y!==n&&this.duplicatedRowIds.delete(t.id),await this.manager.saveList(s)}this.renderNameCell(e,t,s,i,a,l),!w&&m&&this.navigateCell(e,m)};c.addEventListener("blur",()=>void f(!1)),c.addEventListener("keydown",w=>{var y,T,D;if(d){let S=Array.from(d.querySelectorAll(".wl-result-item"));if(w.key==="ArrowDown"){w.preventDefault(),u=Math.min(u+1,S.length-1),S.forEach((E,b)=>{b===u?E.addClass("wl-result-item-focused"):E.removeClass("wl-result-item-focused")});return}if(w.key==="ArrowUp"){w.preventDefault(),u=Math.max(u-1,0),S.forEach((E,b)=>{b===u?E.addClass("wl-result-item-focused"):E.removeClass("wl-result-item-focused")});return}if(w.key==="Enter"&&u>=0){w.preventDefault();let E=(D=(T=(y=S[u])==null?void 0:y.querySelector(".wl-result-title"))==null?void 0:T.textContent)!=null?D:"";E&&(c.value=E),g();return}if(w.key==="Escape"){w.preventDefault(),w.stopPropagation(),w.stopImmediatePropagation(),g();return}}w.key==="Tab"&&w.shiftKey?(w.preventDefault(),m="shift-tab",f(!1)):w.key==="Tab"?(w.preventDefault(),m="tab",f(!1)):w.key==="Enter"?(w.preventDefault(),m="enter",f(!1)):w.key==="Escape"&&(w.preventDefault(),w.stopPropagation(),w.stopImmediatePropagation(),f(!0))})}renderCustomCell(e,t,s,i){e.empty(),e.removeClass("wl-cl-editing");let a=t[s.id];if(s.type==="select"){let l=String(a!=null?a:"");l?e.createSpan({cls:"wl-cl-select-badge",text:l}):e.createSpan({cls:"wl-cl-cell-empty",text:"\u2014"})}else{let l=a!=null&&a!==""?String(a):"";if(l){let n=e.createSpan({cls:"wl-cl-cell-text",text:l});s.bold&&n.addClass("wl-cell-bold"),s.italic&&n.addClass("wl-cell-italic")}else e.createSpan({cls:"wl-cl-cell-empty",text:"\u2014"})}e.addEventListener("click",()=>this.startCustomEdit(e,t,s,i),{once:!0})}startCustomEdit(e,t,s,i){var d;let a=t[s.id];if(P.Platform.isMobile&&s.type!=="select"){let u=a!=null?String(a):"";new _t(this.plugin.app,`Edit ${s.label}`,s.type==="number"?"number":"text",u,async g=>{let p;if(s.type==="number"){let m=parseFloat(g);p=isNaN(m)?void 0:m}else p=g===""?void 0:g;t[s.id]=p,await this.manager.saveList(i),this.renderCustomCell(e,t,s,i)}).open();return}e.empty(),e.addClass("wl-cl-editing");let l=()=>{let u=activeDocument.activeElement;u&&u.scrollIntoView({behavior:"smooth",block:"center"})};window.visualViewport&&window.visualViewport.addEventListener("resize",l),window.setTimeout(()=>e.scrollIntoView({block:"nearest",behavior:"smooth"}),50);let n,r=!1,o=null,c=async u=>{if(!r){if(r=!0,window.visualViewport&&window.visualViewport.removeEventListener("resize",l),this._escapeKeyHandler&&(activeDocument.removeEventListener("keydown",this._escapeKeyHandler,!0),activeDocument.removeEventListener("keyup",this._escapeKeyHandler,!0),this._escapeKeyHandler=null),e.removeClass("wl-cl-editing"),!u){let g=n();t[s.id]=g===""||g===void 0?void 0:g,await this.manager.saveList(i)}this.renderCustomCell(e,t,s,i),!u&&o&&this.navigateCell(e,o)}};if(s.type==="select"){let u=e.createEl("select",{cls:"wl-select wl-cl-cell-select"});u.createEl("option",{value:"",text:"\u2014"});for(let g of(d=s.options)!=null?d:[]){let p=u.createEl("option",{value:g,text:g});a===g&&(p.selected=!0)}n=()=>u.value,u.focus(),u.addEventListener("change",()=>void c(!1)),u.addEventListener("blur",()=>void c(!1)),u.addEventListener("keydown",g=>{g.key==="Escape"?(g.preventDefault(),g.stopPropagation(),c(!0)):g.key==="Tab"&&g.shiftKey?(g.preventDefault(),o="shift-tab",c(!1)):g.key==="Tab"&&(g.preventDefault(),o="tab",c(!1))})}else if(s.type==="number"){let u=e.createEl("input",{cls:"wl-cl-cell-input",attr:{type:"number",value:a!=null?String(a):""}});n=()=>{let g=parseFloat(u.value);return isNaN(g)?"":g},u.focus(),u.select(),u.addEventListener("blur",()=>void c(!1)),u.addEventListener("keydown",g=>{g.key==="Enter"?(g.preventDefault(),o="enter",c(!1)):g.key==="Tab"&&g.shiftKey?(g.preventDefault(),o="shift-tab",c(!1)):g.key==="Tab"?(g.preventDefault(),o="tab",c(!1)):g.key==="Escape"&&(g.preventDefault(),g.stopPropagation(),g.stopImmediatePropagation(),c(!0))})}else{let u=e.createEl("input",{cls:"wl-cl-cell-input",attr:{type:"text",value:a!=null?String(a):""}});n=()=>u.value,u.focus(),u.select(),u.addEventListener("blur",()=>void c(!1)),u.addEventListener("keydown",g=>{g.key==="Enter"?(g.preventDefault(),o="enter",c(!1)):g.key==="Tab"&&g.shiftKey?(g.preventDefault(),o="shift-tab",c(!1)):g.key==="Tab"?(g.preventDefault(),o="tab",c(!1)):g.key==="Escape"&&(g.preventDefault(),g.stopPropagation(),g.stopImmediatePropagation(),c(!0))})}}getEditableCells(e){return Array.from(e.querySelectorAll(":scope > .wl-cl-td")).filter(t=>!t.classList.contains("wl-cl-td-num")&&!t.classList.contains("wl-cl-td-actions"))}navigateCell(e,t){let s=e.closest(".wl-cl-tr-body");if(!s)return;let i=s.parentElement;if(!i)return;let a=Array.from(i.querySelectorAll(":scope > .wl-cl-tr-body")),l=a.indexOf(s),n=this.getEditableCells(s),r=n.indexOf(e);if(t==="tab")if(r>=0&&r<n.length-1)window.setTimeout(()=>{var o;return(o=n[r+1])==null?void 0:o.click()},10);else if(l>=0&&l<a.length-1){let o=a[l+1];if(o){let c=this.getEditableCells(o);c.length&&window.setTimeout(()=>{var d;return(d=c[0])==null?void 0:d.click()},10)}}else this.addRowForNavigation(0);else if(t==="shift-tab"){if(r>0)window.setTimeout(()=>{var o;return(o=n[r-1])==null?void 0:o.click()},10);else if(l>0){let o=a[l-1];if(o){let c=this.getEditableCells(o);c.length&&window.setTimeout(()=>{var d;return(d=c[c.length-1])==null?void 0:d.click()},10)}}}else if(l>=0&&l<a.length-1){let o=a[l+1];if(o){let c=this.getEditableCells(o),d=Math.min(r>=0?r:0,c.length-1);c.length&&window.setTimeout(()=>{var u;return(u=c[d])==null?void 0:u.click()},10)}}else this.addRowForNavigation(r>=0?r:0)}addRowForNavigation(e){let t=this.currentList,s=this._tableContainer,i=this._countEl;if(!t||!s||!i)return;let a={id:this.manager.generateRowId(t.rows),name:""};t.rows.push(a),this.manager.saveList(t).then(()=>{s.empty(),this.buildTable(s,t,i),window.setTimeout(()=>{let l=s.querySelector(".wl-cl-tbody");if(!l)return;let n=Array.from(l.querySelectorAll(".wl-cl-tr-body")),r=n[n.length-1];if(!r)return;let o=this.getEditableCells(r),c=o[Math.min(e,o.length-1)];c==null||c.click()},50)})}};function at(h){return Array.isArray?Array.isArray(h):je(h)==="[object Array]"}function gs(h){if(typeof h=="string")return h;if(typeof h=="bigint")return h.toString();let e=h+"";return e=="0"&&1/h==-1/0?"-0":e}function le(h){return h==null?"":gs(h)}function j(h){return typeof h=="string"}function Vt(h){return typeof h=="number"}function ms(h){return h===!0||h===!1||fs(h)&&je(h)=="[object Boolean]"}function ze(h){return typeof h=="object"}function fs(h){return ze(h)&&h!==null}function Q(h){return h!=null}function Gt(h){return!h.trim().length}function je(h){return h==null?h===void 0?"[object Undefined]":"[object Null]":Object.prototype.toString.call(h)}var ws="Incorrect 'index' type",vs=h=>`Invalid value for key ${h}`,ys=h=>`Pattern length exceeds max of ${h}.`,bs=h=>`Missing ${h} property in key`,Es=h=>`Property 'weight' in key '${h}' must be a positive integer`,He=Object.prototype.hasOwnProperty,oe=class{constructor(e){this._keys=[],this._keyMap={};let t=0;e.forEach(s=>{let i=Ye(s);this._keys.push(i),this._keyMap[i.id]=i,t+=i.weight}),this._keys.forEach(s=>{s.weight/=t})}get(e){return this._keyMap[e]}keys(){return this._keys}toJSON(){return JSON.stringify(this._keys)}};function Ye(h){let e=null,t=null,s=null,i=1,a=null;if(j(h)||at(h))s=h,e=_e(h),t=ce(h);else{if(!He.call(h,"name"))throw new Error(bs("name"));let l=h.name;if(s=l,He.call(h,"weight")&&(i=h.weight,i<=0))throw new Error(Es(l));e=_e(l),t=ce(l),a=h.getFn}return{path:e,id:t,weight:i,src:s,getFn:a}}function _e(h){return at(h)?h:h.split(".")}function ce(h){return at(h)?h.join("."):h}function Ds(h,e){let t=[],s=!1,i=(a,l,n,r)=>{if(Q(a))if(!l[n])t.push(r!==void 0?{v:a,i:r}:a);else{let o=l[n],c=a[o];if(!Q(c))return;if(n===l.length-1&&(j(c)||Vt(c)||ms(c)||typeof c=="bigint"))t.push(r!==void 0?{v:le(c),i:r}:le(c));else if(at(c)){s=!0;for(let d=0,u=c.length;d<u;d+=1)i(c[d],l,n+1,d)}else l.length&&i(c,l,n+1,r)}};return i(h,j(e)?e.split("."):e,0),s?t:t[0]}var Ts={includeMatches:!1,findAllMatches:!1,minMatchCharLength:1},Ss={isCaseSensitive:!1,ignoreDiacritics:!1,includeScore:!1,keys:[],shouldSort:!0,sortFn:(h,e)=>h.score===e.score?h.idx<e.idx?-1:1:h.score<e.score?-1:1},xs={location:0,threshold:.6,distance:100},Ms={useExtendedSearch:!1,useTokenSearch:!1,getFn:Ds,ignoreLocation:!1,ignoreFieldNorm:!1,fieldNormWeight:1},R=Object.freeze({...Ss,...Ts,...xs,...Ms}),Cs=/[^ ]+/g;function Ls(h=1,e=3){let t=new Map,s=Math.pow(10,e);return{get(i){let a=i.match(Cs).length;if(t.has(a))return t.get(a);let l=1/Math.pow(a,.5*h),n=parseFloat(Math.round(l*s)/s);return t.set(a,n),n},clear(){t.clear()}}}var Ct=class{constructor({getFn:e=R.getFn,fieldNormWeight:t=R.fieldNormWeight}={}){this.norm=Ls(t,3),this.getFn=e,this.isCreated=!1,this.docs=[],this.keys=[],this._keysMap={},this.setIndexRecords()}setSources(e=[]){this.docs=e}setIndexRecords(e=[]){this.records=e}setKeys(e=[]){this.keys=e,this._keysMap={},e.forEach((t,s)=>{this._keysMap[t.id]=s})}create(){this.isCreated||!this.docs.length||(this.isCreated=!0,j(this.docs[0])?this.docs.forEach((e,t)=>{this._addString(e,t)}):this.docs.forEach((e,t)=>{this._addObject(e,t)}),this.norm.clear())}add(e){let t=this.size();j(e)?this._addString(e,t):this._addObject(e,t)}removeAt(e){this.records.splice(e,1);for(let t=e,s=this.size();t<s;t+=1)this.records[t].i-=1}removeAll(e){for(let t=e.length-1;t>=0;t-=1)this.records.splice(e[t],1);for(let t=0,s=this.records.length;t<s;t+=1)this.records[t].i=t}getValueForItemAtKeyId(e,t){return e[this._keysMap[t]]}size(){return this.records.length}_addString(e,t){if(!Q(e)||Gt(e))return;let s={v:e,i:t,n:this.norm.get(e)};this.records.push(s)}_addObject(e,t){let s={i:t,$:{}};this.keys.forEach((i,a)=>{let l=i.getFn?i.getFn(e):this.getFn(e,i.path);if(Q(l)){if(at(l)){let n=[];for(let r=0,o=l.length;r<o;r+=1){let c=l[r];if(Q(c)){if(j(c)){if(!Gt(c)){let d={v:c,i:r,n:this.norm.get(c)};n.push(d)}}else if(Q(c.v)){let d=j(c.v)?c.v:le(c.v);if(!Gt(d)){let u={v:d,i:c.i,n:this.norm.get(d)};n.push(u)}}}}s.$[a]=n}else if(j(l)&&!Gt(l)){let n={v:l,n:this.norm.get(l)};s.$[a]=n}}}),this.records.push(s)}toJSON(){return{keys:this.keys.map(({getFn:e,...t})=>t),records:this.records}}};function Qe(h,e,{getFn:t=R.getFn,fieldNormWeight:s=R.fieldNormWeight}={}){let i=new Ct({getFn:t,fieldNormWeight:s});return i.setKeys(h.map(Ye)),i.setSources(e),i.create(),i}function ks(h,{getFn:e=R.getFn,fieldNormWeight:t=R.fieldNormWeight}={}){let{keys:s,records:i}=h,a=new Ct({getFn:e,fieldNormWeight:t});return a.setKeys(s),a.setIndexRecords(i),a}function As(h=[],e=R.minMatchCharLength){let t=[],s=-1,i=-1,a=0;for(let l=h.length;a<l;a+=1){let n=h[a];n&&s===-1?s=a:!n&&s!==-1&&(i=a-1,i-s+1>=e&&t.push([s,i]),s=-1)}return h[a-1]&&a-s>=e&&t.push([s,a-1]),t}var ut=32;function Rs(h,e,t,{location:s=R.location,distance:i=R.distance,threshold:a=R.threshold,findAllMatches:l=R.findAllMatches,minMatchCharLength:n=R.minMatchCharLength,includeMatches:r=R.includeMatches,ignoreLocation:o=R.ignoreLocation}={}){if(e.length>ut)throw new Error(ys(ut));let c=e.length,d=h.length,u=Math.max(0,Math.min(s,d)),g=a,p=u,m=(b,x)=>{let k=b/c;if(o)return k;let M=Math.abs(u-x);return i?k+M/i:M?1:k},f=n>1||r,v=f?Array(d):[],w;for(;(w=h.indexOf(e,p))>-1;){let b=m(0,w);if(g=Math.min(b,g),p=w+c,f){let x=0;for(;x<c;)v[w+x]=1,x+=1}}p=-1;let y=[],T=1,D=c+d,S=1<<c-1;for(let b=0;b<c;b+=1){let x=0,k=D;for(;x<k;)m(b,u+k)<=g?x=k:D=k,k=Math.floor((D-x)/2+x);D=k;let M=Math.max(1,u-k+1),C=l?d:Math.min(u+k,d)+c,L=Array(C+2);L[C+1]=(1<<b)-1;for(let W=C;W>=M;W-=1){let $=W-1,A=t[h[$]];if(f&&(v[$]=+!!A),L[W]=(L[W+1]<<1|1)&A,b&&(L[W]|=(y[W+1]|y[W])<<1|1|y[W+1]),L[W]&S&&(T=m(b,$),T<=g)){if(g=T,p=$,p<=u)break;M=Math.max(1,2*u-p)}}if(m(b+1,u)>g)break;y=L}let E={isMatch:p>=0,score:Math.max(.001,T)};if(f){let b=As(v,n);b.length?r&&(E.indices=b):E.isMatch=!1}return E}function Is(h){let e={};for(let t=0,s=h.length;t<s;t+=1){let i=h.charAt(t);e[i]=(e[i]||0)|1<<s-t-1}return e}function xe(h){if(h.length<=1)return h;h.sort((t,s)=>t[0]-s[0]||t[1]-s[1]);let e=[h[0]];for(let t=1,s=h.length;t<s;t+=1){let i=e[e.length-1],a=h[t];a[0]<=i[1]+1?i[1]=Math.max(i[1],a[1]):e.push(a)}return e}var qe={\u0142:"l",\u0141:"L",\u0111:"d",\u0110:"D",\u00F8:"o",\u00D8:"O",\u0127:"h",\u0126:"H",\u0167:"t",\u0166:"T",\u0131:"i",\u00DF:"ss"},Ps=new RegExp("["+Object.keys(qe).join("")+"]","g"),Lt=String.prototype.normalize?h=>h.normalize("NFD").replace(/[\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u07FD\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u08D3-\u08E1\u08E3-\u0903\u093A-\u093C\u093E-\u094F\u0951-\u0957\u0962\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09E2\u09E3\u09FE\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE2\u0AE3\u0AFA-\u0AFF\u0B01-\u0B03\u0B3C\u0B3E-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B62\u0B63\u0B82\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C00-\u0C04\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81-\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CE2\u0CE3\u0D00-\u0D03\u0D3B\u0D3C\u0D3E-\u0D44\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0D62\u0D63\u0D82\u0D83\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2\u0DF3\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EB9\u0EBB\u0EBC\u0EC8-\u0ECD\u0F18\u0F19\u0F35\u0F37\u0F39\u0F3E\u0F3F\u0F71-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102B-\u103E\u1056-\u1059\u105E-\u1060\u1062-\u1064\u1067-\u106D\u1071-\u1074\u1082-\u108D\u108F\u109A-\u109D\u135D-\u135F\u1712-\u1714\u1732-\u1734\u1752\u1753\u1772\u1773\u17B4-\u17D3\u17DD\u180B-\u180D\u1885\u1886\u18A9\u1920-\u192B\u1930-\u193B\u1A17-\u1A1B\u1A55-\u1A5E\u1A60-\u1A7C\u1A7F\u1AB0-\u1ABE\u1B00-\u1B04\u1B34-\u1B44\u1B6B-\u1B73\u1B80-\u1B82\u1BA1-\u1BAD\u1BE6-\u1BF3\u1C24-\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE8\u1CED\u1CF2-\u1CF4\u1CF7-\u1CF9\u1DC0-\u1DF9\u1DFB-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302F\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA823-\uA827\uA880\uA881\uA8B4-\uA8C5\uA8E0-\uA8F1\uA8FF\uA926-\uA92D\uA947-\uA953\uA980-\uA983\uA9B3-\uA9C0\uA9E5\uAA29-\uAA36\uAA43\uAA4C\uAA4D\uAA7B-\uAA7D\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEB-\uAAEF\uAAF5\uAAF6\uABE3-\uABEA\uABEC\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F]/g,"").replace(Ps,e=>qe[e]):h=>h,kt=class{constructor(e,{location:t=R.location,threshold:s=R.threshold,distance:i=R.distance,includeMatches:a=R.includeMatches,findAllMatches:l=R.findAllMatches,minMatchCharLength:n=R.minMatchCharLength,isCaseSensitive:r=R.isCaseSensitive,ignoreDiacritics:o=R.ignoreDiacritics,ignoreLocation:c=R.ignoreLocation}={}){if(this.options={location:t,threshold:s,distance:i,includeMatches:a,findAllMatches:l,minMatchCharLength:n,isCaseSensitive:r,ignoreDiacritics:o,ignoreLocation:c},e=r?e:e.toLowerCase(),e=o?Lt(e):e,this.pattern=e,this.chunks=[],!this.pattern.length)return;let d=(g,p)=>{this.chunks.push({pattern:g,alphabet:Is(g),startIndex:p})},u=this.pattern.length;if(u>ut){let g=0,p=u%ut,m=u-p;for(;g<m;)d(this.pattern.substr(g,ut),g),g+=ut;if(p){let f=u-ut;d(this.pattern.substr(f),f)}}else d(this.pattern,0)}searchIn(e){let{isCaseSensitive:t,ignoreDiacritics:s,includeMatches:i}=this.options;if(e=t?e:e.toLowerCase(),e=s?Lt(e):e,this.pattern===e){let m={isMatch:!0,score:0};return i&&(m.indices=[[0,e.length-1]]),m}let{location:a,distance:l,threshold:n,findAllMatches:r,minMatchCharLength:o,ignoreLocation:c}=this.options,d=[],u=0,g=!1;this.chunks.forEach(({pattern:m,alphabet:f,startIndex:v})=>{let{isMatch:w,score:y,indices:T}=Rs(e,m,f,{location:a+v,distance:l,threshold:n,findAllMatches:r,minMatchCharLength:o,includeMatches:i,ignoreLocation:c});w&&(g=!0),u+=y,w&&T&&d.push(...T)});let p={isMatch:g,score:g?u/this.chunks.length:1};return g&&i&&(p.indices=xe(d)),p}},st=class{constructor(e){this.pattern=e}static isMultiMatch(e){return Ue(e,this.multiRegex)}static isSingleMatch(e){return Ue(e,this.singleRegex)}search(e){return{isMatch:!1,score:1}}};function Ue(h,e){let t=h.match(e);return t?t[1]:null}var de=class extends st{constructor(e){super(e)}static get type(){return"exact"}static get multiRegex(){return/^="(.*)"$/}static get singleRegex(){return/^=(.*)$/}search(e){let t=e===this.pattern;return{isMatch:t,score:t?0:1,indices:[0,this.pattern.length-1]}}},ue=class extends st{constructor(e){super(e)}static get type(){return"inverse-exact"}static get multiRegex(){return/^!"(.*)"$/}static get singleRegex(){return/^!(.*)$/}search(e){let s=e.indexOf(this.pattern)===-1;return{isMatch:s,score:s?0:1,indices:[0,e.length-1]}}},he=class extends st{constructor(e){super(e)}static get type(){return"prefix-exact"}static get multiRegex(){return/^\^"(.*)"$/}static get singleRegex(){return/^\^(.*)$/}search(e){let t=e.startsWith(this.pattern);return{isMatch:t,score:t?0:1,indices:[0,this.pattern.length-1]}}},pe=class extends st{constructor(e){super(e)}static get type(){return"inverse-prefix-exact"}static get multiRegex(){return/^!\^"(.*)"$/}static get singleRegex(){return/^!\^(.*)$/}search(e){let t=!e.startsWith(this.pattern);return{isMatch:t,score:t?0:1,indices:[0,e.length-1]}}},ge=class extends st{constructor(e){super(e)}static get type(){return"suffix-exact"}static get multiRegex(){return/^"(.*)"\$$/}static get singleRegex(){return/^(.*)\$$/}search(e){let t=e.endsWith(this.pattern);return{isMatch:t,score:t?0:1,indices:[e.length-this.pattern.length,e.length-1]}}},me=class extends st{constructor(e){super(e)}static get type(){return"inverse-suffix-exact"}static get multiRegex(){return/^!"(.*)"\$$/}static get singleRegex(){return/^!(.*)\$$/}search(e){let t=!e.endsWith(this.pattern);return{isMatch:t,score:t?0:1,indices:[0,e.length-1]}}},Kt=class extends st{constructor(e,{location:t=R.location,threshold:s=R.threshold,distance:i=R.distance,includeMatches:a=R.includeMatches,findAllMatches:l=R.findAllMatches,minMatchCharLength:n=R.minMatchCharLength,isCaseSensitive:r=R.isCaseSensitive,ignoreDiacritics:o=R.ignoreDiacritics,ignoreLocation:c=R.ignoreLocation}={}){super(e),this._bitapSearch=new kt(e,{location:t,threshold:s,distance:i,includeMatches:a,findAllMatches:l,minMatchCharLength:n,isCaseSensitive:r,ignoreDiacritics:o,ignoreLocation:c})}static get type(){return"fuzzy"}static get multiRegex(){return/^"(.*)"$/}static get singleRegex(){return/^(.*)$/}search(e){return this._bitapSearch.searchIn(e)}},zt=class extends st{constructor(e){super(e)}static get type(){return"include"}static get multiRegex(){return/^'"(.*)"$/}static get singleRegex(){return/^'(.*)$/}search(e){let t=0,s,i=[],a=this.pattern.length;for(;(s=e.indexOf(this.pattern,t))>-1;)t=s+a,i.push([s,t-1]);let l=!!i.length;return{isMatch:l,score:l?0:1,indices:i}}},fe=[de,zt,he,pe,me,ge,ue,Kt],Ge=fe.length,$s="\0",Bs="|";function Ws(h){let e=[],t=h.length,s=0;for(;s<t;){for(;s<t&&h[s]===" ";)s++;if(s>=t)break;let i=s;for(;i<t&&h[i]!==" "&&h[i]!=='"';)i++;if(i<t&&h[i]==='"'){for(i++;i<t;){if(h[i]==='"'){let a=i+1;if(a>=t||h[a]===" "){i++;break}if(h[a]==="$"&&(a+1>=t||h[a+1]===" ")){i+=2;break}}i++}e.push(h.substring(s,i)),s=i}else{for(;i<t&&h[i]!==" ";)i++;e.push(h.substring(s,i)),s=i}}return e}function Fs(h,e={}){return h.replace(/\\\|/g,$s).split(Bs).map(s=>{let i=s.replace(/\u0000/g,"|"),a=Ws(i.trim()).filter(n=>n&&!!n.trim()),l=[];for(let n=0,r=a.length;n<r;n+=1){let o=a[n],c=!1,d=-1;for(;!c&&++d<Ge;){let u=fe[d],g=u.isMultiMatch(o);g&&(l.push(new u(g,e)),c=!0)}if(!c)for(d=-1;++d<Ge;){let u=fe[d],g=u.isSingleMatch(o);if(g){l.push(new u(g,e));break}}}return l})}var Os=new Set([Kt.type,zt.type]),we=class{constructor(e,{isCaseSensitive:t=R.isCaseSensitive,ignoreDiacritics:s=R.ignoreDiacritics,includeMatches:i=R.includeMatches,minMatchCharLength:a=R.minMatchCharLength,ignoreLocation:l=R.ignoreLocation,findAllMatches:n=R.findAllMatches,location:r=R.location,threshold:o=R.threshold,distance:c=R.distance}={}){this.query=null,this.options={isCaseSensitive:t,ignoreDiacritics:s,includeMatches:i,minMatchCharLength:a,findAllMatches:n,ignoreLocation:l,location:r,threshold:o,distance:c},e=t?e:e.toLowerCase(),e=s?Lt(e):e,this.pattern=e,this.query=Fs(this.pattern,this.options)}static condition(e,t){return t.useExtendedSearch}searchIn(e){let t=this.query;if(!t)return{isMatch:!1,score:1};let{includeMatches:s,isCaseSensitive:i,ignoreDiacritics:a}=this.options;e=i?e:e.toLowerCase(),e=a?Lt(e):e;let l=0,n=[],r=0,o=!1;for(let c=0,d=t.length;c<d;c+=1){let u=t[c];n.length=0,l=0,o=!1;for(let g=0,p=u.length;g<p;g+=1){let m=u[g],{isMatch:f,indices:v,score:w}=m.search(e);if(f){l+=1,r+=w;let y=m.constructor.type;y.startsWith("inverse")&&(o=!0),s&&(Os.has(y)?n.push(...v):n.push(v))}else{r=0,l=0,n.length=0,o=!1;break}}if(l){let g={isMatch:!0,score:r/l};return o&&(g.hasInverse=!0),s&&(g.indices=xe(n)),g}}return{isMatch:!1,score:1}}},ve=[];function Me(...h){ve.push(...h)}function jt(h,e){for(let t=0,s=ve.length;t<s;t+=1){let i=ve[t];if(i.condition(h,e))return new i(h,e)}return new kt(h,e)}var Yt={AND:"$and",OR:"$or"},ye={PATH:"$path",PATTERN:"$val"},be=h=>!!(h[Yt.AND]||h[Yt.OR]),Ns=h=>!!h[ye.PATH],Hs=h=>!at(h)&&ze(h)&&!be(h),Ve=h=>({[Yt.AND]:Object.keys(h).map(e=>({[e]:h[e]}))});function Je(h,e,{auto:t=!0}={}){let s=i=>{if(j(i)){let r={keyId:null,pattern:i};return t&&(r.searcher=jt(i,e)),r}let a=Object.keys(i),l=Ns(i);if(!l&&a.length>1&&!be(i))return s(Ve(i));if(Hs(i)){let r=l?i[ye.PATH]:a[0],o=l?i[ye.PATTERN]:i[r];if(!j(o))throw new Error(vs(r));let c={keyId:ce(r),pattern:o};return t&&(c.searcher=jt(o,e)),c}let n={children:[],operator:a[0]};return a.forEach(r=>{let o=i[r];at(o)&&o.forEach(c=>{n.children.push(s(c))})}),n};return be(h)||(h=Ve(h)),s(h)}function Ee(h,{ignoreFieldNorm:e=R.ignoreFieldNorm}){let t=1;return h.forEach(({key:s,norm:i,score:a})=>{let l=s?s.weight:null;t*=Math.pow(a===0&&l?Number.EPSILON:a,(l||1)*(e?1:i))}),t}function _s(h,{ignoreFieldNorm:e=R.ignoreFieldNorm}){h.forEach(t=>{t.score=Ee(t.matches,{ignoreFieldNorm:e})})}var De=class{constructor(e){this.limit=e,this.heap=[]}get size(){return this.heap.length}shouldInsert(e){return this.size<this.limit||e<this.heap[0].score}insert(e){this.size<this.limit?(this.heap.push(e),this._bubbleUp(this.size-1)):e.score<this.heap[0].score&&(this.heap[0]=e,this._sinkDown(0))}extractSorted(e){return this.heap.sort(e)}_bubbleUp(e){let t=this.heap;for(;e>0;){let s=e-1>>1;if(t[e].score<=t[s].score)break;let i=t[e];t[e]=t[s],t[s]=i,e=s}}_sinkDown(e){let t=this.heap,s=t.length,i=e;do{e=i;let a=2*e+1,l=2*e+2;if(a<s&&t[a].score>t[i].score&&(i=a),l<s&&t[l].score>t[i].score&&(i=l),i!==e){let n=t[e];t[e]=t[i],t[i]=n}}while(i!==e)}};function Us(h,e){let t=h.matches;e.matches=[],Q(t)&&t.forEach(s=>{if(!Q(s.indices)||!s.indices.length)return;let{indices:i,value:a}=s,l={indices:i,value:a};s.key&&(l.key=s.key.src),s.idx>-1&&(l.refIndex=s.idx),e.matches.push(l)})}function Gs(h,e){e.score=h.score}function Vs(h,e,{includeMatches:t=R.includeMatches,includeScore:s=R.includeScore}={}){let i=[];return t&&i.push(Us),s&&i.push(Gs),h.map(a=>{let{idx:l}=a,n={item:e[l],refIndex:l};return i.length&&i.forEach(r=>{r(a,n)}),n})}var Ks=/\b\w+\b/g;function Te({isCaseSensitive:h=!1,ignoreDiacritics:e=!1}={}){return{tokenize(t){return h||(t=t.toLowerCase()),e&&(t=Lt(t)),t.match(Ks)||[]}}}function zs(h,e,t){var n;let s=new Map,i=new Map,a=0;function l(r,o,c,d){let u=t.tokenize(r);if(!u.length)return;a++;let g=new Map;for(let p of u)g.set(p,(g.get(p)||0)+1);for(let[p,m]of g){let f={docIdx:o,keyIdx:c,subIdx:d,tf:m},v=s.get(p);v||(v=[],s.set(p,v)),v.push(f),i.set(p,(i.get(p)||0)+1)}}for(let r of h){let{i:o,v:c,$:d}=r;if(c!==void 0){l(c,o,-1,-1);continue}if(d)for(let u=0;u<e;u++){let g=d[u];if(g)if(Array.isArray(g))for(let p of g)l(p.v,o,u,(n=p.i)!=null?n:-1);else l(g.v,o,u,-1)}}return{terms:s,fieldCount:a,df:i}}function js(h,e,t,s){var r;let{i,v:a,$:l}=e;function n(o,c,d){let u=s.tokenize(o);if(!u.length)return;h.fieldCount++;let g=new Map;for(let p of u)g.set(p,(g.get(p)||0)+1);for(let[p,m]of g){let f={docIdx:i,keyIdx:c,subIdx:d,tf:m},v=h.terms.get(p);v||(v=[],h.terms.set(p,v)),v.push(f),h.df.set(p,(h.df.get(p)||0)+1)}}if(a!==void 0){n(a,-1,-1);return}if(l)for(let o=0;o<t;o++){let c=l[o];if(c)if(Array.isArray(c))for(let d of c)n(d.v,o,(r=d.i)!=null?r:-1);else n(c.v,o,-1)}}function Ke(h,e){for(let[t,s]of h.terms){let i=s.filter(l=>l.docIdx!==e),a=s.length-i.length;a>0&&(h.fieldCount-=a,h.df.set(t,(h.df.get(t)||0)-a),i.length===0?(h.terms.delete(t),h.df.delete(t)):h.terms.set(t,i))}}var X=class{constructor(e,t,s){this.options={...R,...t},this.options.useExtendedSearch,this.options.useTokenSearch,this._keyStore=new oe(this.options.keys),this._docs=e,this._myIndex=null,this._invertedIndex=null,this.setCollection(e,s),this._lastQuery=null,this._lastSearcher=null}_getSearcher(e){if(this._lastQuery===e)return this._lastSearcher;let t=this._invertedIndex?{...this.options,_invertedIndex:this._invertedIndex}:this.options,s=jt(e,t);return this._lastQuery=e,this._lastSearcher=s,s}setCollection(e,t){if(this._docs=e,t&&!(t instanceof Ct))throw new Error(ws);if(this._myIndex=t||Qe(this.options.keys,this._docs,{getFn:this.options.getFn,fieldNormWeight:this.options.fieldNormWeight}),this.options.useTokenSearch){let s=Te({isCaseSensitive:this.options.isCaseSensitive,ignoreDiacritics:this.options.ignoreDiacritics});this._invertedIndex=zs(this._myIndex.records,this._myIndex.keys.length,s)}}add(e){if(Q(e)&&(this._docs.push(e),this._myIndex.add(e),this._invertedIndex)){let t=this._myIndex.records[this._myIndex.records.length-1],s=Te({isCaseSensitive:this.options.isCaseSensitive,ignoreDiacritics:this.options.ignoreDiacritics});js(this._invertedIndex,t,this._myIndex.keys.length,s)}}remove(e=()=>!1){let t=[],s=[];for(let i=0,a=this._docs.length;i<a;i+=1)e(this._docs[i],i)&&(t.push(this._docs[i]),s.push(i));if(s.length){if(this._invertedIndex)for(let i of s)Ke(this._invertedIndex,i);for(let i=s.length-1;i>=0;i-=1)this._docs.splice(s[i],1);this._myIndex.removeAll(s)}return t}removeAt(e){this._invertedIndex&&Ke(this._invertedIndex,e);let t=this._docs.splice(e,1)[0];return this._myIndex.removeAt(e),t}getIndex(){return this._myIndex}search(e,t){let{limit:s=-1}=t||{},{includeMatches:i,includeScore:a,shouldSort:l,sortFn:n,ignoreFieldNorm:r}=this.options;if(j(e)&&!e.trim()){let d=this._docs.map((u,g)=>({item:u,refIndex:g}));return Vt(s)&&s>-1&&(d=d.slice(0,s)),d}let o=Vt(s)&&s>0&&j(e),c;if(o){let d=new De(s);j(this._docs[0])?this._searchStringList(e,{heap:d,ignoreFieldNorm:r}):this._searchObjectList(e,{heap:d,ignoreFieldNorm:r}),c=d.extractSorted(n)}else c=j(e)?j(this._docs[0])?this._searchStringList(e):this._searchObjectList(e):this._searchLogical(e),_s(c,{ignoreFieldNorm:r}),l&&c.sort(n),Vt(s)&&s>-1&&(c=c.slice(0,s));return Vs(c,this._docs,{includeMatches:i,includeScore:a})}_searchStringList(e,{heap:t,ignoreFieldNorm:s}={}){let i=this._getSearcher(e),{records:a}=this._myIndex,l=t?null:[];return a.forEach(({v:n,i:r,n:o})=>{if(!Q(n))return;let{isMatch:c,score:d,indices:u}=i.searchIn(n);if(c){let g={item:n,idx:r,matches:[{score:d,value:n,norm:o,indices:u}]};t?(g.score=Ee(g.matches,{ignoreFieldNorm:s}),t.shouldInsert(g.score)&&t.insert(g)):l.push(g)}}),l}_searchLogical(e){let t=Je(e,this.options),s=(n,r,o)=>{if(!("children"in n)){let{keyId:g,searcher:p}=n,m;return g===null?(m=[],this._myIndex.keys.forEach((f,v)=>{m.push(...this._findMatches({key:f,value:r[v],searcher:p}))})):m=this._findMatches({key:this._keyStore.get(g),value:this._myIndex.getValueForItemAtKeyId(r,g),searcher:p}),m&&m.length?[{idx:o,item:r,matches:m}]:[]}let{children:c,operator:d}=n,u=[];for(let g=0,p=c.length;g<p;g+=1){let m=c[g],f=s(m,r,o);if(f.length)u.push(...f);else if(d===Yt.AND)return[]}return u},i=this._myIndex.records,a=new Map,l=[];return i.forEach(({$:n,i:r})=>{if(Q(n)){let o=s(t,n,r);o.length&&(a.has(r)||(a.set(r,{idx:r,item:n,matches:[]}),l.push(a.get(r))),o.forEach(({matches:c})=>{a.get(r).matches.push(...c)}))}}),l}_searchObjectList(e,{heap:t,ignoreFieldNorm:s}={}){let i=this._getSearcher(e),{keys:a,records:l}=this._myIndex,n=t?null:[];return l.forEach(({$:r,i:o})=>{if(!Q(r))return;let c=[],d=!1,u=!1;if(a.forEach((g,p)=>{let m=this._findMatches({key:g,value:r[p],searcher:i});m.length?(c.push(...m),m[0].hasInverse&&(u=!0)):d=!0}),!(u&&d)&&c.length){let g={idx:o,item:r,matches:c};t?(g.score=Ee(g.matches,{ignoreFieldNorm:s}),t.shouldInsert(g.score)&&t.insert(g)):n.push(g)}}),n}_findMatches({key:e,value:t,searcher:s}){if(!Q(t))return[];let i=[];if(at(t))t.forEach(({v:a,i:l,n})=>{if(!Q(a))return;let{isMatch:r,score:o,indices:c,hasInverse:d}=s.searchIn(a);r&&i.push({score:o,key:e,value:a,idx:l,norm:n,indices:c,hasInverse:d})});else{let{v:a,n:l}=t,{isMatch:n,score:r,indices:o,hasInverse:c}=s.searchIn(a);n&&i.push({score:r,key:e,value:a,norm:l,indices:o,hasInverse:c})}return i}},Se=class{static condition(e,t){return t.useTokenSearch}constructor(e,t){this.options=t,this.analyzer=Te({isCaseSensitive:t.isCaseSensitive,ignoreDiacritics:t.ignoreDiacritics});let s=this.analyzer.tokenize(e),i=t._invertedIndex,{df:a,fieldCount:l}=i;this.termSearchers=[],this.idfWeights=[];for(let n of s){this.termSearchers.push(new kt(n,{location:t.location,threshold:t.threshold,distance:t.distance,includeMatches:t.includeMatches,findAllMatches:t.findAllMatches,minMatchCharLength:t.minMatchCharLength,isCaseSensitive:t.isCaseSensitive,ignoreDiacritics:t.ignoreDiacritics,ignoreLocation:!0}));let r=a.get(n)||0,o=Math.log(1+(l-r+.5)/(r+.5));this.idfWeights.push(o)}}searchIn(e){if(!this.termSearchers.length)return{isMatch:!1,score:1};let t=[],s=0,i=0,a=0;for(let r=0;r<this.termSearchers.length;r++){let o=this.termSearchers[r].searchIn(e),c=this.idfWeights[r];i+=c,o.isMatch&&(a++,s+=c*(1-o.score),o.indices&&t.push(...o.indices))}if(a===0)return{isMatch:!1,score:1};let l=i>0?1-s/i:0,n={isMatch:!0,score:Math.max(.001,l)};return this.options.includeMatches&&t.length&&(n.indices=xe(t)),n}};X.version="7.3.0";X.createIndex=Qe;X.parseIndex=ks;X.config=R;X.match=function(h,e,t){return jt(h,{...R,...t}).searchIn(e)};X.parseQuery=Je;Me(we);Me(Se);X.use=function(...h){h.forEach(e=>Me(e))};var Qt=class{constructor(e,t,s,i){this.eventRef=null;this.persistState={dismissed:[],added:[],firstSeen:{},titleDisplay:{}};this.containerEl=e,this.plugin=t,this.dataManager=s,this.onCountChange=i}async render(){await this.loadPersistState();let e=await this.scanVault();this.renderUI(e),this.registerChangeListener()}destroy(){this.eventRef&&(this.plugin.app.metadataCache.offref(this.eventRef),this.eventRef=null)}async loadPersistState(){var s,i,a,l;let e=await this.plugin.loadData(),t=e==null?void 0:e.drafts;t&&(this.persistState={dismissed:(s=t.dismissed)!=null?s:[],added:(i=t.added)!=null?i:[],firstSeen:(a=t.firstSeen)!=null?a:{},titleDisplay:(l=t.titleDisplay)!=null?l:{}})}async savePersistState(){var t;let e=(t=await this.plugin.loadData())!=null?t:{};await this.plugin.saveData({...e,drafts:this.persistState})}getTag(){var e;return(e=this.plugin.settings.draftsVaultTag)!=null?e:"#watchlog"}async scanVault(){var n,r,o,c;let e=this.getTag(),t=this.plugin.app.vault.getMarkdownFiles(),s=new Map;for(let d of t){let u=this.plugin.app.metadataCache.getFileCache(d);if((r=(n=u==null?void 0:u.tags)==null?void 0:n.some(p=>p.tag===e))!=null&&r)try{let m=(await this.plugin.app.vault.cachedRead(d)).split(` -`);for(let f of m){if(!f.includes(e))continue;let v=f.indexOf(e),w=f.slice(v+e.length).trim();if(!w)continue;let y=w.split(",");for(let T of y){let D=T.trim();if(!D)continue;let S=D.toLowerCase();s.has(S)||s.set(S,{sources:new Set,displayTitle:D}),s.get(S).sources.add(d.basename)}}}catch(p){}}let i=!1,a=new Date().toISOString();for(let[d,{displayTitle:u}]of s)this.persistState.firstSeen[d]||(this.persistState.firstSeen[d]=a,i=!0),this.persistState.titleDisplay[d]||(this.persistState.titleDisplay[d]=u,i=!0);i&&await this.savePersistState();let l=[];for(let[d,{sources:u}]of s)this.persistState.dismissed.includes(d)||l.push({titleKey:d,titleDisplay:(o=this.persistState.titleDisplay[d])!=null?o:d,sources:Array.from(u),firstSeen:(c=this.persistState.firstSeen[d])!=null?c:a,added:this.persistState.added.includes(d)});return l.sort((d,u)=>d.firstSeen.localeCompare(u.firstSeen)),l}registerChangeListener(){this.eventRef&&this.plugin.app.metadataCache.offref(this.eventRef),this.eventRef=this.plugin.app.metadataCache.on("changed",e=>{var a,l;let t=this.getTag(),s=this.plugin.app.metadataCache.getFileCache(e);(l=(a=s==null?void 0:s.tags)==null?void 0:a.some(n=>n.tag===t))!=null&&l&&this.render()})}buildFuse(e){return new X(e,{keys:["title"],threshold:.35,includeScore:!0})}fuzzyMatchesWatchlist(e,t){var i,a;let s=t.search(e);return s.length>0&&((a=(i=s[0])==null?void 0:i.score)!=null?a:1)<=.35}renderUI(e){let t=this.containerEl;t.empty();let s=this.getTag(),i=this.dataManager.getTitles(),a=this.buildFuse(i),l=e.filter(o=>o.added?!1:!this.fuzzyMatchesWatchlist(o.titleDisplay,a)).length;this.onCountChange(l),t.createDiv({cls:"wl-drafts-notice",text:`\u26A0 Write ${s} Movie Name in any note and it appears here automatically. Hit Add when you're ready to add it to your Watchlist.`});let n=t.createDiv({cls:"wl-list-title-wrap"});if(n.createSpan({cls:"wl-list-count",text:String(l)}),n.createSpan({cls:"wl-drafts-count-label",text:` Pending draft${l!==1?"s":""}`}),e.length===0){t.createDiv({cls:"wl-drafts-empty",text:`No drafts found. Add ${s} followed by a title in any vault note.`});return}e.sort((o,c)=>{let d=o.added?!1:this.fuzzyMatchesWatchlist(o.titleDisplay,a),u=c.added?!1:this.fuzzyMatchesWatchlist(c.titleDisplay,a);return d!==u?d?1:-1:o.firstSeen.localeCompare(c.firstSeen)});let r=t.createDiv({cls:"wl-drafts-cards"});for(let o of e)this.renderCard(r,o,a)}renderCard(e,t,s){let i=t.added?!1:this.fuzzyMatchesWatchlist(t.titleDisplay,s),a="wl-drafts-card";i&&(a+=" wl-drafts-card-watchlist"),t.added&&(a+=" wl-drafts-card-added");let l=e.createDiv({cls:a});l.createDiv({cls:"wl-drafts-card-title",text:t.titleDisplay});let n=l.createDiv({cls:"wl-drafts-card-source"});if(t.sources.length>0){let d=t.sources[0];n.createSpan({cls:"wl-drafts-source-link",text:`[[${d}]]`}).addEventListener("click",g=>{g.stopPropagation(),this.plugin.app.workspace.openLinkText(d,"")}),t.sources.length>1&&n.createSpan({cls:"wl-drafts-source-count",text:` (${t.sources.length})`,attr:{title:t.sources.join(` -`)}})}let r=l.createDiv({cls:"wl-drafts-card-dup"});i&&r.createSpan({text:"In Watchlist",attr:{title:"This title already exists in your Watchlist"}});let o=l.createDiv({cls:"wl-drafts-card-actions"});t.added||i?o.createSpan({cls:"wl-drafts-added-label",text:"Added"}):o.createEl("button",{cls:"wl-btn wl-btn-sm wl-btn-primary",text:"Add"}).addEventListener("click",()=>this.openAddModal(t)),o.createEl("button",{cls:"wl-btn wl-btn-sm wl-drafts-dismiss",text:"\u2715",attr:{title:"Dismiss"}}).addEventListener("click",()=>void this.dismissEntry(t.titleKey))}async dismissEntry(e){this.persistState.dismissed.includes(e)||this.persistState.dismissed.push(e),this.persistState.added=this.persistState.added.filter(t=>t!==e),await this.savePersistState(),await this.render()}openAddModal(e){new tt(this.plugin.app,this.plugin,this.dataManager,()=>void this.afterAdded(e.titleKey),{searchQuery:e.titleDisplay,type:"Anime",episodes:0,duration:0,releaseDate:"",link:"",seasons:[]}).open()}async afterAdded(e){var s;((s=this.plugin.settings.draftsAfterAdding)!=null?s:"keep")==="remove"?await this.dismissEntry(e):(this.persistState.added.includes(e)||this.persistState.added.push(e),await this.savePersistState(),await this.render())}};var At="watchlog-view",yt=class extends Xe.ItemView{constructor(t,s,i){super(t);this.dashboardTab=null;this.listTab=null;this.airtimeTab=null;this.customListsTab=null;this.draftsTab=null;this.airtimeBtn=null;this.draftsBtn=null;this.tabContentEl=null;this.plugin=s,this.dataManager=i;let a=s.settings.defaultView;this.activeTab=a==="watchlist"||a==="dashboard"?a:"watchlist",this.dataChangeListener=()=>this.refreshActiveTab()}getViewType(){return At}getDisplayText(){return"Watchlog"}getIcon(){return"tv"}async onOpen(){await super.onOpen(),this.dataManager.onChange(this.dataChangeListener),this.buildUI()}async onClose(){await super.onClose(),this.dataManager.offChange(this.dataChangeListener),this.destroyDraftsTab()}refreshUI(){this.applyColorTheme(this.contentEl),this.buildUI()}applyColorTheme(t){var i;let s=(i=this.plugin.settings.colorTheme)!=null?i:"default";s==="default"?t.removeAttribute("data-theme"):t.setAttribute("data-theme",s)}destroyDraftsTab(){this.draftsTab&&(this.draftsTab.destroy(),this.draftsTab=null)}buildUI(){this.destroyDraftsTab();let t=this.contentEl;t.empty(),t.addClass("wl-view"),this.applyColorTheme(t);let s=t.createDiv({cls:"wl-tab-bar"}),i=s.createEl("button",{cls:`wl-tab-btn${this.activeTab==="dashboard"?" is-active":""}`,text:"Dashboard"}),a=s.createEl("button",{cls:`wl-tab-btn${this.activeTab==="watchlist"?" is-active":""}`,text:"Watchlist"});this.airtimeBtn=s.createEl("button",{cls:`wl-tab-btn${this.activeTab==="upcoming"?" is-active":""}`,text:"Upcoming"});let l=s.createEl("button",{cls:`wl-tab-btn${this.activeTab==="custom-lists"?" is-active":""}`,text:"Custom lists"});this.draftsBtn=s.createEl("button",{cls:`wl-tab-btn${this.activeTab==="drafts"?" is-active":""}`,text:"Drafts"});let n=[i,a,this.airtimeBtn,l,this.draftsBtn];i.addEventListener("click",()=>{this.activeTab!=="dashboard"&&(this.destroyDraftsTab(),this.activeTab="dashboard",n.forEach(r=>r.removeClass("is-active")),i.addClass("is-active"),this.renderTabContent())}),a.addEventListener("click",()=>{this.activeTab!=="watchlist"&&(this.destroyDraftsTab(),this.activeTab="watchlist",n.forEach(r=>r.removeClass("is-active")),a.addClass("is-active"),this.renderTabContent())}),this.airtimeBtn.addEventListener("click",()=>{this.activeTab!=="upcoming"&&(this.destroyDraftsTab(),this.activeTab="upcoming",n.forEach(r=>r.removeClass("is-active")),this.airtimeBtn.addClass("is-active"),this.renderTabContent())}),l.addEventListener("click",()=>{this.activeTab!=="custom-lists"&&(this.destroyDraftsTab(),this.activeTab="custom-lists",n.forEach(r=>r.removeClass("is-active")),l.addClass("is-active"),this.renderTabContent())}),this.draftsBtn.addEventListener("click",()=>{this.activeTab!=="drafts"&&(this.activeTab="drafts",n.forEach(r=>r.removeClass("is-active")),this.draftsBtn.addClass("is-active"),this.renderTabContent())}),this.tabContentEl=t.createDiv({cls:"wl-tab-content"}),this.renderTabContent(),this.updateUpcomingBadge()}renderTabContent(){this.tabContentEl&&(this.tabContentEl.empty(),this.activeTab==="dashboard"?(this.dashboardTab=new It(this.tabContentEl,this.plugin,this.dataManager),this.dashboardTab.render()):this.activeTab==="watchlist"?(this.listTab=new Bt(this.tabContentEl,this.plugin,this.dataManager),this.listTab.render()):this.activeTab==="upcoming"?(this.airtimeTab=new wt(this.tabContentEl,this.plugin,this.dataManager,t=>{this.airtimeBtn&&(this.airtimeBtn.textContent=t>0?`Upcoming (${t})`:"Upcoming")}),this.airtimeTab.render()):this.activeTab==="drafts"?(this.draftsTab=new Qt(this.tabContentEl,this.plugin,this.dataManager,t=>{this.draftsBtn&&(this.draftsBtn.textContent=t>0?`Drafts (${t})`:"Drafts")}),this.draftsTab.render()):(this.customListsTab=new Ut(this.tabContentEl,this.plugin,this.dataManager),this.customListsTab.render()))}refreshActiveTab(){this.applyColorTheme(this.contentEl),this.activeTab==="watchlist"&&this.listTab?this.listTab.render():this.activeTab==="dashboard"&&this.dashboardTab?this.dashboardTab.render():this.activeTab==="upcoming"&&this.airtimeTab?this.airtimeTab.render():this.activeTab==="custom-lists"&&this.customListsTab?this.customListsTab.render():this.activeTab==="drafts"&&this.draftsTab?this.draftsTab.render():this.renderTabContent(),this.updateUpcomingBadge()}updateUpcomingBadge(){if(!this.airtimeBtn)return;let t=wt.getAiredDueCount(this.dataManager)+wt.getMaybeDueCount(this.dataManager);this.airtimeBtn.textContent=t>0?`Upcoming (${t})`:"Upcoming"}};var F=require("obsidian");var nt=require("obsidian"),Ze=["title","type","status","priority","rating","totalEpisodes","episodeDuration","dateStarted","dateFinished","releaseDate","dateAdded","externalLink","notes"];function Ys(h){let e=String(h!=null?h:"");return e.includes(",")||e.includes('"')||e.includes(` -`)?`"${e.replace(/"/g,'""')}"`:e}function Qs(h){let e=Ze.join(","),t=h.map(s=>Ze.map(i=>Ys(s[i])).join(","));return[e,...t].join(` -`)}function ht(h){let e=[],t="",s=!1;for(let i=0;i<h.length;i++){let a=h[i];a==='"'?s&&h[i+1]==='"'?(t+='"',i++):s=!s:a===","&&!s?(e.push(t),t=""):t+=a}return e.push(t),e}var ts={jan:1,feb:2,mar:3,apr:4,may:5,jun:6,jul:7,aug:8,sep:9,oct:10,nov:11,dec:12};function Ce(h){let e=h.trim();if(!e)return null;try{if(/^\d{4}-\d{2}-\d{2}$/.test(e))return e;let t=e.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);if(t){let n=parseInt(t[1]),r=parseInt(t[2]),o=parseInt(t[3]),c,d;return n>12?(c=n,d=r):r>12?(d=n,c=r):(c=n,d=r),d<1||d>12||c<1||c>31?null:`${o}-${String(d).padStart(2,"0")}-${String(c).padStart(2,"0")}`}let s=e.match(/^(\d{1,2})\s+([A-Za-z]{3,})\s+(\d{4})$/);if(s){let n=ts[s[2].toLowerCase().slice(0,3)],r=parseInt(s[1]),o=parseInt(s[3]);if(n)return`${o}-${String(n).padStart(2,"0")}-${String(r).padStart(2,"0")}`}let i=e.match(/^([A-Za-z]{3,})\s+(\d{1,2}),?\s+(\d{4})$/);if(i){let n=ts[i[1].toLowerCase().slice(0,3)],r=parseInt(i[2]),o=parseInt(i[3]);if(n)return`${o}-${String(n).padStart(2,"0")}-${String(r).padStart(2,"0")}`}let a=e.match(/^(\d{1,2})[.-](\d{1,2})[.-](\d{4})$/);if(a){let n=parseInt(a[1]),r=parseInt(a[2]),o=parseInt(a[3]);if(r>=1&&r<=12&&n>=1&&n<=31)return`${o}-${String(r).padStart(2,"0")}-${String(n).padStart(2,"0")}`}let l=new Date(e);if(!isNaN(l.getTime())){let n=l.getDate(),r=l.getMonth()+1;return`${l.getFullYear()}-${String(r).padStart(2,"0")}-${String(n).padStart(2,"0")}`}return null}catch(t){return null}}var Le=[{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 qs(h){let e={};for(let t of Le){let s=h.find(i=>t.autoDetect.includes(i.toLowerCase()));e[t.key]=s!=null?s:""}return e}function Js(h,e,t){var l;if(h.length<2)return[];let s=(l=h[0])!=null?l:"",i=ht(s).map(n=>n.trim()),a=t.getTitles();return h.slice(1).filter(n=>n.trim()).map(n=>{var b;let r=ht(n),o=x=>{var C;let k=e[x];if(!k)return"";let M=i.indexOf(k);return M>=0?((C=r[M])!=null?C:"").trim():""},c={},d=o("title");d&&(c.title=d);let u=o("type");u&&(c.type=u);let g=o("status");g&&(c.status=g);let p=o("priority");p&&(c.priority=p);let m=o("rating");m&&(c.rating=parseFloat(m)||0);let f=o("totalEpisodes");f&&(c.totalEpisodes=parseInt(f)||0);let v=o("episodeDuration");v&&(c.episodeDuration=parseInt(v)||0);let w=o("dateStarted");if(w){let x=Ce(w);x&&(c.dateStarted=x)}let y=o("dateFinished");if(y){let x=Ce(y);x&&(c.dateFinished=x)}let T=o("releaseDate");if(T){let x=Ce(T);x&&(c.releaseDate=x)}let D=o("externalLink");if(D&&(c.externalLink=D),!((b=c.title)!=null&&b.trim())&&!c.type&&!c.status&&!c.rating&&!c.totalEpisodes&&!c.episodeDuration&&!c.dateStarted&&!c.dateFinished&&!c.releaseDate&&!c.externalLink)return null;let E=!!a.find(x=>{var k;return x.title.toLowerCase()===((k=c.title)!=null?k:"").toLowerCase()});return{entry:c,isDuplicate:E}}).filter(n=>n!==null)}var qt="__create__",es={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 Xs(h){var t,s;let e=(t=es[h])!=null?t:es.default;return(s=e[Math.floor(Math.random()*e.length)])!=null?s:"#888780"}var Rt=class extends nt.Modal{constructor(t,s,i,a){super(t);this.selectedIds=new Set;this.csvLines=[];this.csvHeaders=[];this.columnMapping={};this.importRows=[];this.statusValueMap={};this.typeValueMap={};this.ratingValueMap={};this.stepUpload=null;this.stepMapping=null;this.stepValueMap=null;this.stepPreview=null;this.importBtn=null;this.cancelBtn=null;this.isImporting=!1;this.importCancelled=!1;this.progressWrap=null;this.progressBarFill=null;this.progressText=null;this.plugin=s,this.dataManager=i,this.mode=a}onOpen(){let{contentEl:t}=this;t.empty(),t.addClass("wl-csv-modal"),this.mode==="export"?this.renderExport(t):this.renderImport(t)}onClose(){this.contentEl.empty()}renderExport(t){t.createEl("h2",{cls:"wl-modal-title",text:"Export to CSV"});let s=this.dataManager.getTitles();s.forEach(p=>this.selectedIds.add(p.id));let i=t.createDiv({cls:"wl-csv-ctrl-row"}),a=i.createEl("button",{cls:"wl-btn",text:"Select all"}),l=i.createEl("button",{cls:"wl-btn",text:"Select none"}),n=i.createSpan({cls:"wl-csv-count",text:`${s.length} selected`}),r=t.createDiv({cls:"wl-csv-list"}),o=[],c=()=>{n.textContent=`${this.selectedIds.size} selected`};for(let p of s){let m=r.createDiv({cls:"wl-csv-row"}),f=m.createEl("input",{attr:{type:"checkbox"}});f.checked=!0,o.push(f),m.createSpan({cls:"wl-csv-row-title",text:p.title}),m.createSpan({cls:"wl-csv-row-meta",text:`${p.type} \xB7 ${p.status}`}),f.addEventListener("change",()=>{f.checked?this.selectedIds.add(p.id):this.selectedIds.delete(p.id),c()})}a.addEventListener("click",()=>{s.forEach(p=>this.selectedIds.add(p.id)),o.forEach(p=>{p.checked=!0}),c()}),l.addEventListener("click",()=>{this.selectedIds.clear(),o.forEach(p=>{p.checked=!1}),c()});let d=t.createDiv({cls:"wl-modal-btn-row wl-csv-btn-row"}),u=d.createEl("button",{cls:"wl-btn",text:"Cancel"}),g=d.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Export CSV"});u.addEventListener("click",()=>this.close()),g.addEventListener("click",()=>{let p=s.filter(D=>this.selectedIds.has(D.id));if(p.length===0){new nt.Notice("No titles selected.");return}let m=Qs(p),f=new Blob([m],{type:"text/csv;charset=utf-8;"}),v=URL.createObjectURL(f),w=document.createElement("a"),y=new Date,T=`${y.getFullYear()}-${String(y.getMonth()+1).padStart(2,"0")}-${String(y.getDate()).padStart(2,"0")}`;w.href=v,w.download=`watchlog-export-${T}.csv`,w.click(),URL.revokeObjectURL(v),new nt.Notice(`Exported ${p.length} titles.`),this.close()})}renderImport(t){t.createEl("h2",{cls:"wl-modal-title",text:"Import from CSV"}),this.stepUpload=t.createDiv({cls:"wl-csv-step"});let s=this.stepUpload.createDiv({cls:"wl-csv-drop-zone"});s.createDiv({cls:"wl-csv-drop-label",text:"Select a CSV file to import"});let i=s.createEl("input",{attr:{type:"file",accept:".csv"},cls:"wl-csv-file-input"});this.stepMapping=t.createDiv({cls:"wl-csv-step"}),this.stepMapping.hide(),this.stepValueMap=t.createDiv({cls:"wl-csv-step"}),this.stepValueMap.hide(),this.stepPreview=t.createDiv({cls:"wl-csv-step"}),this.stepPreview.hide();let a=t.createDiv({cls:"wl-modal-btn-row wl-csv-btn-row"});this.progressWrap=a.createDiv({cls:"wl-csv-progress-wrap"}),this.progressWrap.hide(),this.progressWrap.createSpan({cls:"wl-csv-bg-note",text:"You can close this window \u2014 import will continue in the background."});let l=this.progressWrap.createDiv({cls:"wl-csv-progress-track"});this.progressBarFill=l.createDiv({cls:"wl-csv-progress-fill"}),this.progressText=this.progressWrap.createSpan({cls:"wl-csv-progress-text",text:"0 / 0"}),this.cancelBtn=a.createEl("button",{cls:"wl-btn",text:"Cancel"}),this.importBtn=a.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Import selected"}),this.importBtn.disabled=!0,this.importBtn.hide(),this.cancelBtn.addEventListener("click",()=>{this.isImporting?(this.importCancelled=!0,this.cancelBtn&&(this.cancelBtn.disabled=!0,this.cancelBtn.textContent="Cancelling\u2026")):this.close()}),this.importBtn.addEventListener("click",()=>void this.doImport()),i.addEventListener("change",()=>{var o;let n=(o=i.files)==null?void 0:o[0];if(!n)return;let r=new FileReader;r.onload=c=>{var g,p;let d=(g=c.target)==null?void 0:g.result;this.csvLines=d.split(` -`);let u=(p=this.csvLines[0])!=null?p:"";this.csvHeaders=ht(u).map(m=>m.trim()),this.columnMapping=qs(this.csvHeaders),this.showMappingStep()},r.readAsText(n)})}showMappingStep(){!this.stepUpload||!this.stepMapping||(this.stepUpload.hide(),this.stepMapping.show(),this.renderMappingStep(this.stepMapping))}renderMappingStep(t){var n;t.empty(),t.createDiv({cls:"wl-csv-step-title",text:"Map your columns"}),t.createDiv({cls:"wl-csv-step-desc",text:'Match each WatchLog field to a column from your CSV. Select "Skip" to leave a field empty.'}),t.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."});let s="",i=t.createDiv({cls:"wl-csv-map-grid"}),a={};for(let r of Le){let o=i.createDiv({cls:"wl-csv-map-row"});o.createSpan({cls:"wl-csv-map-label",text:r.label});let c=o.createEl("select",{cls:"wl-select wl-csv-map-select"});a[r.key]=c,c.createEl("option",{value:s,text:"\u2014 skip \u2014"});for(let d of this.csvHeaders)c.createEl("option",{value:d,text:d});c.value=(n=this.columnMapping[r.key])!=null?n:s}t.createEl("button",{cls:"wl-btn wl-btn-primary wl-csv-preview-btn",text:"Next \u2192"}).addEventListener("click",()=>{var f,v,w,y,T,D,S;for(let E of Le)this.columnMapping[E.key]=(v=(f=a[E.key])==null?void 0:f.value)!=null?v:"";this.importRows=Js(this.csvLines,this.columnMapping,this.dataManager).map(E=>({...E,selected:!E.isDuplicate}));let r=new Set(this.plugin.settings.statuses.map(E=>E.name)),o=new Set(this.plugin.settings.types.map(E=>E.name)),c=[],d=[],u=[],g=new Set,p=new Set,m=new Set;for(let E of this.importRows){let b=(w=E.entry.status)==null?void 0:w.trim();b&&!r.has(b)&&!g.has(b)&&(c.push(b),g.add(b));let x=(y=E.entry.type)==null?void 0:y.trim();x&&!o.has(x)&&!p.has(x)&&(d.push(x),p.add(x));let k=this.columnMapping.rating;if(k){let M=(T=this.csvLines[0])!=null?T:"",L=ht(M).map(N=>N.trim()).indexOf(k);if(L>=0){let N=this.importRows.indexOf(E),W=(D=this.csvLines[N+1])!=null?D:"",A=((S=ht(W)[L])!=null?S:"").trim();A&&isNaN(parseFloat(A))&&!m.has(A)&&(u.push(A),m.add(A))}}}c.length>0||d.length>0||u.length>0?this.showValueMapStep(c,d,u):this.showPreviewStep()})}showValueMapStep(t,s,i=[]){!this.stepMapping||!this.stepValueMap||(this.stepMapping.hide(),this.stepValueMap.show(),this.renderValueMapStep(this.stepValueMap,t,s,i))}renderValueMapStep(t,s,i,a=[]){t.empty(),t.createDiv({cls:"wl-csv-step-title",text:"Map unknown values"}),t.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."});let l=this.plugin.settings.statuses.map(p=>p.name),n=this.plugin.settings.types.map(p=>p.name),r={},o={},c={};if(s.length>0){t.createDiv({cls:"wl-csv-valmap-section-title",text:"Status"});for(let p of s){let m=t.createDiv({cls:"wl-csv-valmap-row"});m.createSpan({cls:"wl-csv-valmap-orig",text:`"${p}"`}),m.createSpan({cls:"wl-csv-valmap-arrow",text:"\u2192"});let f=m.createEl("select",{cls:"wl-select wl-csv-valmap-select"});r[p]=f,f.createEl("option",{value:"",text:"\u2014 leave blank \u2014"});for(let y of l)f.createEl("option",{value:y,text:y});let v=p.toLowerCase(),w=l.find(y=>y.toLowerCase()===v);w&&(f.value=w)}}if(i.length>0){t.createDiv({cls:"wl-csv-valmap-section-title",text:"Type"});for(let p of i){let m=t.createDiv({cls:"wl-csv-valmap-row"});m.createSpan({cls:"wl-csv-valmap-orig",text:`"${p}"`}),m.createSpan({cls:"wl-csv-valmap-arrow",text:"\u2192"});let f=m.createEl("select",{cls:"wl-select wl-csv-valmap-select"});o[p]=f,f.createEl("option",{value:"",text:"\u2014 leave blank \u2014"});for(let y of n)f.createEl("option",{value:y,text:y});f.createEl("option",{value:qt,text:"+ create new type"});let v=p.toLowerCase(),w=n.find(y=>y.toLowerCase()===v);w?f.value=w:f.value=qt}}if(a.length>0){t.createDiv({cls:"wl-csv-valmap-section-title",text:"Rating"});for(let p of a){let m=t.createDiv({cls:"wl-csv-valmap-row"});m.createSpan({cls:"wl-csv-valmap-orig",text:`"${p}"`}),m.createSpan({cls:"wl-csv-valmap-arrow",text:"\u2192"});let f=m.createEl("select",{cls:"wl-select wl-csv-valmap-select"});c[p]=f,f.createEl("option",{value:"",text:"\u2014 leave blank \u2014"});for(let v=1;v<=5;v++)f.createEl("option",{value:String(v),text:`${v}/5`})}}let d=t.createDiv({cls:"wl-csv-valmap-btn-row"}),u=d.createEl("button",{cls:"wl-btn",text:"\u2190 back"}),g=d.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Preview \u2192"});u.addEventListener("click",()=>{!this.stepValueMap||!this.stepMapping||(this.stepValueMap.hide(),this.stepMapping.show())}),g.addEventListener("click",()=>{var p,m,f,v,w,y;this.statusValueMap={};for(let T of s)this.statusValueMap[T]=(m=(p=r[T])==null?void 0:p.value)!=null?m:"";this.typeValueMap={};for(let T of i)this.typeValueMap[T]=(v=(f=o[T])==null?void 0:f.value)!=null?v:"";this.ratingValueMap={};for(let T of a)this.ratingValueMap[T]=(y=(w=c[T])==null?void 0:w.value)!=null?y:"";this.applyValueMapsToRows(),this.showPreviewStep()})}applyValueMapsToRows(){var t,s,i,a,l,n,r;for(let o of this.importRows){let c=(s=(t=o.entry.status)==null?void 0:t.trim())!=null?s:"";if(c&&Object.prototype.hasOwnProperty.call(this.statusValueMap,c)){let g=(i=this.statusValueMap[c])!=null?i:"";o.entry.status=g||void 0}let d=(l=(a=o.entry.type)==null?void 0:a.trim())!=null?l:"";if(d&&Object.prototype.hasOwnProperty.call(this.typeValueMap,d)){let g=(n=this.typeValueMap[d])!=null?n:"";g===qt?o.entry.type=d:o.entry.type=g||void 0}let u=Object.keys(this.ratingValueMap).find(g=>{var D,S,E;let p=this.columnMapping.rating;if(!p)return!1;let m=(D=this.csvLines[0])!=null?D:"",v=ht(m).map(b=>b.trim()).indexOf(p);if(v<0)return!1;let w=this.importRows.indexOf(o),y=(S=this.csvLines[w+1])!=null?S:"";return((E=ht(y)[v])!=null?E:"").trim()===g});if(u!==void 0){let g=(r=this.ratingValueMap[u])!=null?r:"";o.entry.rating=g?parseInt(g):0}o.isDuplicate=!!this.dataManager.getTitles().find(g=>{var p;return g.title.toLowerCase()===((p=o.entry.title)!=null?p:"").toLowerCase()})}}showPreviewStep(){!this.stepValueMap||!this.stepMapping||!this.stepPreview||!this.importBtn||(this.stepValueMap.hide(),this.stepMapping.hide(),this.stepPreview.show(),this.importBtn.show(),this.importBtn.disabled=this.importRows.filter(t=>t.selected).length===0,this.renderPreviewStep(this.stepPreview))}renderPreviewStep(t){var u;t.empty();let s=this.importRows.filter(g=>g.isDuplicate).length;s>0&&t.createDiv({cls:"wl-csv-dupe-warning",text:`${s} duplicate${s!==1?"s":""} found (highlighted). These are deselected by default.`});let i=t.createDiv({cls:"wl-csv-ctrl-row"}),a=i.createEl("button",{cls:"wl-btn",text:"\u2190 back"}),l=i.createEl("button",{cls:"wl-btn",text:"Select all"}),n=i.createEl("button",{cls:"wl-btn",text:"Select none"}),r=i.createSpan({cls:"wl-csv-count",text:""});a.addEventListener("click",()=>{if(!this.stepPreview||!this.stepValueMap||!this.stepMapping||!this.importBtn)return;this.stepPreview.hide(),this.importBtn.hide(),this.importBtn.disabled=!0,Object.keys(this.statusValueMap).length>0||Object.keys(this.typeValueMap).length>0||Object.keys(this.ratingValueMap).length>0?this.stepValueMap.show():this.stepMapping.show()});let o=t.createDiv({cls:"wl-csv-list"}),c=[],d=()=>{let g=this.importRows.filter(p=>p.selected).length;r.textContent=`${g} of ${this.importRows.length} selected`,this.importBtn&&(this.importBtn.disabled=g===0)};for(let g=0;g<this.importRows.length;g++){let p=this.importRows[g],m=o.createDiv({cls:`wl-csv-row${p.isDuplicate?" wl-csv-row-dupe":""}`}),f=m.createEl("input",{attr:{type:"checkbox"}});f.checked=p.selected,c.push(f),m.createSpan({cls:"wl-csv-row-title",text:(u=p.entry.title)!=null?u:"(no title)"});let v=[p.entry.type,p.entry.status].filter(Boolean).join(" \xB7 ");v&&m.createSpan({cls:"wl-csv-row-meta",text:v}),p.isDuplicate&&m.createSpan({cls:"wl-csv-dupe-badge",text:"duplicate"});let w=g;f.addEventListener("change",()=>{this.importRows[w].selected=f.checked,d()})}l.addEventListener("click",()=>{this.importRows.forEach(g=>{g.selected=!0}),c.forEach(g=>{g.checked=!0}),d()}),n.addEventListener("click",()=>{this.importRows.forEach(g=>{g.selected=!1}),c.forEach(g=>{g.checked=!1}),d()}),d()}async doImport(){var c,d,u,g,p,m,f,v,w,y,T;let t=this.importRows.filter(D=>{var S;return D.selected&&((S=D.entry.title)==null?void 0:S.trim())});if(t.length===0){new nt.Notice("No titles selected to import.");return}this.isImporting=!0,this.importCancelled=!1,this.importBtn&&(this.importBtn.disabled=!0),this.cancelBtn&&(this.cancelBtn.textContent="Cancel import"),this.progressWrap&&this.progressWrap.show(),this.progressText&&(this.progressText.textContent=`0 / ${t.length}`),this.progressBarFill&&(this.progressBarFill.style.width="0%");let s=()=>{this.importCancelled=!0};this.plugin.importProgress={current:0,total:t.length,cancel:s};let i=(c=this.plugin.settings.colorTheme)!=null?c:"default",a=new Set(this.plugin.settings.types.map(D=>D.name.toLowerCase())),l=new Set;for(let D of t){let S=(d=D.entry.type)==null?void 0:d.trim();S&&!a.has(S.toLowerCase())&&l.add(S)}for(let[D,S]of Object.entries(this.typeValueMap))S===qt&&!a.has(D.toLowerCase())&&l.add(D);if(l.size>0){for(let D of l)this.plugin.settings.types.push({name:D,color:Xs(i)});await this.plugin.saveSettings()}let n=10,r=0;for(let D=0;D<t.length&&!this.importCancelled;D+=n){let S=Math.min(D+n,t.length);for(let E=D;E<S&&!this.importCancelled;E++){let b=t[E],x=b.entry.title.trim(),k={id:this.dataManager.generateId(x),title:x,type:((u=b.entry.type)==null?void 0:u.trim())||((g=this.plugin.settings.types[0])==null?void 0:g.name)||"Anime",status:b.entry.status||"Plan to watch",priority:b.entry.priority||"Medium",review:"",rating:(p=b.entry.rating)!=null?p:0,notes:"",dateStarted:(m=b.entry.dateStarted)!=null?m:null,dateFinished:(f=b.entry.dateFinished)!=null?f:null,dateAdded:new Date().toISOString(),dateModified:new Date().toISOString(),totalEpisodes:(v=b.entry.totalEpisodes)!=null?v:0,episodeDuration:(w=b.entry.episodeDuration)!=null?w:0,releaseDate:(y=b.entry.releaseDate)!=null?y:null,externalLink:(T=b.entry.externalLink)!=null?T:"",seasons:[],watchedEpisodes:[]};k.status==="Completed"&&k.totalEpisodes>0&&(k.watchedEpisodes=Array.from({length:k.totalEpisodes},(C,L)=>L+1)),this.plugin.importProgress={current:r+1,total:t.length,cancel:s},await this.dataManager.addTitleSilent(k),r++;let M=Math.round(r/t.length*100);this.progressBarFill&&(this.progressBarFill.style.width=`${M}%`),this.progressText&&(this.progressText.textContent=`${r} / ${t.length}`)}}this.isImporting=!1,this.plugin.importProgress=null,this.dataManager.notifyChange();let o=l.size>0?` (${l.size} new type${l.size!==1?"s":""} created)`:"";this.importCancelled?(this.progressText&&(this.progressText.textContent=`Cancelled \u2014 ${r} / ${t.length} imported`),this.cancelBtn&&(this.cancelBtn.disabled=!1,this.cancelBtn.textContent="Close"),new nt.Notice(`Import cancelled. ${r} title${r!==1?"s":""} imported.`)):(this.progressText&&(this.progressText.textContent=`Done \u2014 ${r} imported`),this.cancelBtn&&(this.cancelBtn.disabled=!1,this.cancelBtn.textContent="Close"),new nt.Notice(`Imported ${r} title${r!==1?"s":""}${o}.`),this.close())}};var Xt=class Xt extends F.PluginSettingTab{constructor(t,s){super(t,s);this.activeSection="general";this.plugin=s}display(){let{containerEl:t}=this;t.empty(),t.addClass("wl-settings");let s=t.createDiv({cls:"wl-settings-nav"}),i=[{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"}],a=new Map;for(let n of i){let r=s.createEl("button",{cls:`wl-settings-nav-btn${this.activeSection===n.key?" is-active":""}`,text:n.label});a.set(n.key,r),r.addEventListener("click",()=>{this.activeSection=n.key,a.forEach((o,c)=>{c===n.key?o.addClass("is-active"):o.removeClass("is-active")}),l.empty(),this.renderSection(l)})}let l=t.createDiv({cls:"wl-settings-body"});this.renderSection(l)}renderSection(t){switch(t.empty(),this.activeSection){case"general":this.renderGeneral(t);break;case"api":this.renderApi(t);break;case"customize":this.renderCustomize(t);break;case"folders":this.renderFolders(t);break;case"drafts":this.renderDrafts(t);break;case"custom-lists":this.renderCustomLists(t);break;case"widgets":this.renderWidgets(t);break;case"quick-info":this.renderQuickInfo(t);break}}renderGeneral(t){new F.Setting(t).setName("Default view").setDesc("Which tab opens when you launch the watchlog panel.").addDropdown(r=>r.addOptions({dashboard:"Dashboard",watchlist:"Watchlist"}).setValue(this.plugin.settings.defaultView).onChange(async o=>{this.plugin.settings.defaultView=o,await this.plugin.saveSettings()})),new F.Setting(t).setName("Dashboard card style").setDesc("Choose between ring charts or rectangular stat cards on the dashboard.").addDropdown(r=>{var o;return r.addOptions({circles:"Circles",rectangles:"Rectangles"}).setValue((o=this.plugin.settings.dashboardCardStyle)!=null?o:"circles").onChange(async c=>{this.plugin.settings.dashboardCardStyle=c,await this.plugin.saveSettings(),activeDocument.dispatchEvent(new CustomEvent("watchlog-data-changed"))})}),new F.Setting(t).setName("Episode numbering").setDesc("Absolute: episodes numbered 1\u2192n across all seasons. Per season: each season restarts from 1 (display only, data is unchanged).").addDropdown(r=>{var o;return r.addOptions({absolute:"Absolute","per-season":"Per season"}).setValue((o=this.plugin.settings.episodeNumbering)!=null?o:"absolute").onChange(async c=>{this.plugin.settings.episodeNumbering=c,await this.plugin.saveSettings(),activeDocument.dispatchEvent(new CustomEvent("watchlog-data-changed"))})}),new F.Setting(t).setName("Auto-complete on last episode").setDesc("Mark status as completed when all episodes are watched.").addToggle(r=>r.setValue(this.plugin.settings.autoCompleteOnLastEpisode).onChange(async o=>{this.plugin.settings.autoCompleteOnLastEpisode=o,await this.plugin.saveSettings()})),new F.Setting(t).setName("Set finish date automatically").setDesc("Record today's date as the finish date when a title is completed.").addToggle(r=>r.setValue(this.plugin.settings.setFinishDateAutomatically).onChange(async o=>{this.plugin.settings.setFinishDateAutomatically=o,await this.plugin.saveSettings()})),new F.Setting(t).setName("Sync widget with watchlist").setDesc("Changes in todo widget are reflected in the watchlist and vice versa.").addToggle(r=>r.setValue(this.plugin.settings.syncWidgetWithMainList).onChange(async o=>{this.plugin.settings.syncWidgetWithMainList=o,await this.plugin.saveSettings()})),new F.Setting(t).setName("Colored type badges").setDesc("Show type and status badges with their configured colors. Disable for a plain text style.").addToggle(r=>r.setValue(this.plugin.settings.coloredTypeBadges).onChange(async o=>{this.plugin.settings.coloredTypeBadges=o,await this.plugin.saveSettings()})),t.createDiv({cls:"wl-settings-section-title",text:"Backup & Restore"}),new F.Setting(t).setName("Export backup").setDesc("Export all watchlog data into a single timestamped .JSON file.").addButton(r=>r.setButtonText("Export backup").onClick(()=>this.exportBackup())),new F.Setting(t).setName("Restore from backup").setDesc("Restore all watchlog data from a .JSON backup file. Current data will be replaced.").addButton(r=>r.setButtonText("Restore from backup").onClick(()=>this.openRestoreDialog()));let s={wrap:null,fill:null,text:null};new F.Setting(t).setName("Regenerate note files").setDesc("Creates missing .md files for titles that don't have one. Existing files are not modified.").addButton(r=>r.setButtonText("Regenerate").onClick(()=>{let{wrap:o,fill:c,text:d}=s;o&&c&&d&&this.runRegenerate(o,c,d)}));let i=t.createDiv({cls:"wl-regen-progress-wrap"});i.hide(),i.createSpan({cls:"wl-csv-bg-note",text:"You can close settings \u2014 regeneration continues in the background."});let l=i.createDiv({cls:"wl-csv-progress-track"}).createDiv({cls:"wl-csv-progress-fill"}),n=i.createSpan({cls:"wl-csv-progress-text",text:"0 / 0"});s.wrap=i,s.fill=l,s.text=n,t.createDiv({cls:"wl-settings-section-title",text:"Import / Export CSV"}),new F.Setting(t).setName("Export to CSV").setDesc("Export selected titles as a CSV file.").addButton(r=>r.setButtonText("Export CSV").onClick(()=>{new Rt(this.app,this.plugin,this.plugin.dataManager,"export").open()})),new F.Setting(t).setName("Import from CSV").setDesc("Import titles from a CSV file. Duplicates are highlighted before import.").addButton(r=>r.setButtonText("Import CSV").onClick(()=>{new Rt(this.app,this.plugin,this.plugin.dataManager,"import").open()}))}exportBackup(){let t=new Date,i=`watchlog-backup-${`${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,"0")}-${String(t.getDate()).padStart(2,"0")}`}.json`;this.plugin.loadData().then(a=>{let l=JSON.stringify(a!=null?a:{},null,2),n=new Blob([l],{type:"application/json"}),r=URL.createObjectURL(n),o=activeDocument.createElement("a");o.href=r,o.download=i,activeDocument.body.appendChild(o),o.click(),activeDocument.body.removeChild(o),URL.revokeObjectURL(r),new F.Notice(`Backup exported as ${i}`)})}openRestoreDialog(){let t=activeDocument.createElement("input");t.type="file",t.accept=".json",t.hide(),activeDocument.body.appendChild(t),t.addEventListener("change",()=>{var a;let s=(a=t.files)==null?void 0:a[0];if(!s){activeDocument.body.removeChild(t);return}let i=new FileReader;i.onload=l=>{var o;activeDocument.body.removeChild(t);let n=(o=l.target)==null?void 0:o.result,r;try{r=JSON.parse(n)}catch(c){new F.Notice("Invalid backup file \u2014 could not parse JSON.");return}if(typeof r!="object"||r===null||!("titles"in r)){new F.Notice("Invalid backup file \u2014 missing required fields.");return}new K(this.app,"This will replace all current watchlog data. Continue?",()=>{this.plugin.saveData(r).then(async()=>{await this.plugin.loadSettings(),await this.plugin.dataManager.load(),activeDocument.dispatchEvent(new CustomEvent("watchlog-data-changed")),new F.Notice("Backup restored successfully.")})}).open()},i.readAsText(s)}),t.click()}async runRegenerate(t,s,i){let a=this.plugin.dataManager.getTitles(),l=a.length;if(t.show(),l===0){i.textContent="Done \u2014 no missing files found";return}s.style.width="0%",i.textContent=`0 / ${l}`;let n=0,r=0;for(let o=0;o<a.length;o++){await this.plugin.dataManager.createMarkdownFileIfMissing(a[o])?n++:r++;let d=Math.round((o+1)/l*100);s.style.width=`${d}%`,i.textContent=`${o+1} / ${l}`}n===0?i.textContent="Done \u2014 no missing files found":i.textContent=`Done \u2014 ${n} file${n!==1?"s":""} created, ${r} already existed`}renderApi(t){t.createDiv({cls:"wl-settings-info",text:"Anime data uses the Jikan API (jikan.moe) \u2014 a free public MyAnimeList wrapper. No API key required."}),new F.Setting(t).setName("Active API").setDesc("Which API to use for movies and tv shows.").addDropdown(n=>{var r;return n.addOptions({OMDb:"OMDb",TMDB:"TMDB"}).setValue((r=this.plugin.settings.activeApi)!=null?r:"OMDb").onChange(async o=>{this.plugin.settings.activeApi=o,await this.plugin.saveSettings()})}),t.createDiv({cls:"wl-settings-section-title",text:"OMDb API"});let s=t.createDiv({cls:"wl-settings-info"});s.createSpan({text:"Get a free API key at "}),s.createEl("a",{text:"omdbapi.com/apikey.aspx",href:"https://www.omdbapi.com/apikey.aspx",attr:{target:"_blank",rel:"noopener noreferrer"}}),s.createSpan({text:"."});let i;new F.Setting(t).setName("Omdb API key").addText(n=>(n.setPlaceholder("Paste your omdb key here").setValue(this.plugin.settings.omdbApiKey).onChange(async r=>{this.plugin.settings.omdbApiKey=r,this.plugin.apiService.setOmdbKey(r),await this.plugin.saveSettings()}),n.inputEl.type="password",n)).addButton(n=>n.setButtonText("Test").onClick(async()=>{let r=await this.plugin.apiService.checkOmdbConnection();i.textContent=r?"\u2713 Connected":"\u2717 Failed \u2014 check your key",i.className=r?"wl-status-ok":"wl-status-error"})),i=t.createDiv({cls:"wl-status-indicator",text:this.plugin.settings.omdbApiKey?"(not tested)":"Not set"}),t.createDiv({cls:"wl-settings-section-title",text:"TMDB API"});let a=t.createDiv({cls:"wl-settings-info"});a.createSpan({text:"Get a free API key at "}),a.createEl("a",{text:"themoviedb.org/settings/api",href:"https://www.themoviedb.org/settings/api",attr:{target:"_blank",rel:"noopener noreferrer"}}),a.createSpan({text:"."});let l;new F.Setting(t).setName("Tmdb API read access token").addText(n=>{var r;return n.setPlaceholder("Paste your tmdb key here").setValue((r=this.plugin.settings.tmdbApiKey)!=null?r:"").onChange(async o=>{this.plugin.settings.tmdbApiKey=o,this.plugin.apiService.setTmdbKey(o),await this.plugin.saveSettings()}),n.inputEl.type="password",n}).addButton(n=>n.setButtonText("Test").onClick(async()=>{let r=await this.plugin.apiService.checkTmdbConnection();l.textContent=r?"\u2713 Connected":"\u2717 Failed \u2014 check your key",l.className=r?"wl-status-ok":"wl-status-error"})),l=t.createDiv({cls:"wl-status-indicator",text:this.plugin.settings.tmdbApiKey?"(not tested)":"Not set"})}renderCustomize(t){this.renderThemePicker(t);let s=[{key:"types",label:"Type"},{key:"statuses",label:"Status"},{key:"priorities",label:"Priority"}];for(let i of s)this.renderTagSection(t,i.label,i.key);this.renderSeasonColors(t)}renderThemePicker(t){var r;let s=t.createDiv({cls:"wl-tag-section"});s.createDiv({cls:"wl-settings-section-title",text:"Theme"});let i=[{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"]}],a=(r=this.plugin.settings.colorTheme)!=null?r:"default",l=s.createDiv({cls:"wl-theme-cards"}),n=[];for(let o of i){let c=l.createDiv({cls:`wl-theme-card${a===o.key?" is-selected":""}`});n.push(c),c.createDiv({cls:"wl-theme-card-name",text:o.label});let d=c.createDiv({cls:"wl-theme-strip"});for(let u of o.colors){let g=d.createDiv({cls:"wl-theme-swatch"});g.style.backgroundColor=u}c.addEventListener("click",()=>{(async()=>{this.plugin.settings.colorTheme=o.key,await this.plugin.saveSettings();for(let g of n)g.removeClass("is-selected");c.addClass("is-selected"),activeDocument.dispatchEvent(new CustomEvent("watchlog-data-changed"));let u=this.plugin.app.workspace.getLeavesOfType("watchlog-view");for(let g of u)g.view instanceof yt&&g.view.refreshUI()})()})}}renderSeasonColors(t){let s=t.createDiv({cls:"wl-tag-section"});s.createDiv({cls:"wl-settings-section-title",text:"Season colors"}),s.createDiv({cls:"wl-settings-info",text:"Colors cycle through seasons in order (Season 1 = color 1, etc.)."});let i=s.createDiv({cls:"wl-season-colors-list"}),a=()=>{i.empty(),this.plugin.settings.seasonPalette.forEach((o,c)=>{let d=i.createDiv({cls:"wl-season-color-pill"}),u=d.createEl("input",{cls:"wl-color-input",attr:{type:"color",value:o}});u.addEventListener("change",()=>{(async()=>(this.plugin.settings.seasonPalette[c]=u.value,await this.plugin.saveSettings()))()}),d.createSpan({cls:"wl-season-color-label",text:`Season ${c+1}`}),d.createSpan({cls:"wl-tag-del",text:"\xD7"}).addEventListener("click",()=>{(async()=>(this.plugin.settings.seasonPalette.splice(c,1),await this.plugin.saveSettings(),a()))()})})};a();let l=s.createDiv({cls:"wl-tag-add-row"}),n=l.createEl("input",{cls:"wl-color-input",attr:{type:"color",value:"#888780"}});l.createEl("button",{cls:"wl-btn",text:"+ add color"}).addEventListener("click",()=>{(async()=>(this.plugin.settings.seasonPalette.push(n.value),await this.plugin.saveSettings(),n.value="#888780",a()))()})}renderTagSection(t,s,i){let a=i==="statuses",l=i==="types",n=t.createDiv({cls:"wl-tag-section"});n.createDiv({cls:"wl-settings-section-title",text:s}),a&&n.createDiv({cls:"wl-settings-info",text:"Status names are locked. Only the color can be changed."}),l&&n.createDiv({cls:"wl-settings-info",text:"Built-in types are locked. Custom types can be added and removed."});let r=n.createDiv({cls:"wl-tags-list"}),o=()=>{r.empty();for(let c of this.plugin.settings[i])this.renderTagPill(r,c,i,o)};if(o(),!a){let c=n.createDiv({cls:"wl-tag-add-row"}),d=c.createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Name"}}),u=c.createEl("input",{cls:"wl-color-input",attr:{type:"color",value:"#888780"}});c.createEl("button",{cls:"wl-btn",text:"+ add"}).addEventListener("click",()=>{(async()=>{let p=d.value.trim();p&&(this.plugin.settings[i].push({name:p,color:u.value}),await this.plugin.saveSettings(),d.value="",u.value="#888780",o())})()})}}renderTagPill(t,s,i,a){let l=t.createDiv({cls:"wl-tag-pill"}),n=l.createDiv({cls:"wl-tag-dot-wrap"}),r=n.createSpan({cls:"wl-tag-dot"});r.style.backgroundColor=s.color;let o=n.createEl("input",{cls:"wl-tag-color-picker",attr:{type:"color",value:s.color}});r.addEventListener("click",()=>o.click()),o.addEventListener("change",()=>{(async()=>(s.color=o.value,r.style.backgroundColor=o.value,await this.plugin.saveSettings()))()}),l.createSpan({cls:"wl-tag-name",text:s.name});let c=i==="types"&&Xt.LOCKED_TYPES.includes(s.name);i!=="statuses"&&!c&&l.createSpan({cls:"wl-tag-del",text:"\xD7"}).addEventListener("click",()=>{(async()=>(this.plugin.settings[i]=this.plugin.settings[i].filter(u=>u.name!==s.name),await this.plugin.saveSettings(),a(),new F.Notice(`Removed "${s.name}".`)))()})}renderFolders(t){new F.Setting(t).setName("Root folder name").setDesc("Vault folder where watchlog Markdown files are stored.").addText(a=>a.setValue(this.plugin.settings.rootFolder).onChange(async l=>{this.plugin.settings.rootFolder=l,await this.plugin.saveSettings()})),new F.Setting(t).setName("Auto-create folders").setDesc("Automatically create type subfolders on plugin load.").addToggle(a=>a.setValue(this.plugin.settings.autoCreateFolders).onChange(async l=>{this.plugin.settings.autoCreateFolders=l,await this.plugin.saveSettings()})),t.createDiv({cls:"wl-settings-section-title",text:"Type subfolders"});let s=t.createDiv({cls:"wl-folder-list"});(()=>{s.empty();for(let a of this.plugin.settings.types){let l=s.createDiv({cls:"wl-folder-row"});l.createSpan({cls:"wl-folder-type",text:a.name}),l.createSpan({cls:"wl-folder-path",text:`${this.plugin.settings.rootFolder}/${a.name}`})}})(),new F.Setting(t).setName("Create folders now").setDesc("Manually trigger folder creation for all current types.").addButton(a=>a.setButtonText("Create").onClick(async()=>{await this.plugin.dataManager.ensureFolders(),new F.Notice("Folders created.")}))}renderDrafts(t){t.createDiv({cls:"wl-settings-section-title",text:"Drafts"}),new F.Setting(t).setName("Vault tag to monitor").setDesc("Lines containing this tag followed by a title will appear in the drafts tab.").addText(s=>{var i;return s.setPlaceholder("#watchlog").setValue((i=this.plugin.settings.draftsVaultTag)!=null?i:"#watchlog").onChange(async a=>{this.plugin.settings.draftsVaultTag=a.trim()||"#watchlog",await this.plugin.saveSettings()})}),new F.Setting(t).setName("After adding a title").setDesc("What happens to a draft entry once you hit add and confirm.").addDropdown(s=>{var i;return s.addOptions({keep:"Keep as Added",remove:"Remove"}).setValue((i=this.plugin.settings.draftsAfterAdding)!=null?i:"keep").onChange(async a=>{this.plugin.settings.draftsAfterAdding=a,await this.plugin.saveSettings()})})}renderCustomLists(t){t.createDiv({cls:"wl-settings-section-title",text:"Custom Lists"}),new F.Setting(t).setName("Custom lists folder path").setDesc("Vault folder where custom list files are stored.").addText(s=>{var i;return s.setValue((i=this.plugin.settings.customListsFolder)!=null?i:"WatchLog/CustomLists").onChange(async a=>{this.plugin.settings.customListsFolder=a,await this.plugin.saveSettings()})}),new F.Setting(t).setName("Default columns").setDesc("Columns pre-populated when creating a new list. The name column is always included.").addButton(s=>{var a;let i=((a=this.plugin.settings.defaultCustomColumns)!=null?a:[]).length;s.setButtonText(`Edit (${i} column${i!==1?"s":""})`),s.onClick(()=>{new Ht(this.app,this.plugin).open()})}),new F.Setting(t).setName("Create folder now").setDesc("Manually create the custom lists folder in the vault.").addButton(s=>s.setButtonText("Create").onClick(async()=>{await new vt(this.app,this.plugin).ensureFolder(),new F.Notice("Custom lists folder created.")}))}renderQuickInfo(t){t.createDiv({cls:"wl-settings-section-title",text:"Quick Info"});let s=[{title:"\u{1F4CA} 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 \xD7 episode duration, summed across all titles with status Watching and Completed."},{heading:"Time Remaining",body:"Calculated as: episodes remaining \xD7 episode duration, summed across all titles with status Watching and Plan to Watch."},{heading:"Dropped",body:"Calculated as: episodes watched \xD7 episode duration, summed across all titles with status Dropped."}]},{title:"\u{1F4CB} 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:"\u{1F50D} 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. +`}async deleteList(s){let t=(0,W.normalizePath)(`${this.folderPath}/${s}.md`),e=this.app.vault.getAbstractFileByPath(t);if(e instanceof W.TFile)try{await this.app.fileManager.trashFile(e)}catch(i){}}async renameList(s,t){let e=(0,W.normalizePath)(`${this.folderPath}/${t}.md`),i=(0,W.normalizePath)(`${this.folderPath}/${s}.md`),a=this.app.vault.getAbstractFileByPath(i);if(a instanceof W.TFile)try{await this.app.vault.rename(a,e)}catch(n){}}generateColId(s){let t=new Set(s.map(i=>i.id)),e=1;for(;t.has(`col_${e}`);)e++;return`col_${e}`}generateRowId(s){let t=new Set(s.map(i=>i.id)),e=1;for(;t.has(`row_${e}`);)e++;return`row_${e}`}};function ei(g,s,t,e,i,a,n,r){g.empty(),g.addClass("wl-editcol-card-grid");let o=-1,l=c=>{let d=g.createDiv({cls:"wl-editcol-card wl-editcol-card-auto"});d.setAttribute("data-auto",c),d.createDiv({cls:"wl-editcol-card-name-wrap"}).createDiv({cls:"wl-editcol-card-name-label",text:c}),d.createDiv({cls:"wl-editcol-card-type-locked",text:"Auto"}),d.createDiv({cls:"wl-editcol-card-spacer"})};if(l("#"),l("Name"),s.forEach((c,d)=>{let u=g.createDiv({cls:"wl-editcol-card"}),h=u.createDiv({cls:"wl-editcol-card-handle",text:"\u283F"});h.title="Drag to reorder",h.addEventListener("mousedown",()=>u.setAttribute("draggable","true")),u.addEventListener("dragstart",f=>{var y;o=d,(y=f.dataTransfer)==null||y.setData("text/plain",String(d)),u.addClass("wl-cl-dragging")}),u.addEventListener("dragend",()=>{u.removeClass("wl-cl-dragging"),u.setAttribute("draggable","false")}),u.addEventListener("dragover",f=>{f.preventDefault(),u.addClass("wl-cl-drag-over")}),u.addEventListener("dragleave",()=>u.removeClass("wl-cl-drag-over")),u.addEventListener("drop",f=>{f.preventDefault(),u.removeClass("wl-cl-drag-over");let y=o,w=d;y!==w&&y>=0&&i(y,w)});let p=u.createEl("input",{cls:"wl-modal-input wl-editcol-card-name",attr:{type:"text",placeholder:"Column name",value:c.label}});p.addEventListener("input",()=>{c.label=p.value});let m=u.createEl("select",{cls:"wl-select wl-editcol-card-type"});for(let f of["text","number","select"]){let y=m.createEl("option",{value:f,text:f.charAt(0).toUpperCase()+f.slice(1)});c.type===f&&(y.selected=!0)}if(m.addEventListener("change",()=>{c.type=m.value,n()}),c.type==="text"||c.type==="number"){let f=u.createDiv({cls:"wl-editcol-card-toggles"}),y=f.createEl("button",{cls:`wl-btn wl-btn-sm wl-editcol-card-toggle wl-editcols-bold-btn${c.bold?" is-active":""}`,text:"B"});y.addEventListener("click",()=>{c.bold=!c.bold,y.toggleClass("is-active",!!c.bold)});let w=f.createEl("button",{cls:`wl-btn wl-btn-sm wl-editcol-card-toggle wl-editcols-italic-btn${c.italic?" is-active":""}`,text:"I"});if(w.addEventListener("click",()=>{c.italic=!c.italic,w.toggleClass("is-active",!!c.italic)}),c.type==="number"){let b=f.createEl("button",{cls:`wl-btn wl-btn-sm wl-editcol-card-toggle wl-editcols-autotime-btn${c.autoTime?" is-active":""}`,text:"\u23F1",attr:{title:"Auto-populate with remaining watch time from Watchlist"}});b.addEventListener("click",()=>{c.autoTime=!c.autoTime,b.toggleClass("is-active",!!c.autoTime)})}}let v=u.createEl("button",{cls:"wl-btn wl-btn-sm wl-editcol-card-del",text:"\xD7"});if(v.title="Delete this column",v.addEventListener("click",()=>{t.some(y=>{let w=y[c.id];return w!==void 0&&w!==""&&w!==null})?new H(e,`Delete column "${c.label}"? This will remove all data in this column.`,()=>{a(d)}).open():a(d)}),c.type==="select"){let f=u.createDiv({cls:"wl-editcol-card-opts"}),y=()=>{var b;f.empty(),((b=c.options)!=null?b:[]).forEach((S,D)=>{let E=f.createDiv({cls:"wl-editcol-card-opt-row"}),k=E.createEl("input",{cls:"wl-modal-input wl-editcol-card-opt-input",attr:{type:"text",value:S,placeholder:"Option"}});k.addEventListener("input",()=>{c.options||(c.options=[]),c.options[D]=k.value}),E.createEl("button",{cls:"wl-btn wl-btn-sm wl-editcol-card-opt-del",text:"\xD7",attr:{title:"Remove this option"}}).addEventListener("click",()=>{var x;(x=c.options)==null||x.splice(D,1),y()})}),f.createEl("button",{cls:"wl-btn wl-btn-sm wl-editcol-card-opt-add",text:"+ option"}).addEventListener("click",()=>{c.options||(c.options=[]),c.options.push(""),y()})};y()}}),r){let c=g.createDiv({cls:"wl-editcol-card wl-editcol-card-add"});c.createDiv({cls:"wl-editcol-card-add-label",text:"+ add column"}),c.addEventListener("click",()=>r())}}var xe=class extends W.Modal{constructor(s,t,e,i){super(s),this.list=t,this.manager=e,this.onSave=i,this.cols=t.columns.filter(a=>!a.locked).map(a=>({...a,options:a.options?[...a.options]:void 0}))}onOpen(){this.modalEl.addClass("wl-editcol-modal"),this.titleEl.setText(`Edit Columns \u2014 ${this.list.name}`),this.contentEl.addClass("wl-editcols-modal"),this.renderBody()}renderBody(){this.contentEl.empty(),this.contentEl.addClass("wl-editcols-modal"),this.listEl=this.contentEl.createDiv({cls:"wl-editcols-list"}),this.renderCols();let s=this.contentEl.createDiv({cls:"wl-modal-btn-row wl-editcols-footer"});s.createEl("button",{cls:"wl-btn",text:"Cancel"}).addEventListener("click",()=>this.close()),s.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Save"}).addEventListener("click",()=>void this.handleSave())}renderCols(){ei(this.listEl,this.cols,this.list.rows,this.app,(s,t)=>{let[e]=this.cols.splice(s,1);this.cols.splice(s<t?t-1:t,0,e),this.renderCols()},s=>{this.cols.splice(s,1),this.renderBody()},()=>this.renderBody(),()=>{this.cols.push({id:this.manager.generateColId([...this.list.columns.filter(s=>!s.locked),...this.cols]),label:"",type:"text",bold:!1,italic:!1}),this.renderBody()})}handleSave(){for(let s of this.cols){if(!s.label.trim()){new W.Notice("Column names cannot be empty.");return}if(s.type==="select"&&(!s.options||s.options.length===0)){new W.Notice(`Column "${s.label}" must have at least one option.`);return}}for(let s of this.cols)s.label=s.label.trim(),s.options&&(s.options=s.options.map(t=>t.trim()).filter(t=>t));this.onSave(this.cols).then(()=>this.close())}},Le=class extends W.Modal{constructor(s,t){var e;super(s),this.plugin=t,this.manager=new Wt(s,t),this.cols=((e=t.settings.defaultCustomColumns)!=null?e:[]).map(i=>({...i,options:i.options?[...i.options]:void 0}))}onOpen(){this.modalEl.addClass("wl-editcol-modal"),this.titleEl.setText("Default columns"),this.contentEl.addClass("wl-editcols-modal"),this.renderBody()}renderBody(){this.contentEl.empty(),this.contentEl.addClass("wl-editcols-modal"),this.contentEl.createDiv({cls:"wl-settings-info",text:"These columns are pre-populated when creating a new list. The Name column is always added automatically."}),this.listEl=this.contentEl.createDiv({cls:"wl-editcols-list"}),this.renderCols();let s=this.contentEl.createDiv({cls:"wl-modal-btn-row wl-editcols-footer"});s.createEl("button",{cls:"wl-btn",text:"Cancel"}).addEventListener("click",()=>this.close()),s.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Save"}).addEventListener("click",()=>void this.handleSave())}renderCols(){ei(this.listEl,this.cols,[],this.app,(s,t)=>{let[e]=this.cols.splice(s,1);this.cols.splice(s<t?t-1:t,0,e),this.renderCols()},s=>{this.cols.splice(s,1),this.renderBody()},()=>this.renderBody(),()=>{this.cols.push({id:this.manager.generateColId(this.cols),label:"",type:"text",bold:!1,italic:!1}),this.renderBody()})}async handleSave(){for(let s of this.cols){if(!s.label.trim()){new W.Notice("Column names cannot be empty.");return}if(s.type==="select"&&(!s.options||s.options.length===0)){new W.Notice(`Column "${s.label}" must have at least one option.`);return}}for(let s of this.cols)s.label=s.label.trim(),s.options&&(s.options=s.options.map(t=>t.trim()).filter(t=>t));this.plugin.settings.defaultCustomColumns=this.cols,await this.plugin.saveSettings(),new W.Notice("Default columns saved."),this.close()}},os=class extends W.Modal{constructor(t,e,i,a){super(t);this.listName=e;this.initialNotes=i;this.onSave=a;this.previewComponent=new W.Component}onOpen(){this.previewComponent.load(),this.titleEl.setText(`Notes \u2014 ${this.listName}`);let{contentEl:t}=this;t.addClass("wl-notes-modal");let e=t.createDiv({cls:"wl-notes-mode-bar"}),i=e.createEl("button",{cls:"wl-btn wl-btn-sm is-active",text:"Edit"}),a=e.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Preview"}),n=t.createDiv({cls:"wl-notes-edit-area"}),r=n.createEl("textarea",{cls:"wl-modal-textarea wl-notes-textarea",attr:{placeholder:"Add notes, links, or any text here..."}});r.value=this.initialNotes,window.setTimeout(()=>{r.focus(),r.setSelectionRange(r.value.length,r.value.length)},0);let o=t.createDiv({cls:"wl-notes-preview-area"});o.hide();let l=()=>{n.show(),o.hide(),i.addClass("is-active"),a.removeClass("is-active"),r.focus()},c=async()=>{n.hide(),o.show(),o.empty(),i.removeClass("is-active"),a.addClass("is-active");let p=r.value.trim()||"*No notes yet.*";await W.MarkdownRenderer.render(this.app,p,o,"",this.previewComponent)};i.addEventListener("click",l),a.addEventListener("click",()=>void c()),r.addEventListener("keydown",p=>{(p.ctrlKey||p.metaKey)&&p.key==="b"?(p.preventDefault(),ti(r,"**","**")):(p.ctrlKey||p.metaKey)&&p.key==="i"&&(p.preventDefault(),ti(r,"*","*"))});let d=t.createDiv({cls:"wl-modal-btn-row"});d.createEl("button",{cls:"wl-btn",text:"Cancel"}).addEventListener("click",()=>this.close()),d.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Save"}).addEventListener("click",()=>void this.onSave(r.value).then(()=>this.close()))}onClose(){this.previewComponent.unload(),this.contentEl.empty()}};function ti(g,s,t){let e=g.selectionStart,i=g.selectionEnd,a=g.value.slice(e,i),n=s+a+t;g.setRangeText(n,e,i,"select"),e===i&&(g.selectionStart=e+s.length,g.selectionEnd=e+s.length)}var ls=class extends W.Modal{constructor(t,e,i){super(t);this.existingNames=e;this.onSubmit=i}onOpen(){this.titleEl.setText("New custom list");let{contentEl:t}=this,e="",i=t.createDiv({cls:"wl-cl-name-error"});i.hide(),new W.Setting(t).setName("List name").addText(r=>{r.setPlaceholder("E.g. Marvel movies").onChange(o=>{e=o,i.hide()}),window.setTimeout(()=>r.inputEl.focus(),0),r.inputEl.addEventListener("keydown",o=>{o.key==="Enter"&&(o.preventDefault(),a())})});let a=()=>{let r=e.trim();if(!r){i.textContent="Please enter a name.",i.show();return}if(this.existingNames.includes(r)){i.textContent=`A list named "${r}" already exists.`,i.show();return}this.close(),this.onSubmit(r)},n=t.createDiv({cls:"wl-modal-btn-row"});n.createEl("button",{cls:"wl-btn",text:"Cancel"}).addEventListener("click",()=>this.close()),n.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Create"}).addEventListener("click",a)}onClose(){this.contentEl.empty()}},Re=class{constructor(s,t,e){this.listNames=[];this.activeListName=null;this.currentList=null;this.sortCol=null;this.sortDir="asc";this.searchQuery="";this.duplicatedRowIds=new Set;this._escapeKeyHandler=null;this.activeCleanups=[];this.renderGeneration=0;this.saveQueues=new Map;this._countEl=null;this._tableContainer=null;this.container=s,this.plugin=t,this.dataManager=e,this.manager=new Wt(t.app,t)}applyTabOrder(s){var a;let e=((a=this.plugin.settings.customListTabOrder)!=null?a:[]).filter(n=>s.includes(n)),i=s.filter(n=>!e.includes(n));return[...e,...i]}destroy(){for(let s of this.activeCleanups)try{s()}catch(t){}if(this.activeCleanups=[],this._escapeKeyHandler){try{activeDocument.removeEventListener("keydown",this._escapeKeyHandler)}catch(s){}this._escapeKeyHandler=null}}addCleanup(s){this.activeCleanups.push(s)}saveSerialized(s,t){var a;let i=((a=this.saveQueues.get(s))!=null?a:Promise.resolve()).then(t).catch(n=>console.error("[WL] custom-list save failed:",n));return this.saveQueues.set(s,i),i}async render(){var e;let s=++this.renderGeneration;this.destroy(),this.container.empty(),this.container.addClass("wl-custom-lists");let t=this.manager.listNames();if(this.listNames=this.applyTabOrder(t),this.listNames.length===0){this.buildEmptyState();return}(!this.activeListName||!this.listNames.includes(this.activeListName))&&(this.activeListName=(e=this.listNames[0])!=null?e:null),this.activeListName&&(this.currentList=await this.manager.loadList(this.activeListName)),s===this.renderGeneration&&(this.container.empty(),this.buildSubTabs(),this.currentList&&this.buildListView(this.currentList))}buildEmptyState(){let s=this.container.createDiv({cls:"wl-cl-empty-state"});s.createDiv({cls:"wl-cl-empty-text",text:"No custom lists yet."}),s.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Create your first list"}).addEventListener("click",()=>this.promptCreateList())}buildSubTabs(){let s=this.container.createDiv({cls:"wl-cl-sub-tabs"}),t=null;for(let i of this.listNames){let a=s.createDiv({cls:`wl-cl-sub-tab${i===this.activeListName?" is-active":""}`});a.setAttribute("draggable","true");let n=a.createSpan({cls:"wl-cl-sub-tab-name",text:i});n.addEventListener("dblclick",o=>{o.stopPropagation(),this.startRenameTab(a,n,i)}),a.createSpan({cls:"wl-cl-sub-tab-del",text:"\xD7"}).addEventListener("click",o=>{o.stopPropagation(),this.deleteList(i)}),a.addEventListener("click",()=>{this.activeListName!==i&&(this.activeListName=i,this.sortCol=null,this.sortDir="asc",this.searchQuery="",this.duplicatedRowIds.clear(),this.render())}),a.addEventListener("dragstart",o=>{var l;t=i,(l=o.dataTransfer)==null||l.setData("text/plain",i),a.addClass("wl-cl-dragging")}),a.addEventListener("dragend",()=>a.removeClass("wl-cl-dragging")),a.addEventListener("dragover",o=>{o.preventDefault(),a.addClass("wl-cl-drag-over")}),a.addEventListener("dragleave",()=>a.removeClass("wl-cl-drag-over")),a.addEventListener("drop",o=>{if(o.preventDefault(),a.removeClass("wl-cl-drag-over"),!t||t===i)return;let l=this.listNames.indexOf(t),c=this.listNames.indexOf(i);l<0||c<0||(this.listNames.splice(l,1),this.listNames.splice(c,0,t),this.plugin.settings.customListTabOrder=[...this.listNames],this.plugin.saveSettings().then(()=>void this.render()))})}s.createEl("button",{cls:"wl-cl-sub-tab-add wl-btn-success",text:"+"}).addEventListener("click",()=>this.promptCreateList())}startRenameTab(s,t,e){t.hide();let i=s.createEl("input",{cls:"wl-cl-sub-tab-rename",attr:{type:"text",value:e}});i.focus(),i.select();let a=!1,n=async r=>{if(a||(a=!0,i.remove(),t.show(),!r))return;let o=i.value.trim();if(!(!o||o===e)){if(this.listNames.includes(o)){new W.Notice(`A list named "${o}" already exists.`);return}await this.manager.renameList(e,o),this.activeListName===e&&(this.activeListName=o),await this.render()}};i.addEventListener("keydown",r=>{r.key==="Enter"&&(r.preventDefault(),n(!0)),r.key==="Escape"&&n(!1)}),i.addEventListener("blur",()=>void n(!0))}promptCreateList(){new ls(this.plugin.app,this.listNames,s=>{var i;let t=((i=this.plugin.settings.defaultCustomColumns)!=null?i:[]).map(a=>({...a,id:a.id,options:a.options?[...a.options]:void 0})),e={name:s,columns:t,rows:[],notes:""};new xe(this.plugin.app,e,this.manager,async a=>{e.columns=a,await this.manager.saveList(e),this.activeListName=s,this.sortCol=null,this.sortDir="asc",this.searchQuery="",this.duplicatedRowIds.clear(),await this.render()}).open()}).open()}deleteList(s){new H(this.plugin.app,`Delete list "${s}"? This cannot be undone.`,()=>{(async()=>(await this.manager.deleteList(s),this.activeListName===s&&(this.activeListName=null,this.currentList=null),await this.render()))()}).open()}buildListView(s){let t=this.container.createDiv({cls:"wl-cl-list-view"}),e=t.createDiv({cls:"wl-cl-header"}),i=e.createSpan({cls:"wl-results-count wl-cl-count"}),a=e.createDiv({cls:"wl-cl-toolbar"});a.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Notes"}).addEventListener("click",()=>{new os(this.plugin.app,s.name,s.notes,async h=>{await this.manager.saveNotes(s.name,h),s.notes=h}).open()});let r=a.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Sort"}),o=null;r.addEventListener("click",h=>{if(h.stopPropagation(),o){o.remove(),o=null;return}o=this.buildSortPanel(s,a,()=>{o=null}),activeDocument.addEventListener("click",()=>{o==null||o.remove(),o=null},{once:!0})}),a.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Export"}).addEventListener("click",()=>void this.exportToClipboard(s)),a.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Edit columns"}).addEventListener("click",()=>{new xe(this.plugin.app,s,this.manager,async h=>{let p=new Set(h.map(m=>m.id));for(let m of s.rows)for(let v of Object.keys(m))v!=="id"&&v!=="name"&&v!=="checked"&&!p.has(v)&&delete m[v];s.columns=h;for(let m of h)m.type==="number"&&m.autoTime&&this.autoPopulateTimeColumn(s,m);await this.manager.saveList(s),await this.render()}).open()});let c=t.createDiv({cls:"wl-search-wrap"}).createEl("input",{cls:"wl-search-input",attr:{type:"text",placeholder:"Search...",value:this.searchQuery}}),d=null;c.addEventListener("input",()=>{this.searchQuery=c.value,d!==null&&window.clearTimeout(d),d=window.setTimeout(()=>{d=null,this._tableContainer&&this._countEl&&(this._tableContainer.empty(),this.buildTable(this._tableContainer,s,this._countEl))},250)}),this.plugin.settings.showHintBanners&&t.createDiv({cls:"wl-cl-draft-banner",text:"\u26A0 This is a draft list. Titles here are not included in any stats or counts."});let u=t.createDiv({cls:"wl-cl-table-wrap"});this.buildTable(u,s,i)}buildSortPanel(s,t,e){let i=t.createDiv({cls:"wl-cl-sort-panel"}),a=i.createDiv({cls:"wl-filter-row"});a.createSpan({cls:"wl-filter-label",text:"Sort by"});let n=a.createEl("select",{cls:"wl-select"});n.createEl("option",{value:"name",text:"Name"});for(let c of s.columns.filter(d=>!d.locked))n.createEl("option",{value:c.id,text:c.label});this.sortCol&&(n.value=this.sortCol);let r=i.createDiv({cls:"wl-filter-row"});r.createSpan({cls:"wl-filter-label",text:"Direction"});let o=r.createEl("select",{cls:"wl-select"});o.createEl("option",{value:"asc",text:"A \u2192 z"}),o.createEl("option",{value:"desc",text:"Z \u2192 a"}),o.value=this.sortDir;let l=i.createDiv({cls:"wl-modal-btn-row"});return l.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Clear sort"}).addEventListener("click",c=>{c.stopPropagation(),this.sortCol=null,this.sortDir="asc",e(),this.render()}),l.createEl("button",{cls:"wl-btn wl-btn-sm wl-btn-primary",text:"Apply"}).addEventListener("click",c=>{c.stopPropagation(),this.sortCol=n.value,this.sortDir=o.value,e(),this.render()}),i.addEventListener("click",c=>c.stopPropagation()),i}async exportToClipboard(s){let t=s.columns.filter(r=>!r.locked),e=[{id:"#",label:"#"},{id:"name",label:"Name"},...t],i="| "+e.map(r=>r.label).join(" | ")+" |",a="| "+e.map(()=>"---").join(" | ")+" |",n=this.getFilteredSortedRows(s).map((r,o)=>"| "+e.map(c=>{if(c.id==="#")return String(o+1);let d=r[c.id];return d!=null?String(d):""}).join(" | ")+" |");try{await navigator.clipboard.writeText([i,a,...n].join(` +`)),new W.Notice("Copied to clipboard!")}catch(r){new W.Notice("Failed to copy to clipboard.")}}getFilteredSortedRows(s){let t=[...s.rows];if(this.searchQuery.trim()){let e=this.searchQuery.toLowerCase();t=t.filter(i=>{var a;return String((a=i.name)!=null?a:"").toLowerCase().includes(e)})}if(this.sortCol){let e=this.sortCol,i=this.sortDir;t.sort((a,n)=>{var l,c;let r=String((l=a[e])!=null?l:"").toLowerCase(),o=String((c=n[e])!=null?c:"").toLowerCase();return i==="asc"?r.localeCompare(o):o.localeCompare(r)})}return t}buildTable(s,t,e){this._countEl=e,this._tableContainer=s;let i=this.getFilteredSortedRows(t);e.textContent=`${i.length} title${i.length!==1?"s":""}`;let a=t.columns.filter(u=>!u.locked),n=s.createDiv({cls:"wl-cl-table"}),o=n.createDiv({cls:"wl-cl-thead"}).createDiv({cls:"wl-cl-tr wl-cl-tr-header"});o.createDiv({cls:"wl-cl-th wl-cl-th-tick"}),o.createDiv({cls:"wl-cl-th wl-cl-th-num",text:"#"}),o.createDiv({cls:"wl-cl-th",text:"Name"});for(let u of a){let h=o.createDiv({cls:"wl-cl-th"});if(h.createSpan({text:u.label}),u.type==="number"&&u.autoTime){let p=h.createSpan({cls:"wl-cl-autotime-refresh",text:"\u21BB"});p.title="Refresh remaining time values",p.addEventListener("click",m=>{m.stopPropagation(),this.autoPopulateTimeColumn(t,u),this.manager.saveList(t).then(()=>{s.empty(),this.buildTable(s,t,e)})})}}o.createDiv({cls:"wl-cl-th wl-cl-th-actions"});let l=n.createDiv({cls:"wl-cl-tbody"}),c=-1;i.forEach((u,h)=>{let p=u.checked===!0,m=l.createDiv({cls:`wl-cl-tr wl-cl-tr-body${p?" wl-cl-tr-checked":""}`});m.dataset.rowId=u.id;let f=m.createDiv({cls:"wl-cl-td wl-cl-td-tick"}).createDiv({cls:`wl-cl-tick-btn${p?" is-checked":""}`});f.title=p?"Uncheck row":"Check row",f.textContent=p?"\u2713":"\u25CB",f.addEventListener("click",()=>{let E=t.rows.find(k=>k.id===u.id);E&&(E.checked=!E.checked,this.manager.saveList(t).then(()=>{s.empty(),this.buildTable(s,t,e)}))}),m.createDiv({cls:"wl-cl-td wl-cl-td-num",text:String(h+1)});let y=m.createDiv({cls:"wl-cl-td wl-cl-td-name"});this.renderNameCell(y,u,t,e,a,s);for(let E of a){let k=m.createDiv({cls:"wl-cl-td"});this.renderCustomCell(k,u,E,t)}let w=m.createDiv({cls:"wl-cl-td wl-cl-td-actions"}),b=w.createDiv({cls:"wl-cl-row-action wl-cl-drag-handle",text:"\u283F"});b.title="Drag to reorder";let S=w.createDiv({cls:"wl-cl-row-action wl-cl-dup-btn",text:"\u29C9"});S.title="Duplicate row",S.addEventListener("click",()=>{let E=t.rows.findIndex(C=>C.id===u.id);if(E<0)return;let k={...t.rows[E],id:this.manager.generateRowId(t.rows)};t.rows.splice(E+1,0,k),this.duplicatedRowIds.add(k.id),this.manager.saveList(t).then(()=>{s.empty(),this.buildTable(s,t,e)})});let D=w.createDiv({cls:"wl-cl-row-action wl-cl-row-del",text:"\xD7"});D.title="Delete row",D.addEventListener("click",()=>{t.rows=t.rows.filter(E=>E.id!==u.id),this.duplicatedRowIds.delete(u.id),this.manager.saveList(t).then(()=>{s.empty(),this.buildTable(s,t,e)})}),m.setAttribute("draggable","false"),b.addEventListener("mousedown",()=>m.setAttribute("draggable","true")),m.addEventListener("dragstart",E=>{var k;c=t.rows.findIndex(C=>C.id===u.id),(k=E.dataTransfer)==null||k.setData("text/plain",u.id),m.addClass("wl-cl-dragging")}),m.addEventListener("dragend",()=>{m.removeClass("wl-cl-dragging"),m.setAttribute("draggable","false")}),m.addEventListener("dragover",E=>{E.preventDefault(),m.addClass("wl-cl-drag-over")}),m.addEventListener("dragleave",()=>m.removeClass("wl-cl-drag-over")),m.addEventListener("drop",E=>{E.preventDefault(),m.removeClass("wl-cl-drag-over");let k=t.rows.findIndex(C=>C.id===u.id);if(c!==k&&c>=0){let[C]=t.rows.splice(c,1);t.rows.splice(c<k?k-1:k,0,C),this.manager.saveList(t).then(()=>{s.empty(),this.buildTable(s,t,e)})}})}),s.createDiv({cls:"wl-cl-add-row"}).createEl("button",{cls:"wl-btn wl-btn-sm wl-btn-success",text:"+ add row"}).addEventListener("click",()=>{let u={id:this.manager.generateRowId(t.rows),name:""};t.rows.push(u),this.manager.saveList(t).then(()=>{s.empty(),this.buildTable(s,t,e),window.setTimeout(()=>{var m;let h=s.querySelector(".wl-cl-tbody"),p=h==null?void 0:h.querySelector(".wl-cl-tr-body:last-child");(m=p==null?void 0:p.querySelector(".wl-cl-td-name"))==null||m.click()},0)})})}renderNameCell(s,t,e,i,a,n){var l;s.empty(),s.removeClass("wl-cl-editing");let r=String((l=t.name)!=null?l:""),o=this.duplicatedRowIds.has(t.id);if(r){let c=s.createSpan({cls:"wl-cl-cell-text",text:r});o&&c.addClass("wl-cl-dup-name")}else s.createSpan({cls:"wl-cl-cell-empty",text:"\u2014"});s.addEventListener("click",()=>{this.startNameEdit(s,t,e,i,a,n)},{once:!0})}startNameEdit(s,t,e,i,a,n){var y;let r=String((y=t.name)!=null?y:"");s.empty(),s.addClass("wl-cl-editing"),this._escapeKeyHandler=w=>{w.key==="Escape"&&(w.stopPropagation(),w.stopImmediatePropagation(),w.preventDefault())},activeDocument.addEventListener("keydown",this._escapeKeyHandler,!0),activeDocument.addEventListener("keyup",this._escapeKeyHandler,!0);let o=this._escapeKeyHandler;this.addCleanup(()=>{activeDocument.removeEventListener("keydown",o,!0),activeDocument.removeEventListener("keyup",o,!0)});let l=()=>{let w=activeDocument.activeElement;w&&w.scrollIntoView({behavior:"smooth",block:"center"})};window.visualViewport&&window.visualViewport.addEventListener("resize",l),this.addCleanup(()=>{window.visualViewport&&window.visualViewport.removeEventListener("resize",l)}),window.setTimeout(()=>s.scrollIntoView({block:"nearest",behavior:"smooth"}),50);let d=s.createDiv({cls:"wl-cl-autofill-wrap"}).createEl("input",{cls:"wl-cl-cell-input",attr:{type:"text",value:r}});d.focus(),d.select();let u=null,h=-1,p=()=>{u==null||u.remove(),u=null,h=-1};d.addEventListener("input",()=>{p();let w=d.value.toLowerCase();if(!w)return;let b=this.plugin.readingDataManager,S=[...this.dataManager.getTitles().map(E=>({title:E.title,meta:`${E.type} \xB7 ${E.status}`})),...b.getBooks().map(E=>({title:E.title,meta:`Book \xB7 ${E.status}`})),...b.getMangaList().map(E=>({title:E.title,meta:`Manga \xB7 ${E.status}`}))].filter(E=>E.title.toLowerCase().includes(w)).slice(0,10);if(!S.length)return;let D=d.getBoundingClientRect();u=activeDocument.body.createDiv({cls:"wl-cl-autofill-dropdown wl-pos-fixed"}),u.style.top=`${D.bottom}px`,u.style.left=`${D.left}px`,u.style.width=`${D.width}px`;for(let E of S){let k=u.createDiv({cls:"wl-result-item"});k.createDiv({cls:"wl-result-title",text:E.title}),k.createDiv({cls:"wl-result-meta",text:E.meta}),k.addEventListener("mousedown",C=>{C.preventDefault(),d.value=E.title,p()})}});let m=!1,v=null,f=async w=>{if(m)return;m=!0,window.visualViewport&&window.visualViewport.removeEventListener("resize",l),this._escapeKeyHandler&&(activeDocument.removeEventListener("keydown",this._escapeKeyHandler,!0),activeDocument.removeEventListener("keyup",this._escapeKeyHandler,!0),this._escapeKeyHandler=null),p();let b=d.value.trim();if(!w){let S=this.duplicatedRowIds.has(t.id);t.name=b,S&&b!==r&&this.duplicatedRowIds.delete(t.id),await this.manager.saveList(e)}this.renderNameCell(s,t,e,i,a,n),!w&&v&&this.navigateCell(s,v)};d.addEventListener("blur",()=>void f(!1)),d.addEventListener("keydown",w=>{var b,S,D;if(u){let E=Array.from(u.querySelectorAll(".wl-result-item"));if(w.key==="ArrowDown"){w.preventDefault(),h=Math.min(h+1,E.length-1),E.forEach((k,C)=>{C===h?k.addClass("wl-result-item-focused"):k.removeClass("wl-result-item-focused")});return}if(w.key==="ArrowUp"){w.preventDefault(),h=Math.max(h-1,0),E.forEach((k,C)=>{C===h?k.addClass("wl-result-item-focused"):k.removeClass("wl-result-item-focused")});return}if(w.key==="Enter"&&h>=0){w.preventDefault();let k=(D=(S=(b=E[h])==null?void 0:b.querySelector(".wl-result-title"))==null?void 0:S.textContent)!=null?D:"";k&&(d.value=k),p();return}if(w.key==="Escape"){w.preventDefault(),w.stopPropagation(),w.stopImmediatePropagation(),p();return}}w.key==="Tab"&&w.shiftKey?(w.preventDefault(),v="shift-tab",f(!1)):w.key==="Tab"?(w.preventDefault(),v="tab",f(!1)):w.key==="Enter"?(w.preventDefault(),v="enter",f(!1)):w.key==="Escape"&&(w.preventDefault(),w.stopPropagation(),w.stopImmediatePropagation(),f(!0))})}renderCustomCell(s,t,e,i){s.empty(),s.removeClass("wl-cl-editing");let a=t[e.id];if(e.type==="select"){let n=String(a!=null?a:"");n?s.createSpan({cls:"wl-cl-select-badge",text:n}):s.createSpan({cls:"wl-cl-cell-empty",text:"\u2014"})}else{let n=a!=null&&a!==""?String(a):"";if(n){let r=s.createSpan({cls:"wl-cl-cell-text",text:n});e.bold&&r.addClass("wl-cell-bold"),e.italic&&r.addClass("wl-cell-italic")}else s.createSpan({cls:"wl-cl-cell-empty",text:"\u2014"})}s.addEventListener("click",()=>this.startCustomEdit(s,t,e,i),{once:!0})}startCustomEdit(s,t,e,i){var d;let a=t[e.id];s.empty(),s.addClass("wl-cl-editing");let n=()=>{let u=activeDocument.activeElement;u&&u.scrollIntoView({behavior:"smooth",block:"center"})};window.visualViewport&&window.visualViewport.addEventListener("resize",n),this.addCleanup(()=>{window.visualViewport&&window.visualViewport.removeEventListener("resize",n)}),window.setTimeout(()=>s.scrollIntoView({block:"nearest",behavior:"smooth"}),50);let r,o=!1,l=null,c=async u=>{if(!o){if(o=!0,window.visualViewport&&window.visualViewport.removeEventListener("resize",n),this._escapeKeyHandler&&(activeDocument.removeEventListener("keydown",this._escapeKeyHandler,!0),activeDocument.removeEventListener("keyup",this._escapeKeyHandler,!0),this._escapeKeyHandler=null),s.removeClass("wl-cl-editing"),!u){let h=r();t[e.id]=h===""||h===void 0?void 0:h,await this.manager.saveList(i)}this.renderCustomCell(s,t,e,i),!u&&l&&this.navigateCell(s,l)}};if(e.type==="select"){let u=s.createEl("select",{cls:"wl-select wl-cl-cell-select"});u.createEl("option",{value:"",text:"\u2014"});for(let h of(d=e.options)!=null?d:[]){let p=u.createEl("option",{value:h,text:h});a===h&&(p.selected=!0)}r=()=>u.value,u.focus(),u.addEventListener("change",()=>void c(!1)),u.addEventListener("blur",()=>void c(!1)),u.addEventListener("keydown",h=>{h.key==="Escape"?(h.preventDefault(),h.stopPropagation(),c(!0)):h.key==="Tab"&&h.shiftKey?(h.preventDefault(),l="shift-tab",c(!1)):h.key==="Tab"&&(h.preventDefault(),l="tab",c(!1))})}else if(e.type==="number"){let u=s.createEl("input",{cls:"wl-cl-cell-input",attr:{type:"number",value:a!=null?String(a):""}});r=()=>{let h=parseFloat(u.value);return isNaN(h)?"":h},u.focus(),u.select(),u.addEventListener("blur",()=>void c(!1)),u.addEventListener("keydown",h=>{h.key==="Enter"?(h.preventDefault(),l="enter",c(!1)):h.key==="Tab"&&h.shiftKey?(h.preventDefault(),l="shift-tab",c(!1)):h.key==="Tab"?(h.preventDefault(),l="tab",c(!1)):h.key==="Escape"&&(h.preventDefault(),h.stopPropagation(),h.stopImmediatePropagation(),c(!0))})}else{let u=s.createEl("input",{cls:"wl-cl-cell-input",attr:{type:"text",value:a!=null?String(a):""}});r=()=>u.value,u.focus(),u.select(),u.addEventListener("blur",()=>void c(!1)),u.addEventListener("keydown",h=>{h.key==="Enter"?(h.preventDefault(),l="enter",c(!1)):h.key==="Tab"&&h.shiftKey?(h.preventDefault(),l="shift-tab",c(!1)):h.key==="Tab"?(h.preventDefault(),l="tab",c(!1)):h.key==="Escape"&&(h.preventDefault(),h.stopPropagation(),h.stopImmediatePropagation(),c(!0))})}}autoPopulateTimeColumn(s,t){var i;let e=this.dataManager.getTitles();for(let a of s.rows){let n=String((i=a.name)!=null?i:"").trim();if(!n)continue;let r=e.find(l=>l.title===n);if(!r){a[t.id]="Not found";continue}let o=this.dataManager.calcTimeRemainingForModal(r);a[t.id]=o}}getEditableCells(s){return Array.from(s.querySelectorAll(":scope > .wl-cl-td")).filter(t=>!t.classList.contains("wl-cl-td-num")&&!t.classList.contains("wl-cl-td-actions"))}navigateCell(s,t){let e=s.closest(".wl-cl-tr-body");if(!e)return;let i=e.parentElement;if(!i)return;let a=Array.from(i.querySelectorAll(":scope > .wl-cl-tr-body")),n=a.indexOf(e),r=this.getEditableCells(e),o=r.indexOf(s);if(t==="tab")if(o>=0&&o<r.length-1)window.setTimeout(()=>{var l;return(l=r[o+1])==null?void 0:l.click()},10);else if(n>=0&&n<a.length-1){let l=a[n+1];if(l){let c=this.getEditableCells(l);c.length&&window.setTimeout(()=>{var d;return(d=c[0])==null?void 0:d.click()},10)}}else this.addRowForNavigation(0);else if(t==="shift-tab"){if(o>0)window.setTimeout(()=>{var l;return(l=r[o-1])==null?void 0:l.click()},10);else if(n>0){let l=a[n-1];if(l){let c=this.getEditableCells(l);c.length&&window.setTimeout(()=>{var d;return(d=c[c.length-1])==null?void 0:d.click()},10)}}}else if(n>=0&&n<a.length-1){let l=a[n+1];if(l){let c=this.getEditableCells(l),d=Math.min(o>=0?o:0,c.length-1);c.length&&window.setTimeout(()=>{var u;return(u=c[d])==null?void 0:u.click()},10)}}else this.addRowForNavigation(o>=0?o:0)}addRowForNavigation(s){let t=this.currentList,e=this._tableContainer,i=this._countEl;if(!t||!e||!i)return;let a={id:this.manager.generateRowId(t.rows),name:""};t.rows.push(a),this.manager.saveList(t).then(()=>{e.empty(),this.buildTable(e,t,i),window.setTimeout(()=>{let n=e.querySelector(".wl-cl-tbody");if(!n)return;let r=Array.from(n.querySelectorAll(".wl-cl-tr-body")),o=r[r.length-1];if(!o)return;let l=this.getEditableCells(o),c=l[Math.min(s,l.length-1)];c==null||c.click()},50)})}};function Dt(g){return Array.isArray?Array.isArray(g):ci(g)==="[object Array]"}function na(g){if(typeof g=="string")return g;if(typeof g=="bigint")return g.toString();let s=g+"";return s=="0"&&1/g==-1/0?"-0":s}function cs(g){return g==null?"":na(g)}function J(g){return typeof g=="string"}function Ie(g){return typeof g=="number"}function ra(g){return g===!0||g===!1||oa(g)&&ci(g)=="[object Boolean]"}function li(g){return typeof g=="object"}function oa(g){return li(g)&&g!==null}function X(g){return g!=null}function Ae(g){return!g.trim().length}function ci(g){return g==null?g===void 0?"[object Undefined]":"[object Null]":Object.prototype.toString.call(g)}var la="Incorrect 'index' type",ca=g=>`Invalid value for key ${g}`,da=g=>`Pattern length exceeds max of ${g}.`,ua=g=>`Missing ${g} property in key`,ha=g=>`Property 'weight' in key '${g}' must be a positive integer`,si=Object.prototype.hasOwnProperty,ds=class{constructor(s){this._keys=[],this._keyMap={};let t=0;s.forEach(e=>{let i=di(e);this._keys.push(i),this._keyMap[i.id]=i,t+=i.weight}),this._keys.forEach(e=>{e.weight/=t})}get(s){return this._keyMap[s]}keys(){return this._keys}toJSON(){return JSON.stringify(this._keys)}};function di(g){let s=null,t=null,e=null,i=1,a=null;if(J(g)||Dt(g))e=g,s=ii(g),t=us(g);else{if(!si.call(g,"name"))throw new Error(ua("name"));let n=g.name;if(e=n,si.call(g,"weight")&&(i=g.weight,i<=0))throw new Error(ha(n));s=ii(n),t=us(n),a=g.getFn}return{path:s,id:t,weight:i,src:e,getFn:a}}function ii(g){return Dt(g)?g:g.split(".")}function us(g){return Dt(g)?g.join("."):g}function pa(g,s){let t=[],e=!1,i=(a,n,r,o)=>{if(X(a))if(!n[r])t.push(o!==void 0?{v:a,i:o}:a);else{let l=n[r],c=a[l];if(!X(c))return;if(r===n.length-1&&(J(c)||Ie(c)||ra(c)||typeof c=="bigint"))t.push(o!==void 0?{v:cs(c),i:o}:cs(c));else if(Dt(c)){e=!0;for(let d=0,u=c.length;d<u;d+=1)i(c[d],n,r+1,d)}else n.length&&i(c,n,r+1,o)}};return i(g,J(s)?s.split("."):s,0),e?t:t[0]}var ga={includeMatches:!1,findAllMatches:!1,minMatchCharLength:1},ma={isCaseSensitive:!1,ignoreDiacritics:!1,includeScore:!1,keys:[],shouldSort:!0,sortFn:(g,s)=>g.score===s.score?g.idx<s.idx?-1:1:g.score<s.score?-1:1},va={location:0,threshold:.6,distance:100},fa={useExtendedSearch:!1,useTokenSearch:!1,getFn:pa,ignoreLocation:!1,ignoreFieldNorm:!1,fieldNormWeight:1},B=Object.freeze({...ma,...ga,...va,...fa}),wa=/[^ ]+/g;function ya(g=1,s=3){let t=new Map,e=Math.pow(10,s);return{get(i){let a=i.match(wa).length;if(t.has(a))return t.get(a);let n=1/Math.pow(a,.5*g),r=parseFloat(Math.round(n*e)/e);return t.set(a,r),r},clear(){t.clear()}}}var se=class{constructor({getFn:s=B.getFn,fieldNormWeight:t=B.fieldNormWeight}={}){this.norm=ya(t,3),this.getFn=s,this.isCreated=!1,this.docs=[],this.keys=[],this._keysMap={},this.setIndexRecords()}setSources(s=[]){this.docs=s}setIndexRecords(s=[]){this.records=s}setKeys(s=[]){this.keys=s,this._keysMap={},s.forEach((t,e)=>{this._keysMap[t.id]=e})}create(){this.isCreated||!this.docs.length||(this.isCreated=!0,J(this.docs[0])?this.docs.forEach((s,t)=>{this._addString(s,t)}):this.docs.forEach((s,t)=>{this._addObject(s,t)}),this.norm.clear())}add(s){let t=this.size();J(s)?this._addString(s,t):this._addObject(s,t)}removeAt(s){this.records.splice(s,1);for(let t=s,e=this.size();t<e;t+=1)this.records[t].i-=1}removeAll(s){for(let t=s.length-1;t>=0;t-=1)this.records.splice(s[t],1);for(let t=0,e=this.records.length;t<e;t+=1)this.records[t].i=t}getValueForItemAtKeyId(s,t){return s[this._keysMap[t]]}size(){return this.records.length}_addString(s,t){if(!X(s)||Ae(s))return;let e={v:s,i:t,n:this.norm.get(s)};this.records.push(e)}_addObject(s,t){let e={i:t,$:{}};this.keys.forEach((i,a)=>{let n=i.getFn?i.getFn(s):this.getFn(s,i.path);if(X(n)){if(Dt(n)){let r=[];for(let o=0,l=n.length;o<l;o+=1){let c=n[o];if(X(c)){if(J(c)){if(!Ae(c)){let d={v:c,i:o,n:this.norm.get(c)};r.push(d)}}else if(X(c.v)){let d=J(c.v)?c.v:cs(c.v);if(!Ae(d)){let u={v:d,i:c.i,n:this.norm.get(d)};r.push(u)}}}}e.$[a]=r}else if(J(n)&&!Ae(n)){let r={v:n,n:this.norm.get(n)};e.$[a]=r}}}),this.records.push(e)}toJSON(){return{keys:this.keys.map(({getFn:s,...t})=>t),records:this.records}}};function ui(g,s,{getFn:t=B.getFn,fieldNormWeight:e=B.fieldNormWeight}={}){let i=new se({getFn:t,fieldNormWeight:e});return i.setKeys(g.map(di)),i.setSources(s),i.create(),i}function ba(g,{getFn:s=B.getFn,fieldNormWeight:t=B.fieldNormWeight}={}){let{keys:e,records:i}=g,a=new se({getFn:s,fieldNormWeight:t});return a.setKeys(e),a.setIndexRecords(i),a}function Ea(g=[],s=B.minMatchCharLength){let t=[],e=-1,i=-1,a=0;for(let n=g.length;a<n;a+=1){let r=g[a];r&&e===-1?e=a:!r&&e!==-1&&(i=a-1,i-e+1>=s&&t.push([e,i]),e=-1)}return g[a-1]&&a-e>=s&&t.push([e,a-1]),t}var At=32;function Sa(g,s,t,{location:e=B.location,distance:i=B.distance,threshold:a=B.threshold,findAllMatches:n=B.findAllMatches,minMatchCharLength:r=B.minMatchCharLength,includeMatches:o=B.includeMatches,ignoreLocation:l=B.ignoreLocation}={}){if(s.length>At)throw new Error(da(At));let c=s.length,d=g.length,u=Math.max(0,Math.min(e,d)),h=a,p=u,m=(k,C)=>{let x=k/c;if(l)return x;let T=Math.abs(u-C);return i?x+T/i:T?1:x},v=r>1||o,f=v?Array(d):[],y;for(;(y=g.indexOf(s,p))>-1;){let k=m(0,y);if(h=Math.min(k,h),p=y+c,v){let C=0;for(;C<c;)f[y+C]=1,C+=1}}p=-1;let w=[],b=1,S=c+d,D=1<<c-1;for(let k=0;k<c;k+=1){let C=0,x=S;for(;C<x;)m(k,u+x)<=h?C=x:S=x,x=Math.floor((S-C)/2+C);S=x;let T=Math.max(1,u-x+1),M=n?d:Math.min(u+x,d)+c,L=Array(M+2);L[M+1]=(1<<k)-1;for(let A=M;A>=T;A-=1){let P=A-1,I=t[g[P]];if(v&&(f[P]=+!!I),L[A]=(L[A+1]<<1|1)&I,k&&(L[A]|=(w[A+1]|w[A])<<1|1|w[A+1]),L[A]&D&&(b=m(k,P),b<=h)){if(h=b,p=P,p<=u)break;T=Math.max(1,2*u-p)}}if(m(k+1,u)>h)break;w=L}let E={isMatch:p>=0,score:Math.max(.001,b)};if(v){let k=Ea(f,r);k.length?o&&(E.indices=k):E.isMatch=!1}return E}function Da(g){let s={};for(let t=0,e=g.length;t<e;t+=1){let i=g.charAt(t);s[i]=(s[i]||0)|1<<e-t-1}return s}function Ms(g){if(g.length<=1)return g;g.sort((t,e)=>t[0]-e[0]||t[1]-e[1]);let s=[g[0]];for(let t=1,e=g.length;t<e;t+=1){let i=s[s.length-1],a=g[t];a[0]<=i[1]+1?i[1]=Math.max(i[1],a[1]):s.push(a)}return s}var hi={\u0142:"l",\u0141:"L",\u0111:"d",\u0110:"D",\u00F8:"o",\u00D8:"O",\u0127:"h",\u0126:"H",\u0167:"t",\u0166:"T",\u0131:"i",\u00DF:"ss"},ka=new RegExp("["+Object.keys(hi).join("")+"]","g"),ie=String.prototype.normalize?g=>g.normalize("NFD").replace(/[\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u07FD\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u08D3-\u08E1\u08E3-\u0903\u093A-\u093C\u093E-\u094F\u0951-\u0957\u0962\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09E2\u09E3\u09FE\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE2\u0AE3\u0AFA-\u0AFF\u0B01-\u0B03\u0B3C\u0B3E-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B62\u0B63\u0B82\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C00-\u0C04\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81-\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CE2\u0CE3\u0D00-\u0D03\u0D3B\u0D3C\u0D3E-\u0D44\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0D62\u0D63\u0D82\u0D83\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2\u0DF3\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EB9\u0EBB\u0EBC\u0EC8-\u0ECD\u0F18\u0F19\u0F35\u0F37\u0F39\u0F3E\u0F3F\u0F71-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102B-\u103E\u1056-\u1059\u105E-\u1060\u1062-\u1064\u1067-\u106D\u1071-\u1074\u1082-\u108D\u108F\u109A-\u109D\u135D-\u135F\u1712-\u1714\u1732-\u1734\u1752\u1753\u1772\u1773\u17B4-\u17D3\u17DD\u180B-\u180D\u1885\u1886\u18A9\u1920-\u192B\u1930-\u193B\u1A17-\u1A1B\u1A55-\u1A5E\u1A60-\u1A7C\u1A7F\u1AB0-\u1ABE\u1B00-\u1B04\u1B34-\u1B44\u1B6B-\u1B73\u1B80-\u1B82\u1BA1-\u1BAD\u1BE6-\u1BF3\u1C24-\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE8\u1CED\u1CF2-\u1CF4\u1CF7-\u1CF9\u1DC0-\u1DF9\u1DFB-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302F\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA823-\uA827\uA880\uA881\uA8B4-\uA8C5\uA8E0-\uA8F1\uA8FF\uA926-\uA92D\uA947-\uA953\uA980-\uA983\uA9B3-\uA9C0\uA9E5\uAA29-\uAA36\uAA43\uAA4C\uAA4D\uAA7B-\uAA7D\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEB-\uAAEF\uAAF5\uAAF6\uABE3-\uABEA\uABEC\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F]/g,"").replace(ka,s=>hi[s]):g=>g,ae=class{constructor(s,{location:t=B.location,threshold:e=B.threshold,distance:i=B.distance,includeMatches:a=B.includeMatches,findAllMatches:n=B.findAllMatches,minMatchCharLength:r=B.minMatchCharLength,isCaseSensitive:o=B.isCaseSensitive,ignoreDiacritics:l=B.ignoreDiacritics,ignoreLocation:c=B.ignoreLocation}={}){if(this.options={location:t,threshold:e,distance:i,includeMatches:a,findAllMatches:n,minMatchCharLength:r,isCaseSensitive:o,ignoreDiacritics:l,ignoreLocation:c},s=o?s:s.toLowerCase(),s=l?ie(s):s,this.pattern=s,this.chunks=[],!this.pattern.length)return;let d=(h,p)=>{this.chunks.push({pattern:h,alphabet:Da(h),startIndex:p})},u=this.pattern.length;if(u>At){let h=0,p=u%At,m=u-p;for(;h<m;)d(this.pattern.substr(h,At),h),h+=At;if(p){let v=u-At;d(this.pattern.substr(v),v)}}else d(this.pattern,0)}searchIn(s){let{isCaseSensitive:t,ignoreDiacritics:e,includeMatches:i}=this.options;if(s=t?s:s.toLowerCase(),s=e?ie(s):s,this.pattern===s){let m={isMatch:!0,score:0};return i&&(m.indices=[[0,s.length-1]]),m}let{location:a,distance:n,threshold:r,findAllMatches:o,minMatchCharLength:l,ignoreLocation:c}=this.options,d=[],u=0,h=!1;this.chunks.forEach(({pattern:m,alphabet:v,startIndex:f})=>{let{isMatch:y,score:w,indices:b}=Sa(s,m,v,{location:a+f,distance:n,threshold:r,findAllMatches:o,minMatchCharLength:l,includeMatches:i,ignoreLocation:c});y&&(h=!0),u+=w,y&&b&&d.push(...b)});let p={isMatch:h,score:h?u/this.chunks.length:1};return h&&i&&(p.indices=Ms(d)),p}},gt=class{constructor(s){this.pattern=s}static isMultiMatch(s){return ai(s,this.multiRegex)}static isSingleMatch(s){return ai(s,this.singleRegex)}search(s){return{isMatch:!1,score:1}}};function ai(g,s){let t=g.match(s);return t?t[1]:null}var hs=class extends gt{constructor(s){super(s)}static get type(){return"exact"}static get multiRegex(){return/^="(.*)"$/}static get singleRegex(){return/^=(.*)$/}search(s){let t=s===this.pattern;return{isMatch:t,score:t?0:1,indices:[0,this.pattern.length-1]}}},ps=class extends gt{constructor(s){super(s)}static get type(){return"inverse-exact"}static get multiRegex(){return/^!"(.*)"$/}static get singleRegex(){return/^!(.*)$/}search(s){let e=s.indexOf(this.pattern)===-1;return{isMatch:e,score:e?0:1,indices:[0,s.length-1]}}},gs=class extends gt{constructor(s){super(s)}static get type(){return"prefix-exact"}static get multiRegex(){return/^\^"(.*)"$/}static get singleRegex(){return/^\^(.*)$/}search(s){let t=s.startsWith(this.pattern);return{isMatch:t,score:t?0:1,indices:[0,this.pattern.length-1]}}},ms=class extends gt{constructor(s){super(s)}static get type(){return"inverse-prefix-exact"}static get multiRegex(){return/^!\^"(.*)"$/}static get singleRegex(){return/^!\^(.*)$/}search(s){let t=!s.startsWith(this.pattern);return{isMatch:t,score:t?0:1,indices:[0,s.length-1]}}},vs=class extends gt{constructor(s){super(s)}static get type(){return"suffix-exact"}static get multiRegex(){return/^"(.*)"\$$/}static get singleRegex(){return/^(.*)\$$/}search(s){let t=s.endsWith(this.pattern);return{isMatch:t,score:t?0:1,indices:[s.length-this.pattern.length,s.length-1]}}},fs=class extends gt{constructor(s){super(s)}static get type(){return"inverse-suffix-exact"}static get multiRegex(){return/^!"(.*)"\$$/}static get singleRegex(){return/^!(.*)\$$/}search(s){let t=!s.endsWith(this.pattern);return{isMatch:t,score:t?0:1,indices:[0,s.length-1]}}},Be=class extends gt{constructor(s,{location:t=B.location,threshold:e=B.threshold,distance:i=B.distance,includeMatches:a=B.includeMatches,findAllMatches:n=B.findAllMatches,minMatchCharLength:r=B.minMatchCharLength,isCaseSensitive:o=B.isCaseSensitive,ignoreDiacritics:l=B.ignoreDiacritics,ignoreLocation:c=B.ignoreLocation}={}){super(s),this._bitapSearch=new ae(s,{location:t,threshold:e,distance:i,includeMatches:a,findAllMatches:n,minMatchCharLength:r,isCaseSensitive:o,ignoreDiacritics:l,ignoreLocation:c})}static get type(){return"fuzzy"}static get multiRegex(){return/^"(.*)"$/}static get singleRegex(){return/^(.*)$/}search(s){return this._bitapSearch.searchIn(s)}},Pe=class extends gt{constructor(s){super(s)}static get type(){return"include"}static get multiRegex(){return/^'"(.*)"$/}static get singleRegex(){return/^'(.*)$/}search(s){let t=0,e,i=[],a=this.pattern.length;for(;(e=s.indexOf(this.pattern,t))>-1;)t=e+a,i.push([e,t-1]);let n=!!i.length;return{isMatch:n,score:n?0:1,indices:i}}},ws=[hs,Pe,gs,ms,fs,vs,ps,Be],ni=ws.length,Ta="\0",Ca="|";function Ma(g){let s=[],t=g.length,e=0;for(;e<t;){for(;e<t&&g[e]===" ";)e++;if(e>=t)break;let i=e;for(;i<t&&g[i]!==" "&&g[i]!=='"';)i++;if(i<t&&g[i]==='"'){for(i++;i<t;){if(g[i]==='"'){let a=i+1;if(a>=t||g[a]===" "){i++;break}if(g[a]==="$"&&(a+1>=t||g[a+1]===" ")){i+=2;break}}i++}s.push(g.substring(e,i)),e=i}else{for(;i<t&&g[i]!==" ";)i++;s.push(g.substring(e,i)),e=i}}return s}function xa(g,s={}){return g.replace(/\\\|/g,Ta).split(Ca).map(e=>{let i=e.replace(/\u0000/g,"|"),a=Ma(i.trim()).filter(r=>r&&!!r.trim()),n=[];for(let r=0,o=a.length;r<o;r+=1){let l=a[r],c=!1,d=-1;for(;!c&&++d<ni;){let u=ws[d],h=u.isMultiMatch(l);h&&(n.push(new u(h,s)),c=!0)}if(!c)for(d=-1;++d<ni;){let u=ws[d],h=u.isSingleMatch(l);if(h){n.push(new u(h,s));break}}}return n})}var La=new Set([Be.type,Pe.type]),ys=class{constructor(s,{isCaseSensitive:t=B.isCaseSensitive,ignoreDiacritics:e=B.ignoreDiacritics,includeMatches:i=B.includeMatches,minMatchCharLength:a=B.minMatchCharLength,ignoreLocation:n=B.ignoreLocation,findAllMatches:r=B.findAllMatches,location:o=B.location,threshold:l=B.threshold,distance:c=B.distance}={}){this.query=null,this.options={isCaseSensitive:t,ignoreDiacritics:e,includeMatches:i,minMatchCharLength:a,findAllMatches:r,ignoreLocation:n,location:o,threshold:l,distance:c},s=t?s:s.toLowerCase(),s=e?ie(s):s,this.pattern=s,this.query=xa(this.pattern,this.options)}static condition(s,t){return t.useExtendedSearch}searchIn(s){let t=this.query;if(!t)return{isMatch:!1,score:1};let{includeMatches:e,isCaseSensitive:i,ignoreDiacritics:a}=this.options;s=i?s:s.toLowerCase(),s=a?ie(s):s;let n=0,r=[],o=0,l=!1;for(let c=0,d=t.length;c<d;c+=1){let u=t[c];r.length=0,n=0,l=!1;for(let h=0,p=u.length;h<p;h+=1){let m=u[h],{isMatch:v,indices:f,score:y}=m.search(s);if(v){n+=1,o+=y;let w=m.constructor.type;w.startsWith("inverse")&&(l=!0),e&&(La.has(w)?r.push(...f):r.push(f))}else{o=0,n=0,r.length=0,l=!1;break}}if(n){let h={isMatch:!0,score:o/n};return l&&(h.hasInverse=!0),e&&(h.indices=Ms(r)),h}}return{isMatch:!1,score:1}}},bs=[];function xs(...g){bs.push(...g)}function Fe(g,s){for(let t=0,e=bs.length;t<e;t+=1){let i=bs[t];if(i.condition(g,s))return new i(g,s)}return new ae(g,s)}var $e={AND:"$and",OR:"$or"},Es={PATH:"$path",PATTERN:"$val"},Ss=g=>!!(g[$e.AND]||g[$e.OR]),Ra=g=>!!g[Es.PATH],Aa=g=>!Dt(g)&&li(g)&&!Ss(g),ri=g=>({[$e.AND]:Object.keys(g).map(s=>({[s]:g[s]}))});function pi(g,s,{auto:t=!0}={}){let e=i=>{if(J(i)){let o={keyId:null,pattern:i};return t&&(o.searcher=Fe(i,s)),o}let a=Object.keys(i),n=Ra(i);if(!n&&a.length>1&&!Ss(i))return e(ri(i));if(Aa(i)){let o=n?i[Es.PATH]:a[0],l=n?i[Es.PATTERN]:i[o];if(!J(l))throw new Error(ca(o));let c={keyId:us(o),pattern:l};return t&&(c.searcher=Fe(l,s)),c}let r={children:[],operator:a[0]};return a.forEach(o=>{let l=i[o];Dt(l)&&l.forEach(c=>{r.children.push(e(c))})}),r};return Ss(g)||(g=ri(g)),e(g)}function Ds(g,{ignoreFieldNorm:s=B.ignoreFieldNorm}){let t=1;return g.forEach(({key:e,norm:i,score:a})=>{let n=e?e.weight:null;t*=Math.pow(a===0&&n?Number.EPSILON:a,(n||1)*(s?1:i))}),t}function Ia(g,{ignoreFieldNorm:s=B.ignoreFieldNorm}){g.forEach(t=>{t.score=Ds(t.matches,{ignoreFieldNorm:s})})}var ks=class{constructor(s){this.limit=s,this.heap=[]}get size(){return this.heap.length}shouldInsert(s){return this.size<this.limit||s<this.heap[0].score}insert(s){this.size<this.limit?(this.heap.push(s),this._bubbleUp(this.size-1)):s.score<this.heap[0].score&&(this.heap[0]=s,this._sinkDown(0))}extractSorted(s){return this.heap.sort(s)}_bubbleUp(s){let t=this.heap;for(;s>0;){let e=s-1>>1;if(t[s].score<=t[e].score)break;let i=t[s];t[s]=t[e],t[e]=i,s=e}}_sinkDown(s){let t=this.heap,e=t.length,i=s;do{s=i;let a=2*s+1,n=2*s+2;if(a<e&&t[a].score>t[i].score&&(i=a),n<e&&t[n].score>t[i].score&&(i=n),i!==s){let r=t[s];t[s]=t[i],t[i]=r}}while(i!==s)}};function Ba(g,s){let t=g.matches;s.matches=[],X(t)&&t.forEach(e=>{if(!X(e.indices)||!e.indices.length)return;let{indices:i,value:a}=e,n={indices:i,value:a};e.key&&(n.key=e.key.src),e.idx>-1&&(n.refIndex=e.idx),s.matches.push(n)})}function Pa(g,s){s.score=g.score}function Fa(g,s,{includeMatches:t=B.includeMatches,includeScore:e=B.includeScore}={}){let i=[];return t&&i.push(Ba),e&&i.push(Pa),g.map(a=>{let{idx:n}=a,r={item:s[n],refIndex:n};return i.length&&i.forEach(o=>{o(a,r)}),r})}var $a=/\b\w+\b/g;function Ts({isCaseSensitive:g=!1,ignoreDiacritics:s=!1}={}){return{tokenize(t){return g||(t=t.toLowerCase()),s&&(t=ie(t)),t.match($a)||[]}}}function Wa(g,s,t){var r;let e=new Map,i=new Map,a=0;function n(o,l,c,d){let u=t.tokenize(o);if(!u.length)return;a++;let h=new Map;for(let p of u)h.set(p,(h.get(p)||0)+1);for(let[p,m]of h){let v={docIdx:l,keyIdx:c,subIdx:d,tf:m},f=e.get(p);f||(f=[],e.set(p,f)),f.push(v),i.set(p,(i.get(p)||0)+1)}}for(let o of g){let{i:l,v:c,$:d}=o;if(c!==void 0){n(c,l,-1,-1);continue}if(d)for(let u=0;u<s;u++){let h=d[u];if(h)if(Array.isArray(h))for(let p of h)n(p.v,l,u,(r=p.i)!=null?r:-1);else n(h.v,l,u,-1)}}return{terms:e,fieldCount:a,df:i}}function Oa(g,s,t,e){var o;let{i,v:a,$:n}=s;function r(l,c,d){let u=e.tokenize(l);if(!u.length)return;g.fieldCount++;let h=new Map;for(let p of u)h.set(p,(h.get(p)||0)+1);for(let[p,m]of h){let v={docIdx:i,keyIdx:c,subIdx:d,tf:m},f=g.terms.get(p);f||(f=[],g.terms.set(p,f)),f.push(v),g.df.set(p,(g.df.get(p)||0)+1)}}if(a!==void 0){r(a,-1,-1);return}if(n)for(let l=0;l<t;l++){let c=n[l];if(c)if(Array.isArray(c))for(let d of c)r(d.v,l,(o=d.i)!=null?o:-1);else r(c.v,l,-1)}}function oi(g,s){for(let[t,e]of g.terms){let i=e.filter(n=>n.docIdx!==s),a=e.length-i.length;a>0&&(g.fieldCount-=a,g.df.set(t,(g.df.get(t)||0)-a),i.length===0?(g.terms.delete(t),g.df.delete(t)):g.terms.set(t,i))}}var st=class{constructor(s,t,e){this.options={...B,...t},this.options.useExtendedSearch,this.options.useTokenSearch,this._keyStore=new ds(this.options.keys),this._docs=s,this._myIndex=null,this._invertedIndex=null,this.setCollection(s,e),this._lastQuery=null,this._lastSearcher=null}_getSearcher(s){if(this._lastQuery===s)return this._lastSearcher;let t=this._invertedIndex?{...this.options,_invertedIndex:this._invertedIndex}:this.options,e=Fe(s,t);return this._lastQuery=s,this._lastSearcher=e,e}setCollection(s,t){if(this._docs=s,t&&!(t instanceof se))throw new Error(la);if(this._myIndex=t||ui(this.options.keys,this._docs,{getFn:this.options.getFn,fieldNormWeight:this.options.fieldNormWeight}),this.options.useTokenSearch){let e=Ts({isCaseSensitive:this.options.isCaseSensitive,ignoreDiacritics:this.options.ignoreDiacritics});this._invertedIndex=Wa(this._myIndex.records,this._myIndex.keys.length,e)}}add(s){if(X(s)&&(this._docs.push(s),this._myIndex.add(s),this._invertedIndex)){let t=this._myIndex.records[this._myIndex.records.length-1],e=Ts({isCaseSensitive:this.options.isCaseSensitive,ignoreDiacritics:this.options.ignoreDiacritics});Oa(this._invertedIndex,t,this._myIndex.keys.length,e)}}remove(s=()=>!1){let t=[],e=[];for(let i=0,a=this._docs.length;i<a;i+=1)s(this._docs[i],i)&&(t.push(this._docs[i]),e.push(i));if(e.length){if(this._invertedIndex)for(let i of e)oi(this._invertedIndex,i);for(let i=e.length-1;i>=0;i-=1)this._docs.splice(e[i],1);this._myIndex.removeAll(e)}return t}removeAt(s){this._invertedIndex&&oi(this._invertedIndex,s);let t=this._docs.splice(s,1)[0];return this._myIndex.removeAt(s),t}getIndex(){return this._myIndex}search(s,t){let{limit:e=-1}=t||{},{includeMatches:i,includeScore:a,shouldSort:n,sortFn:r,ignoreFieldNorm:o}=this.options;if(J(s)&&!s.trim()){let d=this._docs.map((u,h)=>({item:u,refIndex:h}));return Ie(e)&&e>-1&&(d=d.slice(0,e)),d}let l=Ie(e)&&e>0&&J(s),c;if(l){let d=new ks(e);J(this._docs[0])?this._searchStringList(s,{heap:d,ignoreFieldNorm:o}):this._searchObjectList(s,{heap:d,ignoreFieldNorm:o}),c=d.extractSorted(r)}else c=J(s)?J(this._docs[0])?this._searchStringList(s):this._searchObjectList(s):this._searchLogical(s),Ia(c,{ignoreFieldNorm:o}),n&&c.sort(r),Ie(e)&&e>-1&&(c=c.slice(0,e));return Fa(c,this._docs,{includeMatches:i,includeScore:a})}_searchStringList(s,{heap:t,ignoreFieldNorm:e}={}){let i=this._getSearcher(s),{records:a}=this._myIndex,n=t?null:[];return a.forEach(({v:r,i:o,n:l})=>{if(!X(r))return;let{isMatch:c,score:d,indices:u}=i.searchIn(r);if(c){let h={item:r,idx:o,matches:[{score:d,value:r,norm:l,indices:u}]};t?(h.score=Ds(h.matches,{ignoreFieldNorm:e}),t.shouldInsert(h.score)&&t.insert(h)):n.push(h)}}),n}_searchLogical(s){let t=pi(s,this.options),e=(r,o,l)=>{if(!("children"in r)){let{keyId:h,searcher:p}=r,m;return h===null?(m=[],this._myIndex.keys.forEach((v,f)=>{m.push(...this._findMatches({key:v,value:o[f],searcher:p}))})):m=this._findMatches({key:this._keyStore.get(h),value:this._myIndex.getValueForItemAtKeyId(o,h),searcher:p}),m&&m.length?[{idx:l,item:o,matches:m}]:[]}let{children:c,operator:d}=r,u=[];for(let h=0,p=c.length;h<p;h+=1){let m=c[h],v=e(m,o,l);if(v.length)u.push(...v);else if(d===$e.AND)return[]}return u},i=this._myIndex.records,a=new Map,n=[];return i.forEach(({$:r,i:o})=>{if(X(r)){let l=e(t,r,o);l.length&&(a.has(o)||(a.set(o,{idx:o,item:r,matches:[]}),n.push(a.get(o))),l.forEach(({matches:c})=>{a.get(o).matches.push(...c)}))}}),n}_searchObjectList(s,{heap:t,ignoreFieldNorm:e}={}){let i=this._getSearcher(s),{keys:a,records:n}=this._myIndex,r=t?null:[];return n.forEach(({$:o,i:l})=>{if(!X(o))return;let c=[],d=!1,u=!1;if(a.forEach((h,p)=>{let m=this._findMatches({key:h,value:o[p],searcher:i});m.length?(c.push(...m),m[0].hasInverse&&(u=!0)):d=!0}),!(u&&d)&&c.length){let h={idx:l,item:o,matches:c};t?(h.score=Ds(h.matches,{ignoreFieldNorm:e}),t.shouldInsert(h.score)&&t.insert(h)):r.push(h)}}),r}_findMatches({key:s,value:t,searcher:e}){if(!X(t))return[];let i=[];if(Dt(t))t.forEach(({v:a,i:n,n:r})=>{if(!X(a))return;let{isMatch:o,score:l,indices:c,hasInverse:d}=e.searchIn(a);o&&i.push({score:l,key:s,value:a,idx:n,norm:r,indices:c,hasInverse:d})});else{let{v:a,n}=t,{isMatch:r,score:o,indices:l,hasInverse:c}=e.searchIn(a);r&&i.push({score:o,key:s,value:a,norm:n,indices:l,hasInverse:c})}return i}},Cs=class{static condition(s,t){return t.useTokenSearch}constructor(s,t){this.options=t,this.analyzer=Ts({isCaseSensitive:t.isCaseSensitive,ignoreDiacritics:t.ignoreDiacritics});let e=this.analyzer.tokenize(s),i=t._invertedIndex,{df:a,fieldCount:n}=i;this.termSearchers=[],this.idfWeights=[];for(let r of e){this.termSearchers.push(new ae(r,{location:t.location,threshold:t.threshold,distance:t.distance,includeMatches:t.includeMatches,findAllMatches:t.findAllMatches,minMatchCharLength:t.minMatchCharLength,isCaseSensitive:t.isCaseSensitive,ignoreDiacritics:t.ignoreDiacritics,ignoreLocation:!0}));let o=a.get(r)||0,l=Math.log(1+(n-o+.5)/(o+.5));this.idfWeights.push(l)}}searchIn(s){if(!this.termSearchers.length)return{isMatch:!1,score:1};let t=[],e=0,i=0,a=0;for(let o=0;o<this.termSearchers.length;o++){let l=this.termSearchers[o].searchIn(s),c=this.idfWeights[o];i+=c,l.isMatch&&(a++,e+=c*(1-l.score),l.indices&&t.push(...l.indices))}if(a===0)return{isMatch:!1,score:1};let n=i>0?1-e/i:0,r={isMatch:!0,score:Math.max(.001,n)};return this.options.includeMatches&&t.length&&(r.indices=Ms(t)),r}};st.version="7.3.0";st.createIndex=ui;st.parseIndex=ba;st.config=B;st.match=function(g,s,t){return Fe(g,{...B,...t}).searchIn(s)};st.parseQuery=pi;xs(ys);xs(Cs);st.use=function(...g){g.forEach(s=>xs(s))};var rt=require("obsidian");function Na(){return{title:"",author:"",status:"Plan to Read",rating:0,pagesRead:0,totalPages:0,chaptersRead:0,totalChapters:0,volumesRead:0,totalVolumes:0,coverUrl:"",googleBooksId:"",malId:"",vaultPage:"",dateStarted:null,dateFinished:null,releaseDate:null}}var We=class extends rt.FuzzySuggestModal{constructor(s,t,e){super(s),this.files=t,this.onPick=e,this.setPlaceholder("Pick a vault note...")}getItems(){return this.files}getItemText(s){return s.path}onChooseItem(s){this.onPick(s)}},Ot=class extends rt.Modal{constructor(t,e,i,a,n,r,o){var l;super(t);this.starsWrap=null;this.lookupResultsEl=null;this.formEl=null;this.openVaultBtn=null;this.linkVaultBtn=null;this.lookupSearchGen=0;this.selectGen=0;if(this.plugin=e,this.readingData=i,this.mode=a,this.state=r?{...r.state}:Na(),!r){let c=i.getSettings().defaultStatus;c&&(this.state.status=c)}this.existingId=(l=r==null?void 0:r.id)!=null?l:null,this.onSaved=n,this.prefillSearch=o!=null?o:""}onOpen(){var e;let t=(e=this.plugin.settings.colorTheme)!=null?e:"default";this.modalEl.setAttribute("data-theme",t),this.contentEl.setAttribute("data-theme",t),this.contentEl.addClass("wl-view"),this.contentEl.addClass("wl-reading-modal"),this.contentEl.addClass(this.mode==="book"?"wl-reading-modal-book":"wl-reading-modal-manga"),this.titleEl.setText(this.headerTitle()),this.buildUI()}onClose(){this.contentEl.empty()}headerTitle(){return this.existingId?this.mode==="book"?"Edit book":"Edit manga":this.mode==="book"?"Add book":"Add manga"}buildUI(){let t=this.contentEl;t.empty(),this.renderHeaderRow(t),this.renderLookupBar(t),t.createDiv({cls:"wl-reading-modal-divider"}),this.formEl=t.createDiv({cls:"wl-reading-form-wrap"}),this.renderForm(this.formEl),this.renderFooter(t)}renderHeaderRow(t){let e=t.createDiv({cls:"wl-reading-modal-header"}),i=e.createDiv({cls:"wl-reading-modal-header-left"});i.createSpan({cls:"wl-reading-modal-header-icon",text:this.mode==="book"?"\u{1F4D6}":"\u{1F4D3}"}),i.createSpan({cls:"wl-reading-modal-header-title",text:this.headerTitle()});let a=e.createDiv({cls:"wl-reading-modal-header-actions"});this.openVaultBtn=a.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Open"}),this.openVaultBtn.addEventListener("click",()=>this.openLinkedVaultPage()),this.refreshVaultButtons()}refreshVaultButtons(){if(this.linkVaultBtn&&(this.linkVaultBtn.textContent=this.state.vaultPage?"Change link":"Link",this.linkVaultBtn.title=this.state.vaultPage?`Linked: ${this.state.vaultPage}`:"Link a vault note to this entry"),this.openVaultBtn){let t=!!this.state.vaultPage;this.openVaultBtn.disabled=!t,this.openVaultBtn.toggleClass("is-hidden",!t)}}openLinkedVaultPage(){let t=this.state.vaultPage;if(!t)return;let e=this.plugin.app.vault.getAbstractFileByPath(t);e instanceof rt.TFile?(this.plugin.app.workspace.getLeaf("tab").openFile(e),this.close()):new rt.Notice("Linked vault page no longer exists.")}renderLookupBar(t){let e=t.createDiv({cls:"wl-reading-lookup"}),i=e.createEl("input",{cls:"wl-modal-input wl-reading-lookup-input",attr:{type:"text",placeholder:this.mode==="book"?"Search by title or ISBN...":"Search by title or MAL ID..."}}),a=e.createEl("button",{cls:"wl-btn wl-reading-lookup-btn",text:"Fetch"});this.lookupResultsEl=t.createDiv({cls:"wl-reading-lookup-results"}),this.prefillSearch&&(i.value=this.prefillSearch);let n=()=>{let r=i.value.trim();if(!r){new rt.Notice("Enter a search term first.");return}this.performLookup(r)};a.addEventListener("click",n),i.addEventListener("keydown",r=>{r.key==="Enter"&&(r.preventDefault(),n())})}async performLookup(t){let e=++this.lookupSearchGen;if(this.lookupResultsEl){this.lookupResultsEl.empty(),this.lookupResultsEl.createDiv({cls:"wl-reading-lookup-loading",text:"Searching..."});try{if(this.mode==="book"){if(!this.plugin.apiService.hasGoogleBooksKey()){if(e!==this.lookupSearchGen)return;this.lookupResultsEl.empty(),this.lookupResultsEl.createDiv({cls:"wl-reading-lookup-empty",text:"Google Books API key required \u2014 add one in Settings \u2192 API \u2192 Books."});return}let i=await this.plugin.apiService.searchGoogleBooks(t);if(e!==this.lookupSearchGen)return;this.renderBookLookupResults(i)}else{let i=/^\d+$/.test(t),a;if(i){let n=await this.plugin.apiService.getMangaByMalId(parseInt(t,10));if(e!==this.lookupSearchGen)return;a=n?[n]:[]}else if(a=await this.plugin.apiService.searchManga(t),e!==this.lookupSearchGen)return;this.renderMangaLookupResults(a)}}catch(i){if(e!==this.lookupSearchGen)return;this.lookupResultsEl.empty();let a=this.mode==="book"?Et(i):"Lookup failed. Check your connection.";this.lookupResultsEl.createDiv({cls:"wl-reading-lookup-empty",text:a})}}}renderBookLookupResults(t){if(this.lookupResultsEl){if(this.lookupResultsEl.empty(),t.length===0){this.lookupResultsEl.createDiv({cls:"wl-reading-lookup-empty",text:"No matches."});return}for(let e of t){let i=this.lookupResultsEl.createDiv({cls:"wl-reading-lookup-item"});this.renderLookupItemTitle(i,e.title,e.url);let a=[e.author||"Unknown author",e.year?String(e.year):""].filter(Boolean).join(" \xB7 ");i.createDiv({cls:"wl-reading-lookup-item-meta",text:a}),i.addEventListener("click",()=>this.applyBookResult(e))}}}renderLookupItemTitle(t,e,i){let a=t.createDiv({cls:"wl-reading-lookup-item-title-row"});if(a.createDiv({cls:"wl-reading-lookup-item-title",text:e||"(untitled)"}),i){let n=a.createEl("a",{cls:"wl-acc-link-icon",text:"\u{1F310}"});n.href=i,n.title="Open external link",n.target="_blank",n.rel="noopener noreferrer",n.addEventListener("click",r=>r.stopPropagation())}}renderMangaLookupResults(t){if(this.lookupResultsEl){if(this.lookupResultsEl.empty(),t.length===0){this.lookupResultsEl.createDiv({cls:"wl-reading-lookup-empty",text:"No matches."});return}for(let e of t){let i=this.lookupResultsEl.createDiv({cls:"wl-reading-lookup-item"});this.renderLookupItemTitle(i,e.title,e.url);let a=[e.author||"Unknown author",e.year?String(e.year):""].filter(Boolean).join(" \xB7 ");i.createDiv({cls:"wl-reading-lookup-item-meta",text:a}),i.addEventListener("click",()=>this.applyMangaResult(e))}}}applyBookResult(t){var i;let e=++this.selectGen;this.state.title=t.title,this.state.author=t.author,this.state.totalPages=t.totalPages,this.state.coverUrl=t.coverUrl,this.state.googleBooksId=t.googleBooksId,this.state.releaseDate=(i=t.releaseDate)!=null?i:"",this.refreshForm(),t.googleBooksId&&(async()=>{try{let a=await this.plugin.apiService.getGoogleBookById(t.googleBooksId);if(e!==this.selectGen||!a)return;a.totalPages>0&&(this.state.totalPages=a.totalPages),a.coverUrl&&(this.state.coverUrl=a.coverUrl),this.refreshForm()}catch(a){}})()}applyMangaResult(t){var i;let e=++this.selectGen;this.state.title=t.title,this.state.author=t.author,this.state.totalChapters=t.totalChapters,this.state.totalVolumes=t.totalVolumes,this.state.coverUrl=t.coverUrl,this.state.malId=t.malId>0?String(t.malId):"",this.state.releaseDate=(i=t.releaseDate)!=null?i:"",this.refreshForm(),t.malId>0&&(async()=>{let a=await this.plugin.apiService.getMangaByMalId(t.malId);e!==this.selectGen||!a||(this.state.totalChapters=a.totalChapters>0?a.totalChapters:this.state.totalChapters,this.state.totalVolumes=a.totalVolumes>0?a.totalVolumes:this.state.totalVolumes,this.refreshForm())})()}refreshForm(){this.formEl&&(this.formEl.empty(),this.renderForm(this.formEl))}renderForm(t){let e=t.createDiv({cls:"wl-reading-form"});this.renderTextField(e,"Title","Book title",this.state.title,i=>{this.state.title=i}),this.renderTextField(e,"Author","Author name",this.state.author,i=>{this.state.author=i}),this.renderStatusRatingRow(e),this.renderProgressOptionalGrid(e)}renderProgressOptionalGrid(t){let e=t.createDiv({cls:"wl-reading-form-grid"}),i=e.createDiv({cls:"wl-reading-form-grid-col"});i.createDiv({cls:"wl-reading-section-label",text:"Progress"}),this.mode==="book"?(this.renderTwoNumberRow(i,{label:"Pages read",value:this.state.pagesRead,onChange:n=>{this.state.pagesRead=n}},{label:"Chapters read",value:this.state.chaptersRead,onChange:n=>{this.state.chaptersRead=n}}),this.renderTwoNumberRow(i,{label:"Total pages",value:this.state.totalPages,onChange:n=>{this.state.totalPages=n}},{label:"Total chapters",value:this.state.totalChapters,onChange:n=>{this.state.totalChapters=n}})):(this.renderTwoNumberRow(i,{label:"Chapters read",value:this.state.chaptersRead,onChange:n=>{this.state.chaptersRead=n}},{label:"Volumes read",value:this.state.volumesRead,onChange:n=>{this.state.volumesRead=n}}),this.renderTwoNumberRow(i,{label:"Total chapters",value:this.state.totalChapters,onChange:n=>{this.state.totalChapters=n}},{label:"Total volumes",value:this.state.totalVolumes,onChange:n=>{this.state.totalVolumes=n}}));let a=e.createDiv({cls:"wl-reading-form-grid-col"});a.createDiv({cls:"wl-reading-section-label",text:"Optional"}),this.renderTextField(a,"Cover URL","https://...",this.state.coverUrl,n=>{this.state.coverUrl=n}),this.renderDateField(a,"Start date",this.state.dateStarted,n=>{this.state.dateStarted=n}),this.renderDateField(a,"Release date",this.state.releaseDate,n=>{this.state.releaseDate=n})}renderTextField(t,e,i,a,n){let r=t.createDiv({cls:"wl-reading-row"});r.createSpan({cls:"wl-reading-label",text:e});let o=r.createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:i}});o.value=a,o.addEventListener("input",()=>n(o.value))}renderStatusRatingRow(t){let e=t.createDiv({cls:"wl-reading-status-rating-row"}),i=e.createDiv({cls:"wl-reading-inline-group"});i.createSpan({cls:"wl-reading-label",text:"Status"});let a=i.createEl("select",{cls:"wl-select wl-reading-status-select"});for(let r of lt){let o=a.createEl("option",{text:r,value:r});r===this.state.status&&(o.selected=!0)}a.addEventListener("change",()=>{this.state.status=a.value});let n=e.createDiv({cls:"wl-reading-inline-group"});n.createSpan({cls:"wl-reading-label",text:"Rating"}),this.starsWrap=n.createDiv({cls:"wl-stars wl-reading-stars"}),this.renderStars()}renderStars(){if(this.starsWrap){this.starsWrap.empty();for(let t=1;t<=5;t++)this.starsWrap.createSpan({cls:`wl-star${this.state.rating>=t?" is-active":""}`,text:"\u2605"}).addEventListener("click",()=>{this.state.rating=this.state.rating===t?0:t,this.renderStars()})}}renderTwoNumberRow(t,e,i){let a=t.createDiv({cls:"wl-reading-row wl-reading-row-split"});this.renderNumberCol(a,e),this.renderNumberCol(a,i)}renderNumberCol(t,e){let i=t.createDiv({cls:"wl-reading-col"});i.createSpan({cls:"wl-reading-label",text:e.label});let a=i.createEl("input",{cls:"wl-modal-input wl-modal-input-sm",attr:{type:"number",min:"0"}});a.value=String(e.value),a.addEventListener("input",()=>{let n=parseInt(a.value,10);e.onChange(isNaN(n)||n<0?0:n)})}renderDateField(t,e,i,a){let n=t.createDiv({cls:"wl-reading-row"});n.createSpan({cls:"wl-reading-label",text:e});let r=n.createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"DD/MM/YYYY",maxlength:"10"}});r.value=i?Q(i):"",r.addEventListener("change",()=>{let o=r.value.trim();if(!o){r.removeClass("wl-input-error"),a(null);return}let l=q(o);l?(r.removeClass("wl-input-error"),a(l)):r.addClass("wl-input-error")})}renderFooter(t){let e=t.createDiv({cls:"wl-reading-modal-footer"});e.createEl("button",{cls:"wl-btn",text:"Cancel"}).addEventListener("click",()=>this.close()),e.createEl("button",{cls:"wl-reading-add-btn wl-btn-success wl-reading-modal-save",text:this.saveButtonLabel()}).addEventListener("click",()=>void this.save())}saveButtonLabel(){return this.existingId?"Save changes":this.mode==="book"?"Add book":"Add manga"}async save(){let t=this.state.title.trim();if(!t){new rt.Notice("Please enter a title.");return}this.mode==="book"?await this.saveBook(t):await this.saveManga(t),this.onSaved(),this.close()}async saveBook(t){if(this.existingId){let e=this.readingData.getBook(this.existingId);if(!e){new rt.Notice("Original book no longer exists.");return}let i={...e,title:t,author:this.state.author.trim(),status:this.state.status,rating:this.state.rating,pagesRead:this.state.pagesRead,totalPages:this.state.totalPages,chaptersRead:this.state.chaptersRead,totalChapters:this.state.totalChapters,coverUrl:this.state.coverUrl.trim(),googleBooksId:this.state.googleBooksId.trim(),vaultPage:this.state.vaultPage,dateStarted:this.state.dateStarted,dateFinished:this.state.dateFinished,releaseDate:this.state.releaseDate};await this.readingData.updateBook(i)}else{let e=new Date().toISOString(),i={id:this.readingData.generateBookId(t),title:t,author:this.state.author.trim(),status:this.state.status,rating:this.state.rating,pagesRead:this.state.pagesRead,totalPages:this.state.totalPages,chaptersRead:this.state.chaptersRead,totalChapters:this.state.totalChapters,coverUrl:this.state.coverUrl.trim(),googleBooksId:this.state.googleBooksId.trim(),vaultPage:this.state.vaultPage,dateStarted:this.state.dateStarted,dateFinished:this.state.dateFinished,releaseDate:this.state.releaseDate,dateAdded:e,dateModified:e,customFields:{}};await this.readingData.addBook(i)}}async saveManga(t){if(this.existingId){let e=this.readingData.getManga(this.existingId);if(!e){new rt.Notice("Original manga no longer exists.");return}let i={...e,title:t,author:this.state.author.trim(),status:this.state.status,rating:this.state.rating,chaptersRead:this.state.chaptersRead,totalChapters:this.state.totalChapters,volumesRead:this.state.volumesRead,totalVolumes:this.state.totalVolumes,coverUrl:this.state.coverUrl.trim(),malId:this.state.malId.trim(),vaultPage:this.state.vaultPage,dateStarted:this.state.dateStarted,dateFinished:this.state.dateFinished,releaseDate:this.state.releaseDate};await this.readingData.updateManga(i)}else{let e=new Date().toISOString(),i={id:this.readingData.generateMangaId(t),title:t,author:this.state.author.trim(),status:this.state.status,rating:this.state.rating,chaptersRead:this.state.chaptersRead,totalChapters:this.state.totalChapters,volumesRead:this.state.volumesRead,totalVolumes:this.state.totalVolumes,coverUrl:this.state.coverUrl.trim(),malId:this.state.malId.trim(),vaultPage:this.state.vaultPage,dateStarted:this.state.dateStarted,dateFinished:this.state.dateFinished,releaseDate:this.state.releaseDate,dateAdded:e,dateModified:e,customFields:{}};await this.readingData.addManga(i)}}};var gi=require("obsidian"),Oe=class extends gi.Modal{constructor(t,e,i){super(t);this.draftText=e;this.onChoice=i}onOpen(){let{contentEl:t}=this;t.empty(),t.addClass("wl-draft-choice-modal"),this.titleEl.setText("Add draft"),t.createDiv({cls:"wl-draft-choice-subtitle",text:this.draftText});let e=t.createDiv({cls:"wl-draft-choice-grid"}),i=(a,n)=>{let r=e.createEl("button",{cls:"wl-draft-choice-btn"});r.createDiv({cls:"wl-draft-choice-label",text:a}),r.addEventListener("click",()=>{this.close(),this.onChoice(n)})};i("Add in Watchlist","watchlist"),i("Add book","book"),i("Add manga","manga")}onClose(){this.contentEl.empty()}};var ne=class g{constructor(s,t,e,i){this.eventRef=null;this.destroyed=!1;this.scanDebounceTimer=null;this.lastScanEntries=[];this.persistState={dismissed:[],added:[],firstSeen:{},titleDisplay:{}};this.containerEl=s,this.plugin=t,this.dataManager=e,this.onCountChange=i}async render(){if(this.destroyed=!1,await this.loadPersistState(),this.destroyed)return;let s=await this.scanVault();this.destroyed||(this.renderUI(s),this.registerChangeListener())}destroy(){this.destroyed=!0,this.scanDebounceTimer&&(window.clearTimeout(this.scanDebounceTimer),this.scanDebounceTimer=null),this.eventRef&&(this.plugin.app.metadataCache.offref(this.eventRef),this.eventRef=null)}async loadPersistState(){var e,i,a,n;let t=this.plugin.dataManager.getData().drafts;t&&(this.persistState={dismissed:(e=t.dismissed)!=null?e:[],added:(i=t.added)!=null?i:[],firstSeen:(a=t.firstSeen)!=null?a:{},titleDisplay:(n=t.titleDisplay)!=null?n:{}})}async savePersistState(){let s=this.plugin.dataManager.getData();s.drafts=this.persistState,this.plugin.dataManager.queueSave()}getTag(){var s;return(s=this.plugin.settings.draftsVaultTag)!=null?s:"#watchlog"}static async scanTaggedFiles(s,t){var a,n;let e=s.app.vault.getMarkdownFiles(),i=new Map;for(let r of e){let o=s.app.metadataCache.getFileCache(r);if((n=(a=o==null?void 0:o.tags)==null?void 0:a.some(c=>c.tag===t))!=null&&n)try{let d=(await s.app.vault.cachedRead(r)).split(` +`);for(let u of d){if(!u.includes(t))continue;let h=u.indexOf(t),p=u.slice(h+t.length).trim();if(!p)continue;let m=p.split(",");for(let v of m){let f=v.trim();if(!f||f.length>100)continue;let y=f.toLowerCase();i.has(y)||i.set(y,{sources:new Set,displayTitle:f}),i.get(y).sources.add(r.basename)}}}catch(c){}}return i}async scanVault(){var n,r;let s=this.getTag(),t=await g.scanTaggedFiles(this.plugin,s),e=!1,i=new Date().toISOString();for(let[o,{displayTitle:l}]of t)this.persistState.firstSeen[o]||(this.persistState.firstSeen[o]=i,e=!0),this.persistState.titleDisplay[o]||(this.persistState.titleDisplay[o]=l,e=!0);e&&await this.savePersistState();let a=[];for(let[o,{sources:l}]of t)this.persistState.dismissed.includes(o)||a.push({titleKey:o,titleDisplay:(n=this.persistState.titleDisplay[o])!=null?n:o,sources:Array.from(l),firstSeen:(r=this.persistState.firstSeen[o])!=null?r:i,added:this.persistState.added.includes(o)});return a.sort((o,l)=>o.firstSeen.localeCompare(l.firstSeen)),this.lastScanEntries=a,a}triggerDebouncedRender(){this.scanDebounceTimer&&window.clearTimeout(this.scanDebounceTimer),this.scanDebounceTimer=window.setTimeout(()=>{this.scanDebounceTimer=null,this.render()},500)}registerChangeListener(){this.eventRef&&this.plugin.app.metadataCache.offref(this.eventRef),this.eventRef=this.plugin.app.metadataCache.on("changed",s=>{this.triggerDebouncedRender()})}static buildFuse(s){return new st(s,{keys:["title"],threshold:.35,includeScore:!0})}static fuzzyMatchesWatchlist(s,t){var i,a;let e=t.search(s);return e.length>0&&((a=(i=e[0])==null?void 0:i.score)!=null?a:1)<=.35}static async computePendingCount(s,t){var u,h,p,m,v;let e=(u=s.settings.draftsVaultTag)!=null?u:"#watchlog",i=await g.scanTaggedFiles(s,e),n=s.dataManager.getData().drafts,r=new Set((h=n==null?void 0:n.dismissed)!=null?h:[]),o=new Set((p=n==null?void 0:n.added)!=null?p:[]),l=(m=n==null?void 0:n.titleDisplay)!=null?m:{},c=g.buildFuse(t.getTitles()),d=0;for(let[f,{displayTitle:y}]of i){if(r.has(f)||o.has(f))continue;let w=(v=l[f])!=null?v:y;g.fuzzyMatchesWatchlist(w,c)||d++}return d}renderUI(s){var c;let t=this.containerEl;t.empty();let e=this.getTag(),i=this.dataManager.getTitles(),a=g.buildFuse(i),n=new Map;for(let d of s)d.added?n.set(d.titleKey,!1):n.set(d.titleKey,g.fuzzyMatchesWatchlist(d.titleDisplay,a));let r=s.filter(d=>d.added?!1:!n.get(d.titleKey)).length;this.onCountChange(r),this.plugin.settings.showHintBanners&&t.createDiv({cls:"wl-drafts-notice",text:`\u26A0 Write ${e} Movie Name in any note and it appears here automatically. Hit Add when you're ready to add it to your Watchlist.`});let o=t.createDiv({cls:"wl-list-title-wrap"});if(o.createSpan({cls:"wl-list-count",text:String(r)}),o.createSpan({cls:"wl-drafts-count-label",text:` Pending draft${r!==1?"s":""}`}),s.length===0){t.createDiv({cls:"wl-drafts-empty",text:`No drafts found. Add ${e} followed by a title in any vault note.`});return}s.sort((d,u)=>{var m,v;let h=(m=n.get(d.titleKey))!=null?m:!1,p=(v=n.get(u.titleKey))!=null?v:!1;return h!==p?h?1:-1:d.firstSeen.localeCompare(u.firstSeen)});let l=t.createDiv({cls:"wl-drafts-cards"});for(let d of s)this.renderCard(l,d,(c=n.get(d.titleKey))!=null?c:!1)}renderCard(s,t,e){let i="wl-drafts-card";e&&(i+=" wl-drafts-card-watchlist"),t.added&&(i+=" wl-drafts-card-added");let a=s.createDiv({cls:i});a.createDiv({cls:"wl-drafts-card-title",text:t.titleDisplay});let n=a.createDiv({cls:"wl-drafts-card-source"});if(t.sources.length>0){let c=t.sources[0];n.createSpan({cls:"wl-drafts-source-link",text:`[[${c}]]`}).addEventListener("click",u=>{u.stopPropagation(),this.plugin.app.workspace.openLinkText(c,"")}),t.sources.length>1&&n.createSpan({cls:"wl-drafts-source-count",text:` (${t.sources.length})`,attr:{title:t.sources.join(` +`)}})}let r=a.createDiv({cls:"wl-drafts-card-dup"});e&&r.createSpan({text:"In Watchlist",attr:{title:"This title already exists in your Watchlist"}});let o=a.createDiv({cls:"wl-drafts-card-actions"});t.added||e?o.createSpan({cls:"wl-drafts-added-label",text:"Added"}):o.createEl("button",{cls:"wl-btn wl-btn-sm wl-btn-primary",text:"Add"}).addEventListener("click",()=>this.openAddModal(t)),o.createEl("button",{cls:"wl-btn wl-btn-sm wl-drafts-dismiss",text:"\u2715",attr:{title:"Dismiss"}}).addEventListener("click",()=>void this.dismissEntry(t.titleKey))}rerenderFromCache(){let s=this.lastScanEntries.filter(t=>!this.persistState.dismissed.includes(t.titleKey)).map(t=>({...t,added:this.persistState.added.includes(t.titleKey)}));this.renderUI(s)}async dismissEntry(s){this.persistState.dismissed.includes(s)||this.persistState.dismissed.push(s),this.persistState.added=this.persistState.added.filter(t=>t!==s),await this.savePersistState(),this.rerenderFromCache()}openAddModal(s){new Oe(this.plugin.app,s.titleDisplay,t=>{t==="watchlist"?this.openWatchlistAddModal(s):this.openReadingAddModal(s,t)}).open()}openWatchlistAddModal(s){new ht(this.plugin.app,this.plugin,this.dataManager,()=>void this.afterAdded(s.titleKey),{searchQuery:s.titleDisplay,title:s.titleDisplay,type:"Anime",episodes:0,duration:0,releaseDate:"",link:"",seasons:[]}).open()}openReadingAddModal(s,t){new Ot(this.plugin.app,this.plugin,this.plugin.readingDataManager,t,()=>void this.afterAdded(s.titleKey),void 0,s.titleDisplay).open()}async afterAdded(s){var e;((e=this.plugin.settings.draftsAfterAdding)!=null?e:"keep")==="remove"?await this.dismissEntry(s):(this.persistState.added.includes(s)||this.persistState.added.push(s),await this.savePersistState(),this.rerenderFromCache())}};var mt=require("obsidian");var it=require("obsidian");var Ne=require("obsidian");var Nt=class extends Ne.Modal{constructor(t,e,i,a,n){super(t);this.listEl=null;this.styleGroupEl=null;this.dragFromIndex=-1;this.plugin=e,this.readingData=i,this.kind=a,this.onChanged=n}onOpen(){var e;let t=(e=this.plugin.settings.colorTheme)!=null?e:"default";this.modalEl.setAttribute("data-theme",t),this.contentEl.setAttribute("data-theme",t),this.contentEl.addClass("wl-view"),this.contentEl.addClass("wl-reading-modal"),this.contentEl.addClass("wl-reading-manage-cols"),this.titleEl.setText(this.kind==="book"?"Manage book fields":"Manage manga fields"),this.build()}onClose(){this.contentEl.empty()}build(){let t=this.contentEl;t.empty();let e=t.createDiv({cls:"wl-reading-modal-header"});e.createSpan({cls:"wl-reading-modal-header-icon",text:this.kind==="book"?"\u{1F4D6}":"\u{1F4D3}"}),e.createSpan({cls:"wl-reading-modal-header-title",text:this.kind==="book"?"Manage book fields":"Manage manga fields"}),t.createDiv({cls:"wl-reading-manage-cols-desc",text:"Custom fields appear in the title detail modal. Changes save automatically."}),this.renderStyleToggle(t),this.listEl=t.createDiv({cls:"wl-reading-manage-cols-list"}),this.renderList(),t.createDiv({cls:"wl-reading-modal-divider"}),this.renderAddSection(t),t.createDiv({cls:"wl-reading-modal-footer"}).createEl("button",{cls:"wl-btn",text:"Close"}).addEventListener("click",()=>this.close())}renderStyleToggle(t){var n,r;let e=this.readingData.getSettings(),i=this.kind==="book"?(n=e.bookCustomFieldStyle)!=null?n:"fill":(r=e.mangaCustomFieldStyle)!=null?r:"fill",a=t.createDiv({cls:"wl-reading-cols-style-row"});a.createSpan({cls:"wl-reading-cols-style-label",text:"Display style:"}),this.styleGroupEl=a.createDiv({cls:"wl-reading-cols-style-group"});for(let o of["fill","border"]){let l=this.styleGroupEl.createEl("button",{cls:`wl-reading-cols-style-btn${i===o?" is-active":""}`,text:o.charAt(0).toUpperCase()+o.slice(1)});l.addEventListener("click",()=>{(async()=>{var d;let c=this.kind==="book"?"bookCustomFieldStyle":"mangaCustomFieldStyle";await this.readingData.updateSettings({[c]:o}),this.onChanged(),(d=this.styleGroupEl)==null||d.querySelectorAll(".wl-reading-cols-style-btn").forEach(u=>{u.removeClass("is-active")}),l.addClass("is-active")})()})}}getColumns(){return this.kind==="book"?this.readingData.getBookColumns():this.readingData.getMangaColumns()}renderList(){if(!this.listEl)return;this.listEl.empty();let t=this.getColumns();if(t.length===0){this.listEl.createDiv({cls:"wl-reading-manage-cols-empty",text:"No custom fields yet. Add one below."});return}t.forEach((e,i)=>{var u;let a=this.listEl.createDiv({cls:"wl-editcol-card wl-reading-col-card"}),n=a.createDiv({cls:"wl-editcol-card-handle",text:"\u283F"});n.title="Drag to reorder",n.addEventListener("mousedown",()=>a.setAttribute("draggable","true")),a.addEventListener("dragstart",h=>{var p;this.dragFromIndex=i,(p=h.dataTransfer)==null||p.setData("text/plain",String(i)),a.addClass("wl-cl-dragging")}),a.addEventListener("dragend",()=>{a.removeClass("wl-cl-dragging"),a.setAttribute("draggable","false")}),a.addEventListener("dragover",h=>{h.preventDefault(),a.addClass("wl-cl-drag-over")}),a.addEventListener("dragleave",()=>a.removeClass("wl-cl-drag-over")),a.addEventListener("drop",h=>{h.preventDefault(),a.removeClass("wl-cl-drag-over");let p=this.dragFromIndex,m=i;this.dragFromIndex=-1,!(p===m||p<0)&&this.reorder(p,m)});let r=a.createEl("input",{cls:"wl-modal-input wl-editcol-card-name",attr:{type:"text",placeholder:"Field name"}});r.value=e.name,r.addEventListener("change",()=>{let h=r.value.trim();if(!h){r.value=e.name;return}h!==e.name&&this.updateColumn({...e,name:h})});let o=a.createEl("select",{cls:"wl-select wl-editcol-card-type"});for(let h of["text","number","select"]){let p=o.createEl("option",{value:h,text:h.charAt(0).toUpperCase()+h.slice(1)});e.type===h&&(p.selected=!0)}let l=a.createEl("button",{cls:"wl-reading-col-color-dot"});l.style.backgroundColor=(u=e.color)!=null?u:It,l.title="Choose field color",l.addEventListener("click",h=>{h.stopPropagation(),this.openColorPalette(l,e)});let c=a.createEl("input",{cls:"wl-modal-input wl-reading-col-opts-input",attr:{type:"text",placeholder:"Comma-separated values"}});c.value=e.options.join(", "),c.style.visibility=e.type==="select"?"":"hidden",c.addEventListener("change",()=>{let h=c.value.split(",").map(p=>p.trim()).filter(Boolean);this.updateColumn({...e,options:h})}),o.addEventListener("change",()=>{let h=o.value;c.style.visibility=h==="select"?"":"hidden",this.updateColumn({...e,type:h})});let d=a.createEl("button",{cls:"wl-btn wl-btn-sm wl-editcol-card-del",text:"\u2715"});d.title="Delete this field",d.addEventListener("click",()=>{new H(this.plugin.app,`Delete field "${e.name}"? This removes its data from every ${this.kind==="book"?"book":"manga"} entry.`,()=>void this.deleteColumn(e.id)).open()})})}openColorPalette(t,e){var l;this.contentEl.querySelectorAll(".wl-reading-col-palette").forEach(c=>c.remove());let i=t.getBoundingClientRect(),a=this.contentEl.createDiv({cls:"wl-reading-col-palette"});a.style.top=`${i.bottom+4}px`,a.style.left=`${i.left}px`;let n=this.contentEl.ownerDocument,r=(l=e.color)!=null?l:It;for(let{color600:c,color50:d}of Ye){let u=a.createEl("button",{cls:"wl-reading-col-palette-swatch"});u.style.backgroundColor=d,u.style.borderColor=c,r===c&&u.addClass("is-active"),u.title=c,u.addEventListener("click",h=>{h.stopPropagation(),a.remove(),this.updateColumn({...e,color:c})})}let o=c=>{!a.contains(c.target)&&c.target!==t&&(a.remove(),n.removeEventListener("mousedown",o,!0))};window.setTimeout(()=>n.addEventListener("mousedown",o,!0),0)}renderAddSection(t){let e=t.createDiv({cls:"wl-reading-add-col"});e.createDiv({cls:"wl-reading-section-label",text:"Add field"});let i=e.createDiv({cls:"wl-reading-add-col-row"}),a=i.createEl("input",{cls:"wl-modal-input wl-reading-add-col-name",attr:{type:"text",placeholder:"Field name"}}),n=i.createEl("select",{cls:"wl-select wl-reading-add-col-type"});for(let c of["text","number","select"])n.createEl("option",{value:c,text:c.charAt(0).toUpperCase()+c.slice(1)});let r=i.createEl("input",{cls:"wl-modal-input wl-reading-add-col-opts",attr:{type:"text",placeholder:"Options (comma-separated)"}}),o=()=>{r.style.display=n.value==="select"?"":"none"};n.addEventListener("change",o),o(),i.createEl("button",{cls:"wl-reading-add-btn wl-btn-success wl-reading-add-col-btn",text:"Add"}).addEventListener("click",()=>{let c=a.value.trim();if(!c){new Ne.Notice("Enter a field name first.");return}let d=n.value,u=d==="select"?r.value.split(",").map(p=>p.trim()).filter(Boolean):[],h={id:this.readingData.generateColumnId(this.kind,c),name:c,type:d,options:u,color:It};this.addColumn(h).then(()=>{a.value="",r.value="",n.value="text",o()})})}async addColumn(t){await this.readingData.addColumn(this.kind,t),this.renderList(),this.onChanged()}async updateColumn(t){await this.readingData.updateColumn(this.kind,t),this.renderList(),this.onChanged()}async deleteColumn(t){await this.readingData.removeColumn(this.kind,t),this.renderList(),this.onChanged()}async reorder(t,e){await this.readingData.reorderColumns(this.kind,t,e),this.renderList(),this.onChanged()}};function Ha(g){var r,o,l;let s=g.match(/(^|\n)## Quotes[ \t]*\r?\n([\s\S]*?)(?=\n## |\n# |$)/);if(!s)return[];let t=(r=s[2])!=null?r:"",e=[],i=t.split(/\r?\n/),a=null,n=()=>{if(a){let c=a.body.join(` +`).trim();c&&e.push({reference:a.reference,text:c})}a=null};for(let c of i){let d=c.match(/^>\s*\[!quote\](.*)$/i);if(d){n(),a={reference:((o=d[1])!=null?o:"").trim(),body:[]};continue}if(a){let u=c.match(/^>\s?(.*)$/);u?a.body.push((l=u[1])!=null?l:""):(c.trim(),n())}}return n(),e}var He=class extends it.Modal{constructor(t,e,i,a,n,r){super(t);this.starsWrapEl=null;this.plugin=e,this.readingData=i,this.mode=a,this.id=n,this.onChanged=r;let o=this.getItem();this.draft={pagesRead:o&&this.mode==="book"?o.pagesRead:0,chaptersRead:o?o.chaptersRead:0,volumesRead:o&&this.mode==="manga"?o.volumesRead:0},this.openSnapshot={...this.draft}}getItem(){return this.mode==="book"?this.readingData.getBook(this.id):this.readingData.getManga(this.id)}onOpen(){var e;let t=(e=this.plugin.settings.colorTheme)!=null?e:"default";this.modalEl.setAttribute("data-theme",t),this.contentEl.setAttribute("data-theme",t),this.contentEl.addClass("wl-view"),this.contentEl.addClass("wl-detail-modal"),this.contentEl.addClass("wl-reading-detail"),this.renderAll()}onClose(){this.commitOnClose(),this.contentEl.empty()}async commitOnClose(){if(!(this.draft.pagesRead!==this.openSnapshot.pagesRead||this.draft.chaptersRead!==this.openSnapshot.chaptersRead||this.draft.volumesRead!==this.openSnapshot.volumesRead))return;let e=this.getItem();if(e){if(this.mode==="book"){let a={...e,pagesRead:this.draft.pagesRead,chaptersRead:this.draft.chaptersRead};this.applyAutoComplete(a),await this.readingData.updateBook(a)}else{let a={...e,chaptersRead:this.draft.chaptersRead,volumesRead:this.draft.volumesRead};this.applyAutoComplete(a),await this.readingData.updateManga(a)}this.logProgressChange(e),this.onChanged()}}logProgressChange(t){var a,n,r,o;let e=this.openSnapshot,i=this.mode==="book"?"Book":"Manga";if(this.mode==="book"){let l=t;this.draft.pagesRead!==e.pagesRead&&l.totalPages>0&&((a=this.plugin.historyManager)==null||a.log(`${t.title} (${i}) At page ${this.draft.pagesRead} / ${l.totalPages}`,{source:"Reading",action:"watched",titleName:t.title})),this.draft.chaptersRead!==e.chaptersRead&&l.totalChapters>0&&((n=this.plugin.historyManager)==null||n.log(`${t.title} (${i}) At chapter ${this.draft.chaptersRead} / ${l.totalChapters}`,{source:"Reading",action:"watched",titleName:t.title}))}else{let l=t;this.draft.chaptersRead!==e.chaptersRead&&l.totalChapters>0&&((r=this.plugin.historyManager)==null||r.log(`${t.title} (${i}) At chapter ${this.draft.chaptersRead} / ${l.totalChapters}`,{source:"Reading",action:"watched",titleName:t.title})),this.draft.volumesRead!==e.volumesRead&&l.totalVolumes>0&&((o=this.plugin.historyManager)==null||o.log(`${t.title} (${i}) At volume ${this.draft.volumesRead} / ${l.totalVolumes}`,{source:"Reading",action:"watched",titleName:t.title}))}}renderAll(){this.contentEl.empty();let t=this.getItem();if(!t){this.contentEl.createDiv({text:"Item no longer exists."});return}this.renderHeader(t),this.renderProgressSection(t),this.renderDetailsSection(t),this.contentEl.createDiv({cls:"wl-reading-modal-divider"}),this.renderCustomFieldsSection(t),this.contentEl.createDiv({cls:"wl-reading-modal-divider"}),this.renderQuotesSection(t),this.renderFooter(t)}renderHeader(t){let e=this.contentEl.createDiv({cls:"wl-reading-detail-header"}),i=e.createDiv({cls:"wl-reading-detail-cover"});t.coverUrl?i.createEl("img",{cls:"wl-reading-detail-cover-img",attr:{src:t.coverUrl,alt:t.title}}):(i.style.backgroundColor=Ls(t.id),i.createSpan({cls:"wl-reading-detail-cover-icon",text:this.mode==="book"?"\u{1F4D6}":"\u{1F4D3}"}));let a=e.createDiv({cls:"wl-reading-detail-info"}),n=a.createDiv({cls:"wl-reading-detail-title-wrap"});this.renderTitleDisplay(n);let r=a.createDiv({cls:"wl-reading-detail-author-wrap"});this.renderAuthorDisplay(r);let o=a.createDiv({cls:"wl-reading-detail-meta-row"}),l=o.createSpan({cls:"wl-reading-detail-status-wrap"});this.renderStatusBadge(l),this.starsWrapEl=o.createDiv({cls:"wl-stars wl-reading-detail-stars"}),this.renderStars();let c=a.createDiv({cls:"wl-reading-detail-actions"});this.renderExternalLinkActions(c);let d=a.createDiv({cls:"wl-reading-detail-vault-row"});this.renderVaultRow(d)}renderTitleDisplay(t){var a;t.empty();let e=this.getItem(),i=t.createEl("h2",{cls:"wl-reading-detail-title wl-reading-detail-editable-text",text:(a=e==null?void 0:e.title)!=null?a:""});i.title="Click to edit",i.addEventListener("click",()=>this.editTitle(t))}editTitle(t){var n;t.empty();let e=this.getItem(),i=t.createEl("input",{cls:"wl-modal-input wl-reading-detail-title-input",attr:{type:"text"}});i.value=(n=e==null?void 0:e.title)!=null?n:"";let a=()=>{let r=i.value.trim();if(!r){this.renderTitleDisplay(t);return}(async()=>{let o=this.getItem();o&&r!==o.title&&(this.mode==="book"?await this.readingData.updateBook({...o,title:r}):await this.readingData.updateManga({...o,title:r}),this.onChanged()),this.renderTitleDisplay(t)})()};i.addEventListener("blur",a),i.addEventListener("keydown",r=>{r.key==="Enter"?(r.preventDefault(),i.blur()):r.key==="Escape"&&(r.preventDefault(),this.renderTitleDisplay(t))}),i.focus(),i.select()}renderAuthorDisplay(t){t.empty();let e=this.getItem(),i=t.createDiv({cls:"wl-reading-detail-author wl-reading-detail-editable-text",text:(e==null?void 0:e.author)||"\u2014"});i.title="Click to edit",i.addEventListener("click",()=>this.editAuthor(t))}editAuthor(t){var n;t.empty();let e=this.getItem(),i=t.createEl("input",{cls:"wl-modal-input wl-reading-detail-author-input",attr:{type:"text"}});i.value=(n=e==null?void 0:e.author)!=null?n:"";let a=()=>{let r=i.value.trim();(async()=>{let o=this.getItem();o&&r!==o.author&&(this.mode==="book"?await this.readingData.updateBook({...o,author:r}):await this.readingData.updateManga({...o,author:r}),this.onChanged()),this.renderAuthorDisplay(t)})()};i.addEventListener("blur",a),i.addEventListener("keydown",r=>{r.key==="Enter"?(r.preventDefault(),i.blur()):r.key==="Escape"&&(r.preventDefault(),this.renderAuthorDisplay(t))}),i.focus(),i.select()}renderStatusBadge(t){var n;t.empty();let e=this.getItem(),i=(n=e==null?void 0:e.status)!=null?n:"Plan to Read",a=t.createSpan({cls:"wl-reading-detail-status",text:i});a.style.backgroundColor=re(i),a.title="Click to change status",a.addEventListener("click",r=>{r.stopPropagation(),this.openStatusDropdown(a,t)})}openStatusDropdown(t,e){this.contentEl.querySelectorAll(".wl-reading-status-dropdown").forEach(o=>o.remove());let i=t.getBoundingClientRect(),a=this.contentEl.createDiv({cls:"wl-reading-status-dropdown"});a.style.top=`${i.bottom+4}px`,a.style.left=`${i.left}px`;let n=this.contentEl.ownerDocument;for(let o of lt){let l=a.createDiv({cls:"wl-reading-status-option"}),c=l.createSpan({cls:"wl-reading-status-option-dot"});c.style.backgroundColor=re(o),l.createSpan({text:o}),l.addEventListener("click",()=>{a.remove(),n.removeEventListener("mousedown",r,!0),this.saveStatus(o).then(()=>this.renderStatusBadge(e))})}let r=o=>{a.contains(o.target)||(a.remove(),n.removeEventListener("mousedown",r,!0))};window.setTimeout(()=>n.addEventListener("mousedown",r,!0),0)}async saveStatus(t){let e=this.getItem();if(!e)return;let i={status:t};if(t==="Completed"&&!e.dateFinished){let a=new Date,n=a.getFullYear(),r=String(a.getMonth()+1).padStart(2,"0"),o=String(a.getDate()).padStart(2,"0");i.dateFinished=`${n}-${r}-${o}`}this.mode==="book"?await this.readingData.updateBook({...e,...i}):await this.readingData.updateManga({...e,...i}),this.onChanged()}renderExternalLinkActions(t){var n;t.empty();let e=this.getItem();if(!e)return;let i=!!e.externalLink;if(t.createEl("button",{cls:"wl-btn wl-btn-sm",text:i?"Change link":"External link"}).addEventListener("click",()=>{var u;t.empty();let r=t.createDiv({cls:"wl-reading-external-link-wrap"}),o=r.createEl("input",{cls:"wl-modal-input wl-reading-external-link-input",attr:{type:"url",placeholder:"https://\u2026"}});o.value=(u=e.externalLink)!=null?u:"";let l=r.createEl("button",{cls:"wl-reading-add-btn wl-btn-success",text:"Save"}),c=r.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Cancel"}),d=()=>{let h=o.value.trim();(async()=>{let p=this.getItem();p&&(this.mode==="book"?await this.readingData.updateBook({...p,externalLink:h}):await this.readingData.updateManga({...p,externalLink:h}),this.onChanged(),this.renderExternalLinkActions(t))})()};l.addEventListener("click",d),c.addEventListener("click",()=>this.renderExternalLinkActions(t)),o.addEventListener("keydown",h=>{h.key==="Enter"?(h.preventDefault(),d()):h.key==="Escape"&&(h.preventDefault(),this.renderExternalLinkActions(t))}),o.focus()}),i){let r=t.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Open"});r.title=(n=e.externalLink)!=null?n:"",r.addEventListener("click",()=>{let o=this.getItem();o!=null&&o.externalLink&&activeWindow.open(o.externalLink,"_blank","noopener,noreferrer")})}}renderVaultRow(t){t.empty();let e=this.getItem();if(!e)return;let i=t.createEl("button",{cls:"wl-btn wl-btn-sm",text:e.vaultPage?"Change":"Link"});if(i.title=e.vaultPage?`Linked: ${e.vaultPage}`:"Link a vault note to this entry",i.addEventListener("click",()=>{let a=this.plugin.app.vault.getMarkdownFiles();new We(this.plugin.app,a,n=>{this.updateVaultPage(n.path).then(()=>this.renderVaultRow(t))}).open()}),e.vaultPage){let a=t.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Open"});a.title="Open the linked vault page in a new tab",a.addEventListener("click",()=>this.openLinkedVaultPage())}if(t.createSpan({cls:"wl-reading-detail-vault-label",text:"Vault page:"}),e.vaultPage){let a=e.vaultPage.split("/").pop()||e.vaultPage,n=t.createSpan({cls:"wl-reading-detail-vault-path",text:a});n.title=e.vaultPage}else t.createSpan({cls:"wl-reading-detail-vault-empty",text:"not linked"})}async updateVaultPage(t){let e=this.getItem();if(e)if(this.mode==="book"){let i={...e,vaultPage:t};await this.readingData.updateBook(i)}else{let i={...e,vaultPage:t};await this.readingData.updateManga(i)}}openLinkedVaultPage(){let t=this.getItem();if(!t||!t.vaultPage)return;let e=this.plugin.app.vault.getAbstractFileByPath(t.vaultPage);e instanceof it.TFile?(this.plugin.app.workspace.getLeaf("tab").openFile(e),this.close()):new it.Notice("Linked vault page no longer exists.")}renderStars(){if(!this.starsWrapEl)return;this.starsWrapEl.empty();let t=this.getItem();if(t)for(let e=1;e<=5;e++)this.starsWrapEl.createSpan({cls:`wl-star${t.rating>=e?" is-active":""}`,text:"\u2605"}).addEventListener("click",()=>{let a=this.getItem();if(!a)return;let n=a.rating===e?0:e;(async()=>{if(this.mode==="book"){let r={...a,rating:n};await this.readingData.updateBook(r)}else{let r={...a,rating:n};await this.readingData.updateManga(r)}this.renderStars()})()})}renderProgressSection(t){let e=this.contentEl.createDiv({cls:"wl-reading-detail-progress"});e.createDiv({cls:"wl-reading-section-label",text:"Progress"});let i=e.createDiv({cls:"wl-reading-arc-row"});this.mode==="book"?(this.renderArcGauge(i,"pages","#8b5cf6",()=>this.draft.pagesRead,a=>{this.draft.pagesRead=a},()=>{var a,n;return(n=(a=this.getItem())==null?void 0:a.totalPages)!=null?n:0},a=>this.commitTotal("totalPages",a)),this.renderArcGauge(i,"chapters","#06b6d4",()=>this.draft.chaptersRead,a=>{this.draft.chaptersRead=a},()=>{var a,n;return(n=(a=this.getItem())==null?void 0:a.totalChapters)!=null?n:0},a=>this.commitTotal("totalChapters",a))):(this.renderArcGauge(i,"chapters","#06b6d4",()=>this.draft.chaptersRead,a=>{this.draft.chaptersRead=a},()=>{var a,n;return(n=(a=this.getItem())==null?void 0:a.totalChapters)!=null?n:0},a=>this.commitTotal("totalChapters",a)),this.renderArcGauge(i,"volumes","#f59e0b",()=>this.draft.volumesRead,a=>{this.draft.volumesRead=a},()=>{var a,n;return(n=(a=this.getItem())==null?void 0:a.totalVolumes)!=null?n:0},a=>this.commitTotal("totalVolumes",a)))}async commitTotal(t,e){let i=this.getItem();i&&(this.mode==="book"?await this.readingData.updateBook({...i,[t]:e}):await this.readingData.updateManga({...i,[t]:e}),this.onChanged())}renderArcGauge(t,e,i,a,n,r,o){let l=t.createDiv({cls:"wl-reading-arc-col"}),c=100,d=8,u=(c-d)/2,h=c/2,p=c/2,m=l.createDiv({cls:"wl-reading-arc-svg-wrap"}),v=activeDocument.createElementNS("http://www.w3.org/2000/svg","svg");v.setAttribute("viewBox",`0 0 ${c} ${c}`),v.setAttribute("class","wl-reading-arc-svg"),m.appendChild(v);let f=Math.PI*u,y=activeDocument.createElementNS("http://www.w3.org/2000/svg","path"),w=`M ${h-u} ${p} A ${u} ${u} 0 1 1 ${h+u} ${p}`;y.setAttribute("d",w),y.setAttribute("fill","none"),y.setAttribute("stroke","#2a2a2a"),y.setAttribute("stroke-width",String(d)),y.setAttribute("stroke-linecap","round"),v.appendChild(y);let b=activeDocument.createElementNS("http://www.w3.org/2000/svg","path");b.setAttribute("d",w),b.setAttribute("fill","none"),b.setAttribute("stroke",i),b.setAttribute("stroke-width",String(d)),b.setAttribute("stroke-linecap","round"),b.setAttribute("stroke-dasharray",String(f)),v.appendChild(b);let S=activeDocument.createElementNS("http://www.w3.org/2000/svg","text");S.setAttribute("x",String(h)),S.setAttribute("y",String(p+4)),S.setAttribute("text-anchor","middle"),S.setAttribute("class","wl-reading-arc-pct"),v.appendChild(S);let D=A=>{let P=r();return isNaN(A)||A<0?0:P>0&&A>P?P:A},E=l.createDiv({cls:"wl-reading-arc-controls"}),k=E.createEl("button",{cls:"wl-btn wl-btn-sm wl-reading-step-btn",text:"\u22121"}),C=E.createEl("input",{cls:"wl-modal-input wl-modal-input-sm wl-reading-arc-input",attr:{type:"number",min:"0",max:String(r())}}),x=E.createEl("button",{cls:"wl-btn wl-btn-sm wl-reading-step-btn",text:"+1"}),T=l.createDiv({cls:"wl-reading-arc-label"}),M=()=>{let A=r(),P=a();C.value=String(P),C.setAttribute("max",String(A));let I=A>0?Math.min(1,P/A):0,F=f*(1-I);b.setAttribute("stroke-dashoffset",String(F)),S.textContent=`${Math.round(I*100)}%`},L=()=>{T.empty(),T.createSpan({text:"of "});let A=T.createSpan({cls:"wl-reading-arc-total wl-reading-detail-editable-text",text:String(r())});A.title="Click to edit",A.addEventListener("click",()=>j()),T.createSpan({text:` ${e}`})},j=()=>{T.empty(),T.createSpan({text:"of "});let A=T.createEl("input",{cls:"wl-modal-input wl-modal-input-sm wl-reading-arc-total-input",attr:{type:"number",min:"0"}});A.value=String(r()),T.createSpan({text:` ${e}`});let P=!1,I=()=>{if(P)return;P=!0;let F=parseInt(A.value,10),_=isNaN(F)||F<0?0:F;(async()=>(_!==r()&&(await o(_),M()),L()))()};A.addEventListener("blur",I),A.addEventListener("keydown",F=>{F.key==="Enter"?(F.preventDefault(),A.blur()):F.key==="Escape"&&(F.preventDefault(),P=!0,L())}),A.focus(),A.select()};L(),M(),C.addEventListener("input",()=>{let A=D(parseInt(C.value,10));n(A),M()}),k.addEventListener("click",()=>{n(D(a()-1)),M()}),x.addEventListener("click",()=>{n(D(a()+1)),M()})}renderDetailsSection(t){let e=this.contentEl.createDiv({cls:"wl-reading-detail-details"});e.createDiv({cls:"wl-reading-section-label",text:"Details"});let i=e.createDiv({cls:"wl-reading-detail-grid"});this.renderIdCell(i,t),this.renderEditableDateCell(i,"Started",()=>{var a,n;return(n=(a=this.getItem())==null?void 0:a.dateStarted)!=null?n:null},async a=>{let n=this.getItem();n&&(this.mode==="book"?await this.readingData.updateBook({...n,dateStarted:a}):await this.readingData.updateManga({...n,dateStarted:a}),this.onChanged())}),this.renderAddedReleaseCell(i,t),this.renderEditableDateCell(i,"Finished",()=>{var a,n;return(n=(a=this.getItem())==null?void 0:a.dateFinished)!=null?n:null},async a=>{let n=this.getItem();n&&(this.mode==="book"?await this.readingData.updateBook({...n,dateFinished:a}):await this.readingData.updateManga({...n,dateFinished:a}),this.onChanged())})}renderIdCell(t,e){let i=this.mode==="book"?"Google Books ID":"MAL ID",a=this.makeDetailCell(t,i);a.addClass("wl-reading-detail-id-value"),this.fillIdValue(a,e)}idText(t){return this.mode==="book"?t.googleBooksId:t.malId}resolveItemLink(t){if(t.externalLink)return t.externalLink;if(this.mode==="manga"){let i=t.malId;return i?`https://myanimelist.net/manga/${encodeURIComponent(i)}`:""}let e=t.googleBooksId;return e?`https://books.google.com/books?id=${encodeURIComponent(e)}`:""}fillIdValue(t,e){t.empty();let i=this.idText(e),a=this.resolveItemLink(e);i?a?t.createEl("a",{cls:"wl-reading-detail-link",text:i,attr:{href:a,target:"_blank",rel:"noopener noreferrer"}}):t.createSpan({text:i}):t.createSpan({text:"\u2014"}),t.createEl("button",{cls:"wl-btn wl-btn-sm wl-reading-detail-id-edit",text:"\u270E",attr:{title:this.mode==="book"?"Edit Google Books ID":"Edit MAL ID"}}).addEventListener("click",()=>this.editIdLink(t,e))}editIdLink(t,e){t.empty();let i=t.createDiv({cls:"wl-reading-external-link-wrap"}),a=i.createEl("input",{cls:"wl-modal-input wl-reading-external-link-input",attr:{type:"text",placeholder:this.mode==="book"?"Google Books volume ID":"MAL manga ID"}});a.value=this.idText(e);let n=i.createEl("button",{cls:"wl-reading-add-btn wl-btn-success",text:"Save"}),r=i.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Cancel"}),o=()=>{let c=this.getItem();c&&this.fillIdValue(t,c)},l=()=>{let c=a.value.trim();(async()=>{let d=this.getItem();if(d){if(c===this.idText(d)){o();return}this.mode==="book"?await this.readingData.updateBook({...d,googleBooksId:c}):await this.readingData.updateManga({...d,malId:c}),this.onChanged(),o(),c&&await this.refetchCoverFromId(c)}})()};n.addEventListener("click",l),r.addEventListener("click",o),a.addEventListener("keydown",c=>{c.key==="Enter"?(c.preventDefault(),l()):c.key==="Escape"&&(c.preventDefault(),o())}),a.focus()}async refetchCoverFromId(t){try{if(this.mode==="book"){if(!this.plugin.apiService.hasGoogleBooksKey()){new it.Notice("Google Books API key required \u2014 add one in Settings \u2192 API \u2192 Books.");return}let e=await this.plugin.apiService.getGoogleBookById(t);if(!e||!e.coverUrl){new it.Notice("This volume doesn't have a cover \u2014 try a different edition's ID.");return}let i=this.getItem();if(!i)return;await this.readingData.updateBook({...i,coverUrl:e.coverUrl})}else{let e=await this.plugin.apiService.getMangaByMalId(Number(t));if(!e||!e.coverUrl){new it.Notice("This manga doesn't have a cover \u2014 try a different ID.");return}let i=this.getItem();if(!i)return;await this.readingData.updateManga({...i,coverUrl:e.coverUrl})}this.onChanged()}catch(e){new it.Notice(this.mode==="book"?Et(e):"Failed to fetch cover.")}}renderAddedReleaseCell(t,e){var l,c;let i=t.createDiv({cls:"wl-reading-detail-cell wl-reading-detail-cell-pair"}),a=i.createDiv({cls:"wl-reading-detail-pair-col"});a.createDiv({cls:"wl-reading-detail-cell-label",text:"Added"}),a.createDiv({cls:"wl-reading-detail-cell-value",text:Q(e.dateAdded.slice(0,10))});let n=i.createDiv({cls:"wl-reading-detail-pair-col"});n.createDiv({cls:"wl-reading-detail-cell-label",text:"Release"});let r=n.createEl("input",{cls:"wl-reading-detail-date-input",attr:{type:"text",placeholder:"Dd/mm/yyyy",maxlength:"10"}}),o=(c=(l=this.getItem())==null?void 0:l.releaseDate)!=null?c:null;r.value=o?Q(o):"",r.addEventListener("change",()=>{let d=r.value.trim(),u=null;if(d&&(u=q(d),!u)){r.addClass("wl-input-error");return}r.removeClass("wl-input-error"),(async()=>{let h=this.getItem();h&&(this.mode==="book"?await this.readingData.updateBook({...h,releaseDate:u}):await this.readingData.updateManga({...h,releaseDate:u}),this.onChanged(),this.renderAll())})()})}makeDetailCell(t,e){let i=t.createDiv({cls:"wl-reading-detail-cell"});return i.createDiv({cls:"wl-reading-detail-cell-label",text:e}),i.createDiv({cls:"wl-reading-detail-cell-value"})}renderEditableDateCell(t,e,i,a){let n=t.createDiv({cls:"wl-reading-detail-cell"});n.createDiv({cls:"wl-reading-detail-cell-label",text:e});let r=n.createDiv({cls:"wl-reading-detail-cell-date"}),o=r.createEl("button",{cls:"wl-btn wl-btn-sm wl-reading-detail-today-btn",text:"Today",attr:{title:"Fill with today's date"}}),l=r.createEl("input",{cls:"wl-reading-detail-date-input",attr:{type:"text",placeholder:"Dd/mm/yyyy",maxlength:"10"}}),c=i();l.value=c?Q(c):"";let d=()=>{o.toggleClass("is-dimmed",!!l.value.trim())};d(),o.addEventListener("click",()=>{if(l.value.trim())return;let u=new Date,h=String(u.getDate()).padStart(2,"0"),p=String(u.getMonth()+1).padStart(2,"0");l.value=`${h}/${p}/${u.getFullYear()}`,d(),l.dispatchEvent(new Event("change"))}),l.addEventListener("change",()=>{let u=l.value.trim();if(!u){l.removeClass("wl-input-error"),d(),a(null);return}let h=q(u);if(!h){l.addClass("wl-input-error");return}l.removeClass("wl-input-error"),d(),a(h)})}renderFooter(t){let e=this.contentEl.createDiv({cls:"wl-reading-detail-footer"});e.createDiv({cls:"wl-reading-detail-footer-left"}).createEl("button",{cls:"wl-delete-btn wl-btn-danger wl-reading-detail-delete",text:"Delete"}).addEventListener("click",()=>{new H(this.plugin.app,`Delete "${t.title}"?`,()=>{(async()=>(this.mode==="book"?await this.readingData.removeBook(t.id):await this.readingData.removeManga(t.id),this.onChanged(),this.close()))()}).open()}),e.createDiv({cls:"wl-reading-detail-footer-right"}).createEl("button",{cls:"wl-reading-add-btn wl-btn-success",text:"Update progress"}).addEventListener("click",()=>void this.commitDraft())}getColumns(){return this.mode==="book"?this.readingData.getBookColumns():this.readingData.getMangaColumns()}openManageColumns(){new Nt(this.plugin.app,this.plugin,this.readingData,this.mode,()=>this.renderAll()).open()}renderCustomFieldsSection(t){var d,u;let e=this.contentEl.createDiv({cls:"wl-reading-detail-custom"}),i=e.createDiv({cls:"wl-reading-detail-custom-heading"});i.createSpan({cls:"wl-reading-section-label",text:"Custom fields"}),i.createEl("a",{cls:"wl-reading-detail-custom-manage",text:"Manage"}).addEventListener("click",h=>{h.preventDefault(),this.openManageColumns()});let n=this.getColumns();if(n.length===0){let h=e.createDiv({cls:"wl-reading-detail-custom-empty"});h.createSpan({text:"No custom fields \u2014 add some via "}),h.createEl("a",{cls:"wl-reading-detail-custom-manage",text:"Manage"}).addEventListener("click",m=>{m.preventDefault(),this.openManageColumns()}),h.createSpan({text:"."});return}let r=this.readingData.getSettings(),l=(this.mode==="book"?(d=r.bookCustomFieldStyle)!=null?d:"fill":(u=r.mangaCustomFieldStyle)!=null?u:"fill")==="border"?"wl-reading-custom-table is-border-mode":"wl-reading-custom-table",c=e.createDiv({cls:l});for(let h of n)this.renderCustomFieldRow(c,t,h)}renderCustomFieldRow(t,e,i){var u,h,p,m,v;let a=t.createDiv({cls:"wl-reading-custom-row"}),n=a.createDiv({cls:"wl-reading-custom-label",text:i.name}),r=a.createDiv({cls:"wl-reading-custom-value"}),o=this.readingData.getSettings(),l=this.mode==="book"?(u=o.bookCustomFieldStyle)!=null?u:"fill":(h=o.mangaCustomFieldStyle)!=null?h:"fill",c=(p=i.color)!=null?p:It;if(l==="fill"){let f=(m=$s[c])!=null?m:"#F1EFE8";n.style.backgroundColor=f,n.style.color="#1e1e1e"}else n.style.border=`1.5px solid ${c}`,n.style.boxSizing="border-box",r.style.border="0.5px solid var(--background-modifier-border)",r.style.boxSizing="border-box";let d=(v=e.customFields)==null?void 0:v[i.id];this.renderCustomValueDisplay(r,e,i,d)}renderCustomValueDisplay(t,e,i,a){t.empty();let n=a!=null&&a!=="";t.createSpan({cls:`wl-reading-custom-display${n?"":" is-placeholder"}`,text:n?String(a):"\u2014"}).addEventListener("click",()=>{this.renderCustomValueEditor(t,e,i,a)})}renderCustomValueEditor(t,e,i,a){if(t.empty(),i.type==="select"){let o=t.createEl("select",{cls:"wl-select wl-reading-custom-input"}),l=o.createEl("option",{value:"",text:"\u2014"});(a==null||a==="")&&(l.selected=!0);for(let d of i.options){let u=o.createEl("option",{value:d,text:d});String(a!=null?a:"")===d&&(u.selected=!0)}let c=()=>{let d=o.value;this.saveCustomField(e,i,d===""?null:d).then(()=>{var h;let u=this.getItem();this.renderCustomValueDisplay(t,u!=null?u:e,i,(h=u==null?void 0:u.customFields)==null?void 0:h[i.id])})};o.addEventListener("change",c),o.addEventListener("blur",c),o.focus();return}let n=t.createEl("input",{cls:"wl-modal-input wl-reading-custom-input",attr:{type:i.type==="number"?"number":"text",placeholder:"Enter value\u2026"}});a!=null&&(n.value=String(a));let r=()=>{let o=n.value.trim(),l;if(o==="")l=null;else if(i.type==="number"){let c=Number(o);l=isNaN(c)?null:c}else l=o;this.saveCustomField(e,i,l).then(()=>{var d;let c=this.getItem();this.renderCustomValueDisplay(t,c!=null?c:e,i,(d=c==null?void 0:c.customFields)==null?void 0:d[i.id])})};n.addEventListener("blur",r),n.addEventListener("keydown",o=>{var l;if(o.key==="Enter")o.preventDefault(),n.blur();else if(o.key==="Escape"){o.preventDefault();let c=this.getItem();this.renderCustomValueDisplay(t,c!=null?c:e,i,(l=c==null?void 0:c.customFields)==null?void 0:l[i.id])}}),n.focus(),n.select()}async saveCustomField(t,e,i){await this.readingData.setCustomField(this.mode,t.id,e.id,i)}async renderQuotesSection(t){let e=this.contentEl.createDiv({cls:"wl-reading-detail-quotes"}),i=e.createDiv({cls:"wl-reading-detail-quotes-heading"});i.createSpan({cls:"wl-reading-section-label",text:"Favorite quotes"});let a=i.createEl("button",{cls:"wl-btn wl-btn-sm wl-reading-detail-quotes-add",text:"Add quote"}),n=e.createDiv({cls:"wl-reading-quote-list"}),r=e.createDiv({cls:"wl-reading-quote-form-host"}),o=async()=>{n.empty();try{let l=await this.readingData.readReadingNote(this.mode,t),c=l?Ha(l):[];if(c.length===0)n.createDiv({cls:"wl-reading-quote-empty",text:"No quotes yet."});else for(let d of c)this.renderQuoteCard(n,d)}catch(l){console.warn("[WL] quotes read failed:",l)}};a.addEventListener("click",()=>{r.empty(),this.renderAddQuoteForm(r,t,async()=>{await o()})}),o()}renderQuoteCard(t,e){let i=t.createDiv({cls:"wl-reading-quote-card"});i.createDiv({cls:"wl-reading-quote-text",text:e.text}),e.reference&&i.createDiv({cls:"wl-reading-quote-ref",text:e.reference})}renderAddQuoteForm(t,e,i){t.empty();let a=t.createDiv({cls:"wl-reading-quote-form"}),n=a.createEl("textarea",{cls:"wl-modal-input wl-reading-quote-textarea",attr:{placeholder:"Quote text\u2026",rows:"3"}}),r=a.createEl("input",{cls:"wl-modal-input wl-reading-quote-ref-input",attr:{type:"text",placeholder:"p. 123 or ch. 5 (optional)"}}),o=a.createDiv({cls:"wl-reading-quote-form-actions"});o.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Cancel"}).addEventListener("click",()=>t.empty()),o.createEl("button",{cls:"wl-reading-add-btn wl-btn-success wl-reading-quote-save",text:"Save quote"}).addEventListener("click",()=>{let d=n.value.trim();if(!d){new it.Notice("Enter the quote text first.");return}(async()=>{try{await this.readingData.appendQuote(this.mode,e,d,r.value),t.empty(),await i()}catch(u){console.warn("[WL] appendQuote failed:",u),new it.Notice("Could not save quote.")}})()}),n.focus()}async commitDraft(){let t=this.getItem();if(t){if(this.mode==="book"){let i={...t,pagesRead:this.draft.pagesRead,chaptersRead:this.draft.chaptersRead};this.applyAutoComplete(i),await this.readingData.updateBook(i)}else{let i={...t,chaptersRead:this.draft.chaptersRead,volumesRead:this.draft.volumesRead};this.applyAutoComplete(i),await this.readingData.updateManga(i)}this.logProgressChange(t),this.openSnapshot={...this.draft},new it.Notice("Progress updated."),this.onChanged(),this.close()}}applyAutoComplete(t){if((this.mode==="book"?this.isBookComplete(t):this.isMangaComplete(t))&&t.status!=="Completed"&&(t.status="Completed"),t.status==="Completed"&&!t.dateFinished){let i=new Date,a=i.getFullYear(),n=String(i.getMonth()+1).padStart(2,"0"),r=String(i.getDate()).padStart(2,"0");t.dateFinished=`${a}-${n}-${r}`}}isBookComplete(t){return t.totalPages>0&&t.pagesRead>=t.totalPages||t.totalChapters>0&&t.chaptersRead>=t.totalChapters}isMangaComplete(t){return t.totalChapters>0&&t.chaptersRead>=t.totalChapters}};var mi="__has__",vi="__none__";function _a(g,s){var e;let t=(e=g.customFields)==null?void 0:e[s];return t!=null&&t!==""}function Rs(){return{statusExclude:[],ratingMode:"all",customExclude:{}}}function fi(){return{key:"dateAdded",dir:"desc",secondKey:"none",secondDir:"asc"}}function Ua(g){var t,e;let s=0;g.statusExclude.length>0&&s++,g.ratingMode!=="all"&&s++;for(let i of Object.keys(g.customExclude))((e=(t=g.customExclude[i])==null?void 0:t.length)!=null?e:0)>0&&s++;return s}function re(g){switch(g){case"Reading":return"#1D9E75";case"Completed":return"#7F77DD";case"Plan to Read":return"#E8873A";case"To be released":return"#3A86C8";case"Dropped":return"#E24B4A";default:return"#888780"}}var wi=["#3a4a6b","#4a3a6b","#6b3a4a","#6b5a3a","#3a6b5a","#5a6b3a","#3a5a6b","#6b3a5a"];function Ls(g){var t;let s=0;for(let e=0;e<g.length;e++)s=s*31+g.charCodeAt(e)>>>0;return(t=wi[s%wi.length])!=null?t:"#3a4a6b"}function yi(g){return g.totalPages>0?Math.min(100,g.pagesRead/g.totalPages*100):0}function bi(g){return g.totalChapters>0?Math.min(100,g.chaptersRead/g.totalChapters*100):0}var at=class at{constructor(s,t,e){this.searchQuery="";this.contentEl=null;this.filterBtn=null;this.sortBtn=null;this.resetBtn=null;this.savedFilterActive=!1;this.savedFilterBtnEl=null;this.openPopover=null;this.popoverCleanup=null;this.activeCleanups=[];this.selectionMode=!1;this.selectedIds=new Set;this.vsScrollContainer=null;this.vsScrollSpacer=null;this.vsGridEl=null;this.vsDisplayItems=[];this.vsScrollHandler=null;this.vsScrollRAF=null;this.vsResizeObserver=null;this.vsLastFirst=-1;this.vsLastLast=-1;this.vsLastScrollTop=0;this.vsPersistentScrollTop=0;this.vsRowHeight=0;this.coverObserver=null;this.observedCovers=new Set;this.coverFetchFailed=new Set;this.container=s,this.plugin=t,this.readingData=e,this.dataChangeListener=()=>this.renderContent(),this.readingData.onChange(this.dataChangeListener)}get activeSubTab(){var s;return at.sessionSubTab!==null?at.sessionSubTab:(s=this.readingData.getSettings().defaultSubTab)!=null?s:"books"}set activeSubTab(s){at.sessionSubTab=s}get filters(){return at.sessionFilters[this.activeSubTab]}get sort(){return at.sessionSort[this.activeSubTab]}activeColumns(){return this.activeSubTab==="books"?this.readingData.getBookColumns():this.readingData.getMangaColumns()}destroy(){this.readingData.offChange(this.dataChangeListener),this.closePopover(),this.destroyVirtualScroll();for(let s of this.activeCleanups)s();this.activeCleanups=[]}render(){this.container.empty(),this.container.addClass("wl-reading-tab"),this.renderSubTabs(),this.renderToolbar(),this.contentEl=this.container.createDiv({cls:"wl-reading-content"}),this.renderContent()}renderSubTabs(){let s=this.container.createDiv({cls:"wl-reading-subtabs"}),t=s.createEl("button",{cls:`wl-reading-subtab${this.activeSubTab==="books"?" is-active":""}`,text:"Books"}),e=s.createEl("button",{cls:`wl-reading-subtab${this.activeSubTab==="manga"?" is-active":""}`,text:"Manga"});t.addEventListener("click",()=>{this.activeSubTab!=="books"&&(this.activeSubTab="books",this.searchQuery="",this.render())}),e.addEventListener("click",()=>{this.activeSubTab!=="manga"&&(this.activeSubTab="manga",this.searchQuery="",this.render())})}renderToolbar(){let s=this.container.createDiv({cls:"wl-reading-toolbar"}),t=s.createDiv({cls:"wl-reading-toolbar-left"}),e=t.createDiv({cls:"wl-reading-search-wrap"}),i=this.activeSubTab==="books"?"Search books...":"Search manga...",a=e.createEl("input",{cls:"wl-reading-search-input",attr:{type:"text",placeholder:i}});a.value=this.searchQuery;let n=0;a.addEventListener("input",()=>{this.searchQuery=a.value,window.clearTimeout(n),n=window.setTimeout(()=>this.renderContent(),200)});let r=this.getSavedFilter();if(r){this.savedFilterActive=this.filtersMatchSaved(r);let u=t.createEl("button",{cls:`wl-btn wl-btn-sm${this.savedFilterActive?" wl-btn-preset-active":""}`,text:"Saved filter"});this.savedFilterBtnEl=u,u.addEventListener("click",()=>{at.sessionFilters[this.activeSubTab]={statusExclude:wt.filter(h=>!r.statusInclude.includes(h)),ratingMode:r.ratingMode,customExclude:{}},this.savedFilterActive=!0,u.addClass("wl-btn-preset-active"),this.refreshFilterButtonBadge(),this.render()})}else this.savedFilterBtnEl=null;if(this.filterBtn=t.createEl("button",{cls:"wl-btn wl-btn-sm wl-reading-filter-btn",text:"Filter"}),this.refreshFilterButtonBadge(),this.filterBtn.addEventListener("click",u=>{u.stopPropagation(),this.toggleFilterPopover()}),this.resetBtn=t.createEl("button",{cls:"wl-btn wl-btn-sm wl-reading-reset-btn",text:"Reset"}),this.resetBtn.addEventListener("click",()=>{var u;at.sessionFilters[this.activeSubTab]=Rs(),this.deactivateSavedFilter(),this.closePopover(),(u=this.filterBtn)==null||u.removeClass("is-popover-open"),this.refreshFilterButtonBadge(),this.renderContent()}),this.refreshFilterButtonBadge(),this.sortBtn=t.createEl("button",{cls:"wl-btn wl-btn-sm wl-reading-sort-btn",text:"Sort"}),this.sortBtn.addEventListener("click",u=>{u.stopPropagation(),this.toggleSortPopover()}),this.selectionMode&&this.selectedIds.size>0&&this.renderSelectionActionBar(t),this.selectionMode){let u=this.selectedIds.size>0,h=t.createEl("button",{cls:"wl-btn wl-btn-sm",text:u?"None":"All"});h.title=u?"Deselect all":"Select all visible",h.addEventListener("click",()=>{if(this.selectedIds.size>0)this.selectedIds.clear();else for(let p of this.vsDisplayItems)this.selectedIds.add(p.id);this.render()})}t.createEl("button",{cls:`wl-btn wl-btn-sm${this.selectionMode?" is-active":""}`,text:"Select"}).addEventListener("click",()=>{this.selectionMode=!this.selectionMode,this.selectedIds.clear(),this.render()});let l=s.createDiv({cls:"wl-reading-toolbar-right"});l.createEl("button",{cls:"wl-btn wl-btn-sm wl-reading-manage-btn",attr:{"aria-label":"Manage columns",title:"Manage columns"},text:"\u2699"}).addEventListener("click",()=>this.openManageColumns()),l.createEl("button",{cls:"wl-reading-add-btn wl-btn-success",text:this.activeSubTab==="books"?"+ Add book":"+ Add manga"}).addEventListener("click",()=>this.openAddModal())}renderSelectionActionBar(s){let t=s.createDiv({cls:"wl-reading-action-bar"}),e=t.createEl("button",{cls:"wl-group-action-btn wl-group-action-btn-delete wl-btn-danger",text:"\u2715"});e.title="Delete selected",e.addEventListener("click",a=>{a.stopPropagation();let n=this.selectedIds.size;new H(this.plugin.app,`Delete ${n} selected item${n!==1?"s":""}? This cannot be undone.`,()=>{(async()=>{let r=Array.from(this.selectedIds);this.activeSubTab==="books"?await this.readingData.removeBooksBatch(r):await this.readingData.removeMangaBatch(r),this.selectedIds.clear(),this.selectionMode=!1,this.render()})()}).open()});let i=t.createEl("select",{cls:"wl-select wl-select-sm"});i.createEl("option",{text:"Status\u2026",value:""});for(let a of lt)i.createEl("option",{text:a,value:a});i.addEventListener("change",()=>{(async()=>{let a=i.value;if(!a)return;let n=this.activeSubTab==="books";for(let r of this.selectedIds)if(n){let o=this.readingData.getBook(r);o&&this.readingData.updateBookSilent({...o,status:a})}else{let o=this.readingData.getManga(r);o&&this.readingData.updateMangaSilent({...o,status:a})}await this.readingData.saveAndNotify(),i.value="",this.selectedIds.clear(),this.selectionMode=!1,this.render()})()})}refreshFilterButtonBadge(){if(!this.filterBtn)return;let s=Ua(this.filters);this.filterBtn.empty(),this.filterBtn.createSpan({text:"Filter"}),s>0&&this.filterBtn.createSpan({cls:"wl-reading-filter-badge",text:String(s)}),this.resetBtn&&(this.resetBtn.style.display=s>0?"":"none")}closePopover(){this.openPopover&&(this.openPopover.remove(),this.openPopover=null),this.popoverCleanup&&(this.popoverCleanup(),this.popoverCleanup=null)}openPopoverAt(s,t,e){this.closePopover();let i=this.container.createDiv({cls:t}),a=s.getBoundingClientRect(),n=this.container.getBoundingClientRect();i.style.position="absolute",i.style.top=`${a.bottom-n.top+4}px`,i.style.left=`${a.left-n.left}px`,i.style.right="auto",e(i),this.openPopover=i;let r=l=>{i.contains(l.target)||this.closePopover()},o=l=>{l.key==="Escape"&&this.closePopover()};window.setTimeout(()=>activeDocument.addEventListener("click",r),0),activeDocument.addEventListener("keydown",o),this.popoverCleanup=()=>{activeDocument.removeEventListener("click",r),activeDocument.removeEventListener("keydown",o)}}toggleFilterPopover(){var s,t,e;if(this.openPopover&&((s=this.filterBtn)!=null&&s.hasClass("is-popover-open"))){this.closePopover(),this.filterBtn.removeClass("is-popover-open");return}(t=this.sortBtn)==null||t.removeClass("is-popover-open"),(e=this.filterBtn)==null||e.addClass("is-popover-open"),this.openPopoverAt(this.filterBtn,"wl-dropdown wl-filters-panel",i=>this.buildFilterPopover(i))}toggleSortPopover(){var s,t,e;if(this.openPopover&&((s=this.sortBtn)!=null&&s.hasClass("is-popover-open"))){this.closePopover(),this.sortBtn.removeClass("is-popover-open");return}(t=this.filterBtn)==null||t.removeClass("is-popover-open"),(e=this.sortBtn)==null||e.addClass("is-popover-open"),this.openPopoverAt(this.sortBtn,"wl-dropdown wl-sorting-panel",i=>this.buildSortPopover(i))}getSavedFilter(){var s,t;return(t=(s=this.readingData.getSettings().savedFilters)==null?void 0:s[this.activeSubTab])!=null?t:null}filtersMatchSaved(s){let t=this.filters;if(Object.values(t.customExclude).some(a=>a.length>0))return!1;let e=new Set(wt.filter(a=>!s.statusInclude.includes(a))),i=new Set(t.statusExclude);if(i.size!==e.size)return!1;for(let a of i)if(!e.has(a))return!1;return t.ratingMode===s.ratingMode}deactivateSavedFilter(){var s;this.savedFilterActive&&(this.savedFilterActive=!1,(s=this.savedFilterBtnEl)==null||s.removeClass("wl-btn-preset-active"))}buildFilterPopover(s){var i;let t={statusExclude:new Set(this.filters.statusExclude),ratingMode:this.filters.ratingMode,customExclude:new Map};for(let a of this.activeColumns())t.customExclude.set(a.id,new Set((i=this.filters.customExclude[a.id])!=null?i:[]));let e={};this.renderFilterPopoverBody(s,t,e)}addFilterSection(s,t,e,i,a){var c,d;let n=s.createDiv({cls:"wl-filter-section"}),r=n.createDiv({cls:"wl-filter-section-header"});r.createSpan({cls:"wl-filter-label",text:i});let o=r.createSpan({cls:"wl-filter-chevron",text:(c=t[e])==null||c?"\u25BC":"\u25B2"}),l=n.createDiv({cls:"wl-filter-section-content"});l.toggleClass("wl-hidden",(d=t[e])!=null?d:!0),r.addEventListener("click",u=>{var p;u.stopPropagation();let h=!((p=t[e])==null||p);t[e]=h,l.toggleClass("wl-hidden",h),o.textContent=h?"\u25BC":"\u25B2"}),a(l)}addMultiSelectBody(s,t,e){let i=[],a=s.createDiv({cls:"wl-filter-checkbox-row"}),n=a.createEl("input",{attr:{type:"checkbox"}});a.createSpan({cls:"wl-filter-all-toggle",text:"\u25C6 All"});let r=()=>{n.checked=i.length>0&&i.every(l=>l.checked)};for(let l of t){let c=s.createDiv({cls:"wl-filter-checkbox-row"}),d=c.createEl("input",{attr:{type:"checkbox"}});d.checked=!e.has(l),c.createSpan({text:l}),i.push(d),d.addEventListener("change",()=>{d.checked?e.delete(l):e.add(l),r()})}r();let o=()=>{if(i.length>0&&i.every(c=>c.checked)){for(let c of i)c.checked=!1;for(let c of t)e.add(c)}else{for(let c of i)c.checked=!0;e.clear()}r()};return n.addEventListener("click",l=>{l.stopPropagation(),o()}),a.addEventListener("click",l=>{l.target!==n&&(l.stopPropagation(),o())}),{selectAll:()=>{for(let l of i)l.checked=!0;e.clear(),r()},deselectAll:()=>{for(let l of i)l.checked=!1;for(let l of t)e.add(l);r()}}}addHasValueBody(s,t){let e=[];for(let[i,a]of[[mi,"Has value"],[vi,"No value"]]){let n=s.createDiv({cls:"wl-filter-checkbox-row"}),r=n.createEl("input",{attr:{type:"checkbox"}});r.checked=!t.has(i),n.createSpan({text:a}),r.addEventListener("change",()=>{r.checked?t.delete(i):t.add(i)}),e.push({key:i,cb:r})}return{selectAll:()=>{for(let i of e)i.cb.checked=!0,t.delete(i.key)},deselectAll:()=>{for(let i of e)i.cb.checked=!1,t.add(i.key)}}}renderFilterPopoverBody(s,t,e){var d;s.empty();let i=[],a=s.createDiv({cls:"wl-filter-global-btns"});a.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Select all"}).addEventListener("click",u=>{u.stopPropagation();for(let h of i)h.selectAll()}),a.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Deselect all"}).addEventListener("click",u=>{u.stopPropagation();for(let h of i)h.deselectAll()});let o=a.createEl("button",{cls:"wl-btn wl-btn-sm",text:this.getSavedFilter()?"Delete":"Save"});o.addEventListener("click",u=>{u.stopPropagation(),(async()=>{var p,m;let h={...(p=this.readingData.getSettings().savedFilters)!=null?p:{}};o.textContent==="Save"?(h[this.activeSubTab]={name:"Saved filter",statusInclude:wt.filter(v=>!t.statusExclude.has(v)),ratingMode:t.ratingMode},await this.readingData.updateSettings({savedFilters:h})):(delete h[this.activeSubTab],await this.readingData.updateSettings({savedFilters:h}),this.savedFilterActive=!1),this.closePopover(),(m=this.filterBtn)==null||m.removeClass("is-popover-open"),this.render()})()}),this.addFilterSection(s,e,"Status","Status",u=>{i.push(this.addMultiSelectBody(u,wt,t.statusExclude))}),this.addFilterSection(s,e,"Rating","Rating",u=>{let h=[{key:"all",label:"All"},{key:"has",label:"Has rating"},{key:"none",label:"No rating"}],p=`wl-reading-rating-${Date.now()}`;for(let m of h){let v=u.createDiv({cls:"wl-filter-checkbox-row"}),f=v.createEl("input",{attr:{type:"radio",name:p}});f.checked=t.ratingMode===m.key,v.createSpan({text:m.label}),f.addEventListener("change",()=>{f.checked&&(t.ratingMode=m.key)})}});for(let u of this.activeColumns()){let h=(d=t.customExclude.get(u.id))!=null?d:new Set;t.customExclude.set(u.id,h),this.addFilterSection(s,e,`custom:${u.id}`,u.name,p=>{u.type==="select"?i.push(this.addMultiSelectBody(p,u.options,h)):i.push(this.addHasValueBody(p,h))})}let l=s.createDiv();l.style.padding="6px 12px 2px",l.createEl("button",{cls:"wl-btn wl-btn-sm wl-filter-apply-btn",text:"Apply"}).addEventListener("click",u=>{var p;u.stopPropagation();let h={};for(let[m,v]of t.customExclude)v.size>0&&(h[m]=Array.from(v));at.sessionFilters[this.activeSubTab]={statusExclude:Array.from(t.statusExclude),ratingMode:t.ratingMode,customExclude:h},this.deactivateSavedFilter(),this.refreshFilterButtonBadge(),this.closePopover(),(p=this.filterBtn)==null||p.removeClass("is-popover-open"),this.renderContent()})}buildSortPopover(s){let t=[{key:"dateAdded",label:"Date added"},{key:"title",label:"Title"},{key:"author",label:"Author"},{key:"rating",label:"Rating"},{key:"progress",label:"Progress"},{key:"status",label:"Status"},{key:"dateStarted",label:"Date started"},{key:"dateFinished",label:"Date finished"}];for(let i of this.activeColumns())t.push({key:`custom:${i.id}`,label:i.name});let e=(i,a,n,r)=>{let o=s.createDiv({cls:"wl-filter-row"});o.createSpan({cls:"wl-filter-label",text:i});let l=o.createEl("select",{cls:"wl-select"});if(!r){let d=l.createEl("option",{text:"None",value:"none"});a==="none"&&(d.selected=!0)}for(let d of t){let u=l.createEl("option",{text:d.label,value:d.key});d.key===a&&(u.selected=!0)}let c=o.createEl("button",{cls:"wl-btn wl-btn-sm wl-sort-dir-btn",text:n==="asc"?"\u2191":"\u2193"});c.title=n==="asc"?"Ascending \u2014 click to switch to descending":"Descending \u2014 click to switch to ascending",l.addEventListener("change",()=>{var d;r?this.sort.key=l.value:this.sort.secondKey=l.value==="none"?"none":l.value,this.closePopover(),(d=this.sortBtn)==null||d.removeClass("is-popover-open"),this.renderContent()}),c.addEventListener("click",d=>{var u;d.stopPropagation(),r?this.sort.dir=this.sort.dir==="asc"?"desc":"asc":this.sort.secondDir=this.sort.secondDir==="asc"?"desc":"asc",this.closePopover(),(u=this.sortBtn)==null||u.removeClass("is-popover-open"),this.renderContent()})};e("Sort by",this.sort.key,this.sort.dir,!0),e("Then by",this.sort.secondKey,this.sort.secondDir,!1)}applyFilters(s){let t=this.filters,e=this.activeColumns();return s.filter(i=>{var a;if(t.statusExclude.includes(i.status)||t.ratingMode==="has"&&!(i.rating>0)||t.ratingMode==="none"&&i.rating>0)return!1;for(let n of e){let r=t.customExclude[n.id];if(!(!r||r.length===0))if(n.type==="select"){let o=(a=i.customFields)==null?void 0:a[n.id],l=o==null?"":String(o);if(l!==""&&r.includes(l))return!1}else{let o=_a(i,n.id);if(o&&r.includes(mi)||!o&&r.includes(vi))return!1}}return!0})}applySearch(s){let t=this.searchQuery.trim();return t?new st(s,{keys:["title","author"],threshold:.4,ignoreLocation:!0}).search(t).map(i=>i.item):s}compareBySortKey(s,t,e,i,a){var n,r,o,l;if(e.startsWith("custom:"))return this.compareCustom(s,t,e.slice(7),i);switch(e){case"title":return s.title.localeCompare(t.title)*i;case"author":return(s.author||"").localeCompare(t.author||"")*i;case"rating":return(s.rating-t.rating)*i;case"dateAdded":return s.dateAdded.localeCompare(t.dateAdded)*i;case"dateStarted":return((n=s.dateStarted)!=null?n:"").localeCompare((r=t.dateStarted)!=null?r:"")*i;case"dateFinished":return((o=s.dateFinished)!=null?o:"").localeCompare((l=t.dateFinished)!=null?l:"")*i;case"status":return s.status.localeCompare(t.status)*i;case"progress":{let c=a?yi(s):bi(s),d=a?yi(t):bi(t);return(c-d)*i}default:return 0}}compareCustom(s,t,e,i){var c,d;let a=this.activeColumns().find(u=>u.id===e),n=(c=s.customFields)==null?void 0:c[e],r=(d=t.customFields)==null?void 0:d[e],o=n==null||n==="",l=r==null||r==="";return o&&l?0:o?1:l?-1:(a==null?void 0:a.type)==="number"?(Number(n)-Number(r))*i:String(n).localeCompare(String(r))*i}applySort(s){let{key:t,dir:e,secondKey:i,secondDir:a}=this.sort,n=this.activeSubTab==="books",r=e==="asc"?1:-1,o=a==="asc"?1:-1;return[...s].sort((l,c)=>{let d=this.compareBySortKey(l,c,t,r,n);return d!==0||i==="none"?d:this.compareBySortKey(l,c,i,o,n)})}renderContent(){if(!this.contentEl)return;this.destroyVirtualScroll();for(let n of this.activeCleanups)n();this.activeCleanups=[],this.contentEl.empty();let t=this.activeSubTab==="books"?this.readingData.getBooks():this.readingData.getMangaList();if(t.length===0){this.renderEmptyState();return}let e=this.applyFilters(t),i=this.applySearch(e),a=this.applySort(i);if(a.length===0){let n=this.searchQuery.trim();this.contentEl.createDiv({cls:"wl-reading-empty-search",text:n?`No matches for "${n}".`:"No items match the current filters."});return}this.renderResultsCount(a.length),this.vsDisplayItems=a,this.renderVirtualGrid()}renderResultsCount(s){if(!this.contentEl)return;let t=this.contentEl.createDiv({cls:"wl-results-count"});t.textContent=`${s} title${s!==1?"s":""}`}destroyVirtualScroll(){this.destroyCoverObserver(),this.vsScrollRAF!==null&&(window.cancelAnimationFrame(this.vsScrollRAF),this.vsScrollRAF=null),this.vsResizeObserver&&(this.vsResizeObserver.disconnect(),this.vsResizeObserver=null),this.vsScrollContainer&&this.vsScrollHandler&&this.vsScrollContainer.removeEventListener("scroll",this.vsScrollHandler),this.vsScrollHandler=null,this.vsScrollContainer=null,this.vsScrollSpacer=null,this.vsGridEl=null,this.vsDisplayItems=[],this.vsLastFirst=-1,this.vsLastLast=-1,this.vsLastScrollTop=0,this.vsRowHeight=0}renderVirtualGrid(){if(!this.contentEl)return;let s=this.contentEl.createDiv({cls:"wl-reading-scroll-container"}),t=s.createDiv({cls:"wl-reading-scroll-spacer"}),e=t.createDiv({cls:"wl-reading-grid wl-reading-grid-virtual"});this.vsScrollContainer=s,this.vsScrollSpacer=t,this.vsGridEl=e,this.vsLastFirst=-1,this.vsLastLast=-1,this.vsScrollHandler=()=>{let i=s.scrollTop;this.vsPersistentScrollTop=i;let a=this.vsRowHeight>0?this.vsRowHeight/2:50;Math.abs(i-this.vsLastScrollTop)<a||(this.vsLastScrollTop=i,this.vsScrollRAF===null&&(this.vsScrollRAF=window.requestAnimationFrame(()=>{this.vsScrollRAF=null,this.renderVisibleCards()})))},s.addEventListener("scroll",this.vsScrollHandler,{passive:!0}),this.vsResizeObserver=new ResizeObserver(()=>{this.renderVisibleCards()}),this.vsResizeObserver.observe(s),this.vsPersistentScrollTop>0&&(this.renderVisibleCards(),s.scrollTop=this.vsPersistentScrollTop,this.vsLastScrollTop=this.vsPersistentScrollTop,this.renderVisibleCards())}getGridMetrics(s){let i=Math.max(1,Math.floor((s+12)/152)),a=(s-12*(i-1))/i,r=a*1.5+72,o=r+12;return{cols:i,cardWidth:a,cardHeight:r,rowHeight:o}}renderVisibleCards(){let s=this.vsScrollContainer,t=this.vsScrollSpacer,e=this.vsGridEl;if(!s||!t||!e)return;let i=s.scrollTop,a=s.clientHeight,n=s.clientWidth;if(n<=0||a<=0)return;let{cols:r,cardHeight:o,rowHeight:l}=this.getGridMetrics(n);this.vsRowHeight=l;let c=this.vsDisplayItems.length,d=Math.ceil(c/r),u=Math.max(0,Math.floor(i/l)-2),h=Math.min(d-1,Math.ceil((i+a)/l)+2),p=u*r,m=Math.min(c-1,(h+1)*r-1);t.style.height=`${d*l}px`,e.style.transform=`translateY(${u*l}px)`,e.style.setProperty("--wl-reading-cols",String(r)),e.style.setProperty("--wl-reading-card-height",`${o}px`);let v=this.vsLastFirst,f=this.vsLastLast;if(p===v&&m===f)return;this.vsLastFirst=p,this.vsLastLast=m,e.empty();let y=this.activeSubTab==="books",w=activeDocument.createDocumentFragment();for(let b=p;b<=m;b++){let S=this.vsDisplayItems[b];if(!S)continue;let D=activeDocument.createElement("div");y?this.renderBookCard(D,S):this.renderMangaCard(D,S);let E=D.firstElementChild;E&&w.appendChild(E)}e.appendChild(w),this.setupCoverObserver()}setupCoverObserver(){let s=this.vsScrollContainer,t=this.vsGridEl;!s||!t||(this.coverObserver||(this.coverObserver=new IntersectionObserver(e=>this.handleCoverIntersection(e),{root:s,rootMargin:"0px",threshold:0})),this.observedCovers.clear(),t.querySelectorAll('.wl-reading-card[data-needs-cover="true"]').forEach(e=>{let i=e;this.observedCovers.has(i)||(this.coverObserver.observe(i),this.observedCovers.add(i))}))}handleCoverIntersection(s){var e;let t=this.activeSubTab==="books"?"book":"manga";for(let i of s){if(!i.isIntersecting)continue;let a=i.target;(e=this.coverObserver)==null||e.unobserve(a),this.observedCovers.delete(a);let n=a.dataset.itemId;if(!n)continue;let r=t==="book"?this.readingData.getBook(n):this.readingData.getManga(n);if(!r||r.coverUrl||this.coverFetchFailed.has(n))continue;let o=a.querySelector(".wl-reading-card-cover");o==null||o.addClass("is-loading"),this.resolveCover(t,r).then(({url:l,sourceId:c})=>{o==null||o.removeClass("is-loading"),l?(c?this.readingData.updateCoverAndSource(t,n,l,c):this.readingData.updateCoverUrl(t,n,l),this.applyCoverToCard(a,l,r.title)):this.coverFetchFailed.add(n)})}}async resolveCover(s,t){var e,i;try{if(s==="book"){let o=t.googleBooksId;if(o){let u=await this.plugin.apiService.getGoogleBookById(o);return{url:(e=u==null?void 0:u.coverUrl)!=null?e:"",sourceId:""}}let l=t.author?`${t.title} ${t.author}`:t.title,d=(await this.plugin.apiService.searchGoogleBooks(l))[0];return!d||!d.coverUrl||!d.googleBooksId?{url:"",sourceId:""}:{url:d.coverUrl,sourceId:d.googleBooksId}}let a=t.malId;if(a){let o=await this.plugin.apiService.getMangaByMalId(Number(a));return{url:(i=o==null?void 0:o.coverUrl)!=null?i:"",sourceId:""}}let r=(await this.plugin.apiService.searchManga(t.title))[0];return!r||!r.coverUrl||!r.malId?{url:"",sourceId:""}:{url:r.coverUrl,sourceId:String(r.malId)}}catch(a){return{url:"",sourceId:""}}}applyCoverToCard(s,t,e){let i=s.querySelector(".wl-reading-card-cover");if(!i)return;let a=i.querySelector(".wl-reading-card-cover-icon"),n=activeDocument.createElement("img");n.className="wl-reading-card-cover-img",n.alt=e,n.loading="lazy",n.onload=()=>{i.style.backgroundColor="",a==null||a.remove()},n.onerror=()=>{n.remove()},i.insertBefore(n,i.firstChild),n.src=t,delete s.dataset.needsCover}destroyCoverObserver(){this.coverObserver&&(this.coverObserver.disconnect(),this.coverObserver=null),this.observedCovers.clear()}renderBookCard(s,t){let e=this.renderCardShell(s,t,"\u{1F4D6}"),i=t.totalPages>0?Math.min(100,Math.round(t.pagesRead/t.totalPages*100)):0;this.renderProgressBar(e,i,t.status);let a=e.createDiv({cls:"wl-reading-card-progress-text"});t.totalPages>0?a.textContent=`${t.pagesRead} / ${t.totalPages} pages`:t.pagesRead>0?a.textContent=`${t.pagesRead} pages`:a.textContent="No progress",this.bindCardClick(e,"book",t.id)}renderMangaCard(s,t){let e=this.renderCardShell(s,t,"\u{1F4D3}"),i=t.totalChapters>0?Math.min(100,Math.round(t.chaptersRead/t.totalChapters*100)):0;this.renderProgressBar(e,i,t.status);let a=e.createDiv({cls:"wl-reading-card-progress-text"}),n=t.totalChapters>0?`Ch. ${t.chaptersRead}/${t.totalChapters}`:t.chaptersRead>0?`Ch. ${t.chaptersRead}`:"",r=t.totalVolumes>0?`Vol. ${t.volumesRead}/${t.totalVolumes}`:t.volumesRead>0?`Vol. ${t.volumesRead}`:"";n&&a.createSpan({cls:"wl-reading-card-progress-piece",text:n}),r&&a.createSpan({cls:"wl-reading-card-progress-piece",text:r}),!n&&!r&&(a.textContent="No progress"),this.bindCardClick(e,"manga",t.id)}bindCardClick(s,t,e){this.selectionMode?(this.selectedIds.has(e)&&s.addClass("is-selected"),s.addEventListener("click",i=>{i.stopPropagation(),this.selectedIds.has(e)?(this.selectedIds.delete(e),s.removeClass("is-selected")):(this.selectedIds.add(e),s.addClass("is-selected")),this.render()})):s.addEventListener("click",()=>this.openDetailModal(t,e))}renderCardShell(s,t,e){let i=s.createDiv({cls:"wl-reading-card"}),a=i.createDiv({cls:"wl-reading-card-cover"});t.coverUrl?a.createEl("img",{cls:"wl-reading-card-cover-img",attr:{src:t.coverUrl,alt:t.title,loading:"lazy"}}):(a.style.backgroundColor=Ls(t.id),a.createSpan({cls:"wl-reading-card-cover-icon",text:e}),this.coverFetchFailed.has(t.id)||(i.dataset.needsCover="true",i.dataset.itemId=t.id));let n=a.createSpan({cls:"wl-reading-card-status-badge",text:t.status});n.style.backgroundColor=re(t.status);let r=i.createEl("button",{cls:"wl-reading-card-menu-btn",text:"\u22EE"});r.setAttr("aria-label","More actions"),r.addEventListener("click",c=>{c.stopPropagation(),this.openCardContextMenu(i,r,t)});let o=i.createDiv({cls:"wl-reading-card-title",text:t.title});o.title=t.title;let l=i.createDiv({cls:"wl-reading-card-author",text:t.author||"\u2014"});return l.title=t.author||"",i}openCardContextMenu(s,t,e){this.container.querySelectorAll(".wl-reading-card-context-menu").forEach(d=>d.remove());let i=s.createDiv({cls:"wl-reading-card-context-menu"}),a=i.createDiv({cls:"wl-reading-card-context-item",text:"Refresh cover"}),n=s.getBoundingClientRect(),r=this.container.getBoundingClientRect();n.right>r.right-180&&i.classList.add("is-right-aligned");let o=s.ownerDocument,l=()=>{i.remove(),o.removeEventListener("click",c,!0)},c=d=>{!i.contains(d.target)&&d.target!==t&&l()};window.setTimeout(()=>o.addEventListener("click",c,!0),0),this.activeCleanups.push(()=>o.removeEventListener("click",c,!0)),a.addEventListener("click",d=>{d.stopPropagation(),l(),this.refreshCover(e)})}refreshCover(s){if((this.activeSubTab==="books"?"book":"manga")==="book"){let i=s;if(!i.googleBooksId){new mt.Notice("No Google Books ID \u2014 cannot refresh cover.");return}if(!this.plugin.apiService.hasGoogleBooksKey()){new mt.Notice("Google Books API key required \u2014 add one in Settings \u2192 API \u2192 Books.");return}(async()=>{try{let a=await this.plugin.apiService.getGoogleBookById(i.googleBooksId);if(!a||!a.coverUrl){new mt.Notice("Could not fetch cover from API.");return}let n=this.readingData.getBook(i.id);if(!n)return;await this.readingData.updateBook({...n,coverUrl:a.coverUrl}),new mt.Notice("Cover refreshed.")}catch(a){new mt.Notice(Et(a))}})()}else{let i=s;if(!i.malId){new mt.Notice("No MAL ID \u2014 cannot refresh cover.");return}(async()=>{try{let a=await this.plugin.apiService.getMangaByMalId(Number(i.malId));if(!a||!a.coverUrl){new mt.Notice("Could not fetch cover from API.");return}let n=this.readingData.getManga(i.id);if(!n)return;await this.readingData.updateManga({...n,coverUrl:a.coverUrl}),new mt.Notice("Cover refreshed.")}catch(a){new mt.Notice("Failed to refresh cover.")}})()}}renderProgressBar(s,t,e){let a=s.createDiv({cls:"wl-reading-card-progress-bar"}).createDiv({cls:"wl-reading-card-progress-fill"});a.style.width=`${t}%`,a.style.backgroundColor=re(e)}renderEmptyState(){if(!this.contentEl)return;let s=this.contentEl.createDiv({cls:"wl-reading-empty"}),t=this.activeSubTab==="books";s.createSpan({cls:"wl-reading-empty-icon",text:t?"\u{1F4D6}":"\u{1F4D3}"});let e=s.createDiv({cls:"wl-reading-empty-msg"});e.createSpan({text:t?"No books yet \u2014 ":"No manga yet \u2014 "}),e.createEl("a",{cls:"wl-reading-empty-link",text:t?"add your first book":"add your first manga"}).addEventListener("click",a=>{a.preventDefault(),this.openAddModal()})}openAddModal(){new Ot(this.plugin.app,this.plugin,this.readingData,this.activeSubTab==="books"?"book":"manga",()=>this.renderContent()).open()}openManageColumns(){new Nt(this.plugin.app,this.plugin,this.readingData,this.activeSubTab==="books"?"book":"manga",()=>this.renderContent()).open()}openDetailModal(s,t){new He(this.plugin.app,this.plugin,this.readingData,s,t,()=>this.renderContent()).open()}};at.sessionSubTab=null,at.sessionFilters={books:Rs(),manga:Rs()},at.sessionSort={books:fi(),manga:fi()};var _e=at;var Va=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],Ga=["January","February","March","April","May","June","July","August","September","October","November","December"],Ka={completed:"#1D9E75",watched:"#1D9E75",added:"#378ADD",deleted:"#E24B4A",status:"#BA7517",rating:"#BA7517"},za={Reading:"#1D9E75",Watchlist:"#378ADD"};function Si(g){if(g.action)return g.action;let s=g.message.toLowerCase();return s.includes("was added")?"added":s.includes("was deleted")?"deleted":s.includes("was reviewed")||s.includes("rating changed")?"rating":s.includes("episode")&&s.includes("watched")?"watched":s.includes("was marked as watched")||s.includes("completed")?"completed":s.includes("status")?"status":"added"}function Ei(g){return g.source?g.source:"Watchlist"}function qa(g){let s=Si(g),t=g.message;switch(s){case"added":return"Added";case"deleted":return"Deleted";case"rating":{let e=t.match(/Rating → (\d+\/5)/);return e?`Rating \u2192 ${e[1]}`:"Rating changed"}case"status":{let e=t.match(/status changed to (.+)$/);return e?`Status \u2192 ${e[1]}`:"Status changed"}case"completed":return"Completed";case"watched":{let e=t.match(/At (page|chapter|volume) (\d+) \/ (\d+)/i);if(e)return`At ${e[1]} ${e[2]} / ${e[3]}`;let i=t.match(/episode (\d+)/i);return i?`Episode ${i[1]} watched`:"Watched"}}return"Updated"}function ja(g){let s=g.message.match(/\(([^)]+)\)/);return s?s[1]:""}function Qa(g){let s;if(g.titleName)s=g.titleName;else{let e=g.message.match(/^(.+?)\s*\(/);s=e?e[1]:g.message}let t=ja(g);return t?`${s} (${t})`:s}function Ya(g){let s=new Date(g+"T12:00:00");return`${Va[s.getDay()]}, ${s.getDate()} ${Ga[s.getMonth()]} ${s.getFullYear()}`}function Ja(g){let s=new Date(g);return`${String(s.getHours()).padStart(2,"0")}:${String(s.getMinutes()).padStart(2,"0")}`}function Xa(g){let s=new Date(g);return`${s.getFullYear()}-${String(s.getMonth()+1).padStart(2,"0")}-${String(s.getDate()).padStart(2,"0")}`}var As=32,Is=40,Ue=class{constructor(s,t){this.sourceFilter="all";this.scrollContainer=null;this.spacer=null;this.viewport=null;this.rows=[];this.rowOffsets=[];this.totalHeight=0;this.scrollHandler=null;this.scrollRAF=null;this.resizeObserver=null;this.lastFirst=-1;this.lastLast=-1;this.renderedNodes=new Map;this.container=s,this.plugin=t}destroy(){this.destroyVirtualScroll()}render(){var e,i;this.destroyVirtualScroll(),this.container.empty(),this.container.addClass("wl-log-tab"),this.renderFilterBar();let s=(i=(e=this.plugin.historyManager)==null?void 0:e.getEntries())!=null?i:[],t=this.sourceFilter==="all"?s:s.filter(a=>Ei(a)===this.sourceFilter);if(t.length===0){this.container.createDiv({cls:"wl-log-empty",text:this.sourceFilter==="all"?"No events yet. Actions from Watchlist and Reading will appear here.":`No ${this.sourceFilter} events yet.`});return}this.buildVirtualRows(t),this.renderVirtualTimeline()}renderFilterBar(){let s=this.container.createDiv({cls:"wl-log-filter-bar"}),t=[{label:"All",value:"all"},{label:"Watchlist",value:"Watchlist"},{label:"Reading",value:"Reading"}];for(let e of t)s.createEl("button",{cls:`wl-log-filter-btn${this.sourceFilter===e.value?" is-active":""}`,text:e.label}).addEventListener("click",()=>{this.sourceFilter!==e.value&&(this.sourceFilter=e.value,this.render())})}buildVirtualRows(s){this.rows=[],this.rowOffsets=[];let t=new Map;for(let a of s){let n=Xa(a.timestamp),r=t.get(n);r||(r=[],t.set(n,r)),r.push(a)}let e=[];for(let[a,n]of t)e.push({date:a,entries:n});let i=0;for(let a of e){this.rows.push({type:"header",date:a.date}),this.rowOffsets.push(i),i+=Is;for(let n=0;n<a.entries.length;n++)this.rows.push({type:"entry",entry:a.entries[n],isLastInGroup:n===a.entries.length-1}),this.rowOffsets.push(i),i+=As}this.totalHeight=i}renderVirtualTimeline(){let s=this.container.createDiv({cls:"wl-log-timeline wl-log-virtual-scroll"}),t=s.createDiv({cls:"wl-log-spacer"}),e=t.createDiv({cls:"wl-log-viewport"});this.scrollContainer=s,this.spacer=t,this.viewport=e,this.renderedNodes.clear(),this.lastFirst=-1,this.lastLast=-1,t.style.height=`${this.totalHeight}px`,t.style.position="relative",e.style.position="absolute",e.style.top="0",e.style.left="0",e.style.right="0",e.style.height=`${this.totalHeight}px`,this.scrollHandler=()=>{this.scrollRAF===null&&(this.scrollRAF=window.requestAnimationFrame(()=>{this.scrollRAF=null,this.renderVisibleRows()}))},s.addEventListener("scroll",this.scrollHandler,{passive:!0}),this.resizeObserver=new ResizeObserver(()=>{this.renderedNodes.clear(),this.viewport&&this.viewport.empty(),this.lastFirst=-1,this.lastLast=-1,this.renderVisibleRows()}),this.resizeObserver.observe(s),this.renderVisibleRows()}renderVisibleRows(){var r;let s=this.scrollContainer,t=this.viewport;if(!s||!t)return;let e=s.scrollTop,i=s.clientHeight;if(i<=0)return;let a=this.findFirstVisible(e),n=this.findLastVisible(e+i);if(!(a===this.lastFirst&&n===this.lastLast)){this.lastFirst=a,this.lastLast=n;for(let[o,l]of this.renderedNodes)(o<a||o>n)&&(l.remove(),this.renderedNodes.delete(o));for(let o=a;o<=n;o++){if(this.renderedNodes.has(o))continue;let l=this.rows[o];if(!l)continue;let c;l.type==="header"?(c=activeDocument.createElement("div"),c.className="wl-log-day-header",c.style.height=`${Is}px`,c.textContent=Ya(l.date)):c=this.buildEntryRow(l);let d=(r=this.rowOffsets[o])!=null?r:0;c.style.position="absolute",c.style.top=`${d}px`,c.style.left="0",c.style.right="0",t.appendChild(c),this.renderedNodes.set(o,c)}}}buildEntryRow(s){var f,y;let t=s.entry,e=Si(t),i=(f=Ka[e])!=null?f:"#888780",a=Ei(t),n=(y=za[a])!=null?y:"#888780",r=activeDocument.createElement("div");r.className="wl-log-entry",r.style.height=`${As}px`;let o=activeDocument.createElement("div");o.className="wl-log-dot-col";let l=activeDocument.createElement("div");if(l.className="wl-log-dot",l.style.backgroundColor=n,o.appendChild(l),!s.isLastInGroup){let w=activeDocument.createElement("div");w.className="wl-log-connector",o.appendChild(w)}r.appendChild(o);let c=activeDocument.createElement("div");c.className="wl-log-content";let d=activeDocument.createElement("span");d.className="wl-log-source",d.textContent=a,c.appendChild(d);let u=activeDocument.createElement("span");u.className="wl-log-sep",u.textContent=" \xB7 ",c.appendChild(u);let h=activeDocument.createElement("span");h.className="wl-log-title",h.textContent=Qa(t),c.appendChild(h);let p=activeDocument.createElement("span");p.className="wl-log-sep",p.textContent=" \u2014 ",c.appendChild(p);let m=activeDocument.createElement("span");m.className="wl-log-action",m.textContent=qa(t),m.style.color=i,c.appendChild(m),r.appendChild(c);let v=activeDocument.createElement("div");return v.className="wl-log-time",v.textContent=Ja(t.timestamp),r.appendChild(v),r}findFirstVisible(s){let t=0,e=this.rows.length-1;for(;t<e;){let i=t+e>>>1;this.rowOffsets[i]+(this.rows[i].type==="header"?Is:As)<=s?t=i+1:e=i}return Math.max(0,t-2)}findLastVisible(s){let t=0,e=this.rows.length-1;for(;t<e;){let i=t+e+1>>>1;this.rowOffsets[i]>=s?e=i-1:t=i}return Math.min(this.rows.length-1,t+2)}destroyVirtualScroll(){this.scrollRAF!==null&&(window.cancelAnimationFrame(this.scrollRAF),this.scrollRAF=null),this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.scrollContainer&&this.scrollHandler&&this.scrollContainer.removeEventListener("scroll",this.scrollHandler),this.scrollHandler=null,this.scrollContainer=null,this.spacer=null,this.viewport=null,this.renderedNodes.clear(),this.lastFirst=-1,this.lastLast=-1}};var oe="watchlog-view",Di=["wl-dashboard","wl-list","wl-airtime","wl-reading-tab","wl-custom-lists","wl-log-tab"],kt=class extends Ht.ItemView{constructor(t,e,i){super(t);this.dashboardTab=null;this.listTab=null;this.airtimeTab=null;this.readingTab=null;this.customListsTab=null;this.draftsTab=null;this.logTab=null;this.airtimeBtn=null;this.draftsBtn=null;this.tabButtons={};this.tabContentEl=null;this.mobileResizeObserver=null;this.mobileSizerRaf=null;this.lastAppliedTabHeight=-1;this.editPinActive=!1;this.smallestEditHeight=-1;this.pinReleaseTimer=null;this.focusListenersBound=!1;this.overflowObserver=null;this.plugin=e,this.dataManager=i,this.activeTab="dashboard",this.dataChangeListener=()=>{this.refreshActiveTab()}}getViewType(){return oe}getDisplayText(){return"Watchlog"}getIcon(){return"tv"}async onOpen(){await super.onOpen(),this.dataManager.onChange(this.dataChangeListener),this.buildUI(),Ht.Platform.isMobile&&this.setupTabContentSizer()}setupTabContentSizer(){var t;(t=this.mobileResizeObserver)==null||t.disconnect(),this.mobileResizeObserver=new ResizeObserver(()=>{this.mobileSizerRaf===null&&(this.mobileSizerRaf=window.requestAnimationFrame(()=>{this.mobileSizerRaf=null,this.applyTabContentHeight()}))}),this.mobileResizeObserver.observe(this.contentEl),this.focusListenersBound||(this.registerDomEvent(this.contentEl,"focusin",e=>this.onScrollerFocusIn(e)),this.registerDomEvent(this.contentEl,"focusout",e=>this.onScrollerFocusOut(e)),this.focusListenersBound=!0)}isEditorInScroller(t){let e=t;if(!e||typeof e.tagName!="string")return!1;let i=this.contentEl.querySelector(":scope > .wl-tab-content");if(!i||!i.contains(e))return!1;let a=e.tagName.toLowerCase();return a==="input"||a==="textarea"||e.isContentEditable===!0}onScrollerFocusIn(t){!Ht.Platform.isMobile||!this.isEditorInScroller(t.target)||(this.pinReleaseTimer!==null&&(window.clearTimeout(this.pinReleaseTimer),this.pinReleaseTimer=null),this.editPinActive||(this.smallestEditHeight=-1),this.editPinActive=!0,this.lastAppliedTabHeight=-1,this.applyTabContentHeight())}onScrollerFocusOut(t){this.isEditorInScroller(t.target)&&(this.pinReleaseTimer!==null&&window.clearTimeout(this.pinReleaseTimer),this.pinReleaseTimer=window.setTimeout(()=>{this.pinReleaseTimer=null;let e=this.contentEl.ownerDocument.activeElement;this.isEditorInScroller(e)||this.releaseEditPin()},250))}releaseEditPin(){if(!this.editPinActive)return;this.editPinActive=!1,this.smallestEditHeight=-1;let t=this.contentEl.querySelector(":scope > .wl-tab-content");t==null||t.style.removeProperty("height"),this.lastAppliedTabHeight=-1,this.applyTabContentHeight()}applyTabContentHeight(){let t=this.contentEl;if(!t)return;let e=t.querySelector(":scope > .wl-tab-bar"),i=t.querySelector(":scope > .wl-tab-content");if(!i)return;let a=t.clientHeight,n=e?e.offsetHeight:0,r=a-n;if(!(r<=0)){if(this.editPinActive){if(!this.isEditorInScroller(this.contentEl.ownerDocument.activeElement)){this.releaseEditPin();return}(this.smallestEditHeight<0||r<this.smallestEditHeight)&&(this.smallestEditHeight=r),r=this.smallestEditHeight}r!==this.lastAppliedTabHeight&&(this.lastAppliedTabHeight=r,i.style.setProperty("height",`${r}px`,"important"),i.style.setProperty("flex","none","important"))}}refreshMobileLayout(){!Ht.Platform.isMobile||!this.mobileResizeObserver||(this.lastAppliedTabHeight=-1,this.applyTabContentHeight())}async onClose(){var e,i,a,n,r;await super.onClose(),this.dataManager.offChange(this.dataChangeListener),(e=this.overflowObserver)==null||e.disconnect(),this.overflowObserver=null,this.mobileResizeObserver&&(this.mobileResizeObserver.disconnect(),this.mobileResizeObserver=null),this.mobileSizerRaf!==null&&(window.cancelAnimationFrame(this.mobileSizerRaf),this.mobileSizerRaf=null),this.pinReleaseTimer!==null&&(window.clearTimeout(this.pinReleaseTimer),this.pinReleaseTimer=null),this.editPinActive=!1,this.smallestEditHeight=-1;let t=this.contentEl.querySelector(".wl-tab-content");t&&(t.style.removeProperty("height"),t.style.removeProperty("flex")),this.destroyDraftsTab(),(i=this.customListsTab)==null||i.destroy(),(a=this.listTab)==null||a.destroy(),(n=this.readingTab)==null||n.destroy(),this.readingTab=null,(r=this.logTab)==null||r.destroy(),this.logTab=null}refreshUI(){this.applyColorTheme(this.contentEl),this.buildUI()}applyColorTheme(t){var i;let e=(i=this.plugin.settings.colorTheme)!=null?i:"default";e==="default"?t.removeAttribute("data-theme"):t.setAttribute("data-theme",e)}destroyDraftsTab(){this.draftsTab&&(this.draftsTab.destroy(),this.draftsTab=null)}buildUI(){this.destroyDraftsTab();let t=this.contentEl;t.empty(),t.addClass("wl-view"),this.applyColorTheme(t);let e=t.createDiv({cls:"wl-tab-bar"}),i=e.createEl("button",{cls:`wl-tab-btn${this.activeTab==="dashboard"?" is-active":""}`,text:"Dashboard"}),a=e.createEl("button",{cls:`wl-tab-btn${this.activeTab==="watchlist"?" is-active":""}`,text:"Watchlist"}),n=e.createEl("button",{cls:`wl-tab-btn${this.activeTab==="reading"?" is-active":""}`,text:"Reading"});this.airtimeBtn=e.createEl("button",{cls:`wl-tab-btn${this.activeTab==="upcoming"?" is-active":""}`,text:"Upcoming"});let r=e.createEl("button",{cls:`wl-tab-btn${this.activeTab==="custom-lists"?" is-active":""}`,text:"Custom lists"});this.draftsBtn=e.createEl("button",{cls:`wl-tab-btn${this.activeTab==="drafts"?" is-active":""}`,text:"Drafts"});let o=e.createEl("button",{cls:`wl-tab-btn${this.activeTab==="log"?" is-active":""}`,text:"Log"}),l=[i,a,this.airtimeBtn,n,r,this.draftsBtn,o];this.tabButtons={dashboard:i,watchlist:a,upcoming:this.airtimeBtn,reading:n,"custom-lists":r,drafts:this.draftsBtn,log:o},i.addEventListener("click",()=>{this.activeTab!=="dashboard"&&(this.destroyDraftsTab(),this.activeTab="dashboard",l.forEach(c=>c.removeClass("is-active")),i.addClass("is-active"),this.renderTabContent())}),a.addEventListener("click",()=>{this.activeTab!=="watchlist"&&(this.destroyDraftsTab(),this.activeTab="watchlist",l.forEach(c=>c.removeClass("is-active")),a.addClass("is-active"),this.renderTabContent())}),this.airtimeBtn.addEventListener("click",()=>{this.activeTab!=="upcoming"&&(this.destroyDraftsTab(),this.activeTab="upcoming",l.forEach(c=>c.removeClass("is-active")),this.airtimeBtn.addClass("is-active"),this.renderTabContent())}),n.addEventListener("click",()=>{this.activeTab!=="reading"&&(this.destroyDraftsTab(),this.activeTab="reading",l.forEach(c=>c.removeClass("is-active")),n.addClass("is-active"),this.renderTabContent())}),r.addEventListener("click",()=>{this.activeTab!=="custom-lists"&&(this.destroyDraftsTab(),this.activeTab="custom-lists",l.forEach(c=>c.removeClass("is-active")),r.addClass("is-active"),this.renderTabContent())}),this.draftsBtn.addEventListener("click",()=>{this.activeTab!=="drafts"&&(this.activeTab="drafts",l.forEach(c=>c.removeClass("is-active")),this.draftsBtn.addClass("is-active"),this.renderTabContent())}),o.addEventListener("click",()=>{this.activeTab!=="log"&&(this.destroyDraftsTab(),this.activeTab="log",l.forEach(c=>c.removeClass("is-active")),o.addClass("is-active"),this.renderTabContent())}),this.tabContentEl=t.createDiv({cls:"wl-tab-content"}),this.renderTabContent(),this.updateUpcomingBadge(),this.updateDraftsBadge(),this.registerEvent(this.plugin.app.metadataCache.on("resolved",()=>{this.updateDraftsBadge()}))}setActiveTab(t){var e;if(this.activeTab!==t){if(!this.tabContentEl){this.activeTab=t;return}t!=="drafts"&&this.destroyDraftsTab(),this.activeTab=t;for(let i of Object.values(this.tabButtons))i==null||i.removeClass("is-active");(e=this.tabButtons[t])==null||e.addClass("is-active"),this.renderTabContent()}}renderTabContent(){var t,e,i,a,n;if(this.tabContentEl){(t=this.customListsTab)==null||t.destroy(),this.activeTab!=="reading"&&((e=this.readingTab)==null||e.destroy(),this.readingTab=null),this.activeTab!=="log"&&((i=this.logTab)==null||i.destroy(),this.logTab=null),this.tabContentEl.empty();for(let r of Di)this.tabContentEl.removeClass(r);this.activeTab==="dashboard"?(this.dashboardTab=new fe(this.tabContentEl,this.plugin,this.dataManager,this.plugin.readingDataManager),this.dashboardTab.render()):this.activeTab==="watchlist"?((a=this.listTab)==null||a.destroy(),this.listTab=new Ee(this.tabContentEl,this.plugin,this.dataManager),this.listTab.render()):this.activeTab==="upcoming"?(this.airtimeTab=new vt(this.tabContentEl,this.plugin,this.dataManager,r=>{this.airtimeBtn&&(this.airtimeBtn.textContent=r>0?`Upcoming (${r})`:"Upcoming")}),this.airtimeTab.render()):this.activeTab==="reading"?((n=this.readingTab)==null||n.destroy(),this.readingTab=new _e(this.tabContentEl,this.plugin,this.plugin.readingDataManager),this.readingTab.render()):this.activeTab==="drafts"?(this.draftsTab=new ne(this.tabContentEl,this.plugin,this.dataManager,r=>this.setDraftsBadge(r)),this.draftsTab.render()):this.activeTab==="log"?(this.logTab=new Ue(this.tabContentEl,this.plugin),this.logTab.render()):(this.customListsTab=new Re(this.tabContentEl,this.plugin,this.dataManager),this.customListsTab.render()),this.guardOverflow(),this.refreshMobileLayout()}}refreshActiveTab(){if(this.applyColorTheme(this.contentEl),this.tabContentEl)for(let t of Di)this.tabContentEl.removeClass(t);if(this.activeTab==="watchlist"&&this.listTab)this.listTab.render();else if(this.activeTab==="dashboard"&&this.dashboardTab)this.dashboardTab.render();else if(this.activeTab==="upcoming"&&this.airtimeTab)this.airtimeTab.render();else if(this.activeTab==="reading"&&this.readingTab)this.readingTab.render();else if(this.activeTab==="custom-lists"&&this.customListsTab)this.customListsTab.render();else if(this.activeTab==="drafts"&&this.draftsTab)this.draftsTab.render();else if(this.activeTab==="log"&&this.logTab)this.logTab.render();else{this.renderTabContent();return}this.guardOverflow(),this.updateUpcomingBadge()}guardOverflow(){var i;if(!this.tabContentEl)return;(i=this.overflowObserver)==null||i.disconnect();let t=this.tabContentEl,e=Date.now();this.overflowObserver=new MutationObserver(()=>{if(Date.now()-e>300)return;window.getComputedStyle(t).overflow==="hidden"&&(t.style.overflow="auto")}),this.overflowObserver.observe(t,{attributes:!0,attributeFilter:["style","class"]})}updateUpcomingBadge(){if(!this.airtimeBtn)return;let t=vt.getAiredDueCount(this.dataManager,this.plugin.readingDataManager)+vt.getMaybeDueCount(this.dataManager);this.airtimeBtn.textContent=t>0?`Upcoming (${t})`:"Upcoming"}setDraftsBadge(t){this.draftsBtn&&(this.draftsBtn.textContent=t>0?`Drafts (${t})`:"Drafts")}async updateDraftsBadge(){if(!this.draftsBtn)return;let t=await ne.computePendingCount(this.plugin,this.dataManager);this.setDraftsBadge(t)}};var R=require("obsidian");var Tt=require("obsidian"),ki=["title","type","status","priority","rating","totalEpisodes","episodeDuration","dateStarted","dateFinished","releaseDate","dateAdded","externalLink","notes"];function Za(g){let s=String(g!=null?g:"");return s.includes(",")||s.includes('"')||s.includes(` +`)?`"${s.replace(/"/g,'""')}"`:s}function tn(g){let s=ki.join(","),t=g.map(e=>ki.map(i=>Za(e[i])).join(","));return[s,...t].join(` +`)}function en(g){let s=[],t=[],e="",i=!1,a=g.replace(/\r\n?/g,` +`);for(let n=0;n<a.length;n++){let r=a[n];i?r==='"'?a[n+1]==='"'?(e+='"',n++):i=!1:e+=r:r==='"'?i=!0:r===","?(t.push(e),e=""):r===` +`?(t.push(e),e="",s.push(t),t=[]):e+=r}for((e.length>0||t.length>0)&&(t.push(e),s.push(t));s.length>0&&s[s.length-1].every(n=>n==="");)s.pop();return s}var Ti={jan:1,feb:2,mar:3,apr:4,may:5,jun:6,jul:7,aug:8,sep:9,oct:10,nov:11,dec:12};function Bs(g){let s=g.trim();if(!s)return null;try{if(/^\d{4}-\d{2}-\d{2}$/.test(s))return s;let t=s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);if(t){let r=parseInt(t[1]),o=parseInt(t[2]),l=parseInt(t[3]),c,d;return r>12?(c=r,d=o):o>12?(d=r,c=o):(c=r,d=o),d<1||d>12||c<1||c>31?null:`${l}-${String(d).padStart(2,"0")}-${String(c).padStart(2,"0")}`}let e=s.match(/^(\d{1,2})\s+([A-Za-z]{3,})\s+(\d{4})$/);if(e){let r=Ti[e[2].toLowerCase().slice(0,3)],o=parseInt(e[1]),l=parseInt(e[3]);if(r)return`${l}-${String(r).padStart(2,"0")}-${String(o).padStart(2,"0")}`}let i=s.match(/^([A-Za-z]{3,})\s+(\d{1,2}),?\s+(\d{4})$/);if(i){let r=Ti[i[1].toLowerCase().slice(0,3)],o=parseInt(i[2]),l=parseInt(i[3]);if(r)return`${l}-${String(r).padStart(2,"0")}-${String(o).padStart(2,"0")}`}let a=s.match(/^(\d{1,2})[.-](\d{1,2})[.-](\d{4})$/);if(a){let r=parseInt(a[1]),o=parseInt(a[2]),l=parseInt(a[3]);if(o>=1&&o<=12&&r>=1&&r<=31)return`${l}-${String(o).padStart(2,"0")}-${String(r).padStart(2,"0")}`}let n=new Date(s);if(!isNaN(n.getTime())){let r=n.getDate(),o=n.getMonth()+1;return`${n.getFullYear()}-${String(o).padStart(2,"0")}-${String(r).padStart(2,"0")}`}return null}catch(t){return null}}var Ps=[{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"]},{key:"notes",label:"Notes",autoDetect:["notes","note","comment","comments"]}];function sn(g){let s={};for(let t of Ps){let e=g.find(i=>t.autoDetect.includes(i.toLowerCase()));s[t.key]=e!=null?e:""}return s}function an(g,s,t){var a;if(g.length<2)return[];let e=((a=g[0])!=null?a:[]).map(n=>n.trim()),i=t.getTitles();return g.slice(1).filter(n=>n.some(r=>r.trim())).map(n=>{var E;let r=k=>{var T;let C=s[k];if(!C)return"";let x=e.indexOf(C);return x>=0?((T=n[x])!=null?T:"").trim():""},o={},l=r("title");l&&(o.title=l);let c=r("type");c&&(o.type=c);let d=r("status");d&&(o.status=d);let u=r("priority");u&&(o.priority=u);let h=r("rating");if(h){let k=parseFloat(h)||0;o.rating=Math.max(0,Math.min(5,k))}let p=r("totalEpisodes");p&&(o.totalEpisodes=parseInt(p)||0);let m=r("episodeDuration");m&&(o.episodeDuration=parseInt(m)||0);let v=r("dateStarted");if(v){let k=Bs(v);k&&(o.dateStarted=k)}let f=r("dateFinished");if(f){let k=Bs(f);k&&(o.dateFinished=k)}let y=r("releaseDate");if(y){let k=Bs(y);k&&(o.releaseDate=k)}let w=r("externalLink");w&&(o.externalLink=w);let b=r("notes");if(b&&(o.notes=b),!((E=o.title)!=null&&E.trim())&&!o.type&&!o.status&&!o.rating&&!o.totalEpisodes&&!o.episodeDuration&&!o.dateStarted&&!o.dateFinished&&!o.releaseDate&&!o.externalLink&&!o.notes)return null;let D=!!i.find(k=>{var C;return k.title.toLowerCase()===((C=o.title)!=null?C:"").toLowerCase()});return{entry:o,isDuplicate:D}}).filter(n=>n!==null)}var Ve="__create__",Ci={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 nn(g){var t,e;let s=(t=Ci[g])!=null?t:Ci.default;return(e=s[Math.floor(Math.random()*s.length)])!=null?e:"#888780"}var le=class extends Tt.Modal{constructor(t,e,i,a){super(t);this.selectedIds=new Set;this.csvRows=[];this.csvHeaders=[];this.columnMapping={};this.importRows=[];this.statusValueMap={};this.typeValueMap={};this.ratingValueMap={};this.stepUpload=null;this.stepMapping=null;this.stepValueMap=null;this.stepPreview=null;this.importBtn=null;this.cancelBtn=null;this.isImporting=!1;this.importCancelled=!1;this.progressWrap=null;this.progressBarFill=null;this.progressText=null;this.plugin=e,this.dataManager=i,this.mode=a}onOpen(){let{contentEl:t}=this;t.empty(),t.addClass("wl-csv-modal"),this.mode==="export"?this.renderExport(t):this.renderImport(t)}onClose(){this.contentEl.empty()}renderExport(t){t.createEl("h2",{cls:"wl-modal-title",text:"Export to CSV"});let e=this.dataManager.getTitles();e.forEach(p=>this.selectedIds.add(p.id));let i=t.createDiv({cls:"wl-csv-ctrl-row"}),a=i.createEl("button",{cls:"wl-btn",text:"Select all"}),n=i.createEl("button",{cls:"wl-btn",text:"Select none"}),r=i.createSpan({cls:"wl-csv-count",text:`${e.length} selected`}),o=t.createDiv({cls:"wl-csv-list"}),l=[],c=()=>{r.textContent=`${this.selectedIds.size} selected`};for(let p of e){let m=o.createDiv({cls:"wl-csv-row"}),v=m.createEl("input",{attr:{type:"checkbox"}});v.checked=!0,l.push(v),m.createSpan({cls:"wl-csv-row-title",text:p.title}),m.createSpan({cls:"wl-csv-row-meta",text:`${p.type} \xB7 ${p.status}`}),v.addEventListener("change",()=>{v.checked?this.selectedIds.add(p.id):this.selectedIds.delete(p.id),c()})}a.addEventListener("click",()=>{e.forEach(p=>this.selectedIds.add(p.id)),l.forEach(p=>{p.checked=!0}),c()}),n.addEventListener("click",()=>{this.selectedIds.clear(),l.forEach(p=>{p.checked=!1}),c()});let d=t.createDiv({cls:"wl-modal-btn-row wl-csv-btn-row"}),u=d.createEl("button",{cls:"wl-btn",text:"Cancel"}),h=d.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Export CSV"});u.addEventListener("click",()=>this.close()),h.addEventListener("click",()=>{let p=e.filter(S=>this.selectedIds.has(S.id));if(p.length===0){new Tt.Notice("No titles selected.");return}let m=tn(p),v=new Blob([m],{type:"text/csv;charset=utf-8;"}),f=URL.createObjectURL(v),y=activeDocument.createElement("a"),w=new Date,b=`${w.getFullYear()}-${String(w.getMonth()+1).padStart(2,"0")}-${String(w.getDate()).padStart(2,"0")}`;y.href=f,y.download=`watchlog-export-${b}.csv`,y.click(),URL.revokeObjectURL(f),new Tt.Notice(`Exported ${p.length} titles.`),this.close()})}renderImport(t){t.createEl("h2",{cls:"wl-modal-title",text:"Import from CSV"}),this.stepUpload=t.createDiv({cls:"wl-csv-step"});let e=this.stepUpload.createDiv({cls:"wl-csv-drop-zone"});e.createDiv({cls:"wl-csv-drop-label",text:"Select a CSV file to import"});let i=e.createEl("input",{attr:{type:"file",accept:".csv"},cls:"wl-csv-file-input"});this.stepMapping=t.createDiv({cls:"wl-csv-step"}),this.stepMapping.hide(),this.stepValueMap=t.createDiv({cls:"wl-csv-step"}),this.stepValueMap.hide(),this.stepPreview=t.createDiv({cls:"wl-csv-step"}),this.stepPreview.hide();let a=t.createDiv({cls:"wl-modal-btn-row wl-csv-btn-row"});this.progressWrap=a.createDiv({cls:"wl-csv-progress-wrap"}),this.progressWrap.hide(),this.progressWrap.createSpan({cls:"wl-csv-bg-note",text:"You can close this window \u2014 import will continue in the background."});let n=this.progressWrap.createDiv({cls:"wl-csv-progress-track"});this.progressBarFill=n.createDiv({cls:"wl-csv-progress-fill"}),this.progressText=this.progressWrap.createSpan({cls:"wl-csv-progress-text",text:"0 / 0"}),this.cancelBtn=a.createEl("button",{cls:"wl-btn",text:"Cancel"}),this.importBtn=a.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Import selected"}),this.importBtn.disabled=!0,this.importBtn.hide(),this.cancelBtn.addEventListener("click",()=>{this.isImporting?(this.importCancelled=!0,this.cancelBtn&&(this.cancelBtn.disabled=!0,this.cancelBtn.textContent="Cancelling\u2026")):this.close()}),this.importBtn.addEventListener("click",()=>void this.doImport()),i.addEventListener("change",()=>{var l;let r=(l=i.files)==null?void 0:l[0];if(!r)return;let o=new FileReader;o.onload=c=>{var u,h;let d=(u=c.target)==null?void 0:u.result;this.csvRows=en(d),this.csvHeaders=((h=this.csvRows[0])!=null?h:[]).map(p=>p.trim()),this.columnMapping=sn(this.csvHeaders),this.showMappingStep()},o.readAsText(r)})}showMappingStep(){!this.stepUpload||!this.stepMapping||(this.stepUpload.hide(),this.stepMapping.show(),this.renderMappingStep(this.stepMapping))}renderMappingStep(t){var r;t.empty(),t.createDiv({cls:"wl-csv-step-title",text:"Map your columns"}),t.createDiv({cls:"wl-csv-step-desc",text:'Match each WatchLog field to a column from your CSV. Select "Skip" to leave a field empty.'}),t.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."});let e="",i=t.createDiv({cls:"wl-csv-map-grid"}),a={};for(let o of Ps){let l=i.createDiv({cls:"wl-csv-map-row"});l.createSpan({cls:"wl-csv-map-label",text:o.label});let c=l.createEl("select",{cls:"wl-select wl-csv-map-select"});a[o.key]=c,c.createEl("option",{value:e,text:"\u2014 skip \u2014"});for(let d of this.csvHeaders)c.createEl("option",{value:d,text:d});c.value=(r=this.columnMapping[o.key])!=null?r:e}t.createEl("button",{cls:"wl-btn wl-btn-primary wl-csv-preview-btn",text:"Next \u2192"}).addEventListener("click",()=>{var v,f,y,w,b,S,D;for(let E of Ps)this.columnMapping[E.key]=(f=(v=a[E.key])==null?void 0:v.value)!=null?f:"";this.importRows=an(this.csvRows,this.columnMapping,this.dataManager).map(E=>({...E,selected:!E.isDuplicate}));let o=new Set(this.plugin.settings.statuses.map(E=>E.name)),l=new Set(this.plugin.settings.types.map(E=>E.name)),c=[],d=[],u=[],h=new Set,p=new Set,m=new Set;for(let E of this.importRows){let k=(y=E.entry.status)==null?void 0:y.trim();k&&!o.has(k)&&!h.has(k)&&(c.push(k),h.add(k));let C=(w=E.entry.type)==null?void 0:w.trim();C&&!l.has(C)&&!p.has(C)&&(d.push(C),p.add(C));let x=this.columnMapping.rating;if(x){let M=((b=this.csvRows[0])!=null?b:[]).map(L=>L.trim()).indexOf(x);if(M>=0){let L=this.importRows.indexOf(E),A=((D=((S=this.csvRows[L+1])!=null?S:[])[M])!=null?D:"").trim();A&&isNaN(parseFloat(A))&&!m.has(A)&&(u.push(A),m.add(A))}}}c.length>0||d.length>0||u.length>0?this.showValueMapStep(c,d,u):this.showPreviewStep()})}showValueMapStep(t,e,i=[]){!this.stepMapping||!this.stepValueMap||(this.stepMapping.hide(),this.stepValueMap.show(),this.renderValueMapStep(this.stepValueMap,t,e,i))}renderValueMapStep(t,e,i,a=[]){t.empty(),t.createDiv({cls:"wl-csv-step-title",text:"Map unknown values"}),t.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."});let n=this.plugin.settings.statuses.map(p=>p.name),r=this.plugin.settings.types.map(p=>p.name),o={},l={},c={};if(e.length>0){t.createDiv({cls:"wl-csv-valmap-section-title",text:"Status"});for(let p of e){let m=t.createDiv({cls:"wl-csv-valmap-row"});m.createSpan({cls:"wl-csv-valmap-orig",text:`"${p}"`}),m.createSpan({cls:"wl-csv-valmap-arrow",text:"\u2192"});let v=m.createEl("select",{cls:"wl-select wl-csv-valmap-select"});o[p]=v,v.createEl("option",{value:"",text:"\u2014 leave blank \u2014"});for(let w of n)v.createEl("option",{value:w,text:w});let f=p.toLowerCase(),y=n.find(w=>w.toLowerCase()===f);y&&(v.value=y)}}if(i.length>0){t.createDiv({cls:"wl-csv-valmap-section-title",text:"Type"});for(let p of i){let m=t.createDiv({cls:"wl-csv-valmap-row"});m.createSpan({cls:"wl-csv-valmap-orig",text:`"${p}"`}),m.createSpan({cls:"wl-csv-valmap-arrow",text:"\u2192"});let v=m.createEl("select",{cls:"wl-select wl-csv-valmap-select"});l[p]=v,v.createEl("option",{value:"",text:"\u2014 leave blank \u2014"});for(let w of r)v.createEl("option",{value:w,text:w});v.createEl("option",{value:Ve,text:"+ create new type"});let f=p.toLowerCase(),y=r.find(w=>w.toLowerCase()===f);y?v.value=y:v.value=Ve}}if(a.length>0){t.createDiv({cls:"wl-csv-valmap-section-title",text:"Rating"});for(let p of a){let m=t.createDiv({cls:"wl-csv-valmap-row"});m.createSpan({cls:"wl-csv-valmap-orig",text:`"${p}"`}),m.createSpan({cls:"wl-csv-valmap-arrow",text:"\u2192"});let v=m.createEl("select",{cls:"wl-select wl-csv-valmap-select"});c[p]=v,v.createEl("option",{value:"",text:"\u2014 leave blank \u2014"});for(let f=1;f<=5;f++)v.createEl("option",{value:String(f),text:`${f}/5`})}}let d=t.createDiv({cls:"wl-csv-valmap-btn-row"}),u=d.createEl("button",{cls:"wl-btn",text:"\u2190 back"}),h=d.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Preview \u2192"});u.addEventListener("click",()=>{!this.stepValueMap||!this.stepMapping||(this.stepValueMap.hide(),this.stepMapping.show())}),h.addEventListener("click",()=>{var p,m,v,f,y,w;this.statusValueMap={};for(let b of e)this.statusValueMap[b]=(m=(p=o[b])==null?void 0:p.value)!=null?m:"";this.typeValueMap={};for(let b of i)this.typeValueMap[b]=(f=(v=l[b])==null?void 0:v.value)!=null?f:"";this.ratingValueMap={};for(let b of a)this.ratingValueMap[b]=(w=(y=c[b])==null?void 0:y.value)!=null?w:"";this.applyValueMapsToRows(),this.showPreviewStep()})}applyValueMapsToRows(){var t,e,i,a,n,r,o;for(let l of this.importRows){let c=(e=(t=l.entry.status)==null?void 0:t.trim())!=null?e:"";if(c&&Object.prototype.hasOwnProperty.call(this.statusValueMap,c)){let h=(i=this.statusValueMap[c])!=null?i:"";l.entry.status=h||void 0}let d=(n=(a=l.entry.type)==null?void 0:a.trim())!=null?n:"";if(d&&Object.prototype.hasOwnProperty.call(this.typeValueMap,d)){let h=(r=this.typeValueMap[d])!=null?r:"";h===Ve?l.entry.type=d:l.entry.type=h||void 0}let u=Object.keys(this.ratingValueMap).find(h=>{var w,b,S;let p=this.columnMapping.rating;if(!p)return!1;let v=((w=this.csvRows[0])!=null?w:[]).map(D=>D.trim()).indexOf(p);if(v<0)return!1;let f=this.importRows.indexOf(l);return((S=((b=this.csvRows[f+1])!=null?b:[])[v])!=null?S:"").trim()===h});if(u!==void 0){let h=(o=this.ratingValueMap[u])!=null?o:"";l.entry.rating=h?parseInt(h):0}l.isDuplicate=!!this.dataManager.getTitles().find(h=>{var p;return h.title.toLowerCase()===((p=l.entry.title)!=null?p:"").toLowerCase()})}}showPreviewStep(){!this.stepValueMap||!this.stepMapping||!this.stepPreview||!this.importBtn||(this.stepValueMap.hide(),this.stepMapping.hide(),this.stepPreview.show(),this.importBtn.show(),this.importBtn.disabled=this.importRows.filter(t=>t.selected).length===0,this.renderPreviewStep(this.stepPreview))}renderPreviewStep(t){var u;t.empty();let e=this.importRows.filter(h=>h.isDuplicate).length;e>0&&t.createDiv({cls:"wl-csv-dupe-warning",text:`${e} duplicate${e!==1?"s":""} found (highlighted). These are deselected by default.`});let i=t.createDiv({cls:"wl-csv-ctrl-row"}),a=i.createEl("button",{cls:"wl-btn",text:"\u2190 back"}),n=i.createEl("button",{cls:"wl-btn",text:"Select all"}),r=i.createEl("button",{cls:"wl-btn",text:"Select none"}),o=i.createSpan({cls:"wl-csv-count",text:""});a.addEventListener("click",()=>{if(!this.stepPreview||!this.stepValueMap||!this.stepMapping||!this.importBtn)return;this.stepPreview.hide(),this.importBtn.hide(),this.importBtn.disabled=!0,Object.keys(this.statusValueMap).length>0||Object.keys(this.typeValueMap).length>0||Object.keys(this.ratingValueMap).length>0?this.stepValueMap.show():this.stepMapping.show()});let l=t.createDiv({cls:"wl-csv-list"}),c=[],d=()=>{let h=this.importRows.filter(p=>p.selected).length;o.textContent=`${h} of ${this.importRows.length} selected`,this.importBtn&&(this.importBtn.disabled=h===0)};for(let h=0;h<this.importRows.length;h++){let p=this.importRows[h],m=l.createDiv({cls:`wl-csv-row${p.isDuplicate?" wl-csv-row-dupe":""}`}),v=m.createEl("input",{attr:{type:"checkbox"}});v.checked=p.selected,c.push(v),m.createSpan({cls:"wl-csv-row-title",text:(u=p.entry.title)!=null?u:"(no title)"});let f=[p.entry.type,p.entry.status].filter(Boolean).join(" \xB7 ");f&&m.createSpan({cls:"wl-csv-row-meta",text:f}),p.isDuplicate&&m.createSpan({cls:"wl-csv-dupe-badge",text:"duplicate"});let y=h;v.addEventListener("change",()=>{this.importRows[y].selected=v.checked,d()})}n.addEventListener("click",()=>{this.importRows.forEach(h=>{h.selected=!0}),c.forEach(h=>{h.checked=!0}),d()}),r.addEventListener("click",()=>{this.importRows.forEach(h=>{h.selected=!1}),c.forEach(h=>{h.checked=!1}),d()}),d()}async doImport(){var c,d,u,h,p,m,v,f,y,w,b,S;let t=this.importRows.filter(D=>{var E;return D.selected&&((E=D.entry.title)==null?void 0:E.trim())});if(t.length===0){new Tt.Notice("No titles selected to import.");return}this.isImporting=!0,this.importCancelled=!1,this.importBtn&&(this.importBtn.disabled=!0),this.cancelBtn&&(this.cancelBtn.textContent="Cancel import"),this.progressWrap&&this.progressWrap.show(),this.progressText&&(this.progressText.textContent=`0 / ${t.length}`),this.progressBarFill&&(this.progressBarFill.style.width="0%");let e=()=>{this.importCancelled=!0};this.plugin.importProgress={current:0,total:t.length,cancel:e};let i=(c=this.plugin.settings.colorTheme)!=null?c:"default",a=new Set(this.plugin.settings.types.map(D=>D.name.toLowerCase())),n=new Set;for(let D of t){let E=(d=D.entry.type)==null?void 0:d.trim();E&&!a.has(E.toLowerCase())&&n.add(E)}for(let[D,E]of Object.entries(this.typeValueMap))E===Ve&&!a.has(D.toLowerCase())&&n.add(D);if(n.size>0){for(let D of n)this.plugin.settings.types.push({name:D,color:nn(i)});await this.plugin.saveSettings()}let r=10,o=0;try{for(let D=0;D<t.length&&!this.importCancelled;D+=r){let E=Math.min(D+r,t.length),k=[];for(let x=D;x<E&&!this.importCancelled;x++){let T=t[x],M=T.entry.title.trim(),L={id:this.dataManager.generateId(M),title:M,type:((u=T.entry.type)==null?void 0:u.trim())||((h=this.plugin.settings.types[0])==null?void 0:h.name)||"Anime",status:T.entry.status||"Plan to watch",priority:T.entry.priority||"Medium",review:"",rating:(p=T.entry.rating)!=null?p:0,notes:(m=T.entry.notes)!=null?m:"",dateStarted:(v=T.entry.dateStarted)!=null?v:null,dateFinished:(f=T.entry.dateFinished)!=null?f:null,dateAdded:new Date().toISOString(),dateModified:new Date().toISOString(),totalEpisodes:(y=T.entry.totalEpisodes)!=null?y:0,episodeDuration:(w=T.entry.episodeDuration)!=null?w:0,releaseDate:(b=T.entry.releaseDate)!=null?b:null,externalLink:(S=T.entry.externalLink)!=null?S:"",seasons:[],watchedEpisodes:[]};L.status==="Completed"&&L.totalEpisodes>0&&(L.watchedEpisodes=Array.from({length:L.totalEpisodes},(j,A)=>A+1)),k.push(L)}if(k.length===0)break;await this.dataManager.addTitleBatch(k),o+=k.length,this.plugin.importProgress={current:o,total:t.length,cancel:e};let C=Math.round(o/t.length*100);this.progressBarFill&&(this.progressBarFill.style.width=`${C}%`),this.progressText&&(this.progressText.textContent=`${o} / ${t.length}`)}}finally{this.isImporting=!1,this.plugin.importProgress=null,this.dataManager.notifyChange()}let l=n.size>0?` (${n.size} new type${n.size!==1?"s":""} created)`:"";this.importCancelled?(this.progressText&&(this.progressText.textContent=`Cancelled \u2014 ${o} / ${t.length} imported`),this.cancelBtn&&(this.cancelBtn.disabled=!1,this.cancelBtn.textContent="Close"),new Tt.Notice(`Import cancelled. ${o} title${o!==1?"s":""} imported.`)):(this.progressText&&(this.progressText.textContent=`Done \u2014 ${o} imported`),this.cancelBtn&&(this.cancelBtn.disabled=!1,this.cancelBtn.textContent="Close"),new Tt.Notice(`Imported ${o} title${o!==1?"s":""}${l}.`),this.close())}};var Mi=require("obsidian"),ce=class extends Mi.Modal{constructor(t,e,i){super(t);this.mode=e;this.onChoice=i}onOpen(){let{contentEl:t}=this;t.empty(),t.addClass("wl-draft-choice-modal"),this.titleEl.setText(this.mode==="export"?"Export to CSV":"Import from CSV"),t.createDiv({cls:"wl-draft-choice-subtitle",text:this.mode==="export"?"Which library do you want to export?":"Which library do you want to import into?"});let e=t.createDiv({cls:"wl-draft-choice-grid"}),i=(a,n)=>{let r=e.createEl("button",{cls:"wl-draft-choice-btn"});r.createDiv({cls:"wl-draft-choice-label",text:a}),r.addEventListener("click",()=>{this.close(),this.onChoice(n)})};i("Books","book"),i("Manga","manga")}onClose(){this.contentEl.empty()}};var Ct=require("obsidian");function rn(g){let s=String(g!=null?g:"");return s.includes(",")||s.includes('"')||s.includes(` +`)?`"${s.replace(/"/g,'""')}"`:s}function on(g){let s=[],t=[],e="",i=!1,a=g.replace(/\r\n?/g,` +`);for(let n=0;n<a.length;n++){let r=a[n];i?r==='"'?a[n+1]==='"'?(e+='"',n++):i=!1:e+=r:r==='"'?i=!0:r===","?(t.push(e),e=""):r===` +`?(t.push(e),e="",s.push(t),t=[]):e+=r}for((e.length>0||t.length>0)&&(t.push(e),s.push(t));s.length>0&&s[s.length-1].every(n=>n==="");)s.pop();return s}var xi={jan:1,feb:2,mar:3,apr:4,may:5,jun:6,jul:7,aug:8,sep:9,oct:10,nov:11,dec:12};function ln(g){let s=g.trim();if(!s)return null;try{if(/^\d{4}-\d{2}-\d{2}$/.test(s))return s;let t=s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);if(t){let r=parseInt(t[1]),o=parseInt(t[2]),l=parseInt(t[3]),c,d;return r>12?(c=r,d=o):o>12?(d=r,c=o):(c=r,d=o),d<1||d>12||c<1||c>31?null:`${l}-${String(d).padStart(2,"0")}-${String(c).padStart(2,"0")}`}let e=s.match(/^(\d{1,2})\s+([A-Za-z]{3,})\s+(\d{4})$/);if(e){let r=xi[e[2].toLowerCase().slice(0,3)],o=parseInt(e[1]),l=parseInt(e[3]);if(r)return`${l}-${String(r).padStart(2,"0")}-${String(o).padStart(2,"0")}`}let i=s.match(/^([A-Za-z]{3,})\s+(\d{1,2}),?\s+(\d{4})$/);if(i){let r=xi[i[1].toLowerCase().slice(0,3)],o=parseInt(i[2]),l=parseInt(i[3]);if(r)return`${l}-${String(r).padStart(2,"0")}-${String(o).padStart(2,"0")}`}let a=s.match(/^(\d{1,2})[.-](\d{1,2})[.-](\d{4})$/);if(a){let r=parseInt(a[1]),o=parseInt(a[2]),l=parseInt(a[3]);if(o>=1&&o<=12&&r>=1&&r<=31)return`${l}-${String(o).padStart(2,"0")}-${String(r).padStart(2,"0")}`}let n=new Date(s);if(!isNaN(n.getTime())){let r=n.getDate(),o=n.getMonth()+1;return`${n.getFullYear()}-${String(o).padStart(2,"0")}-${String(r).padStart(2,"0")}`}return null}catch(t){return null}}var cn=["title","author","status","rating","pagesRead","totalPages","chaptersRead","totalChapters","dateStarted","dateFinished","releaseDate","dateAdded","externalLink","googleBooksId"],dn=["title","author","status","rating","chaptersRead","totalChapters","volumesRead","totalVolumes","dateStarted","dateFinished","releaseDate","dateAdded","externalLink","malId"],Li=[{key:"title",label:"Title",coercion:"string",autoDetect:["title","name"]},{key:"author",label:"Author",coercion:"string",autoDetect:["author","writer","by"]},{key:"status",label:"Status",coercion:"string",autoDetect:["status"]},{key:"rating",label:"Rating",coercion:"rating",autoDetect:["rating","score"]}],Ri=[{key:"dateStarted",label:"Date Started",coercion:"date",autoDetect:["started","datestarted","date started","date_started","start date"]},{key:"dateFinished",label:"Date Finished",coercion:"date",autoDetect:["finished","datefinished","date finished","date_finished","end date","finish date","completed date"]},{key:"releaseDate",label:"Release Date",coercion:"date",autoDetect:["releasedate","release date","release_date","published","publish date"]},{key:"externalLink",label:"Link",coercion:"string",autoDetect:["link","externallink","external link","external_link","url"]}],un=[...Li,{key:"pagesRead",label:"Pages Read",coercion:"int",autoDetect:["pagesread","pages read","read pages","page"]},{key:"totalPages",label:"Total Pages",coercion:"int",autoDetect:["totalpages","total pages","pages","page count"]},{key:"chaptersRead",label:"Chapters Read",coercion:"int",autoDetect:["chaptersread","chapters read","read chapters"]},{key:"totalChapters",label:"Total Chapters",coercion:"int",autoDetect:["totalchapters","total chapters","chapters","chapter count"]},...Ri,{key:"googleBooksId",label:"Google Books ID",coercion:"string",autoDetect:["googlebooksid","google books id","gbid","volumeid"]}],hn=[...Li,{key:"chaptersRead",label:"Chapters Read",coercion:"int",autoDetect:["chaptersread","chapters read","read chapters"]},{key:"totalChapters",label:"Total Chapters",coercion:"int",autoDetect:["totalchapters","total chapters","chapters","chapter count"]},{key:"volumesRead",label:"Volumes Read",coercion:"int",autoDetect:["volumesread","volumes read","read volumes"]},{key:"totalVolumes",label:"Total Volumes",coercion:"int",autoDetect:["totalvolumes","total volumes","volumes","volume count"]},...Ri,{key:"malId",label:"MAL ID",coercion:"string",autoDetect:["malid","mal id","myanimelist id","mal"]}];function pn(g){return g==="book"?{exportColumns:cn,importFields:un}:{exportColumns:dn,importFields:hn}}function gn(g,s){let t={};for(let e of g){let i=s.find(a=>e.autoDetect.includes(a.toLowerCase()));t[e.key]=i!=null?i:""}return t}var de=class extends Ct.Modal{constructor(t,e,i,a){super(t);this.selectedIds=new Set;this.csvRows=[];this.csvHeaders=[];this.columnMapping={};this.importRows=[];this.statusValueMap={};this.ratingValueMap={};this.stepUpload=null;this.stepMapping=null;this.stepValueMap=null;this.stepPreview=null;this.importBtn=null;this.cancelBtn=null;this.isImporting=!1;this.importCancelled=!1;this.progressWrap=null;this.progressBarFill=null;this.progressText=null;this.plugin=e,this.kind=i,this.mode=a,this.schema=pn(i)}get kindLabel(){return this.kind==="book"?"Books":"Manga"}get itemNoun(){return this.kind==="book"?"book":"manga"}getItems(){return this.kind==="book"?this.plugin.readingDataManager.getBooks():this.plugin.readingDataManager.getMangaList()}onOpen(){let{contentEl:t}=this;t.empty(),t.addClass("wl-csv-modal"),this.mode==="export"?this.renderExport(t):this.renderImport(t)}onClose(){this.contentEl.empty()}itemsToCsv(t){let e=this.schema.exportColumns,i=e.join(","),a=t.map(n=>e.map(r=>rn(n[r])).join(","));return[i,...a].join(` +`)}renderExport(t){t.createEl("h2",{cls:"wl-modal-title",text:`Export ${this.kindLabel} to CSV`});let e=this.getItems();e.forEach(p=>this.selectedIds.add(p.id));let i=t.createDiv({cls:"wl-csv-ctrl-row"}),a=i.createEl("button",{cls:"wl-btn",text:"Select all"}),n=i.createEl("button",{cls:"wl-btn",text:"Select none"}),r=i.createSpan({cls:"wl-csv-count",text:`${e.length} selected`}),o=t.createDiv({cls:"wl-csv-list"}),l=[],c=()=>{r.textContent=`${this.selectedIds.size} selected`};for(let p of e){let m=o.createDiv({cls:"wl-csv-row"}),v=m.createEl("input",{attr:{type:"checkbox"}});v.checked=!0,l.push(v),m.createSpan({cls:"wl-csv-row-title",text:p.title});let f=[p.author,p.status].filter(Boolean).join(" \xB7 ");f&&m.createSpan({cls:"wl-csv-row-meta",text:f}),v.addEventListener("change",()=>{v.checked?this.selectedIds.add(p.id):this.selectedIds.delete(p.id),c()})}a.addEventListener("click",()=>{e.forEach(p=>this.selectedIds.add(p.id)),l.forEach(p=>{p.checked=!0}),c()}),n.addEventListener("click",()=>{this.selectedIds.clear(),l.forEach(p=>{p.checked=!1}),c()});let d=t.createDiv({cls:"wl-modal-btn-row wl-csv-btn-row"}),u=d.createEl("button",{cls:"wl-btn",text:"Cancel"}),h=d.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Export CSV"});u.addEventListener("click",()=>this.close()),h.addEventListener("click",()=>{let p=e.filter(S=>this.selectedIds.has(S.id));if(p.length===0){new Ct.Notice(`No ${this.itemNoun} selected.`);return}let m=this.itemsToCsv(p),v=new Blob([m],{type:"text/csv;charset=utf-8;"}),f=URL.createObjectURL(v),y=activeDocument.createElement("a"),w=new Date,b=`${w.getFullYear()}-${String(w.getMonth()+1).padStart(2,"0")}-${String(w.getDate()).padStart(2,"0")}`;y.href=f,y.download=`watchlog-${this.itemNoun==="book"?"books":"manga"}-export-${b}.csv`,y.click(),URL.revokeObjectURL(f),new Ct.Notice(`Exported ${p.length} ${this.kindLabel.toLowerCase()}.`),this.close()})}renderImport(t){t.createEl("h2",{cls:"wl-modal-title",text:`Import ${this.kindLabel} from CSV`}),this.stepUpload=t.createDiv({cls:"wl-csv-step"});let e=this.stepUpload.createDiv({cls:"wl-csv-drop-zone"});e.createDiv({cls:"wl-csv-drop-label",text:"Select a CSV file to import"});let i=e.createEl("input",{attr:{type:"file",accept:".csv"},cls:"wl-csv-file-input"});this.stepMapping=t.createDiv({cls:"wl-csv-step"}),this.stepMapping.hide(),this.stepValueMap=t.createDiv({cls:"wl-csv-step"}),this.stepValueMap.hide(),this.stepPreview=t.createDiv({cls:"wl-csv-step"}),this.stepPreview.hide();let a=t.createDiv({cls:"wl-modal-btn-row wl-csv-btn-row"});this.progressWrap=a.createDiv({cls:"wl-csv-progress-wrap"}),this.progressWrap.hide(),this.progressWrap.createSpan({cls:"wl-csv-bg-note",text:"You can close this window \u2014 import will continue in the background."});let n=this.progressWrap.createDiv({cls:"wl-csv-progress-track"});this.progressBarFill=n.createDiv({cls:"wl-csv-progress-fill"}),this.progressText=this.progressWrap.createSpan({cls:"wl-csv-progress-text",text:"0 / 0"}),this.cancelBtn=a.createEl("button",{cls:"wl-btn",text:"Cancel"}),this.importBtn=a.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Import selected"}),this.importBtn.disabled=!0,this.importBtn.hide(),this.cancelBtn.addEventListener("click",()=>{this.isImporting?(this.importCancelled=!0,this.cancelBtn&&(this.cancelBtn.disabled=!0,this.cancelBtn.textContent="Cancelling\u2026")):this.close()}),this.importBtn.addEventListener("click",()=>void this.doImport()),i.addEventListener("change",()=>{var l;let r=(l=i.files)==null?void 0:l[0];if(!r)return;let o=new FileReader;o.onload=c=>{var u,h;let d=(u=c.target)==null?void 0:u.result;this.csvRows=on(d),this.csvHeaders=((h=this.csvRows[0])!=null?h:[]).map(p=>p.trim()),this.columnMapping=gn(this.schema.importFields,this.csvHeaders),this.showMappingStep()},o.readAsText(r)})}showMappingStep(){!this.stepUpload||!this.stepMapping||(this.stepUpload.hide(),this.stepMapping.show(),this.renderMappingStep(this.stepMapping))}renderMappingStep(t){var r;t.empty(),t.createDiv({cls:"wl-csv-step-title",text:"Map your columns"}),t.createDiv({cls:"wl-csv-step-desc",text:`Match each ${this.itemNoun==="book"?"Book":"Manga"} field to a column from your CSV. Select "\u2014 skip \u2014" to leave a field empty.`}),t.createDiv({cls:"wl-csv-info-note",text:"Rows with empty fields will be skipped automatically. If you see missing entries, check that your CSV has no empty rows at the top."});let e="",i=t.createDiv({cls:"wl-csv-map-grid"}),a={};for(let o of this.schema.importFields){let l=i.createDiv({cls:"wl-csv-map-row"});l.createSpan({cls:"wl-csv-map-label",text:o.label});let c=l.createEl("select",{cls:"wl-select wl-csv-map-select"});a[o.key]=c,c.createEl("option",{value:e,text:"\u2014 skip \u2014"});for(let d of this.csvHeaders)c.createEl("option",{value:d,text:d});c.value=(r=this.columnMapping[o.key])!=null?r:e}t.createEl("button",{cls:"wl-btn wl-btn-primary wl-csv-preview-btn",text:"Next \u2192"}).addEventListener("click",()=>{var v,f,y,w,b,S;for(let D of this.schema.importFields)this.columnMapping[D.key]=(f=(v=a[D.key])==null?void 0:v.value)!=null?f:"";this.importRows=this.applyMapping().map(D=>({...D,selected:!D.isDuplicate}));let o=new Set(wt),l=[],c=[],d=new Set,u=new Set,h=((y=this.csvRows[0])!=null?y:[]).map(D=>D.trim()),p=this.columnMapping.rating,m=p?h.indexOf(p):-1;for(let D of this.importRows){let E=(w=D.entry.status)==null?void 0:w.trim();if(E&&!o.has(E)&&!d.has(E)&&(l.push(E),d.add(E)),m>=0){let k=this.importRows.indexOf(D),x=((S=((b=this.csvRows[k+1])!=null?b:[])[m])!=null?S:"").trim();x&&isNaN(parseFloat(x))&&!u.has(x)&&(c.push(x),u.add(x))}}l.length>0||c.length>0?this.showValueMapStep(l,c):this.showPreviewStep()})}applyMapping(){var i;if(this.csvRows.length<2)return[];let t=((i=this.csvRows[0])!=null?i:[]).map(a=>a.trim()),e=this.getItems();return this.csvRows.slice(1).filter(a=>a.some(n=>n.trim())).map(a=>{let n=l=>{var u;let c=this.columnMapping[l];if(!c)return"";let d=t.indexOf(c);return d>=0?((u=a[d])!=null?u:"").trim():""},r={};for(let l of this.schema.importFields){let c=n(l.key);if(c)switch(l.coercion){case"string":r[l.key]=c;break;case"int":r[l.key]=parseInt(c)||0;break;case"rating":{let d=parseFloat(c)||0;r.rating=Math.max(0,Math.min(5,d));break}case"date":{let d=ln(c);d&&(r[l.key]=d);break}}}if(!r.title||!r.title.trim())return null;let o=!!e.find(l=>{var c;return l.title.toLowerCase()===((c=r.title)!=null?c:"").toLowerCase()});return{entry:r,isDuplicate:o}}).filter(a=>a!==null)}showValueMapStep(t,e){!this.stepMapping||!this.stepValueMap||(this.stepMapping.hide(),this.stepValueMap.show(),this.renderValueMapStep(this.stepValueMap,t,e))}renderValueMapStep(t,e,i){t.empty(),t.createDiv({cls:"wl-csv-step-title",text:"Map unknown values"}),t.createDiv({cls:"wl-csv-step-desc",text:"Some values in your CSV were not recognized. Map each to an existing value, or leave it blank."});let a={},n={};if(e.length>0){t.createDiv({cls:"wl-csv-valmap-section-title",text:"Status"});for(let c of e){let d=t.createDiv({cls:"wl-csv-valmap-row"});d.createSpan({cls:"wl-csv-valmap-orig",text:`"${c}"`}),d.createSpan({cls:"wl-csv-valmap-arrow",text:"\u2192"});let u=d.createEl("select",{cls:"wl-select wl-csv-valmap-select"});a[c]=u,u.createEl("option",{value:"",text:"\u2014 leave blank \u2014"});for(let m of lt)u.createEl("option",{value:m,text:m});let h=c.toLowerCase(),p=lt.find(m=>m.toLowerCase()===h);p&&(u.value=p)}}if(i.length>0){t.createDiv({cls:"wl-csv-valmap-section-title",text:"Rating"});for(let c of i){let d=t.createDiv({cls:"wl-csv-valmap-row"});d.createSpan({cls:"wl-csv-valmap-orig",text:`"${c}"`}),d.createSpan({cls:"wl-csv-valmap-arrow",text:"\u2192"});let u=d.createEl("select",{cls:"wl-select wl-csv-valmap-select"});n[c]=u,u.createEl("option",{value:"",text:"\u2014 leave blank \u2014"});for(let h=1;h<=5;h++)u.createEl("option",{value:String(h),text:`${h}/5`})}}let r=t.createDiv({cls:"wl-csv-valmap-btn-row"}),o=r.createEl("button",{cls:"wl-btn",text:"\u2190 back"}),l=r.createEl("button",{cls:"wl-btn wl-btn-primary",text:"Preview \u2192"});o.addEventListener("click",()=>{!this.stepValueMap||!this.stepMapping||(this.stepValueMap.hide(),this.stepMapping.show())}),l.addEventListener("click",()=>{var c,d,u,h;this.statusValueMap={};for(let p of e)this.statusValueMap[p]=(d=(c=a[p])==null?void 0:c.value)!=null?d:"";this.ratingValueMap={};for(let p of i)this.ratingValueMap[p]=(h=(u=n[p])==null?void 0:u.value)!=null?h:"";this.applyValueMapsToRows(),this.showPreviewStep()})}applyValueMapsToRows(){var a,n,r,o,l,c,d;let t=((a=this.csvRows[0])!=null?a:[]).map(u=>u.trim()),e=this.columnMapping.rating,i=e?t.indexOf(e):-1;for(let u of this.importRows){let h=(r=(n=u.entry.status)==null?void 0:n.trim())!=null?r:"";if(h&&Object.prototype.hasOwnProperty.call(this.statusValueMap,h)){let p=(o=this.statusValueMap[h])!=null?o:"";u.entry.status=p||void 0}if(i>=0){let p=this.importRows.indexOf(u),v=((c=((l=this.csvRows[p+1])!=null?l:[])[i])!=null?c:"").trim();if(v&&Object.prototype.hasOwnProperty.call(this.ratingValueMap,v)){let f=(d=this.ratingValueMap[v])!=null?d:"";u.entry.rating=f?parseInt(f):0}}}}showPreviewStep(){!this.stepValueMap||!this.stepMapping||!this.stepPreview||!this.importBtn||(this.stepValueMap.hide(),this.stepMapping.hide(),this.stepPreview.show(),this.importBtn.show(),this.importBtn.disabled=this.importRows.filter(t=>t.selected).length===0,this.renderPreviewStep(this.stepPreview))}renderPreviewStep(t){var u;t.empty();let e=this.importRows.filter(h=>h.isDuplicate).length;e>0&&t.createDiv({cls:"wl-csv-dupe-warning",text:`${e} duplicate${e!==1?"s":""} found (highlighted). These are deselected by default.`});let i=t.createDiv({cls:"wl-csv-ctrl-row"}),a=i.createEl("button",{cls:"wl-btn",text:"\u2190 back"}),n=i.createEl("button",{cls:"wl-btn",text:"Select all"}),r=i.createEl("button",{cls:"wl-btn",text:"Select none"}),o=i.createSpan({cls:"wl-csv-count",text:""});a.addEventListener("click",()=>{if(!this.stepPreview||!this.stepValueMap||!this.stepMapping||!this.importBtn)return;this.stepPreview.hide(),this.importBtn.hide(),this.importBtn.disabled=!0,Object.keys(this.statusValueMap).length>0||Object.keys(this.ratingValueMap).length>0?this.stepValueMap.show():this.stepMapping.show()});let l=t.createDiv({cls:"wl-csv-list"}),c=[],d=()=>{let h=this.importRows.filter(p=>p.selected).length;o.textContent=`${h} of ${this.importRows.length} selected`,this.importBtn&&(this.importBtn.disabled=h===0)};for(let h=0;h<this.importRows.length;h++){let p=this.importRows[h],m=l.createDiv({cls:`wl-csv-row${p.isDuplicate?" wl-csv-row-dupe":""}`}),v=m.createEl("input",{attr:{type:"checkbox"}});v.checked=p.selected,c.push(v),m.createSpan({cls:"wl-csv-row-title",text:(u=p.entry.title)!=null?u:"(no title)"});let f=[p.entry.author,p.entry.status].filter(Boolean).join(" \xB7 ");f&&m.createSpan({cls:"wl-csv-row-meta",text:f}),p.isDuplicate&&m.createSpan({cls:"wl-csv-dupe-badge",text:"duplicate"});let y=h;v.addEventListener("change",()=>{this.importRows[y].selected=v.checked,d()})}n.addEventListener("click",()=>{this.importRows.forEach(h=>{h.selected=!0}),c.forEach(h=>{h.checked=!0}),d()}),r.addEventListener("click",()=>{this.importRows.forEach(h=>{h.selected=!1}),c.forEach(h=>{h.checked=!1}),d()}),d()}buildBook(t,e){var n,r,o,l,c,d,u,h,p,m,v;let i=t.title.trim(),a=new Date().toISOString();return{id:this.plugin.readingDataManager.generateBookId(i),title:i,author:(n=t.author)!=null?n:"",status:t.status||e,rating:(r=t.rating)!=null?r:0,pagesRead:(o=t.pagesRead)!=null?o:0,totalPages:(l=t.totalPages)!=null?l:0,chaptersRead:(c=t.chaptersRead)!=null?c:0,totalChapters:(d=t.totalChapters)!=null?d:0,coverUrl:"",googleBooksId:(u=t.googleBooksId)!=null?u:"",externalLink:(h=t.externalLink)!=null?h:"",vaultPage:"",dateStarted:(p=t.dateStarted)!=null?p:null,dateFinished:(m=t.dateFinished)!=null?m:null,releaseDate:(v=t.releaseDate)!=null?v:null,dateAdded:a,dateModified:a,customFields:{}}}buildManga(t,e){var n,r,o,l,c,d,u,h,p,m,v;let i=t.title.trim(),a=new Date().toISOString();return{id:this.plugin.readingDataManager.generateMangaId(i),title:i,author:(n=t.author)!=null?n:"",status:t.status||e,rating:(r=t.rating)!=null?r:0,chaptersRead:(o=t.chaptersRead)!=null?o:0,totalChapters:(l=t.totalChapters)!=null?l:0,volumesRead:(c=t.volumesRead)!=null?c:0,totalVolumes:(d=t.totalVolumes)!=null?d:0,coverUrl:"",malId:(u=t.malId)!=null?u:"",externalLink:(h=t.externalLink)!=null?h:"",vaultPage:"",dateStarted:(p=t.dateStarted)!=null?p:null,dateFinished:(m=t.dateFinished)!=null?m:null,releaseDate:(v=t.releaseDate)!=null?v:null,dateAdded:a,dateModified:a,customFields:{}}}async doImport(){var r;let t=this.importRows.filter(o=>{var l;return o.selected&&((l=o.entry.title)==null?void 0:l.trim())});if(t.length===0){new Ct.Notice(`No ${this.itemNoun} selected to import.`);return}this.isImporting=!0,this.importCancelled=!1,this.importBtn&&(this.importBtn.disabled=!0),this.cancelBtn&&(this.cancelBtn.textContent="Cancel import"),this.progressWrap&&this.progressWrap.show(),this.progressText&&(this.progressText.textContent=`0 / ${t.length}`),this.progressBarFill&&(this.progressBarFill.style.width="0%");let e=this.plugin.readingDataManager,i=(r=e.getSettings().defaultStatus)!=null?r:"Plan to Read",a=10,n=0;try{for(let o=0;o<t.length&&!this.importCancelled;o+=a){let l=Math.min(o+a,t.length);if(this.kind==="book"){let d=[];for(let u=o;u<l&&!this.importCancelled;u++)d.push(this.buildBook(t[u].entry,i));if(d.length===0)break;await e.addBookBatch(d),n+=d.length}else{let d=[];for(let u=o;u<l&&!this.importCancelled;u++)d.push(this.buildManga(t[u].entry,i));if(d.length===0)break;await e.addMangaBatch(d),n+=d.length}let c=Math.round(n/t.length*100);this.progressBarFill&&(this.progressBarFill.style.width=`${c}%`),this.progressText&&(this.progressText.textContent=`${n} / ${t.length}`)}}finally{this.isImporting=!1,e.notifyChange()}this.importCancelled?(this.progressText&&(this.progressText.textContent=`Cancelled \u2014 ${n} / ${t.length} imported`),this.cancelBtn&&(this.cancelBtn.disabled=!1,this.cancelBtn.textContent="Close"),new Ct.Notice(`Import cancelled. ${n} ${this.itemNoun}${n!==1?"s":""} imported.`)):(this.progressText&&(this.progressText.textContent=`Done \u2014 ${n} imported`),this.cancelBtn&&(this.cancelBtn.disabled=!1,this.cancelBtn.textContent="Close"),new Ct.Notice(`Imported ${n} ${this.itemNoun}${n!==1?"s":""}.`),this.close())}};var Ke=class Ke extends R.PluginSettingTab{constructor(t,e){super(t,e);this.textSaveTimer=null;this.activeSection="general";this.plugin=e}debouncedSaveSettings(){this.textSaveTimer!==null&&window.clearTimeout(this.textSaveTimer),this.textSaveTimer=window.setTimeout(()=>{this.textSaveTimer=null,this.plugin.saveSettings()},500)}display(){let{containerEl:t}=this;t.empty(),t.addClass("wl-settings");let e=t.createDiv({cls:"wl-settings-nav"}),i=[{key:"general",label:"General"},{key:"api",label:"API"},{key:"customize",label:"Customize"},{key:"watchlist",label:"Watchlist"},{key:"drafts",label:"Drafts"},{key:"custom-lists",label:"Custom Lists"},{key:"reading",label:"Reading"},{key:"widgets",label:"Widgets"},{key:"quick-info",label:"Quick Info"}],a=new Map;for(let r of i){let o=e.createEl("button",{cls:`wl-settings-nav-btn${this.activeSection===r.key?" is-active":""}`,text:r.label});a.set(r.key,o),o.addEventListener("click",()=>{this.activeSection=r.key,a.forEach((l,c)=>{c===r.key?l.addClass("is-active"):l.removeClass("is-active")}),n.empty(),this.renderSection(n)})}let n=t.createDiv({cls:"wl-settings-body"});this.renderSection(n)}renderSection(t){switch(t.empty(),this.activeSection){case"general":this.renderGeneral(t);break;case"api":this.renderApi(t);break;case"customize":this.renderCustomize(t);break;case"watchlist":this.renderWatchlist(t);break;case"drafts":this.renderDrafts(t);break;case"custom-lists":this.renderCustomLists(t);break;case"reading":this.renderReading(t);break;case"widgets":this.renderWidgets(t);break;case"quick-info":this.renderQuickInfo(t);break}}renderGeneral(t){new R.Setting(t).setName("Dashboard card style").setDesc("Choose between ring charts or rectangular stat cards on the dashboard.").addDropdown(o=>{var l;return o.addOptions({circles:"Circles",rectangles:"Rectangles"}).setValue((l=this.plugin.settings.dashboardCardStyle)!=null?l:"circles").onChange(async c=>{this.plugin.settings.dashboardCardStyle=c,await this.plugin.saveSettings(),activeDocument.dispatchEvent(new CustomEvent("watchlog-data-changed"))})}),new R.Setting(t).setName("Set finish date automatically").setDesc("Record today's date as the finish date when a watch title or reading entry is completed.").addToggle(o=>o.setValue(this.plugin.settings.setFinishDateAutomatically).onChange(async l=>{this.plugin.settings.setFinishDateAutomatically=l,await this.plugin.saveSettings()})),new R.Setting(t).setName("Re-fetch all posters").setDesc("Clears all cached poster and reading cover URLs. Watch posters reload lazily when you scroll the Cards view; reading covers can be re-fetched from each card's \u22EE menu.").addButton(o=>o.setButtonText("Re-fetch posters").onClick(()=>{let l=this.plugin.dataManager.getTitles(),c=this.plugin.readingDataManager.getBooks().length+this.plugin.readingDataManager.getMangaList().length;new H(this.app,`This will clear cached images for all ${l.length} watch titles and ${c} reading entries. Items without API access will show placeholder cards. Continue?`,()=>{this.refetchAllPosters()}).open()})),new R.Setting(t).setName("Show hint banners").setDesc("Show informational hint banners in Upcoming, Custom Lists, and Drafts tabs.").addToggle(o=>o.setValue(this.plugin.settings.showHintBanners).onChange(async l=>{this.plugin.settings.showHintBanners=l,await this.plugin.saveSettings(),activeDocument.dispatchEvent(new CustomEvent("watchlog-data-changed"))})),new R.Setting(t).setName("Show Upcoming count in status bar").setDesc('Show a "N due" counter in the status bar when Upcoming entries are due. Click it to open the Upcoming tab. (Desktop only.)').addToggle(o=>o.setValue(this.plugin.settings.showUpcomingStatusBar).onChange(async l=>{this.plugin.settings.showUpcomingStatusBar=l,await this.plugin.saveSettings(),this.plugin.updateStatusBar()})),t.createDiv({cls:"wl-settings-section-title",text:"Backup & Restore"}),new R.Setting(t).setName("Export backup").setDesc("Export all watchlog data \u2014 watchlist, reading (books & manga) and activity log \u2014 into a single timestamped .JSON file.").addButton(o=>o.setButtonText("Export backup").onClick(()=>void this.exportBackup())),new R.Setting(t).setName("Restore from backup").setDesc("Restore from a .JSON backup file. This replaces your current watchlist, reading and activity-log data with the contents of the backup. Older watch-only backups restore the watchlist only and leave reading and activity log untouched.").addButton(o=>o.setButtonText("Restore from backup").onClick(()=>this.openRestoreDialog()));let e={wrap:null,fill:null,text:null};new R.Setting(t).setName("Regenerate note files").setDesc("Creates missing .md files for titles that don't have one. Existing files are not modified.").addButton(o=>o.setButtonText("Regenerate").onClick(()=>{let{wrap:l,fill:c,text:d}=e;l&&c&&d&&this.runRegenerate(l,c,d)}));let i=t.createDiv({cls:"wl-regen-progress-wrap"});i.hide(),i.createSpan({cls:"wl-csv-bg-note",text:"You can close settings \u2014 regeneration continues in the background."});let n=i.createDiv({cls:"wl-csv-progress-track"}).createDiv({cls:"wl-csv-progress-fill"}),r=i.createSpan({cls:"wl-csv-progress-text",text:"0 / 0"});e.wrap=i,e.fill=n,e.text=r}async exportBackup(){var c;let t=new Date,i=`watchlog-backup-${`${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,"0")}-${String(t.getDate()).padStart(2,"0")}`}.json`,a={version:2,exportedAt:t.toISOString(),watch:(c=this.plugin.dataManager.getData())!=null?c:{},reading:this.plugin.readingDataManager.getData(),history:this.plugin.historyManager.exportEntries()},n=JSON.stringify(a,null,2);if(R.Platform.isMobile){await this.exportBackupToVault(i,n);return}let r=new Blob([n],{type:"application/json"}),o=URL.createObjectURL(r),l=activeDocument.createElement("a");l.href=o,l.download=i,activeDocument.body.appendChild(l),l.click(),activeDocument.body.removeChild(l),URL.revokeObjectURL(o),new R.Notice(`Backup exported as ${i}`)}async exportBackupToVault(t,e){let i=this.app.vault.adapter,a=(0,R.normalizePath)("WatchLog/backups");if(!await i.exists(a))try{await i.mkdir(a)}catch(c){}let n=t.lastIndexOf("."),r=n===-1?t:t.slice(0,n),o=n===-1?"":t.slice(n),l=(0,R.normalizePath)(`${a}/${t}`);if(await i.exists(l)){let c=2;for(;await i.exists((0,R.normalizePath)(`${a}/${r}-${c}${o}`));)c++;l=(0,R.normalizePath)(`${a}/${r}-${c}${o}`)}await i.write(l,e),new R.Notice(`Backup saved to ${l}`)}openRestoreDialog(){let t=activeDocument.createElement("input");t.type="file",t.accept=".json",t.hide(),activeDocument.body.appendChild(t);let e=!1,i=()=>{if(t.parentElement)try{activeDocument.body.removeChild(t)}catch(n){}},a=()=>{window.setTimeout(()=>{e||i(),window.removeEventListener("focus",a)},200)};window.addEventListener("focus",a),t.addEventListener("change",()=>{var o;e=!0;let n=(o=t.files)==null?void 0:o[0];if(!n){i();return}let r=new FileReader;r.onload=l=>{var h;i();let c=(h=l.target)==null?void 0:h.result,d;try{d=JSON.parse(c)}catch(p){new R.Notice("Invalid backup file \u2014 could not parse JSON.");return}if(typeof d!="object"||d===null){new R.Notice("Invalid backup file \u2014 not an object.");return}let u=d;if(u.version===2){let p=u.watch,m=u.reading,v=u.history;if(!this.isValidWatchData(p)){new R.Notice("Invalid backup file \u2014 watch data missing required fields (titles, groups, settings).");return}if(!this.isValidReadingData(m)){new R.Notice("Invalid backup file \u2014 reading data missing required fields (books, manga).");return}if(!Array.isArray(v)){new R.Notice("Invalid backup file \u2014 activity log is not a list.");return}new H(this.app,"This will replace ALL current watchlog data \u2014 watchlist, reading (books & manga) and activity log. Continue?",()=>{(async()=>(await this.plugin.saveData(p),await this.plugin.loadSettings(),await this.plugin.dataManager.load(),await this.plugin.readingDataManager.restore(m),await this.plugin.historyManager.restore(v),activeDocument.dispatchEvent(new CustomEvent("watchlog-data-changed")),new R.Notice("Backup restored successfully.")))()}).open();return}if(!this.isValidWatchData(d)){new R.Notice("Invalid backup file \u2014 missing required fields (titles, groups, settings).");return}new H(this.app,"This is a legacy (watchlist-only) backup. It will replace your current watchlist. Reading and activity-log data are left untouched. Continue?",()=>{this.plugin.saveData(d).then(async()=>{await this.plugin.loadSettings(),await this.plugin.dataManager.load(),activeDocument.dispatchEvent(new CustomEvent("watchlog-data-changed")),new R.Notice("Legacy backup restored \u2014 watchlist only; reading and activity log left untouched.")})}).open()},r.readAsText(n)}),t.click()}isValidWatchData(t){if(typeof t!="object"||t===null)return!1;let e=t;return Array.isArray(e.titles)&&Array.isArray(e.groups)&&typeof e.settings=="object"&&e.settings!==null}isValidReadingData(t){if(typeof t!="object"||t===null)return!1;let e=t;return Array.isArray(e.books)&&Array.isArray(e.manga)&&typeof e.settings=="object"&&e.settings!==null}async refetchAllPosters(){let t=this.plugin.dataManager.getTitles();for(let i of t)i.posterUrl="";await this.plugin.dataManager.save();let e=this.plugin.readingDataManager;for(let i of e.getBooks())i.coverUrl="";for(let i of e.getMangaList())i.coverUrl="";await e.saveAndNotify(),new R.Notice("Cached posters and covers cleared. Watch posters reload as you browse; refresh reading covers from each card's \u22EE menu.")}async runRegenerate(t,e,i){let a=this.plugin.dataManager.getTitles(),n=this.plugin.readingDataManager,r=n.getBooks(),o=n.getMangaList(),l=a.length+r.length+o.length;if(t.show(),l===0){i.textContent="Done \u2014 no missing files found";return}e.style.width="0%",i.textContent=`0 / ${l}`;let c=0,d=0,u=0,h=p=>{p?c++:d++,u++;let m=Math.round(u/l*100);e.style.width=`${m}%`,i.textContent=`${u} / ${l}`};for(let p of a)h(await this.plugin.dataManager.createMarkdownFileIfMissing(p));for(let p of r)h(await n.createReadingNoteIfMissing("book",p));for(let p of o)h(await n.createReadingNoteIfMissing("manga",p));c===0?i.textContent="Done \u2014 no missing files found":i.textContent=`Done \u2014 ${c} file${c!==1?"s":""} created, ${d} already existed`}apiCallout(t,e,i){let a=t.createDiv({cls:`wl-api-callout wl-api-callout--${i}`});return a.createDiv({cls:"wl-api-callout-title",text:e}),a.createDiv({cls:"wl-api-callout-body"})}renderApi(t){let e=this.apiCallout(t,"Movies & TV Shows","movies");new R.Setting(e).setName("Active API").setDesc("Which API to use for movies and tv shows.").addDropdown(u=>{var h;return u.addOptions({OMDb:"OMDb",TMDB:"TMDB"}).setValue((h=this.plugin.settings.activeApi)!=null?h:"OMDb").onChange(async p=>{this.plugin.settings.activeApi=p,await this.plugin.saveSettings()})});let i=e.createDiv({cls:"wl-settings-info"});i.createSpan({text:"Get a free OMDb API key at "}),i.createEl("a",{text:"omdbapi.com/apikey.aspx",href:"https://www.omdbapi.com/apikey.aspx",attr:{target:"_blank",rel:"noopener noreferrer"}}),i.createSpan({text:"."});let a;new R.Setting(e).setName("Omdb API key").addText(u=>(u.setPlaceholder("Paste your omdb key here").setValue(this.plugin.settings.omdbApiKey).onChange(h=>{this.plugin.settings.omdbApiKey=h,this.plugin.apiService.setOmdbKey(h),this.debouncedSaveSettings()}),u.inputEl.type="password",u)).addButton(u=>u.setButtonText("Test").onClick(async()=>{let h=await this.plugin.apiService.checkOmdbConnection();a.textContent=h?"\u2713 Connected":"\u2717 Failed \u2014 check your key",a.className=h?"wl-status-ok":"wl-status-error"})),a=e.createDiv({cls:"wl-status-indicator",text:this.plugin.settings.omdbApiKey?"(not tested)":"Not set"});let n=e.createDiv({cls:"wl-settings-info"});n.createSpan({text:"Get a free TMDB read access token at "}),n.createEl("a",{text:"themoviedb.org/settings/api",href:"https://www.themoviedb.org/settings/api",attr:{target:"_blank",rel:"noopener noreferrer"}}),n.createSpan({text:"."});let r;new R.Setting(e).setName("Tmdb API read access token").addText(u=>{var h;return u.setPlaceholder("Paste your tmdb key here").setValue((h=this.plugin.settings.tmdbApiKey)!=null?h:"").onChange(p=>{this.plugin.settings.tmdbApiKey=p,this.plugin.apiService.setTmdbKey(p),this.debouncedSaveSettings()}),u.inputEl.type="password",u}).addButton(u=>u.setButtonText("Test").onClick(async()=>{let h=await this.plugin.apiService.checkTmdbConnection();r.textContent=h?"\u2713 Connected":"\u2717 Failed \u2014 check your key",r.className=h?"wl-status-ok":"wl-status-error"})),r=e.createDiv({cls:"wl-status-indicator",text:this.plugin.settings.tmdbApiKey?"(not tested)":"Not set"});let o=this.apiCallout(t,"Anime","anime");new R.Setting(o).setName("Anime API source").setDesc("Which API to use for new anime titles.").addDropdown(u=>{var h;return u.addOptions({jikan:"Jikan (MyAnimeList)",anilist:"AniList"}).setValue((h=this.plugin.settings.animeApiSource)!=null?h:"jikan").onChange(async p=>{this.plugin.settings.animeApiSource=p,await this.plugin.saveSettings()})}),o.createDiv({cls:"wl-settings-info",text:"Jikan uses MyAnimeList's database (larger catalog, no API key needed). AniList has its own database with more precise airing schedules and a GraphQL API. This setting only affects new titles \u2014 existing titles keep their original API source."});let l=this.apiCallout(t,"Books","books"),c;new R.Setting(l).setName("Google Books API key").addText(u=>{var h;return u.setPlaceholder("Paste your Google Books key here").setValue((h=this.plugin.settings.googleBooksApiKey)!=null?h:"").onChange(p=>{this.plugin.settings.googleBooksApiKey=p,this.plugin.apiService.setGoogleBooksKey(p),this.debouncedSaveSettings()}),u.inputEl.type="password",u}).addButton(u=>u.setButtonText("Test").onClick(async()=>{c.textContent="Testing...",c.className="wl-status-indicator";try{let h=await this.plugin.apiService.checkGoogleBooksConnection();c.textContent=h?"\u2713 Connected":"\u2717 Failed",c.className=h?"wl-status-ok":"wl-status-error"}catch(h){c.textContent=`\u2717 ${Et(h)}`,c.className="wl-status-error"}})),c=l.createDiv({cls:"wl-status-indicator",text:this.plugin.settings.googleBooksApiKey?"(not tested)":"Not set"});let d=l.createDiv({cls:"wl-settings-info"});d.createSpan({text:"Get a free key in the Google Cloud Console: create a project, enable the Books API, then create an API key under "}),d.createEl("a",{text:"APIs & Services \u2192 Credentials",href:"https://console.cloud.google.com/apis/credentials",attr:{target:"_blank",rel:"noopener noreferrer"}}),d.createSpan({text:"."}),this.renderTypeApiMapping(t)}renderTypeApiMapping(t){var l,c,d;let e=t.createDiv({cls:"wl-settings-card"});e.createDiv({cls:"wl-settings-card-title",text:"API routing by type in Watchlist"}),e.createDiv({cls:"wl-settings-card-desc",text:"Choose which API group to use for each title type. Anime, Movie, and TV Show are locked to their default APIs."});let i=e.createDiv({cls:"wl-type-api-list"}),a=(l=this.plugin.settings.types)!=null?l:[],n=(c=this.plugin.settings.typeApiMapping)!=null?c:{},r=new Set(a.map(u=>u.name)),o=!1;for(let u of Object.keys(n))(!r.has(u)||u==="Anime"||u==="Movie"||u==="TV Show"||u==="TvShow")&&(delete n[u],o=!0);o&&this.plugin.saveSettings();for(let u of a){let h=i.createDiv({cls:"wl-type-api-row"});if(h.createSpan({cls:"wl-type-api-name",text:u.name}),u.name==="Anime")h.createSpan({cls:"wl-type-api-locked",text:"Jikan / AniList (locked)"});else if(u.name==="Movie"||u.name==="TV Show"||u.name==="TvShow")h.createSpan({cls:"wl-type-api-locked",text:"OMDb / TMDB (locked)"});else{let p=h.createEl("select",{cls:"wl-select wl-type-api-select"}),m=(d=n[u.name])!=null?d:"",v=[{value:"",label:"\u2014 Not set \u2014"},{value:"anime",label:"Anime API (Jikan / AniList)"},{value:"movie",label:"Movie / TV API (OMDb / TMDB)"}];for(let f of v){let y=p.createEl("option",{text:f.label,value:f.value});m===f.value&&(y.selected=!0)}p.addEventListener("change",()=>{let f=p.value;this.plugin.settings.typeApiMapping||(this.plugin.settings.typeApiMapping={}),f===""?delete this.plugin.settings.typeApiMapping[u.name]:this.plugin.settings.typeApiMapping[u.name]=f,this.plugin.saveSettings()})}}}renderCustomize(t){this.renderThemePicker(t);let e=[{key:"types",label:"Type"},{key:"statuses",label:"Status"},{key:"priorities",label:"Priority"}];for(let i of e)this.renderTagSection(t,i.label,i.key);this.renderSeasonColors(t),this.renderReadingColors(t)}renderReadingColors(t){let e=t.createDiv({cls:"wl-tag-section"});e.createDiv({cls:"wl-settings-section-title",text:"Reading"});let i=[{label:"Manga",kind:"manga"},{label:"Book",kind:"book"}],a=e.createDiv({cls:"wl-tags-list"});for(let{label:n,kind:r}of i){let o=a.createDiv({cls:"wl-tag-pill"}),l=o.createDiv({cls:"wl-tag-dot-wrap"}),c=l.createSpan({cls:"wl-tag-dot"});c.style.backgroundColor=this.plugin.settings.readingTypeColors[r];let d=l.createEl("input",{cls:"wl-tag-color-picker",attr:{type:"color",value:this.plugin.settings.readingTypeColors[r]}});c.addEventListener("click",()=>d.click()),d.addEventListener("change",()=>{(async()=>(this.plugin.settings.readingTypeColors[r]=d.value,c.style.backgroundColor=d.value,await this.plugin.saveSettings()))()}),o.createSpan({cls:"wl-tag-name",text:n})}}renderThemePicker(t){var o;let e=t.createDiv({cls:"wl-tag-section"});e.createDiv({cls:"wl-settings-section-title",text:"Theme"});let i=[{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"]}],a=(o=this.plugin.settings.colorTheme)!=null?o:"default",n=e.createDiv({cls:"wl-theme-cards"}),r=[];for(let l of i){let c=n.createDiv({cls:`wl-theme-card${a===l.key?" is-selected":""}`});r.push(c),c.createDiv({cls:"wl-theme-card-name",text:l.label});let d=c.createDiv({cls:"wl-theme-strip"});for(let u of l.colors){let h=d.createDiv({cls:"wl-theme-swatch"});h.style.backgroundColor=u}c.addEventListener("click",()=>{(async()=>{this.plugin.settings.colorTheme=l.key,await this.plugin.saveSettings();for(let h of r)h.removeClass("is-selected");c.addClass("is-selected"),activeDocument.dispatchEvent(new CustomEvent("watchlog-data-changed"));let u=this.plugin.app.workspace.getLeavesOfType("watchlog-view");for(let h of u)h.view instanceof kt&&h.view.refreshUI()})()})}}renderSeasonColors(t){let e=t.createDiv({cls:"wl-tag-section"});e.createDiv({cls:"wl-settings-section-title",text:"Season colors"}),e.createDiv({cls:"wl-settings-info",text:"Colors cycle through seasons in order (Season 1 = color 1, etc.)."});let i=e.createDiv({cls:"wl-season-colors-list"}),a=()=>{i.empty(),this.plugin.settings.seasonPalette.forEach((l,c)=>{let d=i.createDiv({cls:"wl-season-color-pill"}),u=d.createEl("input",{cls:"wl-color-input",attr:{type:"color",value:l}});u.addEventListener("change",()=>{(async()=>(this.plugin.settings.seasonPalette[c]=u.value,await this.plugin.saveSettings()))()}),d.createSpan({cls:"wl-season-color-label",text:`Season ${c+1}`}),d.createSpan({cls:"wl-tag-del",text:"\xD7"}).addEventListener("click",()=>{(async()=>(this.plugin.settings.seasonPalette.splice(c,1),await this.plugin.saveSettings(),a()))()})})};a();let n=e.createDiv({cls:"wl-tag-add-row"}),r=n.createEl("input",{cls:"wl-color-input",attr:{type:"color",value:"#888780"}});n.createEl("button",{cls:"wl-btn wl-btn-success",text:"+ add color"}).addEventListener("click",()=>{(async()=>(this.plugin.settings.seasonPalette.push(r.value),await this.plugin.saveSettings(),r.value="#888780",a()))()})}renderTagSection(t,e,i){let a=i==="statuses",n=i==="types",r=t.createDiv({cls:"wl-tag-section"});r.createDiv({cls:"wl-settings-section-title",text:e}),a&&r.createDiv({cls:"wl-settings-info",text:"Status names are locked. Only the color can be changed."}),n&&r.createDiv({cls:"wl-settings-info",text:"Built-in types are locked. Custom types can be added and removed."});let o=r.createDiv({cls:"wl-tags-list"}),l=()=>{o.empty();for(let c of this.plugin.settings[i])this.renderTagPill(o,c,i,l)};if(l(),!a){let c=r.createDiv({cls:"wl-tag-add-row"}),d=c.createEl("input",{cls:"wl-modal-input",attr:{type:"text",placeholder:"Name"}}),u=c.createEl("input",{cls:"wl-color-input",attr:{type:"color",value:"#888780"}});c.createEl("button",{cls:"wl-btn wl-btn-success",text:"+ add"}).addEventListener("click",()=>{(async()=>{let p=d.value.trim();p&&(this.plugin.settings[i].push({name:p,color:u.value}),await this.plugin.saveSettings(),d.value="",u.value="#888780",l())})()})}}renderTagPill(t,e,i,a){let n=t.createDiv({cls:"wl-tag-pill"}),r=n.createDiv({cls:"wl-tag-dot-wrap"}),o=r.createSpan({cls:"wl-tag-dot"});o.style.backgroundColor=e.color;let l=r.createEl("input",{cls:"wl-tag-color-picker",attr:{type:"color",value:e.color}});o.addEventListener("click",()=>l.click()),l.addEventListener("change",()=>{(async()=>(e.color=l.value,o.style.backgroundColor=l.value,await this.plugin.saveSettings()))()}),n.createSpan({cls:"wl-tag-name",text:e.name});let c=i==="types"&&Ke.LOCKED_TYPES.includes(e.name);i!=="statuses"&&!c&&n.createSpan({cls:"wl-tag-del",text:"\xD7"}).addEventListener("click",()=>{(async()=>(this.plugin.settings[i]=this.plugin.settings[i].filter(u=>u.name!==e.name),await this.plugin.saveSettings(),a(),new R.Notice(`Removed "${e.name}".`)))()})}renderWatchlist(t){new R.Setting(t).setName("Default watchlist view").setDesc("Which sub-tab opens by default when entering the Watchlist.").addDropdown(a=>{var n;return a.addOptions({list:"List",cards:"Cards"}).setValue((n=this.plugin.settings.defaultWatchlistView)!=null?n:"cards").onChange(async r=>{this.plugin.settings.defaultWatchlistView=r,await this.plugin.saveSettings()})}),new R.Setting(t).setName("Episode numbering").setDesc("Absolute: episodes numbered 1\u2192n across all seasons. Per season: each season restarts from 1 (display only, data is unchanged).").addDropdown(a=>{var n;return a.addOptions({absolute:"Absolute","per-season":"Per season"}).setValue((n=this.plugin.settings.episodeNumbering)!=null?n:"absolute").onChange(async r=>{this.plugin.settings.episodeNumbering=r,await this.plugin.saveSettings(),activeDocument.dispatchEvent(new CustomEvent("watchlog-data-changed"))})}),new R.Setting(t).setName("Auto-complete on last episode").setDesc("Mark status as completed when all episodes are watched.").addToggle(a=>a.setValue(this.plugin.settings.autoCompleteOnLastEpisode).onChange(async n=>{this.plugin.settings.autoCompleteOnLastEpisode=n,await this.plugin.saveSettings()})),new R.Setting(t).setName("Colored type badges").setDesc("Show type and status badges with their configured colors. Disable for a plain text style.").addToggle(a=>a.setValue(this.plugin.settings.coloredTypeBadges).onChange(async n=>{this.plugin.settings.coloredTypeBadges=n,await this.plugin.saveSettings()})),t.createDiv({cls:"wl-settings-section-title",text:"Import / Export CSV"}),new R.Setting(t).setName("Export to CSV").setDesc("Export selected titles as a CSV file.").addButton(a=>a.setButtonText("Export CSV").onClick(()=>{new le(this.app,this.plugin,this.plugin.dataManager,"export").open()})),new R.Setting(t).setName("Import from CSV").setDesc("Import titles from a CSV file. Duplicates are highlighted before import.").addButton(a=>a.setButtonText("Import CSV").onClick(()=>{new le(this.app,this.plugin,this.plugin.dataManager,"import").open()})),t.createDiv({cls:"wl-settings-section-title",text:"Folders"}),new R.Setting(t).setName("Root folder name").setDesc("Vault folder where watchlog Markdown files are stored.").addText(a=>a.setValue(this.plugin.settings.rootFolder).onChange(async n=>{this.plugin.settings.rootFolder=n,await this.plugin.saveSettings()})),new R.Setting(t).setName("Auto-create folders").setDesc("Automatically create type subfolders on plugin load.").addToggle(a=>a.setValue(this.plugin.settings.autoCreateFolders).onChange(async n=>{this.plugin.settings.autoCreateFolders=n,await this.plugin.saveSettings()})),t.createDiv({cls:"wl-settings-section-title",text:"Type subfolders"});let e=t.createDiv({cls:"wl-folder-list"});(()=>{e.empty();for(let a of this.plugin.settings.types){let n=e.createDiv({cls:"wl-folder-row"});n.createSpan({cls:"wl-folder-type",text:a.name}),n.createSpan({cls:"wl-folder-path",text:`${this.plugin.settings.rootFolder}/${a.name}`})}})(),new R.Setting(t).setName("Create folders now").setDesc("Manually trigger folder creation for all current types.").addButton(a=>a.setButtonText("Create").onClick(async()=>{await this.plugin.dataManager.ensureFolders(),new R.Notice("Folders created.")}))}renderDrafts(t){t.createDiv({cls:"wl-settings-section-title",text:"Drafts"}),new R.Setting(t).setName("Vault tag to monitor").setDesc("Lines containing this tag followed by a title will appear in the drafts tab.").addText(e=>{var i;return e.setPlaceholder("#watchlog").setValue((i=this.plugin.settings.draftsVaultTag)!=null?i:"#watchlog").onChange(async a=>{this.plugin.settings.draftsVaultTag=a.trim()||"#watchlog",await this.plugin.saveSettings()})}),new R.Setting(t).setName("After adding a title").setDesc("What happens to a draft entry once you hit add and confirm.").addDropdown(e=>{var i;return e.addOptions({keep:"Keep as Added",remove:"Remove"}).setValue((i=this.plugin.settings.draftsAfterAdding)!=null?i:"keep").onChange(async a=>{this.plugin.settings.draftsAfterAdding=a,await this.plugin.saveSettings()})})}renderCustomLists(t){t.createDiv({cls:"wl-settings-section-title",text:"Custom Lists"}),new R.Setting(t).setName("Custom lists folder path").setDesc("Vault folder where custom list files are stored.").addText(e=>{var i;return e.setValue((i=this.plugin.settings.customListsFolder)!=null?i:"WatchLog/CustomLists").onChange(async a=>{this.plugin.settings.customListsFolder=a,await this.plugin.saveSettings()})}),new R.Setting(t).setName("Default columns").setDesc("Columns pre-populated when creating a new list. The name column is always included.").addButton(e=>{var a;let i=((a=this.plugin.settings.defaultCustomColumns)!=null?a:[]).length;e.setButtonText(`Edit (${i} column${i!==1?"s":""})`),e.onClick(()=>{new Le(this.app,this.plugin).open()})}),new R.Setting(t).setName("Create folder now").setDesc("Manually create the custom lists folder in the vault.").addButton(e=>e.setButtonText("Create").onClick(async()=>{await new Wt(this.app,this.plugin).ensureFolder(),new R.Notice("Custom lists folder created.")}))}renderReading(t){let e=this.plugin.readingDataManager,i=e.getSettings();t.createDiv({cls:"wl-settings-section-title",text:"Reading"}),new R.Setting(t).setName("Reading folder path").setDesc("Vault folder where reading note files are generated. Changing this will not move existing files.").addText(a=>a.setPlaceholder("WatchLog/Reading").setValue(i.defaultFolder).onChange(n=>{let r=n.trim()||"WatchLog/Reading";e.updateSettings({defaultFolder:r})})),new R.Setting(t).setName("Default status for new entries").setDesc("Status pre-selected in the Add book / Add manga modal.").addDropdown(a=>{let n={};for(let r of lt)n[r]=r;a.addOptions(n).setValue(i.defaultStatus).onChange(r=>{e.updateSettings({defaultStatus:r})})}),new R.Setting(t).setName("Default sub-tab").setDesc("Which sub-tab opens by default when you switch to the Reading tab.").addDropdown(a=>{var n;a.addOptions({books:"Books",manga:"Manga"}).setValue((n=i.defaultSubTab)!=null?n:"books").onChange(r=>{e.updateSettings({defaultSubTab:r})})}),t.createDiv({cls:"wl-settings-section-title",text:"Import / Export CSV"}),new R.Setting(t).setName("Export to CSV").setDesc("Export your Books or Manga library as a CSV file.").addButton(a=>a.setButtonText("Export CSV").onClick(()=>{new ce(this.app,"export",n=>{new de(this.app,this.plugin,n,"export").open()}).open()})),new R.Setting(t).setName("Import from CSV").setDesc("Import Books or Manga from a CSV file, with column mapping and duplicate detection.").addButton(a=>a.setButtonText("Import CSV").onClick(()=>{new ce(this.app,"import",n=>{new de(this.app,this.plugin,n,"import").open()}).open()}))}renderQuickInfo(t){t.createDiv({cls:"wl-settings-section-title",text:"Quick Info"});let e=[{title:"\u{1F4CA} 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 \xD7 episode duration, summed across all titles with status Watching and Completed."},{heading:"Time Remaining",body:"Calculated as: episodes remaining \xD7 episode duration, summed across all titles with status Watching and Plan to Watch."},{heading:"Dropped",body:"Calculated as: episodes watched \xD7 episode duration, summed across all titles with status Dropped."}]},{title:"\u{1F4CB} 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:"\u{1F50D} API & Search",items:[{heading:"Which API is used for what",body:`WatchLog uses a different API per content type: +\u2022 Anime \u2014 Jikan (MyAnimeList data) or AniList, whichever you set under Settings \u2192 API \u2192 Anime. No key is needed for either. +\u2022 Movies & TV Shows \u2014 OMDb or TMDB, whichever is set as active under Settings \u2192 API. Both need a free key/token. +\u2022 Books (Reading) \u2014 Google Books. A free API key is required; add it under Settings \u2192 API \u2192 Books. +\u2022 Manga (Reading) \u2014 Jikan (MyAnimeList data). No key needed. -Note: APIs are there to help, not mandatory; you can manually add a new title. +The anime API choice only affects new titles \u2014 existing titles keep the source they were added with. -Note: The "Add from URL" button also uses an API.`}]},{title:"\u{1F4C5} 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(let i of s){let a=t.createDiv({cls:"wl-qi-section"}),l=a.createDiv({cls:"wl-qi-section-header"}),n=l.createSpan({cls:"wl-qi-chevron",text:"\u25B6"});l.createSpan({cls:"wl-qi-section-label",text:i.title});let r=a.createDiv({cls:"wl-qi-section-body wl-qi-section-body-hidden"});for(let o of i.items){let c=r.createDiv({cls:"wl-qi-banner"});c.createDiv({cls:"wl-qi-banner-heading",text:o.heading}),c.createDiv({cls:"wl-qi-banner-body",text:o.body})}l.addEventListener("click",()=>{let o=!r.hasClass("wl-qi-section-body-hidden");o?r.addClass("wl-qi-section-body-hidden"):r.removeClass("wl-qi-section-body-hidden"),n.textContent=o?"\u25B6":"\u25BC"})}}renderWidgets(t){t.createDiv({cls:"wl-settings-info",text:'Insert widgets into notes using the "WatchLog: Insert widget" command. Configure the keyboard shortcut in Obsidian Settings \u2192 Hotkeys.'});let s=[{name:"wl-todo",desc:'Track a specific title inline \u2014 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 \u2014 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 \xB7 Time Remaining \xB7 Completed.",syntax:"```wl-stat\ntime completed full\n```"},{name:"wl-now-next",desc:"Full-width double card: Now Watching \xB7 Up Next.",syntax:"```wl-now-next\n```"}];for(let i of s){let a=t.createDiv({cls:"wl-widget-info-section"});a.createDiv({cls:"wl-widget-info-name",text:i.name}),a.createDiv({cls:"wl-widget-info-desc",text:i.desc});let l=a.createDiv({cls:"wl-widget-info-code-row"});l.createEl("code",{cls:"wl-widget-info-code",text:i.syntax});let n=l.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Copy"});n.addEventListener("click",()=>{navigator.clipboard.writeText(i.syntax).then(()=>{n.textContent="Copied!",window.setTimeout(()=>{n.textContent="Copy"},1500)})})}}};Xt.LOCKED_TYPES=["Anime","Movie","TV Show"];var Jt=Xt;var Zt=class{constructor(e,t){this.widgetRegistry=new Map;this.widgetMiniRegistry=new Map;this.plugin=e,this.dataManager=t,this.plugin.registerMarkdownCodeBlockProcessor("watchlog",(s,i,a)=>this.process(s,i,a)),this.plugin.registerMarkdownCodeBlockProcessor("wl-todo",(s,i,a)=>this.processWlTodo(s,i)),this.plugin.registerMarkdownCodeBlockProcessor("wl-stat",(s,i,a)=>this.processWlStat(s,i)),this.plugin.registerMarkdownCodeBlockProcessor("wl-upcoming",(s,i,a)=>this.processWlUpcoming(s,i)),this.plugin.registerMarkdownCodeBlockProcessor("wl-nowwatching",(s,i,a)=>this.processWlNowWatching(s,i)),this.plugin.registerMarkdownCodeBlockProcessor("wl-now-next",(s,i,a)=>this.processWlNowNext(s,i)),this.dataChangedListener=()=>this.onDataChanged(),activeDocument.addEventListener("watchlog-data-changed",this.dataChangedListener),e.register(()=>{activeDocument.removeEventListener("watchlog-data-changed",this.dataChangedListener)})}onDataChanged(){var t;let e=[];for(let[s,i]of this.widgetRegistry){if(!s.isConnected){e.push(s);continue}let a=this.dataManager.getTitle(i);if(!a){e.push(s);continue}((t=this.widgetMiniRegistry.get(s))!=null?t:!1)?this.renderWidgetMinimal(s,a):this.renderWidget(s,a)}for(let s of e)this.widgetRegistry.delete(s),this.widgetMiniRegistry.delete(s)}process(e,t,s){var n;let i=e.match(/id:\s*(.+)/),a=(n=i==null?void 0:i[1])==null?void 0:n.trim();if(!a){t.createDiv({cls:"wl-widget-error",text:"WatchLog: missing id field."});return}let l=this.dataManager.getTitle(a);if(!l){t.createDiv({cls:"wl-widget-error",text:`WatchLog: title "${a}" not found.`});return}this.widgetRegistry.set(t,a),this.renderWidget(t,l)}renderWidget(e,t){this.renderWidgetFull(e,t)}renderWidgetFull(e,t){var m;e.empty(),e.addClass("wl-widget");let s=e.createDiv({cls:"wl-widget-main-row"}),i=s.createEl("input",{cls:"wl-widget-cb",attr:{type:"checkbox"}});i.title="Personal task marker (does not affect watchlog data)",i.addEventListener("change",()=>{let f=s.querySelector(".wl-widget-title");f&&(f.style.textDecoration=i.checked?"line-through":"",f.style.opacity=i.checked?"0.5":"")}),s.createSpan({cls:"wl-widget-title",text:t.title}),this.sep(s);let a=this.getTagDef(t.type,this.plugin.settings.types),l=this.plugin.settings.coloredTypeBadges,n=s.createSpan({cls:l?"wl-badge wl-badge-sm":"wl-badge-plain",text:t.type});l&&a&&(n.style.backgroundColor=_(t.type,a.color,this.plugin.settings.colorTheme)),this.sep(s);let r=this.getTagDef(t.status,this.plugin.settings.statuses),o=s.createSpan({cls:l?"wl-badge wl-badge-sm":"wl-badge-plain",text:t.status});l&&r&&(o.style.backgroundColor=_(t.status,r.color,this.plugin.settings.colorTheme)),this.sep(s);let c=this.dataManager.getProgress(t),d=s.createDiv({cls:"wl-widget-bar-wrap"});d.createDiv({cls:"wl-progress-bar"}).style.width=`${c}%`,this.sep(s),s.createSpan({cls:"wl-widget-percent",text:`${c}%`}),this.sep(s);let u=(m=t.dateStarted)!=null?m:t.dateAdded,g=t.dateStarted?`since ${this.formatShortDate(u)}`:`added ${this.formatShortDate(u)}`;if(s.createSpan({cls:"wl-widget-date",text:g}),t.type==="Movie"){let f=e.createDiv({cls:"wl-widget-next-row"}),v=t.watchedEpisodes.includes(1),w=f.createEl("input",{cls:"wl-widget-next-cb",attr:{type:"checkbox"}});w.checked=v,w.title="Mark movie as watched",w.addEventListener("change",()=>{this.dataManager.markEpisodeWatched(t.id,1,w.checked).then(()=>{let y=this.dataManager.getTitle(t.id);y&&this.renderWidget(e,y)})}),f.createSpan({cls:"wl-widget-next-label",text:v?"Watched":"Mark as watched"})}else{let f=this.dataManager.getNextUnwatchedEpisode(t),v=e.createDiv({cls:"wl-widget-next-row"});if(f!==null){let w=v.createEl("input",{cls:"wl-widget-next-cb",attr:{type:"checkbox"}});w.checked=!1,w.title=`Mark Episode ${f} as watched`,w.addEventListener("change",()=>{this.dataManager.markEpisodeWatched(t.id,f,!0).then(()=>{let y=this.dataManager.getTitle(t.id);y&&this.renderWidget(e,y)})}),v.createSpan({cls:"wl-widget-next-label",text:`Next up: Ep ${f}`})}else v.createSpan({cls:"wl-widget-next-label",text:"\u2713 All episodes watched"})}}renderWidgetMinimal(e,t){e.empty(),e.addClass("wl-widget-minimal");let s=e.createDiv({cls:"wl-widget-minimal-row"}),i=s.createEl("input",{cls:"wl-widget-cb",attr:{type:"checkbox"}});i.title="Personal task marker (does not affect watchlog data)",i.addEventListener("change",()=>{let u=s.querySelector(".wl-widget-title");u&&(u.style.textDecoration=i.checked?"line-through":"",u.style.opacity=i.checked?"0.5":"")}),s.createSpan({cls:"wl-widget-title",text:t.title}),this.sep(s);let a=this.getTagDef(t.type,this.plugin.settings.types),l=this.plugin.settings.coloredTypeBadges,n=s.createSpan({cls:l?"wl-badge wl-badge-sm":"wl-badge-plain",text:t.type});l&&a&&(n.style.backgroundColor=_(t.type,a.color,this.plugin.settings.colorTheme)),this.sep(s);let r=this.getTagDef(t.status,this.plugin.settings.statuses),o=s.createSpan({cls:l?"wl-badge wl-badge-sm":"wl-badge-plain",text:t.status});l&&r&&(o.style.backgroundColor=_(t.status,r.color,this.plugin.settings.colorTheme)),this.sep(s);let c=this.dataManager.getProgress(t);if(s.createSpan({cls:"wl-widget-percent",text:`${c}%`}),this.sep(s),t.type==="Movie"){let u=t.watchedEpisodes.includes(1),g=s.createEl("input",{cls:"wl-widget-next-cb",attr:{type:"checkbox"}});g.checked=u,g.title="Mark movie as watched",g.addEventListener("change",()=>{this.dataManager.markEpisodeWatched(t.id,1,g.checked).then(()=>{let p=this.dataManager.getTitle(t.id);p&&this.renderWidgetMinimal(e,p)})})}else{let u=this.dataManager.getNextUnwatchedEpisode(t),g=s.createEl("input",{cls:"wl-widget-next-cb",attr:{type:"checkbox"}});u!==null?(g.checked=!1,g.title=`Mark Episode ${u} as watched`,g.addEventListener("change",()=>{this.dataManager.markEpisodeWatched(t.id,u,!0).then(()=>{let p=this.dataManager.getTitle(t.id);p&&this.renderWidgetMinimal(e,p)})})):(g.checked=!0,g.disabled=!0,g.title="All episodes watched")}}sep(e){e.createSpan({cls:"wl-widget-sep",text:"\xB7"})}getTagDef(e,t){return t.find(s=>s.name===e)}formatShortDate(e){try{let t=new Date(e);return`${String(t.getDate()).padStart(2,"0")}/${String(t.getMonth()+1).padStart(2,"0")}/${String(t.getFullYear()).slice(2)}`}catch(t){return e}}processWlTodo(e,t){var n;let s=e.trim().split(` -`).map(r=>r.trim()).filter(Boolean),i=s.includes("mini"),a=(n=s.find(r=>r!=="mini"))!=null?n:"";if(!a){t.createDiv({cls:"wl-widget-error",text:"wl-todo: missing title name."});return}let l=this.dataManager.getTitles().find(r=>r.title.toLowerCase()===a.toLowerCase());if(!l){t.createDiv({cls:"wl-widget-error",text:`wl-todo: "${a}" not found.`});return}this.widgetRegistry.set(t,l.id),this.widgetMiniRegistry.set(t,i),i?this.renderWidgetMinimal(t,l):this.renderWidget(t,l)}processWlStat(e,t){let s=e.trim().toLowerCase();if(t.addClass("wl-wstat"),s==="watched"){let i=this.dataManager.getTotalTimeWatched();this.renderStatCard(t,"\u{1F550}",V(i),"watched")}else if(s==="completed"){let i=this.dataManager.getCompletedCount();this.renderStatCard(t,"\u2705",`${i} titles`,"completed")}else if(s==="remaining"){let i=this.dataManager.getTotalTimeRemaining();this.renderStatCard(t,"\u23F3",V(i),"remaining")}else if(s==="time"){let i=this.dataManager.getTotalTimeWatched(),a=this.dataManager.getTotalTimeRemaining();this.renderTimeCardMini(t,i,a)}else if(s==="time full"){let i=this.dataManager.getTotalTimeWatched(),a=this.dataManager.getTotalTimeRemaining();this.renderTimeCardFull(t,i,a)}else if(s==="completed full"){let i=this.dataManager.getCompletedCount();this.renderCompletedCardFull(t,i)}else if(s==="time completed full"){let i=this.dataManager.getTotalTimeWatched(),a=this.dataManager.getTotalTimeRemaining(),l=this.dataManager.getCompletedCount();this.renderTripleStatCard(t,i,a,l)}else t.createDiv({cls:"wl-widget-error",text:`wl-stat: unknown stat "${s}". Use watched, completed, remaining, time, time full, completed full, or time completed full.`})}renderStatCard(e,t,s,i){e.empty();let a=e.createDiv({cls:"wl-stat-card"});a.createSpan({cls:"wl-stat-icon",text:t}),a.createSpan({cls:"wl-stat-value",text:s}),a.createSpan({cls:"wl-stat-label",text:i})}renderTimeCardMini(e,t,s){e.empty();let i=e.createDiv({cls:"wl-stat-card wl-stat-card-time"});i.createSpan({cls:"wl-stat-icon",text:"\u{1F550}"}),i.createSpan({cls:"wl-stat-value",text:V(t)}),i.createSpan({cls:"wl-stat-label",text:"watched"}),i.createSpan({cls:"wl-stat-sep",text:"\xB7"}),i.createSpan({cls:"wl-stat-icon",text:"\u23F3"}),i.createSpan({cls:"wl-stat-value",text:V(s)}),i.createSpan({cls:"wl-stat-label",text:"left"})}renderTimeCardFull(e,t,s){e.empty();let i=e.createDiv({cls:"wl-full-card wl-full-card-time"});i.createDiv({cls:"wl-full-card-header",text:"Time"});let a=i.createDiv({cls:"wl-full-card-time-cols"}),l=(n,r)=>{let o=a.createDiv({cls:"wl-full-card-time-col"});o.createDiv({cls:"wl-full-card-value",text:V(n)}),o.createDiv({cls:"wl-full-card-days",text:this.formatTimeDays(n)}),o.createDiv({cls:"wl-full-card-sub",text:r})};l(t,"watched"),l(s,"remaining")}formatTimeDays(e){let t=Math.floor(e/1440),s=Math.floor(e%1440/60);return t===0?s>0?`${s}h`:"0h":`${t} days ${s}h`}renderCompletedCardFull(e,t){e.empty();let s=e.createDiv({cls:"wl-full-card"});s.createDiv({cls:"wl-full-card-header",text:"Completed"}),s.createDiv({cls:"wl-full-card-value",text:String(t)}),s.createDiv({cls:"wl-full-card-sub",text:"titles completed"})}renderTripleStatCard(e,t,s,i){e.empty(),e.addClass("wl-wstat-triple");let l=e.createDiv({cls:"wl-full-card wl-full-card-triple"}).createDiv({cls:"wl-triple-cols"}),n=(o,c)=>{let d=l.createDiv({cls:"wl-triple-col"});d.createDiv({cls:"wl-full-card-value",text:V(o)}),d.createDiv({cls:"wl-full-card-days",text:this.formatTimeDays(o)}),d.createDiv({cls:"wl-full-card-sub",text:c})};n(t,"watched"),l.createDiv({cls:"wl-vert-sep"}),n(s,"remaining"),l.createDiv({cls:"wl-vert-sep"});let r=l.createDiv({cls:"wl-triple-col"});r.createDiv({cls:"wl-full-card-value",text:String(i)}),r.createDiv({cls:"wl-full-card-sub",text:"completed"})}processWlUpcoming(e,t){let s=e.trim().toLowerCase(),i=s==="next full";if(s!=="next"&&s!=="next full"){t.createDiv({cls:"wl-widget-error",text:'wl-upcoming: only "next" or "next full" is supported.'});return}let a=this.dataManager.getAirtimeEntries(),l=this.dataManager.getTitles(),n=Date.now(),r=null,o=1/0;for(let v of a){let w=l.find(T=>T.id===v.titleId);if(!w||v.schedule.recurrence!=="once"||!v.schedule.releaseDate)continue;let y=new Date(v.schedule.releaseDate+"T12:00:00").getTime();if(y>=n&&y<o){o=y;let T=Math.round((y-n)/864e5);r={titleName:w.title,type:w.type,releaseDate:v.schedule.releaseDate,daysUntil:T}}}if(t.addClass("wl-wupcoming"),i){this.renderUpcomingFull(t,r);return}if(!r){t.createDiv({cls:"wl-upcoming-card wl-upcoming-empty",text:"No upcoming titles."});return}let c=t.createDiv({cls:"wl-upcoming-card"});c.createSpan({cls:"wl-upcoming-title",text:r.titleName});let d=this.getTagDef(r.type,this.plugin.settings.types),u=this.plugin.settings.coloredTypeBadges,g=c.createSpan({cls:u?"wl-badge wl-badge-sm":"wl-badge-plain",text:r.type});u&&d&&(g.style.backgroundColor=_(r.type,d.color,this.plugin.settings.colorTheme)),c.createSpan({cls:"wl-upcoming-date",text:r.releaseDate});let p=r.daysUntil,m=p>=30?` (${Math.round(p/30)} month${Math.round(p/30)!==1?"s":""})`:"",f=p===0?"today":p===1?"tomorrow":`in ${p} days${m}`;c.createSpan({cls:"wl-upcoming-countdown",text:f})}renderUpcomingFull(e,t){e.empty();let s=e.createDiv({cls:"wl-full-card"});if(s.createDiv({cls:"wl-full-card-header",text:"Up Next"}),!t){s.createDiv({cls:"wl-full-card-sub",text:"No upcoming titles."});return}s.createDiv({cls:"wl-full-card-title",text:t.titleName});let i=s.createDiv({cls:"wl-full-card-meta"}),a=this.getTagDef(t.type,this.plugin.settings.types),l=this.plugin.settings.coloredTypeBadges,n=i.createSpan({cls:l?"wl-badge wl-badge-sm":"wl-badge-plain",text:t.type});l&&a&&(n.style.backgroundColor=_(t.type,a.color,this.plugin.settings.colorTheme));let r=t.daysUntil,o=r===0?"today":r===1?"tomorrow":`in ${r} day${r!==1?"s":""}`;i.createSpan({text:o}),i.createSpan({cls:"wl-full-card-sub",text:t.releaseDate})}processWlNowWatching(e,t){let s=e.trim().toLowerCase()==="full";t.addClass("wl-wnowwatching");let i=this.dataManager.getTitles().find(m=>m.pinned),a=this.dataManager.getPinnedGroupId(),l=a?this.dataManager.getGroups().find(m=>m.id===a):null;if(s){this.renderNowWatchingFull(t,i!=null?i:null,l!=null?l:null);return}if(!i&&!l){t.createDiv({cls:"wl-nowwatching-card wl-nowwatching-empty",text:"Pin a title to set it as now watching."});return}let n=t.createDiv({cls:"wl-nowwatching-card"});if(l){n.createSpan({cls:"wl-nowwatching-title",text:l.name});let m=this.dataManager.getTitles(),f=l.titleIds.map(D=>m.find(S=>S.id===D)).filter(D=>D!==void 0),v=f.reduce((D,S)=>D+S.watchedEpisodes.length,0),w=f.reduce((D,S)=>D+S.totalEpisodes,0),y=w>0?Math.round(v/w*100):0;n.createSpan({cls:"wl-nowwatching-ep",text:`${f.length} title${f.length!==1?"s":""}`});let T=n.createDiv({cls:"wl-nowwatching-bar-wrap"});T.createDiv({cls:"wl-nowwatching-bar"}).style.width=`${y}%`,n.createSpan({cls:"wl-nowwatching-pct",text:`${y}%`});return}let r=i;n.createSpan({cls:"wl-nowwatching-title",text:r.title});let o=this.getTagDef(r.type,this.plugin.settings.types),c=this.plugin.settings.coloredTypeBadges,d=n.createSpan({cls:c?"wl-badge wl-badge-sm":"wl-badge-plain",text:r.type});if(c&&o&&(d.style.backgroundColor=_(r.type,o.color,this.plugin.settings.colorTheme)),r.type!=="Movie"){let m=this.dataManager.getNextUnwatchedEpisode(r);n.createSpan({cls:"wl-nowwatching-ep",text:m!==null?`Ep ${m}`:"\u2713 Completed"})}let u=this.dataManager.getProgress(r),p=n.createDiv({cls:"wl-nowwatching-bar-wrap"}).createDiv({cls:"wl-nowwatching-bar"});p.style.width=`${u}%`,n.createSpan({cls:"wl-nowwatching-pct",text:`${u}%`})}renderNowWatchingFull(e,t,s){e.empty();let i=e.createDiv({cls:"wl-full-card wl-full-card-nw"});if(i.createDiv({cls:"wl-full-card-header",text:"NOW WATCHING"}),!t&&!s){i.createDiv({cls:"wl-full-card-sub",text:"Pin a title to set it as now watching."});return}if(s){i.createDiv({cls:"wl-full-card-title",text:s.name});let u=this.dataManager.getTitles(),g=s.titleIds.map(y=>u.find(T=>T.id===y)).filter(y=>y!==void 0),p=g.reduce((y,T)=>y+T.watchedEpisodes.length,0),m=g.reduce((y,T)=>y+T.totalEpisodes,0),f=m>0?Math.round(p/m*100):0,v=i.createDiv({cls:"wl-nw-bottom-row"});v.createSpan({cls:"wl-full-card-sub",text:`${g.length} title${g.length!==1?"s":""}`});let w=v.createDiv({cls:"wl-nw-bar-col"});w.createDiv({cls:"wl-nw-pct",text:`${f}%`}),w.createDiv({cls:"wl-full-card-bar-wrap"}).createDiv({cls:"wl-full-card-bar"}).style.width=`${f}%`;return}let a=t;i.createDiv({cls:"wl-full-card-title",text:a.title});let l=this.dataManager.getProgress(a),n=i.createDiv({cls:"wl-nw-bottom-row"}),r=this.getTagDef(a.type,this.plugin.settings.types),o=this.plugin.settings.coloredTypeBadges,c=n.createSpan({cls:o?"wl-badge wl-badge-sm":"wl-badge-plain",text:a.type});o&&r&&(c.style.backgroundColor=_(a.type,r.color,this.plugin.settings.colorTheme));let d=n.createDiv({cls:"wl-nw-bar-col"});d.createDiv({cls:"wl-nw-pct",text:`${l}%`}),d.createDiv({cls:"wl-full-card-bar-wrap"}).createDiv({cls:"wl-full-card-bar"}).style.width=`${l}%`}processWlNowNext(e,t){t.addClass("wl-wnownext");let s=this.dataManager.getTitles().find(d=>d.pinned),i=this.dataManager.getPinnedGroupId(),a=i?this.dataManager.getGroups().find(d=>d.id===i):null,l=this.dataManager.getAirtimeEntries(),n=this.dataManager.getTitles(),r=Date.now(),o=null,c=1/0;for(let d of l){let u=n.find(p=>p.id===d.titleId);if(!u||d.schedule.recurrence!=="once"||!d.schedule.releaseDate)continue;let g=new Date(d.schedule.releaseDate+"T12:00:00").getTime();if(g>=r&&g<c){c=g;let p=Math.round((g-r)/864e5);o={titleName:u.title,type:u.type,releaseDate:d.schedule.releaseDate,daysUntil:p}}}this.renderNowNextCard(t,s!=null?s:null,a!=null?a:null,o)}renderNowNextCard(e,t,s,i){e.empty();let l=e.createDiv({cls:"wl-full-card wl-full-card-now-next"}).createDiv({cls:"wl-double-cols"}),n=l.createDiv({cls:"wl-double-col"});if(n.createDiv({cls:"wl-full-card-header",text:"NOW WATCHING"}),!t&&!s)n.createDiv({cls:"wl-full-card-sub",text:"Nothing pinned."});else if(s){n.createDiv({cls:"wl-full-card-title",text:s.name});let o=this.dataManager.getTitles(),c=s.titleIds.map(p=>o.find(m=>m.id===p)).filter(p=>p!==void 0),d=c.reduce((p,m)=>p+m.watchedEpisodes.length,0),u=c.reduce((p,m)=>p+m.totalEpisodes,0),g=u>0?Math.round(d/u*100):0;n.createDiv({cls:"wl-full-card-sub",text:`${c.length} title${c.length!==1?"s":""} \xB7 ${g}%`})}else{let o=t;n.createDiv({cls:"wl-full-card-title",text:o.title});let c=this.dataManager.getProgress(o);n.createDiv({cls:"wl-full-card-sub",text:`${c}%`})}l.createDiv({cls:"wl-vert-sep"});let r=l.createDiv({cls:"wl-double-col"});if(r.createDiv({cls:"wl-full-card-header",text:"UPCOMING NEXT"}),!i)r.createDiv({cls:"wl-full-card-sub",text:"No upcoming."});else{r.createDiv({cls:"wl-full-card-title",text:i.titleName});let o=i.daysUntil,c=o===0?"today":o===1?"tomorrow":`in ${o}d`;r.createDiv({cls:"wl-full-card-sub",text:`${i.releaseDate} \xB7 ${c}`})}}};var te=class extends rt.Plugin{constructor(){super(...arguments);this.settings=it;this.dataManager=new Et(this);this.apiService=new Dt("","");this.historyManager=new bt(this);this.importProgress=null;this.notifiedEntries=new Set}async onload(){await this.loadSettings(),this.dataManager=new Et(this),await this.dataManager.load(),this.dataManager.startWatchingExternalChanges(),this.apiService=new Dt(this.settings.omdbApiKey,this.settings.tmdbApiKey),this.historyManager=new bt(this),await this.historyManager.load(),this.dataManager.setHistoryManager(this.historyManager),await this.dataManager.ensureFolders(),this.startAirtimeScheduler(),this.registerView(At,t=>new yt(t,this,this.dataManager)),new Zt(this,this.dataManager),this.addRibbonIcon("tv","Watchlog",()=>{this.activateView()}),this.addCommand({id:"open-panel",name:"Open panel",callback:()=>void this.activateView()}),this.addCommand({id:"add-title",name:"Add title",callback:()=>{new tt(this.app,this,this.dataManager,()=>{this.activateView()}).open()}}),this.addCommand({id:"insert-widget",name:"Insert widget",editorCallback:(t,s)=>{this.openWidgetPalette(t)}}),this.addCommand({id:"search-title",name:"Search title",callback:()=>{let t=this.dataManager.getTitles();new dt(this.app,t,s=>{this.activateView().then(()=>{new rt.Notice(`"${s.title}" \u2014 ${s.type} \xB7 ${s.status}`)})}).open()}}),this.addSettingTab(new Jt(this.app,this))}onunload(){}startAirtimeScheduler(){this.checkAirtimeNotifications(),this.registerInterval(window.setInterval(()=>{this.checkAirtimeNotifications()},6e4))}markAirtimeHandled(t){this.notifiedEntries.add(t)}checkAirtimeNotifications(){var l,n;let t=new Date,s=`${String(t.getHours()).padStart(2,"0")}:${String(t.getMinutes()).padStart(2,"0")}`,i=`${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,"0")}-${String(t.getDate()).padStart(2,"0")}`,a=this.dataManager.getAirtimeEntries();for(let r of a){if(!r.schedule.releaseTime||r.schedule.releaseTime!==s||!this.isScheduledForToday(r.schedule))continue;let o=`${r.id}-${i}`;if(this.notifiedEntries.has(o))continue;this.notifiedEntries.add(o);let c=this.dataManager.getTitle(r.titleId);if(c&&(new rt.Notice(c.title,8e3),((l=c.totalEpisodes)!=null?l:0)>1&&r.currentEpisode!==void 0)){let d=(n=r.totalEpisodes)!=null?n:c.totalEpisodes,u=r.currentEpisode+1;u<=d&&(r.currentEpisode=u,this.dataManager.updateAirtimeEntry(r))}}}isScheduledForToday(t){var a;let s=new Date,i=new Date(s.getFullYear(),s.getMonth(),s.getDate());if(t.recurrence==="daily")return!0;if(t.recurrence==="weekly")return t.dayOfWeek===s.getDay();if(t.recurrence==="monthly")return s.getDate()===((a=t.dayOfMonth)!=null?a:-1);if(t.recurrence==="once"&&t.releaseDate){let l=new Date(t.releaseDate+"T00:00:00");return new Date(l.getFullYear(),l.getMonth(),l.getDate()).getTime()===i.getTime()}return!1}async loadSettings(){var i,a,l,n,r,o,c;let t=await this.loadData();this.settings=Object.assign({},it,(i=t==null?void 0:t.settings)!=null?i:{}),(a=t==null?void 0:t.settings)!=null&&a.listFilters&&(this.settings.listFilters=Object.assign({},it.listFilters,t.settings.listFilters)),this.settings.defaultView==="list"&&(this.settings.defaultView="watchlist"),(l=this.settings.types)!=null&&l.length||(this.settings.types=it.types),(n=this.settings.statuses)!=null&&n.length||(this.settings.statuses=it.statuses),(r=this.settings.reviews)!=null&&r.length||(this.settings.reviews=it.reviews),(o=this.settings.priorities)!=null&&o.length||(this.settings.priorities=it.priorities),(c=this.settings.seasonPalette)!=null&&c.length||(this.settings.seasonPalette=it.seasonPalette);let s=this.settings.statuses.find(d=>d.name==="Plan to watch");if(s&&s.color==="#888780"&&(s.color="#00A9A5"),!this.settings.statuses.find(d=>d.name==="To be released")){let d=this.settings.statuses.findIndex(g=>g.name==="Completed"),u=d>=0?d+1:this.settings.statuses.length;this.settings.statuses.splice(u,0,{name:"To be released",color:"#E8873A"})}}async saveSettings(){var s;let t=(s=await this.loadData())!=null?s:{};await this.saveData({...t,settings:this.settings})}openWidgetPalette(t){let s=[{name:"wl-todo \u2014 Track a title (full)",id:"wl-todo"},{name:"wl-todo mini \u2014 Track a title (compact)",id:"wl-todo:mini"},{name:"wl-stat: watched \u2014 Time watched (mini)",id:"wl-stat:watched"},{name:"wl-stat: completed \u2014 Completed titles (mini)",id:"wl-stat:completed"},{name:"wl-stat: remaining \u2014 Time remaining (mini)",id:"wl-stat:remaining"},{name:"wl-stat: time \u2014 Watched + remaining (mini)",id:"wl-stat:time"},{name:"wl-upcoming: next \u2014 Next upcoming (mini)",id:"wl-upcoming:next"},{name:"wl-stat: time completed full \u2014 Time \xB7 Remaining \xB7 Completed card",id:"wl-stat:time completed full"},{name:"wl-now-next \u2014 Now Watching + Up Next card",id:"wl-now-next"}];new ke(this.app,s,i=>{if(i.id==="wl-todo"||i.id==="wl-todo:mini"){let a=this.dataManager.getTitles();if(a.length===0){new rt.Notice("No titles in your watchlog library yet.");return}let l=i.id==="wl-todo:mini";new dt(this.app,a,n=>{l?t.replaceSelection(`\`\`\`wl-todo -${n.title} +Make sure the correct type is selected before searching. If Anime is selected and you search for a movie or TV show (or vice versa), it most likely won't be found, because each type queries its own API. APIs are a convenience, not a requirement \u2014 you can always add a title manually. The "Add from URL" button also relies on an API.`}]},{title:"\u{1F4DA} Reading",items:[{heading:"Books vs Manga",body:"Reading has two independent sub-tabs: Books and Manga. Each keeps its own entries, custom columns, filters, sort order, and saved filter. Set which one opens by default under Settings \u2192 Reading."},{heading:"Statuses",body:'Reading entries use five statuses: Reading, Completed, Plan to Read, To be released, and Dropped. "To be released" is set automatically when an entry has a release date in the future and cannot be chosen manually \u2014 once that date passes, the entry reverts to Plan to Read. Future-dated entries also appear in the shared Upcoming tab.'},{heading:"Progress tracking",body:'Books track pages read out of total pages. Manga track chapters read out of total chapters, plus volumes read out of total volumes. The card progress bar fills based on pages (books) or chapters (manga); entries with no total show "No progress" instead of a percentage.'},{heading:"Notes & favorite quotes",body:"Each entry generates a Markdown note file (under your Reading folder) with frontmatter plus Notes and Quotes sections. Favorite quotes you add from an entry's detail view are saved into that note as quote callouts, so they live alongside your own notes."},{heading:"Covers",body:`Covers are fetched automatically as cards scroll into view \u2014 Google Books for books, Jikan for manga. If a cover is missing or wrong, use "Refresh cover" from a card's \u22EE menu. (Refreshing a book cover needs the Google Books API key.)`},{heading:"Custom columns",body:"Use the \u2699 button in the Reading toolbar to add custom columns (text, number, or select). Columns are per sub-tab, and you can filter and sort by them just like the built-in fields."}]},{title:"\u{1F4C5} 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(let i of e){let a=t.createDiv({cls:"wl-qi-section"}),n=a.createDiv({cls:"wl-qi-section-header"}),r=n.createSpan({cls:"wl-qi-chevron",text:"\u25B6"});n.createSpan({cls:"wl-qi-section-label",text:i.title});let o=a.createDiv({cls:"wl-qi-section-body wl-qi-section-body-hidden"});for(let l of i.items){let c=o.createDiv({cls:"wl-qi-banner"});c.createDiv({cls:"wl-qi-banner-heading",text:l.heading}),c.createDiv({cls:"wl-qi-banner-body",text:l.body})}n.addEventListener("click",()=>{let l=!o.hasClass("wl-qi-section-body-hidden");l?o.addClass("wl-qi-section-body-hidden"):o.removeClass("wl-qi-section-body-hidden"),r.textContent=l?"\u25B6":"\u25BC"})}}renderWidgets(t){t.createDiv({cls:"wl-settings-info",text:'Insert widgets into notes using the "WatchLog: Insert widget" command. Configure the keyboard shortcut in Obsidian Settings \u2192 Hotkeys.'});let e=[{name:"wl-todo",desc:'Track a specific title inline \u2014 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 \u2014 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 \xB7 Time Remaining \xB7 Completed.",syntax:"```wl-stat\ntime completed full\n```"},{name:"wl-now-next",desc:"Full-width double card: Now Watching \xB7 Up Next.",syntax:"```wl-now-next\n```"}];for(let i of e){let a=t.createDiv({cls:"wl-widget-info-section"});a.createDiv({cls:"wl-widget-info-name",text:i.name}),a.createDiv({cls:"wl-widget-info-desc",text:i.desc});let n=a.createDiv({cls:"wl-widget-info-code-row"});n.createEl("code",{cls:"wl-widget-info-code",text:i.syntax});let r=n.createEl("button",{cls:"wl-btn wl-btn-sm",text:"Copy"});r.addEventListener("click",()=>{navigator.clipboard.writeText(i.syntax).then(()=>{r.textContent="Copied!",window.setTimeout(()=>{r.textContent="Copy"},1500)})})}}};Ke.LOCKED_TYPES=["Anime","Movie","TV Show"];var Ge=Ke;var Ai=require("obsidian"),ue=class extends Ai.FuzzySuggestModal{constructor(s,t,e){super(s),this.titles=t,this.onSelect=e,this.setPlaceholder("Search for a title to insert...")}getItems(){return this.titles}getItemText(s){return`${s.title} (${s.type})`}onChooseItem(s){this.onSelect(s)}};var ze=class{constructor(s,t){this.widgetRegistry=new Map;this.widgetMiniRegistry=new Map;this.widgetRerender=new Map;this.plugin=s,this.dataManager=t,this.plugin.registerMarkdownCodeBlockProcessor("watchlog",(e,i,a)=>this.process(e,i,a)),this.plugin.registerMarkdownCodeBlockProcessor("wl-todo",(e,i,a)=>this.processWlTodo(e,i)),this.plugin.registerMarkdownCodeBlockProcessor("wl-stat",(e,i,a)=>this.processWlStat(e,i)),this.plugin.registerMarkdownCodeBlockProcessor("wl-upcoming",(e,i,a)=>this.processWlUpcoming(e,i)),this.plugin.registerMarkdownCodeBlockProcessor("wl-nowwatching",(e,i,a)=>this.processWlNowWatching(e,i)),this.plugin.registerMarkdownCodeBlockProcessor("wl-now-next",(e,i,a)=>this.processWlNowNext(e,i)),this.dataChangedListener=()=>this.onDataChanged(),activeDocument.addEventListener("watchlog-data-changed",this.dataChangedListener),s.register(()=>{activeDocument.removeEventListener("watchlog-data-changed",this.dataChangedListener)})}onDataChanged(){var e;let s=[];for(let[i,a]of this.widgetRegistry){if(!i.isConnected){s.push(i);continue}let n=this.dataManager.getTitle(a);if(!n){s.push(i);continue}((e=this.widgetMiniRegistry.get(i))!=null?e:!1)?this.renderWidgetMinimal(i,n):this.renderWidget(i,n)}for(let i of s)this.widgetRegistry.delete(i),this.widgetMiniRegistry.delete(i);let t=[];for(let[i,a]of this.widgetRerender){if(!i.isConnected){t.push(i);continue}try{a()}catch(n){console.warn("[WL] widget rerender failed:",n)}}for(let i of t)this.widgetRerender.delete(i)}cleanup(){this.widgetRegistry.clear(),this.widgetMiniRegistry.clear(),this.widgetRerender.clear()}process(s,t,e){var r;let i=s.match(/id:\s*(.+)/),a=(r=i==null?void 0:i[1])==null?void 0:r.trim();if(!a){t.createDiv({cls:"wl-widget-error",text:"WatchLog: missing id field."});return}let n=this.dataManager.getTitle(a);if(!n){t.createDiv({cls:"wl-widget-error",text:`WatchLog: title "${a}" not found.`});return}this.widgetRegistry.set(t,a),this.renderWidget(t,n)}renderWidget(s,t){this.renderWidgetFull(s,t)}renderWidgetFull(s,t){var m;s.empty(),s.addClass("wl-widget");let e=s.createDiv({cls:"wl-widget-main-row"}),i=e.createEl("input",{cls:"wl-widget-cb",attr:{type:"checkbox"}});i.title="Personal task marker (does not affect watchlog data)",i.addEventListener("change",()=>{let v=e.querySelector(".wl-widget-title");v&&(v.style.textDecoration=i.checked?"line-through":"",v.style.opacity=i.checked?"0.5":"")}),e.createSpan({cls:"wl-widget-title",text:t.title}),this.sep(e);let a=this.getTagDef(t.type,this.plugin.settings.types),n=this.plugin.settings.coloredTypeBadges,r=e.createSpan({cls:n?"wl-badge wl-badge-sm":"wl-badge-plain",text:t.type});n&&a&&(r.style.backgroundColor=N(t.type,a.color,this.plugin.settings.colorTheme)),this.sep(e);let o=this.getTagDef(t.status,this.plugin.settings.statuses),l=e.createSpan({cls:n?"wl-badge wl-badge-sm":"wl-badge-plain",text:t.status});n&&o&&(l.style.backgroundColor=N(t.status,o.color,this.plugin.settings.colorTheme)),this.sep(e);let c=this.dataManager.getProgress(t),d=e.createDiv({cls:"wl-widget-bar-wrap"});d.createDiv({cls:"wl-progress-bar"}).style.width=`${c}%`,this.sep(e),e.createSpan({cls:"wl-widget-percent",text:`${c}%`}),this.sep(e);let u=(m=t.dateStarted)!=null?m:t.dateAdded,h=t.dateStarted?`since ${this.formatShortDate(u)}`:`added ${this.formatShortDate(u)}`;if(e.createSpan({cls:"wl-widget-date",text:h}),t.type==="Movie"){let v=s.createDiv({cls:"wl-widget-next-row"}),f=t.watchedEpisodes.includes(1),y=v.createEl("input",{cls:"wl-widget-next-cb",attr:{type:"checkbox"}});y.checked=f,y.title="Mark movie as watched",y.addEventListener("change",()=>{this.dataManager.markEpisodeWatched(t.id,1,y.checked).then(()=>{let w=this.dataManager.getTitle(t.id);w&&this.renderWidget(s,w)})}),v.createSpan({cls:"wl-widget-next-label",text:f?"Watched":"Mark as watched"})}else{let v=this.dataManager.getNextUnwatchedEpisode(t),f=s.createDiv({cls:"wl-widget-next-row"});if(v!==null){let y=f.createEl("input",{cls:"wl-widget-next-cb",attr:{type:"checkbox"}});y.checked=!1,y.title=`Mark Episode ${v} as watched`,y.addEventListener("change",()=>{this.dataManager.markEpisodeWatched(t.id,v,!0).then(()=>{let w=this.dataManager.getTitle(t.id);w&&this.renderWidget(s,w)})}),f.createSpan({cls:"wl-widget-next-label",text:`Next up: Ep ${v}`})}else f.createSpan({cls:"wl-widget-next-label",text:"\u2713 All episodes watched"})}}renderWidgetMinimal(s,t){s.empty(),s.addClass("wl-widget-minimal");let e=s.createDiv({cls:"wl-widget-minimal-row"}),i=e.createEl("input",{cls:"wl-widget-cb",attr:{type:"checkbox"}});i.title="Personal task marker (does not affect watchlog data)",i.addEventListener("change",()=>{let u=e.querySelector(".wl-widget-title");u&&(u.style.textDecoration=i.checked?"line-through":"",u.style.opacity=i.checked?"0.5":"")}),e.createSpan({cls:"wl-widget-title",text:t.title}),this.sep(e);let a=this.getTagDef(t.type,this.plugin.settings.types),n=this.plugin.settings.coloredTypeBadges,r=e.createSpan({cls:n?"wl-badge wl-badge-sm":"wl-badge-plain",text:t.type});n&&a&&(r.style.backgroundColor=N(t.type,a.color,this.plugin.settings.colorTheme)),this.sep(e);let o=this.getTagDef(t.status,this.plugin.settings.statuses),l=e.createSpan({cls:n?"wl-badge wl-badge-sm":"wl-badge-plain",text:t.status});n&&o&&(l.style.backgroundColor=N(t.status,o.color,this.plugin.settings.colorTheme)),this.sep(e);let c=this.dataManager.getProgress(t);if(e.createSpan({cls:"wl-widget-percent",text:`${c}%`}),this.sep(e),t.type==="Movie"){let u=t.watchedEpisodes.includes(1),h=e.createEl("input",{cls:"wl-widget-next-cb",attr:{type:"checkbox"}});h.checked=u,h.title="Mark movie as watched",h.addEventListener("change",()=>{this.dataManager.markEpisodeWatched(t.id,1,h.checked).then(()=>{let p=this.dataManager.getTitle(t.id);p&&this.renderWidgetMinimal(s,p)})})}else{let u=this.dataManager.getNextUnwatchedEpisode(t),h=e.createEl("input",{cls:"wl-widget-next-cb",attr:{type:"checkbox"}});u!==null?(h.checked=!1,h.title=`Mark Episode ${u} as watched`,h.addEventListener("change",()=>{this.dataManager.markEpisodeWatched(t.id,u,!0).then(()=>{let p=this.dataManager.getTitle(t.id);p&&this.renderWidgetMinimal(s,p)})})):(h.checked=!0,h.disabled=!0,h.title="All episodes watched")}}sep(s){s.createSpan({cls:"wl-widget-sep",text:"\xB7"})}getTagDef(s,t){return t.find(e=>e.name===s)}formatShortDate(s){try{let t=new Date(s);return`${String(t.getDate()).padStart(2,"0")}/${String(t.getMonth()+1).padStart(2,"0")}/${String(t.getFullYear()).slice(2)}`}catch(t){return s}}processWlTodo(s,t){var r;let e=s.trim().split(` +`).map(o=>o.trim()).filter(Boolean),i=e.includes("mini"),a=(r=e.find(o=>o!=="mini"))!=null?r:"";if(!a){t.createDiv({cls:"wl-widget-error",text:"wl-todo: missing title name."});return}let n=this.dataManager.getTitles().find(o=>o.title.toLowerCase()===a.toLowerCase());if(!n){t.createDiv({cls:"wl-widget-error",text:`wl-todo: "${a}" not found.`});return}this.widgetRegistry.set(t,n.id),this.widgetMiniRegistry.set(t,i),i?this.renderWidgetMinimal(t,n):this.renderWidget(t,n)}processWlStat(s,t){this.widgetRerender.set(t,()=>this.renderWlStat(s,t)),this.renderWlStat(s,t)}renderWlStat(s,t){let e=s.trim().toLowerCase();if(t.empty(),t.addClass("wl-wstat"),e==="watched"){let i=this.dataManager.getTotalTimeWatched();this.renderStatCard(t,"\u{1F550}",z(i),"watched")}else if(e==="completed"){let i=this.dataManager.getCompletedCount();this.renderStatCard(t,"\u2705",`${i} titles`,"completed")}else if(e==="remaining"){let i=this.dataManager.getTotalTimeRemaining();this.renderStatCard(t,"\u23F3",z(i),"remaining")}else if(e==="time"){let i=this.dataManager.getTotalTimeWatched(),a=this.dataManager.getTotalTimeRemaining();this.renderTimeCardMini(t,i,a)}else if(e==="time full"){let i=this.dataManager.getTotalTimeWatched(),a=this.dataManager.getTotalTimeRemaining();this.renderTimeCardFull(t,i,a)}else if(e==="completed full"){let i=this.dataManager.getCompletedCount();this.renderCompletedCardFull(t,i)}else if(e==="time completed full"){let i=this.dataManager.getTotalTimeWatched(),a=this.dataManager.getTotalTimeRemaining(),n=this.dataManager.getCompletedCount();this.renderTripleStatCard(t,i,a,n)}else t.createDiv({cls:"wl-widget-error",text:`wl-stat: unknown stat "${e}". Use watched, completed, remaining, time, time full, completed full, or time completed full.`})}renderStatCard(s,t,e,i){s.empty();let a=s.createDiv({cls:"wl-stat-card"});a.createSpan({cls:"wl-stat-icon",text:t}),a.createSpan({cls:"wl-stat-value",text:e}),a.createSpan({cls:"wl-stat-label",text:i})}renderTimeCardMini(s,t,e){s.empty();let i=s.createDiv({cls:"wl-stat-card wl-stat-card-time"});i.createSpan({cls:"wl-stat-icon",text:"\u{1F550}"}),i.createSpan({cls:"wl-stat-value",text:z(t)}),i.createSpan({cls:"wl-stat-label",text:"watched"}),i.createSpan({cls:"wl-stat-sep",text:"\xB7"}),i.createSpan({cls:"wl-stat-icon",text:"\u23F3"}),i.createSpan({cls:"wl-stat-value",text:z(e)}),i.createSpan({cls:"wl-stat-label",text:"left"})}renderTimeCardFull(s,t,e){s.empty();let i=s.createDiv({cls:"wl-full-card wl-full-card-time"});i.createDiv({cls:"wl-full-card-header",text:"Time"});let a=i.createDiv({cls:"wl-full-card-time-cols"}),n=(r,o)=>{let l=a.createDiv({cls:"wl-full-card-time-col"});l.createDiv({cls:"wl-full-card-value",text:z(r)}),l.createDiv({cls:"wl-full-card-days",text:this.formatTimeDays(r)}),l.createDiv({cls:"wl-full-card-sub",text:o})};n(t,"watched"),n(e,"remaining")}formatTimeDays(s){let t=Math.floor(s/1440),e=Math.floor(s%1440/60);return t===0?e>0?`${e}h`:"0h":`${t} days ${e}h`}renderCompletedCardFull(s,t){s.empty();let e=s.createDiv({cls:"wl-full-card"});e.createDiv({cls:"wl-full-card-header",text:"Completed"}),e.createDiv({cls:"wl-full-card-value",text:String(t)}),e.createDiv({cls:"wl-full-card-sub",text:"titles completed"})}renderTripleStatCard(s,t,e,i){s.empty(),s.addClass("wl-wstat-triple");let n=s.createDiv({cls:"wl-full-card wl-full-card-triple"}).createDiv({cls:"wl-triple-cols"}),r=(l,c)=>{let d=n.createDiv({cls:"wl-triple-col"});d.createDiv({cls:"wl-full-card-value",text:z(l)}),d.createDiv({cls:"wl-full-card-days",text:this.formatTimeDays(l)}),d.createDiv({cls:"wl-full-card-sub",text:c})};r(t,"watched"),n.createDiv({cls:"wl-vert-sep"}),r(e,"remaining"),n.createDiv({cls:"wl-vert-sep"});let o=n.createDiv({cls:"wl-triple-col"});o.createDiv({cls:"wl-full-card-value",text:String(i)}),o.createDiv({cls:"wl-full-card-sub",text:"completed"})}processWlUpcoming(s,t){this.widgetRerender.set(t,()=>this.renderWlUpcoming(s,t)),this.renderWlUpcoming(s,t)}renderWlUpcoming(s,t){t.empty();let e=s.trim().toLowerCase(),i=e==="next full";if(e!=="next"&&e!=="next full"){t.createDiv({cls:"wl-widget-error",text:'wl-upcoming: only "next" or "next full" is supported.'});return}let a=this.dataManager.getAirtimeEntries(),n=this.dataManager.getTitles(),r=new Map(n.map(y=>[y.id,y])),o=Date.now(),l=null,c=1/0;for(let y of a){let w=r.get(y.titleId);if(!w||y.schedule.recurrence!=="once"||!y.schedule.releaseDate)continue;let b=new Date(y.schedule.releaseDate+"T12:00:00").getTime();if(b>=o&&b<c){c=b;let S=Math.round((b-o)/864e5);l={titleName:w.title,type:w.type,releaseDate:y.schedule.releaseDate,daysUntil:S}}}if(t.addClass("wl-wupcoming"),i){this.renderUpcomingFull(t,l);return}if(!l){t.createDiv({cls:"wl-upcoming-card wl-upcoming-empty",text:"No upcoming titles."});return}let d=t.createDiv({cls:"wl-upcoming-card"});d.createSpan({cls:"wl-upcoming-title",text:l.titleName});let u=this.getTagDef(l.type,this.plugin.settings.types),h=this.plugin.settings.coloredTypeBadges,p=d.createSpan({cls:h?"wl-badge wl-badge-sm":"wl-badge-plain",text:l.type});h&&u&&(p.style.backgroundColor=N(l.type,u.color,this.plugin.settings.colorTheme)),d.createSpan({cls:"wl-upcoming-date",text:l.releaseDate});let m=l.daysUntil,v=m>=30?` (${Math.round(m/30)} month${Math.round(m/30)!==1?"s":""})`:"",f=m===0?"today":m===1?"tomorrow":`in ${m} days${v}`;d.createSpan({cls:"wl-upcoming-countdown",text:f})}renderUpcomingFull(s,t){s.empty();let e=s.createDiv({cls:"wl-full-card"});if(e.createDiv({cls:"wl-full-card-header",text:"Up Next"}),!t){e.createDiv({cls:"wl-full-card-sub",text:"No upcoming titles."});return}e.createDiv({cls:"wl-full-card-title",text:t.titleName});let i=e.createDiv({cls:"wl-full-card-meta"}),a=this.getTagDef(t.type,this.plugin.settings.types),n=this.plugin.settings.coloredTypeBadges,r=i.createSpan({cls:n?"wl-badge wl-badge-sm":"wl-badge-plain",text:t.type});n&&a&&(r.style.backgroundColor=N(t.type,a.color,this.plugin.settings.colorTheme));let o=t.daysUntil,l=o===0?"today":o===1?"tomorrow":`in ${o} day${o!==1?"s":""}`;i.createSpan({text:l}),i.createSpan({cls:"wl-full-card-sub",text:t.releaseDate})}processWlNowWatching(s,t){this.widgetRerender.set(t,()=>this.renderWlNowWatching(s,t)),this.renderWlNowWatching(s,t)}renderWlNowWatching(s,t){t.empty();let e=s.trim().toLowerCase()==="full";t.addClass("wl-wnowwatching");let i=this.dataManager.getTitles().find(m=>m.pinned),a=this.dataManager.getPinnedGroupId(),n=a?this.dataManager.getGroups().find(m=>m.id===a):null;if(e){this.renderNowWatchingFull(t,i!=null?i:null,n!=null?n:null);return}if(!i&&!n){t.createDiv({cls:"wl-nowwatching-card wl-nowwatching-empty",text:"Pin a title to set it as now watching."});return}let r=t.createDiv({cls:"wl-nowwatching-card"});if(n){r.createSpan({cls:"wl-nowwatching-title",text:n.name});let m=this.dataManager.getTitles(),v=new Map(m.map(D=>[D.id,D])),f=n.titleIds.map(D=>v.get(D)).filter(D=>D!==void 0),y=f.reduce((D,E)=>D+E.watchedEpisodes.length,0),w=f.reduce((D,E)=>D+this.dataManager.getEffectiveTotal(E),0),b=w>0?Math.min(100,Math.round(y/w*100)):0;r.createSpan({cls:"wl-nowwatching-ep",text:`${f.length} title${f.length!==1?"s":""}`});let S=r.createDiv({cls:"wl-nowwatching-bar-wrap"});S.createDiv({cls:"wl-nowwatching-bar"}).style.width=`${b}%`,r.createSpan({cls:"wl-nowwatching-pct",text:`${b}%`});return}let o=i;r.createSpan({cls:"wl-nowwatching-title",text:o.title});let l=this.getTagDef(o.type,this.plugin.settings.types),c=this.plugin.settings.coloredTypeBadges,d=r.createSpan({cls:c?"wl-badge wl-badge-sm":"wl-badge-plain",text:o.type});if(c&&l&&(d.style.backgroundColor=N(o.type,l.color,this.plugin.settings.colorTheme)),o.type!=="Movie"){let m=this.dataManager.getNextUnwatchedEpisode(o);r.createSpan({cls:"wl-nowwatching-ep",text:m!==null?`Ep ${m}`:"\u2713 Completed"})}let u=this.dataManager.getProgress(o),p=r.createDiv({cls:"wl-nowwatching-bar-wrap"}).createDiv({cls:"wl-nowwatching-bar"});p.style.width=`${u}%`,r.createSpan({cls:"wl-nowwatching-pct",text:`${u}%`})}renderNowWatchingFull(s,t,e){s.empty();let i=s.createDiv({cls:"wl-full-card wl-full-card-nw"});if(i.createDiv({cls:"wl-full-card-header",text:"NOW WATCHING"}),!t&&!e){i.createDiv({cls:"wl-full-card-sub",text:"Pin a title to set it as now watching."});return}if(e){i.createDiv({cls:"wl-full-card-title",text:e.name});let u=this.dataManager.getTitles(),h=new Map(u.map(b=>[b.id,b])),p=e.titleIds.map(b=>h.get(b)).filter(b=>b!==void 0),m=p.reduce((b,S)=>b+S.watchedEpisodes.length,0),v=p.reduce((b,S)=>b+this.dataManager.getEffectiveTotal(S),0),f=v>0?Math.min(100,Math.round(m/v*100)):0,y=i.createDiv({cls:"wl-nw-bottom-row"});y.createSpan({cls:"wl-full-card-sub",text:`${p.length} title${p.length!==1?"s":""}`});let w=y.createDiv({cls:"wl-nw-bar-col"});w.createDiv({cls:"wl-nw-pct",text:`${f}%`}),w.createDiv({cls:"wl-full-card-bar-wrap"}).createDiv({cls:"wl-full-card-bar"}).style.width=`${f}%`;return}let a=t;i.createDiv({cls:"wl-full-card-title",text:a.title});let n=this.dataManager.getProgress(a),r=i.createDiv({cls:"wl-nw-bottom-row"}),o=this.getTagDef(a.type,this.plugin.settings.types),l=this.plugin.settings.coloredTypeBadges,c=r.createSpan({cls:l?"wl-badge wl-badge-sm":"wl-badge-plain",text:a.type});l&&o&&(c.style.backgroundColor=N(a.type,o.color,this.plugin.settings.colorTheme));let d=r.createDiv({cls:"wl-nw-bar-col"});d.createDiv({cls:"wl-nw-pct",text:`${n}%`}),d.createDiv({cls:"wl-full-card-bar-wrap"}).createDiv({cls:"wl-full-card-bar"}).style.width=`${n}%`}processWlNowNext(s,t){this.widgetRerender.set(t,()=>this.renderWlNowNext(s,t)),this.renderWlNowNext(s,t)}renderWlNowNext(s,t){t.empty(),t.addClass("wl-wnownext");let e=this.dataManager.getTitles().find(u=>u.pinned),i=this.dataManager.getPinnedGroupId(),a=i?this.dataManager.getGroups().find(u=>u.id===i):null,n=this.dataManager.getAirtimeEntries(),r=this.dataManager.getTitles(),o=new Map(r.map(u=>[u.id,u])),l=Date.now(),c=null,d=1/0;for(let u of n){let h=o.get(u.titleId);if(!h||u.schedule.recurrence!=="once"||!u.schedule.releaseDate)continue;let p=new Date(u.schedule.releaseDate+"T12:00:00").getTime();if(p>=l&&p<d){d=p;let m=Math.round((p-l)/864e5);c={titleName:h.title,type:h.type,releaseDate:u.schedule.releaseDate,daysUntil:m}}}this.renderNowNextCard(t,e!=null?e:null,a!=null?a:null,c)}renderNowNextCard(s,t,e,i){s.empty();let n=s.createDiv({cls:"wl-full-card wl-full-card-now-next"}).createDiv({cls:"wl-double-cols"}),r=n.createDiv({cls:"wl-double-col"});if(r.createDiv({cls:"wl-full-card-header",text:"NOW WATCHING"}),!t&&!e)r.createDiv({cls:"wl-full-card-sub",text:"Nothing pinned."});else if(e){r.createDiv({cls:"wl-full-card-title",text:e.name});let l=this.dataManager.getTitles(),c=new Map(l.map(m=>[m.id,m])),d=e.titleIds.map(m=>c.get(m)).filter(m=>m!==void 0),u=d.reduce((m,v)=>m+v.watchedEpisodes.length,0),h=d.reduce((m,v)=>m+this.dataManager.getEffectiveTotal(v),0),p=h>0?Math.min(100,Math.round(u/h*100)):0;r.createDiv({cls:"wl-full-card-sub",text:`${d.length} title${d.length!==1?"s":""} \xB7 ${p}%`})}else{let l=t;r.createDiv({cls:"wl-full-card-title",text:l.title});let c=this.dataManager.getProgress(l);r.createDiv({cls:"wl-full-card-sub",text:`${c}%`})}n.createDiv({cls:"wl-vert-sep"});let o=n.createDiv({cls:"wl-double-col"});if(o.createDiv({cls:"wl-full-card-header",text:"UPCOMING NEXT"}),!i)o.createDiv({cls:"wl-full-card-sub",text:"No upcoming."});else{o.createDiv({cls:"wl-full-card-title",text:i.titleName});let l=i.daysUntil,c=l===0?"today":l===1?"tomorrow":`in ${l}d`;o.createDiv({cls:"wl-full-card-sub",text:`${i.releaseDate} \xB7 ${c}`})}}};var qe=class extends ot.Plugin{constructor(){super(...arguments);this.settings=dt;this.dataManager=new zt(this);this.readingDataManager=new qt(this);this.apiService=new Qt("","");this.historyManager=new Kt(this);this.importProgress=null;this.notifiedEntries=new Set;this.statusBarEl=null}async onload(){await this.loadSettings(),this.dataManager=new zt(this),await this.dataManager.load(),this.dataManager.startWatchingExternalChanges(),this.readingDataManager=new qt(this),await this.readingDataManager.load(),this.apiService=new Qt(this.settings.omdbApiKey,this.settings.tmdbApiKey,this.settings.googleBooksApiKey),this.historyManager=new Kt(this),await this.historyManager.load(),this.dataManager.setHistoryManager(this.historyManager),this.readingDataManager.setHistoryManager(this.historyManager),this.posterService=new ve(this.dataManager,()=>this.settings),await this.dataManager.ensureFolders(),this.startAirtimeScheduler(),this.registerView(oe,e=>new kt(e,this,this.dataManager)),this.widgetRenderer=new ze(this,this.dataManager),this.addRibbonIcon("tv","Watchlog",()=>{this.activateView()}),this.addCommand({id:"open-panel",name:"Open panel",callback:()=>void this.activateView()}),this.addCommand({id:"add-title",name:"Add title",callback:()=>{new ht(this.app,this,this.dataManager,()=>{this.activateView()}).open()}}),this.addCommand({id:"insert-widget",name:"Insert widget",editorCallback:(e,i)=>{this.openWidgetPalette(e)}}),this.addCommand({id:"search-title",name:"Search title",callback:()=>{let e=this.dataManager.getTitles();new ue(this.app,e,i=>{this.activateView().then(()=>{new ot.Notice(`"${i.title}" \u2014 ${i.type} \xB7 ${i.status}`)})}).open()}}),this.addSettingTab(new Ge(this.app,this)),this.setupStatusBar();let t=()=>this.updateStatusBar();activeDocument.addEventListener("watchlog-data-changed",t),this.register(()=>activeDocument.removeEventListener("watchlog-data-changed",t))}onunload(){var t,e,i;this.dataManager.flushPendingSaveSync(),this.dataManager.flushPosterSaveSync(),this.dataManager.flushQueuedSaveSync(),(t=this.readingDataManager)==null||t.flushCoverSave(),(e=this.posterService)==null||e.destroy(),(i=this.widgetRenderer)==null||i.cleanup()}startAirtimeScheduler(){this.checkAirtimeNotifications(),this.registerInterval(window.setInterval(()=>{this.checkAirtimeNotifications()},6e4))}markAirtimeHandled(t){this.notifiedEntries.add(t)}checkAirtimeNotifications(){var n,r,o,l;let t=new Date,e=`${String(t.getHours()).padStart(2,"0")}:${String(t.getMinutes()).padStart(2,"0")}`,i=`${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,"0")}-${String(t.getDate()).padStart(2,"0")}`,a=this.dataManager.getAirtimeEntries();for(let c of a){if(!c.schedule.releaseTime||c.schedule.releaseTime!==e||!this.isScheduledForToday(c.schedule))continue;let d=`${c.id}-${i}`;if(this.notifiedEntries.has(d))continue;this.notifiedEntries.add(d);let u,h;if(c.source==="reading"){let m=((n=c.readingKind)!=null?n:"book")==="book"?this.readingDataManager.getBook(c.titleId):this.readingDataManager.getManga(c.titleId);if(!m)continue;u=m.title,h=(o=(r=c.totalEpisodes)!=null?r:m.totalChapters)!=null?o:0}else{let p=this.dataManager.getTitle(c.titleId);if(!p)continue;u=p.title,h=(l=c.totalEpisodes)!=null?l:p.totalEpisodes}if(new ot.Notice(u,8e3),h>1&&c.currentEpisode!==void 0){let p=c.currentEpisode+1;p<=h&&(c.currentEpisode=p,this.dataManager.updateAirtimeEntry(c))}}this.updateStatusBar()}setupStatusBar(){ot.Platform.isMobile||(this.statusBarEl=this.addStatusBarItem(),this.statusBarEl.addClass("wl-statusbar-upcoming"),this.statusBarEl.style.cursor="pointer",this.statusBarEl.setAttribute("aria-label","WatchLog \u2014 Upcoming due"),this.statusBarEl.addEventListener("click",()=>void this.activateView("upcoming")),this.updateStatusBar())}updateStatusBar(){let t=this.statusBarEl;if(!t)return;if(!this.settings.showUpcomingStatusBar){t.hide();return}let e=vt.getAiredDueCount(this.dataManager,this.readingDataManager)+vt.getMaybeDueCount(this.dataManager);if(e<=0){t.hide();return}t.empty(),t.show(),t.style.color="var(--color-orange)";let i=t.createSpan({cls:"wl-statusbar-icon"});(0,ot.setIcon)(i,"tv"),t.createSpan({cls:"wl-statusbar-text",text:`${e} due`})}isScheduledForToday(t){var a;let e=new Date,i=new Date(e.getFullYear(),e.getMonth(),e.getDate());if(t.recurrence==="daily")return!0;if(t.recurrence==="weekly")return t.dayOfWeek===e.getDay();if(t.recurrence==="monthly")return e.getDate()===((a=t.dayOfMonth)!=null?a:-1);if(t.recurrence==="once"&&t.releaseDate){let n=new Date(t.releaseDate+"T00:00:00");return new Date(n.getFullYear(),n.getMonth(),n.getDate()).getTime()===i.getTime()}return!1}async loadSettings(){var i,a,n,r,o,l,c;let t=await this.loadData();this.settings=Object.assign({},dt,(i=t==null?void 0:t.settings)!=null?i:{}),(a=t==null?void 0:t.settings)!=null&&a.listFilters&&(this.settings.listFilters=Object.assign({},dt.listFilters,t.settings.listFilters)),this.settings.animeApiSource===void 0&&(this.settings.animeApiSource="jikan"),this.settings.typeApiMapping===void 0&&(this.settings.typeApiMapping={}),this.settings.readingTypeColors?(this.settings.readingTypeColors.manga||(this.settings.readingTypeColors.manga="#D4537E"),this.settings.readingTypeColors.book||(this.settings.readingTypeColors.book="#D85A30")):this.settings.readingTypeColors={manga:"#D4537E",book:"#D85A30"},this.settings.defaultWatchlistView!=="list"&&this.settings.defaultWatchlistView!=="cards"&&(this.settings.defaultWatchlistView="cards"),this.settings.showUpcomingStatusBar===void 0&&(this.settings.showUpcomingStatusBar=!0),(n=this.settings.types)!=null&&n.length||(this.settings.types=dt.types),(r=this.settings.statuses)!=null&&r.length||(this.settings.statuses=dt.statuses),(o=this.settings.reviews)!=null&&o.length||(this.settings.reviews=dt.reviews),(l=this.settings.priorities)!=null&&l.length||(this.settings.priorities=dt.priorities),(c=this.settings.seasonPalette)!=null&&c.length||(this.settings.seasonPalette=dt.seasonPalette);let e=this.settings.statuses.find(d=>d.name==="Plan to watch");if(e&&e.color==="#888780"&&(e.color="#00A9A5"),!this.settings.statuses.find(d=>d.name==="To be released")){let d=this.settings.statuses.findIndex(h=>h.name==="Completed"),u=d>=0?d+1:this.settings.statuses.length;this.settings.statuses.splice(u,0,{name:"To be released",color:"#E8873A"})}}async saveSettings(){await this.dataManager.saveSettings(this.settings)}openWidgetPalette(t){let e=[{name:"wl-todo \u2014 Track a title (full)",id:"wl-todo"},{name:"wl-todo mini \u2014 Track a title (compact)",id:"wl-todo:mini"},{name:"wl-stat: watched \u2014 Time watched (mini)",id:"wl-stat:watched"},{name:"wl-stat: completed \u2014 Completed titles (mini)",id:"wl-stat:completed"},{name:"wl-stat: remaining \u2014 Time remaining (mini)",id:"wl-stat:remaining"},{name:"wl-stat: time \u2014 Watched + remaining (mini)",id:"wl-stat:time"},{name:"wl-upcoming: next \u2014 Next upcoming (mini)",id:"wl-upcoming:next"},{name:"wl-stat: time completed full \u2014 Time \xB7 Remaining \xB7 Completed card",id:"wl-stat:time completed full"},{name:"wl-now-next \u2014 Now Watching + Up Next card",id:"wl-now-next"}];new Fs(this.app,e,i=>{if(i.id==="wl-todo"||i.id==="wl-todo:mini"){let a=this.dataManager.getTitles();if(a.length===0){new ot.Notice("No titles in your watchlog library yet.");return}let n=i.id==="wl-todo:mini";new ue(this.app,a,r=>{n?t.replaceSelection(`\`\`\`wl-todo +${r.title} mini \`\`\``):t.replaceSelection(`\`\`\`wl-todo -${n.title} -\`\`\``)}).open()}else i.id==="wl-stat:watched"?t.replaceSelection("```wl-stat\nwatched\n```"):i.id==="wl-stat:completed"?t.replaceSelection("```wl-stat\ncompleted\n```"):i.id==="wl-stat:remaining"?t.replaceSelection("```wl-stat\nremaining\n```"):i.id==="wl-stat:time"?t.replaceSelection("```wl-stat\ntime\n```"):i.id==="wl-upcoming:next"?t.replaceSelection("```wl-upcoming\nnext\n```"):i.id==="wl-stat:time completed full"?t.replaceSelection("```wl-stat\ntime completed full\n```"):i.id==="wl-now-next"&&t.replaceSelection("```wl-now-next\n```")}).open()}async activateView(){let{workspace:t}=this.app,s=t.getLeavesOfType(At);if(s.length>0){t.revealLeaf(s[0]);return}let i=t.getLeaf("tab");await i.setViewState({type:At,active:!0}),t.revealLeaf(i)}},ke=class extends rt.FuzzySuggestModal{constructor(e,t,s){super(e),this.items=t,this.onSelect=s,this.setPlaceholder("Select a widget to insert...")}getItems(){return this.items}getItemText(e){return e.name}onChooseItem(e){this.onSelect(e)}}; +${r.title} +\`\`\``)}).open()}else i.id==="wl-stat:watched"?t.replaceSelection("```wl-stat\nwatched\n```"):i.id==="wl-stat:completed"?t.replaceSelection("```wl-stat\ncompleted\n```"):i.id==="wl-stat:remaining"?t.replaceSelection("```wl-stat\nremaining\n```"):i.id==="wl-stat:time"?t.replaceSelection("```wl-stat\ntime\n```"):i.id==="wl-upcoming:next"?t.replaceSelection("```wl-upcoming\nnext\n```"):i.id==="wl-stat:time completed full"?t.replaceSelection("```wl-stat\ntime completed full\n```"):i.id==="wl-now-next"&&t.replaceSelection("```wl-now-next\n```")}).open()}async activateView(t){let{workspace:e}=this.app,i=e.getLeavesOfType(oe);if(i.length>0){let n=i[0];e.revealLeaf(n),t&&n.view instanceof kt&&n.view.setActiveTab(t);return}let a=e.getLeaf("tab");await a.setViewState({type:oe,active:!0}),e.revealLeaf(a),t&&a.view instanceof kt&&a.view.setActiveTab(t)}},Fs=class extends ot.FuzzySuggestModal{constructor(s,t,e){super(s),this.items=t,this.onSelect=e,this.setPlaceholder("Select a widget to insert...")}getItems(){return this.items}getItemText(s){return s.name}onChooseItem(s){this.onSelect(s)}}; diff --git a/manifest.json b/manifest.json index 11fa745..b78d5eb 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,10 @@ -{ - "id": "watchlog", - "name": "WatchLog", - "version": "1.1.0", - "minAppVersion": "1.7.2", - "description": "Track your anime, movies, and TV shows; with episode tracking, progress stats, upcoming release alerts, and embeddable widgets.", - "author": "BogdanS", - "authorUrl": "", - "isDesktopOnly": false -} +{ + "id": "watchlog", + "name": "WatchLog", + "version": "2.0.0", + "minAppVersion": "1.7.2", + "description": "Track your anime, movies, books, manga and TV shows; with episode tracking, progress stats, upcoming release alerts, and embeddable widgets.", + "author": "BogdanS", + "authorUrl": "", + "isDesktopOnly": false +} diff --git a/package-lock.json b/package-lock.json index 38516c2..9c4b5a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5174 +1,5174 @@ -{ - "name": "watchlog-plugin", - "version": "0.9.8", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "watchlog-plugin", - "version": "0.9.8", - "license": "0-BSD", - "dependencies": { - "fuse.js": "^7.3.0", - "obsidian": "latest" - }, - "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" - } - }, - "node_modules/@codemirror/state": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", - "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@marijn/find-cluster-break": "^1.0.0" - } - }, - "node_modules/@codemirror/view": { - "version": "6.38.6", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", - "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@codemirror/state": "^6.5.0", - "crelt": "^1.0.6", - "style-mod": "^4.1.0", - "w3c-keyname": "^2.2.4" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", - "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", - "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", - "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", - "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", - "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", - "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", - "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", - "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", - "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", - "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", - "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", - "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", - "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", - "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", - "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", - "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", - "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", - "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", - "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", - "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", - "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", - "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", - "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", - "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", - "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "9.30.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", - "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/json": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@eslint/json/-/json-0.14.0.tgz", - "integrity": "sha512-rvR/EZtvUG3p9uqrSmcDJPYSH7atmWr0RnFWN6m917MAPx82+zQgPUmDu0whPFG6XTyM0vB/hR6c1Q63OaYtCQ==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@eslint/core": "^0.17.0", - "@eslint/plugin-kit": "^0.4.1", - "@humanwhocodes/momoa": "^3.3.10", - "natural-compare": "^1.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/momoa": { - "version": "3.3.10", - "resolved": "https://registry.npmjs.org/@humanwhocodes/momoa/-/momoa-3.3.10.tgz", - "integrity": "sha512-KWiFQpSAqEIyrTXko3hFNLeQvSK8zXlJQzhhxsyVn58WFRYXST99b3Nqnu+ttOtjds2Pl2grUHGpe2NzhPynuQ==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@marijn/find-cluster-break": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", - "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", - "license": "MIT", - "peer": true - }, - "node_modules/@microsoft/eslint-plugin-sdl": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@microsoft/eslint-plugin-sdl/-/eslint-plugin-sdl-1.1.0.tgz", - "integrity": "sha512-dxdNHOemLnBhfY3eByrujX9KyLigcNtW8sU+axzWv5nLGcsSBeKW2YYyTpfPo1hV8YPOmIGnfA4fZHyKVtWqBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-plugin-n": "17.10.3", - "eslint-plugin-react": "7.37.3", - "eslint-plugin-security": "1.4.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "eslint": "^9" - } - }, - "node_modules/@microsoft/eslint-plugin-sdl/node_modules/eslint-plugin-security": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-1.4.0.tgz", - "integrity": "sha512-xlS7P2PLMXeqfhyf3NpqbvbnW04kN8M9NtmhpR3XGyOvt/vNKS7XPXT5EDbwKW9vCjWH4PpfQvgD/+JgN0VJKA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "safe-regex": "^1.1.0" - } - }, - "node_modules/@microsoft/eslint-plugin-sdl/node_modules/safe-regex": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", - "integrity": "sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ret": "~0.1.10" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@pkgr/core": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.2.tgz", - "integrity": "sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts" - } - }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/codemirror": { - "version": "5.60.8", - "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", - "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", - "license": "MIT", - "dependencies": { - "@types/tern": "*" - } - }, - "node_modules/@types/eslint": { - "version": "8.56.2", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.2.tgz", - "integrity": "sha512-uQDwm1wFHmbBbCZCqAlq6Do9LYwByNZHWzXppSnay9SuwJ+VRbjkbLABer54kcPnMSlG6Fdiy2yaFXm/z9Z5gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "16.18.126", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", - "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/tern": { - "version": "0.23.9", - "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", - "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz", - "integrity": "sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.35.1", - "@typescript-eslint/type-utils": "8.35.1", - "@typescript-eslint/utils": "8.35.1", - "@typescript-eslint/visitor-keys": "8.35.1", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.35.1", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.1.tgz", - "integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.35.1", - "@typescript-eslint/types": "8.35.1", - "@typescript-eslint/typescript-estree": "8.35.1", - "@typescript-eslint/visitor-keys": "8.35.1", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.1.tgz", - "integrity": "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.35.1", - "@typescript-eslint/types": "^8.35.1", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.1.tgz", - "integrity": "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.35.1", - "@typescript-eslint/visitor-keys": "8.35.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.1.tgz", - "integrity": "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.1.tgz", - "integrity": "sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "8.35.1", - "@typescript-eslint/utils": "8.35.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.1.tgz", - "integrity": "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.1.tgz", - "integrity": "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.35.1", - "@typescript-eslint/tsconfig-utils": "8.35.1", - "@typescript-eslint/types": "8.35.1", - "@typescript-eslint/visitor-keys": "8.35.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.1.tgz", - "integrity": "sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.35.1", - "@typescript-eslint/types": "8.35.1", - "@typescript-eslint/typescript-estree": "8.35.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.1.tgz", - "integrity": "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.35.1", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlast": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", - "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-shim-unscopables": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.tosorted": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", - "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3", - "es-errors": "^1.3.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/crelt": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", - "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", - "license": "MIT", - "peer": true - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/empathic": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", - "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/es-abstract": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", - "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-iterator-helpers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", - "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.0.3", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.6", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "iterator.prototype": "^1.1.4", - "safe-array-concat": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/esbuild": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", - "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.5", - "@esbuild/android-arm": "0.25.5", - "@esbuild/android-arm64": "0.25.5", - "@esbuild/android-x64": "0.25.5", - "@esbuild/darwin-arm64": "0.25.5", - "@esbuild/darwin-x64": "0.25.5", - "@esbuild/freebsd-arm64": "0.25.5", - "@esbuild/freebsd-x64": "0.25.5", - "@esbuild/linux-arm": "0.25.5", - "@esbuild/linux-arm64": "0.25.5", - "@esbuild/linux-ia32": "0.25.5", - "@esbuild/linux-loong64": "0.25.5", - "@esbuild/linux-mips64el": "0.25.5", - "@esbuild/linux-ppc64": "0.25.5", - "@esbuild/linux-riscv64": "0.25.5", - "@esbuild/linux-s390x": "0.25.5", - "@esbuild/linux-x64": "0.25.5", - "@esbuild/netbsd-arm64": "0.25.5", - "@esbuild/netbsd-x64": "0.25.5", - "@esbuild/openbsd-arm64": "0.25.5", - "@esbuild/openbsd-x64": "0.25.5", - "@esbuild/sunos-x64": "0.25.5", - "@esbuild/win32-arm64": "0.25.5", - "@esbuild/win32-ia32": "0.25.5", - "@esbuild/win32-x64": "0.25.5" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-compat-utils": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz", - "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "eslint": ">=6.0.0" - } - }, - "node_modules/eslint-compat-utils/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-depend": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-depend/-/eslint-plugin-depend-1.3.1.tgz", - "integrity": "sha512-1uo2rFAr9vzNrCYdp7IBZRB54LiyVxfaIso0R6/QV3t6Dax6DTbW/EV2Hktf0f4UtmGHK8UyzJWI382pwW04jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "empathic": "^2.0.0", - "module-replacements": "^2.8.0", - "semver": "^7.6.3" - } - }, - "node_modules/eslint-plugin-depend/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/eslint-plugin-es-x": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-es-x/-/eslint-plugin-es-x-7.8.0.tgz", - "integrity": "sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/ota-meshi", - "https://opencollective.com/eslint" - ], - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.1.2", - "@eslint-community/regexpp": "^4.11.0", - "eslint-compat-utils": "^0.5.1" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": ">=8" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-json-schema-validator": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-json-schema-validator/-/eslint-plugin-json-schema-validator-5.1.0.tgz", - "integrity": "sha512-ZmVyxRIjm58oqe2kTuy90PpmZPrrKvOjRPXKzq8WCgRgAkidCgm5X8domL2KSfadZ3QFAmifMgGTcVNhZ5ez2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.3.0", - "ajv": "^8.0.0", - "debug": "^4.3.1", - "eslint-compat-utils": "^0.5.0", - "json-schema-migrate": "^2.0.0", - "jsonc-eslint-parser": "^2.0.0", - "minimatch": "^8.0.0", - "synckit": "^0.9.0", - "toml-eslint-parser": "^0.9.0", - "tunnel-agent": "^0.6.0", - "yaml-eslint-parser": "^1.0.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ota-meshi" - }, - "peerDependencies": { - "eslint": ">=6.0.0" - } - }, - "node_modules/eslint-plugin-json-schema-validator/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/eslint-plugin-json-schema-validator/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/eslint-plugin-json-schema-validator/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint-plugin-json-schema-validator/node_modules/minimatch": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz", - "integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/eslint-plugin-n": { - "version": "17.10.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-17.10.3.tgz", - "integrity": "sha512-ySZBfKe49nQZWR1yFaA0v/GsH6Fgp8ah6XV0WDz6CN8WO0ek4McMzb7A2xnf4DCYV43frjCygvb9f/wx7UUxRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "enhanced-resolve": "^5.17.0", - "eslint-plugin-es-x": "^7.5.0", - "get-tsconfig": "^4.7.0", - "globals": "^15.8.0", - "ignore": "^5.2.4", - "minimatch": "^9.0.5", - "semver": "^7.5.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": ">=8.23.0" - } - }, - "node_modules/eslint-plugin-n/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/eslint-plugin-n/node_modules/globals": { - "version": "15.15.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", - "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint-plugin-n/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/eslint-plugin-n/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/eslint-plugin-obsidianmd": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/eslint-plugin-obsidianmd/-/eslint-plugin-obsidianmd-0.1.9.tgz", - "integrity": "sha512-/gyo5vky3Y7re4BtT/8MQbHU5Wes4o6VRqas3YmXE7aTCnMsdV0kfzV1GDXJN9Hrsc9UQPoeKUMiapKL0aGE4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@microsoft/eslint-plugin-sdl": "^1.1.0", - "@types/eslint": "8.56.2", - "@types/node": "20.12.12", - "eslint": ">=9.0.0 <10.0.0", - "eslint-plugin-depend": "1.3.1", - "eslint-plugin-import": "^2.31.0", - "eslint-plugin-json-schema-validator": "5.1.0", - "eslint-plugin-security": "2.1.1", - "globals": "14.0.0", - "obsidian": "1.8.7", - "typescript": "5.4.5" - }, - "bin": { - "eslint-plugin-obsidian": "dist/lib/index.js" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@eslint/js": "^9.30.1", - "@eslint/json": "0.14.0", - "eslint": ">=9.0.0 <10.0.0", - "obsidian": "1.8.7", - "typescript-eslint": "^8.35.1" - } - }, - "node_modules/eslint-plugin-obsidianmd/node_modules/@types/node": { - "version": "20.12.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz", - "integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/eslint-plugin-obsidianmd/node_modules/obsidian": { - "version": "1.8.7", - "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.8.7.tgz", - "integrity": "sha512-h4bWwNFAGRXlMlMAzdEiIM2ppTGlrh7uGOJS6w4gClrsjc+ei/3YAtU2VdFUlCiPuTHpY4aBpFJJW75S1Tl/JA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/codemirror": "5.60.8", - "moment": "2.29.4" - }, - "peerDependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0" - } - }, - "node_modules/eslint-plugin-obsidianmd/node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/eslint-plugin-react": { - "version": "7.37.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.3.tgz", - "integrity": "sha512-DomWuTQPFYZwF/7c9W2fkKkStqZmBd3uugfqBYLdkZ3Hii23WzZuOLUskGxB8qkSKqftxEeGL1TB2kMhrce0jA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.8", - "array.prototype.findlast": "^1.2.5", - "array.prototype.flatmap": "^1.3.3", - "array.prototype.tosorted": "^1.1.4", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.2.1", - "estraverse": "^5.3.0", - "hasown": "^2.0.2", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.8", - "object.fromentries": "^2.0.8", - "object.values": "^1.2.1", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.12", - "string.prototype.repeat": "^1.0.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" - } - }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/eslint-plugin-security": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-2.1.1.tgz", - "integrity": "sha512-7cspIGj7WTfR3EhaILzAPcfCo5R9FbeWvbgsPYWivSurTBKW88VQxtP3c4aWMG9Hz/GfJlJVdXEJ3c8LqS+u2w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "safe-regex": "^2.1.1" - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/@eslint/js": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/fuse.js": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.3.0.tgz", - "integrity": "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==", - "license": "Apache-2.0", - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/krisk" - } - }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/iterator.prototype": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", - "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "get-proto": "^1.0.0", - "has-symbols": "^1.1.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-migrate": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/json-schema-migrate/-/json-schema-migrate-2.0.0.tgz", - "integrity": "sha512-r38SVTtojDRp4eD6WsCqiE0eNDt4v1WalBXb9cyZYw9ai5cGtBwzRNWjHzJl38w6TxFkXAIA7h+fyX3tnrAFhQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - } - }, - "node_modules/json-schema-migrate/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/json-schema-migrate/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } - }, - "node_modules/jsonc-eslint-parser": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/jsonc-eslint-parser/-/jsonc-eslint-parser-2.4.1.tgz", - "integrity": "sha512-uuPNLJkKN8NXAlZlQ6kmUF9qO+T6Kyd7oV4+/7yy8Jz6+MZNyhPq8EdLpdfnPVzUC8qSf1b4j1azKaGnFsjmsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.5.0", - "eslint-visitor-keys": "^3.0.0", - "espree": "^9.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ota-meshi" - } - }, - "node_modules/jsonc-eslint-parser/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/jsonc-eslint-parser/node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/jsonc-eslint-parser/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/jsx-ast-utils": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/module-replacements": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/module-replacements/-/module-replacements-2.10.1.tgz", - "integrity": "sha512-qkKuLpMHDqRSM676OPL7HUpCiiP3NSxgf8NNR1ga2h/iJLNKTsOSjMEwrcT85DMSti2vmOqxknOVBGWj6H6etQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/moment": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", - "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.entries": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/obsidian": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.10.3.tgz", - "integrity": "sha512-VP+ZSxNMG7y6Z+sU9WqLvJAskCfkFrTz2kFHWmmzis+C+4+ELjk/sazwcTHrHXNZlgCeo8YOlM6SOrAFCynNew==", - "license": "MIT", - "dependencies": { - "@types/codemirror": "5.60.8", - "moment": "2.29.4" - }, - "peerDependencies": { - "@codemirror/state": "6.5.0", - "@codemirror/view": "6.38.6" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/regexp-tree": { - "version": "0.1.27", - "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", - "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", - "dev": true, - "license": "MIT", - "bin": { - "regexp-tree": "bin/regexp-tree" - } - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/ret": { - "version": "0.1.15", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", - "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", - "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "regexp-tree": "~0.1.1" - } - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/string.prototype.matchall": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "regexp.prototype.flags": "^1.5.3", - "set-function-name": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.repeat": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", - "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/style-mod": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", - "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", - "license": "MIT", - "peer": true - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/synckit": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.3.tgz", - "integrity": "sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@pkgr/core": "^0.1.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts" - } - }, - "node_modules/synckit/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, - "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toml-eslint-parser": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/toml-eslint-parser/-/toml-eslint-parser-0.9.3.tgz", - "integrity": "sha512-moYoCvkNUAPCxSW9jmHmRElhm4tVJpHL8ItC/+uYD0EpPSFXbck7yREz9tNdJVTSpHVod8+HoipcpbQ0oE6gsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.0.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ota-meshi" - } - }, - "node_modules/toml-eslint-parser/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, - "node_modules/tslib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", - "dev": true, - "license": "0BSD" - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.1.tgz", - "integrity": "sha512-xslJjFzhOmHYQzSB/QTeASAHbjmxOGEP6Coh93TXmUBFQoJ1VU35UHIDmG06Jd6taf3wqqC1ntBnCMeymy5Ovw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.35.1", - "@typescript-eslint/parser": "8.35.1", - "@typescript-eslint/utils": "8.35.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true, - "license": "MIT" - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/w3c-keyname": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", - "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", - "license": "MIT", - "peer": true - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, - "node_modules/yaml-eslint-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/yaml-eslint-parser/-/yaml-eslint-parser-1.3.0.tgz", - "integrity": "sha512-E/+VitOorXSLiAqtTd7Yqax0/pAS3xaYMP+AUUJGOK1OZG3rhcj9fcJOM5HJ2VrP1FrStVCWr1muTfQCdj4tAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.0.0", - "yaml": "^2.0.0" - }, - "engines": { - "node": "^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ota-meshi" - } - }, - "node_modules/yaml-eslint-parser/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} +{ + "name": "watchlog-plugin", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "watchlog-plugin", + "version": "2.0.0", + "license": "0-BSD", + "dependencies": { + "fuse.js": "^7.3.0", + "obsidian": "latest" + }, + "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" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", + "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/json": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/json/-/json-0.14.0.tgz", + "integrity": "sha512-rvR/EZtvUG3p9uqrSmcDJPYSH7atmWr0RnFWN6m917MAPx82+zQgPUmDu0whPFG6XTyM0vB/hR6c1Q63OaYtCQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@eslint/core": "^0.17.0", + "@eslint/plugin-kit": "^0.4.1", + "@humanwhocodes/momoa": "^3.3.10", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/momoa": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/@humanwhocodes/momoa/-/momoa-3.3.10.tgz", + "integrity": "sha512-KWiFQpSAqEIyrTXko3hFNLeQvSK8zXlJQzhhxsyVn58WFRYXST99b3Nqnu+ttOtjds2Pl2grUHGpe2NzhPynuQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT", + "peer": true + }, + "node_modules/@microsoft/eslint-plugin-sdl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@microsoft/eslint-plugin-sdl/-/eslint-plugin-sdl-1.1.0.tgz", + "integrity": "sha512-dxdNHOemLnBhfY3eByrujX9KyLigcNtW8sU+axzWv5nLGcsSBeKW2YYyTpfPo1hV8YPOmIGnfA4fZHyKVtWqBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-plugin-n": "17.10.3", + "eslint-plugin-react": "7.37.3", + "eslint-plugin-security": "1.4.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "eslint": "^9" + } + }, + "node_modules/@microsoft/eslint-plugin-sdl/node_modules/eslint-plugin-security": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-1.4.0.tgz", + "integrity": "sha512-xlS7P2PLMXeqfhyf3NpqbvbnW04kN8M9NtmhpR3XGyOvt/vNKS7XPXT5EDbwKW9vCjWH4PpfQvgD/+JgN0VJKA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-regex": "^1.1.0" + } + }, + "node_modules/@microsoft/eslint-plugin-sdl/node_modules/safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ret": "~0.1.10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgr/core": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.2.tgz", + "integrity": "sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/eslint": { + "version": "8.56.2", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.2.tgz", + "integrity": "sha512-uQDwm1wFHmbBbCZCqAlq6Do9LYwByNZHWzXppSnay9SuwJ+VRbjkbLABer54kcPnMSlG6Fdiy2yaFXm/z9Z5gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "16.18.126", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", + "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz", + "integrity": "sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.35.1", + "@typescript-eslint/type-utils": "8.35.1", + "@typescript-eslint/utils": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.35.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.1.tgz", + "integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.35.1", + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/typescript-estree": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.1.tgz", + "integrity": "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.35.1", + "@typescript-eslint/types": "^8.35.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.1.tgz", + "integrity": "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.1.tgz", + "integrity": "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.1.tgz", + "integrity": "sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.35.1", + "@typescript-eslint/utils": "8.35.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.1.tgz", + "integrity": "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.1.tgz", + "integrity": "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.35.1", + "@typescript-eslint/tsconfig-utils": "8.35.1", + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.1.tgz", + "integrity": "sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.35.1", + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/typescript-estree": "8.35.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.1.tgz", + "integrity": "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.35.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT", + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-compat-utils": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz", + "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/eslint-compat-utils/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-depend": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-depend/-/eslint-plugin-depend-1.3.1.tgz", + "integrity": "sha512-1uo2rFAr9vzNrCYdp7IBZRB54LiyVxfaIso0R6/QV3t6Dax6DTbW/EV2Hktf0f4UtmGHK8UyzJWI382pwW04jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "empathic": "^2.0.0", + "module-replacements": "^2.8.0", + "semver": "^7.6.3" + } + }, + "node_modules/eslint-plugin-depend/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-plugin-es-x": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-es-x/-/eslint-plugin-es-x-7.8.0.tgz", + "integrity": "sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/ota-meshi", + "https://opencollective.com/eslint" + ], + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.1.2", + "@eslint-community/regexpp": "^4.11.0", + "eslint-compat-utils": "^0.5.1" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": ">=8" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-json-schema-validator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-json-schema-validator/-/eslint-plugin-json-schema-validator-5.1.0.tgz", + "integrity": "sha512-ZmVyxRIjm58oqe2kTuy90PpmZPrrKvOjRPXKzq8WCgRgAkidCgm5X8domL2KSfadZ3QFAmifMgGTcVNhZ5ez2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.3.0", + "ajv": "^8.0.0", + "debug": "^4.3.1", + "eslint-compat-utils": "^0.5.0", + "json-schema-migrate": "^2.0.0", + "jsonc-eslint-parser": "^2.0.0", + "minimatch": "^8.0.0", + "synckit": "^0.9.0", + "toml-eslint-parser": "^0.9.0", + "tunnel-agent": "^0.6.0", + "yaml-eslint-parser": "^1.0.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/eslint-plugin-json-schema-validator/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint-plugin-json-schema-validator/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/eslint-plugin-json-schema-validator/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-json-schema-validator/node_modules/minimatch": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz", + "integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint-plugin-n": { + "version": "17.10.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-17.10.3.tgz", + "integrity": "sha512-ySZBfKe49nQZWR1yFaA0v/GsH6Fgp8ah6XV0WDz6CN8WO0ek4McMzb7A2xnf4DCYV43frjCygvb9f/wx7UUxRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "enhanced-resolve": "^5.17.0", + "eslint-plugin-es-x": "^7.5.0", + "get-tsconfig": "^4.7.0", + "globals": "^15.8.0", + "ignore": "^5.2.4", + "minimatch": "^9.0.5", + "semver": "^7.5.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": ">=8.23.0" + } + }, + "node_modules/eslint-plugin-n/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/eslint-plugin-n/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-n/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint-plugin-n/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-plugin-obsidianmd": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-plugin-obsidianmd/-/eslint-plugin-obsidianmd-0.1.9.tgz", + "integrity": "sha512-/gyo5vky3Y7re4BtT/8MQbHU5Wes4o6VRqas3YmXE7aTCnMsdV0kfzV1GDXJN9Hrsc9UQPoeKUMiapKL0aGE4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/eslint-plugin-sdl": "^1.1.0", + "@types/eslint": "8.56.2", + "@types/node": "20.12.12", + "eslint": ">=9.0.0 <10.0.0", + "eslint-plugin-depend": "1.3.1", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-json-schema-validator": "5.1.0", + "eslint-plugin-security": "2.1.1", + "globals": "14.0.0", + "obsidian": "1.8.7", + "typescript": "5.4.5" + }, + "bin": { + "eslint-plugin-obsidian": "dist/lib/index.js" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@eslint/js": "^9.30.1", + "@eslint/json": "0.14.0", + "eslint": ">=9.0.0 <10.0.0", + "obsidian": "1.8.7", + "typescript-eslint": "^8.35.1" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/@types/node": { + "version": "20.12.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz", + "integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/obsidian": { + "version": "1.8.7", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.8.7.tgz", + "integrity": "sha512-h4bWwNFAGRXlMlMAzdEiIM2ppTGlrh7uGOJS6w4gClrsjc+ei/3YAtU2VdFUlCiPuTHpY4aBpFJJW75S1Tl/JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.3.tgz", + "integrity": "sha512-DomWuTQPFYZwF/7c9W2fkKkStqZmBd3uugfqBYLdkZ3Hii23WzZuOLUskGxB8qkSKqftxEeGL1TB2kMhrce0jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.8", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-security": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-2.1.1.tgz", + "integrity": "sha512-7cspIGj7WTfR3EhaILzAPcfCo5R9FbeWvbgsPYWivSurTBKW88VQxtP3c4aWMG9Hz/GfJlJVdXEJ3c8LqS+u2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-regex": "^2.1.1" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fuse.js": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.3.0.tgz", + "integrity": "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/krisk" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-migrate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/json-schema-migrate/-/json-schema-migrate-2.0.0.tgz", + "integrity": "sha512-r38SVTtojDRp4eD6WsCqiE0eNDt4v1WalBXb9cyZYw9ai5cGtBwzRNWjHzJl38w6TxFkXAIA7h+fyX3tnrAFhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + } + }, + "node_modules/json-schema-migrate/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/json-schema-migrate/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsonc-eslint-parser": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/jsonc-eslint-parser/-/jsonc-eslint-parser-2.4.1.tgz", + "integrity": "sha512-uuPNLJkKN8NXAlZlQ6kmUF9qO+T6Kyd7oV4+/7yy8Jz6+MZNyhPq8EdLpdfnPVzUC8qSf1b4j1azKaGnFsjmsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.5.0", + "eslint-visitor-keys": "^3.0.0", + "espree": "^9.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + } + }, + "node_modules/jsonc-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/jsonc-eslint-parser/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/jsonc-eslint-parser/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/module-replacements": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/module-replacements/-/module-replacements-2.10.1.tgz", + "integrity": "sha512-qkKuLpMHDqRSM676OPL7HUpCiiP3NSxgf8NNR1ga2h/iJLNKTsOSjMEwrcT85DMSti2vmOqxknOVBGWj6H6etQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obsidian": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.10.3.tgz", + "integrity": "sha512-VP+ZSxNMG7y6Z+sU9WqLvJAskCfkFrTz2kFHWmmzis+C+4+ELjk/sazwcTHrHXNZlgCeo8YOlM6SOrAFCynNew==", + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "regexp-tree": "~0.1.1" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT", + "peer": true + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/synckit": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.3.tgz", + "integrity": "sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/synckit/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toml-eslint-parser": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/toml-eslint-parser/-/toml-eslint-parser-0.9.3.tgz", + "integrity": "sha512-moYoCvkNUAPCxSW9jmHmRElhm4tVJpHL8ItC/+uYD0EpPSFXbck7yREz9tNdJVTSpHVod8+HoipcpbQ0oE6gsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.0.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + } + }, + "node_modules/toml-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.1.tgz", + "integrity": "sha512-xslJjFzhOmHYQzSB/QTeASAHbjmxOGEP6Coh93TXmUBFQoJ1VU35UHIDmG06Jd6taf3wqqC1ntBnCMeymy5Ovw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.35.1", + "@typescript-eslint/parser": "8.35.1", + "@typescript-eslint/utils": "8.35.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT", + "peer": true + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/yaml-eslint-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/yaml-eslint-parser/-/yaml-eslint-parser-1.3.0.tgz", + "integrity": "sha512-E/+VitOorXSLiAqtTd7Yqax0/pAS3xaYMP+AUUJGOK1OZG3rhcj9fcJOM5HJ2VrP1FrStVCWr1muTfQCdj4tAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.0.0", + "yaml": "^2.0.0" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + } + }, + "node_modules/yaml-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json index 6c6b4ed..9ffd237 100644 --- a/package.json +++ b/package.json @@ -1,30 +1,30 @@ -{ - "name": "watchlog-plugin", - "version": "1.1.0", - "description": "Track your anime, movies, and TV shows; with episode tracking, progress stats, upcoming release alerts, and embeddable widgets.", - "main": "main.js", - "type": "module", - "scripts": { - "dev": "node esbuild.config.mjs", - "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", - "version": "node version-bump.mjs && git add manifest.json versions.json", - "lint": "eslint ." - }, - "keywords": [], - "license": "0-BSD", - "devDependencies": { - "@eslint/js": "9.30.1", - "@types/node": "^16.11.6", - "esbuild": "0.25.5", - "eslint-plugin-obsidianmd": "0.1.9", - "globals": "14.0.0", - "jiti": "2.6.1", - "tslib": "2.4.0", - "typescript": "^5.8.3", - "typescript-eslint": "8.35.1" - }, - "dependencies": { - "fuse.js": "^7.3.0", - "obsidian": "latest" - } -} +{ + "name": "watchlog-plugin", + "version": "2.0.0", + "description": "Track your anime, movies, books, manga and TV shows; with episode tracking, progress stats, upcoming release alerts, and embeddable widgets.", + "main": "main.js", + "type": "module", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "version": "node version-bump.mjs && git add manifest.json versions.json", + "lint": "eslint ." + }, + "keywords": [], + "license": "0-BSD", + "devDependencies": { + "@eslint/js": "9.30.1", + "@types/node": "^16.11.6", + "esbuild": "0.25.5", + "eslint-plugin-obsidianmd": "0.1.9", + "globals": "14.0.0", + "jiti": "2.6.1", + "tslib": "2.4.0", + "typescript": "^5.8.3", + "typescript-eslint": "8.35.1" + }, + "dependencies": { + "fuse.js": "^7.3.0", + "obsidian": "latest" + } +} diff --git a/src/AddFromUrlModal.ts b/src/AddFromUrlModal.ts index 7d03ca4..63badb9 100644 --- a/src/AddFromUrlModal.ts +++ b/src/AddFromUrlModal.ts @@ -66,31 +66,34 @@ export class AddFromUrlModal extends Modal { } if (this.errorEl) this.errorEl.hide(); - const result = this.plugin.settings.activeApi === 'TMDB' - ? await this.plugin.apiService.getTmdbByImdbId(imdbId) - : await this.plugin.apiService.getOmdbByImdbId(imdbId); + try { + const result = this.plugin.settings.activeApi === 'TMDB' + ? await this.plugin.apiService.getTmdbByImdbId(imdbId) + : await this.plugin.apiService.getOmdbByImdbId(imdbId); - if (!result) { + if (!result) { + this.showError('Title not found. Please check the URL and try again.'); + return; + } + + this.close(); + + const type = result.mediaType === 'movie' ? 'Movie' : 'TV Show'; + new AddTitleModal(this.app, this.plugin, this.dataManager, this.onAdded, { + title: result.title, + type, + episodes: result.episodes, + duration: result.episodeDuration, + releaseDate: result.releaseDate, + link: result.url, + seasons: result.seasons, + }).open(); + } finally { if (this.addBtn) { this.addBtn.disabled = false; this.addBtn.textContent = 'Add'; } - this.showError('Title not found. Please check the URL and try again.'); - return; } - - this.close(); - - const type = result.mediaType === 'movie' ? 'Movie' : 'TV Show'; - new AddTitleModal(this.app, this.plugin, this.dataManager, this.onAdded, { - title: result.title, - type, - episodes: result.episodes, - duration: result.episodeDuration, - releaseDate: result.releaseDate, - link: result.url, - seasons: result.seasons, - }).open(); } private showError(msg: string): void { diff --git a/src/AddReadingModal.ts b/src/AddReadingModal.ts new file mode 100644 index 0000000..75137fb --- /dev/null +++ b/src/AddReadingModal.ts @@ -0,0 +1,741 @@ +import { App, FuzzySuggestModal, Modal, Notice, TFile } from 'obsidian'; +import type WatchLogPlugin from './main'; +import type { ReadingDataManager } from './ReadingDataManager'; +import type { BookSearchResult, MangaSearchResult } from './ApiService'; +import { googleBooksErrorMessage } from './ApiService'; +import { + Book, + Manga, + ReadingStatus, + SELECTABLE_READING_STATUSES, + formatDateDisplay, + parseDateInput, +} from './types'; + +export type ReadingMode = 'book' | 'manga'; + +interface ReadingFormState { + title: string; + author: string; + status: ReadingStatus; + rating: number; + pagesRead: number; + totalPages: number; + chaptersRead: number; + totalChapters: number; + volumesRead: number; + totalVolumes: number; + coverUrl: string; + googleBooksId: string; + malId: string; + vaultPage: string; + dateStarted: string | null; + dateFinished: string | null; + releaseDate: string | null; +} + +function emptyFormState(): ReadingFormState { + return { + title: '', + author: '', + status: 'Plan to Read', + rating: 0, + pagesRead: 0, + totalPages: 0, + chaptersRead: 0, + totalChapters: 0, + volumesRead: 0, + totalVolumes: 0, + coverUrl: '', + googleBooksId: '', + malId: '', + vaultPage: '', + dateStarted: null, + dateFinished: null, + releaseDate: null, + }; +} + +export function bookToFormState(book: Book): ReadingFormState { + return { + title: book.title, + author: book.author, + status: book.status, + rating: book.rating, + pagesRead: book.pagesRead, + totalPages: book.totalPages, + chaptersRead: book.chaptersRead, + totalChapters: book.totalChapters, + volumesRead: 0, + totalVolumes: 0, + coverUrl: book.coverUrl, + googleBooksId: book.googleBooksId, + malId: '', + vaultPage: book.vaultPage, + dateStarted: book.dateStarted, + dateFinished: book.dateFinished, + releaseDate: book.releaseDate, + }; +} + +export function mangaToFormState(manga: Manga): ReadingFormState { + return { + title: manga.title, + author: manga.author, + status: manga.status, + rating: manga.rating, + pagesRead: 0, + totalPages: 0, + chaptersRead: manga.chaptersRead, + totalChapters: manga.totalChapters, + volumesRead: manga.volumesRead, + totalVolumes: manga.totalVolumes, + coverUrl: manga.coverUrl, + googleBooksId: '', + malId: manga.malId, + vaultPage: manga.vaultPage, + dateStarted: manga.dateStarted, + dateFinished: manga.dateFinished, + releaseDate: manga.releaseDate, + }; +} + +export class VaultFilePicker extends FuzzySuggestModal<TFile> { + private onPick: (file: TFile) => void; + private files: TFile[]; + + constructor(app: App, files: TFile[], onPick: (file: TFile) => void) { + super(app); + this.files = files; + this.onPick = onPick; + this.setPlaceholder('Pick a vault note...'); + } + + getItems(): TFile[] { + return this.files; + } + getItemText(item: TFile): string { + return item.path; + } + onChooseItem(item: TFile): void { + this.onPick(item); + } +} + +export class AddReadingModal extends Modal { + private plugin: WatchLogPlugin; + private readingData: ReadingDataManager; + private mode: ReadingMode; + private state: ReadingFormState; + private existingId: string | null; + private onSaved: () => void; + private starsWrap: HTMLElement | null = null; + private lookupResultsEl: HTMLElement | null = null; + private formEl: HTMLElement | null = null; + private openVaultBtn: HTMLButtonElement | null = null; + private linkVaultBtn: HTMLButtonElement | null = null; + private lookupSearchGen = 0; + private selectGen = 0; + private prefillSearch: string; + + constructor( + app: App, + plugin: WatchLogPlugin, + readingData: ReadingDataManager, + mode: ReadingMode, + onSaved: () => void, + initial?: { state: ReadingFormState; id: string }, + prefillSearch?: string, + ) { + super(app); + this.plugin = plugin; + this.readingData = readingData; + this.mode = mode; + this.state = initial ? { ...initial.state } : emptyFormState(); + if (!initial) { + const defStatus = readingData.getSettings().defaultStatus; + if (defStatus) this.state.status = defStatus; + } + this.existingId = initial?.id ?? null; + this.onSaved = onSaved; + this.prefillSearch = prefillSearch ?? ''; + } + + onOpen(): void { + const colorTheme = this.plugin.settings.colorTheme ?? 'default'; + this.modalEl.setAttribute('data-theme', colorTheme); + this.contentEl.setAttribute('data-theme', colorTheme); + this.contentEl.addClass('wl-view'); + this.contentEl.addClass('wl-reading-modal'); + this.contentEl.addClass(this.mode === 'book' ? 'wl-reading-modal-book' : 'wl-reading-modal-manga'); + this.titleEl.setText(this.headerTitle()); + this.buildUI(); + } + + onClose(): void { + this.contentEl.empty(); + } + + private headerTitle(): string { + if (this.existingId) { + return this.mode === 'book' ? 'Edit book' : 'Edit manga'; + } + return this.mode === 'book' ? 'Add book' : 'Add manga'; + } + + private buildUI(): void { + const c = this.contentEl; + c.empty(); + + this.renderHeaderRow(c); + this.renderLookupBar(c); + c.createDiv({ cls: 'wl-reading-modal-divider' }); + this.formEl = c.createDiv({ cls: 'wl-reading-form-wrap' }); + this.renderForm(this.formEl); + this.renderFooter(c); + } + + private renderHeaderRow(parent: HTMLElement): void { + const row = parent.createDiv({ cls: 'wl-reading-modal-header' }); + const left = row.createDiv({ cls: 'wl-reading-modal-header-left' }); + left.createSpan({ + cls: 'wl-reading-modal-header-icon', + text: this.mode === 'book' ? '📖' : '📓', + }); + left.createSpan({ + cls: 'wl-reading-modal-header-title', + text: this.headerTitle(), + }); + + const actions = row.createDiv({ cls: 'wl-reading-modal-header-actions' }); + // "Link" button intentionally omitted from the Add modal — vault-page linking + // lives in the detail/edit modal (ReadingDetailModal). + this.openVaultBtn = actions.createEl('button', { + cls: 'wl-btn wl-btn-sm', + text: 'Open', + }); + this.openVaultBtn.addEventListener('click', () => this.openLinkedVaultPage()); + this.refreshVaultButtons(); + } + + private refreshVaultButtons(): void { + if (this.linkVaultBtn) { + this.linkVaultBtn.textContent = this.state.vaultPage ? 'Change link' : 'Link'; + this.linkVaultBtn.title = this.state.vaultPage + ? `Linked: ${this.state.vaultPage}` + : 'Link a vault note to this entry'; + } + if (this.openVaultBtn) { + const hasLink = !!this.state.vaultPage; + this.openVaultBtn.disabled = !hasLink; + this.openVaultBtn.toggleClass('is-hidden', !hasLink); + } + } + + private openLinkedVaultPage(): void { + const path = this.state.vaultPage; + if (!path) return; + const file = this.plugin.app.vault.getAbstractFileByPath(path); + if (file instanceof TFile) { + void this.plugin.app.workspace.getLeaf('tab').openFile(file); + this.close(); + } else { + new Notice('Linked vault page no longer exists.'); + } + } + + private renderLookupBar(parent: HTMLElement): void { + const lookup = parent.createDiv({ cls: 'wl-reading-lookup' }); + const input = lookup.createEl('input', { + cls: 'wl-modal-input wl-reading-lookup-input', + attr: { + type: 'text', + placeholder: this.mode === 'book' + ? 'Search by title or ISBN...' + : 'Search by title or MAL ID...', + }, + }); + const fetchBtn = lookup.createEl('button', { + cls: 'wl-btn wl-reading-lookup-btn', + text: 'Fetch', + }); + this.lookupResultsEl = parent.createDiv({ cls: 'wl-reading-lookup-results' }); + + // Prefill the search box (e.g. from a Drafts entry) without auto-fetching, + // mirroring the Watchlist Add modal's prefill behavior. + if (this.prefillSearch) input.value = this.prefillSearch; + + const runFetch = (): void => { + const q = input.value.trim(); + if (!q) { + new Notice('Enter a search term first.'); + return; + } + void this.performLookup(q); + }; + + fetchBtn.addEventListener('click', runFetch); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + runFetch(); + } + }); + } + + private async performLookup(query: string): Promise<void> { + const gen = ++this.lookupSearchGen; + if (!this.lookupResultsEl) return; + this.lookupResultsEl.empty(); + this.lookupResultsEl.createDiv({ cls: 'wl-reading-lookup-loading', text: 'Searching...' }); + + try { + if (this.mode === 'book') { + if (!this.plugin.apiService.hasGoogleBooksKey()) { + if (gen !== this.lookupSearchGen) return; + this.lookupResultsEl.empty(); + this.lookupResultsEl.createDiv({ + cls: 'wl-reading-lookup-empty', + text: 'Google Books API key required — add one in Settings → API → Books.', + }); + return; + } + const results = await this.plugin.apiService.searchGoogleBooks(query); + if (gen !== this.lookupSearchGen) return; + this.renderBookLookupResults(results); + } else { + const isNumeric = /^\d+$/.test(query); + let results: MangaSearchResult[]; + if (isNumeric) { + const single = await this.plugin.apiService.getMangaByMalId(parseInt(query, 10)); + if (gen !== this.lookupSearchGen) return; + results = single ? [single] : []; + } else { + results = await this.plugin.apiService.searchManga(query); + if (gen !== this.lookupSearchGen) return; + } + this.renderMangaLookupResults(results); + } + } catch (err) { + if (gen !== this.lookupSearchGen) return; + this.lookupResultsEl.empty(); + const msg = this.mode === 'book' ? googleBooksErrorMessage(err) : 'Lookup failed. Check your connection.'; + this.lookupResultsEl.createDiv({ cls: 'wl-reading-lookup-empty', text: msg }); + } + } + + private renderBookLookupResults(results: BookSearchResult[]): void { + if (!this.lookupResultsEl) return; + this.lookupResultsEl.empty(); + if (results.length === 0) { + this.lookupResultsEl.createDiv({ cls: 'wl-reading-lookup-empty', text: 'No matches.' }); + return; + } + for (const r of results) { + const item = this.lookupResultsEl.createDiv({ cls: 'wl-reading-lookup-item' }); + this.renderLookupItemTitle(item, r.title, r.url); + const meta = [r.author || 'Unknown author', r.year ? String(r.year) : ''].filter(Boolean).join(' · '); + item.createDiv({ cls: 'wl-reading-lookup-item-meta', text: meta }); + item.addEventListener('click', () => this.applyBookResult(r)); + } + } + + /** Title row with a globe web-link to the right (same pattern as the Watchlist). */ + private renderLookupItemTitle(item: HTMLElement, title: string, url: string): void { + const titleRow = item.createDiv({ cls: 'wl-reading-lookup-item-title-row' }); + titleRow.createDiv({ cls: 'wl-reading-lookup-item-title', text: title || '(untitled)' }); + if (url) { + const linkIcon = titleRow.createEl('a', { cls: 'wl-acc-link-icon', text: '🌐' }); + linkIcon.href = url; + linkIcon.title = 'Open external link'; + linkIcon.target = '_blank'; + linkIcon.rel = 'noopener noreferrer'; + linkIcon.addEventListener('click', (e) => e.stopPropagation()); + } + } + + private renderMangaLookupResults(results: MangaSearchResult[]): void { + if (!this.lookupResultsEl) return; + this.lookupResultsEl.empty(); + if (results.length === 0) { + this.lookupResultsEl.createDiv({ cls: 'wl-reading-lookup-empty', text: 'No matches.' }); + return; + } + for (const r of results) { + const item = this.lookupResultsEl.createDiv({ cls: 'wl-reading-lookup-item' }); + this.renderLookupItemTitle(item, r.title, r.url); + const meta = [r.author || 'Unknown author', r.year ? String(r.year) : ''].filter(Boolean).join(' · '); + item.createDiv({ cls: 'wl-reading-lookup-item-meta', text: meta }); + item.addEventListener('click', () => this.applyMangaResult(r)); + } + } + + private applyBookResult(r: BookSearchResult): void { + // Always overwrite the auto-populated fields so re-selecting a different + // result fully replaces the previous selection's data. + const gen = ++this.selectGen; + this.state.title = r.title; + this.state.author = r.author; + this.state.totalPages = r.totalPages; + this.state.coverUrl = r.coverUrl; + this.state.googleBooksId = r.googleBooksId; + this.state.releaseDate = r.releaseDate ?? ''; + this.refreshForm(); + // Keep the results list open so another result can be picked. + // Search results sometimes omit page counts and only return thumbnail covers — + // fetch the full volume for the selected book to upgrade both. + if (r.googleBooksId) { + void (async () => { + try { + const detail = await this.plugin.apiService.getGoogleBookById(r.googleBooksId); + if (gen !== this.selectGen || !detail) return; // a newer selection superseded this one + if (detail.totalPages > 0) this.state.totalPages = detail.totalPages; + if (detail.coverUrl) this.state.coverUrl = detail.coverUrl; + this.refreshForm(); + } catch { + // Best-effort enrichment; the book is already applied from search results. + } + })(); + } + } + + private applyMangaResult(r: MangaSearchResult): void { + // Always overwrite the auto-populated fields so re-selecting a different + // result fully replaces the previous selection's data. + const gen = ++this.selectGen; + this.state.title = r.title; + this.state.author = r.author; + this.state.totalChapters = r.totalChapters; + this.state.totalVolumes = r.totalVolumes; + this.state.coverUrl = r.coverUrl; + this.state.malId = r.malId > 0 ? String(r.malId) : ''; + this.state.releaseDate = r.releaseDate ?? ''; + this.refreshForm(); + // Keep the results list open so another result can be picked. + // Search returns null chapters/volumes for ongoing titles — fetch detail only + // for the selected manga to populate the totals. + if (r.malId > 0) { + void (async () => { + const detail = await this.plugin.apiService.getMangaByMalId(r.malId); + if (gen !== this.selectGen || !detail) return; // superseded or no data + this.state.totalChapters = detail.totalChapters > 0 ? detail.totalChapters : this.state.totalChapters; + this.state.totalVolumes = detail.totalVolumes > 0 ? detail.totalVolumes : this.state.totalVolumes; + this.refreshForm(); + })(); + } + } + + private refreshForm(): void { + if (!this.formEl) return; + this.formEl.empty(); + this.renderForm(this.formEl); + } + + private renderForm(parent: HTMLElement): void { + const form = parent.createDiv({ cls: 'wl-reading-form' }); + + // Title (required) + this.renderTextField(form, 'Title', 'Book title', this.state.title, (v) => { + this.state.title = v; + }); + + // Author + this.renderTextField(form, 'Author', 'Author name', this.state.author, (v) => { + this.state.author = v; + }); + + // Status + Rating row (compact, inline) + this.renderStatusRatingRow(form); + + // PROGRESS + OPTIONAL side-by-side grid + this.renderProgressOptionalGrid(form); + } + + private renderProgressOptionalGrid(parent: HTMLElement): void { + const grid = parent.createDiv({ cls: 'wl-reading-form-grid' }); + + const progressCol = grid.createDiv({ cls: 'wl-reading-form-grid-col' }); + progressCol.createDiv({ cls: 'wl-reading-section-label', text: 'Progress' }); + if (this.mode === 'book') { + this.renderTwoNumberRow( + progressCol, + { label: 'Pages read', value: this.state.pagesRead, onChange: (n) => { this.state.pagesRead = n; } }, + { label: 'Chapters read', value: this.state.chaptersRead, onChange: (n) => { this.state.chaptersRead = n; } }, + ); + this.renderTwoNumberRow( + progressCol, + { label: 'Total pages', value: this.state.totalPages, onChange: (n) => { this.state.totalPages = n; } }, + { label: 'Total chapters', value: this.state.totalChapters, onChange: (n) => { this.state.totalChapters = n; } }, + ); + } else { + this.renderTwoNumberRow( + progressCol, + { label: 'Chapters read', value: this.state.chaptersRead, onChange: (n) => { this.state.chaptersRead = n; } }, + { label: 'Volumes read', value: this.state.volumesRead, onChange: (n) => { this.state.volumesRead = n; } }, + ); + this.renderTwoNumberRow( + progressCol, + { label: 'Total chapters', value: this.state.totalChapters, onChange: (n) => { this.state.totalChapters = n; } }, + { label: 'Total volumes', value: this.state.totalVolumes, onChange: (n) => { this.state.totalVolumes = n; } }, + ); + } + + const optionalCol = grid.createDiv({ cls: 'wl-reading-form-grid-col' }); + optionalCol.createDiv({ cls: 'wl-reading-section-label', text: 'Optional' }); + this.renderTextField(optionalCol, 'Cover URL', 'https://...', this.state.coverUrl, (v) => { + this.state.coverUrl = v; + }); + this.renderDateField(optionalCol, 'Start date', this.state.dateStarted, (parsed) => { + this.state.dateStarted = parsed; + }); + this.renderDateField(optionalCol, 'Release date', this.state.releaseDate, (parsed) => { + this.state.releaseDate = parsed; + }); + } + + private renderTextField( + parent: HTMLElement, + label: string, + placeholder: string, + value: string, + onChange: (v: string) => void, + ): void { + const row = parent.createDiv({ cls: 'wl-reading-row' }); + row.createSpan({ cls: 'wl-reading-label', text: label }); + const input = row.createEl('input', { + cls: 'wl-modal-input', + attr: { type: 'text', placeholder }, + }); + input.value = value; + input.addEventListener('input', () => onChange(input.value)); + } + + private renderStatusRatingRow(parent: HTMLElement): void { + const row = parent.createDiv({ cls: 'wl-reading-status-rating-row' }); + + // Status (compact — only as wide as needed) + const statusGroup = row.createDiv({ cls: 'wl-reading-inline-group' }); + statusGroup.createSpan({ cls: 'wl-reading-label', text: 'Status' }); + const select = statusGroup.createEl('select', { cls: 'wl-select wl-reading-status-select' }); + for (const s of SELECTABLE_READING_STATUSES) { + const opt = select.createEl('option', { text: s, value: s }); + if (s === this.state.status) opt.selected = true; + } + select.addEventListener('change', () => { + this.state.status = select.value as ReadingStatus; + }); + + // Rating (inline next to status) + const ratingGroup = row.createDiv({ cls: 'wl-reading-inline-group' }); + ratingGroup.createSpan({ cls: 'wl-reading-label', text: 'Rating' }); + this.starsWrap = ratingGroup.createDiv({ cls: 'wl-stars wl-reading-stars' }); + this.renderStars(); + } + + private renderStars(): void { + if (!this.starsWrap) return; + this.starsWrap.empty(); + for (let i = 1; i <= 5; i++) { + const star = this.starsWrap.createSpan({ + cls: `wl-star${this.state.rating >= i ? ' is-active' : ''}`, + text: '★', + }); + star.addEventListener('click', () => { + this.state.rating = this.state.rating === i ? 0 : i; + this.renderStars(); + }); + } + } + + private renderTwoNumberRow( + parent: HTMLElement, + left: { label: string; value: number; onChange: (n: number) => void }, + right: { label: string; value: number; onChange: (n: number) => void }, + ): void { + const row = parent.createDiv({ cls: 'wl-reading-row wl-reading-row-split' }); + this.renderNumberCol(row, left); + this.renderNumberCol(row, right); + } + + private renderNumberCol( + parent: HTMLElement, + field: { label: string; value: number; onChange: (n: number) => void }, + ): void { + const col = parent.createDiv({ cls: 'wl-reading-col' }); + col.createSpan({ cls: 'wl-reading-label', text: field.label }); + const input = col.createEl('input', { + cls: 'wl-modal-input wl-modal-input-sm', + attr: { type: 'number', min: '0' }, + }); + input.value = String(field.value); + input.addEventListener('input', () => { + const n = parseInt(input.value, 10); + field.onChange(isNaN(n) || n < 0 ? 0 : n); + }); + } + + private renderDateField( + parent: HTMLElement, + label: string, + value: string | null, + onParsed: (parsed: string | null) => void, + ): void { + const row = parent.createDiv({ cls: 'wl-reading-row' }); + row.createSpan({ cls: 'wl-reading-label', text: label }); + const input = row.createEl('input', { + cls: 'wl-modal-input', + attr: { type: 'text', placeholder: 'DD/MM/YYYY', maxlength: '10' }, + }); + input.value = value ? formatDateDisplay(value) : ''; + input.addEventListener('change', () => { + const raw = input.value.trim(); + if (!raw) { + input.removeClass('wl-input-error'); + onParsed(null); + return; + } + const parsed = parseDateInput(raw); + if (parsed) { + input.removeClass('wl-input-error'); + onParsed(parsed); + } else { + input.addClass('wl-input-error'); + } + }); + } + + private renderFooter(parent: HTMLElement): void { + const footer = parent.createDiv({ cls: 'wl-reading-modal-footer' }); + const cancelBtn = footer.createEl('button', { cls: 'wl-btn', text: 'Cancel' }); + cancelBtn.addEventListener('click', () => this.close()); + + const saveBtn = footer.createEl('button', { + cls: 'wl-reading-add-btn wl-btn-success wl-reading-modal-save', + text: this.saveButtonLabel(), + }); + saveBtn.addEventListener('click', () => void this.save()); + } + + private saveButtonLabel(): string { + if (this.existingId) return 'Save changes'; + return this.mode === 'book' ? 'Add book' : 'Add manga'; + } + + private async save(): Promise<void> { + const title = this.state.title.trim(); + if (!title) { + new Notice('Please enter a title.'); + return; + } + + if (this.mode === 'book') { + await this.saveBook(title); + } else { + await this.saveManga(title); + } + this.onSaved(); + this.close(); + } + + private async saveBook(title: string): Promise<void> { + if (this.existingId) { + const existing = this.readingData.getBook(this.existingId); + if (!existing) { + new Notice('Original book no longer exists.'); + return; + } + const updated: Book = { + ...existing, + title, + author: this.state.author.trim(), + status: this.state.status, + rating: this.state.rating, + pagesRead: this.state.pagesRead, + totalPages: this.state.totalPages, + chaptersRead: this.state.chaptersRead, + totalChapters: this.state.totalChapters, + coverUrl: this.state.coverUrl.trim(), + googleBooksId: this.state.googleBooksId.trim(), + vaultPage: this.state.vaultPage, + dateStarted: this.state.dateStarted, + dateFinished: this.state.dateFinished, + releaseDate: this.state.releaseDate, + }; + await this.readingData.updateBook(updated); + } else { + const now = new Date().toISOString(); + const book: Book = { + id: this.readingData.generateBookId(title), + title, + author: this.state.author.trim(), + status: this.state.status, + rating: this.state.rating, + pagesRead: this.state.pagesRead, + totalPages: this.state.totalPages, + chaptersRead: this.state.chaptersRead, + totalChapters: this.state.totalChapters, + coverUrl: this.state.coverUrl.trim(), + googleBooksId: this.state.googleBooksId.trim(), + vaultPage: this.state.vaultPage, + dateStarted: this.state.dateStarted, + dateFinished: this.state.dateFinished, + releaseDate: this.state.releaseDate, + dateAdded: now, + dateModified: now, + customFields: {}, + }; + await this.readingData.addBook(book); + } + } + + private async saveManga(title: string): Promise<void> { + if (this.existingId) { + const existing = this.readingData.getManga(this.existingId); + if (!existing) { + new Notice('Original manga no longer exists.'); + return; + } + const updated: Manga = { + ...existing, + title, + author: this.state.author.trim(), + status: this.state.status, + rating: this.state.rating, + chaptersRead: this.state.chaptersRead, + totalChapters: this.state.totalChapters, + volumesRead: this.state.volumesRead, + totalVolumes: this.state.totalVolumes, + coverUrl: this.state.coverUrl.trim(), + malId: this.state.malId.trim(), + vaultPage: this.state.vaultPage, + dateStarted: this.state.dateStarted, + dateFinished: this.state.dateFinished, + releaseDate: this.state.releaseDate, + }; + await this.readingData.updateManga(updated); + } else { + const now = new Date().toISOString(); + const manga: Manga = { + id: this.readingData.generateMangaId(title), + title, + author: this.state.author.trim(), + status: this.state.status, + rating: this.state.rating, + chaptersRead: this.state.chaptersRead, + totalChapters: this.state.totalChapters, + volumesRead: this.state.volumesRead, + totalVolumes: this.state.totalVolumes, + coverUrl: this.state.coverUrl.trim(), + malId: this.state.malId.trim(), + vaultPage: this.state.vaultPage, + dateStarted: this.state.dateStarted, + dateFinished: this.state.dateFinished, + releaseDate: this.state.releaseDate, + dateAdded: now, + dateModified: now, + customFields: {}, + }; + await this.readingData.addManga(manga); + } + } +} diff --git a/src/AddTitleModal.ts b/src/AddTitleModal.ts index 64725a8..556fdb3 100644 --- a/src/AddTitleModal.ts +++ b/src/AddTitleModal.ts @@ -8,7 +8,7 @@ import type { MediaSearchResult, Season, } from './types'; -import { formatDateDisplay, parseDateInput, parseReleaseDateInput } from './types'; +import { formatDateDisplay, parseDateInput, parseReleaseDateInput, getApiGroupForType } from './types'; type SearchResult = AnimeSearchResult | MediaSearchResult; @@ -26,7 +26,9 @@ export class AddTitleModal extends Modal { private searchQuery = ''; private searchResults: SearchResult[] = []; private isSearching = false; + private searchGeneration = 0; private autoSearch = false; + private noApiMessage = ''; // Editable fields private fieldTitle = ''; @@ -39,6 +41,7 @@ export class AddTitleModal extends Modal { private fieldPriority = 'Medium'; private fieldDateStarted = ''; private fieldMalId: number | null = null; + private fieldAnilistId: number | null = null; private skipDuplicateCheck = false; private duplicateWarningEl: HTMLElement | null = null; @@ -117,6 +120,7 @@ export class AddTitleModal extends Modal { typeSelect.addEventListener('change', () => { this.selectedType = typeSelect.value; this.searchResults = []; + this.noApiMessage = ''; this.renderResults(); this.renderForm(); }); @@ -148,28 +152,42 @@ export class AddTitleModal extends Modal { private async performSearch(): Promise<void> { if (!this.searchQuery.trim()) return; + const gen = ++this.searchGeneration; this.isSearching = true; this.renderResults(); try { const api = this.plugin.apiService; const activeApi = this.plugin.settings.activeApi ?? 'OMDb'; - const isAnime = this.selectedType === 'Anime'; + const apiGroup = getApiGroupForType(this.selectedType, this.plugin.settings.typeApiMapping); const isMovie = this.selectedType === 'Movie'; - if (isAnime) { - this.searchResults = await api.searchAnime(this.searchQuery); + let results: SearchResult[] = []; + let noApi = ''; + if (apiGroup === '') { + noApi = `No API configured for type "${this.selectedType}". Go to Settings → API to set one up.`; + } else if (apiGroup === 'anime') { + const animeSource = this.plugin.settings.animeApiSource ?? 'jikan'; + results = animeSource === 'anilist' + ? await api.searchAniList(this.searchQuery) + : await api.searchAnime(this.searchQuery); } else if (activeApi === 'TMDB') { - this.searchResults = await api.searchTmdb(this.searchQuery, isMovie ? 'movie' : 'series'); + results = await api.searchTmdb(this.searchQuery, isMovie ? 'movie' : 'series'); } else { - this.searchResults = await api.searchOmdb(this.searchQuery, isMovie ? 'movie' : 'series'); + results = await api.searchOmdb(this.searchQuery, isMovie ? 'movie' : 'series'); } + if (gen !== this.searchGeneration) return; // stale + this.noApiMessage = noApi; + this.searchResults = results; } catch { + if (gen !== this.searchGeneration) return; new Notice('Search failed. Check your connection and API settings.'); this.searchResults = []; } finally { - this.isSearching = false; - this.renderResults(); + if (gen === this.searchGeneration) { + this.isSearching = false; + this.renderResults(); + } } } @@ -182,6 +200,13 @@ export class AddTitleModal extends Modal { return; } + if (this.noApiMessage) { + const msg = this.resultsEl.createDiv({ cls: 'wl-modal-no-api' }); + msg.createSpan({ cls: 'wl-modal-no-api-icon', text: 'ℹ' }); + msg.createSpan({ cls: 'wl-modal-no-api-text', text: this.noApiMessage }); + return; + } + if (this.searchResults.length === 0) return; this.searchResults.forEach((result, idx) => { @@ -204,6 +229,7 @@ export class AddTitleModal extends Modal { } itemEl.addClass('is-selected'); + const gen = ++this.searchGeneration; const api = this.plugin.apiService; const activeApi = this.plugin.settings.activeApi ?? 'OMDb'; try { @@ -211,6 +237,7 @@ export class AddTitleModal extends Modal { const full = activeApi === 'TMDB' ? await api.getTmdbTvDetails(result.imdbId) : await api.getOmdbTvDetails(result.imdbId); + if (gen !== this.searchGeneration) return; if (full) { this.fieldTitle = full.title; this.fieldEpisodes = full.episodes; @@ -223,6 +250,7 @@ export class AddTitleModal extends Modal { const full = activeApi === 'TMDB' ? await api.getTmdbMovieDetails(result.imdbId) : await api.getOmdbMovieDetails(result.imdbId); + if (gen !== this.searchGeneration) return; if (full) { this.fieldTitle = full.title; this.fieldEpisodes = full.episodes; @@ -239,7 +267,13 @@ export class AddTitleModal extends Modal { this.fieldReleaseDate = anime.releaseDate; this.fieldLink = anime.url; this.fieldSeasons = anime.seasons; - this.fieldMalId = anime.malId; + if (anime.anilistId && anime.anilistId > 0) { + this.fieldAnilistId = anime.anilistId; + this.fieldMalId = null; + } else { + this.fieldMalId = anime.malId; + this.fieldAnilistId = null; + } } } catch { this.fieldTitle = result.title; @@ -478,9 +512,10 @@ export class AddTitleModal extends Modal { seasons: this.fieldSeasons, watchedEpisodes: [], ...(this.fieldMalId !== null ? { malId: this.fieldMalId } : {}), + ...(this.fieldAnilistId !== null ? { anilistId: this.fieldAnilistId } : {}), }; - // Bug 3: auto-complete episodes when adding with Completed status + // Auto-complete episodes when adding with Completed status if (entry.status === 'Completed' && entry.totalEpisodes > 0) { entry.watchedEpisodes = Array.from({ length: entry.totalEpisodes }, (_, i) => i + 1); if (this.plugin.settings.setFinishDateAutomatically) { @@ -490,6 +525,17 @@ export class AddTitleModal extends Modal { await this.dataManager.addTitle(entry); + // Fire-and-forget community rating fetch (no await — non-blocking) + void (async () => { + const animeSource = this.plugin.settings.animeApiSource ?? 'jikan'; + const group = getApiGroupForType(entry.type, this.plugin.settings.typeApiMapping); + if (group === '') return; + const result = await this.plugin.apiService.fetchCommunityRating(entry, animeSource, group); + if (result) { + this.dataManager.updateCommunityRating(entry.id, result.rating, result.votes, result.source); + } + })(); + if (this.selectedGroupId) { await this.dataManager.addTitleToGroup(this.selectedGroupId, entry.id); } else if (this.newGroupName.trim()) { diff --git a/src/AirtimeTab.ts b/src/AirtimeTab.ts index aae3efb..2542042 100644 --- a/src/AirtimeTab.ts +++ b/src/AirtimeTab.ts @@ -1,11 +1,42 @@ import { App, Modal, Notice } from 'obsidian'; import type WatchLogPlugin from './main'; import type { DataManager } from './DataManager'; -import type { WatchLogTitle, AirtimeEntry, AirtimeSchedule, AirtimeRecurrence, MaybeTitle } from './types'; -import { getAirtimeScheduleString, getThemedColor } from './types'; -import { InsertWidgetModal } from './InsertWidgetModal'; +import type { ReadingDataManager } from './ReadingDataManager'; +import type { WatchLogTitle, AirtimeEntry, AirtimeSchedule, AirtimeRecurrence, MaybeTitle, Book, Manga } from './types'; +import { getAirtimeScheduleString, getThemedColor, getReadingTypeColor, parseDateInput, isReleaseDateFuture } from './types'; import { ConfirmModal } from './ConfirmModal'; import { MaybeAddModal } from './MaybeAddModal'; +import { ReadingScheduleModal } from './ReadingScheduleModal'; +import { UpcomingFinderModal, type UpcomingFinderItem } from './UpcomingFinderModal'; + +/** + * A normalized Upcoming card subject — either a watchlist title or a reading item. + * Lets the tracker render and act on both through one shared code path. + */ +type ResolvedUpcoming = { + source: 'watchlist' | 'reading'; + id: string; + title: string; + /** Badge text: a watch type ("Anime", "Movie", …) or "Book" / "Manga". */ + typeName: string; + /** Colored-badge color, or null to render a plain badge (reading). */ + typeColor: string | null; + externalLink: string; + /** True when the item is a single release (movie/book): 0 or 1 total units. */ + isSingle: boolean; + /** Total episodes (watch) or total chapters (reading). */ + totalUnits: number; + /** Lowercase noun for the incrementing unit: "episode" | "chapter". */ + unitNoun: string; + /** Capitalized incrementing unit: "Episode" | "Chapter". */ + unitNounCap: string; + /** Capitalized grouping unit: "Season" | "Volume". */ + groupNounCap: string; + /** Badge tail: "Airing next" | "Reading next". */ + nextLabel: string; + /** Status to revert to on single-release tick: "Plan to watch" | "Plan to Read". */ + planStatus: string; +}; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -73,7 +104,7 @@ function getDetailedCountdown( if (schedule.recurrence === 'daily') { const passed = isAirtimePassedNow(schedule); - // If user already ticked today, advance to tomorrow + // Already ticked today → advance to tomorrow if (passed && acknowledgedToday) return { kind: 'future', label: 'Tomorrow', daysUntil: 1 }; if (passed) return { kind: 'aired', label: 'Aired', daysUntil: 0 }; return { kind: 'today-before', label: 'Today', daysUntil: 0 }; @@ -138,9 +169,10 @@ export class AirtimeTab { private container: HTMLElement; private plugin: WatchLogPlugin; private dataManager: DataManager; + private readingDataManager: ReadingDataManager; private onCountChange?: (count: number) => void; - // Sub-tab state (Feature 2) + // Sub-tab state currentSubTab: 'tracker' | 'history' | 'maybe' = 'tracker'; // Selection mode @@ -159,9 +191,63 @@ export class AirtimeTab { this.container = container; this.plugin = plugin; this.dataManager = dataManager; + this.readingDataManager = plugin.readingDataManager; this.onCountChange = onCountChange; } + /** + * Normalizes an airtime entry into a renderable subject, resolving the + * underlying watchlist title or reading item. Returns null for orphan entries. + */ + private resolveEntry(entry: AirtimeEntry): ResolvedUpcoming | null { + if (entry.source === 'reading') { + const kind = entry.readingKind ?? 'book'; + const item: Book | Manga | undefined = + kind === 'book' ? this.readingDataManager.getBook(entry.titleId) : this.readingDataManager.getManga(entry.titleId); + if (!item) return null; + const totalUnits = entry.totalEpisodes ?? item.totalChapters ?? 0; + return { + source: 'reading', + id: item.id, + title: item.title, + typeName: kind === 'book' ? 'Book' : 'Manga', + typeColor: this.plugin.settings.coloredTypeBadges + ? getReadingTypeColor(kind, this.plugin.settings) + : null, + externalLink: item.externalLink ?? '', + isSingle: totalUnits <= 1, + totalUnits, + unitNoun: 'chapter', + unitNounCap: 'Chapter', + groupNounCap: 'Volume', + nextLabel: 'Reading next', + planStatus: 'Plan to Read', + }; + } + + const title = this.dataManager.getTitle(entry.titleId); + if (!title) return null; + const typeDef = this.plugin.settings.types.find((t) => t.name === title.type); + const colored = this.plugin.settings.coloredTypeBadges; + return { + source: 'watchlist', + id: title.id, + title: title.title, + typeName: title.type, + typeColor: colored && typeDef + ? getThemedColor(title.type, typeDef.color, this.plugin.settings.colorTheme) + : null, + externalLink: title.externalLink, + isSingle: title.totalEpisodes <= 1, + totalUnits: entry.totalEpisodes ?? title.totalEpisodes, + unitNoun: 'episode', + unitNounCap: 'Episode', + groupNounCap: 'Season', + nextLabel: 'Airing next', + planStatus: 'Plan to watch', + }; + } + /** Returns the count of Due entries in the Maybe list — usable without rendering the tab. */ static getMaybeDueCount(dataManager: DataManager): number { const today = new Date(); @@ -173,15 +259,29 @@ export class AirtimeTab { } /** Returns the count of Aired/Due entries — usable without rendering the tab. */ - static getAiredDueCount(dataManager: DataManager): number { + static getAiredDueCount(dataManager: DataManager, readingDataManager?: ReadingDataManager): number { const allEntries = dataManager.getAirtimeEntries(); const titles = dataManager.getTitles(); + const titleMap = new Map(titles.map((t) => [t.id, t])); let count = 0; for (const entry of allEntries) { - const title = titles.find((t) => t.id === entry.titleId); - if (!title) continue; - const isMovie = title.totalEpisodes <= 1; - const cd = getDetailedCountdown(entry.schedule, isMovie, entry.lastAcknowledgedDate); + let isSingle: boolean; + if (entry.source === 'reading') { + // Verify the reading item still exists (skip orphans) when we have the manager. + if (readingDataManager) { + const kind = entry.readingKind ?? 'book'; + const item = kind === 'book' + ? readingDataManager.getBook(entry.titleId) + : readingDataManager.getManga(entry.titleId); + if (!item) continue; + } + isSingle = (entry.totalEpisodes ?? 0) <= 1; + } else { + const title = titleMap.get(entry.titleId); + if (!title) continue; + isSingle = title.totalEpisodes <= 1; + } + const cd = getDetailedCountdown(entry.schedule, isSingle, entry.lastAcknowledgedDate); if (cd.kind === 'aired' || cd.kind === 'due') count++; } return count; @@ -191,7 +291,7 @@ export class AirtimeTab { this.container.empty(); this.container.addClass('wl-airtime'); if (this.onCountChange) { - this.onCountChange(AirtimeTab.getAiredDueCount(this.dataManager) + AirtimeTab.getMaybeDueCount(this.dataManager)); + this.onCountChange(AirtimeTab.getAiredDueCount(this.dataManager, this.readingDataManager) + AirtimeTab.getMaybeDueCount(this.dataManager)); } this.renderInnerTabBar(); if (this.currentSubTab === 'tracker') { @@ -205,11 +305,11 @@ export class AirtimeTab { private renderInnerTabBar(): void { const bar = this.container.createDiv({ cls: 'wl-inner-tab-bar' }); - const trackerCount = AirtimeTab.getAiredDueCount(this.dataManager); + const trackerCount = AirtimeTab.getAiredDueCount(this.dataManager, this.readingDataManager); const maybeCount = AirtimeTab.getMaybeDueCount(this.dataManager); const tabs: Array<{ key: 'tracker' | 'history' | 'maybe'; label: string; badge: number }> = [ { key: 'tracker', label: 'Tracker', badge: trackerCount }, - { key: 'history', label: 'History', badge: 0 }, + { key: 'history', label: 'Log', badge: 0 }, { key: 'maybe', label: 'Maybe', badge: maybeCount }, ]; for (const { key, label, badge } of tabs) { @@ -230,11 +330,12 @@ export class AirtimeTab { } private renderTracker(): void { - // Notice banner - this.container.createDiv({ - cls: 'wl-cl-draft-banner', - text: '⚠ All titles with a release date in the future will be automatically marked as "To be released" in Watchlist and added here.', - }); + if (this.plugin.settings.showHintBanners) { + this.container.createDiv({ + cls: 'wl-cl-draft-banner', + text: '⚠ All titles with a release date in the future will be automatically marked as "To be released" in Watchlist and added here.', + }); + } this.renderHeader(); this.renderSearch(); this.renderCards(); @@ -279,21 +380,20 @@ export class AirtimeTab { metaRow.createSpan({ cls: 'wl-airtime-schedule', text: title.releaseDate }); } - const right = card.createDiv({ cls: 'wl-airtime-card-right' }); + const right = card.createDiv({ cls: 'wl-airtime-card-right wl-airtime-history-right' }); if (title.releaseDate) { right.createDiv({ cls: 'wl-airtime-pill wl-airtime-pill-aired', text: formatDaysAgo(title.releaseDate) }); } - const actions = right.createDiv({ cls: 'wl-airtime-actions' }); - const globeBtn = actions.createEl('button', { cls: 'wl-airtime-action-btn', text: '🌐' }); + const globeBtn = right.createEl('button', { cls: 'wl-airtime-action-btn', text: '🌐' }); globeBtn.title = 'Open page'; globeBtn.addEventListener('click', (e) => { e.stopPropagation(); - if (title.externalLink) window.open(title.externalLink, '_blank'); + if (title.externalLink) activeWindow.open(title.externalLink, '_blank'); else new Notice('No external link set for this title.'); }); - const deleteBtn = actions.createEl('button', { cls: 'wl-airtime-action-btn wl-airtime-action-btn-delete', text: '✕' }); + const deleteBtn = right.createEl('button', { cls: 'wl-airtime-action-btn wl-airtime-action-btn-delete wl-btn-danger', text: '✕' }); deleteBtn.title = 'Remove from watchlist'; deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); @@ -311,8 +411,10 @@ export class AirtimeTab { headerEl.createSpan({ cls: 'wl-list-count', text: String(maybeTitles.length) }); } const controls = headerEl.createDiv({ cls: 'wl-header-controls' }); - const addBtnWrap = controls.createDiv({ cls: 'wl-add-btn-wrap' }); - const addBtn = addBtnWrap.createEl('button', { cls: 'wl-add-btn', text: '+ add' }); + // Add pinned to the far right of the toolbar row + const rightGroup = controls.createDiv({ cls: 'wl-header-controls-right' }); + const addBtnWrap = rightGroup.createDiv({ cls: 'wl-add-btn-wrap' }); + const addBtn = addBtnWrap.createEl('button', { cls: 'wl-add-btn wl-btn-success', text: '+ add' }); addBtn.addEventListener('click', (e) => { e.stopPropagation(); new MaybeAddModal(this.plugin.app, this.plugin, this.dataManager, () => this.render()).open(); @@ -376,10 +478,10 @@ export class AirtimeTab { globeBtn.title = 'Open page'; globeBtn.addEventListener('click', (e) => { e.stopPropagation(); - if (mt.externalLink) window.open(mt.externalLink, '_blank'); + if (mt.externalLink) activeWindow.open(mt.externalLink, '_blank'); else new Notice('No external link set.'); }); - const deleteBtn = right.createEl('button', { cls: 'wl-airtime-action-btn wl-airtime-action-btn-delete', text: '✕' }); + const deleteBtn = right.createEl('button', { cls: 'wl-airtime-action-btn wl-airtime-action-btn-delete wl-btn-danger', text: '✕' }); deleteBtn.title = 'Remove from maybe'; deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); @@ -396,9 +498,14 @@ export class AirtimeTab { attr: { type: 'text', placeholder: 'Search upcoming...' }, }); input.value = this.searchQuery; + let debounceTimer: number | null = null; input.addEventListener('input', () => { this.searchQuery = input.value; - this.rerenderCards(); + if (debounceTimer !== null) window.clearTimeout(debounceTimer); + debounceTimer = window.setTimeout(() => { + debounceTimer = null; + this.rerenderCards(); + }, 250); }); } @@ -415,10 +522,9 @@ export class AirtimeTab { private renderHeader(): void { const header = this.container.createDiv({ cls: 'wl-list-header' }); - // Entry count (only entries with a valid title match) - const allTitles = this.dataManager.getTitles(); + // Entry count (only entries that still resolve to a watchlist or reading item) const count = this.dataManager.getAirtimeEntries().filter( - (e) => allTitles.some((t) => t.id === e.titleId), + (e) => this.resolveEntry(e) !== null, ).length; if (count > 0) { header.createSpan({ cls: 'wl-list-count', text: String(count) }); @@ -448,8 +554,11 @@ export class AirtimeTab { }); } + // Select + add pinned to the far right of the toolbar row + const rightGroup = controls.createDiv({ cls: 'wl-header-controls-right' }); + // Selection mode toggle button - const selBtn = controls.createEl('button', { + const selBtn = rightGroup.createEl('button', { cls: `wl-btn wl-btn-sm${this.selectionMode ? ' is-active' : ''}`, text: 'Select', }); @@ -459,8 +568,8 @@ export class AirtimeTab { this.render(); }); - const addBtnWrap = controls.createDiv({ cls: 'wl-add-btn-wrap' }); - const addBtn = addBtnWrap.createEl('button', { cls: 'wl-add-btn', text: '+ add' }); + const addBtnWrap = rightGroup.createDiv({ cls: 'wl-add-btn-wrap' }); + const addBtn = addBtnWrap.createEl('button', { cls: 'wl-add-btn wl-btn-success', text: '+ add' }); addBtn.addEventListener('click', (e) => { e.stopPropagation(); this.openAddFlow(); @@ -472,7 +581,7 @@ export class AirtimeTab { // Delete — removes selected entries from Upcoming const deleteBtn = bar.createEl('button', { - cls: 'wl-group-action-btn wl-group-action-btn-delete', + cls: 'wl-group-action-btn wl-group-action-btn-delete wl-btn-danger', text: '✕', }); deleteBtn.title = 'Remove from upcoming'; @@ -484,9 +593,7 @@ export class AirtimeTab { `Remove ${count} selected item${count !== 1 ? 's' : ''} from Upcoming? This cannot be undone.`, () => { void (async () => { - for (const id of Array.from(this.selectedItems)) { - await this.dataManager.removeAirtimeEntry(id); - } + await this.dataManager.removeAirtimeEntriesBatch(Array.from(this.selectedItems)); this.selectedItems.clear(); this.render(); })(); @@ -498,26 +605,142 @@ export class AirtimeTab { // ── Add flow ────────────────────────────────────────────────────────────────── private openAddFlow(): void { - const titles = this.dataManager.getTitles(); - if (titles.length === 0) { - new Notice('No titles in your watchlog library yet.'); - return; - } - const entries = this.dataManager.getAirtimeEntries(); - const alreadyAdded = new Set(entries.map((e) => e.titleId)); - const available = titles.filter((t) => !alreadyAdded.has(t.id)); + // Already-added keys, kept separate per source so a watchlist id and a + // reading id that happen to match don't shadow each other. + const addedWatch = new Set(entries.filter((e) => e.source !== 'reading').map((e) => e.titleId)); + const addedReading = new Set(entries.filter((e) => e.source === 'reading').map((e) => e.titleId)); - if (available.length === 0) { - new Notice('All titles are already in upcoming.'); + const items: UpcomingFinderItem[] = []; + for (const t of this.dataManager.getTitles()) { + if (addedWatch.has(t.id)) continue; + items.push({ source: 'watchlist', id: t.id, title: t.title, typeLabel: t.type }); + } + for (const b of this.readingDataManager.getBooks()) { + if (addedReading.has(b.id)) continue; + items.push({ source: 'reading', kind: 'book', id: b.id, title: b.title, typeLabel: 'Book' }); + } + for (const m of this.readingDataManager.getMangaList()) { + if (addedReading.has(m.id)) continue; + items.push({ source: 'reading', kind: 'manga', id: m.id, title: m.title, typeLabel: 'Manga' }); + } + + if (this.dataManager.getTitles().length === 0 && + this.readingDataManager.getBooks().length === 0 && + this.readingDataManager.getMangaList().length === 0) { + new Notice('No titles in your watchlog or reading library yet.'); + return; + } + if (items.length === 0) { + new Notice('Everything is already in upcoming.'); return; } - new InsertWidgetModal(this.plugin.app, available, (title) => { - void this.startAddWithTitle(title); + new UpcomingFinderModal(this.plugin.app, items, (item) => { + if (item.source === 'reading') { + const kind = item.kind ?? 'book'; + const readItem = kind === 'book' + ? this.readingDataManager.getBook(item.id) + : this.readingDataManager.getManga(item.id); + if (readItem) this.startAddWithReading(readItem, kind); + } else { + const title = this.dataManager.getTitle(item.id); + if (title) void this.startAddWithTitle(title); + } }).open(); } + private startAddWithReading(item: Book | Manga, kind: 'book' | 'manga'): void { + const prefilled: AirtimeSchedule | null = + item.releaseDate && /^\d{4}-\d{2}-\d{2}$/.test(item.releaseDate) + ? { recurrence: 'once', releaseDate: item.releaseDate } + : null; + + new ReadingScheduleModal( + this.plugin.app, + item, + kind, + prefilled, + null, + null, + null, + null, + async (schedule, volume, chapter, totalVolumes, totalChapters) => { + const entry: AirtimeEntry = { + id: this.dataManager.generateReadingAirtimeId(item.id), + titleId: item.id, + source: 'reading', + readingKind: kind, + schedule, + currentSeason: volume ?? undefined, + currentEpisode: chapter ?? undefined, + totalSeasons: totalVolumes ?? undefined, + totalEpisodes: totalChapters ?? undefined, + dateAdded: new Date().toISOString(), + }; + await this.dataManager.addAirtimeEntry(entry); + await this.syncReadingItemFromSchedule(item.id, kind, schedule, totalVolumes, totalChapters); + this.render(); + }, + ).open(); + } + + /** Pushes schedule-derived totals / release date back onto the reading item. */ + private async syncReadingItemFromSchedule( + itemId: string, + kind: 'book' | 'manga', + schedule: AirtimeSchedule, + totalVolumes: number | null, + totalChapters: number | null, + ): Promise<void> { + if (kind === 'book') { + const b = this.readingDataManager.getBook(itemId); + if (!b) return; + let changed = false; + if (totalChapters !== null && totalChapters !== b.totalChapters) { b.totalChapters = totalChapters; changed = true; } + // Only write a future 'once' date back (drives auto "To be released"); a + // past/due date is left alone so the just-added entry isn't auto-removed. + if (schedule.recurrence === 'once' && isReleaseDateFuture(schedule.releaseDate)) { + const newDate = schedule.releaseDate ?? null; + if (b.releaseDate !== newDate) { b.releaseDate = newDate; changed = true; } + } + if (changed) await this.readingDataManager.updateBook(b); + } else { + const m = this.readingDataManager.getManga(itemId); + if (!m) return; + let changed = false; + if (totalChapters !== null && totalChapters !== m.totalChapters) { m.totalChapters = totalChapters; changed = true; } + if (totalVolumes !== null && totalVolumes !== m.totalVolumes) { m.totalVolumes = totalVolumes; changed = true; } + // Only write a future 'once' date back (drives auto "To be released"); a + // past/due date is left alone so the just-added entry isn't auto-removed. + if (schedule.recurrence === 'once' && isReleaseDateFuture(schedule.releaseDate)) { + const newDate = schedule.releaseDate ?? null; + if (m.releaseDate !== newDate) { m.releaseDate = newDate; changed = true; } + } + if (changed) await this.readingDataManager.updateManga(m); + } + } + + /** + * On a single-release tick, revert the underlying item's auto "To be released" + * status back to its plan status (watchlist title or reading item). + */ + private async revertToBeReleasedStatus(entry: AirtimeEntry, r: ResolvedUpcoming): Promise<void> { + if (r.source === 'reading') { + const kind = entry.readingKind ?? 'book'; + if (kind === 'book') { + const b = this.readingDataManager.getBook(entry.titleId); + if (b && b.status === 'To be released') { b.status = 'Plan to Read'; await this.readingDataManager.updateBook(b); } + } else { + const m = this.readingDataManager.getManga(entry.titleId); + if (m && m.status === 'To be released') { m.status = 'Plan to Read'; await this.readingDataManager.updateManga(m); } + } + } else { + const t = this.dataManager.getTitle(entry.titleId); + if (t && t.status === 'To be released') { t.status = 'Plan to watch'; await this.dataManager.updateTitle(t); } + } + } + private async startAddWithTitle(title: WatchLogTitle): Promise<void> { let prefilled: AirtimeSchedule | null = null; @@ -530,7 +753,7 @@ export class AirtimeTab { new Notice('Schedule auto-filled from myanimelist.'); } } catch { - // ignore — user fills manually + // ignore — fields stay empty for manual entry } } @@ -568,7 +791,7 @@ export class AirtimeTab { t.totalEpisodes = totalEpisodes; changed = true; } - // Bug 2: sync releaseDate when schedule is 'once' + // Sync releaseDate when schedule is 'once' if (schedule.recurrence === 'once') { const newDate = schedule.releaseDate ?? null; if (t.releaseDate !== newDate) { @@ -597,20 +820,18 @@ export class AirtimeTab { return; } - const titles = this.dataManager.getTitles(); const q = this.searchQuery.trim().toLowerCase(); const cardData = allEntries .map((entry) => { - const title = titles.find((t) => t.id === entry.titleId); - if (!title) return null; - if (q && !title.title.toLowerCase().includes(q)) return null; - const isMovie = title.totalEpisodes <= 1; - const countdown = getDetailedCountdown(entry.schedule, isMovie, entry.lastAcknowledgedDate); - return { entry, title, countdown }; + const r = this.resolveEntry(entry); + if (!r) return null; + if (q && !r.title.toLowerCase().includes(q)) return null; + const countdown = getDetailedCountdown(entry.schedule, r.isSingle, entry.lastAcknowledgedDate); + return { entry, r, countdown }; }) .filter( - (d): d is { entry: AirtimeEntry; title: WatchLogTitle; countdown: DetailedCountdown } => + (d): d is { entry: AirtimeEntry; r: ResolvedUpcoming; countdown: DetailedCountdown } => d !== null, ) .sort((a, b) => { @@ -629,8 +850,8 @@ export class AirtimeTab { } const cardsEl = this.container.createDiv({ cls: 'wl-airtime-cards' }); - for (const { entry, title, countdown } of cardData) { - this.renderCard(cardsEl, entry, title, countdown); + for (const { entry, r, countdown } of cardData) { + this.renderCard(cardsEl, entry, r, countdown); } } @@ -639,11 +860,11 @@ export class AirtimeTab { private renderCard( parent: HTMLElement, entry: AirtimeEntry, - title: WatchLogTitle, + r: ResolvedUpcoming, countdown: DetailedCountdown, ): void { - const isMovie = title.totalEpisodes <= 1; - const totalEps = entry.totalEpisodes ?? title.totalEpisodes; + const isMovie = r.isSingle; + const totalEps = r.totalUnits; const isFinalEpisode = !isMovie && entry.currentEpisode !== undefined && @@ -686,25 +907,24 @@ export class AirtimeTab { // ── Left ────────────────────────────────────────────────────────────────── const left = card.createDiv({ cls: 'wl-airtime-card-left' }); - left.createDiv({ cls: 'wl-airtime-card-title', text: title.title }); + left.createDiv({ cls: 'wl-airtime-card-title', text: r.title }); - // Type badge + schedule string (above episode badge per spec) + // Type badge + schedule string, above the episode badge. Reading entries + // render a plain (theme-driven) badge; watch entries may be colored. const metaRow = left.createDiv({ cls: 'wl-airtime-card-meta' }); - const typeDef = this.plugin.settings.types.find((t) => t.name === title.type); - const colored = this.plugin.settings.coloredTypeBadges; const typeBadge = metaRow.createSpan({ - cls: colored ? 'wl-badge wl-badge-sm' : 'wl-badge-plain', - text: title.type, + cls: r.typeColor ? 'wl-badge wl-badge-sm' : 'wl-badge-plain', + text: r.typeName, }); - if (colored && typeDef) typeBadge.style.backgroundColor = getThemedColor(title.type, typeDef.color, this.plugin.settings.colorTheme); + if (r.typeColor) typeBadge.style.backgroundColor = r.typeColor; metaRow.createSpan({ cls: 'wl-airtime-schedule', text: getAirtimeScheduleString(entry.schedule) }); - // Episode badge — only for series/anime, positioned below meta row + // Progress badge — only for multi-part titles (series / manga), below meta row if (!isMovie && (entry.currentSeason !== undefined || entry.currentEpisode !== undefined)) { const badgeParts: string[] = []; - if (entry.currentSeason !== undefined) badgeParts.push(`Season ${entry.currentSeason}`); - if (entry.currentEpisode !== undefined) badgeParts.push(`Episode ${entry.currentEpisode}`); - badgeParts.push('Airing next'); + if (entry.currentSeason !== undefined) badgeParts.push(`${r.groupNounCap} ${entry.currentSeason}`); + if (entry.currentEpisode !== undefined) badgeParts.push(`${r.unitNounCap} ${entry.currentEpisode}`); + badgeParts.push(r.nextLabel); const badgeCls = isFinalEpisode ? 'wl-ep-badge wl-ep-badge-final' : 'wl-ep-badge'; @@ -737,14 +957,14 @@ export class AirtimeTab { cls: 'wl-airtime-action-btn wl-airtime-action-btn-tick', text: '✓', }); - tickBtn.title = isMovie ? 'Mark as released' : (isFinalEpisode ? 'Final episode — mark done' : 'Mark episode as aired'); + tickBtn.title = isMovie ? 'Mark as released' : (isFinalEpisode ? `Final ${r.unitNoun} — mark done` : `Mark ${r.unitNoun} as aired`); tickBtn.addEventListener('click', (e) => { e.stopPropagation(); const confirmMsg = isMovie - ? `"${title.title}" has been released.\nRemove from Upcoming and set status to Plan to watch?` + ? `"${r.title}" has been released.\nRemove from Upcoming and set status to ${r.planStatus}?` : isFinalEpisode - ? `Final episode of "${title.title}" aired.\nRemove from Upcoming? (Watchlist status unchanged)` - : `Episode ${entry.currentEpisode ?? ''} of "${title.title}" aired.\nMark and track next episode?`; + ? `Final ${r.unitNoun} of "${r.title}".\nRemove from Upcoming? (status unchanged)` + : `${r.unitNounCap} ${entry.currentEpisode ?? ''} of "${r.title}".\nMark and track next ${r.unitNoun}?`; new ConfirmModal(this.plugin.app, confirmMsg, () => { void (async () => { @@ -755,17 +975,13 @@ export class AirtimeTab { if (isMovie) { await this.dataManager.removeAirtimeEntry(entry.id); - const t = this.dataManager.getTitle(title.id); - if (t && t.status === 'To be released') { - t.status = 'Plan to watch'; - await this.dataManager.updateTitle(t); - } + await this.revertToBeReleasedStatus(entry, r); } else if (isFinalEpisode) { - // Final episode: remove from Upcoming, do NOT change Watchlist status + // Final unit: remove from Upcoming, do NOT change status await this.dataManager.removeAirtimeEntry(entry.id); } else { - // Non-final episode: increment episode and record acknowledgement date - // so the countdown resets to the next occurrence immediately. + // Non-final: increment the unit (episode/chapter) and record + // the acknowledgement date so the countdown resets immediately. entry.currentEpisode = (entry.currentEpisode ?? 1) + 1; entry.lastAcknowledgedDate = todayStr; await this.dataManager.updateAirtimeEntry(entry); @@ -781,71 +997,108 @@ export class AirtimeTab { globeBtn.title = 'Open page'; globeBtn.addEventListener('click', (e) => { e.stopPropagation(); - if (title.externalLink) { - window.open(title.externalLink, '_blank'); + if (r.externalLink) { + activeWindow.open(r.externalLink, '_blank'); } else { new Notice('No external link set for this title.'); } }); - // Edit — open schedule modal + // Edit — open the schedule modal matching the entry's source const editBtn = actions.createEl('button', { cls: 'wl-airtime-action-btn', text: '✏' }); editBtn.title = 'Edit schedule'; editBtn.addEventListener('click', (e) => { e.stopPropagation(); - new AirtimeScheduleModal( - this.plugin.app, - title, - { ...entry.schedule }, - entry.currentSeason ?? null, - entry.currentEpisode ?? null, - entry.totalSeasons ?? null, - entry.totalEpisodes ?? null, - async (schedule, season, episode, totalSeasons, totalEpisodes) => { - entry.schedule = schedule; - entry.currentSeason = season ?? undefined; - entry.currentEpisode = episode ?? undefined; - entry.totalSeasons = totalSeasons ?? undefined; - entry.totalEpisodes = totalEpisodes ?? undefined; - await this.dataManager.updateAirtimeEntry(entry); - - // Sync back to Watchlist title - const currentTitle = this.dataManager.getTitle(entry.titleId); - if (currentTitle) { - let changed = false; - if (totalEpisodes !== null && totalEpisodes !== currentTitle.totalEpisodes) { - currentTitle.totalEpisodes = totalEpisodes; - changed = true; - } - // Bug 2: sync releaseDate when schedule is 'once' - if (schedule.recurrence === 'once') { - const newDate = schedule.releaseDate ?? null; - if (currentTitle.releaseDate !== newDate) { - currentTitle.releaseDate = newDate; - changed = true; - } - } - if (changed) await this.dataManager.updateTitle(currentTitle); - } - - this.render(); - }, - ).open(); + if (r.source === 'reading') { + this.openEditReadingSchedule(entry); + } else { + this.openEditWatchSchedule(entry); + } }); - // Delete — with confirmation (Feature 1) + // Delete — with confirmation const deleteBtn = actions.createEl('button', { - cls: 'wl-airtime-action-btn wl-airtime-action-btn-delete', + cls: 'wl-airtime-action-btn wl-airtime-action-btn-delete wl-btn-danger', text: '✕', }); deleteBtn.title = 'Remove from upcoming'; deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); - new ConfirmModal(this.plugin.app, `Remove "${title.title}" from Upcoming?`, () => { + new ConfirmModal(this.plugin.app, `Remove "${r.title}" from Upcoming?`, () => { void this.dataManager.removeAirtimeEntry(entry.id).then(() => this.render()); }).open(); }); } + + private openEditWatchSchedule(entry: AirtimeEntry): void { + const title = this.dataManager.getTitle(entry.titleId); + if (!title) return; + new AirtimeScheduleModal( + this.plugin.app, + title, + { ...entry.schedule }, + entry.currentSeason ?? null, + entry.currentEpisode ?? null, + entry.totalSeasons ?? null, + entry.totalEpisodes ?? null, + async (schedule, season, episode, totalSeasons, totalEpisodes) => { + entry.schedule = schedule; + entry.currentSeason = season ?? undefined; + entry.currentEpisode = episode ?? undefined; + entry.totalSeasons = totalSeasons ?? undefined; + entry.totalEpisodes = totalEpisodes ?? undefined; + await this.dataManager.updateAirtimeEntry(entry); + + // Sync back to Watchlist title + const currentTitle = this.dataManager.getTitle(entry.titleId); + if (currentTitle) { + let changed = false; + if (totalEpisodes !== null && totalEpisodes !== currentTitle.totalEpisodes) { + currentTitle.totalEpisodes = totalEpisodes; + changed = true; + } + // Sync releaseDate when schedule is 'once' + if (schedule.recurrence === 'once') { + const newDate = schedule.releaseDate ?? null; + if (currentTitle.releaseDate !== newDate) { + currentTitle.releaseDate = newDate; + changed = true; + } + } + if (changed) await this.dataManager.updateTitle(currentTitle); + } + + this.render(); + }, + ).open(); + } + + private openEditReadingSchedule(entry: AirtimeEntry): void { + const kind = entry.readingKind ?? 'book'; + const item: Book | Manga | undefined = + kind === 'book' ? this.readingDataManager.getBook(entry.titleId) : this.readingDataManager.getManga(entry.titleId); + if (!item) return; + new ReadingScheduleModal( + this.plugin.app, + item, + kind, + { ...entry.schedule }, + entry.currentSeason ?? null, // volume + entry.currentEpisode ?? null, // chapter + entry.totalSeasons ?? null, // total volumes + entry.totalEpisodes ?? null, // total chapters + async (schedule, volume, chapter, totalVolumes, totalChapters) => { + entry.schedule = schedule; + entry.currentSeason = volume ?? undefined; + entry.currentEpisode = chapter ?? undefined; + entry.totalSeasons = totalVolumes ?? undefined; + entry.totalEpisodes = totalChapters ?? undefined; + await this.dataManager.updateAirtimeEntry(entry); + await this.syncReadingItemFromSchedule(entry.titleId, kind, schedule, totalVolumes, totalChapters); + this.render(); + }, + ).open(); + } } // ── AirtimeScheduleModal ────────────────────────────────────────────────────── @@ -887,7 +1140,7 @@ class AirtimeScheduleModal extends Modal { this.schedule = existingSchedule ? { ...existingSchedule } : { recurrence: isMovie ? 'once' : 'weekly' }; - // Fix 9: pre-fill releaseDate from title if the schedule doesn't have one yet + // Pre-fill releaseDate from title if the schedule doesn't have one yet if (!this.schedule.releaseDate && title.releaseDate && /^\d{4}-\d{2}-\d{2}$/.test(title.releaseDate)) { this.schedule.releaseDate = title.releaseDate; } @@ -979,11 +1232,11 @@ class AirtimeScheduleModal extends Modal { ? this.schedule.releaseDate.split('-').reverse().join('/') : ''; dateInput.addEventListener('change', () => { - const parts = dateInput.value.split('/'); - if (parts.length === 3) { - this.schedule.recurrence = 'once'; - this.schedule.releaseDate = `${parts[2]}-${parts[1]}-${parts[0]}`; - } + const parsed = parseDateInput(dateInput.value); + if (parsed) { + this.schedule.recurrence = 'once'; + this.schedule.releaseDate = parsed; + } }); makeTimeRow('Time (optional)', content); @@ -1019,10 +1272,10 @@ class AirtimeScheduleModal extends Modal { ? this.schedule.releaseDate.split('-').reverse().join('/') : ''; inp.addEventListener('change', () => { - const parts = inp.value.split('/'); - if (parts.length === 3) { - this.schedule.releaseDate = `${parts[2]}-${parts[1]}-${parts[0]}`; - } + const parsed = parseDateInput(inp.value); + if (parsed) { + this.schedule.releaseDate = parsed; + } }); } @@ -1078,7 +1331,7 @@ class AirtimeScheduleModal extends Modal { if (this.currentEpisode !== null) epInput.value = String(this.currentEpisode); epInput.addEventListener('input', () => { this.currentEpisode = parseInt(epInput.value) || null; }); - // ── Feature 4: Total seasons + total episodes ──────────────────────────── + // ── Total seasons + total episodes ─────────────────────────────────────── const totSeasRow = makeRow('Total seasons'); const totSeasInput = totSeasRow.createEl('input', { cls: 'wl-modal-input wl-modal-input-sm', @@ -1095,7 +1348,7 @@ class AirtimeScheduleModal extends Modal { if (this.totalEpisodes !== null) totEpInput.value = String(this.totalEpisodes); totEpInput.addEventListener('input', () => { this.totalEpisodes = parseInt(totEpInput.value) || null; }); - // Static hint note (always visible per spec) + // Static hint note (always visible) content.createDiv({ cls: 'wl-modal-info wl-schedule-hint', text: 'Titles with 0 or 1 total episodes will be treated as a single release date, like a movie.', diff --git a/src/ApiService.ts b/src/ApiService.ts index a407fbf..6924142 100644 --- a/src/ApiService.ts +++ b/src/ApiService.ts @@ -2,6 +2,7 @@ import { requestUrl } from 'obsidian'; import type { AnimeSearchResult, MediaSearchResult, + WatchLogTitle, JikanAnime, OmdbSearchResponse, OmdbDetailResponse, @@ -12,20 +13,143 @@ import type { TmdbExternalIds, TmdbFindResult, Season, + AniListMedia, + AniListSearchResponse, + AniListMediaResponse, } from './types'; const JIKAN_BASE = 'https://api.jikan.moe/v4'; const OMDB_BASE = 'https://www.omdbapi.com'; const TMDB_BASE = 'https://api.themoviedb.org/3'; +const ANILIST_ENDPOINT = 'https://graphql.anilist.co'; +const ANILIST_RATE_LIMIT_MS = 700; +const GOOGLE_BOOKS_BASE = 'https://www.googleapis.com/books/v1'; +const JIKAN_RATE_LIMIT_MS = 400; const API_TIMEOUT_MS = 8000; +export interface BookSearchResult { + title: string; + author: string; + year: number; + totalPages: number; + coverUrl: string; + googleBooksId: string; + releaseDate: string; + url: string; +} + +export interface MangaSearchResult { + malId: number; + title: string; + author: string; + year: number; + totalChapters: number; + totalVolumes: number; + coverUrl: string; + releaseDate: string; + url: string; +} + +interface GoogleVolumeInfo { + title?: string; + subtitle?: string; + authors?: string[]; + publishedDate?: string; + pageCount?: number; + imageLinks?: { + smallThumbnail?: string; + thumbnail?: string; + small?: string; + medium?: string; + large?: string; + extraLarge?: string; + }; + infoLink?: string; + canonicalVolumeLink?: string; +} + +interface GoogleVolume { + id?: string; + volumeInfo?: GoogleVolumeInfo; +} + +interface GoogleBooksResponse { + items?: GoogleVolume[]; +} + +interface JikanMangaItem { + mal_id: number; + title: string; + title_english?: string | null; + authors?: Array<{ name: string }>; + chapters?: number | null; + volumes?: number | null; + published?: { from?: string | null } | null; + images?: { jpg?: { image_url?: string | null } | null } | null; + url?: string | null; +} + +/** Why a Google Books request failed — used to surface accurate, non-misleading UI messages. */ +export type GoogleBooksErrorReason = 'no-key' | 'rate-limited' | 'http' | 'parse' | 'network'; + +export class GoogleBooksError extends Error { + reason: GoogleBooksErrorReason; + status?: number; + constructor(reason: GoogleBooksErrorReason, message: string, status?: number) { + super(message); + this.name = 'GoogleBooksError'; + this.reason = reason; + this.status = status; + } +} + +/** Maps any error from a Google Books call to a short, user-facing explanation. */ +export function googleBooksErrorMessage(err: unknown): string { + if (err instanceof GoogleBooksError) { + switch (err.reason) { + case 'no-key': + return 'Google Books API key required — add one in Settings → API → Books.'; + case 'rate-limited': + return 'Google Books rate limit reached — your API key is missing or over quota.'; + case 'http': + return `Google Books request failed (HTTP ${err.status ?? '?'}).`; + case 'parse': + return 'Google Books returned an unreadable response.'; + default: + return 'Google Books request failed — check your connection.'; + } + } + return 'Google Books request failed — check your connection.'; +} + export class ApiService { private omdbApiKey: string; private tmdbApiKey: string; + private googleBooksApiKey: string; + private anilistLastRequest = 0; + private anilistQueue: Promise<void> = Promise.resolve(); + private jikanLastRequest = 0; + private jikanQueue: Promise<void> = Promise.resolve(); - constructor(omdbApiKey: string, tmdbApiKey = '') { + private throttleJikan<T>(task: () => Promise<T>): Promise<T> { + const run = async (): Promise<T> => { + const now = Date.now(); + const gap = now - this.jikanLastRequest; + if (gap < JIKAN_RATE_LIMIT_MS) { + await new Promise<void>((r) => window.setTimeout(r, JIKAN_RATE_LIMIT_MS - gap)); + } + this.jikanLastRequest = Date.now(); + return task(); + }; + const next = this.jikanQueue.then(run, run); + this.jikanQueue = next.then(() => undefined, () => undefined); + return next; + } + + constructor(omdbApiKey: string, tmdbApiKey = '', googleBooksApiKey = '') { this.omdbApiKey = omdbApiKey; this.tmdbApiKey = tmdbApiKey; + this.googleBooksApiKey = googleBooksApiKey; } setOmdbKey(key: string): void { @@ -36,16 +160,178 @@ export class ApiService { this.tmdbApiKey = key; } + setGoogleBooksKey(key: string): void { + this.googleBooksApiKey = key; + } + + hasGoogleBooksKey(): boolean { + return this.googleBooksApiKey.trim().length > 0; + } + private tmdbHeaders(): Record<string, string> { return { 'Authorization': `Bearer ${this.tmdbApiKey}` }; } private async fetchWithTimeout(url: string, headers?: Record<string, string>): Promise<unknown> { - const timeoutPromise = new Promise<never>((_, reject) => - window.setTimeout(() => reject(new Error('Request timed out')), API_TIMEOUT_MS), - ); - const fetchPromise = requestUrl({ url, headers }).then((r) => r.json as unknown); - return Promise.race([fetchPromise, timeoutPromise]); + let timer: number | null = null; + const timeoutPromise = new Promise<never>((_, reject) => { + timer = window.setTimeout(() => reject(new Error('Request timed out')), API_TIMEOUT_MS); + }); + try { + const fetchPromise = requestUrl({ url, headers }).then((r) => r.json as unknown); + return await Promise.race([fetchPromise, timeoutPromise]); + } finally { + if (timer !== null) window.clearTimeout(timer); + } + } + + private async postJsonWithTimeout( + url: string, + body: unknown, + headers?: Record<string, string>, + ): Promise<unknown> { + let timer: number | null = null; + const timeoutPromise = new Promise<never>((_, reject) => { + timer = window.setTimeout(() => reject(new Error('Request timed out')), API_TIMEOUT_MS); + }); + try { + const fetchPromise = requestUrl({ + url, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(headers ?? {}) }, + body: JSON.stringify(body), + }).then((r) => r.json as unknown); + return await Promise.race([fetchPromise, timeoutPromise]); + } finally { + if (timer !== null) window.clearTimeout(timer); + } + } + + // ─── AniList (GraphQL) ─────────────────────────────────────────────────────── + + /** Serializes AniList requests with a minimum gap of ANILIST_RATE_LIMIT_MS between them. */ + private throttleAniList<T>(task: () => Promise<T>): Promise<T> { + const run = async (): Promise<T> => { + const now = Date.now(); + const gap = now - this.anilistLastRequest; + if (gap < ANILIST_RATE_LIMIT_MS) { + await new Promise<void>((r) => window.setTimeout(r, ANILIST_RATE_LIMIT_MS - gap)); + } + this.anilistLastRequest = Date.now(); + return task(); + }; + const next = this.anilistQueue.then(run, run); + this.anilistQueue = next.then(() => undefined, () => undefined); + return next; + } + + private stripHtml(s: string): string { + return s + .replace(/<br\s*\/?>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .trim(); + } + + private formatAniListDate(d: { year?: number | null; month?: number | null; day?: number | null } | null | undefined): string { + if (!d?.year) return ''; + const yyyy = String(d.year); + const mm = String(d.month ?? 1).padStart(2, '0'); + const dd = String(d.day ?? 1).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; + } + + private mapAniListMedia(media: AniListMedia): AnimeSearchResult { + const name = media.title?.english || media.title?.romaji || media.title?.native || ''; + const episodes = media.episodes ?? 0; + const duration = media.duration ?? 24; + const seasons: Season[] = + episodes > 0 ? [{ name: 'Season 1', episodes, offset: 0 }] : []; + const description = media.description ? this.stripHtml(media.description) : ''; + return { + malId: 0, + anilistId: media.id, + title: name, + episodes, + duration, + releaseDate: this.formatAniListDate(media.startDate), + url: `https://anilist.co/anime/${media.id}`, + seasons, + description, + averageScore: media.averageScore ?? undefined, + genres: media.genres ?? undefined, + posterUrl: media.coverImage?.large ?? media.coverImage?.medium ?? undefined, + }; + } + + async searchAniList(query: string): Promise<AnimeSearchResult[]> { + const gql = ` + query ($search: String) { + Page(page: 1, perPage: 10) { + media(search: $search, type: ANIME) { + id + title { romaji english native } + episodes + duration + status + season + seasonYear + startDate { year month day } + averageScore + popularity + coverImage { large medium } + description + genres + } + } + } + `; + try { + const res = await this.throttleAniList(() => + this.postJsonWithTimeout(ANILIST_ENDPOINT, { query: gql, variables: { search: query } }), + ); + const data = res as AniListSearchResponse; + const media = data.data?.Page?.media ?? []; + return media.map((m) => this.mapAniListMedia(m)); + } catch { + return []; + } + } + + async getAniListById(anilistId: number): Promise<AniListMedia | null> { + const gql = ` + query ($id: Int) { + Media(id: $id, type: ANIME) { + id + title { romaji english native } + episodes + duration + status + averageScore + popularity + coverImage { large } + nextAiringEpisode { + airingAt + episode + timeUntilAiring + } + } + } + `; + try { + const res = await this.throttleAniList(() => + this.postJsonWithTimeout(ANILIST_ENDPOINT, { query: gql, variables: { id: anilistId } }), + ); + const data = res as AniListMediaResponse; + return data.data?.Media ?? null; + } catch { + return null; + } } // ─── Jikan / MAL ───────────────────────────────────────────────────────────── @@ -324,6 +610,313 @@ export class ApiService { } } + // ─── Community ratings ─────────────────────────────────────────────────────── + + private parseOmdbVotes(s: string | undefined): number { + if (!s || s === 'N/A') return 0; + const n = parseInt(s.replace(/,/g, ''), 10); + return isNaN(n) ? 0 : n; + } + + private parseOmdbRating(s: string | undefined): number { + if (!s || s === 'N/A') return 0; + const n = parseFloat(s); + return isNaN(n) ? 0 : n; + } + + private extractImdbId(externalLink: string): string { + const m = externalLink.match(/tt\d+/); + return m ? m[0] : ''; + } + + private async fetchMalRating(malId: number): Promise<{ rating: number; votes: number } | null> { + try { + const url = `${JIKAN_BASE}/anime/${malId}`; + const data = (await this.fetchWithTimeout(url)) as { + data?: { score?: number | null; scored_by?: number | null }; + }; + const rating = data.data?.score ?? 0; + const votes = data.data?.scored_by ?? 0; + if (!rating && !votes) return null; + return { rating, votes }; + } catch { + return null; + } + } + + private async fetchAniListRating( + anilistId: number, + ): Promise<{ rating: number; votes: number } | null> { + const media = await this.getAniListById(anilistId); + if (!media) return null; + const rating = media.averageScore ?? 0; + const votes = media.popularity ?? 0; + if (!rating && !votes) return null; + return { rating, votes }; + } + + private async fetchOmdbRating(imdbId: string): Promise<{ rating: number; votes: number } | null> { + if (!this.omdbApiKey) return null; + try { + const url = `${OMDB_BASE}/?i=${encodeURIComponent(imdbId)}&apikey=${encodeURIComponent(this.omdbApiKey)}`; + const data = (await this.fetchWithTimeout(url)) as { + Response?: string; + imdbRating?: string; + imdbVotes?: string; + }; + if (data.Response === 'False') return null; + const rating = this.parseOmdbRating(data.imdbRating); + const votes = this.parseOmdbVotes(data.imdbVotes); + if (!rating && !votes) return null; + return { rating, votes }; + } catch { + return null; + } + } + + private async fetchTmdbRatingByImdb( + imdbId: string, + ): Promise<{ rating: number; votes: number } | null> { + if (!this.tmdbApiKey) return null; + try { + const url = `${TMDB_BASE}/find/${encodeURIComponent(imdbId)}?external_source=imdb_id`; + const data = (await this.fetchWithTimeout(url, this.tmdbHeaders())) as { + movie_results?: Array<{ vote_average?: number; vote_count?: number }>; + tv_results?: Array<{ vote_average?: number; vote_count?: number }>; + }; + const hit = + (data.movie_results && data.movie_results[0]) || + (data.tv_results && data.tv_results[0]) || + null; + if (!hit) return null; + const rating = hit.vote_average ?? 0; + const votes = hit.vote_count ?? 0; + if (!rating && !votes) return null; + return { rating, votes }; + } catch { + return null; + } + } + + /** + * Picks the right API based on the title's stored IDs and returns the + * community rating + vote count + source label. Returns null on failure + * or if no suitable API is available. + */ + async fetchCommunityRating( + title: WatchLogTitle, + animeApiSource: 'jikan' | 'anilist' = 'jikan', + apiGroup: 'anime' | 'movie' | '' = '', + ): Promise<{ rating: number; votes: number; source: 'mal' | 'anilist' | 'imdb' | 'tmdb' } | null> { + const hasMal = (title.malId ?? 0) > 0; + const hasAniList = (title.anilistId ?? 0) > 0; + const resolvedGroup: 'anime' | 'movie' | '' = + apiGroup || (title.type === 'Anime' ? 'anime' : title.type === 'Movie' || title.type === 'TV Show' || title.type === 'TvShow' ? 'movie' : ''); + + if (resolvedGroup === '') return null; + + // Anime API group is bound to anime APIs. + if (resolvedGroup === 'anime') { + if (animeApiSource === 'anilist') { + if (hasAniList) { + const r = await this.fetchAniListRating(title.anilistId!); + if (r) return { ...r, source: 'anilist' }; + } + if (hasMal) { + const r = await this.fetchMalRating(title.malId!); + if (r) return { ...r, source: 'mal' }; + } + } else { + if (hasMal) { + const r = await this.fetchMalRating(title.malId!); + if (r) return { ...r, source: 'mal' }; + } + if (hasAniList) { + const r = await this.fetchAniListRating(title.anilistId!); + if (r) return { ...r, source: 'anilist' }; + } + } + // Last-resort: anime without any anime-api ID falls through to IMDb/TMDB. + } + + const imdbId = this.extractImdbId(title.externalLink ?? ''); + if (imdbId) { + if (this.omdbApiKey) { + const r = await this.fetchOmdbRating(imdbId); + if (r) return { ...r, source: 'imdb' }; + } + if (this.tmdbApiKey) { + const r = await this.fetchTmdbRatingByImdb(imdbId); + if (r) return { ...r, source: 'tmdb' }; + } + } + + return null; + } + + // ─── Google Books (Books) ───────────────────────────────────────────────────── + + /** + * Picks the largest available cover from a volume's imageLinks, falling down the + * chain extraLarge → large → medium → small → thumbnail → smallThumbnail. Returns + * a direct (200) HTTPS image URL — upgraded from http and stripped of the page-curl + * effect — so no local caching is needed. Empty string when no image is present. + */ + private googleCoverUrl(info: GoogleVolumeInfo): string { + const links = info.imageLinks; + if (!links) return ''; + const raw = + links.extraLarge ?? + links.large ?? + links.medium ?? + links.small ?? + links.thumbnail ?? + links.smallThumbnail ?? + ''; + if (!raw) return ''; + return raw.replace(/^http:/, 'https:').replace(/&edge=curl/g, ''); + } + + /** Normalizes Google's `publishedDate` (YYYY | YYYY-MM | YYYY-MM-DD) to a YYYY-MM-DD storage value. */ + private googleReleaseDate(published?: string): string { + if (!published) return ''; + if (/^\d{4}-\d{2}-\d{2}$/.test(published)) return published; + if (/^\d{4}-\d{2}$/.test(published)) return `${published}-01`; + if (/^\d{4}$/.test(published)) return `${published}-01-01`; + return published; + } + + private mapGoogleVolume(item: GoogleVolume): BookSearchResult { + const info = item.volumeInfo ?? {}; + const published = info.publishedDate ?? ''; + const year = published ? parseInt(published.slice(0, 4), 10) || 0 : 0; + const fullTitle = info.subtitle ? `${info.title ?? ''}: ${info.subtitle}` : (info.title ?? ''); + return { + title: fullTitle, + author: (info.authors ?? []).join(', '), + year, + totalPages: info.pageCount ?? 0, + coverUrl: this.googleCoverUrl(info), + googleBooksId: item.id ?? '', + releaseDate: this.googleReleaseDate(published), + url: info.infoLink ?? info.canonicalVolumeLink ?? '', + }; + } + + /** + * Google Books requests must always carry the user's API key — the anonymous + * endpoint shares a global quota and returns 429. Throws a typed GoogleBooksError + * (no-key / rate-limited / http / parse / network) so callers can show an accurate + * message instead of a generic "check your connection". `path` is everything after + * the base, e.g. `/volumes?q=...`; the `key` param is appended here. + */ + private async googleBooksFetch(path: string): Promise<unknown> { + const key = this.googleBooksApiKey.trim(); + if (!key) { + throw new GoogleBooksError('no-key', 'Google Books API key not set'); + } + const sep = path.includes('?') ? '&' : '?'; + const url = `${GOOGLE_BOOKS_BASE}${path}${sep}key=${encodeURIComponent(key)}`; + let timer: number | null = null; + const timeoutPromise = new Promise<never>((_, reject) => { + timer = window.setTimeout(() => reject(new GoogleBooksError('network', 'Request timed out')), API_TIMEOUT_MS); + }); + try { + const reqPromise = requestUrl({ url, throw: false }).then((r) => { + // 429 (rate limit) and 403 (quota/keyInvalid) both point at the key, not the network. + if (r.status === 429 || r.status === 403) { + throw new GoogleBooksError('rate-limited', `Google Books returned ${r.status}`, r.status); + } + if (r.status < 200 || r.status >= 300) { + throw new GoogleBooksError('http', `Google Books returned ${r.status}`, r.status); + } + try { + return r.json as unknown; + } catch { + throw new GoogleBooksError('parse', 'Failed to parse Google Books response'); + } + }); + return await Promise.race([reqPromise, timeoutPromise]); + } catch (err) { + if (err instanceof GoogleBooksError) throw err; + throw new GoogleBooksError('network', (err as Error)?.message ?? 'Network error'); + } finally { + if (timer !== null) window.clearTimeout(timer); + } + } + + /** Throws GoogleBooksError on failure (no key, rate limit, etc.) — see googleBooksErrorMessage. */ + async searchGoogleBooks(query: string): Promise<BookSearchResult[]> { + const data = (await this.googleBooksFetch( + `/volumes?q=${encodeURIComponent(query)}&maxResults=10`, + )) as GoogleBooksResponse; + return (data.items ?? []).slice(0, 10).map((item) => this.mapGoogleVolume(item)); + } + + /** Resolves on success, throws GoogleBooksError on failure (so the test can report the real cause). */ + async checkGoogleBooksConnection(): Promise<boolean> { + const data = (await this.googleBooksFetch('/volumes?q=tolkien&maxResults=1')) as GoogleBooksResponse; + return Array.isArray(data.items); + } + + // ─── Jikan (Manga) ────────────────────────────────────────────────────────── + + private mapJikanManga(m: JikanMangaItem): MangaSearchResult { + const yearStr = m.published?.from ?? ''; + const year = yearStr ? parseInt(yearStr.slice(0, 4), 10) || 0 : 0; + return { + malId: m.mal_id, + title: m.title_english ?? m.title, + author: (m.authors ?? []).map((a) => a.name).join(', '), + year, + totalChapters: m.chapters ?? 0, + totalVolumes: m.volumes ?? 0, + coverUrl: m.images?.jpg?.image_url ?? '', + releaseDate: m.published?.from ? (m.published.from.split('T')[0] ?? '') : '', + url: m.url ?? `https://myanimelist.net/manga/${m.mal_id}`, + }; + } + + async searchManga(query: string): Promise<MangaSearchResult[]> { + const url = `${JIKAN_BASE}/manga?q=${encodeURIComponent(query)}&limit=10`; + try { + const data = (await this.throttleJikan(() => this.fetchWithTimeout(url))) as { data?: JikanMangaItem[] }; + return (data.data ?? []).map((m) => this.mapJikanManga(m)); + } catch { + return []; + } + } + + /** + * Detail lookup for a single manga (Jikan /manga/{mal_id}). Search-list results + * return null chapters/volumes for ongoing titles, so totals are fetched here on + * selection. Shares the existing Jikan throttle. (Jikan also exposes a richer + * `/manga/{id}/full` endpoint not used here.) + */ + async getMangaByMalId(malId: number): Promise<MangaSearchResult | null> { + const url = `${JIKAN_BASE}/manga/${malId}`; + try { + const data = (await this.throttleJikan(() => this.fetchWithTimeout(url))) as { data?: JikanMangaItem }; + if (!data.data) return null; + return this.mapJikanManga(data.data); + } catch { + return null; + } + } + + /** + * Detail lookup for a single Google Books volume. The search endpoint sometimes + * omits pageCount and only returns thumbnail-sized images, so on selection this + * resolves the full volume (better page count + larger cover). Returns null when + * the volume can't be fetched. + */ + async getGoogleBookById(volumeId: string): Promise<BookSearchResult | null> { + if (!volumeId) return null; + const item = (await this.googleBooksFetch(`/volumes/${encodeURIComponent(volumeId)}`)) as GoogleVolume; + if (!item || !item.id) return null; + return this.mapGoogleVolume(item); + } + async checkTmdbConnection(): Promise<boolean> { if (!this.tmdbApiKey) return false; try { diff --git a/src/CommunityRating.ts b/src/CommunityRating.ts new file mode 100644 index 0000000..94b2725 --- /dev/null +++ b/src/CommunityRating.ts @@ -0,0 +1,251 @@ +import { Notice } from 'obsidian'; +import type WatchLogPlugin from './main'; +import type { WatchLogTitle, AnimeSearchResult } from './types'; +import { formatVoteCount, getApiGroupForType } from './types'; + +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; + +const SOURCE_LABELS: Record<string, string> = { + imdb: 'IMDb', + mal: 'MAL', + anilist: 'AniList', + tmdb: 'TMDB', +}; + +function formatScore(rating: number, source: string): string { + if (source === 'anilist') return `${Math.round(rating)}%`; + return rating.toFixed(rating >= 10 ? 0 : 1); +} + +export function renderCommunityRating( + parent: HTMLElement, + plugin: WatchLogPlugin, + titleId: string, + onUpdated?: () => void, +): void { + const title = plugin.dataManager.getTitle(titleId); + if (!title) return; + + parent.createSpan({ cls: 'wl-rating-divider' }); + + const badge = parent.createDiv({ cls: 'wl-community-badge' }); + const hasRating = !!title.communitySource && (title.communityRating ?? 0) > 0; + + if (hasRating) { + const source = title.communitySource ?? ''; + const label = SOURCE_LABELS[source] ?? source.toUpperCase(); + badge.createSpan({ + cls: `wl-community-source wl-community-source--${source}`, + text: label, + }); + badge.createSpan({ + cls: 'wl-community-score', + text: formatScore(title.communityRating ?? 0, source), + }); + if ((title.communityVotes ?? 0) > 0) { + badge.createSpan({ + cls: 'wl-community-votes', + text: `(${formatVoteCount(title.communityVotes ?? 0)})`, + }); + } + } else { + badge.createSpan({ cls: 'wl-community-empty', text: 'No community rating' }); + } + + const refreshBtn = badge.createEl('button', { + cls: 'wl-community-refresh', + text: '⟳', + attr: { title: 'Refresh community rating', type: 'button' }, + }); + refreshBtn.addEventListener('click', (e) => { + e.stopPropagation(); + void refreshCommunityRating(plugin, titleId, refreshBtn, true).then(() => { + if (onUpdated) onUpdated(); + }); + }); +} + +/** + * Auto-refresh: triggers a background fetch when data is stale (≥30 days) + * or has never been fetched. For anime titles, only uses the API that + * matches the current animeApiSource setting AND that has its ID already + * present on the title — no search-by-name in the background. For non-anime + * titles, uses the standard fetchCommunityRating routing. + */ +const pendingRatingRefreshes: Set<string> = new Set(); + +export function maybeAutoRefreshCommunityRating( + plugin: WatchLogPlugin, + titleId: string, + onUpdated?: () => void, +): void { + const title = plugin.dataManager.getTitle(titleId); + if (!title) return; + const last = title.communityRatingLastFetched ?? ''; + if (last) { + const ts = Date.parse(last); + if (!isNaN(ts) && Date.now() - ts < THIRTY_DAYS_MS) return; + } + if (!hasFetchableSource(title, plugin.settings.animeApiSource ?? 'jikan', plugin.settings.typeApiMapping)) return; + if (pendingRatingRefreshes.has(titleId)) return; + pendingRatingRefreshes.add(titleId); + void backgroundRefresh(plugin, titleId) + .then((ok) => { + if (ok && onUpdated) onUpdated(); + }) + .finally(() => { + pendingRatingRefreshes.delete(titleId); + }); +} + +function hasFetchableSource( + title: WatchLogTitle, + animeApiSource: 'jikan' | 'anilist', + mapping: Record<string, 'anime' | 'movie' | ''> | undefined, +): boolean { + const group = getApiGroupForType(title.type, mapping); + if (group === '') return false; + if (group === 'anime') { + if (animeApiSource === 'anilist') return (title.anilistId ?? 0) > 0; + return (title.malId ?? 0) > 0; + } + return !!(title.externalLink ?? '').match(/tt\d+/); +} + +async function backgroundRefresh(plugin: WatchLogPlugin, titleId: string): Promise<boolean> { + const title = plugin.dataManager.getTitle(titleId); + if (!title) return false; + const animeSource = plugin.settings.animeApiSource ?? 'jikan'; + const group = getApiGroupForType(title.type, plugin.settings.typeApiMapping); + const result = await plugin.apiService.fetchCommunityRating(title, animeSource, group); + if (!result) return false; + plugin.dataManager.updateCommunityRating(titleId, result.rating, result.votes, result.source); + return true; +} + +/** + * Manual refresh: + * - For anime: respects current animeApiSource. If the title is missing + * the ID for that source, searches by name on that API; on a reasonable + * match, stores the new ID + updates externalLink to the new source. + * - For non-anime: standard fetchCommunityRating. + * Saves changes via updateTitle (full save) when IDs/externalLink change; + * otherwise the lighter updateCommunityRating path. + */ +export async function refreshCommunityRating( + plugin: WatchLogPlugin, + titleId: string, + btnEl: HTMLElement | null, + showNotice: boolean, +): Promise<boolean> { + let title = plugin.dataManager.getTitle(titleId); + if (!title) return false; + if (btnEl) btnEl.addClass('is-loading'); + try { + const animeSource = plugin.settings.animeApiSource ?? 'jikan'; + const group = getApiGroupForType(title.type, plugin.settings.typeApiMapping); + + if (group === '') { + if (showNotice) { + new Notice(`No API configured for type "${title.type}". Configure it in Settings → API.`); + } + return false; + } + + if (group === 'anime') { + const wantsAniList = animeSource === 'anilist'; + const hasPreferredId = wantsAniList + ? (title.anilistId ?? 0) > 0 + : (title.malId ?? 0) > 0; + + if (!hasPreferredId) { + // Search by name on the preferred API. + const matched = await searchForMatch(plugin, title, wantsAniList); + if (!matched) { + if (showNotice) { + new Notice(`Could not find matching title on ${wantsAniList ? 'AniList' : 'MAL'}.`); + } + return false; + } + // Persist the new ID + externalLink before fetching the rating. + const fresh = plugin.dataManager.getTitle(titleId); + if (!fresh) return false; + if (wantsAniList) { + fresh.anilistId = matched.anilistId ?? 0; + fresh.externalLink = `https://anilist.co/anime/${fresh.anilistId}`; + } else { + fresh.malId = matched.malId; + fresh.externalLink = `https://myanimelist.net/anime/${fresh.malId}`; + } + await plugin.dataManager.updateTitle(fresh); + title = fresh; + } else { + // We already have the right ID — still make sure externalLink + // matches the preferred source so the UI link is consistent. + const expectedLink = wantsAniList + ? `https://anilist.co/anime/${title.anilistId}` + : `https://myanimelist.net/anime/${title.malId}`; + if (title.externalLink !== expectedLink) { + title.externalLink = expectedLink; + await plugin.dataManager.updateTitle(title); + } + } + } + + const result = await plugin.apiService.fetchCommunityRating(title, animeSource, group); + if (!result) { + if (showNotice) new Notice('Could not fetch community rating.'); + return false; + } + plugin.dataManager.updateCommunityRating( + titleId, + result.rating, + result.votes, + result.source, + ); + if (showNotice) { + const label = SOURCE_LABELS[result.source] ?? result.source; + new Notice(`Rating updated from ${label}.`); + } + return true; + } finally { + if (btnEl) btnEl.removeClass('is-loading'); + } +} + +function normalizeName(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); +} + +function isReasonableMatch(needle: string, hay: string): boolean { + const a = normalizeName(needle); + const b = normalizeName(hay); + if (!a || !b) return false; + if (a === b) return true; + if (a.includes(b) || b.includes(a)) return true; + const aWords = new Set(a.split(' ').filter((w) => w.length > 2)); + const bWords = new Set(b.split(' ').filter((w) => w.length > 2)); + if (aWords.size === 0 || bWords.size === 0) return false; + let shared = 0; + for (const w of aWords) if (bWords.has(w)) shared++; + const minSize = Math.min(aWords.size, bWords.size); + return shared / minSize >= 0.5; +} + +async function searchForMatch( + plugin: WatchLogPlugin, + title: WatchLogTitle, + wantsAniList: boolean, +): Promise<AnimeSearchResult | null> { + const results = wantsAniList + ? await plugin.apiService.searchAniList(title.title) + : await plugin.apiService.searchAnime(title.title); + if (!results.length) return null; + const top = results[0]!; + if (isReasonableMatch(title.title, top.title)) return top; + // Try a few more results in case the first isn't the best. + for (let i = 1; i < Math.min(results.length, 5); i++) { + if (isReasonableMatch(title.title, results[i]!.title)) return results[i]!; + } + return null; +} diff --git a/src/CsvModal.ts b/src/CsvModal.ts index 64052ae..5258ea5 100644 --- a/src/CsvModal.ts +++ b/src/CsvModal.ts @@ -28,28 +28,44 @@ function titlesToCsv(titles: WatchLogTitle[]): string { return [header, ...rows].join('\n'); } -function parseCsvRow(row: string): string[] { - const fields: string[] = []; +/** Full CSV parser: handles quoted fields with embedded newlines, escaped quotes, comma delimiters. */ +function parseCSV(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; let cur = ''; let inQuotes = false; - for (let i = 0; i < row.length; i++) { - const ch = row[i]; - if (ch === '"') { - if (inQuotes && row[i + 1] === '"') { - cur += '"'; - i++; + // Normalize CRLF to LF + const t = text.replace(/\r\n?/g, '\n'); + for (let i = 0; i < t.length; i++) { + const ch = t[i]; + if (inQuotes) { + if (ch === '"') { + if (t[i + 1] === '"') { cur += '"'; i++; } + else { inQuotes = false; } } else { - inQuotes = !inQuotes; + cur += ch; } - } else if (ch === ',' && !inQuotes) { - fields.push(cur); - cur = ''; } else { - cur += ch; + if (ch === '"') { + inQuotes = true; + } else if (ch === ',') { + row.push(cur); cur = ''; + } else if (ch === '\n') { + row.push(cur); cur = ''; + rows.push(row); row = []; + } else { + cur += ch; + } } } - fields.push(cur); - return fields; + // Final field/row + if (cur.length > 0 || row.length > 0) { + row.push(cur); + rows.push(row); + } + // Drop trailing fully-empty rows + while (rows.length > 0 && rows[rows.length - 1]!.every((c) => c === '')) rows.pop(); + return rows; } // ── Date parsing ───────────────────────────────────────────────────────────── @@ -136,6 +152,7 @@ const WL_IMPORT_FIELDS: WlFieldDef[] = [ { key: 'dateFinished', label: 'Date Finished', autoDetect: ['finished', 'datefinished', 'date finished', 'date_finished', 'end date', 'finish date', 'completed date'] }, { key: 'releaseDate', label: 'Release Date', autoDetect: ['releasedate', 'release date', 'release_date', 'air date', 'airdate'] }, { key: 'externalLink', label: 'Link', autoDetect: ['link', 'externallink', 'external link', 'external_link', 'url'] }, + { key: 'notes', label: 'Notes', autoDetect: ['notes', 'note', 'comment', 'comments'] }, ]; function autoMap(headers: string[]): Record<string, string> { @@ -148,19 +165,17 @@ function autoMap(headers: string[]): Record<string, string> { } function applyMapping( - lines: string[], + rows: string[][], mapping: Record<string, string>, dataManager: DataManager, ): { entry: Partial<WatchLogTitle>; isDuplicate: boolean }[] { - if (lines.length < 2) return []; - const headerLine = lines[0] ?? ''; - const headers = parseCsvRow(headerLine).map((h) => h.trim()); + if (rows.length < 2) return []; + const headers = (rows[0] ?? []).map((h) => h.trim()); const existingTitles = dataManager.getTitles(); - return lines.slice(1) - .filter((l) => l.trim()) - .map((line) => { - const values = parseCsvRow(line); + return rows.slice(1) + .filter((r) => r.some((c) => c.trim())) + .map((values) => { const get = (fieldKey: string): string => { const colName = mapping[fieldKey]; if (!colName) return ''; @@ -179,7 +194,10 @@ function applyMapping( const priorityVal = get('priority'); if (priorityVal) entry.priority = priorityVal; const ratingVal = get('rating'); - if (ratingVal) entry.rating = parseFloat(ratingVal) || 0; + if (ratingVal) { + const r = parseFloat(ratingVal) || 0; + entry.rating = Math.max(0, Math.min(5, r)); + } const epsVal = get('totalEpisodes'); if (epsVal) entry.totalEpisodes = parseInt(epsVal) || 0; const durVal = get('episodeDuration'); @@ -192,11 +210,14 @@ function applyMapping( if (releaseDateRaw) { const d = parseCsvDate(releaseDateRaw); if (d) entry.releaseDate = d; } const linkVal = get('externalLink'); if (linkVal) entry.externalLink = linkVal; + const notesVal = get('notes'); + if (notesVal) entry.notes = notesVal; // Skip rows where all mapped fields resolved to empty const isEmpty = !entry.title?.trim() && !entry.type && !entry.status && !entry.rating && !entry.totalEpisodes && !entry.episodeDuration - && !entry.dateStarted && !entry.dateFinished && !entry.releaseDate && !entry.externalLink; + && !entry.dateStarted && !entry.dateFinished && !entry.releaseDate && !entry.externalLink + && !entry.notes; if (isEmpty) return null; const isDuplicate = !!existingTitles.find( @@ -233,7 +254,7 @@ export class CsvModal extends Modal { private selectedIds = new Set<string>(); // Import state — shared across steps - private csvLines: string[] = []; + private csvRows: string[][] = []; private csvHeaders: string[] = []; private columnMapping: Record<string, string> = {}; private importRows: { entry: Partial<WatchLogTitle>; isDuplicate: boolean; selected: boolean }[] = []; @@ -347,7 +368,7 @@ export class CsvModal extends Modal { const csv = titlesToCsv(toExport); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); - const a = document.createElement('a'); + const a = activeDocument.createElement('a'); const today = new Date(); const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; a.href = url; @@ -418,9 +439,8 @@ export class CsvModal extends Modal { const reader = new FileReader(); reader.onload = (ev) => { const text = ev.target?.result as string; - this.csvLines = text.split('\n'); - const headerLine = this.csvLines[0] ?? ''; - this.csvHeaders = parseCsvRow(headerLine).map((h) => h.trim()); + this.csvRows = parseCSV(text); + this.csvHeaders = (this.csvRows[0] ?? []).map((h) => h.trim()); this.columnMapping = autoMap(this.csvHeaders); this.showMappingStep(); }; @@ -474,7 +494,7 @@ export class CsvModal extends Modal { this.columnMapping[field.key] = selectEls[field.key]?.value ?? ''; } // Parse rows with current column mapping - this.importRows = applyMapping(this.csvLines, this.columnMapping, this.dataManager) + this.importRows = applyMapping(this.csvRows, this.columnMapping, this.dataManager) .map((r) => ({ ...r, selected: !r.isDuplicate })); // Check for unknown status/type values and non-numeric ratings @@ -502,13 +522,11 @@ export class CsvModal extends Modal { // Detect non-numeric rating values (r.entry.rating would be 0 from parseFloat on non-numeric) const ratingColName = this.columnMapping['rating']; if (ratingColName) { - const headerLine = this.csvLines[0] ?? ''; - const headers = parseCsvRow(headerLine).map((h) => h.trim()); + const headers = (this.csvRows[0] ?? []).map((h) => h.trim()); const ratingIdx = headers.indexOf(ratingColName); if (ratingIdx >= 0) { const lineIdx = this.importRows.indexOf(r); - const rawLine = this.csvLines[lineIdx + 1] ?? ''; - const rawValues = parseCsvRow(rawLine); + const rawValues = this.csvRows[lineIdx + 1] ?? []; const rawRating = (rawValues[ratingIdx] ?? '').trim(); if (rawRating && isNaN(parseFloat(rawRating)) && !seenRatings.has(rawRating)) { unknownRatings.push(rawRating); @@ -658,16 +676,13 @@ export class CsvModal extends Modal { } // Rating — map non-numeric value to numeric const rawRatingKey = Object.keys(this.ratingValueMap).find((k) => { - // Find if this row's original CSV rating matched this key const ratingColName = this.columnMapping['rating']; if (!ratingColName) return false; - const headerLine = this.csvLines[0] ?? ''; - const headers = parseCsvRow(headerLine).map((h) => h.trim()); + const headers = (this.csvRows[0] ?? []).map((h) => h.trim()); const ratingIdx = headers.indexOf(ratingColName); if (ratingIdx < 0) return false; const rowIdx = this.importRows.indexOf(r); - const rawLine = this.csvLines[rowIdx + 1] ?? ''; - const rawValues = parseCsvRow(rawLine); + const rawValues = this.csvRows[rowIdx + 1] ?? []; return (rawValues[ratingIdx] ?? '').trim() === k; }); if (rawRatingKey !== undefined) { @@ -819,62 +834,62 @@ export class CsvModal extends Modal { const CHUNK_SIZE = 10; let added = 0; - for (let chunkStart = 0; chunkStart < toImport.length; chunkStart += CHUNK_SIZE) { - if (this.importCancelled) break; - - const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, toImport.length); - for (let i = chunkStart; i < chunkEnd; i++) { + try { + for (let chunkStart = 0; chunkStart < toImport.length; chunkStart += CHUNK_SIZE) { if (this.importCancelled) break; - const r = toImport[i]!; - const titleName = r.entry.title!.trim(); - const entry: WatchLogTitle = { - id: this.dataManager.generateId(titleName), - title: titleName, - type: r.entry.type?.trim() || this.plugin.settings.types[0]?.name || 'Anime', - status: r.entry.status || 'Plan to watch', - priority: r.entry.priority || 'Medium', - review: '', - rating: r.entry.rating ?? 0, - notes: '', - dateStarted: r.entry.dateStarted ?? null, - dateFinished: r.entry.dateFinished ?? null, - dateAdded: new Date().toISOString(), - dateModified: new Date().toISOString(), - totalEpisodes: r.entry.totalEpisodes ?? 0, - episodeDuration: r.entry.episodeDuration ?? 0, - releaseDate: r.entry.releaseDate ?? null, - externalLink: r.entry.externalLink ?? '', - seasons: [], - watchedEpisodes: [], - }; - - if (entry.status === 'Completed' && entry.totalEpisodes > 0) { - entry.watchedEpisodes = Array.from({ length: entry.totalEpisodes }, (_, k) => k + 1); + const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, toImport.length); + const chunkEntries: WatchLogTitle[] = []; + for (let i = chunkStart; i < chunkEnd; i++) { + if (this.importCancelled) break; + const r = toImport[i]!; + const titleName = r.entry.title!.trim(); + const entry: WatchLogTitle = { + id: this.dataManager.generateId(titleName), + title: titleName, + type: r.entry.type?.trim() || this.plugin.settings.types[0]?.name || 'Anime', + status: r.entry.status || 'Plan to watch', + priority: r.entry.priority || 'Medium', + review: '', + rating: r.entry.rating ?? 0, + notes: r.entry.notes ?? '', + dateStarted: r.entry.dateStarted ?? null, + dateFinished: r.entry.dateFinished ?? null, + dateAdded: new Date().toISOString(), + dateModified: new Date().toISOString(), + totalEpisodes: r.entry.totalEpisodes ?? 0, + episodeDuration: r.entry.episodeDuration ?? 0, + releaseDate: r.entry.releaseDate ?? null, + externalLink: r.entry.externalLink ?? '', + seasons: [], + watchedEpisodes: [], + }; + if (entry.status === 'Completed' && entry.totalEpisodes > 0) { + entry.watchedEpisodes = Array.from({ length: entry.totalEpisodes }, (_, k) => k + 1); + } + chunkEntries.push(entry); } - this.plugin.importProgress = { current: added + 1, total: toImport.length, cancel: cancelFn }; - await this.dataManager.addTitleSilent(entry); - added++; + if (chunkEntries.length === 0) break; + await this.dataManager.addTitleBatch(chunkEntries); + added += chunkEntries.length; + this.plugin.importProgress = { current: added, total: toImport.length, cancel: cancelFn }; const pct = Math.round((added / toImport.length) * 100); if (this.progressBarFill) this.progressBarFill.style.width = `${pct}%`; if (this.progressText) this.progressText.textContent = `${added} / ${toImport.length}`; } + } finally { + this.isImporting = false; + this.plugin.importProgress = null; + this.dataManager.notifyChange(); } - this.isImporting = false; - - // Clear header progress bar and trigger a final UI refresh - this.plugin.importProgress = null; - this.dataManager.notifyChange(); - const newTypesMsg = typesToCreate.size > 0 ? ` (${typesToCreate.size} new type${typesToCreate.size !== 1 ? 's' : ''} created)` : ''; if (this.importCancelled) { - // Import was cancelled — stay open so user can see what was imported if (this.progressText) { this.progressText.textContent = `Cancelled — ${added} / ${toImport.length} imported`; } @@ -884,7 +899,6 @@ export class CsvModal extends Modal { } new Notice(`Import cancelled. ${added} title${added !== 1 ? 's' : ''} imported.`); } else { - // All done — show success state, then close if (this.progressText) { this.progressText.textContent = `Done — ${added} imported`; } diff --git a/src/CustomListsTab.ts b/src/CustomListsTab.ts index cfa20c3..cf4b90e 100644 --- a/src/CustomListsTab.ts +++ b/src/CustomListsTab.ts @@ -1,4 +1,4 @@ -import { App, Component, MarkdownRenderer, Modal, Notice, Platform, TFile, normalizePath, Setting } from 'obsidian'; +import { App, Component, MarkdownRenderer, Modal, Notice, TFile, normalizePath, Setting } from 'obsidian'; import type WatchLogPlugin from './main'; import type { DataManager } from './DataManager'; import type { CustomListColumn, CustomListRow, CustomList } from './types'; @@ -9,11 +9,25 @@ import { ConfirmModal } from './ConfirmModal'; // ───────────────────────────────────────────────────────────────────────────── export class CustomListManager { + /** Names of lists whose ## Data JSON is corrupt — never overwrite these. */ + corruptLists: Set<string> = new Set(); + + /** Per-list save queue: serializes writes to the same list file so table + * and notes saves cannot race and clobber each other. */ + private saveQueues: Map<string, Promise<void>> = new Map(); + constructor( private readonly app: App, private readonly plugin: WatchLogPlugin, ) {} + private async saveSerialized(listName: string, saveFn: () => Promise<void>): Promise<void> { + const prev = this.saveQueues.get(listName) ?? Promise.resolve(); + const next = prev.then(saveFn).catch(e => console.error('[WL] Save error for list:', listName, e)); + this.saveQueues.set(listName, next); + await next; + } + get folderPath(): string { return this.plugin.settings.customListsFolder || 'WatchLog/CustomLists'; } @@ -43,10 +57,13 @@ export class CustomListManager { try { const content = await this.app.vault.read(file); return this.parse(name, content); - } catch { return null; } + } catch (e) { + console.warn('[WL] Failed to read custom list', name, e); + return null; + } } - private parse(name: string, content: string): CustomList { + private parse(name: string, content: string): CustomList | null { const notesMatch = content.match(/## Notes\n([\s\S]*?)(?=\n## |\s*$)/); const notes = (notesMatch?.[1] ?? '').trim(); @@ -68,48 +85,61 @@ export class CustomListManager { typeof r === 'object' && r !== null && 'id' in r, ); } - } catch { /* invalid JSON */ } + } catch (e) { + console.warn('[WL] Custom list JSON parse failed for', name, e); + new Notice(`Custom list "${name}" has corrupt data and cannot be loaded.`); + this.corruptLists.add(name); + return null; + } } return { name, columns, rows, notes }; } async saveList(list: CustomList): Promise<void> { - await this.ensureFolder(); - const path = normalizePath(`${this.folderPath}/${list.name}.md`); - const content = this.serialize(list); - const existing = this.app.vault.getAbstractFileByPath(path); - try { - if (existing instanceof TFile) { - await this.app.vault.modify(existing, content); - } else { - await this.app.vault.create(path, content); + await this.saveSerialized(list.name, async () => { + if (this.corruptLists.has(list.name)) { + console.warn('[WL] Refusing to save corrupt custom list', list.name); + return; } - } catch (e) { - console.error('WatchLog: failed to save custom list', e); - } + await this.ensureFolder(); + const path = normalizePath(`${this.folderPath}/${list.name}.md`); + const content = this.serialize(list); + const existing = this.app.vault.getAbstractFileByPath(path); + try { + if (existing instanceof TFile) { + await this.app.vault.modify(existing, content); + } else { + await this.app.vault.create(path, content); + } + } catch (e) { + console.error('WatchLog: failed to save custom list', e); + } + }); } /** Saves only the ## Notes section without touching ## Data JSON. */ async saveNotes(name: string, notes: string): Promise<void> { - const path = normalizePath(`${this.folderPath}/${name}.md`); - const file = this.app.vault.getAbstractFileByPath(path); - if (!(file instanceof TFile)) return; - try { - const content = await this.app.vault.read(file); - const notesMarker = '## Notes\n'; - const dataMarker = '\n## Data\n'; - const notesIdx = content.indexOf(notesMarker); - if (notesIdx === -1) return; - const afterNotes = notesIdx + notesMarker.length; - const dataIdx = content.indexOf(dataMarker, afterNotes); - const updated = dataIdx !== -1 - ? content.slice(0, afterNotes) + notes + content.slice(dataIdx) - : content.slice(0, afterNotes) + notes + '\n'; - await this.app.vault.modify(file, updated); - } catch (e) { - console.error('WatchLog: failed to save notes', e); - } + await this.saveSerialized(name, async () => { + const path = normalizePath(`${this.folderPath}/${name}.md`); + const file = this.app.vault.getAbstractFileByPath(path); + if (!(file instanceof TFile)) return; + try { + const content = await this.app.vault.read(file); + const notesMarker = '## Notes\n'; + const dataMarker = '\n## Data\n'; + const notesIdx = content.indexOf(notesMarker); + if (notesIdx === -1) return; + const afterNotes = notesIdx + notesMarker.length; + const dataIdx = content.indexOf(dataMarker, afterNotes); + const updated = dataIdx !== -1 + ? content.slice(0, afterNotes) + notes + content.slice(dataIdx) + : content.slice(0, afterNotes) + notes + '\n'; + await this.app.vault.modify(file, updated); + } catch (e) { + console.error('WatchLog: failed to save notes', e); + } + }); } private serialize(list: CustomList): string { @@ -157,58 +187,64 @@ function renderColumnList( listEl: HTMLElement, cols: CustomListColumn[], existingRows: CustomListRow[], - manager: CustomListManager, app: App, onReorder: (from: number, to: number) => void, onDelete: (idx: number) => void, onTypeChange: () => void, + onAdd?: () => void, ): void { listEl.empty(); + listEl.addClass('wl-editcol-card-grid'); let dragFromIndex = -1; - if (cols.length === 0) { - listEl.createDiv({ cls: 'wl-empty-state', text: 'No custom columns. Click "+ Add column" to add one.' }); - return; - } + // Permanent auto-included columns — non-editable, non-deletable, non-draggable. + const renderAutoCard = (label: string): void => { + const card = listEl.createDiv({ cls: 'wl-editcol-card wl-editcol-card-auto' }); + card.setAttribute('data-auto', label); + const nameWrap = card.createDiv({ cls: 'wl-editcol-card-name-wrap' }); + nameWrap.createDiv({ cls: 'wl-editcol-card-name-label', text: label }); + card.createDiv({ cls: 'wl-editcol-card-type-locked', text: 'Auto' }); + card.createDiv({ cls: 'wl-editcol-card-spacer' }); + }; + renderAutoCard('#'); + renderAutoCard('Name'); + // User columns cols.forEach((col, idx) => { - const row = listEl.createDiv({ cls: 'wl-editcols-row' }); + const card = listEl.createDiv({ cls: 'wl-editcol-card' }); - // ── Header line (handle | name | type | B/I toggles | × delete) ── - const header = row.createDiv({ cls: 'wl-editcols-row-header' }); - - // Drag handle - const handle = header.createDiv({ cls: 'wl-editcols-handle', text: '⠿' }); - handle.addEventListener('mousedown', () => row.setAttribute('draggable', 'true')); - - row.addEventListener('dragstart', (e) => { + // Drag handle (top of card) + const handle = card.createDiv({ cls: 'wl-editcol-card-handle', text: '⠿' }); + handle.title = 'Drag to reorder'; + handle.addEventListener('mousedown', () => card.setAttribute('draggable', 'true')); + card.addEventListener('dragstart', (e) => { dragFromIndex = idx; e.dataTransfer?.setData('text/plain', String(idx)); - row.addClass('wl-cl-dragging'); + card.addClass('wl-cl-dragging'); }); - row.addEventListener('dragend', () => { - row.removeClass('wl-cl-dragging'); - row.setAttribute('draggable', 'false'); + card.addEventListener('dragend', () => { + card.removeClass('wl-cl-dragging'); + card.setAttribute('draggable', 'false'); }); - row.addEventListener('dragover', (e) => { e.preventDefault(); row.addClass('wl-cl-drag-over'); }); - row.addEventListener('dragleave', () => row.removeClass('wl-cl-drag-over')); - row.addEventListener('drop', (e) => { + card.addEventListener('dragover', (e) => { e.preventDefault(); card.addClass('wl-cl-drag-over'); }); + card.addEventListener('dragleave', () => card.removeClass('wl-cl-drag-over')); + card.addEventListener('drop', (e) => { e.preventDefault(); - row.removeClass('wl-cl-drag-over'); + card.removeClass('wl-cl-drag-over'); const from = dragFromIndex; const to = idx; if (from !== to && from >= 0) onReorder(from, to); }); // Name input - const nameInput = header.createEl('input', { - cls: 'wl-modal-input wl-editcols-name', + const nameInput = card.createEl('input', { + cls: 'wl-modal-input wl-editcol-card-name', attr: { type: 'text', placeholder: 'Column name', value: col.label }, }); nameInput.addEventListener('input', () => { col.label = nameInput.value; }); // Type select - const typeSelect = header.createEl('select', { cls: 'wl-select wl-editcols-type' }); + const typeSelect = card.createEl('select', { cls: 'wl-select wl-editcol-card-type' }); for (const t of ['text', 'number', 'select'] as const) { const opt = typeSelect.createEl('option', { value: t, @@ -221,11 +257,11 @@ function renderColumnList( onTypeChange(); }); - // Bold/Italic toggles (text/number only) — stay in header + // Bold/Italic toggles (text/number only) if (col.type === 'text' || col.type === 'number') { - const toggles = header.createDiv({ cls: 'wl-editcols-toggles' }); + const toggles = card.createDiv({ cls: 'wl-editcol-card-toggles' }); const boldBtn = toggles.createEl('button', { - cls: `wl-btn wl-btn-sm wl-editcols-toggle wl-editcols-bold-btn${col.bold ? ' is-active' : ''}`, + cls: `wl-btn wl-btn-sm wl-editcol-card-toggle wl-editcols-bold-btn${col.bold ? ' is-active' : ''}`, text: 'B', }); boldBtn.addEventListener('click', () => { @@ -233,18 +269,30 @@ function renderColumnList( boldBtn.toggleClass('is-active', !!col.bold); }); const italicBtn = toggles.createEl('button', { - cls: `wl-btn wl-btn-sm wl-editcols-toggle wl-editcols-italic-btn${col.italic ? ' is-active' : ''}`, + cls: `wl-btn wl-btn-sm wl-editcol-card-toggle wl-editcols-italic-btn${col.italic ? ' is-active' : ''}`, text: 'I', }); italicBtn.addEventListener('click', () => { col.italic = !col.italic; italicBtn.toggleClass('is-active', !!col.italic); }); + + if (col.type === 'number') { + const autoTimeBtn = toggles.createEl('button', { + cls: `wl-btn wl-btn-sm wl-editcol-card-toggle wl-editcols-autotime-btn${col.autoTime ? ' is-active' : ''}`, + text: '⏱', + attr: { title: 'Auto-populate with remaining watch time from Watchlist' }, + }); + autoTimeBtn.addEventListener('click', () => { + col.autoTime = !col.autoTime; + autoTimeBtn.toggleClass('is-active', !!col.autoTime); + }); + } } - // Delete column button — always at far right of header, clearly separated - const delBtn = header.createEl('button', { - cls: 'wl-btn wl-btn-sm wl-editcols-del', + // Delete card button + const delBtn = card.createEl('button', { + cls: 'wl-btn wl-btn-sm wl-editcol-card-del', text: '×', }); delBtn.title = 'Delete this column'; @@ -262,29 +310,32 @@ function renderColumnList( } }); - // ── Options area (select type only) — below the header line ── + // Options block (select type only) if (col.type === 'select') { - const optsArea = row.createDiv({ cls: 'wl-editcols-opts-area' }); + const optsArea = card.createDiv({ cls: 'wl-editcol-card-opts' }); const renderOpts = (): void => { optsArea.empty(); (col.options ?? []).forEach((opt, oi) => { - const optRow = optsArea.createDiv({ cls: 'wl-editcols-opt-row' }); + const optRow = optsArea.createDiv({ cls: 'wl-editcol-card-opt-row' }); const optInput = optRow.createEl('input', { - cls: 'wl-modal-input', - attr: { type: 'text', value: opt, placeholder: 'Option label' }, + cls: 'wl-modal-input wl-editcol-card-opt-input', + attr: { type: 'text', value: opt, placeholder: 'Option' }, }); optInput.addEventListener('input', () => { if (!col.options) col.options = []; col.options[oi] = optInput.value; }); const delOpt = optRow.createEl('button', { - cls: 'wl-btn wl-btn-sm wl-editcols-opt-del', + cls: 'wl-btn wl-btn-sm wl-editcol-card-opt-del', text: '×', attr: { title: 'Remove this option' }, }); delOpt.addEventListener('click', () => { col.options?.splice(oi, 1); renderOpts(); }); }); - const addOpt = optsArea.createEl('button', { cls: 'wl-btn wl-btn-sm', text: '+ add option' }); + const addOpt = optsArea.createEl('button', { + cls: 'wl-btn wl-btn-sm wl-editcol-card-opt-add', + text: '+ option', + }); addOpt.addEventListener('click', () => { if (!col.options) col.options = []; col.options.push(''); @@ -294,6 +345,13 @@ function renderColumnList( renderOpts(); } }); + + // Add-column card (last in row) + if (onAdd) { + const addCard = listEl.createDiv({ cls: 'wl-editcol-card wl-editcol-card-add' }); + addCard.createDiv({ cls: 'wl-editcol-card-add-label', text: '+ add column' }); + addCard.addEventListener('click', () => onAdd()); + } } // ───────────────────────────────────────────────────────────────────────────── @@ -323,6 +381,7 @@ export class EditColumnsModal extends Modal { } onOpen(): void { + this.modalEl.addClass('wl-editcol-modal'); this.titleEl.setText(`Edit Columns — ${this.list.name}`); this.contentEl.addClass('wl-editcols-modal'); this.renderBody(); @@ -335,21 +394,6 @@ export class EditColumnsModal extends Modal { this.listEl = this.contentEl.createDiv({ cls: 'wl-editcols-list' }); this.renderCols(); - const addBtn = this.contentEl.createEl('button', { - cls: 'wl-btn wl-editcols-add-btn', - text: '+ add column', - }); - addBtn.addEventListener('click', () => { - this.cols.push({ - id: this.manager.generateColId([...this.list.columns.filter(c => !c.locked), ...this.cols]), - label: '', - type: 'text', - bold: false, - italic: false, - }); - this.renderBody(); - }); - const footer = this.contentEl.createDiv({ cls: 'wl-modal-btn-row wl-editcols-footer' }); const cancelBtn = footer.createEl('button', { cls: 'wl-btn', text: 'Cancel' }); cancelBtn.addEventListener('click', () => this.close()); @@ -362,7 +406,6 @@ export class EditColumnsModal extends Modal { this.listEl, this.cols, this.list.rows, - this.manager, this.app, (from, to) => { const [moved] = this.cols.splice(from, 1); @@ -371,6 +414,16 @@ export class EditColumnsModal extends Modal { }, (idx) => { this.cols.splice(idx, 1); this.renderBody(); }, () => this.renderBody(), + () => { + this.cols.push({ + id: this.manager.generateColId([...this.list.columns.filter(c => !c.locked), ...this.cols]), + label: '', + type: 'text', + bold: false, + italic: false, + }); + this.renderBody(); + }, ); } @@ -409,6 +462,7 @@ export class DefaultColumnsModal extends Modal { } onOpen(): void { + this.modalEl.addClass('wl-editcol-modal'); this.titleEl.setText('Default columns'); this.contentEl.addClass('wl-editcols-modal'); this.renderBody(); @@ -426,21 +480,6 @@ export class DefaultColumnsModal extends Modal { this.listEl = this.contentEl.createDiv({ cls: 'wl-editcols-list' }); this.renderCols(); - const addBtn = this.contentEl.createEl('button', { - cls: 'wl-btn wl-editcols-add-btn', - text: '+ add column', - }); - addBtn.addEventListener('click', () => { - this.cols.push({ - id: this.manager.generateColId(this.cols), - label: '', - type: 'text', - bold: false, - italic: false, - }); - this.renderBody(); - }); - const footer = this.contentEl.createDiv({ cls: 'wl-modal-btn-row wl-editcols-footer' }); const cancelBtn = footer.createEl('button', { cls: 'wl-btn', text: 'Cancel' }); cancelBtn.addEventListener('click', () => this.close()); @@ -453,7 +492,6 @@ export class DefaultColumnsModal extends Modal { this.listEl, this.cols, [], - this.manager, this.app, (from, to) => { const [moved] = this.cols.splice(from, 1); @@ -462,6 +500,16 @@ export class DefaultColumnsModal extends Modal { }, (idx) => { this.cols.splice(idx, 1); this.renderBody(); }, () => this.renderBody(), + () => { + this.cols.push({ + id: this.manager.generateColId(this.cols), + label: '', + type: 'text', + bold: false, + italic: false, + }); + this.renderBody(); + }, ); } @@ -485,7 +533,7 @@ export class DefaultColumnsModal extends Modal { } // ───────────────────────────────────────────────────────────────────────────── -// NotesModal — Bug 2: replaces "open raw .md" with a simple textarea modal +// NotesModal — edits a list's notes in a simple textarea modal // ───────────────────────────────────────────────────────────────────────────── class NotesModal extends Modal { @@ -582,7 +630,7 @@ function wrapSelection(ta: HTMLTextAreaElement, before: string, after: string): } // ───────────────────────────────────────────────────────────────────────────── -// ListNameModal — replaces window.prompt() (blocked in Electron) +// ListNameModal — text-input modal (window.prompt() is blocked in Electron) // ───────────────────────────────────────────────────────────────────────────── class ListNameModal extends Modal { @@ -631,78 +679,6 @@ class ListNameModal extends Modal { onClose(): void { this.contentEl.empty(); } } -// ───────────────────────────────────────────────────────────────────────────── -// MobileCellEditModal — replaces inline input editing on iOS to avoid keyboard overlay -// ───────────────────────────────────────────────────────────────────────────── - -class MobileCellEditModal extends Modal { - private inputType: 'text' | 'number'; - private initialValue: string; - private label: string; - private getSuggestions: (() => { title: string; type: string; status: string }[]) | undefined; - private onSave: (value: string) => void | Promise<void>; - - constructor( - app: App, - label: string, - inputType: 'text' | 'number', - initialValue: string, - onSave: (value: string) => void | Promise<void>, - getSuggestions?: () => { title: string; type: string; status: string }[], - ) { - super(app); - this.label = label; - this.inputType = inputType; - this.initialValue = initialValue; - this.onSave = onSave; - this.getSuggestions = getSuggestions; - } - - onOpen(): void { - this.titleEl.setText(this.label); - const { contentEl } = this; - - const input = contentEl.createEl('input', { - cls: 'wl-cl-cell-input wl-mobile-edit-input', - attr: { type: this.inputType, value: this.initialValue }, - }); - - if (this.getSuggestions) { - const suggestionsEl = contentEl.createDiv({ cls: 'wl-cl-autofill-dropdown wl-mobile-suggestions' }); - input.addEventListener('input', () => { - suggestionsEl.empty(); - const q = input.value.toLowerCase(); - if (!q) return; - const matches = this.getSuggestions!() - .filter(t => t.title.toLowerCase().includes(q)) - .slice(0, 10); - for (const s of matches) { - const item = suggestionsEl.createDiv({ cls: 'wl-result-item' }); - item.createDiv({ cls: 'wl-result-title', text: s.title }); - item.createDiv({ cls: 'wl-result-meta', text: `${s.type} · ${s.status}` }); - item.addEventListener('mousedown', (e) => { - e.preventDefault(); - input.value = s.title; - suggestionsEl.empty(); - }); - } - }); - } - - const btnRow = contentEl.createDiv({ cls: 'wl-modal-btn-row' }); - btnRow.createEl('button', { cls: 'wl-btn mod-cta', text: 'Save' }) - .addEventListener('click', () => { void this.onSave(input.value); this.close(); }); - - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { e.preventDefault(); void this.onSave(input.value); this.close(); } - }); - - window.setTimeout(() => { input.focus(); input.select(); }, 50); - } - - onClose(): void { this.contentEl.empty(); } -} - // ───────────────────────────────────────────────────────────────────────────── // CustomListsTab // ───────────────────────────────────────────────────────────────────────────── @@ -722,6 +698,15 @@ export class CustomListsTab { private duplicatedRowIds: Set<string> = new Set(); private _escapeKeyHandler: ((e: KeyboardEvent) => void) | null = null; + // Cleanup functions for inline-editor document/visualViewport listeners + private activeCleanups: Array<() => void> = []; + + // Generation counter to guard against stale renders / race conditions + private renderGeneration = 0; + + // Per-list save queue to prevent overlapping table + notes writes + private saveQueues: Map<string, Promise<void>> = new Map(); + // Kept for keyboard navigation (set at buildTable time) private _countEl: HTMLElement | null = null; private _tableContainer: HTMLElement | null = null; @@ -741,7 +726,33 @@ export class CustomListsTab { return [...ordered, ...unordered]; } + /** Tear down active inline-editor listeners (called on rerender / view close). */ + destroy(): void { + for (const fn of this.activeCleanups) { + try { fn(); } catch { /* ignore */ } + } + this.activeCleanups = []; + if (this._escapeKeyHandler) { + try { activeDocument.removeEventListener('keydown', this._escapeKeyHandler); } catch { /* ignore */ } + this._escapeKeyHandler = null; + } + } + + addCleanup(fn: () => void): void { + this.activeCleanups.push(fn); + } + + /** Serializes saves per-list so notes + table writes don't overlap. */ + saveSerialized(listName: string, saveFn: () => Promise<void>): Promise<void> { + const prev = this.saveQueues.get(listName) ?? Promise.resolve(); + const next = prev.then(saveFn).catch((e) => console.error('[WL] custom-list save failed:', e)); + this.saveQueues.set(listName, next); + return next; + } + async render(): Promise<void> { + const gen = ++this.renderGeneration; + this.destroy(); this.container.empty(); this.container.addClass('wl-custom-lists'); @@ -761,6 +772,7 @@ export class CustomListsTab { this.currentList = await this.manager.loadList(this.activeListName); } + if (gen !== this.renderGeneration) return; // stale render this.container.empty(); this.buildSubTabs(); @@ -828,7 +840,7 @@ export class CustomListsTab { }); } - const addBtn = tabBar.createEl('button', { cls: 'wl-cl-sub-tab-add', text: '+' }); + const addBtn = tabBar.createEl('button', { cls: 'wl-cl-sub-tab-add wl-btn-success', text: '+' }); addBtn.addEventListener('click', () => this.promptCreateList()); } @@ -906,7 +918,7 @@ export class CustomListsTab { const countEl = header.createSpan({ cls: 'wl-results-count wl-cl-count' }); const toolbar = header.createDiv({ cls: 'wl-cl-toolbar' }); - // Notes — Bug 2: opens NotesModal instead of raw file + // Notes — opens NotesModal const notesBtn = toolbar.createEl('button', { cls: 'wl-btn wl-btn-sm', text: 'Notes' }); notesBtn.addEventListener('click', () => { new NotesModal( @@ -945,12 +957,17 @@ export class CustomListsTab { const colIds = new Set(cols.map(c => c.id)); for (const row of list.rows) { for (const key of Object.keys(row)) { - if (key !== 'id' && key !== 'name' && !colIds.has(key)) { + if (key !== 'id' && key !== 'name' && key !== 'checked' && !colIds.has(key)) { delete (row as Record<string, unknown>)[key]; } } } list.columns = cols; + for (const col of cols) { + if (col.type === 'number' && col.autoTime) { + this.autoPopulateTimeColumn(list, col); + } + } await this.manager.saveList(list); await this.render(); }, @@ -963,19 +980,25 @@ export class CustomListsTab { cls: 'wl-search-input', attr: { type: 'text', placeholder: 'Search...', value: this.searchQuery }, }); + let searchDebounce: number | null = null; searchInput.addEventListener('input', () => { this.searchQuery = searchInput.value; - if (this._tableContainer && this._countEl) { - this._tableContainer.empty(); - this.buildTable(this._tableContainer, list, this._countEl); - } + if (searchDebounce !== null) window.clearTimeout(searchDebounce); + searchDebounce = window.setTimeout(() => { + searchDebounce = null; + if (this._tableContainer && this._countEl) { + this._tableContainer.empty(); + this.buildTable(this._tableContainer, list, this._countEl); + } + }, 250); }); - // Draft warning - view.createDiv({ - cls: 'wl-cl-draft-banner', - text: '⚠ This is a draft list. Titles here are not included in any stats or counts.', - }); + if (this.plugin.settings.showHintBanners) { + view.createDiv({ + cls: 'wl-cl-draft-banner', + text: '⚠ This is a draft list. Titles here are not included in any stats or counts.', + }); + } // Table const tableWrap = view.createDiv({ cls: 'wl-cl-table-wrap' }); @@ -1074,7 +1097,22 @@ export class CustomListsTab { hRow.createDiv({ cls: 'wl-cl-th wl-cl-th-tick' }); // tick column header hRow.createDiv({ cls: 'wl-cl-th wl-cl-th-num', text: '#' }); hRow.createDiv({ cls: 'wl-cl-th', text: 'Name' }); - for (const col of nonLockedCols) hRow.createDiv({ cls: 'wl-cl-th', text: col.label }); + for (const col of nonLockedCols) { + const th = hRow.createDiv({ cls: 'wl-cl-th' }); + th.createSpan({ text: col.label }); + if (col.type === 'number' && col.autoTime) { + const refreshBtn = th.createSpan({ cls: 'wl-cl-autotime-refresh', text: '↻' }); + refreshBtn.title = 'Refresh remaining time values'; + refreshBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.autoPopulateTimeColumn(list, col); + void this.manager.saveList(list).then(() => { + container.empty(); + this.buildTable(container, list, countEl); + }); + }); + } + } hRow.createDiv({ cls: 'wl-cl-th wl-cl-th-actions' }); // Body @@ -1086,7 +1124,7 @@ export class CustomListsTab { const tr = tbody.createDiv({ cls: `wl-cl-tr wl-cl-tr-body${isChecked ? ' wl-cl-tr-checked' : ''}` }); tr.dataset.rowId = row.id; - // Tick button (Feature 5) + // Tick button const tickCell = tr.createDiv({ cls: 'wl-cl-td wl-cl-td-tick' }); const tickBtn = tickCell.createDiv({ cls: `wl-cl-tick-btn${isChecked ? ' is-checked' : ''}` }); tickBtn.title = isChecked ? 'Uncheck row' : 'Check row'; @@ -1161,7 +1199,7 @@ export class CustomListsTab { // Add row const addRowEl = container.createDiv({ cls: 'wl-cl-add-row' }); - addRowEl.createEl('button', { cls: 'wl-btn wl-btn-sm', text: '+ add row' }) + addRowEl.createEl('button', { cls: 'wl-btn wl-btn-sm wl-btn-success', text: '+ add row' }) .addEventListener('click', () => { const newRow: CustomListRow = { id: this.manager.generateRowId(list.rows), name: '' }; list.rows.push(newRow); @@ -1214,25 +1252,6 @@ export class CustomListsTab { ): void { const prevVal = String((row as Record<string, string | undefined>)['name'] ?? ''); - if (Platform.isMobile) { - new MobileCellEditModal( - this.plugin.app, - 'Edit Name', - 'text', - prevVal, - async (value) => { - const newVal = value.trim(); - const isDup = this.duplicatedRowIds.has(row.id); - (row as Record<string, unknown>)['name'] = newVal; - if (isDup && newVal !== prevVal) this.duplicatedRowIds.delete(row.id); - await this.manager.saveList(list); - this.renderNameCell(cell, row, list, countEl, nonLockedCols, tableContainer); - }, - () => this.dataManager.getTitles(), - ).open(); - return; - } - cell.empty(); cell.addClass('wl-cl-editing'); this._escapeKeyHandler = (e: KeyboardEvent) => { @@ -1244,6 +1263,11 @@ export class CustomListsTab { }; activeDocument.addEventListener('keydown', this._escapeKeyHandler, true); activeDocument.addEventListener('keyup', this._escapeKeyHandler, true); + const escHandler = this._escapeKeyHandler; + this.addCleanup(() => { + activeDocument.removeEventListener('keydown', escHandler, true); + activeDocument.removeEventListener('keyup', escHandler, true); + }); // Mobile viewport: scroll active element into view when virtual keyboard shrinks viewport const vpResizeHandler = (): void => { @@ -1251,6 +1275,9 @@ export class CustomListsTab { if (active) active.scrollIntoView({ behavior: 'smooth', block: 'center' }); }; if (window.visualViewport) window.visualViewport.addEventListener('resize', vpResizeHandler); + this.addCleanup(() => { + if (window.visualViewport) window.visualViewport.removeEventListener('resize', vpResizeHandler); + }); window.setTimeout(() => cell.scrollIntoView({ block: 'nearest', behavior: 'smooth' }), 50); const wrap = cell.createDiv({ cls: 'wl-cl-autofill-wrap' }); @@ -1270,8 +1297,14 @@ export class CustomListsTab { clearDropdown(); const q = input.value.toLowerCase(); if (!q) return; - const suggestions = this.dataManager.getTitles() - .filter(t => t.title.toLowerCase().includes(q)) + // Suggestions come from both the Watchlist and the Reading list (Books + Manga). + const reading = this.plugin.readingDataManager; + const suggestions: { title: string; meta: string }[] = [ + ...this.dataManager.getTitles().map(t => ({ title: t.title, meta: `${t.type} · ${t.status}` })), + ...reading.getBooks().map(b => ({ title: b.title, meta: `Book · ${b.status}` })), + ...reading.getMangaList().map(m => ({ title: m.title, meta: `Manga · ${m.status}` })), + ] + .filter(s => s.title.toLowerCase().includes(q)) .slice(0, 10); if (!suggestions.length) return; const rect = input.getBoundingClientRect(); @@ -1279,11 +1312,11 @@ export class CustomListsTab { dropdown.style.top = `${rect.bottom}px`; dropdown.style.left = `${rect.left}px`; dropdown.style.width = `${rect.width}px`; - for (const title of suggestions) { + for (const sug of suggestions) { const item = dropdown.createDiv({ cls: 'wl-result-item' }); - item.createDiv({ cls: 'wl-result-title', text: title.title }); - item.createDiv({ cls: 'wl-result-meta', text: `${title.type} · ${title.status}` }); - item.addEventListener('mousedown', (e) => { e.preventDefault(); input.value = title.title; clearDropdown(); }); + item.createDiv({ cls: 'wl-result-title', text: sug.title }); + item.createDiv({ cls: 'wl-result-meta', text: sug.meta }); + item.addEventListener('mousedown', (e) => { e.preventDefault(); input.value = sug.title; clearDropdown(); }); } }); @@ -1313,7 +1346,7 @@ export class CustomListsTab { input.addEventListener('blur', () => void doSave(false)); input.addEventListener('keydown', (e) => { - // QoL 2: keyboard navigation in suggestion dropdown + // Keyboard navigation in suggestion dropdown if (dropdown) { const items = Array.from(dropdown.querySelectorAll<HTMLElement>('.wl-result-item')); if (e.key === 'ArrowDown') { @@ -1393,29 +1426,6 @@ export class CustomListsTab { ): void { const currentVal = (row as Record<string, string | number | undefined>)[col.id]; - if (Platform.isMobile && col.type !== 'select') { - const initialStr = currentVal !== undefined && currentVal !== null ? String(currentVal) : ''; - new MobileCellEditModal( - this.plugin.app, - `Edit ${col.label}`, - col.type === 'number' ? 'number' : 'text', - initialStr, - async (value) => { - let newVal: string | number | undefined; - if (col.type === 'number') { - const n = parseFloat(value); - newVal = isNaN(n) ? undefined : n; - } else { - newVal = value === '' ? undefined : value; - } - (row as Record<string, unknown>)[col.id] = newVal; - await this.manager.saveList(list); - this.renderCustomCell(cell, row, col, list); - }, - ).open(); - return; - } - cell.empty(); cell.addClass('wl-cl-editing'); @@ -1425,6 +1435,9 @@ export class CustomListsTab { if (active) active.scrollIntoView({ behavior: 'smooth', block: 'center' }); }; if (window.visualViewport) window.visualViewport.addEventListener('resize', vpResizeHandler); + this.addCleanup(() => { + if (window.visualViewport) window.visualViewport.removeEventListener('resize', vpResizeHandler); + }); window.setTimeout(() => cell.scrollIntoView({ block: 'nearest', behavior: 'smooth' }), 50); let getValue: () => string | number | undefined; @@ -1499,7 +1512,24 @@ export class CustomListsTab { } } - // ── Keyboard navigation (QoL 1) ──────────────────────────────────────────── + // ── Auto-populate time column ────────────────────────────────────────────── + + private autoPopulateTimeColumn(list: CustomList, col: CustomListColumn): void { + const titles = this.dataManager.getTitles(); + for (const row of list.rows) { + const rowName = String(row['name'] ?? '').trim(); + if (!rowName) continue; + const match = titles.find(t => t.title === rowName); + if (!match) { + row[col.id] = 'Not found'; + continue; + } + const remainingMinutes = this.dataManager.calcTimeRemainingForModal(match); + row[col.id] = remainingMinutes; + } + } + + // ── Keyboard navigation ───────────────────────────────────────────────────── /** Returns editable cells (Name + custom) in a row, skipping # and actions. */ private getEditableCells(tr: Element): Element[] { diff --git a/src/DashboardTab.ts b/src/DashboardTab.ts index 465a2ca..7dfea22 100644 --- a/src/DashboardTab.ts +++ b/src/DashboardTab.ts @@ -1,88 +1,244 @@ import type WatchLogPlugin from './main'; import type { DataManager } from './DataManager'; -import type { TagDefinition } from './types'; -import { formatTime, getThemedColor } from './types'; +import type { ReadingDataManager } from './ReadingDataManager'; +import type { Book, Manga, TagDefinition, WatchLogTitle } from './types'; +import { formatTime, getThemedColor, getReadingTypeColor } from './types'; const RING_CIRCUMFERENCE = 2 * Math.PI * 45; // r=45 +/** Aggregated reading stats for one kind (books or manga). */ +interface ReadingAggregate { + left: number; // count of Reading + Plan to Read (To be released excluded) + read: number; // sum of pages (books) / chapters (manga) read + total: number; // sum of total pages / chapters + volumesRead: number; + totalVolumes: number; +} + export class DashboardTab { private container: HTMLElement; private plugin: WatchLogPlugin; private dataManager: DataManager; + private readingData: ReadingDataManager; - constructor(container: HTMLElement, plugin: WatchLogPlugin, dataManager: DataManager) { + constructor( + container: HTMLElement, + plugin: WatchLogPlugin, + dataManager: DataManager, + readingData: ReadingDataManager, + ) { this.container = container; this.plugin = plugin; this.dataManager = dataManager; + this.readingData = readingData; } render(): void { this.container.empty(); this.container.addClass('wl-dashboard'); - this.renderCards(); - this.renderSummaryMetrics(); - this.renderSuggestions(); + // Precompute shared collections once per render so each subrenderer + // doesn't trigger another full scan of the titles array. + const allTitles = this.dataManager.getTitles(); + const byType = new Map<string, WatchLogTitle[]>(); + for (const t of allTitles) { + const arr = byType.get(t.type); + if (arr) arr.push(t); + else byType.set(t.type, [t]); + } + const planTitles = allTitles.filter((t) => t.status === 'Plan to watch'); + + // Reading data sourced from ReadingDataManager so footer counts span both datasets. + const books = this.readingData.getBooks(); + const manga = this.readingData.getMangaList(); + + // Library + Completed counts sum watch + reading (books + manga). A reading item + // only counts as completed when its status is exactly 'Completed', matching the watch side. + const titleCount = allTitles.length + books.length + manga.length; + const completedCount = + allTitles.reduce((n, t) => n + (t.status === 'Completed' ? 1 : 0), 0) + + books.reduce((n, b) => n + (b.status === 'Completed' ? 1 : 0), 0) + + manga.reduce((n, m) => n + (m.status === 'Completed' ? 1 : 0), 0); + + // Reading aggregates computed once per render (not per data change). + const booksAgg = this.aggregateBooks(books); + const mangaAgg = this.aggregateManga(manga); + + this.renderCards(allTitles, byType, booksAgg, mangaAgg); + this.renderSummaryMetrics(titleCount, completedCount); + this.renderSuggestions(planTitles); this.renderRecentlyWatched(); this.renderRecentlyAdded(); } - private renderCards(): void { + private statsFor(titles: WatchLogTitle[]): { watched: number; total: number } { + const EXCLUDED = new Set(['Dropped', 'To be released']); + let total = 0; + let watched = 0; + for (const t of titles) { + if (EXCLUDED.has(t.status)) continue; + total++; + if (t.status === 'Completed') watched++; + } + return { watched, total }; + } + + private renderCards( + allTitles: WatchLogTitle[], + byType: Map<string, WatchLogTitle[]>, + booksAgg: ReadingAggregate, + mangaAgg: ReadingAggregate, + ): void { const isRect = this.plugin.settings.dashboardCardStyle === 'rectangles'; const grid = this.container.createDiv({ cls: 'wl-rings-grid' }); - // Always-present Total card - const totalStats = this.dataManager.getStatsByType('All'); + // Unified Total / Time card, anchored at the top (spans full width). + const totalStats = this.statsFor(allTitles); const totalPercent = totalStats.total === 0 ? 0 : Math.round((totalStats.watched / totalStats.total) * 100); - if (isRect) { - this.renderRect(grid, 'Total', '#7F77DD', totalPercent, totalStats); - } else { - this.renderRing(grid, 'Total', '#7F77DD', totalPercent, totalStats); - } + const timeWatched = this.dataManager.getTotalTimeWatched(); + const timeRemaining = this.dataManager.getTotalTimeRemaining(); + this.renderUnifiedCard( + grid, isRect, totalPercent, totalStats, + formatTime(timeWatched), formatTime(timeRemaining), + ); // One card per type for (const type of this.plugin.settings.types) { - const stats = this.dataManager.getStatsByType(type.name); + const stats = this.statsFor(byType.get(type.name) ?? []); const percent = stats.total === 0 ? 0 : Math.round((stats.watched / stats.total) * 100); if (isRect) { - this.renderRect(grid, type.name, type.color, percent, stats); + const item = grid.createDiv({ cls: 'wl-rect-item' }); + this.fillRect(item, type.name, type.color, percent, stats); + // Reserve the sublabel line so type cards match the taller Books/Manga cards. + item.createDiv({ cls: 'wl-rect-subline', text: ' ' }); } else { - this.renderRing(grid, type.name, type.color, percent, stats); + const item = grid.createDiv({ cls: 'wl-ring-item' }); + this.fillRing(item, type.name, type.color, percent, stats); + item.createDiv({ cls: 'wl-ring-subtitle wl-ring-subline', text: ' ' }); } } - // Time Watched card - const timeWatched = this.dataManager.getTotalTimeWatched(); + // Reading cards (Books + Manga) at the end of the flow. + this.renderReadingCard(grid, isRect, 'Books', booksAgg); + this.renderReadingCard(grid, isRect, 'Manga', mangaAgg); + } + + // ── Unified Total / Time card ────────────────────────────────────────────────── + + private renderUnifiedCard( + grid: HTMLElement, + isRect: boolean, + totalPercent: number, + totalStats: { watched: number; total: number }, + timeWatchedStr: string, + timeRemainingStr: string, + ): void { + // Rect cards carry an outer border; ring cards don't — match the active style. + const card = grid.createDiv({ + cls: isRect ? 'wl-dash-unified wl-dash-unified-bordered' : 'wl-dash-unified', + }); + + // Segment 1 — Total (full card content, matching the chosen card style). + // Ring content is centered; rect content stretches so its bar spans the segment. + const seg1 = card.createDiv({ cls: isRect ? 'wl-dash-seg' : 'wl-dash-seg wl-dash-seg-center' }); if (isRect) { - this.renderTimeRect(grid, 'Time Watched', '#1D9E75', formatTime(timeWatched)); + // Rect content goes straight into the segment so its border is the card's. + this.fillRect(seg1, 'Total', '#7F77DD', totalPercent, totalStats); } else { - this.renderTimeRing(grid, 'Time Watched', '#1D9E75', formatTime(timeWatched)); + // Rings are borderless; wrap so the decorative accent ring matches type cards. + this.fillRing(seg1.createDiv({ cls: 'wl-ring-item' }), 'Total', '#7F77DD', totalPercent, totalStats); } - // Time Remaining card - const timeRemaining = this.dataManager.getTotalTimeRemaining(); + // Segments 2 & 3 — Time Watched / Remaining: label + value, no bar. + this.renderTimeSegment(card, 'Time Watched', timeWatchedStr); + this.renderTimeSegment(card, 'Time Remaining', timeRemainingStr); + } + + private renderTimeSegment(card: HTMLElement, label: string, value: string): void { + const seg = card.createDiv({ cls: 'wl-dash-seg wl-dash-seg-center' }); + seg.createDiv({ cls: 'wl-dash-seg-label', text: label }); + seg.createDiv({ cls: 'wl-dash-seg-value', text: value }); + } + + // ── Reading cards (Books / Manga) ────────────────────────────────────────────── + + private aggregateBooks(books: Book[]): ReadingAggregate { + const agg: ReadingAggregate = { left: 0, read: 0, total: 0, volumesRead: 0, totalVolumes: 0 }; + for (const b of books) { + if (b.status === 'To be released') continue; // excluded entirely + if (b.status === 'Reading' || b.status === 'Plan to Read') agg.left++; + agg.read += b.pagesRead; + agg.total += b.totalPages; + } + return agg; + } + + private aggregateManga(manga: Manga[]): ReadingAggregate { + const agg: ReadingAggregate = { left: 0, read: 0, total: 0, volumesRead: 0, totalVolumes: 0 }; + for (const m of manga) { + if (m.status === 'To be released') continue; // excluded entirely + if (m.status === 'Reading' || m.status === 'Plan to Read') agg.left++; + agg.read += m.chaptersRead; + agg.total += m.totalChapters; + agg.volumesRead += m.volumesRead; + agg.totalVolumes += m.totalVolumes; + } + return agg; + } + + private renderReadingCard( + grid: HTMLElement, + isRect: boolean, + label: 'Books' | 'Manga', + agg: ReadingAggregate, + ): void { + const percent = agg.total > 0 ? Math.round((agg.read / agg.total) * 100) : 0; + // Configured Reading type color (Books → book, Manga → manga), mirroring how + // watchlist type cards use their type color. + const color = getReadingTypeColor(label === 'Books' ? 'book' : 'manga', this.plugin.settings); + + // Single inline subline under the bar: Books → pages; Manga → chapters · vol. + const subline = + label === 'Books' + ? `${agg.read} / ${agg.total} pages` + : `${agg.read} / ${agg.total} chapters · ${agg.volumesRead} / ${agg.totalVolumes} vol`; + if (isRect) { - this.renderTimeRect(grid, 'Time Remaining', '#BA7517', formatTime(timeRemaining)); + const item = grid.createDiv({ cls: 'wl-rect-item' }); + const topRow = item.createDiv({ cls: 'wl-rect-top' }); + topRow.createSpan({ cls: 'wl-rect-label', text: label }); + topRow.createSpan({ cls: 'wl-rect-unwatched', text: `${agg.left} left` }); + item.createDiv({ cls: 'wl-rect-value', text: `${percent}%` }); + const barWrap = item.createDiv({ cls: 'wl-rect-bar-wrap' }); + const bar = barWrap.createDiv({ cls: 'wl-rect-bar' }); + bar.style.width = `${percent}%`; + bar.style.backgroundColor = color; + item.createDiv({ cls: 'wl-rect-subline', text: subline }); } else { - this.renderTimeRing(grid, 'Time Remaining', '#BA7517', formatTime(timeRemaining)); + const item = grid.createDiv({ cls: 'wl-ring-item' }); + const svg = this.makeRingSvg(percent, color, `${percent}%`, '', true); + item.appendChild(svg); + item.createDiv({ cls: 'wl-ring-label', text: label }); + item.createDiv({ cls: 'wl-ring-subtitle', text: `${agg.left} left` }); + item.createDiv({ cls: 'wl-ring-subtitle wl-ring-subline', text: subline }); } } - // ── Ring (circle) cards ─────────────────────────────────────────────────────── + // ── Shared card content builders ─────────────────────────────────────────────── - private renderRing( - parent: HTMLElement, - label: string, - color: string, + /** Builds the progress-ring SVG. When `themeStroke` is set, the arc colour is + * applied via inline style so CSS vars (e.g. var(--wl-accent)) resolve. */ + private makeRingSvg( percent: number, - stats: { watched: number; total: number }, - ): void { - const item = parent.createDiv({ cls: 'wl-ring-item' }); - + color: string, + centerText: string, + subText: string, + themeStroke = false, + ): SVGSVGElement { const svgNS = 'http://www.w3.org/2000/svg'; const svg = activeDocument.createElementNS(svgNS, 'svg'); svg.setAttribute('viewBox', '0 0 120 120'); @@ -104,102 +260,59 @@ export class DashboardTab { arc.setAttribute('cy', '60'); arc.setAttribute('r', '45'); arc.setAttribute('fill', 'none'); - arc.setAttribute('stroke', color); + if (themeStroke) arc.style.stroke = color; + else arc.setAttribute('stroke', color); arc.setAttribute('stroke-width', '10'); arc.setAttribute('stroke-linecap', 'round'); arc.setAttribute('stroke-dasharray', String(RING_CIRCUMFERENCE)); - const dashOffset = RING_CIRCUMFERENCE * (1 - percent / 100); - arc.setAttribute('stroke-dashoffset', String(dashOffset)); + arc.setAttribute('stroke-dashoffset', String(RING_CIRCUMFERENCE * (1 - percent / 100))); arc.setAttribute('transform', 'rotate(-90 60 60)'); arc.addClass('wl-ring-arc'); svg.appendChild(arc); const text = activeDocument.createElementNS(svgNS, 'text'); text.setAttribute('x', '60'); - text.setAttribute('y', '55'); + text.setAttribute('y', subText ? '55' : '60'); text.setAttribute('text-anchor', 'middle'); text.setAttribute('dominant-baseline', 'middle'); text.addClass('wl-ring-percent-text'); - text.textContent = `${percent}%`; + text.textContent = centerText; svg.appendChild(text); - const subText = activeDocument.createElementNS(svgNS, 'text'); - subText.setAttribute('x', '60'); - subText.setAttribute('y', '72'); - subText.setAttribute('text-anchor', 'middle'); - subText.addClass('wl-ring-sub-text'); - subText.textContent = `${stats.watched}/${stats.total}`; - svg.appendChild(subText); + if (subText) { + const sub = activeDocument.createElementNS(svgNS, 'text'); + sub.setAttribute('x', '60'); + sub.setAttribute('y', '72'); + sub.setAttribute('text-anchor', 'middle'); + sub.addClass('wl-ring-sub-text'); + sub.textContent = subText; + svg.appendChild(sub); + } + return svg; + } + + private fillRing( + item: HTMLElement, + label: string, + color: string, + percent: number, + stats: { watched: number; total: number }, + ): void { + const svg = this.makeRingSvg(percent, color, `${percent}%`, `${stats.watched}/${stats.total}`); item.appendChild(svg); item.createDiv({ cls: 'wl-ring-label', text: label }); const unwatched = stats.total - stats.watched; item.createDiv({ cls: 'wl-ring-subtitle', text: `${unwatched} unwatched` }); } - private renderTimeRing( - parent: HTMLElement, - label: string, - color: string, - timeStr: string, - ): void { - const item = parent.createDiv({ cls: 'wl-ring-item' }); - - const svgNS = 'http://www.w3.org/2000/svg'; - const svg = activeDocument.createElementNS(svgNS, 'svg'); - svg.setAttribute('viewBox', '0 0 120 120'); - svg.setAttribute('width', '110'); - svg.setAttribute('height', '110'); - svg.addClass('wl-ring-svg'); - - const bgCircle = activeDocument.createElementNS(svgNS, 'circle'); - bgCircle.setAttribute('cx', '60'); - bgCircle.setAttribute('cy', '60'); - bgCircle.setAttribute('r', '45'); - bgCircle.setAttribute('fill', 'none'); - bgCircle.setAttribute('stroke-width', '10'); - bgCircle.addClass('wl-ring-track'); - svg.appendChild(bgCircle); - - const arc = activeDocument.createElementNS(svgNS, 'circle'); - arc.setAttribute('cx', '60'); - arc.setAttribute('cy', '60'); - arc.setAttribute('r', '45'); - arc.setAttribute('fill', 'none'); - arc.setAttribute('stroke', color); - arc.setAttribute('stroke-width', '10'); - arc.setAttribute('stroke-linecap', 'round'); - arc.setAttribute('stroke-dasharray', String(RING_CIRCUMFERENCE)); - arc.setAttribute('stroke-dashoffset', '0'); - arc.setAttribute('transform', 'rotate(-90 60 60)'); - arc.addClass('wl-ring-arc'); - svg.appendChild(arc); - - const text = activeDocument.createElementNS(svgNS, 'text'); - text.setAttribute('x', '60'); - text.setAttribute('y', '60'); - text.setAttribute('text-anchor', 'middle'); - text.setAttribute('dominant-baseline', 'middle'); - text.addClass('wl-ring-time-text'); - text.textContent = timeStr; - svg.appendChild(text); - - item.appendChild(svg); - item.createDiv({ cls: 'wl-ring-label', text: label }); - item.createDiv({ cls: 'wl-ring-subtitle', text: 'total' }); - } - - // ── Rectangle cards ─────────────────────────────────────────────────────────── - - private renderRect( - parent: HTMLElement, + private fillRect( + item: HTMLElement, label: string, color: string, percent: number, stats: { watched: number; total: number }, ): void { - const item = parent.createDiv({ cls: 'wl-rect-item' }); - const topRow = item.createDiv({ cls: 'wl-rect-top' }); topRow.createSpan({ cls: 'wl-rect-label', text: label }); const unwatched = stats.total - stats.watched; @@ -213,33 +326,12 @@ export class DashboardTab { bar.style.backgroundColor = color; } - private renderTimeRect( - parent: HTMLElement, - label: string, - color: string, - timeStr: string, - ): void { - const item = parent.createDiv({ cls: 'wl-rect-item' }); - - const topRow = item.createDiv({ cls: 'wl-rect-top' }); - topRow.createSpan({ cls: 'wl-rect-label', text: label }); - topRow.createSpan({ cls: 'wl-rect-unwatched', text: 'total' }); - - item.createDiv({ cls: 'wl-rect-value', text: timeStr }); - - const barWrap = item.createDiv({ cls: 'wl-rect-bar-wrap' }); - const bar = barWrap.createDiv({ cls: 'wl-rect-bar wl-rect-bar-full' }); - bar.style.backgroundColor = color; - } - // ── Summary metrics ─────────────────────────────────────────────────────────── - private renderSummaryMetrics(): void { + private renderSummaryMetrics(titleCount: number, completedCount: number): void { const section = this.container.createDiv({ cls: 'wl-summary-metrics' }); - const titles = this.dataManager.getTitles(); - const completed = this.dataManager.getCompletedCount(); - this.renderMetricRow(section, 'Titles in library', String(titles.length)); - this.renderMetricRow(section, 'Completed', String(completed)); + this.renderMetricRow(section, 'Titles in library', String(titleCount)); + this.renderMetricRow(section, 'Completed', String(completedCount)); } private renderMetricRow(parent: HTMLElement, label: string, value: string): void { @@ -250,8 +342,7 @@ export class DashboardTab { // ── Suggestions ────────────────────────────────────────────────────────────── - private renderSuggestions(): void { - const planTitles = this.dataManager.getTitles().filter((t) => t.status === 'Plan to watch'); + private renderSuggestions(planTitles: WatchLogTitle[]): void { if (planTitles.length === 0) return; const section = this.container.createDiv({ cls: 'wl-suggestions' }); diff --git a/src/DataManager.ts b/src/DataManager.ts index 5c9d1b1..443a65b 100644 --- a/src/DataManager.ts +++ b/src/DataManager.ts @@ -1,15 +1,42 @@ import { App, TFile, normalizePath } from 'obsidian'; import type WatchLogPlugin from './main'; -import type { WatchLogData, WatchLogTitle, WatchLogGroup, AirtimeEntry, MaybeTitle, SavedFilterPreset } from './types'; +import type { WatchLogData, WatchLogTitle, WatchLogGroup, AirtimeEntry, MaybeTitle, SavedFilterPreset, WatchLogPluginSettings, Book, Manga } from './types'; import type { HistoryManager } from './HistoryManager'; import { formatHistoryDate } from './HistoryManager'; +function isValidWatchLogData(data: unknown): data is WatchLogData { + return ( + data !== null && + typeof data === 'object' && + Array.isArray((data as { titles?: unknown }).titles) + ); +} + export class DataManager { private plugin: WatchLogPlugin; private app: App; private data: WatchLogData; private changeListeners: Array<() => void> = []; private historyManager: HistoryManager | null = null; + // Debounced save state for high-frequency edits (e.g. rapid episode clicks). + // In-memory mutations are applied immediately; the disk write coalesces. + private pendingSaveTimer: number | null = null; + private pendingMdTitleIds: Set<string> = new Set(); + private readonly EPISODE_SAVE_DEBOUNCE_MS = 500; + // Timestamp of the last self-initiated saveOnly. The 'raw' file watcher uses + // this to ignore the echo of our own writes, which otherwise would re-load + // data.json and notify listeners on every debounced episode-click save. + private lastSelfSaveTime = 0; + private readonly SELF_SAVE_ECHO_WINDOW_MS = 2000; + + // Debounced batched save for poster URL updates. Updates land in memory + // immediately; the disk write coalesces to ~once every 5s. + private posterSaveTimer: number | null = null; + private readonly POSTER_SAVE_DEBOUNCE_MS = 5000; + + // Generic queued-save debounce for cross-cutting saves (settings, drafts, lists) + private queuedSaveTimer: number | null = null; + private readonly QUEUED_SAVE_DEBOUNCE_MS = 100; setHistoryManager(hm: HistoryManager): void { this.historyManager = hm; @@ -22,38 +49,41 @@ export class DataManager { } async load(): Promise<void> { - const loaded = (await this.plugin.loadData()) as WatchLogData | null; - if (loaded) { - this.data = loaded; - } else { - this.data = { titles: [], groups: [], settings: {} }; + const raw: unknown = await this.plugin.loadData(); + this.data = isValidWatchLogData(raw) + ? raw + : { titles: [], groups: [], settings: {} }; + const changed = this.migrateData(); + if (changed) { + await this.saveOnly(); } - this.migrateData(); } - private migrateData(): void { - // Ensure core arrays exist (data.json may be missing or partial on first install) + private migrateData(): boolean { + let changed = false; if (!Array.isArray(this.data.titles)) { this.data.titles = []; + changed = true; } if (!Array.isArray((this.data as unknown as Record<string, unknown>)['groups'])) { this.data.groups = []; + changed = true; } - // Ensure airtime array exists if (!Array.isArray(this.data.airtime)) { this.data.airtime = []; + changed = true; } for (const title of this.data.titles) { - // Ensure dateAdded is a full ISO timestamp (old data may be date-only "YYYY-MM-DD") if (!title.dateAdded) { title.dateAdded = new Date().toISOString(); + changed = true; } else if (!title.dateAdded.includes('T')) { title.dateAdded = new Date(title.dateAdded).toISOString(); + changed = true; } - // Migrate lastInteracted → dateModified if (!title.dateModified) { const raw = title as unknown as Record<string, unknown>; const lastInteracted = raw['lastInteracted']; @@ -62,19 +92,194 @@ export class DataManager { } else { title.dateModified = title.dateAdded; } + changed = true; } + + for (const season of (title.seasons ?? [])) { + if (!Array.isArray(season.skippedEpisodes)) { + season.skippedEpisodes = []; + changed = true; + } + } + + if (title.posterUrl === undefined) { title.posterUrl = ''; changed = true; } + if (title.manualPosterUrl === undefined) { title.manualPosterUrl = ''; changed = true; } + if (title.anilistId === undefined) { title.anilistId = 0; changed = true; } + if (title.communityRating === undefined) { title.communityRating = 0; changed = true; } + if (title.communityVotes === undefined) { title.communityVotes = 0; changed = true; } + if (title.communitySource === undefined) { title.communitySource = ''; changed = true; } + if (title.communityRatingLastFetched === undefined) { title.communityRatingLastFetched = ''; changed = true; } + } + + if (!this.data.posterRetryDone) { + for (const title of this.data.titles) { + if (title.posterUrl === 'none') { + title.posterUrl = ''; + } + } + this.data.posterRetryDone = true; + changed = true; + } + return changed; + } + + /** + * Silent poster URL update. Writes the value in memory and schedules a + * debounced disk save (5s). Does NOT notify listeners — the card that + * triggered the fetch updates its own <img> directly. + */ + updatePosterUrl(titleId: string, url: string): void { + const title = this.data.titles.find((t) => t.id === titleId); + if (title) { + title.posterUrl = url; + this.schedulePosterSave(); } } + /** + * Silent community-rating update. Writes the values in memory and saves + * to disk without notifying listeners — the caller is responsible for + * any targeted UI refresh. + */ + updateCommunityRating( + id: string, + rating: number, + votes: number, + source: '' | 'imdb' | 'mal' | 'anilist' | 'tmdb', + ): void { + const title = this.data.titles.find((t) => t.id === id); + if (!title) return; + title.communityRating = rating; + title.communityVotes = votes; + title.communitySource = source; + title.communityRatingLastFetched = new Date().toISOString(); + void this.saveOnly(); + } + + private schedulePosterSave(): void { + if (this.posterSaveTimer !== null) return; + this.posterSaveTimer = window.setTimeout(() => { + this.posterSaveTimer = null; + void this.saveOnly(); + }, this.POSTER_SAVE_DEBOUNCE_MS); + } + + /** Flush a pending debounced poster save now (e.g. on plugin unload). */ + flushPosterSave(): void { + if (this.posterSaveTimer !== null) { + window.clearTimeout(this.posterSaveTimer); + this.posterSaveTimer = null; + void this.saveOnly(); + } + } + + /** Synchronously flush a pending poster save during plugin unload. */ + flushPosterSaveSync(): void { + if (this.posterSaveTimer !== null) { + window.clearTimeout(this.posterSaveTimer); + this.posterSaveTimer = null; + this.lastSelfSaveTime = Date.now(); + void this.plugin.saveData(this.data); + } + } + + /** Public access to the full in-memory data snapshot. */ + getData(): WatchLogData { + return this.data; + } + + /** Centralized debounced save for cross-cutting paths (settings, drafts, lists). */ + queueSave(): void { + if (this.queuedSaveTimer !== null) { + window.clearTimeout(this.queuedSaveTimer); + } + this.queuedSaveTimer = window.setTimeout(() => { + this.queuedSaveTimer = null; + void this.saveOnly(); + }, this.QUEUED_SAVE_DEBOUNCE_MS); + } + + flushQueuedSaveSync(): void { + if (this.queuedSaveTimer !== null) { + window.clearTimeout(this.queuedSaveTimer); + this.queuedSaveTimer = null; + this.lastSelfSaveTime = Date.now(); + void this.plugin.saveData(this.data); + } + } + + /** Save settings through the centralized data path. */ + async saveSettings(settings: WatchLogPluginSettings): Promise<void> { + this.data.settings = settings; + await this.saveOnly(); + } + private async saveOnly(): Promise<void> { + this.lastSelfSaveTime = Date.now(); await this.plugin.saveData(this.data); + this.lastSelfSaveTime = Date.now(); } async save(): Promise<void> { + // An immediate save supersedes any pending debounced one — the in-memory + // state already contains those changes. + if (this.pendingSaveTimer !== null) { + window.clearTimeout(this.pendingSaveTimer); + this.pendingSaveTimer = null; + this.pendingMdTitleIds.clear(); + } await this.saveOnly(); this.notifyListeners(); } + /** + * Schedule a debounced silent save (no listener notification, no full re-render). + * Subsequent calls within the debounce window reset the timer. Callers are + * responsible for any targeted DOM updates. + */ + scheduleEpisodeSave(titleIdForMd: string): void { + this.pendingMdTitleIds.add(titleIdForMd); + if (this.pendingSaveTimer !== null) { + window.clearTimeout(this.pendingSaveTimer); + } + this.pendingSaveTimer = window.setTimeout(() => { + void this.flushPendingSave(); + }, this.EPISODE_SAVE_DEBOUNCE_MS); + } + + /** Flush a pending debounced save now (e.g. on view close / plugin unload). */ + async flushPendingSave(): Promise<void> { + if (this.pendingSaveTimer !== null) { + window.clearTimeout(this.pendingSaveTimer); + this.pendingSaveTimer = null; + } + if (this.pendingMdTitleIds.size === 0) return; + const ids = Array.from(this.pendingMdTitleIds); + this.pendingMdTitleIds.clear(); + await this.saveOnly(); + for (const id of ids) { + const t = this.getTitle(id); + if (t) await this.updateMarkdownFile(t); + } + } + + /** Synchronously flush a pending episode-save during plugin unload (no awaits). */ + flushPendingSaveSync(): void { + if (this.pendingSaveTimer !== null) { + window.clearTimeout(this.pendingSaveTimer); + this.pendingSaveTimer = null; + } + if (this.pendingMdTitleIds.size === 0) return; + const ids = Array.from(this.pendingMdTitleIds); + this.pendingMdTitleIds.clear(); + this.lastSelfSaveTime = Date.now(); + void this.plugin.saveData(this.data); + for (const id of ids) { + const t = this.getTitle(id); + if (t) void this.updateMarkdownFile(t); + } + } + getTitles(): WatchLogTitle[] { return this.data.titles ?? []; } @@ -95,7 +300,10 @@ export class DataManager { } await this.updateMarkdownFile(title); const now = new Date().toISOString(); - void this.historyManager?.log(`${title.title} (${title.type}) was added on ${formatHistoryDate(now)}`); + void this.historyManager?.log( + `${title.title} (${title.type}) was added on ${formatHistoryDate(now)}`, + { source: 'Watchlist', action: 'added', titleName: title.title }, + ); } /** Add without triggering a UI re-render. Caller must call notifyChange() after the batch. */ @@ -112,6 +320,42 @@ export class DataManager { await this.updateMarkdownFile(title); } + /** Batch-add: push all titles into memory, save once, then write all MD files. */ + async addTitleBatch(titles: WatchLogTitle[]): Promise<void> { + const upcoming: WatchLogTitle[] = []; + for (const title of titles) { + if (this.canAutoAddToUpcoming(title)) { + title.status = 'To be released'; + upcoming.push(title); + } + this.data.titles.push(title); + } + await this.saveOnly(); + for (const title of upcoming) { + await this.autoAddToUpcoming(title); + } + for (const title of titles) { + await this.updateMarkdownFile(title); + } + } + + /** Batch-update via mutator. Single save + single notify; MD files updated. */ + async batchUpdate(ids: string[], mutator: (t: WatchLogTitle) => void): Promise<void> { + const updated: WatchLogTitle[] = []; + for (const id of ids) { + const t = this.getTitle(id); + if (!t) continue; + mutator(t); + t.dateModified = new Date().toISOString(); + updated.push(t); + } + if (updated.length === 0) return; + await this.save(); + for (const t of updated) { + await this.updateMarkdownFile(t); + } + } + /** Remove multiple titles in one batch: single save, MD files deleted in chunks of 10. */ async removeTitlesBatch(ids: string[]): Promise<void> { const CHUNK_SIZE = 10; @@ -125,7 +369,7 @@ export class DataManager { group.titleIds = group.titleIds.filter((tid) => tid !== id); } if (this.data.airtime) { - this.data.airtime = this.data.airtime.filter((e) => e.titleId !== id); + this.data.airtime = this.data.airtime.filter((e) => e.titleId !== id || e.source === 'reading'); } const d = this.data as unknown as Record<string, unknown>; if (d['collapsedSeasons']) { @@ -172,24 +416,77 @@ export class DataManager { /** Remove all Upcoming entries for a given title (e.g. when date goes past). */ async removeAirtimeEntriesForTitle(titleId: string): Promise<void> { const before = (this.data.airtime ?? []).length; - this.data.airtime = (this.data.airtime ?? []).filter((e) => e.titleId !== titleId); + this.data.airtime = (this.data.airtime ?? []).filter((e) => e.titleId !== titleId || e.source === 'reading'); if ((this.data.airtime ?? []).length !== before) { await this.save(); } } + // ── Reading → Upcoming bridge ────────────────────────────────────────────────── + // Reading items (Book/Manga) live in reading.json, but their Upcoming entries + // share the same airtime list as watch titles — mirroring autoAddToUpcoming. + + /** + * Auto-adds a reading item with a future release date to Upcoming, mirroring + * the watchlist `autoAddToUpcoming`. No-op if an entry already exists. + */ + async autoAddReadingToUpcoming(item: Book | Manga, kind: 'book' | 'manga'): Promise<void> { + if (!this.data.airtime) this.data.airtime = []; + if (this.data.airtime.some((e) => e.source === 'reading' && e.titleId === item.id)) return; + const entry: AirtimeEntry = { + id: this.generateReadingAirtimeId(item.id), + titleId: item.id, + source: 'reading', + readingKind: kind, + schedule: { + recurrence: 'once', + releaseDate: item.releaseDate ?? undefined, + }, + dateAdded: new Date().toISOString(), + }; + this.data.airtime.push(entry); + await this.plugin.saveData(this.data); + } + + /** Remove all Upcoming entries for a given reading item (book/manga id). */ + async removeReadingAirtimeEntries(itemId: string): Promise<void> { + const before = (this.data.airtime ?? []).length; + this.data.airtime = (this.data.airtime ?? []).filter( + (e) => !(e.source === 'reading' && e.titleId === itemId), + ); + if ((this.data.airtime ?? []).length !== before) { + await this.save(); + } + } + + generateReadingAirtimeId(itemId: string): string { + const base = `airtime-reading-${itemId}`; + const existing = (this.data.airtime ?? []).map((e) => e.id); + if (!existing.includes(base)) return base; + let counter = 2; + while (existing.includes(`${base}-${counter}`)) counter++; + return `${base}-${counter}`; + } + async updateTitle(updated: WatchLogTitle): Promise<void> { updated.dateModified = new Date().toISOString(); + if (updated.status === 'Completed') { + updated.priority = ''; + } const idx = this.data.titles.findIndex((t) => t.id === updated.id); if (idx >= 0) { const old = this.data.titles[idx]!; const now = new Date().toISOString(); if (old.rating !== updated.rating) { - void this.historyManager?.log(`${updated.title} (${updated.type}) was reviewed on ${formatHistoryDate(now)}`); + void this.historyManager?.log( + `${updated.title} (${updated.type}) was reviewed on ${formatHistoryDate(now)}`, + { source: 'Watchlist', action: 'rating', titleName: updated.title }, + ); } if (old.status !== updated.status && updated.status === 'Completed') { void this.historyManager?.log( - `${updated.title} (${updated.type}) was marked as watched on ${formatHistoryDate(now)}` + `${updated.title} (${updated.type}) was marked as watched on ${formatHistoryDate(now)}`, + { source: 'Watchlist', action: 'completed', titleName: updated.title }, ); } this.data.titles[idx] = updated; @@ -198,11 +495,42 @@ export class DataManager { } } + /** Update without saving or notifying listeners. Caller must call + * save() (or saveOnly + notifyChange) once after the batch completes, + * and is responsible for updating the markdown file per title if needed. */ + updateTitleSilent(updated: WatchLogTitle): void { + updated.dateModified = new Date().toISOString(); + if (updated.status === 'Completed') { + updated.priority = ''; + } + const idx = this.data.titles.findIndex((t) => t.id === updated.id); + if (idx >= 0) { + const old = this.data.titles[idx]!; + const now = new Date().toISOString(); + if (old.rating !== updated.rating) { + void this.historyManager?.log( + `${updated.title} (${updated.type}) was reviewed on ${formatHistoryDate(now)}`, + { source: 'Watchlist', action: 'rating', titleName: updated.title }, + ); + } + if (old.status !== updated.status && updated.status === 'Completed') { + void this.historyManager?.log( + `${updated.title} (${updated.type}) was marked as watched on ${formatHistoryDate(now)}`, + { source: 'Watchlist', action: 'completed', titleName: updated.title }, + ); + } + this.data.titles[idx] = updated; + } + } + async removeTitle(id: string): Promise<void> { const title = this.getTitle(id); if (title) { const now = new Date().toISOString(); - void this.historyManager?.log(`${title.title} (${title.type}) was deleted on ${formatHistoryDate(now)}`); + void this.historyManager?.log( + `${title.title} (${title.type}) was deleted on ${formatHistoryDate(now)}`, + { source: 'Watchlist', action: 'deleted', titleName: title.title }, + ); } this.data.titles = this.data.titles.filter((t) => t.id !== id); // Remove from any groups @@ -211,7 +539,7 @@ export class DataManager { } // Remove airtime entries for this title if (this.data.airtime) { - this.data.airtime = this.data.airtime.filter((e) => e.titleId !== id); + this.data.airtime = this.data.airtime.filter((e) => e.titleId !== id || e.source === 'reading'); } // Remove collapsed seasons for this title const d = this.data as unknown as Record<string, unknown>; @@ -333,6 +661,13 @@ export class DataManager { await this.save(); } + async removeAirtimeEntriesBatch(ids: string[]): Promise<void> { + if (ids.length === 0) return; + const removeSet = new Set(ids); + this.data.airtime = (this.data.airtime ?? []).filter((e) => !removeSet.has(e.id)); + await this.save(); + } + // ── Maybe CRUD ──────────────────────────────────────────────────────────────── getMaybeTitles(): MaybeTitle[] { @@ -378,7 +713,7 @@ export class DataManager { return title.watchedEpisodes.includes(1) ? title.episodeDuration : 0; } if (title.status === 'Completed') { - return title.totalEpisodes * title.episodeDuration; + return this.getEffectiveTotal(title) * title.episodeDuration; } // Watching, Dropped, Plan to watch: only watched episodes return title.watchedEpisodes.length * title.episodeDuration; @@ -392,12 +727,12 @@ export class DataManager { if (title.type === 'Movie') { return title.watchedEpisodes.includes(1) ? 0 : title.episodeDuration; } + const effective = this.getEffectiveTotal(title); if (title.status === 'Plan to watch') { - return Math.max(0, title.totalEpisodes - title.watchedEpisodes.length) * title.episodeDuration; + return Math.max(0, effective - title.watchedEpisodes.length) * title.episodeDuration; } if (title.status === 'Watching') { - const unwatched = Math.max(0, title.totalEpisodes - title.watchedEpisodes.length); - return unwatched * title.episodeDuration; + return Math.max(0, effective - title.watchedEpisodes.length) * title.episodeDuration; } // Completed or other: 0 return 0; @@ -409,14 +744,15 @@ export class DataManager { if (title.type === 'Movie') { return title.watchedEpisodes.includes(1) ? 0 : title.episodeDuration; } + const effective = this.getEffectiveTotal(title); if (title.status === 'Dropped') { - return Math.max(0, title.totalEpisodes - title.watchedEpisodes.length) * title.episodeDuration; + return Math.max(0, effective - title.watchedEpisodes.length) * title.episodeDuration; } if (title.status === 'Plan to watch') { - return Math.max(0, title.totalEpisodes - title.watchedEpisodes.length) * title.episodeDuration; + return Math.max(0, effective - title.watchedEpisodes.length) * title.episodeDuration; } if (title.status === 'Watching') { - return Math.max(0, title.totalEpisodes - title.watchedEpisodes.length) * title.episodeDuration; + return Math.max(0, effective - title.watchedEpisodes.length) * title.episodeDuration; } // Completed or other: 0 return 0; @@ -441,6 +777,13 @@ export class DataManager { } ).on('raw', (path: string) => { if (path.endsWith('watchlog/data.json')) { + // Suppress the echo of our own saveOnly() — Obsidian fires 'raw' for + // any write to the plugin data file, including our debounced + // episode-click saves. Without this guard each click cascades into + // load() + notifyListeners(), forcing a full tab re-render. + if (Date.now() - this.lastSelfSaveTime < this.SELF_SAVE_ECHO_WINDOW_MS) { + return; + } void (async () => { await this.load(); this.notifyListeners(); @@ -488,11 +831,96 @@ export class DataManager { await this.save(); } + // ── Skip helpers ────────────────────────────────────────────────────────────── + + getTotalSkippedCount(title: WatchLogTitle): number { + return title.seasons.reduce((sum, s) => sum + (s.skippedEpisodes?.length ?? 0), 0); + } + + /** totalEpisodes minus all season-defined skipped episodes. */ + getEffectiveTotal(title: WatchLogTitle): number { + return Math.max(0, title.totalEpisodes - this.getTotalSkippedCount(title)); + } + + /** Returns true if absoluteEpNum falls in any season's skippedEpisodes list. */ + isEpisodeSkipped(title: WatchLogTitle, absoluteEpNum: number): boolean { + for (const season of title.seasons) { + const rel = absoluteEpNum - season.offset; + if (rel >= 1 && rel <= season.episodes && (season.skippedEpisodes ?? []).includes(rel)) { + return true; + } + } + return false; + } + + /** + * In-memory toggle of an episode's watched state plus auto-complete logic. + * Schedules a debounced save and does NOT notify listeners — caller is + * responsible for targeted DOM updates. Returns the (mutated) title. + */ + applyEpisodeWatchedToggle( + id: string, + episodeNumber: number, + watched: boolean, + ): WatchLogTitle | null { + const title = this.getTitle(id); + if (!title) return null; + + if (watched) { + if (!title.watchedEpisodes.includes(episodeNumber)) { + title.watchedEpisodes.push(episodeNumber); + title.watchedEpisodes.sort((a, b) => a - b); + } + const now = new Date().toISOString(); + if (title.totalEpisodes > 1) { + void this.historyManager?.log( + `${title.title} (${title.type}) episode ${episodeNumber} was marked as watched on ${formatHistoryDate(now)}`, + { source: 'Watchlist', action: 'watched', titleName: title.title }, + ); + } else { + void this.historyManager?.log( + `${title.title} (${title.type}) was marked as watched on ${formatHistoryDate(now)}`, + { source: 'Watchlist', action: 'watched', titleName: title.title }, + ); + } + } else { + title.watchedEpisodes = title.watchedEpisodes.filter((e) => e !== episodeNumber); + } + + title.dateModified = new Date().toISOString(); + this.applyAutoCompleteRules(title); + this.scheduleEpisodeSave(id); + return title; + } + + /** Shared auto-complete / un-complete rules. Mutates title in place. */ + private applyAutoCompleteRules(title: WatchLogTitle): void { + const effectiveTotal = this.getEffectiveTotal(title); + if ( + this.plugin.settings.autoCompleteOnLastEpisode && + effectiveTotal > 0 && + title.watchedEpisodes.length >= effectiveTotal + ) { + if (title.status !== 'Completed') { + title.status = 'Completed'; + // Match updateTitle(): completed titles drop priority. + title.priority = ''; + } + if (this.plugin.settings.setFinishDateAutomatically && !title.dateFinished) { + title.dateFinished = new Date().toISOString().split('T')[0] ?? null; + } + } else if (title.status === 'Completed' && title.watchedEpisodes.length < effectiveTotal) { + title.status = 'Watching'; + title.dateFinished = null; + } + } + // ── Progress helpers ────────────────────────────────────────────────────────── getProgress(title: WatchLogTitle): number { - if (title.totalEpisodes === 0) return 0; - return Math.round((title.watchedEpisodes.length / title.totalEpisodes) * 100); + const effective = this.getEffectiveTotal(title); + if (effective === 0) return 0; + return Math.min(100, Math.round((title.watchedEpisodes.length / effective) * 100)); } getNextUnwatchedEpisode(title: WatchLogTitle): number | null { @@ -514,24 +942,31 @@ export class DataManager { } const now = new Date().toISOString(); if (title.totalEpisodes > 1) { - void this.historyManager?.log(`${title.title} (${title.type}) episode ${episodeNumber} was marked as watched on ${formatHistoryDate(now)}`); + void this.historyManager?.log( + `${title.title} (${title.type}) episode ${episodeNumber} was marked as watched on ${formatHistoryDate(now)}`, + { source: 'Watchlist', action: 'watched', titleName: title.title }, + ); } else { - void this.historyManager?.log(`${title.title} (${title.type}) was marked as watched on ${formatHistoryDate(now)}`); + void this.historyManager?.log( + `${title.title} (${title.type}) was marked as watched on ${formatHistoryDate(now)}`, + { source: 'Watchlist', action: 'watched', titleName: title.title }, + ); } } else { title.watchedEpisodes = title.watchedEpisodes.filter((e) => e !== episodeNumber); } + const effectiveTotal = this.getEffectiveTotal(title); if ( this.plugin.settings.autoCompleteOnLastEpisode && - title.totalEpisodes > 0 && - title.watchedEpisodes.length >= title.totalEpisodes + effectiveTotal > 0 && + title.watchedEpisodes.length >= effectiveTotal ) { title.status = 'Completed'; if (this.plugin.settings.setFinishDateAutomatically && !title.dateFinished) { title.dateFinished = new Date().toISOString().split('T')[0] ?? null; } - } else if (title.status === 'Completed' && title.watchedEpisodes.length < title.totalEpisodes) { + } else if (title.status === 'Completed' && title.watchedEpisodes.length < effectiveTotal) { title.status = 'Watching'; title.dateFinished = null; } @@ -552,16 +987,17 @@ export class DataManager { title.watchedEpisodes = title.watchedEpisodes.filter((e) => !remove.has(e)); } + const effectiveTotal = this.getEffectiveTotal(title); if ( this.plugin.settings.autoCompleteOnLastEpisode && - title.totalEpisodes > 0 && - title.watchedEpisodes.length >= title.totalEpisodes + effectiveTotal > 0 && + title.watchedEpisodes.length >= effectiveTotal ) { title.status = 'Completed'; if (this.plugin.settings.setFinishDateAutomatically && !title.dateFinished) { title.dateFinished = new Date().toISOString().split('T')[0] ?? null; } - } else if (title.status === 'Completed' && title.watchedEpisodes.length < title.totalEpisodes) { + } else if (title.status === 'Completed' && title.watchedEpisodes.length < effectiveTotal) { title.status = 'Watching'; title.dateFinished = null; } @@ -591,6 +1027,8 @@ export class DataManager { } } + private lastMarkdownPathById: Map<string, string> = new Map(); + async updateMarkdownFile(title: WatchLogTitle): Promise<void> { const root = this.plugin.settings.rootFolder; const folderPath = normalizePath(`${root}/${title.type}`); @@ -599,12 +1037,28 @@ export class DataManager { const filePath = normalizePath(`${folderPath}/${safeTitle}.md`); const progress = this.getProgress(title); const content = this.buildMarkdownContent(title, progress); + + // If a different path was previously written for this title (rename/type change), + // remove the stale file before writing the new one. + const previousPath = this.lastMarkdownPathById.get(title.id); + if (previousPath && previousPath !== filePath) { + try { + const oldFile = this.app.vault.getAbstractFileByPath(previousPath); + if (oldFile instanceof TFile) { + await this.app.fileManager.trashFile(oldFile); + } + } catch { + // best-effort cleanup + } + } + const existing = this.app.vault.getAbstractFileByPath(filePath); if (existing instanceof TFile) { await this.app.vault.modify(existing, content); } else { await this.app.vault.create(filePath, content); } + this.lastMarkdownPathById.set(title.id, filePath); } async createMarkdownFileIfMissing(title: WatchLogTitle): Promise<boolean> { diff --git a/src/DraftChoiceModal.ts b/src/DraftChoiceModal.ts new file mode 100644 index 0000000..bce3f56 --- /dev/null +++ b/src/DraftChoiceModal.ts @@ -0,0 +1,46 @@ +import { App, Modal } from 'obsidian'; + +export type DraftAddChoice = 'watchlist' | 'book' | 'manga'; + +/** + * Small choice modal shown when adding a draft. Offers three inline options + * (Watchlist / Book / Manga), each routing to its own corresponding Add modal. + * The draft's text is shown as a subtitle to identify what's being added. + */ +export class DraftChoiceModal extends Modal { + constructor( + app: App, + private draftText: string, + private onChoice: (choice: DraftAddChoice) => void, + ) { + super(app); + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('wl-draft-choice-modal'); + + this.titleEl.setText('Add draft'); + contentEl.createDiv({ cls: 'wl-draft-choice-subtitle', text: this.draftText }); + + const grid = contentEl.createDiv({ cls: 'wl-draft-choice-grid' }); + + const makeOption = (label: string, choice: DraftAddChoice): void => { + const btn = grid.createEl('button', { cls: 'wl-draft-choice-btn' }); + btn.createDiv({ cls: 'wl-draft-choice-label', text: label }); + btn.addEventListener('click', () => { + this.close(); + this.onChoice(choice); + }); + }; + + makeOption('Add in Watchlist', 'watchlist'); + makeOption('Add book', 'book'); + makeOption('Add manga', 'manga'); + } + + onClose(): void { + this.contentEl.empty(); + } +} diff --git a/src/DraftsTab.ts b/src/DraftsTab.ts index 75801af..3c9b09a 100644 --- a/src/DraftsTab.ts +++ b/src/DraftsTab.ts @@ -1,381 +1,449 @@ -import Fuse from 'fuse.js'; -import type { EventRef } from 'obsidian'; -import type WatchLogPlugin from './main'; -import type { DataManager } from './DataManager'; -import type { DraftPersistState, WatchLogTitle } from './types'; -import { AddTitleModal } from './AddTitleModal'; - -interface LiveDraftEntry { - titleKey: string; - titleDisplay: string; - sources: string[]; - firstSeen: string; - added: boolean; -} - -export class DraftsTab { - private plugin: WatchLogPlugin; - private dataManager: DataManager; - private containerEl: HTMLElement; - private onCountChange: (count: number) => void; - - private eventRef: EventRef | null = null; - private destroyed = false; - private scanDebounceTimer: ReturnType<typeof setTimeout> | null = null; - private lastScanEntries: LiveDraftEntry[] = []; - private persistState: DraftPersistState = { - dismissed: [], - added: [], - firstSeen: {}, - titleDisplay: {}, - }; - - constructor( - containerEl: HTMLElement, - plugin: WatchLogPlugin, - dataManager: DataManager, - onCountChange: (count: number) => void, - ) { - this.containerEl = containerEl; - this.plugin = plugin; - this.dataManager = dataManager; - this.onCountChange = onCountChange; - } - - async render(): Promise<void> { - this.destroyed = false; - await this.loadPersistState(); - if (this.destroyed) return; - - const entries = await this.scanVault(); - if (this.destroyed) return; - - this.renderUI(entries); - this.registerChangeListener(); - } - - destroy(): void { - this.destroyed = true; - if (this.scanDebounceTimer) { - clearTimeout(this.scanDebounceTimer); - this.scanDebounceTimer = null; - } - if (this.eventRef) { - this.plugin.app.metadataCache.offref(this.eventRef); - this.eventRef = null; - } - } - - // ── Persistence ─────────────────────────────────────────────────────────────── - - private async loadPersistState(): Promise<void> { - const data = (await this.plugin.loadData()) as Record<string, unknown> | null; - const drafts = data?.['drafts'] as Partial<DraftPersistState> | undefined; - if (drafts) { - this.persistState = { - dismissed: drafts.dismissed ?? [], - added: drafts.added ?? [], - firstSeen: drafts.firstSeen ?? {}, - titleDisplay: drafts.titleDisplay ?? {}, - }; - } - } - - private async savePersistState(): Promise<void> { - const data = ((await this.plugin.loadData()) as Record<string, unknown> | null) ?? {}; - await this.plugin.saveData({ ...data, drafts: this.persistState }); - } - - // ── Vault scanning ──────────────────────────────────────────────────────────── - - private getTag(): string { - return this.plugin.settings.draftsVaultTag ?? '#watchlog'; - } - - private async scanVault(): Promise<LiveDraftEntry[]> { - const tag = this.getTag(); - const files = this.plugin.app.vault.getMarkdownFiles(); - // Map: lowercase title key → { sources, displayTitle } - const liveMap: Map<string, { sources: Set<string>; displayTitle: string }> = new Map(); - - for (const file of files) { - const cache = this.plugin.app.metadataCache.getFileCache(file); - const hasTag = cache?.tags?.some((t) => t.tag === tag) ?? false; - if (!hasTag) continue; - - try { - const content = await this.plugin.app.vault.cachedRead(file); - const lines = content.split('\n'); - for (const line of lines) { - if (!line.includes(tag)) continue; - const tagIdx = line.indexOf(tag); - const afterTag = line.slice(tagIdx + tag.length).trim(); - if (!afterTag) continue; - - // Each comma-separated segment after the tag is a separate title - const segments = afterTag.split(','); - for (const seg of segments) { - const displayTitle = seg.trim(); - if (!displayTitle || displayTitle.length > 100) continue; - const titleKey = displayTitle.toLowerCase(); - if (!liveMap.has(titleKey)) { - liveMap.set(titleKey, { sources: new Set(), displayTitle }); - } - liveMap.get(titleKey)!.sources.add(file.basename); - } - } - } catch { - // Skip unreadable files - } - } - - // Record firstSeen and displayTitle for newly discovered entries - let stateChanged = false; - const now = new Date().toISOString(); - for (const [key, { displayTitle }] of liveMap) { - if (!this.persistState.firstSeen[key]) { - this.persistState.firstSeen[key] = now; - stateChanged = true; - } - if (!this.persistState.titleDisplay[key]) { - this.persistState.titleDisplay[key] = displayTitle; - stateChanged = true; - } - } - if (stateChanged) { - await this.savePersistState(); - } - - // Build visible entries (dismissed entries are excluded entirely) - const entries: LiveDraftEntry[] = []; - for (const [key, { sources }] of liveMap) { - if (this.persistState.dismissed.includes(key)) continue; - entries.push({ - titleKey: key, - titleDisplay: this.persistState.titleDisplay[key] ?? key, - sources: Array.from(sources), - firstSeen: this.persistState.firstSeen[key] ?? now, - added: this.persistState.added.includes(key), - }); - } - - // Sort oldest-first (FIFO queue) - entries.sort((a, b) => a.firstSeen.localeCompare(b.firstSeen)); - this.lastScanEntries = entries; - return entries; - } - - // ── Event listener ──────────────────────────────────────────────────────────── - - private triggerDebouncedRender(): void { - if (this.scanDebounceTimer) clearTimeout(this.scanDebounceTimer); - this.scanDebounceTimer = setTimeout(() => { - this.scanDebounceTimer = null; - void this.render(); - }, 500); - } - - private registerChangeListener(): void { - if (this.eventRef) { - this.plugin.app.metadataCache.offref(this.eventRef); - } - this.eventRef = this.plugin.app.metadataCache.on('changed', (_file) => { - this.triggerDebouncedRender(); - }); - } - - // ── Fuzzy match helper ──────────────────────────────────────────────────────── - - private buildFuse(titles: WatchLogTitle[]): Fuse<WatchLogTitle> { - return new Fuse(titles, { - keys: ['title'], - threshold: 0.35, - includeScore: true, - }); - } - - private fuzzyMatchesWatchlist(displayTitle: string, fuse: Fuse<WatchLogTitle>): boolean { - const results = fuse.search(displayTitle); - return results.length > 0 && (results[0]?.score ?? 1) <= 0.35; - } - - // ── Rendering ───────────────────────────────────────────────────────────────── - - private renderUI(entries: LiveDraftEntry[]): void { - const el = this.containerEl; - el.empty(); - - const tag = this.getTag(); - const watchlistTitles = this.dataManager.getTitles(); - const fuse = this.buildFuse(watchlistTitles); - - // Compute fuzzy match results once per entry and cache them - const fuzzyCache = new Map<string, boolean>(); - for (const entry of entries) { - if (entry.added) { - fuzzyCache.set(entry.titleKey, false); - } else { - fuzzyCache.set(entry.titleKey, this.fuzzyMatchesWatchlist(entry.titleDisplay, fuse)); - } - } - - // Pending = not added AND not already present in the Watchlist (exact or fuzzy) - const pendingCount = entries.filter((e) => { - if (e.added) return false; - return !fuzzyCache.get(e.titleKey); - }).length; - this.onCountChange(pendingCount); - - // Notice banner — matches Custom Lists draft banner style (Fix 3) - el.createDiv({ - cls: 'wl-drafts-notice', - text: `⚠ Write ${tag} Movie Name in any note and it appears here automatically. Hit Add when you're ready to add it to your Watchlist.`, - }); - - // Count pill - const countWrap = el.createDiv({ cls: 'wl-list-title-wrap' }); - countWrap.createSpan({ cls: 'wl-list-count', text: String(pendingCount) }); - countWrap.createSpan({ - cls: 'wl-drafts-count-label', - text: ` Pending draft${pendingCount !== 1 ? 's' : ''}`, - }); - - if (entries.length === 0) { - el.createDiv({ - cls: 'wl-drafts-empty', - text: `No drafts found. Add ${tag} followed by a title in any vault note.`, - }); - return; - } - - // Sort: non-Watchlist entries first (oldest-first), Watchlist entries last (oldest-first) - entries.sort((a, b) => { - const aDup = fuzzyCache.get(a.titleKey) ?? false; - const bDup = fuzzyCache.get(b.titleKey) ?? false; - if (aDup !== bDup) return aDup ? 1 : -1; - return a.firstSeen.localeCompare(b.firstSeen); - }); - - // Cards (Fix 2 — Upcoming tab card style) - const cards = el.createDiv({ cls: 'wl-drafts-cards' }); - for (const entry of entries) { - this.renderCard(cards, entry, fuzzyCache.get(entry.titleKey) ?? false); - } - } - - private renderCard(cards: HTMLElement, entry: LiveDraftEntry, isDuplicate: boolean): void { - // Build class list (Fix 4 — dim watchlist rows; style added rows) - let cls = 'wl-drafts-card'; - if (isDuplicate) cls += ' wl-drafts-card-watchlist'; - if (entry.added) cls += ' wl-drafts-card-added'; - - const card = cards.createDiv({ cls }); - - // Title (left, bold) - card.createDiv({ cls: 'wl-drafts-card-title', text: entry.titleDisplay }); - - // Source link (center) - const sourceEl = card.createDiv({ cls: 'wl-drafts-card-source' }); - if (entry.sources.length > 0) { - const primaryNote = entry.sources[0]!; - const link = sourceEl.createSpan({ - cls: 'wl-drafts-source-link', - text: `[[${primaryNote}]]`, - }); - link.addEventListener('click', (e) => { - e.stopPropagation(); - void this.plugin.app.workspace.openLinkText(primaryNote, ''); - }); - if (entry.sources.length > 1) { - sourceEl.createSpan({ - cls: 'wl-drafts-source-count', - text: ` (${entry.sources.length})`, - attr: { title: entry.sources.join('\n') }, - }); - } - } - - // "In Watchlist" indicator (center-right, only when applicable) — Fix 4 - const dupEl = card.createDiv({ cls: 'wl-drafts-card-dup' }); - if (isDuplicate) { - dupEl.createSpan({ - text: 'In Watchlist', - attr: { title: 'This title already exists in your Watchlist' }, - }); - } - - // Actions (far right) - const actions = card.createDiv({ cls: 'wl-drafts-card-actions' }); - - if (entry.added || isDuplicate) { - actions.createSpan({ cls: 'wl-drafts-added-label', text: 'Added' }); - } else { - const addBtn = actions.createEl('button', { - cls: 'wl-btn wl-btn-sm wl-btn-primary', - text: 'Add', - }); - addBtn.addEventListener('click', () => this.openAddModal(entry)); - } - - const dismissBtn = actions.createEl('button', { - cls: 'wl-btn wl-btn-sm wl-drafts-dismiss', - text: '✕', - attr: { title: 'Dismiss' }, - }); - dismissBtn.addEventListener('click', () => void this.dismissEntry(entry.titleKey)); - } - - private rerenderFromCache(): void { - const entries = this.lastScanEntries - .filter((e) => !this.persistState.dismissed.includes(e.titleKey)) - .map((e) => ({ - ...e, - added: this.persistState.added.includes(e.titleKey), - })); - this.renderUI(entries); - } - - // ── Actions ─────────────────────────────────────────────────────────────────── - - private async dismissEntry(titleKey: string): Promise<void> { - if (!this.persistState.dismissed.includes(titleKey)) { - this.persistState.dismissed.push(titleKey); - } - this.persistState.added = this.persistState.added.filter((k) => k !== titleKey); - await this.savePersistState(); - this.rerenderFromCache(); - } - - private openAddModal(entry: LiveDraftEntry): void { - const modal = new AddTitleModal( - this.plugin.app, - this.plugin, - this.dataManager, - () => void this.afterAdded(entry.titleKey), - { - searchQuery: entry.titleDisplay, - title: entry.titleDisplay, - type: 'Anime', - episodes: 0, - duration: 0, - releaseDate: '', - link: '', - seasons: [], - }, - ); - modal.open(); - } - - private async afterAdded(titleKey: string): Promise<void> { - const behavior = this.plugin.settings.draftsAfterAdding ?? 'keep'; - if (behavior === 'remove') { - await this.dismissEntry(titleKey); - } else { - if (!this.persistState.added.includes(titleKey)) { - this.persistState.added.push(titleKey); - } - await this.savePersistState(); - this.rerenderFromCache(); - } - } -} +import Fuse from 'fuse.js'; +import type { EventRef } from 'obsidian'; +import type WatchLogPlugin from './main'; +import type { DataManager } from './DataManager'; +import type { DraftPersistState, WatchLogTitle } from './types'; +import { AddTitleModal } from './AddTitleModal'; +import { AddReadingModal } from './AddReadingModal'; +import { DraftChoiceModal } from './DraftChoiceModal'; + +interface LiveDraftEntry { + titleKey: string; + titleDisplay: string; + sources: string[]; + firstSeen: string; + added: boolean; +} + +export class DraftsTab { + private plugin: WatchLogPlugin; + private dataManager: DataManager; + private containerEl: HTMLElement; + private onCountChange: (count: number) => void; + + private eventRef: EventRef | null = null; + private destroyed = false; + private scanDebounceTimer: number | null = null; + private lastScanEntries: LiveDraftEntry[] = []; + private persistState: DraftPersistState = { + dismissed: [], + added: [], + firstSeen: {}, + titleDisplay: {}, + }; + + constructor( + containerEl: HTMLElement, + plugin: WatchLogPlugin, + dataManager: DataManager, + onCountChange: (count: number) => void, + ) { + this.containerEl = containerEl; + this.plugin = plugin; + this.dataManager = dataManager; + this.onCountChange = onCountChange; + } + + async render(): Promise<void> { + this.destroyed = false; + await this.loadPersistState(); + if (this.destroyed) return; + + const entries = await this.scanVault(); + if (this.destroyed) return; + + this.renderUI(entries); + this.registerChangeListener(); + } + + destroy(): void { + this.destroyed = true; + if (this.scanDebounceTimer) { + window.clearTimeout(this.scanDebounceTimer); + this.scanDebounceTimer = null; + } + if (this.eventRef) { + this.plugin.app.metadataCache.offref(this.eventRef); + this.eventRef = null; + } + } + + // ── Persistence ─────────────────────────────────────────────────────────────── + + private async loadPersistState(): Promise<void> { + const data = this.plugin.dataManager.getData() as unknown as Record<string, unknown>; + const drafts = data['drafts'] as Partial<DraftPersistState> | undefined; + if (drafts) { + this.persistState = { + dismissed: drafts.dismissed ?? [], + added: drafts.added ?? [], + firstSeen: drafts.firstSeen ?? {}, + titleDisplay: drafts.titleDisplay ?? {}, + }; + } + } + + private async savePersistState(): Promise<void> { + const data = this.plugin.dataManager.getData() as unknown as Record<string, unknown>; + data['drafts'] = this.persistState; + this.plugin.dataManager.queueSave(); + } + + // ── Vault scanning ──────────────────────────────────────────────────────────── + + private getTag(): string { + return this.plugin.settings.draftsVaultTag ?? '#watchlog'; + } + + /** + * Scans the vault for inline-tag draft mentions, returning a map of + * lowercase title key → { sources, displayTitle }. Pure (no persistState side + * effects) so it can back both the full render and the count-only badge scan. + * Inline-tag matching only — frontmatter tags are intentionally ignored. + */ + private static async scanTaggedFiles( + plugin: WatchLogPlugin, + tag: string, + ): Promise<Map<string, { sources: Set<string>; displayTitle: string }>> { + const files = plugin.app.vault.getMarkdownFiles(); + const liveMap: Map<string, { sources: Set<string>; displayTitle: string }> = new Map(); + + for (const file of files) { + const cache = plugin.app.metadataCache.getFileCache(file); + const hasTag = cache?.tags?.some((t) => t.tag === tag) ?? false; + if (!hasTag) continue; + + try { + const content = await plugin.app.vault.cachedRead(file); + const lines = content.split('\n'); + for (const line of lines) { + if (!line.includes(tag)) continue; + const tagIdx = line.indexOf(tag); + const afterTag = line.slice(tagIdx + tag.length).trim(); + if (!afterTag) continue; + + // Each comma-separated segment after the tag is a separate title + const segments = afterTag.split(','); + for (const seg of segments) { + const displayTitle = seg.trim(); + if (!displayTitle || displayTitle.length > 100) continue; + const titleKey = displayTitle.toLowerCase(); + if (!liveMap.has(titleKey)) { + liveMap.set(titleKey, { sources: new Set(), displayTitle }); + } + liveMap.get(titleKey)!.sources.add(file.basename); + } + } + } catch { + // Skip unreadable files + } + } + + return liveMap; + } + + private async scanVault(): Promise<LiveDraftEntry[]> { + const tag = this.getTag(); + const liveMap = await DraftsTab.scanTaggedFiles(this.plugin, tag); + + // Record firstSeen and displayTitle for newly discovered entries + let stateChanged = false; + const now = new Date().toISOString(); + for (const [key, { displayTitle }] of liveMap) { + if (!this.persistState.firstSeen[key]) { + this.persistState.firstSeen[key] = now; + stateChanged = true; + } + if (!this.persistState.titleDisplay[key]) { + this.persistState.titleDisplay[key] = displayTitle; + stateChanged = true; + } + } + if (stateChanged) { + await this.savePersistState(); + } + + // Build visible entries (dismissed entries are excluded entirely) + const entries: LiveDraftEntry[] = []; + for (const [key, { sources }] of liveMap) { + if (this.persistState.dismissed.includes(key)) continue; + entries.push({ + titleKey: key, + titleDisplay: this.persistState.titleDisplay[key] ?? key, + sources: Array.from(sources), + firstSeen: this.persistState.firstSeen[key] ?? now, + added: this.persistState.added.includes(key), + }); + } + + // Sort oldest-first (FIFO queue) + entries.sort((a, b) => a.firstSeen.localeCompare(b.firstSeen)); + this.lastScanEntries = entries; + return entries; + } + + // ── Event listener ──────────────────────────────────────────────────────────── + + private triggerDebouncedRender(): void { + if (this.scanDebounceTimer) window.clearTimeout(this.scanDebounceTimer); + this.scanDebounceTimer = window.setTimeout(() => { + this.scanDebounceTimer = null; + void this.render(); + }, 500); + } + + private registerChangeListener(): void { + if (this.eventRef) { + this.plugin.app.metadataCache.offref(this.eventRef); + } + this.eventRef = this.plugin.app.metadataCache.on('changed', (_file) => { + this.triggerDebouncedRender(); + }); + } + + // ── Fuzzy match helper ──────────────────────────────────────────────────────── + + private static buildFuse(titles: WatchLogTitle[]): Fuse<WatchLogTitle> { + return new Fuse(titles, { + keys: ['title'], + threshold: 0.35, + includeScore: true, + }); + } + + private static fuzzyMatchesWatchlist(displayTitle: string, fuse: Fuse<WatchLogTitle>): boolean { + const results = fuse.search(displayTitle); + return results.length > 0 && (results[0]?.score ?? 1) <= 0.35; + } + + /** + * Count-only drafts scan for the tab badge — mirrors the pending-count logic in + * renderUI without touching the DOM or mutating persistState. "Pending" = a tagged + * draft that is not dismissed, not marked added, and not already in the Watchlist + * (exact key or fuzzy title match). Cheap: only tagged files are read, the rest is + * in-memory. Inline-tag matching only. + */ + static async computePendingCount(plugin: WatchLogPlugin, dataManager: DataManager): Promise<number> { + const tag = plugin.settings.draftsVaultTag ?? '#watchlog'; + const liveMap = await DraftsTab.scanTaggedFiles(plugin, tag); + + const data = plugin.dataManager.getData() as unknown as Record<string, unknown>; + const drafts = data['drafts'] as Partial<DraftPersistState> | undefined; + const dismissed = new Set(drafts?.dismissed ?? []); + const added = new Set(drafts?.added ?? []); + const titleDisplay = drafts?.titleDisplay ?? {}; + + const fuse = DraftsTab.buildFuse(dataManager.getTitles()); + + let pending = 0; + for (const [key, { displayTitle }] of liveMap) { + if (dismissed.has(key) || added.has(key)) continue; + const display = titleDisplay[key] ?? displayTitle; + if (!DraftsTab.fuzzyMatchesWatchlist(display, fuse)) pending++; + } + return pending; + } + + // ── Rendering ───────────────────────────────────────────────────────────────── + + private renderUI(entries: LiveDraftEntry[]): void { + const el = this.containerEl; + el.empty(); + + const tag = this.getTag(); + const watchlistTitles = this.dataManager.getTitles(); + const fuse = DraftsTab.buildFuse(watchlistTitles); + + // Compute fuzzy match results once per entry and cache them + const fuzzyCache = new Map<string, boolean>(); + for (const entry of entries) { + if (entry.added) { + fuzzyCache.set(entry.titleKey, false); + } else { + fuzzyCache.set(entry.titleKey, DraftsTab.fuzzyMatchesWatchlist(entry.titleDisplay, fuse)); + } + } + + // Pending = not added AND not already present in the Watchlist (exact or fuzzy) + const pendingCount = entries.filter((e) => { + if (e.added) return false; + return !fuzzyCache.get(e.titleKey); + }).length; + this.onCountChange(pendingCount); + + if (this.plugin.settings.showHintBanners) { + el.createDiv({ + cls: 'wl-drafts-notice', + text: `⚠ Write ${tag} Movie Name in any note and it appears here automatically. Hit Add when you're ready to add it to your Watchlist.`, + }); + } + + // Count pill + const countWrap = el.createDiv({ cls: 'wl-list-title-wrap' }); + countWrap.createSpan({ cls: 'wl-list-count', text: String(pendingCount) }); + countWrap.createSpan({ + cls: 'wl-drafts-count-label', + text: ` Pending draft${pendingCount !== 1 ? 's' : ''}`, + }); + + if (entries.length === 0) { + el.createDiv({ + cls: 'wl-drafts-empty', + text: `No drafts found. Add ${tag} followed by a title in any vault note.`, + }); + return; + } + + // Sort: non-Watchlist entries first (oldest-first), Watchlist entries last (oldest-first) + entries.sort((a, b) => { + const aDup = fuzzyCache.get(a.titleKey) ?? false; + const bDup = fuzzyCache.get(b.titleKey) ?? false; + if (aDup !== bDup) return aDup ? 1 : -1; + return a.firstSeen.localeCompare(b.firstSeen); + }); + + // Cards (Upcoming tab card style) + const cards = el.createDiv({ cls: 'wl-drafts-cards' }); + for (const entry of entries) { + this.renderCard(cards, entry, fuzzyCache.get(entry.titleKey) ?? false); + } + } + + private renderCard(cards: HTMLElement, entry: LiveDraftEntry, isDuplicate: boolean): void { + // Build class list — dim watchlist rows; style added rows + let cls = 'wl-drafts-card'; + if (isDuplicate) cls += ' wl-drafts-card-watchlist'; + if (entry.added) cls += ' wl-drafts-card-added'; + + const card = cards.createDiv({ cls }); + + // Title (left, bold) + card.createDiv({ cls: 'wl-drafts-card-title', text: entry.titleDisplay }); + + // Source link (center) + const sourceEl = card.createDiv({ cls: 'wl-drafts-card-source' }); + if (entry.sources.length > 0) { + const primaryNote = entry.sources[0]!; + const link = sourceEl.createSpan({ + cls: 'wl-drafts-source-link', + text: `[[${primaryNote}]]`, + }); + link.addEventListener('click', (e) => { + e.stopPropagation(); + void this.plugin.app.workspace.openLinkText(primaryNote, ''); + }); + if (entry.sources.length > 1) { + sourceEl.createSpan({ + cls: 'wl-drafts-source-count', + text: ` (${entry.sources.length})`, + attr: { title: entry.sources.join('\n') }, + }); + } + } + + // "In Watchlist" indicator (center-right, only when applicable) + const dupEl = card.createDiv({ cls: 'wl-drafts-card-dup' }); + if (isDuplicate) { + dupEl.createSpan({ + text: 'In Watchlist', + attr: { title: 'This title already exists in your Watchlist' }, + }); + } + + // Actions (far right) + const actions = card.createDiv({ cls: 'wl-drafts-card-actions' }); + + if (entry.added || isDuplicate) { + actions.createSpan({ cls: 'wl-drafts-added-label', text: 'Added' }); + } else { + const addBtn = actions.createEl('button', { + cls: 'wl-btn wl-btn-sm wl-btn-primary', + text: 'Add', + }); + addBtn.addEventListener('click', () => this.openAddModal(entry)); + } + + const dismissBtn = actions.createEl('button', { + cls: 'wl-btn wl-btn-sm wl-drafts-dismiss', + text: '✕', + attr: { title: 'Dismiss' }, + }); + dismissBtn.addEventListener('click', () => void this.dismissEntry(entry.titleKey)); + } + + private rerenderFromCache(): void { + const entries = this.lastScanEntries + .filter((e) => !this.persistState.dismissed.includes(e.titleKey)) + .map((e) => ({ + ...e, + added: this.persistState.added.includes(e.titleKey), + })); + this.renderUI(entries); + } + + // ── Actions ─────────────────────────────────────────────────────────────────── + + private async dismissEntry(titleKey: string): Promise<void> { + if (!this.persistState.dismissed.includes(titleKey)) { + this.persistState.dismissed.push(titleKey); + } + this.persistState.added = this.persistState.added.filter((k) => k !== titleKey); + await this.savePersistState(); + this.rerenderFromCache(); + } + + private openAddModal(entry: LiveDraftEntry): void { + new DraftChoiceModal(this.plugin.app, entry.titleDisplay, (choice) => { + if (choice === 'watchlist') { + this.openWatchlistAddModal(entry); + } else { + this.openReadingAddModal(entry, choice); + } + }).open(); + } + + private openWatchlistAddModal(entry: LiveDraftEntry): void { + const modal = new AddTitleModal( + this.plugin.app, + this.plugin, + this.dataManager, + () => void this.afterAdded(entry.titleKey), + { + searchQuery: entry.titleDisplay, + title: entry.titleDisplay, + type: 'Anime', + episodes: 0, + duration: 0, + releaseDate: '', + link: '', + seasons: [], + }, + ); + modal.open(); + } + + private openReadingAddModal(entry: LiveDraftEntry, mode: 'book' | 'manga'): void { + new AddReadingModal( + this.plugin.app, + this.plugin, + this.plugin.readingDataManager, + mode, + () => void this.afterAdded(entry.titleKey), + undefined, + entry.titleDisplay, + ).open(); + } + + private async afterAdded(titleKey: string): Promise<void> { + const behavior = this.plugin.settings.draftsAfterAdding ?? 'keep'; + if (behavior === 'remove') { + await this.dismissEntry(titleKey); + } else { + if (!this.persistState.added.includes(titleKey)) { + this.persistState.added.push(titleKey); + } + await this.savePersistState(); + this.rerenderFromCache(); + } + } +} diff --git a/src/EditTitleModal.ts b/src/EditTitleModal.ts index ee0a2a1..2dd374d 100644 --- a/src/EditTitleModal.ts +++ b/src/EditTitleModal.ts @@ -4,16 +4,56 @@ import type { DataManager } from './DataManager'; import type { WatchLogTitle, Season } from './types'; import { formatDateDisplay, parseDateInput, parseReleaseDateInput } from './types'; -/** Parses a seasons textarea (one line per season: "Name: N") back to Season[]. */ +/** Parses "5,8,33-37,42" into a sorted unique flat array of numbers. */ +function parseSkipSpec(spec: string): number[] { + const result: number[] = []; + for (const part of spec.split(',').map((s) => s.trim()).filter(Boolean)) { + const rangeParts = part.split('-'); + if (rangeParts.length === 2) { + const from = parseInt(rangeParts[0] ?? '0', 10); + const to = parseInt(rangeParts[1] ?? '0', 10); + if (!isNaN(from) && !isNaN(to) && from <= to) { + for (let i = from; i <= to; i++) result.push(i); + } + } else { + const n = parseInt(part, 10); + if (!isNaN(n)) result.push(n); + } + } + return [...new Set(result)].sort((a, b) => a - b); +} + +/** Serializes a sorted number array into a compact skip-spec string like "5,8,33-37,42". */ +function serializeSkipSpec(nums: number[]): string { + if (nums.length === 0) return ''; + const sorted = [...new Set(nums)].sort((a, b) => a - b); + const parts: string[] = []; + let i = 0; + while (i < sorted.length) { + let j = i; + while (j + 1 < sorted.length && sorted[j + 1] === (sorted[j] as number) + 1) j++; + if (j > i) { + parts.push(`${sorted[i]}-${sorted[j]}`); + } else { + parts.push(String(sorted[i])); + } + i = j + 1; + } + return parts.join(','); +} + +/** Parses a seasons textarea (one line per season: "Name: N" or "Name: N (skip_spec)") back to Season[]. */ function parseSeasonsText(text: string): Season[] { const lines = text.split('\n').map((l) => l.trim()).filter(Boolean); const seasons: Season[] = []; let offset = 0; for (const line of lines) { - const match = line.match(/^(.+?):\s*(\d+)/); + // Match "Name: N" optionally followed by " (skip_spec)" + const match = line.match(/^(.+?):\s*(\d+)(?:\s*\(([^)]+)\))?/); if (match && match[1] && match[2]) { - const eps = parseInt(match[2]); - seasons.push({ name: match[1].trim(), episodes: eps, offset }); + const eps = parseInt(match[2], 10); + const skippedEpisodes = match[3] ? parseSkipSpec(match[3]) : []; + seasons.push({ name: match[1].trim(), episodes: eps, offset, skippedEpisodes }); offset += eps; } } @@ -21,7 +61,13 @@ function parseSeasonsText(text: string): Season[] { } function seasonsToText(seasons: Season[]): string { - return seasons.map((s) => `${s.name}: ${s.episodes}`).join('\n'); + return seasons.map((s) => { + const base = `${s.name}: ${s.episodes}`; + if (s.skippedEpisodes && s.skippedEpisodes.length > 0) { + return `${base} (${serializeSkipSpec(s.skippedEpisodes)})`; + } + return base; + }).join('\n'); } export class EditTitleModal extends Modal { @@ -45,6 +91,7 @@ export class EditTitleModal extends Modal { private fieldNotes: string; private fieldDateStarted: string; private fieldDateFinished: string; + private fieldPosterUrl: string; private skipDuplicateCheck = false; private duplicateWarningEl: HTMLElement | null = null; @@ -71,11 +118,12 @@ export class EditTitleModal extends Modal { this.fieldSeasonsText = seasonsToText(title.seasons); this.fieldStatus = title.status; this.fieldPriority = title.priority; - this.fieldReview = (title as unknown as { review?: string }).review ?? ''; + this.fieldReview = title.review ?? ''; this.fieldRating = title.rating; this.fieldNotes = title.notes; this.fieldDateStarted = title.dateStarted ?? ''; this.fieldDateFinished = title.dateFinished ?? ''; + this.fieldPosterUrl = title.manualPosterUrl ?? ''; } onOpen(): void { @@ -161,7 +209,26 @@ export class EditTitleModal extends Modal { attr: { type: 'number', min: '0' }, }); epsInput.value = String(this.fieldEpisodes); - epsInput.addEventListener('input', () => { this.fieldEpisodes = parseInt(epsInput.value) || 0; }); + const epsSkipLabel = epsRow.createSpan({ cls: 'wl-modal-skip-inline' }); + const epsWatchLabel = epsRow.createSpan({ cls: 'wl-modal-skip-inline' }); + + const updateSkipCounts = (): void => { + const parsed = parseSeasonsText(this.fieldSeasonsText); + const totalSkipped = parsed.reduce((sum, s) => sum + (s.skippedEpisodes?.length ?? 0), 0); + if (totalSkipped === 0) { + epsSkipLabel.textContent = ''; + epsWatchLabel.textContent = ''; + return; + } + const toWatch = Math.max(0, this.fieldEpisodes - totalSkipped); + epsSkipLabel.textContent = `· ${totalSkipped} to skip`; + epsWatchLabel.textContent = `· ${toWatch} to watch`; + }; + + epsInput.addEventListener('input', () => { + this.fieldEpisodes = parseInt(epsInput.value) || 0; + updateSkipCounts(); + }); // Duration const durRow = makeRow('Ep. duration (min)'); @@ -209,6 +276,20 @@ export class EditTitleModal extends Modal { linkInput.value = this.fieldLink; linkInput.addEventListener('input', () => { this.fieldLink = linkInput.value; }); + // Poster URL (manual override) — directly under External link + const posterRow = makeRow('Poster URL'); + const posterStack = posterRow.createDiv({ cls: 'wl-modal-input-stack' }); + const posterInput = posterStack.createEl('input', { + cls: 'wl-modal-input', + attr: { type: 'url', placeholder: 'https://example.com/poster.jpg' }, + }); + posterInput.value = this.fieldPosterUrl; + posterInput.addEventListener('input', () => { this.fieldPosterUrl = posterInput.value; }); + posterStack.createDiv({ + cls: 'wl-modal-info', + text: 'Override the auto-fetched cover. Leave blank to let WatchLog fetch one again.', + }); + // Date started const startRow = makeRow('Date started'); const startInput = startRow.createEl('input', { @@ -257,7 +338,7 @@ export class EditTitleModal extends Modal { const seasonsHelp = seasonsRow.createDiv({ cls: 'wl-modal-input-stack' }); const seasonsInput = seasonsHelp.createEl('textarea', { cls: 'wl-modal-textarea', - attr: { rows: '4', placeholder: 'Season 1: 12\nseason 2: 13' }, + attr: { rows: '4', placeholder: 'Season 1: 12\nSeason 2: 13 (5,8,33-37)' }, }); seasonsInput.value = this.fieldSeasonsText; @@ -265,18 +346,27 @@ export class EditTitleModal extends Modal { const updateTotalPreview = (): void => { const parsed = parseSeasonsText(seasonsInput.value); const total = parsed.reduce((sum, s) => sum + s.episodes, 0); - totalPreviewEl.textContent = `Total episodes: ${total}`; + const totalSkipped = parsed.reduce((sum, s) => sum + (s.skippedEpisodes?.length ?? 0), 0); + totalPreviewEl.textContent = totalSkipped > 0 + ? `Total episodes: ${total} · ${totalSkipped} to skip` + : `Total episodes: ${total}`; }; updateTotalPreview(); + updateSkipCounts(); seasonsInput.addEventListener('input', () => { this.fieldSeasonsText = seasonsInput.value; updateTotalPreview(); + updateSkipCounts(); }); seasonsHelp.createDiv({ cls: 'wl-modal-info', - text: 'One per line: "Season Name: N" (e.g. "Season 1: 12")', + text: 'Format: "Season Name: N" (e.g. "Season 1: 12")', + }); + seasonsHelp.createDiv({ + cls: 'wl-modal-info', + text: 'Skip episodes: add "(1,3,5-10)" after count (e.g. "Season 1: 48 (33-37)")', }); seasonsHelp.createDiv({ cls: 'wl-modal-info', @@ -349,6 +439,7 @@ export class EditTitleModal extends Modal { seasons, dateStarted: this.fieldDateStarted || null, dateFinished: this.fieldDateFinished || null, + manualPosterUrl: this.fieldPosterUrl.trim(), }; // Auto-mark all episodes watched when status is set to Completed @@ -358,7 +449,7 @@ export class EditTitleModal extends Modal { await this.dataManager.updateTitle(updated); - // Bug 2: sync updated releaseDate to any existing 'once' airtime entry + // Sync updated releaseDate to any existing 'once' airtime entry const allEntries = this.dataManager.getAirtimeEntries(); const existingAirtimeEntry = allEntries.find((e) => e.titleId === updated.id); if (existingAirtimeEntry && existingAirtimeEntry.schedule.recurrence === 'once') { diff --git a/src/HistoryManager.ts b/src/HistoryManager.ts index 08f6b46..18353ff 100644 --- a/src/HistoryManager.ts +++ b/src/HistoryManager.ts @@ -1,10 +1,16 @@ import { normalizePath } from 'obsidian'; import type WatchLogPlugin from './main'; +export type HistorySource = 'Watchlist' | 'Reading'; +export type HistoryAction = 'added' | 'completed' | 'deleted' | 'status' | 'rating' | 'watched'; + export interface HistoryEntry { id: string; timestamp: string; message: string; + source?: HistorySource; + action?: HistoryAction; + titleName?: string; } const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; @@ -41,16 +47,23 @@ export class HistoryManager { const parsed = JSON.parse(raw) as { entries?: HistoryEntry[] }; this.entries = Array.isArray(parsed.entries) ? parsed.entries : []; } - } catch { + } catch (e) { + console.warn('[WL] HistoryManager.load failed:', e); this.entries = []; } } - async log(message: string): Promise<void> { + async log( + message: string, + meta?: { source?: HistorySource; action?: HistoryAction; titleName?: string }, + ): Promise<void> { const entry: HistoryEntry = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, timestamp: new Date().toISOString(), message, + source: meta?.source, + action: meta?.action, + titleName: meta?.titleName, }; this.entries.push(entry); if (this.entries.length > this.MAX_ENTRIES) { @@ -63,6 +76,18 @@ export class HistoryManager { return [...this.entries].reverse(); } + /** Raw stored entries (oldest-first), for inclusion in a full backup. */ + exportEntries(): HistoryEntry[] { + return [...this.entries]; + } + + /** Replace the entire audit log (used by full-snapshot restore). */ + async restore(entries: HistoryEntry[]): Promise<void> { + const list = Array.isArray(entries) ? entries : []; + this.entries = list.length > this.MAX_ENTRIES ? list.slice(-this.MAX_ENTRIES) : list; + await this.save(); + } + private async save(): Promise<void> { try { await this.plugin.app.vault.adapter.write( diff --git a/src/ListTab.ts b/src/ListTab.ts index 32ae852..022ae23 100644 --- a/src/ListTab.ts +++ b/src/ListTab.ts @@ -1,2082 +1,2939 @@ -import type WatchLogPlugin from './main'; -import type { DataManager } from './DataManager'; -import type { WatchLogTitle, WatchLogGroup, TagDefinition, SavedFilterPreset } from './types'; -import { formatTime, formatDateDisplay, parseDateInput, getThemedColor } from './types'; -import { AddTitleModal } from './AddTitleModal'; -import { AddFromUrlModal } from './AddFromUrlModal'; -import { EditTitleModal } from './EditTitleModal'; -import { ConfirmModal } from './ConfirmModal'; - -// ── Display item type ───────────────────────────────────────────────────────── - -type DisplayItem = - | { kind: 'title'; data: WatchLogTitle } - | { kind: 'group'; data: WatchLogGroup; members: WatchLogTitle[] }; - -// ── Shared constants ────────────────────────────────────────────────────────── - -const PRIORITY_ORDER = ['High', 'Medium', 'Low'] as const; - -// ── ListTab ─────────────────────────────────────────────────────────────────── - -export class ListTab { - private container: HTMLElement; - private plugin: WatchLogPlugin; - private dataManager: DataManager; - - // Preserved state - filterTypeExclude: Set<string> = new Set(); - filterStatusExclude: Set<string> = new Set(); - filterPriorityExclude: Set<string> = new Set(); - filterRatingExclude: Set<string> = new Set(); - filterGroupExclude: Set<string> = new Set(); - filterSort = 'dateAdded'; - filterSortDir: 'asc' | 'desc' = 'desc'; - filterSecondSort = 'none'; - filterSecondSortDir: 'asc' | 'desc' = 'asc'; - searchQuery = ''; - expandedId: string | null = null; - collapsedSeasons: Set<number> = new Set(); - expandedGroups: Set<string> = new Set(); - renamingGroupId: string | null = null; - - // Selection mode - selectionMode = false; - selectedItems: Set<string> = new Set(); // title ids or group ids - - // Pin state — groups (persisted via DataManager) - pinnedGroupId: string | null = null; - - // Saved filter preset state - savedFilterActive = false; - private savedFilterBtnEl: HTMLButtonElement | null = null; - - // Empty-only filter flags (QoL 4) - filterRatingEmptyOnly = false; - filterPriorityEmptyOnly = false; - - // Recently arrived only filter (Feature 1) - filterRecentlyArrivedOnly = false; - - // Watchlist sub-tab (Feature 3) - currentSubTab: 'list' | 'history' = 'list'; - - // Virtual-scroll cleanup — removes the rAF-throttled scroll listener on re-render - private scrollCleanup: (() => void) | null = null; - - // Last known scroll position — updated continuously so rerenderTable() has the - // correct value even when called after an async operation has mutated the DOM. - private _lastScrollTop = 0; - private scrollPositionListener: (() => void) | null = null; - - constructor(container: HTMLElement, plugin: WatchLogPlugin, dataManager: DataManager) { - this.container = container; - this.plugin = plugin; - this.dataManager = dataManager; - this.scrollPositionListener = () => { - this._lastScrollTop = this.container.scrollTop; - }; - this.container.addEventListener('scroll', this.scrollPositionListener, { passive: true }); - this.pinnedGroupId = dataManager.getPinnedGroupId(); - - // Restore persisted filter state - const saved = plugin.settings.listFilters; - if (saved) { - this.filterTypeExclude = new Set(saved.typeExclude ?? []); - this.filterStatusExclude = new Set(saved.statusExclude ?? []); - this.filterGroupExclude = new Set(saved.groupExclude ?? []); - this.filterRatingExclude = new Set(saved.ratingExclude ?? []); - this.filterPriorityExclude = new Set(saved.priorityExclude ?? []); - const ms = ListTab.migrateSortKey(saved.sort ?? 'dateAdded-newest'); - this.filterSort = ms.key; - this.filterSortDir = saved.sortDir ?? ms.dir; - const ms2 = ListTab.migrateSortKey(saved.secondSort ?? 'none'); - this.filterSecondSort = ms2.key; - this.filterSecondSortDir = saved.secondSortDir ?? ms2.dir; - this.filterRatingEmptyOnly = saved.ratingEmptyOnly ?? false; - this.filterPriorityEmptyOnly = saved.priorityEmptyOnly ?? false; - this.filterRecentlyArrivedOnly = saved.recentlyArrivedOnly ?? false; - } - - // Bug 1: detect if current filters match the saved preset so the button stays green - const savedPreset = dataManager.getSavedFilterPreset(); - if (savedPreset) { - const setsMatch = (a: Set<string>, b: string[]) => - a.size === b.length && b.every((x) => a.has(x)); - this.savedFilterActive = - setsMatch(this.filterTypeExclude, savedPreset.typeExclude) && - setsMatch(this.filterStatusExclude, savedPreset.statusExclude) && - setsMatch(this.filterGroupExclude, savedPreset.groupExclude) && - setsMatch(this.filterRatingExclude, savedPreset.ratingExclude) && - setsMatch(this.filterPriorityExclude, savedPreset.priorityExclude) && - (this.filterRatingEmptyOnly === (savedPreset.ratingEmptyOnly ?? false)) && - (this.filterPriorityEmptyOnly === (savedPreset.priorityEmptyOnly ?? false)) && - (this.filterRecentlyArrivedOnly === (savedPreset.recentlyArrivedOnly ?? false)); - } - } - - private saveFiltersToSettings(): void { - this.plugin.settings.listFilters = { - typeExclude: Array.from(this.filterTypeExclude), - statusExclude: Array.from(this.filterStatusExclude), - groupExclude: Array.from(this.filterGroupExclude), - ratingExclude: Array.from(this.filterRatingExclude), - priorityExclude: Array.from(this.filterPriorityExclude), - sort: this.filterSort, - sortDir: this.filterSortDir, - secondSort: this.filterSecondSort, - secondSortDir: this.filterSecondSortDir, - ratingEmptyOnly: this.filterRatingEmptyOnly, - priorityEmptyOnly: this.filterPriorityEmptyOnly, - recentlyArrivedOnly: this.filterRecentlyArrivedOnly, - }; - void this.plugin.saveSettings(); - } - - private hasActiveFilter(): boolean { - return this.filterTypeExclude.size > 0 || - this.filterStatusExclude.size > 0 || - this.filterPriorityExclude.size > 0 || - this.filterRatingExclude.size > 0 || - this.filterGroupExclude.size > 0 || - this.filterRatingEmptyOnly || - this.filterPriorityEmptyOnly || - this.filterRecentlyArrivedOnly; - } - - private clearAllFilters(): void { - this.filterTypeExclude.clear(); - this.filterStatusExclude.clear(); - this.filterPriorityExclude.clear(); - this.filterRatingExclude.clear(); - this.filterGroupExclude.clear(); - this.filterRatingEmptyOnly = false; - this.filterPriorityEmptyOnly = false; - this.filterRecentlyArrivedOnly = false; - } - - private applyPreset(preset: SavedFilterPreset): void { - this.filterTypeExclude = new Set(preset.typeExclude); - this.filterStatusExclude = new Set(preset.statusExclude); - this.filterGroupExclude = new Set(preset.groupExclude); - this.filterRatingExclude = new Set(preset.ratingExclude); - this.filterPriorityExclude = new Set(preset.priorityExclude); - this.filterRatingEmptyOnly = preset.ratingEmptyOnly ?? false; - this.filterPriorityEmptyOnly = preset.priorityEmptyOnly ?? false; - this.filterRecentlyArrivedOnly = preset.recentlyArrivedOnly ?? false; - } - - private deactivateSavedFilter(): void { - if (this.savedFilterActive) { - this.savedFilterActive = false; - this.savedFilterBtnEl?.removeClass('wl-btn-preset-active'); - } - } - - private static isRecentlyArrived(title: WatchLogTitle): boolean { - if (!title.releaseDate || !/^\d{4}-\d{2}-\d{2}$/.test(title.releaseDate)) return false; - const releaseMs = new Date(title.releaseDate + 'T00:00:00').getTime(); - const today = new Date(); - today.setHours(0, 0, 0, 0); - const diffDays = (today.getTime() - releaseMs) / 86400000; - return diffDays >= 0 && diffDays <= 7; - } - - private titlePassesFilters(t: WatchLogTitle): boolean { - if (this.filterTypeExclude.size > 0 && this.filterTypeExclude.has(t.type)) return false; - if (this.filterRecentlyArrivedOnly) { - if (!ListTab.isRecentlyArrived(t)) return false; - } else if (this.filterStatusExclude.size > 0 && this.filterStatusExclude.has(t.status)) { - return false; - } - if (this.filterRatingEmptyOnly) { - if (t.rating !== 0) return false; - } else if (this.filterRatingExclude.size > 0 && this.filterRatingExclude.has(`${t.rating}★`)) { - return false; - } - if (this.filterPriorityEmptyOnly) { - if (t.priority) return false; - } else if (this.filterPriorityExclude.size > 0 && this.filterPriorityExclude.has(t.priority)) { - return false; - } - return true; - } - - destroy(): void { - if (this.scrollPositionListener) { - this.container.removeEventListener('scroll', this.scrollPositionListener); - this.scrollPositionListener = null; - } - if (this.scrollCleanup) { - this.scrollCleanup(); - this.scrollCleanup = null; - } - } - - render(): void { - this._lastScrollTop = this.container.scrollTop || this._lastScrollTop; - this.container.empty(); - this.container.addClass('wl-list'); - this.renderSubTabBar(); - if (this.currentSubTab === 'list') { - this.renderListContent(); - } else { - this.renderHistoryContent(); - } - } - - private renderSubTabBar(): void { - const bar = this.container.createDiv({ cls: 'wl-inner-tab-bar' }); - const listBtn = bar.createEl('button', { - cls: `wl-inner-tab-btn${this.currentSubTab === 'list' ? ' is-active' : ''}`, - text: 'List', - }); - const histBtn = bar.createEl('button', { - cls: `wl-inner-tab-btn${this.currentSubTab === 'history' ? ' is-active' : ''}`, - text: 'History', - }); - listBtn.addEventListener('click', () => { - if (this.currentSubTab === 'list') return; - this.currentSubTab = 'list'; - this.render(); - }); - histBtn.addEventListener('click', () => { - if (this.currentSubTab === 'history') return; - this.currentSubTab = 'history'; - this.render(); - }); - } - - private renderListContent(): void { - this.renderHeader(); - this.renderSearch(); - this.container.createDiv({ cls: 'wl-divider' }); - this.renderTable(this._lastScrollTop); - } - - private renderHistoryContent(): void { - const entries = this.plugin.historyManager?.getEntries() ?? []; - if (entries.length === 0) { - this.container.createDiv({ - cls: 'wl-history-empty', - text: 'No history yet. Actions in the Watchlist will appear here.', - }); - return; - } - const list = this.container.createDiv({ cls: 'wl-history-list' }); - for (const entry of entries) { - const row = list.createDiv({ cls: 'wl-history-entry' }); - row.textContent = entry.message; - } - } - - // ── Header ──────────────────────────────────────────────────────────────────── - - private renderHeader(): void { - const header = this.container.createDiv({ cls: 'wl-list-header' }); - - // Import progress bar (shown while a background import is running) - if (this.plugin.importProgress) { - const { current, total, cancel } = this.plugin.importProgress; - const progWrap = header.createDiv({ cls: 'wl-header-import-progress' }); - const track = progWrap.createDiv({ cls: 'wl-header-import-track' }); - const fill = track.createDiv({ cls: 'wl-header-import-fill' }); - fill.style.width = `${total > 0 ? Math.round((current / total) * 100) : 0}%`; - progWrap.createSpan({ cls: 'wl-header-import-text', text: `${current} / ${total}` }); - const cancelBtn = progWrap.createEl('button', { cls: 'wl-btn wl-btn-sm', text: 'Cancel import' }); - cancelBtn.addEventListener('click', () => cancel()); - } - - const controls = header.createDiv({ cls: 'wl-header-controls' }); - - // Saved filter preset button (shown only when a preset is stored) - const preset = this.dataManager.getSavedFilterPreset(); - if (preset) { - const savedBtn = controls.createEl('button', { - cls: `wl-btn wl-btn-sm${this.savedFilterActive ? ' wl-btn-preset-active' : ''}`, - text: 'Saved filter', - }); - this.savedFilterBtnEl = savedBtn; - savedBtn.addEventListener('click', () => { - this.applyPreset(preset); - this.savedFilterActive = true; - savedBtn.addClass('wl-btn-preset-active'); - this.saveFiltersToSettings(); - this.rerenderTable(); - }); - } else { - this.savedFilterBtnEl = null; - } - - // Filters dropdown button - this.renderFiltersDropdown(controls); - - // Sorting dropdown button - this.renderSortingDropdown(controls); - - // Action bar — visible when in selection mode AND items are selected - if (this.selectionMode && this.selectedItems.size > 0) { - this.renderActionBar(controls); - } - - // Select All / Deselect All button — visible in selection mode - if (this.selectionMode) { - const hasAny = this.selectedItems.size > 0; - const selAllBtn = controls.createEl('button', { cls: 'wl-btn wl-btn-sm', text: hasAny ? 'None' : 'All' }); - selAllBtn.title = hasAny ? 'Deselect all' : 'Select all visible'; - selAllBtn.addEventListener('click', () => { - if (this.selectedItems.size > 0) { - this.selectedItems.clear(); - } else { - for (const item of this.getDisplayItems()) { - this.selectedItems.add(item.data.id); - } - } - this.render(); - }); - } - - // Selection mode toggle button - const selBtn = controls.createEl('button', { - cls: `wl-btn wl-btn-sm${this.selectionMode ? ' is-active' : ''}`, - text: 'Select', - }); - selBtn.addEventListener('click', () => { - this.selectionMode = !this.selectionMode; - this.selectedItems.clear(); - this.render(); - }); - - // + Add from URL - const addUrlBtn = controls.createEl('button', { cls: 'wl-btn wl-btn-sm', text: '+add from URL' }); - addUrlBtn.addEventListener('click', () => { - new AddFromUrlModal(this.plugin.app, this.plugin, this.dataManager, () => { - this.render(); - }).open(); - }); - - // + Add - const addBtnWrap = controls.createDiv({ cls: 'wl-add-btn-wrap' }); - const addBtn = addBtnWrap.createEl('button', { cls: 'wl-add-btn', text: '+ add' }); - addBtn.addEventListener('click', (e) => { - e.stopPropagation(); - this.openAddTitleModal(); - }); - } - - 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 ▼' }); - const dotEl = btn.createSpan({ cls: 'wl-filter-dot' }); - if (this.hasActiveFilter()) { dotEl.show(); } else { dotEl.hide(); } - - const clearBtn = parent.createEl('button', { cls: 'wl-btn wl-btn-sm wl-btn-clear-filters', text: '✕ clear' }); - clearBtn.title = 'Clear all filters'; - if (this.hasActiveFilter()) { clearBtn.show(); } else { clearBtn.hide(); } - clearBtn.addEventListener('click', () => { - this.clearAllFilters(); - this.saveFiltersToSettings(); - this.deactivateSavedFilter(); - dotEl.hide(); - clearBtn.hide(); - this.rerenderTable(); - }); - - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const existing = activeDocument.querySelector('.wl-filters-panel'); - if (existing) { existing.remove(); return; } - - const panel = activeDocument.body.createDiv({ cls: 'wl-dropdown wl-filters-panel wl-filters-panel-popup' }); - const rect = btn.getBoundingClientRect(); - panel.style.top = `${rect.bottom + 4}px`; - panel.style.left = `${rect.left}px`; - - const typeOpts = this.plugin.settings.types.map((t) => t.name); - const statusOpts = this.plugin.settings.statuses.map((s) => s.name); - const ratingOpts = ['1★', '2★', '3★', '4★', '5★']; - const priorityOpts = this.plugin.settings.priorities.map((p) => p.name); - const groupOpts = this.dataManager.getGroups().map((g) => g.name); - - // Draft — live filter state is not mutated until Apply is clicked - const draft = { - typeExclude: new Set(this.filterTypeExclude), - statusExclude: new Set(this.filterStatusExclude), - ratingExclude: new Set(this.filterRatingExclude), - priorityExclude: new Set(this.filterPriorityExclude), - groupExclude: new Set(this.filterGroupExclude), - filterRatingEmptyOnly: this.filterRatingEmptyOnly, - filterPriorityEmptyOnly: this.filterPriorityEmptyOnly, - filterRecentlyArrivedOnly: this.filterRecentlyArrivedOnly, - }; - - const sections: Array<{ label: string; opts: string[]; excludeSet: Set<string> }> = [ - { label: 'Type', opts: typeOpts, excludeSet: draft.typeExclude }, - { label: 'Status', opts: statusOpts, excludeSet: draft.statusExclude }, - { label: 'Rating', opts: ratingOpts, excludeSet: draft.ratingExclude }, - { label: 'Priority', opts: priorityOpts, excludeSet: draft.priorityExclude }, - ]; - if (groupOpts.length > 0) { - sections.push({ label: 'Group', opts: groupOpts, excludeSet: draft.groupExclude }); - } - - const allCheckboxRefs: Array<{ cb: HTMLInputElement }> = []; - - // Global buttons - const globalRow = panel.createDiv({ cls: 'wl-filter-global-btns' }); - let recentlyArrivedCbRef: HTMLInputElement | null = null; - - const selectAllBtn = globalRow.createEl('button', { cls: 'wl-btn wl-btn-sm', text: 'Select all' }); - selectAllBtn.addEventListener('click', (ev) => { - ev.stopPropagation(); - for (const { excludeSet } of sections) excludeSet.clear(); - for (const { cb } of allCheckboxRefs) cb.checked = true; - draft.filterRecentlyArrivedOnly = false; - if (recentlyArrivedCbRef) recentlyArrivedCbRef.checked = false; - }); - const deselectAllBtn = globalRow.createEl('button', { cls: 'wl-btn wl-btn-sm', text: 'Deselect all' }); - deselectAllBtn.addEventListener('click', (ev) => { - ev.stopPropagation(); - for (const { opts, excludeSet } of sections) { - for (const opt of opts) excludeSet.add(opt); - } - for (const { cb } of allCheckboxRefs) cb.checked = false; - draft.filterRecentlyArrivedOnly = false; - if (recentlyArrivedCbRef) recentlyArrivedCbRef.checked = false; - }); - - // Save / Delete preset button - const existingPreset = this.dataManager.getSavedFilterPreset(); - const saveDeleteBtn = globalRow.createEl('button', { - cls: 'wl-btn wl-btn-sm', - text: existingPreset ? 'Delete' : 'Save', - }); - saveDeleteBtn.addEventListener('click', (ev) => { - void (async () => { - ev.stopPropagation(); - if (saveDeleteBtn.textContent === 'Save') { - await this.dataManager.setSavedFilterPreset({ - typeExclude: Array.from(draft.typeExclude), - statusExclude: Array.from(draft.statusExclude), - groupExclude: Array.from(draft.groupExclude), - ratingExclude: Array.from(draft.ratingExclude), - priorityExclude: Array.from(draft.priorityExclude), - ratingEmptyOnly: draft.filterRatingEmptyOnly, - priorityEmptyOnly: draft.filterPriorityEmptyOnly, - recentlyArrivedOnly: draft.filterRecentlyArrivedOnly, - }); - } else { - await this.dataManager.setSavedFilterPreset(null); - this.savedFilterActive = false; - } - panel.remove(); - this.render(); - })(); - }); - - // Collapsed state per section (collapsed by default) — QoL 3 - const sectionCollapsed: Record<string, boolean> = {}; - for (const { label } of sections) { - sectionCollapsed[label] = true; - } - - for (const { label, opts, excludeSet } of sections) { - const section = panel.createDiv({ cls: 'wl-filter-section' }); - - // Collapsible header (QoL 3) - const sectionHeader = section.createDiv({ cls: 'wl-filter-section-header' }); - sectionHeader.createSpan({ cls: 'wl-filter-label', text: label }); - const chevron = sectionHeader.createSpan({ cls: 'wl-filter-chevron', text: '▼' }); - - // Content container — hidden by default - const content = section.createDiv({ cls: 'wl-filter-section-content wl-hidden' }); - - sectionHeader.addEventListener('click', (ev) => { - ev.stopPropagation(); - sectionCollapsed[label] = !sectionCollapsed[label]; - const collapsed = sectionCollapsed[label] ?? true; - content.toggleClass('wl-hidden', collapsed); - chevron.textContent = collapsed ? '▼' : '▲'; - }); - - // Feature 1 — "Recently arrived" checkbox in Status section - if (label === 'Status') { - const raRow = content.createDiv({ cls: 'wl-filter-checkbox-row' }); - const raCb = raRow.createEl('input', { attr: { type: 'checkbox' } }); - raCb.checked = draft.filterRecentlyArrivedOnly; - raRow.createSpan({ cls: 'wl-recently-arrived-filter', text: '✦ Recently arrived' }); - recentlyArrivedCbRef = raCb; - - raCb.addEventListener('change', () => { - draft.filterRecentlyArrivedOnly = raCb.checked; - if (raCb.checked) { - // Clear status exclude set when Recently arrived is active - excludeSet.clear(); - for (const optCb of statusOptionCbs) optCb.checked = true; - } - }); - } - - // QoL 4 — "Empty" checkbox for Rating and Priority sections - let emptyCb: HTMLInputElement | null = null; - const optionCbs: HTMLInputElement[] = []; - const statusOptionCbs: HTMLInputElement[] = []; - - if (label === 'Rating' || label === 'Priority') { - const emptyRow = content.createDiv({ cls: 'wl-filter-checkbox-row' }); - emptyCb = emptyRow.createEl('input', { attr: { type: 'checkbox' } }); - emptyCb.checked = label === 'Rating' ? draft.filterRatingEmptyOnly : draft.filterPriorityEmptyOnly; - emptyRow.createSpan({ text: 'Empty' }); - const localEmptyCb = emptyCb; - - emptyCb.addEventListener('change', () => { - if (label === 'Rating') { - draft.filterRatingEmptyOnly = localEmptyCb.checked; - } else { - draft.filterPriorityEmptyOnly = localEmptyCb.checked; - } - if (localEmptyCb.checked) { - for (const optCb of optionCbs) { optCb.checked = true; } - excludeSet.clear(); - } - }); - } - - for (const opt of opts) { - const cbRow = content.createDiv({ cls: 'wl-filter-checkbox-row' }); - const cb = cbRow.createEl('input', { attr: { type: 'checkbox' } }); - cb.checked = !excludeSet.has(opt); - cbRow.createSpan({ text: opt }); - optionCbs.push(cb); - if (label === 'Status') statusOptionCbs.push(cb); - allCheckboxRefs.push({ cb }); - const localEmptyCb = emptyCb; - - cb.addEventListener('change', () => { - if (cb.checked) excludeSet.delete(opt); - else excludeSet.add(opt); - // Deactivate Recently arrived when a specific status is interacted with - if (label === 'Status' && recentlyArrivedCbRef) { - draft.filterRecentlyArrivedOnly = false; - recentlyArrivedCbRef.checked = false; - } - // Deactivate Empty when a specific option is interacted with - if ((label === 'Rating' || label === 'Priority') && localEmptyCb) { - if (label === 'Rating') draft.filterRatingEmptyOnly = false; - else draft.filterPriorityEmptyOnly = false; - localEmptyCb.checked = false; - } - }); - } - } - - // Apply — commits draft to live state, re-renders, and closes the panel - const applyBtn = panel.createEl('button', { cls: 'wl-btn wl-btn-sm wl-filter-apply-btn', text: 'Apply' }); - applyBtn.addEventListener('click', (ev) => { - ev.stopPropagation(); - this.filterTypeExclude = draft.typeExclude; - this.filterStatusExclude = draft.statusExclude; - this.filterRatingExclude = draft.ratingExclude; - this.filterPriorityExclude = draft.priorityExclude; - this.filterGroupExclude = draft.groupExclude; - this.filterRatingEmptyOnly = draft.filterRatingEmptyOnly; - this.filterPriorityEmptyOnly = draft.filterPriorityEmptyOnly; - this.filterRecentlyArrivedOnly = draft.filterRecentlyArrivedOnly; - this.saveFiltersToSettings(); - this.deactivateSavedFilter(); - const active = this.hasActiveFilter(); - if (active) { dotEl.show(); clearBtn.show(); } else { dotEl.hide(); clearBtn.hide(); } - this.rerenderTable(); - panel.remove(); - activeDocument.removeEventListener('mousedown', closer, false); - }); - - const closer = (ev: MouseEvent) => { - const target = ev.target as Node; - if (panel.contains(target) || wrap.contains(target)) return; - panel.remove(); - activeDocument.removeEventListener('mousedown', closer, false); - }; - window.setTimeout(() => activeDocument.addEventListener('mousedown', closer, false), 0); - }); - } - - private renderSortingDropdown(parent: HTMLElement): void { - const wrap = parent.createDiv({ cls: 'wl-add-btn-wrap' }); - const btn = wrap.createEl('button', { cls: 'wl-btn wl-btn-sm', text: 'Sorting ▼' }); - - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const existing = wrap.querySelector('.wl-dropdown'); - if (existing) { existing.remove(); return; } - - const panel = wrap.createDiv({ cls: 'wl-dropdown wl-sorting-panel' }); - - if (window.innerWidth <= 600) { - const rect = btn.getBoundingClientRect(); - panel.addClass('wl-sorting-panel-mobile'); - panel.style.top = `${rect.bottom + 4}px`; - } - - const sortOptions = [ - 'Title', 'Date added', 'Progress', 'Rating', 'Priority', - 'Started', 'Status', 'Date watched', 'Time left', 'Time watched', - ]; - - const addSortRow = (labelText: string, currentKey: string, currentDir: 'asc' | 'desc', isPrimary: boolean): void => { - const row = panel.createDiv({ cls: 'wl-filter-row' }); - row.createSpan({ cls: 'wl-filter-label', text: labelText }); - const sel = row.createEl('select', { cls: 'wl-select' }); - - if (!isPrimary) { - const noneOpt = sel.createEl('option', { text: 'None', value: 'none' }); - noneOpt.selected = currentKey === 'none'; - } - for (const opt of sortOptions) { - const el = sel.createEl('option', { text: opt, value: opt }); - if (this.sortLabelToKey(opt) === currentKey) el.selected = true; - } - - const dirBtn = row.createEl('button', { - cls: 'wl-btn wl-btn-sm wl-sort-dir-btn', - text: currentDir === 'asc' ? '↑' : '↓', - }); - dirBtn.title = currentDir === 'asc' - ? 'Ascending — click to switch to descending' - : 'Descending — click to switch to ascending'; - - sel.addEventListener('change', () => { - const newKey = sel.value === 'none' ? 'none' : this.sortLabelToKey(sel.value); - if (isPrimary) { - this.filterSort = newKey; - this.filterSortDir = ListTab.SORT_DEFAULT_DIR[newKey] ?? 'asc'; - } else { - this.filterSecondSort = newKey; - this.filterSecondSortDir = ListTab.SORT_DEFAULT_DIR[newKey] ?? 'asc'; - } - panel.remove(); - activeDocument.removeEventListener('click', closer, true); - this.saveFiltersToSettings(); - this.render(); - }); - - dirBtn.addEventListener('click', (ev) => { - ev.stopPropagation(); - if (isPrimary) { - this.filterSortDir = this.filterSortDir === 'asc' ? 'desc' : 'asc'; - } else { - this.filterSecondSortDir = this.filterSecondSortDir === 'asc' ? 'desc' : 'asc'; - } - panel.remove(); - activeDocument.removeEventListener('click', closer, true); - this.saveFiltersToSettings(); - this.render(); - }); - }; - - addSortRow('Sort by', this.filterSort, this.filterSortDir, true); - addSortRow('Then by', this.filterSecondSort, this.filterSecondSortDir, false); - - const closer = (ev: MouseEvent) => { - if (!wrap.contains(ev.target as Node)) { - panel.remove(); - activeDocument.removeEventListener('click', closer, true); - } - }; - window.setTimeout(() => activeDocument.addEventListener('click', closer, true), 0); - }); - } - - private renderActionBar(parent: HTMLElement): void { - const bar = parent.createDiv({ cls: 'wl-action-bar' }); - - // Delete button — same style as group delete button - const deleteBtn = bar.createEl('button', { - cls: 'wl-group-action-btn wl-group-action-btn-delete', - text: '✕', - }); - deleteBtn.title = 'Delete selected'; - deleteBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const count = this.selectedItems.size; - new ConfirmModal(this.plugin.app, `Delete ${count} selected item${count !== 1 ? 's' : ''}? This cannot be undone.`, () => { - void (async () => { - const groupIds = new Set(this.dataManager.getGroups().map((g) => g.id)); - const titleIds: string[] = []; - for (const id of Array.from(this.selectedItems)) { - if (groupIds.has(id)) { - await this.dataManager.removeGroup(id); - this.expandedGroups.delete(id); - } else { - titleIds.push(id); - if (this.expandedId === id) this.expandedId = null; - } - } - if (titleIds.length > 0) { - await this.dataManager.removeTitlesBatch(titleIds); - } - this.selectedItems.clear(); - this.render(); - })(); - }).open(); - }); - - // Change Status dropdown - const statusSelect = bar.createEl('select', { cls: 'wl-select wl-select-sm' }); - statusSelect.createEl('option', { text: 'Status…', value: '' }); - for (const s of this.plugin.settings.statuses.filter((s) => s.name !== 'To be released')) { - statusSelect.createEl('option', { text: s.name, value: s.name }); - } - statusSelect.addEventListener('change', () => { - void (async () => { - const newStatus = statusSelect.value; - if (!newStatus) return; - const groupIds = new Set(this.dataManager.getGroups().map((g) => g.id)); - for (const id of this.selectedItems) { - if (groupIds.has(id)) continue; - const t = this.dataManager.getTitle(id); - if (!t) continue; - t.status = newStatus; - await this.dataManager.updateTitle(t); - } - statusSelect.value = ''; - this.render(); - })(); - }); - - // Move to Group dropdown - const allGroups = this.dataManager.getGroups(); - const hasGroupSelected = Array.from(this.selectedItems).some((id) => - this.dataManager.getGroups().some((g) => g.id === id), - ); - const moveSelect = bar.createEl('select', { cls: 'wl-select wl-select-sm' }); - if (hasGroupSelected) { - moveSelect.disabled = true; - moveSelect.title = 'A group is selected — cannot move groups into other groups'; - } - moveSelect.createEl('option', { text: 'Group…', value: '' }); - for (const g of allGroups) { - moveSelect.createEl('option', { text: g.name, value: g.id }); - } - moveSelect.createEl('option', { text: 'Create new group…', value: '__new__' }); - moveSelect.addEventListener('change', () => { - void (async () => { - const value = moveSelect.value; - if (!value) return; - const groupIds = new Set(allGroups.map((g) => g.id)); - const titleIds = Array.from(this.selectedItems).filter((id) => !groupIds.has(id)); - - if (value === '__new__') { - // Inline group-name input (prompt() is not available in Obsidian/Electron) - moveSelect.value = ''; - moveSelect.hide(); - const nameInput = bar.createEl('input', { - cls: 'wl-modal-input wl-group-name-input', - attr: { type: 'text', placeholder: 'Group name…', maxlength: '64' }, - }); - const confirmBtn = bar.createEl('button', { cls: 'wl-group-action-btn', text: '✓' }); - confirmBtn.title = 'Create group'; - const cancelBtn = bar.createEl('button', { cls: 'wl-group-action-btn', text: '✕' }); - cancelBtn.title = 'Cancel'; - - const doCreate = async () => { - const name = nameInput.value.trim(); - if (!name) return; - const newGroup: WatchLogGroup = { - id: this.dataManager.generateGroupId(name), - name, - titleIds, - dateAdded: new Date().toISOString(), - }; - await this.dataManager.addGroup(newGroup); - this.selectedItems.clear(); - this.selectionMode = false; - this.render(); - }; - confirmBtn.addEventListener('click', (e) => { e.stopPropagation(); void doCreate(); }); - cancelBtn.addEventListener('click', (e) => { e.stopPropagation(); this.render(); }); - nameInput.addEventListener('keydown', (e) => { - e.stopPropagation(); - if (e.key === 'Enter') void doCreate(); - if (e.key === 'Escape') this.render(); - }); - nameInput.addEventListener('click', (e) => e.stopPropagation()); - window.setTimeout(() => nameInput.focus(), 0); - return; - } - - for (const id of titleIds) { - await this.dataManager.addTitleToGroup(value, id); - } - moveSelect.value = ''; - this.selectedItems.clear(); - this.selectionMode = false; - this.render(); - })(); - }); - } - - // ── Modal helpers ───────────────────────────────────────────────────────────── - - private openAddTitleModal(): void { - new AddTitleModal(this.plugin.app, this.plugin, this.dataManager, () => { - this.render(); - }).open(); - } - - // ── Search & Controls ───────────────────────────────────────────────────────── - - private renderSearch(): void { - const searchWrap = this.container.createDiv({ cls: 'wl-search-wrap' }); - const input = searchWrap.createEl('input', { - cls: 'wl-search-input', - attr: { type: 'text', placeholder: 'Search titles...' }, - }); - input.value = this.searchQuery; - let searchDebounce = 0; - input.addEventListener('input', () => { - this.searchQuery = input.value; - window.clearTimeout(searchDebounce); - searchDebounce = window.setTimeout(() => { this.rerenderTable(); }, 250); - }); - } - - - private getSortLabelFor(key: string): string { - const map: Record<string, string> = { - title: 'Title', - dateAdded: 'Date added', - progress: 'Progress', - rating: 'Rating', - priority: 'Priority', - started: 'Started', - status: 'Status', - dateWatched: 'Date watched', - timeLeft: 'Time left', - timeWatched: 'Time watched', - }; - return map[key] ?? 'Date added'; - } - - private sortLabelToKey(label: string): string { - const map: Record<string, string> = { - 'Title': 'title', - 'Date added': 'dateAdded', - 'Progress': 'progress', - 'Rating': 'rating', - 'Priority': 'priority', - 'Started': 'started', - 'Status': 'status', - 'Date watched': 'dateWatched', - 'Time left': 'timeLeft', - 'Time watched': 'timeWatched', - }; - return map[label] ?? 'dateAdded'; - } - - private static readonly SORT_DEFAULT_DIR: Record<string, 'asc' | 'desc'> = { - title: 'asc', - dateAdded: 'desc', - progress: 'desc', - rating: 'desc', - priority: 'asc', - started: 'asc', - status: 'asc', - dateWatched: 'desc', - timeLeft: 'desc', - timeWatched: 'desc', - }; - - private static migrateSortKey(oldKey: string): { key: string; dir: 'asc' | 'desc' } { - const map: Record<string, { key: string; dir: 'asc' | 'desc' }> = { - 'title-asc': { key: 'title', dir: 'asc' }, - 'title-desc': { key: 'title', dir: 'desc' }, - 'dateAdded-newest': { key: 'dateAdded', dir: 'desc' }, - 'dateAdded-oldest': { key: 'dateAdded', dir: 'asc' }, - 'progress-high': { key: 'progress', dir: 'desc' }, - 'progress-low': { key: 'progress', dir: 'asc' }, - 'rating-high': { key: 'rating', dir: 'desc' }, - 'rating-low': { key: 'rating', dir: 'asc' }, - 'priority': { key: 'priority', dir: 'asc' }, - 'started-asc': { key: 'started', dir: 'asc' }, - 'started-desc': { key: 'started', dir: 'desc' }, - 'status-asc': { key: 'status', dir: 'asc' }, - 'status-desc': { key: 'status', dir: 'desc' }, - 'dateWatched-newest': { key: 'dateWatched', dir: 'desc' }, - 'dateWatched-oldest': { key: 'dateWatched', dir: 'asc' }, - 'timeLeft-high': { key: 'timeLeft', dir: 'desc' }, - 'timeLeft-low': { key: 'timeLeft', dir: 'asc' }, - 'timeWatched-high': { key: 'timeWatched', dir: 'desc' }, - 'timeWatched-low': { key: 'timeWatched', dir: 'asc' }, - 'none': { key: 'none', dir: 'asc' }, - }; - return map[oldKey] ?? { key: oldKey, dir: 'desc' }; - } - - private static sortBaseAndDirToKey(base: string, dir: 'asc' | 'desc'): string { - const map: Record<string, { asc: string; desc: string }> = { - title: { asc: 'title-asc', desc: 'title-desc' }, - dateAdded: { asc: 'dateAdded-oldest', desc: 'dateAdded-newest' }, - progress: { asc: 'progress-low', desc: 'progress-high' }, - rating: { asc: 'rating-low', desc: 'rating-high' }, - priority: { asc: 'priority', desc: 'priority' }, - started: { asc: 'started-asc', desc: 'started-desc' }, - status: { asc: 'status-asc', desc: 'status-desc' }, - dateWatched: { asc: 'dateWatched-oldest', desc: 'dateWatched-newest' }, - timeLeft: { asc: 'timeLeft-low', desc: 'timeLeft-high' }, - timeWatched: { asc: 'timeWatched-low', desc: 'timeWatched-high' }, - }; - return map[base]?.[dir] ?? 'dateAdded-newest'; - } - - // ── Table rendering ─────────────────────────────────────────────────────────── - - rerenderTable(): void { - const savedScroll = this._lastScrollTop; - const existing = this.container.querySelector('.wl-table-section'); - if (existing) existing.remove(); - this.renderTable(savedScroll); - } - - private renderTable(restoreScroll?: number): void { - // Tear down any previous scroll listener before rebuilding the DOM - if (this.scrollCleanup) { - this.scrollCleanup(); - this.scrollCleanup = null; - } - - const section = this.container.createDiv({ cls: 'wl-table-section' }); - const items = this.getDisplayItems(); - - const titleCount = items.reduce((n, item) => n + (item.kind === 'title' ? 1 : 0), 0); - const groupCount = items.reduce((n, item) => n + (item.kind === 'group' ? 1 : 0), 0); - const countEl = section.createDiv({ cls: 'wl-results-count' }); - const parts: string[] = []; - if (titleCount > 0) parts.push(`${titleCount} title${titleCount !== 1 ? 's' : ''}`); - if (groupCount > 0) parts.push(`${groupCount} group${groupCount !== 1 ? 's' : ''}`); - countEl.textContent = parts.length > 0 ? parts.join(', ') : '0 titles'; - - const tableCls = this.selectionMode ? 'wl-table wl-selection-mode' : 'wl-table'; - const table = section.createDiv({ cls: tableCls }); - const colHeader = table.createDiv({ cls: 'wl-table-header-row' }); - if (this.selectionMode) { - colHeader.createDiv({ cls: 'wl-col-select-h' }); - } - colHeader.createDiv({ cls: 'wl-col-title-h', text: 'Title' }); - colHeader.createDiv({ cls: 'wl-col-priority-h', text: 'Priority' }); - colHeader.createDiv({ cls: 'wl-col-started-h', text: 'Started' }); - colHeader.createDiv({ cls: 'wl-col-rating-h', text: 'Rating' }); - colHeader.createDiv({ cls: 'wl-col-status-h', text: 'Status' }); - - if (items.length === 0) { - table.createDiv({ cls: 'wl-empty-state', text: 'No titles match your filters.' }); - return; - } - - this.mountVirtualRows(table, items, restoreScroll); - } - - /** - * Virtualised list renderer. - * - * Phase 1 — all items are rendered into a hidden measurement container so we - * read their *actual* heights in one synchronous batch (single forced layout). - * The container is removed before the virtual one is created. - * - * Phase 2 — a single fixed-height `wl-virt-container` div is inserted. Its - * height is set once from the cached offset table and never changes during - * scroll, so the scrollable area is perfectly stable (CLS = 0 while scrolling). - * - * Phase 3 — on each scroll event (rAF-throttled, passive) we binary-search the - * offset table to find which items are visible and absolutely-position only - * those inside the container. No layout reads happen inside the handler. - * - * On expand/collapse `rerenderTable()` is called, which destroys and recreates - * the entire virtualisation — remeasuring affected rows automatically. - */ - private mountVirtualRows(table: HTMLElement, items: DisplayItem[], restoreScroll?: number): void { - const BUFFER_ROWS = 5; - const INTER_ITEM_GAP = 8; // px — mirrors the CSS margin-top between top-level rows - const FALLBACK_H = 44; // px — used when offsetHeight returns 0 and for buffer math - - // ── Phase 1: uniform row heights ────────────────────────────────────────── - const ROW_H = 68; // px — covers a standard unexpanded title row with badge + progress bar - const heights: number[] = items.map(() => ROW_H); - - // ── Phase 1b: measure the expanded title row (accordion included) ───────── - // Only one title can be expanded at a time, so this is a single DOM measurement. - if (this.expandedId !== null) { - const expandedIdx = items.findIndex( - (item) => item.kind === 'title' && item.data.id === this.expandedId, - ); - if (expandedIdx !== -1) { - const item = items[expandedIdx]; - if (item?.kind === 'title') { - const probe = activeDocument.body.createDiv(); - probe.style.cssText = `position:absolute;left:-9999px;top:0;width:${table.clientWidth}px;visibility:hidden;`; - this.renderRow(probe, item.data); - const measured = probe.offsetHeight; - probe.remove(); - if (measured > 0) heights[expandedIdx] = measured; - } - } - } - - // ── Phase 1c: measure expanded group rows ──────────────────────────────── - // Multiple groups can be expanded simultaneously, so check every group item. - for (let i = 0; i < items.length; i++) { - const item = items[i]; - if (item?.kind !== 'group') continue; - if (!this.expandedGroups.has(item.data.id)) continue; - const probe = activeDocument.body.createDiv(); - probe.style.cssText = `position:absolute;left:-9999px;top:0;width:${table.clientWidth}px;visibility:hidden;`; - this.renderGroupRow(probe, item.data, item.members); - const measured = probe.offsetHeight; - probe.remove(); - if (measured > 0) heights[i] = measured; - } - - // ── Phase 2: cumulative offset table (includes inter-item gap) ──────────── - const offsets: number[] = []; - let totalHeight = 0; - for (let i = 0; i < items.length; i++) { - offsets.push(totalHeight); - totalHeight += heights[i] ?? FALLBACK_H; - if (i < items.length - 1) totalHeight += INTER_ITEM_GAP; - } - - // ── Phase 3: fixed-height container ────────────────────────────────────── - // Height is set once and never mutated during scroll — this is what keeps - // the total scrollable area stable and CLS at zero. - const virt = table.createDiv({ cls: 'wl-virt-container' }); - virt.style.height = `${totalHeight}px`; - - // Distance from the scroll container's content-top to virt's top edge. - // Computed once here (outside the scroll handler) using getBoundingClientRect. - const scrollEl = this.container; - const containerTopOffset = - virt.getBoundingClientRect().top - - scrollEl.getBoundingClientRect().top + - scrollEl.scrollTop; - - // Restore scroll AFTER containerTopOffset is computed (which needs scrollTop=0 - // on a fresh DOM) but BEFORE renderWindow() so it sees the correct position. - if (restoreScroll !== undefined) { - scrollEl.scrollTop = restoreScroll; - } - - // ── Phase 4: rAF-throttled scroll handler ───────────────────────────────── - let rafId = 0; - let lastStart = -1; - let lastEnd = -1; - - const renderWindow = (): void => { - // Read scroll state first — no DOM writes until after all reads - const scrollTop = scrollEl.scrollTop; - const viewH = scrollEl.clientHeight || 600; - - const bufferPx = BUFFER_ROWS * FALLBACK_H; - const relTop = scrollTop - containerTopOffset; - const windowTop = relTop - bufferPx; - const windowBot = relTop + viewH + bufferPx; - - // Binary search: first item whose bottom edge is past windowTop - let lo = 0; - let hi = items.length - 1; - while (lo <= hi) { - const mid = (lo + hi) >>> 1; - if ((offsets[mid] ?? 0) + (heights[mid] ?? FALLBACK_H) <= windowTop) { - lo = mid + 1; - } else { - hi = mid - 1; - } - } - const startIdx = Math.max(0, lo); - - // Linear scan from startIdx: last item whose top edge is before windowBot - let endIdx = startIdx; - for (let i = startIdx; i < items.length; i++) { - if ((offsets[i] ?? 0) >= windowBot) break; - endIdx = i; - } - - // Skip DOM work entirely if the visible range has not changed - if (startIdx === lastStart && endIdx === lastEnd) return; - lastStart = startIdx; - lastEnd = endIdx; - - // Write phase — clear old rows, insert visible slice - virt.empty(); - for (let i = startIdx; i <= endIdx; i++) { - const item = items[i]; - if (!item) continue; // guard: items array may be stale on concurrent re-render - const rowEl = virt.createDiv({ cls: 'wl-virt-item' }); - rowEl.style.top = `${offsets[i] ?? 0}px`; - if (item.kind === 'title') { - this.renderRow(rowEl, item.data); - } else { - this.renderGroupRow(rowEl, item.data, item.members); - } - } - }; - - renderWindow(); - - const onScroll = (): void => { - if (rafId !== 0) return; - rafId = window.requestAnimationFrame(() => { - rafId = 0; - renderWindow(); - }); - }; - - scrollEl.addEventListener('scroll', onScroll, { passive: true }); - this.scrollCleanup = (): void => { - scrollEl.removeEventListener('scroll', onScroll); - if (rafId !== 0) { - cancelAnimationFrame(rafId); - rafId = 0; - } - }; - } - - // ── Display items (merged groups + top-level titles) ────────────────────────── - - private getDisplayItems(): DisplayItem[] { - const groups = this.dataManager.getGroups(); - const groupedIds = this.dataManager.getGroupedTitleIds(); - const allTitles = this.dataManager.getTitles(); - const q = this.searchQuery.trim().toLowerCase(); - - // Top-level titles (not in any group), filtered - const topLevel = this.getFilteredSortedTitles().filter((t) => !groupedIds.has(t.id)); - - // Groups: skip those excluded by group filter; show if any member passes filters - const displayGroups: Array<{ kind: 'group'; data: WatchLogGroup; members: WatchLogTitle[] }> = []; - for (const group of groups) { - if (this.filterGroupExclude.size > 0 && this.filterGroupExclude.has(group.name)) continue; - - const members = group.titleIds - .map((id) => allTitles.find((t) => t.id === id)) - .filter((t): t is WatchLogTitle => t !== undefined); - - // Search: show group if name matches OR any member title matches - if (q) { - const nameMatch = group.name.toLowerCase().includes(q); - const memberMatch = members.some((m) => m.title.toLowerCase().includes(q)); - if (!nameMatch && !memberMatch) continue; - } - - // Show group if any member passes type/status/rating/priority filters (fixes BUG 3) - const anyFilterActive = this.filterTypeExclude.size > 0 || - this.filterStatusExclude.size > 0 || - this.filterRatingExclude.size > 0 || - this.filterPriorityExclude.size > 0; - if (anyFilterActive && !members.every((m) => this.titlePassesFilters(m))) continue; - - displayGroups.push({ kind: 'group', data: group, members }); - } - - const allItems: DisplayItem[] = [ - ...displayGroups, - ...topLevel.map((t) => ({ kind: 'title' as const, data: t })), - ]; - - return allItems.sort((a, b) => { - // Pinned item always first - const aPinned = (a.kind === 'title' && a.data.pinned) || (a.kind === 'group' && a.data.id === this.pinnedGroupId); - const bPinned = (b.kind === 'title' && b.data.pinned) || (b.kind === 'group' && b.data.id === this.pinnedGroupId); - if (aPinned && !bPinned) return -1; - if (!aPinned && bPinned) return 1; - return this.compareDisplayItems(a, b); - }); - } - - // Status sort order (ascending): Watching → Plan to watch → Completed → To be released → Dropped - private readonly STATUS_ORDER = ['Watching', 'Plan to watch', 'Completed', 'To be released', 'Dropped']; - - private getStatusIndex(status: string): number { - const i = this.STATUS_ORDER.indexOf(status); - return i === -1 ? 99 : i; - } - - private compareDisplayItemsByKey(key: string, a: DisplayItem, b: DisplayItem): number { - - const getName = (item: DisplayItem): string => - item.kind === 'title' ? item.data.title : item.data.name; - - const getProgress = (item: DisplayItem): number => - item.kind === 'title' - ? this.dataManager.getProgress(item.data) - : this.getGroupProgress(item.members); - - const getPriorityIndex = (item: DisplayItem): number => { - if (item.kind === 'title') { - const i = PRIORITY_ORDER.indexOf(item.data.priority as typeof PRIORITY_ORDER[number]); - return i === -1 ? 99 : i; - } - const indices = item.members - .map((m) => PRIORITY_ORDER.indexOf(m.priority as typeof PRIORITY_ORDER[number])) - .filter((i) => i !== -1); - return indices.length > 0 ? Math.min(...indices) : 99; - }; - - const getDateAdded = (item: DisplayItem): number => { - if (item.kind === 'title') return new Date(item.data.dateAdded).getTime(); - const times = item.members.map((m) => new Date(m.dateAdded).getTime()); - return times.length > 0 ? Math.max(...times) : 0; - }; - - const getStartedDate = (item: DisplayItem): number | null => { - if (item.kind === 'title') { - return item.data.dateStarted ? new Date(item.data.dateStarted).getTime() : null; - } - const dates = item.members - .map((m) => (m.dateStarted ? new Date(m.dateStarted).getTime() : null)) - .filter((d): d is number => d !== null); - return dates.length > 0 ? Math.min(...dates) : null; - }; - - const getStatusIdx = (item: DisplayItem): number => { - if (item.kind === 'title') return this.getStatusIndex(item.data.status); - return this.getStatusIndex(this.getGroupStatus(item.members)); - }; - - const getRating = (item: DisplayItem): number => { - if (item.kind === 'title') return item.data.rating; - const rated = item.members.filter((m) => m.rating > 0); - if (rated.length === 0) return 0; - return rated.reduce((s, m) => s + m.rating, 0) / rated.length; - }; - - const getDateWatched = (item: DisplayItem): number => { - if (item.kind === 'title') { - return item.data.dateFinished ? new Date(item.data.dateFinished).getTime() : 0; - } - const dates = item.members - .map((m) => m.dateFinished ? new Date(m.dateFinished).getTime() : 0) - .filter((d) => d > 0); - return dates.length > 0 ? Math.max(...dates) : 0; - }; - - const getTimeLeft = (item: DisplayItem): number => - item.kind === 'title' - ? this.dataManager.calcTimeRemaining(item.data) - : item.members.reduce((s, m) => s + this.dataManager.calcTimeRemaining(m), 0); - - const getTimeWatched = (item: DisplayItem): number => - item.kind === 'title' - ? this.dataManager.calcTimeWatched(item.data) - : item.members.reduce((s, m) => s + this.dataManager.calcTimeWatched(m), 0); - - switch (key) { - case 'title-asc': return getName(a).localeCompare(getName(b)); - case 'title-desc': return getName(b).localeCompare(getName(a)); - case 'dateAdded-newest': return getDateAdded(b) - getDateAdded(a); - case 'dateAdded-oldest': return getDateAdded(a) - getDateAdded(b); - case 'progress-high': return getProgress(b) - getProgress(a); - case 'progress-low': return getProgress(a) - getProgress(b); - case 'rating-high': { - const ra = getRating(a), rb = getRating(b); - if (ra === 0 && rb === 0) return 0; - if (ra === 0) return 1; - if (rb === 0) return -1; - return rb - ra; - } - case 'rating-low': { - const ra = getRating(a), rb = getRating(b); - if (ra === 0 && rb === 0) return 0; - if (ra === 0) return 1; - if (rb === 0) return -1; - return ra - rb; - } - case 'priority': return getPriorityIndex(a) - getPriorityIndex(b); - case 'status-asc': return getStatusIdx(a) - getStatusIdx(b); - case 'status-desc': return getStatusIdx(b) - getStatusIdx(a); - case 'started-asc': { - const da = getStartedDate(a); - const db = getStartedDate(b); - if (da == null && db == null) return 0; - if (da == null) return 1; - if (db == null) return -1; - return da - db; - } - case 'started-desc': { - const da = getStartedDate(a); - const db = getStartedDate(b); - if (da == null && db == null) return 0; - if (da == null) return 1; - if (db == null) return -1; - return db - da; - } - case 'dateWatched-newest': return getDateWatched(b) - getDateWatched(a); - case 'dateWatched-oldest': { - const da = getDateWatched(a) || Infinity; - const db = getDateWatched(b) || Infinity; - return da - db; - } - case 'timeLeft-high': return getTimeLeft(b) - getTimeLeft(a); - case 'timeLeft-low': return getTimeLeft(a) - getTimeLeft(b); - case 'timeWatched-high': return getTimeWatched(b) - getTimeWatched(a); - case 'timeWatched-low': return getTimeWatched(a) - getTimeWatched(b); - default: return 0; - } - } - - private compareDisplayItems(a: DisplayItem, b: DisplayItem): number { - const primaryKey = ListTab.sortBaseAndDirToKey(this.filterSort, this.filterSortDir); - const primary = this.compareDisplayItemsByKey(primaryKey, a, b); - if (primary !== 0 || this.filterSecondSort === 'none') return primary; - const secondKey = ListTab.sortBaseAndDirToKey(this.filterSecondSort, this.filterSecondSortDir); - return this.compareDisplayItemsByKey(secondKey, a, b); - } - - // ── Individual title row ────────────────────────────────────────────────────── - - private renderRow(parent: HTMLElement, title: WatchLogTitle, indented = false): void { - const isExpanded = this.expandedId === title.id; - const isSelected = this.selectedItems.has(title.id); - const classes = ['wl-row', isExpanded ? 'is-expanded' : '', indented ? 'wl-row-indented' : '', isSelected ? 'wl-row-selected' : ''] - .filter(Boolean) - .join(' '); - const row = parent.createDiv({ cls: classes }); - row.dataset['titleId'] = title.id; - - // Selection checkbox column - if (this.selectionMode) { - const cbCol = row.createDiv({ cls: 'wl-col-select' }); - const cb = cbCol.createEl('input', { attr: { type: 'checkbox' } }); - cb.checked = isSelected; - cb.addEventListener('click', (e) => e.stopPropagation()); - cb.addEventListener('change', () => { - if (cb.checked) { this.selectedItems.add(title.id); } - else { this.selectedItems.delete(title.id); } - this.render(); - }); - } - - // Title + type badge + progress bar - const colTitle = row.createDiv({ cls: 'wl-col-title' }); - colTitle.createDiv({ cls: 'wl-row-title-text', text: title.title }); - const typeDef = this.getTagDef(title.type, this.plugin.settings.types); - const colored = this.plugin.settings.coloredTypeBadges; - const badgeRow = colTitle.createDiv({ cls: 'wl-badge-row' }); - const typeBadge = badgeRow.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); - if (ListTab.isRecentlyArrived(title)) { - badgeRow.createSpan({ cls: 'wl-recently-arrived', text: '· Recently arrived' }); - } - const barWrap = colTitle.createDiv({ cls: 'wl-progress-wrap' }); - const bar = barWrap.createDiv({ cls: 'wl-progress-bar' }); - bar.style.width = `${this.dataManager.getProgress(title)}%`; - - // Priority - const colPriority = row.createDiv({ cls: 'wl-col-priority' }); - if (title.priority) { - const priorityDef = this.getTagDef(title.priority, this.plugin.settings.priorities); - const pBadge = colPriority.createSpan({ cls: 'wl-priority-badge', text: title.priority }); - if (priorityDef) pBadge.style.color = priorityDef.color; - } - - // Started date - const colStarted = row.createDiv({ cls: 'wl-col-started' }); - colStarted.textContent = title.dateStarted ? formatDateDisplay(title.dateStarted) : '—'; - - // Rating - const colRating = row.createDiv({ cls: 'wl-col-rating' }); - if (title.rating > 0) { - colRating.createSpan({ cls: 'wl-row-rating', text: `★ ${title.rating}/5` }); - } else { - colRating.createSpan({ cls: 'wl-row-rating wl-row-rating-empty', text: '—' }); - } - - // Status badge (respects coloredTypeBadges toggle) - const colStatus = row.createDiv({ cls: 'wl-col-status' }); - const statusDef = this.getTagDef(title.status, this.plugin.settings.statuses); - const statusBadge = colStatus.createSpan({ - cls: colored ? 'wl-badge' : 'wl-badge-plain', - text: title.status, - }); - if (colored && statusDef) statusBadge.style.backgroundColor = getThemedColor(title.status, statusDef.color, this.plugin.settings.colorTheme); - - // Pin icon - const colPin = row.createDiv({ cls: 'wl-col-pin' }); - const pinIcon = colPin.createSpan({ - cls: `wl-pin-icon${title.pinned ? ' is-pinned' : ''}`, - text: '📌', - }); - pinIcon.title = title.pinned ? 'Unpin' : 'Pin to top'; - pinIcon.addEventListener('click', (e) => { - e.stopPropagation(); - void (async () => { - const t = this.dataManager.getTitle(title.id); - if (!t) return; - const newPinned = !t.pinned; - // Unpin all others first - if (newPinned) { - for (const other of this.dataManager.getTitles()) { - if (other.id !== t.id && other.pinned) { - other.pinned = false; - await this.dataManager.updateTitle(other); - } - } - } - t.pinned = newPinned; - await this.dataManager.updateTitle(t); - this.rerenderTable(); - })(); - }); - - row.addEventListener('click', () => { - if (this.selectionMode) { - if (this.selectedItems.has(title.id)) { this.selectedItems.delete(title.id); } - else { this.selectedItems.add(title.id); } - this.render(); - return; - } - this.expandedId = isExpanded ? null : title.id; - if (!isExpanded) { - this.collapsedSeasons = this.dataManager.getCollapsedSeasonsForTitle(title.id); - } - this.rerenderTable(); - }); - - if (isExpanded && !this.selectionMode) { - this.renderAccordion(parent, title); - } - } - - // ── Group header row ────────────────────────────────────────────────────────── - - private renderGroupRow( - parent: HTMLElement, - group: WatchLogGroup, - members: WatchLogTitle[], - ): void { - const isExpanded = this.expandedGroups.has(group.id); - const isSelected = this.selectedItems.has(group.id); - const row = parent.createDiv({ - cls: `wl-row wl-group-row${isExpanded ? ' is-expanded' : ''}${isSelected ? ' wl-row-selected' : ''}`, - }); - - // Add data-* attributes so filters can evaluate the group row the same way as title rows - row.dataset['type'] = members[0]?.type ?? ''; - row.dataset['status'] = this.getGroupStatus(members); - row.dataset['priority'] = this.getGroupHighestPriority(members) ?? ''; - row.dataset['rating'] = `${Math.round(this.getGroupRating(members))}★`; - row.dataset['group'] = group.name; - - // Selection checkbox column - if (this.selectionMode) { - const cbCol = row.createDiv({ cls: 'wl-col-select' }); - const cb = cbCol.createEl('input', { attr: { type: 'checkbox' } }); - cb.checked = isSelected; - cb.addEventListener('click', (e) => e.stopPropagation()); - cb.addEventListener('change', () => { - if (cb.checked) { this.selectedItems.add(group.id); } - else { this.selectedItems.delete(group.id); } - this.render(); - }); - } - - // Col: Title + type badge + group stats + progress bar - const colTitle = row.createDiv({ cls: 'wl-col-title' }); - - // Group name row: name (or rename input) + action buttons - if (this.renamingGroupId === group.id) { - // Rename mode - const renameRow = colTitle.createDiv({ cls: 'wl-group-name-row' }); - const nameInput = renameRow.createEl('input', { - cls: 'wl-group-rename-input', - attr: { type: 'text', value: group.name }, - }); - nameInput.addEventListener('click', (e) => e.stopPropagation()); - nameInput.addEventListener('keydown', (e) => { - e.stopPropagation(); - if (e.key === 'Enter') { - void (async () => { - const newName = nameInput.value.trim(); - if (newName && newName !== group.name) { - const updated = { ...group, name: newName }; - await this.dataManager.updateGroup(updated); - } - this.renamingGroupId = null; - this.rerenderTable(); - })(); - } else if (e.key === 'Escape') { - this.renamingGroupId = null; - this.rerenderTable(); - } - }); - const saveBtn = renameRow.createEl('button', { cls: 'wl-group-action-btn', text: '✓' }); - saveBtn.title = 'Save'; - saveBtn.addEventListener('click', (e) => { - e.stopPropagation(); - void (async () => { - const newName = nameInput.value.trim(); - if (newName && newName !== group.name) { - const updated = { ...group, name: newName }; - await this.dataManager.updateGroup(updated); - } - this.renamingGroupId = null; - this.rerenderTable(); - })(); - }); - const cancelBtn = renameRow.createEl('button', { cls: 'wl-group-action-btn', text: '✕' }); - cancelBtn.title = 'Cancel'; - cancelBtn.addEventListener('click', (e) => { - e.stopPropagation(); - this.renamingGroupId = null; - this.rerenderTable(); - }); - // Focus the input - window.setTimeout(() => nameInput.focus(), 0); - } else { - // Normal mode: name + pencil + delete buttons - const nameRow = colTitle.createDiv({ cls: 'wl-group-name-row' }); - nameRow.createSpan({ cls: 'wl-row-title-text wl-group-name', text: group.name }); - const renameBtn = nameRow.createEl('button', { cls: 'wl-group-action-btn', text: '✏' }); - renameBtn.title = 'Rename group'; - renameBtn.addEventListener('click', (e) => { - e.stopPropagation(); - this.renamingGroupId = group.id; - this.rerenderTable(); - }); - const deleteBtn = nameRow.createEl('button', { cls: 'wl-group-action-btn wl-group-action-btn-delete', text: '✕' }); - deleteBtn.title = 'Delete group (titles are kept)'; - deleteBtn.addEventListener('click', (e) => { - e.stopPropagation(); - new ConfirmModal(this.plugin.app, `Delete group "${group.name}"? All titles inside will be returned to the main list.`, () => { - void this.dataManager.removeGroup(group.id).then(() => { - this.expandedGroups.delete(group.id); - this.rerenderTable(); - }); - }).open(); - }); - } - - const primaryType = members[0]?.type ?? ''; - if (primaryType) { - const typeDef = this.getTagDef(primaryType, this.plugin.settings.types); - const colored = this.plugin.settings.coloredTypeBadges; - const typeBadge = colTitle.createSpan({ - cls: colored ? 'wl-badge wl-badge-sm' : 'wl-badge-plain', - text: primaryType, - }); - if (colored && typeDef) typeBadge.style.backgroundColor = getThemedColor(primaryType, typeDef.color, this.plugin.settings.colorTheme); - } - - // Group stats (always visible per spec 8f) - const statsEl = colTitle.createDiv({ cls: 'wl-group-stats' }); - const timeWatched = members.reduce((s, t) => s + this.dataManager.calcTimeWatched(t), 0); - const timeLeft = members.reduce((s, t) => s + this.dataManager.calcTimeRemaining(t), 0); - statsEl.createSpan({ text: `${members.length} title${members.length !== 1 ? 's' : ''}` }); - statsEl.createSpan({ text: ` · ${formatTime(timeWatched)} watched` }); - statsEl.createSpan({ text: ` · ${formatTime(timeLeft)} left` }); - - const barWrap = colTitle.createDiv({ cls: 'wl-progress-wrap' }); - barWrap.createDiv({ cls: 'wl-progress-bar' }).style.width = - `${this.getGroupProgress(members)}%`; - - // Col: Priority (highest among members) - const colGrpPriority = row.createDiv({ cls: 'wl-col-priority' }); - const highestPriority = this.getGroupHighestPriority(members); - if (highestPriority) { - const pDef = this.getTagDef(highestPriority, this.plugin.settings.priorities); - const pBadge = colGrpPriority.createSpan({ cls: 'wl-priority-badge', text: highestPriority }); - if (pDef) pBadge.style.color = pDef.color; - } - - // Col: Started (earliest among members) - const colStarted = row.createDiv({ cls: 'wl-col-started' }); - const groupStarted = this.getGroupStartedDate(members); - colStarted.textContent = groupStarted ? formatDateDisplay(groupStarted) : '—'; - - // Col: Rating (average) - const colRating = row.createDiv({ cls: 'wl-col-rating' }); - const avgRating = this.getGroupRating(members); - if (avgRating > 0) { - colRating.createSpan({ - cls: 'wl-row-rating', - text: `★ ${avgRating.toFixed(1)}/5`, - }); - } else { - colRating.createSpan({ cls: 'wl-row-rating wl-row-rating-empty', text: '—' }); - } - - // Col: Status (computed per priority rule, respects coloredTypeBadges) - const colStatus = row.createDiv({ cls: 'wl-col-status' }); - const groupStatus = this.getGroupStatus(members); - const groupStatusDef = this.getTagDef(groupStatus, this.plugin.settings.statuses); - const groupColored = this.plugin.settings.coloredTypeBadges; - const statusBadge = colStatus.createSpan({ - cls: groupColored ? 'wl-badge' : 'wl-badge-plain', - text: groupStatus, - }); - if (groupColored) { - if (groupStatusDef) { - statusBadge.style.backgroundColor = getThemedColor(groupStatus, groupStatusDef.color, this.plugin.settings.colorTheme); - } else if (groupStatus === 'In Progress') { - statusBadge.style.backgroundColor = getThemedColor('In Progress', '#724CF9', this.plugin.settings.colorTheme); - } - } - - // Col: Pin - const isGrpPinned = this.pinnedGroupId === group.id; - const colGrpPin = row.createDiv({ cls: 'wl-col-pin' }); - const grpPinIcon = colGrpPin.createSpan({ - cls: `wl-pin-icon${isGrpPinned ? ' is-pinned' : ''}`, - text: '📌', - }); - grpPinIcon.title = isGrpPinned ? 'Unpin' : 'Pin to top'; - grpPinIcon.addEventListener('click', (e) => { - e.stopPropagation(); - this.pinnedGroupId = isGrpPinned ? null : group.id; - void this.dataManager.setPinnedGroupId(this.pinnedGroupId); - // Unpin any pinned title - if (this.pinnedGroupId) { - for (const t of this.dataManager.getTitles()) { - if (t.pinned) { - t.pinned = false; - void this.dataManager.updateTitle(t); - } - } - } - this.rerenderTable(); - }); - - // Toggle expand/collapse (disabled while renaming or in selection mode) - row.addEventListener('click', () => { - if (this.renamingGroupId === group.id) return; - if (this.selectionMode) { - if (this.selectedItems.has(group.id)) { this.selectedItems.delete(group.id); } - else { this.selectedItems.add(group.id); } - this.render(); - return; - } - if (isExpanded) { - this.expandedGroups.delete(group.id); - } else { - this.expandedGroups.add(group.id); - } - this.rerenderTable(); - }); - - // Render member titles indented when expanded - if (isExpanded) { - for (const member of members) { - this.renderRow(parent, member, true); - } - } - } - - // ── Group helper calculations ───────────────────────────────────────────────── - - private getGroupProgress(members: WatchLogTitle[]): number { - const totalWatched = members.reduce((s, t) => s + t.watchedEpisodes.length, 0); - const totalEps = members.reduce((s, t) => s + t.totalEpisodes, 0); - if (totalEps === 0) return 0; - return Math.round((totalWatched / totalEps) * 100); - } - - private getGroupStatus(members: WatchLogTitle[]): string { - if (members.length === 0) return 'Plan to watch'; - const statuses = members.map((t) => t.status); - if (statuses.some((s) => s === 'Watching')) return 'Watching'; - if (statuses.every((s) => s === 'Completed')) return 'Completed'; - if (statuses.every((s) => s === 'Plan to watch')) return 'Plan to watch'; - return 'In Progress'; - } - - private getGroupStartedDate(members: WatchLogTitle[]): string | null { - const dates = members.map((t) => t.dateStarted).filter((d): d is string => d !== null); - if (dates.length === 0) return null; - return dates.sort()[0] ?? null; - } - - private getGroupRating(members: WatchLogTitle[]): number { - const rated = members.filter((t) => t.rating > 0); - if (rated.length === 0) return 0; - return rated.reduce((s, t) => s + t.rating, 0) / rated.length; - } - - private getGroupHighestPriority(members: WatchLogTitle[]): string | null { - let best: string | null = null; - let bestIdx = Infinity; - for (const m of members) { - const idx = PRIORITY_ORDER.indexOf(m.priority as typeof PRIORITY_ORDER[number]); - if (idx !== -1 && idx < bestIdx) { - bestIdx = idx; - best = m.priority; - } - } - return best; - } - - // ── Accordion ───────────────────────────────────────────────────────────────── - - private renderAccordion(parent: HTMLElement, title: WatchLogTitle): void { - const accordion = parent.createDiv({ cls: 'wl-accordion' }); - accordion.addEventListener('click', (e) => e.stopPropagation()); - - // Header - const accHeader = accordion.createDiv({ cls: 'wl-accordion-header' }); - const accLeft = accHeader.createDiv({ cls: 'wl-acc-header-left' }); - const episodeTotal = title.totalEpisodes > 0 ? ` · ${title.totalEpisodes} eps total` : ''; - const startedStr = title.dateStarted - ? `Started ${formatDateDisplay(title.dateStarted)}` - : 'Not started'; - const subtitleRow = accLeft.createDiv({ cls: 'wl-acc-subtitle' }); - subtitleRow.createSpan({ cls: 'wl-acc-subtitle-text', text: `${startedStr}${episodeTotal}` }); - if (title.externalLink) { - const linkIcon = subtitleRow.createEl('a', { cls: 'wl-acc-link-icon', text: '🌐' }); - linkIcon.href = title.externalLink; - linkIcon.title = 'Open external link'; - linkIcon.target = '_blank'; - linkIcon.rel = 'noopener noreferrer'; - linkIcon.addEventListener('click', (e) => e.stopPropagation()); - } - - // Stats row: 3 new blocks + existing progress block, all in one right-aligned row - const watchedEps = title.watchedEpisodes.length; - const totalEps = title.totalEpisodes; - const timeWatched = this.dataManager.calcTimeWatched(title); - const timeLeft = this.dataManager.calcTimeRemainingForModal(title); - const progress = this.dataManager.getProgress(title); - - const accRightGroup = accHeader.createDiv({ cls: 'wl-acc-right-group' }); - - const makeStatBlock = (value: string, label: string): void => { - const block = accRightGroup.createDiv({ cls: 'wl-acc-stat-block' }); - block.createDiv({ cls: 'wl-acc-percent', text: value }); - block.createDiv({ cls: 'wl-acc-progress-label', text: label }); - }; - makeStatBlock(formatTime(timeLeft), 'left'); - makeStatBlock(formatTime(timeWatched), 'watched'); - makeStatBlock(`${watchedEps} / ${totalEps}`, 'episodes'); - - const accRight = accRightGroup.createDiv({ cls: 'wl-acc-header-right' }); - accRight.createDiv({ cls: 'wl-acc-percent', text: `${progress}%` }); - accRight.createDiv({ cls: 'wl-acc-progress-label', text: 'progress' }); - const accBarWrap = accRight.createDiv({ cls: 'wl-acc-progress-wrap' }); - accBarWrap.createDiv({ cls: 'wl-progress-bar' }).style.width = `${progress}%`; - - // Body - const accBody = accordion.createDiv({ cls: 'wl-accordion-body' }); - if (title.type === 'Movie') { - this.renderMovieBody(accBody, title); - } else { - this.renderEpisodesBody(accBody, title); - } - - // Footer - this.renderAccordionFooter(accordion, title); - } - - private renderMovieBody(parent: HTMLElement, title: WatchLogTitle): void { - const row = parent.createDiv({ cls: 'wl-movie-row' }); - const cb = row.createEl('input', { - cls: 'wl-movie-checkbox', - attr: { type: 'checkbox' }, - }); - cb.checked = title.watchedEpisodes.includes(1); - row.createSpan({ cls: 'wl-movie-label', text: 'Watched' }); - - cb.addEventListener('change', (e) => { - e.stopPropagation(); - void this.dataManager.markEpisodeWatched(title.id, 1, cb.checked).then(() => this.rerenderTable()); - }); - } - - private renderEpisodesBody(parent: HTMLElement, title: WatchLogTitle): void { - if (title.seasons.length === 0) { - if (title.totalEpisodes > 0) { - this.renderEpisodeGrid(parent, title, title.totalEpisodes, 0); - } - return; - } - - title.seasons.forEach((season, seasonIdx) => { - let isCollapsed = this.collapsedSeasons.has(seasonIdx); - const seasonWrap = parent.createDiv({ cls: 'wl-season-wrap' }); - - const seasonHeader = seasonWrap.createDiv({ cls: 'wl-season-header' }); - const badge = seasonHeader.createSpan({ cls: 'wl-season-badge' }); - badge.textContent = season.name; - const palette = this.plugin.settings.seasonPalette; - badge.style.backgroundColor = palette[seasonIdx % palette.length] ?? '#888780'; - seasonHeader.createSpan({ cls: 'wl-season-ep-count', text: `${season.episodes} eps` }); - const chevron = seasonHeader.createSpan({ - cls: `wl-chevron${isCollapsed ? '' : ' is-open'}`, - text: '›', - }); - - // Render grid immediately only if this season is expanded - if (!isCollapsed) { - this.renderEpisodeGrid(seasonWrap, title, season.episodes, season.offset); - } - - seasonHeader.addEventListener('click', (e) => { - e.stopPropagation(); - if (isCollapsed) { - // Expanding: create grid in-place - isCollapsed = false; - this.collapsedSeasons.delete(seasonIdx); - chevron.classList.add('is-open'); - this.renderEpisodeGrid(seasonWrap, title, season.episodes, season.offset); - } else { - // Collapsing: remove grid from DOM entirely - isCollapsed = true; - this.collapsedSeasons.add(seasonIdx); - chevron.classList.remove('is-open'); - seasonWrap.querySelector('.wl-episode-grid')?.remove(); - } - if (this.expandedId) { - void this.dataManager.persistCollapsedSeasons(this.expandedId, this.collapsedSeasons); - } - }); - }); - } - - private renderEpisodeGrid( - parent: HTMLElement, - title: WatchLogTitle, - count: number, - offset: number, - ): void { - const grid = parent.createDiv({ cls: 'wl-episode-grid' }); - const watched = new Set(title.watchedEpisodes); - const seasonEps = Array.from({ length: count }, (_, i) => offset + i + 1); - const allWatched = seasonEps.length > 0 && seasonEps.every((ep) => watched.has(ep)); - - // Fill/clear toggle button (first element in the row) - const fillBtn = grid.createDiv({ - cls: `wl-season-fill-btn${allWatched ? ' is-clear' : ' is-fill'}`, - }); - fillBtn.textContent = allWatched ? '✗' : '✓'; - fillBtn.title = allWatched ? 'Clear all episodes in this season' : 'Mark all episodes in this season as watched'; - fillBtn.addEventListener('click', (e) => { - e.stopPropagation(); - void this.dataManager.markSeasonWatched(title.id, seasonEps, !allWatched).then(() => this.rerenderTable()); - }); - - const perSeason = this.plugin.settings.episodeNumbering === 'per-season'; - - for (let i = 0; i < count; i++) { - const epNum = offset + i + 1; - const isWatched = watched.has(epNum); - const box = grid.createDiv({ - cls: `wl-episode-box${isWatched ? ' is-watched' : ''}`, - }); - const displayNum = perSeason ? i + 1 : epNum; - box.textContent = isWatched ? '✓' : String(displayNum); - box.title = `Episode ${epNum}`; - - box.addEventListener('click', (e) => { - e.stopPropagation(); - void this.dataManager.markEpisodeWatched(title.id, epNum, !isWatched).then(() => this.rerenderTable()); - }); - } - } - - private renderAccordionFooter(parent: HTMLElement, title: WatchLogTitle): void { - const footer = parent.createDiv({ cls: 'wl-accordion-footer' }); - - // Star rating - const starsRow = footer.createDiv({ cls: 'wl-stars-row' }); - starsRow.createSpan({ cls: 'wl-stars-label', text: 'Rating' }); - const starsWrap = starsRow.createDiv({ cls: 'wl-stars' }); - for (let i = 1; i <= 5; i++) { - const star = starsWrap.createSpan({ - cls: `wl-star${title.rating >= i ? ' is-active' : ''}`, - text: '★', - }); - star.addEventListener('click', (e) => { - e.stopPropagation(); - void (async () => { - const t = this.dataManager.getTitle(title.id); - if (!t) return; - t.rating = t.rating === i ? 0 : i; - await this.dataManager.updateTitle(t); - this.rerenderTable(); - })(); - }); - } - - // Notes - const notesRow = footer.createDiv({ cls: 'wl-footer-row' }); - const notesInput = notesRow.createEl('input', { - cls: 'wl-notes-input', - attr: { type: 'text', placeholder: 'Add a note...' }, - }); - notesInput.value = title.notes; - notesInput.addEventListener('click', (e) => e.stopPropagation()); - notesInput.addEventListener('change', () => { - void (async () => { - const t = this.dataManager.getTitle(title.id); - if (!t) return; - t.notes = notesInput.value; - await this.dataManager.updateTitle(t); - this.rerenderTable(); - })(); - }); - - // Date watched - const dateRow = footer.createDiv({ cls: 'wl-footer-row' }); - dateRow.createSpan({ cls: 'wl-footer-label', text: 'Date watched' }); - const dateInput = dateRow.createEl('input', { - cls: 'wl-footer-date', - attr: { type: 'text', placeholder: 'Dd/mm/yyyy', maxlength: '10' }, - }); - dateInput.value = formatDateDisplay(title.dateFinished); - dateInput.addEventListener('click', (e) => e.stopPropagation()); - dateInput.addEventListener('change', () => { - void (async () => { - const t = this.dataManager.getTitle(title.id); - if (!t) return; - const parsed = parseDateInput(dateInput.value); - if (dateInput.value.trim() && !parsed) { - dateInput.addClass('wl-input-error'); - return; - } - dateInput.removeClass('wl-input-error'); - t.dateFinished = parsed; - await this.dataManager.updateTitle(t); - this.rerenderTable(); - })(); - }); - - // Priority - const priorityRow = footer.createDiv({ cls: 'wl-footer-row' }); - priorityRow.createSpan({ cls: 'wl-footer-label', text: 'Priority' }); - const prioritySelect = priorityRow.createEl('select', { cls: 'wl-select wl-select-sm' }); - for (const p of this.plugin.settings.priorities) { - const opt = prioritySelect.createEl('option', { text: p.name, value: p.name }); - if (p.name === title.priority) opt.selected = true; - } - prioritySelect.addEventListener('click', (e) => e.stopPropagation()); - prioritySelect.addEventListener('change', () => { - void (async () => { - const t = this.dataManager.getTitle(title.id); - if (!t) return; - t.priority = prioritySelect.value; - await this.dataManager.updateTitle(t); - this.rerenderTable(); - })(); - }); - - // Delete (left) + Edit dropdown (right) - const actionRow = footer.createDiv({ cls: 'wl-footer-row wl-footer-action-row' }); - - const deleteBtn = actionRow.createEl('button', { cls: 'wl-delete-btn', text: 'Remove' }); - deleteBtn.addEventListener('click', (e) => { - e.stopPropagation(); - new ConfirmModal(this.plugin.app, `Remove "${title.title}" from watchlog?`, () => { - void this.dataManager.removeTitle(title.id).then(() => { - this.expandedId = null; - this.rerenderTable(); - }); - }).open(); - }); - - const editBtnWrap = actionRow.createDiv({ cls: 'wl-add-btn-wrap' }); - const editBtn = editBtnWrap.createEl('button', { cls: 'wl-edit-btn', text: 'Edit' }); - editBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const current = this.dataManager.getTitle(title.id); - if (!current) return; - new EditTitleModal(this.plugin.app, this.plugin, this.dataManager, current, () => { - this.rerenderTable(); - }).open(); - }); - } - - // ── Filter + sort ───────────────────────────────────────────────────────────── - - private compareTitlesByKey(key: string, a: WatchLogTitle, b: WatchLogTitle): number { - switch (key) { - case 'title-asc': return a.title.localeCompare(b.title); - case 'title-desc': return b.title.localeCompare(a.title); - case 'dateAdded-newest': return new Date(b.dateAdded).getTime() - new Date(a.dateAdded).getTime(); - case 'dateAdded-oldest': return new Date(a.dateAdded).getTime() - new Date(b.dateAdded).getTime(); - case 'progress-high': return this.dataManager.getProgress(b) - this.dataManager.getProgress(a); - case 'progress-low': return this.dataManager.getProgress(a) - this.dataManager.getProgress(b); - case 'rating-high': { - const ra = a.rating === 0 ? -1 : a.rating; - const rb = b.rating === 0 ? -1 : b.rating; - return rb - ra; - } - case 'rating-low': { - if (a.rating === 0 && b.rating === 0) return 0; - if (a.rating === 0) return 1; - if (b.rating === 0) return -1; - return a.rating - b.rating; - } - case 'priority': { - const ai = PRIORITY_ORDER.indexOf(a.priority as typeof PRIORITY_ORDER[number]); - const bi = PRIORITY_ORDER.indexOf(b.priority as typeof PRIORITY_ORDER[number]); - return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi); - } - case 'started-asc': { - const da = a.dateStarted ? new Date(a.dateStarted).getTime() : Infinity; - const db = b.dateStarted ? new Date(b.dateStarted).getTime() : Infinity; - return da - db; - } - case 'started-desc': { - const da2 = a.dateStarted ? new Date(a.dateStarted).getTime() : null; - const db2 = b.dateStarted ? new Date(b.dateStarted).getTime() : null; - if (da2 == null && db2 == null) return 0; - if (da2 == null) return 1; - if (db2 == null) return -1; - return db2 - da2; - } - case 'status-asc': return this.getStatusIndex(a.status) - this.getStatusIndex(b.status); - case 'status-desc': return this.getStatusIndex(b.status) - this.getStatusIndex(a.status); - case 'dateWatched-newest': { - const da = a.dateFinished ? new Date(a.dateFinished).getTime() : 0; - const db = b.dateFinished ? new Date(b.dateFinished).getTime() : 0; - return db - da; - } - case 'dateWatched-oldest': { - const da = a.dateFinished ? new Date(a.dateFinished).getTime() : Infinity; - const db = b.dateFinished ? new Date(b.dateFinished).getTime() : Infinity; - return da - db; - } - case 'timeLeft-high': return this.dataManager.calcTimeRemaining(b) - this.dataManager.calcTimeRemaining(a); - case 'timeLeft-low': return this.dataManager.calcTimeRemaining(a) - this.dataManager.calcTimeRemaining(b); - case 'timeWatched-high': return this.dataManager.calcTimeWatched(b) - this.dataManager.calcTimeWatched(a); - case 'timeWatched-low': return this.dataManager.calcTimeWatched(a) - this.dataManager.calcTimeWatched(b); - default: return 0; - } - } - - private getFilteredSortedTitles(): WatchLogTitle[] { - let titles = this.dataManager.getTitles(); - - if (this.searchQuery.trim()) { - const q = this.searchQuery.toLowerCase(); - titles = titles.filter((t) => t.title.toLowerCase().includes(q)); - } - - titles = titles.filter((t) => this.titlePassesFilters(t)); - - titles = [...titles].sort((a, b) => { - const primary = this.compareTitlesByKey(this.filterSort, a, b); - if (primary !== 0 || this.filterSecondSort === 'none') return primary; - return this.compareTitlesByKey(this.filterSecondSort, a, b); - }); - - return titles; - } - - // ── Utilities ───────────────────────────────────────────────────────────────── - - private getTagDef(name: string, tags: TagDefinition[]): TagDefinition | undefined { - return tags.find((t) => t.name === name); - } - -} - +import { Modal } from 'obsidian'; +import type WatchLogPlugin from './main'; +import type { DataManager } from './DataManager'; +import type { WatchLogTitle, WatchLogGroup, TagDefinition, SavedFilterPreset, Season } from './types'; +import { formatTime, formatDateDisplay, parseDateInput, getThemedColor, getDisplayPoster } from './types'; +import { renderCommunityRating, maybeAutoRefreshCommunityRating, refreshCommunityRating } from './CommunityRating'; +import { AddTitleModal } from './AddTitleModal'; +import { AddFromUrlModal } from './AddFromUrlModal'; +import { EditTitleModal } from './EditTitleModal'; +import { ConfirmModal } from './ConfirmModal'; +import { TitleDetailModal, GroupDetailModal } from './TitleDetailModal'; + +// ── Display item type ───────────────────────────────────────────────────────── + +type DisplayItem = + | { kind: 'title'; data: WatchLogTitle } + | { kind: 'group'; data: WatchLogGroup; members: WatchLogTitle[] }; + +// ── Shared constants ────────────────────────────────────────────────────────── + +const PRIORITY_ORDER = ['High', 'Medium', 'Low'] as const; + +// ── ListTab ─────────────────────────────────────────────────────────────────── + +export class ListTab { + private container: HTMLElement; + private plugin: WatchLogPlugin; + private dataManager: DataManager; + + // Preserved state + filterTypeExclude: Set<string> = new Set(); + filterStatusExclude: Set<string> = new Set(); + filterPriorityExclude: Set<string> = new Set(); + filterRatingExclude: Set<string> = new Set(); + filterGroupExclude: Set<string> = new Set(); + filterSort = 'dateAdded'; + filterSortDir: 'asc' | 'desc' = 'desc'; + filterSecondSort = 'none'; + filterSecondSortDir: 'asc' | 'desc' = 'asc'; + searchQuery = ''; + expandedId: string | null = null; + collapsedSeasons: Set<number> = new Set(); + expandedGroups: Set<string> = new Set(); + renamingGroupId: string | null = null; + + // Selection mode + selectionMode = false; + selectedItems: Set<string> = new Set(); // title ids or group ids + + // Pin state — groups (persisted via DataManager) + pinnedGroupId: string | null = null; + + // Saved filter preset state + savedFilterActive = false; + private savedFilterBtnEl: HTMLButtonElement | null = null; + + // Empty-only filter flags + filterRatingEmptyOnly = false; + filterPriorityEmptyOnly = false; + + // Recently arrived only filter + filterRecentlyArrivedOnly = false; + + // Groups only filter — hides standalone titles, shows only groups + filterGroupsOnly = false; + + // Watchlist sub-tab + currentSubTab: 'list' | 'cards' = 'list'; + + // Virtual-scroll cleanup — removes the rAF-throttled scroll listener on re-render + private scrollCleanup: (() => void) | null = null; + + // Cards-view poster lazy loading + private cardsObserver: IntersectionObserver | null = null; + private observedCards: Set<HTMLElement> = new Set(); + + // Cards virtual scroll state + private cardsScrollContainer: HTMLElement | null = null; + private cardsScrollSpacer: HTMLElement | null = null; + private cardsGridEl: HTMLElement | null = null; + private cardsDisplayItems: DisplayItem[] = []; + private cardsScrollHandler: (() => void) | null = null; + private cardsScrollRAF: number | null = null; + private cardsResizeObserver: ResizeObserver | null = null; + private cardsLastFirst = -1; + private cardsLastLast = -1; + private cardsLastScrollTop = 0; + // Survives destroyVirtualScroll() so render() can restore scroll position + // after a full Cards-view rebuild (mirrors how _lastScrollTop works for List). + private _cardsPersistentScrollTop = 0; + private cardsRowHeight = 0; + + // Last known scroll position — updated continuously so rerenderTable() has the + // correct value even when called after an async operation has mutated the DOM. + private _lastScrollTop = 0; + private scrollPositionListener: (() => void) | null = null; + + // Document-level click handlers registered for transient menus/panels. + // Cleaned up on rerender so DOM removal doesn't leave dangling listeners. + private activeCleanups: (() => void)[] = []; + + constructor(container: HTMLElement, plugin: WatchLogPlugin, dataManager: DataManager) { + this.container = container; + this.plugin = plugin; + this.dataManager = dataManager; + this.currentSubTab = plugin.settings.defaultWatchlistView === 'list' ? 'list' : 'cards'; + this.scrollPositionListener = () => { + this._lastScrollTop = this.container.scrollTop; + }; + this.container.addEventListener('scroll', this.scrollPositionListener, { passive: true }); + this.pinnedGroupId = dataManager.getPinnedGroupId(); + + // Restore persisted filter state + const saved = plugin.settings.listFilters; + if (saved) { + this.filterTypeExclude = new Set(saved.typeExclude ?? []); + this.filterStatusExclude = new Set(saved.statusExclude ?? []); + this.filterGroupExclude = new Set(saved.groupExclude ?? []); + this.filterRatingExclude = new Set(saved.ratingExclude ?? []); + this.filterPriorityExclude = new Set(saved.priorityExclude ?? []); + const ms = ListTab.migrateSortKey(saved.sort ?? 'dateAdded-newest'); + this.filterSort = ms.key; + this.filterSortDir = saved.sortDir ?? ms.dir; + const ms2 = ListTab.migrateSortKey(saved.secondSort ?? 'none'); + this.filterSecondSort = ms2.key; + this.filterSecondSortDir = saved.secondSortDir ?? ms2.dir; + this.filterRatingEmptyOnly = saved.ratingEmptyOnly ?? false; + this.filterPriorityEmptyOnly = saved.priorityEmptyOnly ?? false; + this.filterRecentlyArrivedOnly = saved.recentlyArrivedOnly ?? false; + this.filterGroupsOnly = saved.groupsOnly ?? false; + } + + // Detect if current filters match the saved preset so the button stays green + const savedPreset = dataManager.getSavedFilterPreset(); + if (savedPreset) { + const setsMatch = (a: Set<string>, b: string[]) => + a.size === b.length && b.every((x) => a.has(x)); + this.savedFilterActive = + setsMatch(this.filterTypeExclude, savedPreset.typeExclude) && + setsMatch(this.filterStatusExclude, savedPreset.statusExclude) && + setsMatch(this.filterGroupExclude, savedPreset.groupExclude) && + setsMatch(this.filterRatingExclude, savedPreset.ratingExclude) && + setsMatch(this.filterPriorityExclude, savedPreset.priorityExclude) && + (this.filterRatingEmptyOnly === (savedPreset.ratingEmptyOnly ?? false)) && + (this.filterPriorityEmptyOnly === (savedPreset.priorityEmptyOnly ?? false)) && + (this.filterRecentlyArrivedOnly === (savedPreset.recentlyArrivedOnly ?? false)) && + (this.filterGroupsOnly === (savedPreset.groupsOnly ?? false)); + } + } + + private saveFiltersToSettings(): void { + this.plugin.settings.listFilters = { + typeExclude: Array.from(this.filterTypeExclude), + statusExclude: Array.from(this.filterStatusExclude), + groupExclude: Array.from(this.filterGroupExclude), + ratingExclude: Array.from(this.filterRatingExclude), + priorityExclude: Array.from(this.filterPriorityExclude), + sort: this.filterSort, + sortDir: this.filterSortDir, + secondSort: this.filterSecondSort, + secondSortDir: this.filterSecondSortDir, + ratingEmptyOnly: this.filterRatingEmptyOnly, + priorityEmptyOnly: this.filterPriorityEmptyOnly, + recentlyArrivedOnly: this.filterRecentlyArrivedOnly, + groupsOnly: this.filterGroupsOnly, + }; + void this.plugin.saveSettings(); + } + + private hasActiveFilter(): boolean { + return this.filterTypeExclude.size > 0 || + this.filterStatusExclude.size > 0 || + this.filterPriorityExclude.size > 0 || + this.filterRatingExclude.size > 0 || + this.filterGroupExclude.size > 0 || + this.filterRatingEmptyOnly || + this.filterPriorityEmptyOnly || + this.filterRecentlyArrivedOnly || + this.filterGroupsOnly; + } + + private clearAllFilters(): void { + this.filterTypeExclude.clear(); + this.filterStatusExclude.clear(); + this.filterPriorityExclude.clear(); + this.filterRatingExclude.clear(); + this.filterGroupExclude.clear(); + this.filterRatingEmptyOnly = false; + this.filterPriorityEmptyOnly = false; + this.filterRecentlyArrivedOnly = false; + this.filterGroupsOnly = false; + } + + private applyPreset(preset: SavedFilterPreset): void { + this.filterTypeExclude = new Set(preset.typeExclude); + this.filterStatusExclude = new Set(preset.statusExclude); + this.filterGroupExclude = new Set(preset.groupExclude); + this.filterRatingExclude = new Set(preset.ratingExclude); + this.filterPriorityExclude = new Set(preset.priorityExclude); + this.filterRatingEmptyOnly = preset.ratingEmptyOnly ?? false; + this.filterPriorityEmptyOnly = preset.priorityEmptyOnly ?? false; + this.filterRecentlyArrivedOnly = preset.recentlyArrivedOnly ?? false; + this.filterGroupsOnly = preset.groupsOnly ?? false; + } + + private deactivateSavedFilter(): void { + if (this.savedFilterActive) { + this.savedFilterActive = false; + this.savedFilterBtnEl?.removeClass('wl-btn-preset-active'); + } + } + + private static isRecentlyArrived(title: WatchLogTitle): boolean { + if (!title.releaseDate || !/^\d{4}-\d{2}-\d{2}$/.test(title.releaseDate)) return false; + const releaseMs = new Date(title.releaseDate + 'T00:00:00').getTime(); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const diffDays = (today.getTime() - releaseMs) / 86400000; + return diffDays >= 0 && diffDays <= 7; + } + + private titlePassesFilters(t: WatchLogTitle): boolean { + if (this.filterTypeExclude.size > 0 && this.filterTypeExclude.has(t.type)) return false; + if (this.filterRecentlyArrivedOnly) { + if (!ListTab.isRecentlyArrived(t)) return false; + } else if (this.filterStatusExclude.size > 0 && this.filterStatusExclude.has(t.status)) { + return false; + } + if (this.filterRatingEmptyOnly) { + if (t.rating !== 0) return false; + } else if (this.filterRatingExclude.size > 0 && this.filterRatingExclude.has(`${t.rating}★`)) { + return false; + } + if (this.filterPriorityEmptyOnly) { + if (t.priority) return false; + } else if (this.filterPriorityExclude.size > 0 && this.filterPriorityExclude.has(t.priority)) { + return false; + } + return true; + } + + destroy(): void { + if (this.scrollPositionListener) { + this.container.removeEventListener('scroll', this.scrollPositionListener); + this.scrollPositionListener = null; + } + if (this.scrollCleanup) { + this.scrollCleanup(); + this.scrollCleanup = null; + } + this.destroyVirtualScroll(); + // Make sure any debounced episode-click save reaches disk before we + // lose the tab instance (e.g. on tab switch or view close). + void this.dataManager.flushPendingSave(); + } + + render(): void { + this._lastScrollTop = this.container.scrollTop || this._lastScrollTop; + this.activeCleanups.forEach((fn) => fn()); + this.activeCleanups = []; + this.container.empty(); + this.container.addClass('wl-list'); + this.renderSubTabBar(); + if (this.currentSubTab === 'list') { + this.renderListContent(); + } else { + this.renderCardsContent(); + } + } + + private renderSubTabBar(): void { + const bar = this.container.createDiv({ cls: 'wl-inner-tab-bar' }); + const cardsBtn = bar.createEl('button', { + cls: `wl-inner-tab-btn${this.currentSubTab === 'cards' ? ' is-active' : ''}`, + text: 'Cards', + }); + const listBtn = bar.createEl('button', { + cls: `wl-inner-tab-btn${this.currentSubTab === 'list' ? ' is-active' : ''}`, + text: 'List', + }); + listBtn.addEventListener('click', () => { + if (this.currentSubTab === 'list') return; + this.destroyVirtualScroll(); + this.currentSubTab = 'list'; + this.render(); + }); + cardsBtn.addEventListener('click', () => { + if (this.currentSubTab === 'cards') return; + this.currentSubTab = 'cards'; + this.render(); + }); + } + + private renderListContent(): void { + this.renderHeader(); + this.container.createDiv({ cls: 'wl-divider' }); + this.renderTable(this._lastScrollTop); + } + + private renderCardsContent(): void { + this.renderHeader(); + this.container.createDiv({ cls: 'wl-divider' }); + this.renderCardsView(); + } + + private renderCardsView(): void { + this.destroyVirtualScroll(); + const items = this.getDisplayItems(); + this.cardsDisplayItems = items; + this.renderResultsCount(this.container, items); + if (items.length === 0) { + const empty = this.container.createDiv({ cls: 'wl-cards-empty' }); + empty.createSpan({ cls: 'wl-cards-empty-icon', text: '🎬' }); + empty.createEl('p', { cls: 'wl-cards-empty-msg', text: 'No titles match your filters' }); + return; + } + + const scroll = this.container.createDiv({ cls: 'wl-cards-scroll-container' }); + const spacer = scroll.createDiv({ cls: 'wl-cards-scroll-spacer' }); + const grid = spacer.createDiv({ cls: 'wl-cards-grid wl-cards-grid-virtual' }); + this.cardsScrollContainer = scroll; + this.cardsScrollSpacer = spacer; + this.cardsGridEl = grid; + this.cardsLastFirst = -1; + this.cardsLastLast = -1; + + this.cardsScrollHandler = () => { + const scrollTop = scroll.scrollTop; + this._cardsPersistentScrollTop = scrollTop; + // Only schedule a render when the user has scrolled at least half a + // row's height — below that, the visible range cannot change so the + // rerun would do nothing useful. + const threshold = this.cardsRowHeight > 0 ? this.cardsRowHeight / 2 : 50; + if (Math.abs(scrollTop - this.cardsLastScrollTop) < threshold) return; + this.cardsLastScrollTop = scrollTop; + if (this.cardsScrollRAF !== null) return; + this.cardsScrollRAF = window.requestAnimationFrame(() => { + this.cardsScrollRAF = null; + this.renderVisibleCards(); + }); + }; + scroll.addEventListener('scroll', this.cardsScrollHandler, { passive: true }); + + // Let renderVisibleCards detect range changes. We do NOT reset + // cardsLastFirst/Last when width shifts (e.g. scrollbar appears after + // the spacer height is set) — the CSS grid reflows on its own and the + // internal range comparison already short-circuits if no items moved. + // Resetting here caused a spurious second "initial" render. + this.cardsResizeObserver = new ResizeObserver(() => { + this.renderVisibleCards(); + }); + this.cardsResizeObserver.observe(scroll); + + // Restore scroll position from before the last re-render. The spacer + // height isn't set yet (renderVisibleCards does that), so do a first + // pass to size the spacer, then assign scrollTop, then re-run so the + // correct row range is materialized. + if (this._cardsPersistentScrollTop > 0) { + this.renderVisibleCards(); + scroll.scrollTop = this._cardsPersistentScrollTop; + this.cardsLastScrollTop = this._cardsPersistentScrollTop; + this.renderVisibleCards(); + } + } + + private getCardsGridMetrics(containerWidth: number): { + cols: number; + cardWidth: number; + cardHeight: number; + rowHeight: number; + } { + const gap = 12; + const minCardWidth = 140; + const cols = Math.max(1, Math.floor((containerWidth + gap) / (minCardWidth + gap))); + const cardWidth = (containerWidth - gap * (cols - 1)) / cols; + const cardHeight = cardWidth * 1.5; + const rowHeight = cardHeight + gap; + return { cols, cardWidth, cardHeight, rowHeight }; + } + + private renderVisibleCards(): void { + const scroll = this.cardsScrollContainer; + const spacer = this.cardsScrollSpacer; + const grid = this.cardsGridEl; + if (!scroll || !spacer || !grid) return; + + const scrollTop = scroll.scrollTop; + const viewportHeight = scroll.clientHeight; + const width = scroll.clientWidth; + if (width <= 0 || viewportHeight <= 0) return; + + const { cols, cardWidth, cardHeight, rowHeight } = this.getCardsGridMetrics(width); + this.cardsRowHeight = rowHeight; + const totalItems = this.cardsDisplayItems.length; + const totalRows = Math.ceil(totalItems / cols); + + const firstVisibleRow = Math.max(0, Math.floor(scrollTop / rowHeight) - 2); + const lastVisibleRow = Math.min( + totalRows - 1, + Math.ceil((scrollTop + viewportHeight) / rowHeight) + 2, + ); + const firstIndex = firstVisibleRow * cols; + const lastIndex = Math.min(totalItems - 1, (lastVisibleRow + 1) * cols - 1); + + spacer.style.height = `${totalRows * rowHeight}px`; + grid.style.transform = `translateY(${firstVisibleRow * rowHeight}px)`; + grid.style.setProperty('--wl-cards-cols', String(cols)); + grid.style.setProperty('--wl-card-height', `${cardHeight}px`); + + const oldFirst = this.cardsLastFirst; + const oldLast = this.cardsLastLast; + if (firstIndex === oldFirst && lastIndex === oldLast) return; + + const isInitial = oldFirst === -1 || oldLast === -1; + const hasOverlap = !isInitial && firstIndex <= oldLast && lastIndex >= oldFirst; + + this.cardsLastFirst = firstIndex; + this.cardsLastLast = lastIndex; + + if (isInitial || !hasOverlap) { + this.fullRenderCards(grid, firstIndex, lastIndex, cardWidth, cardHeight); + this.setupCardsObserver(scroll); + return; + } + + // Incremental update — trim from edges, then add what's new. + if (firstIndex > oldFirst) { + const removeCount = firstIndex - oldFirst; + for (let i = 0; i < removeCount; i++) { + const el = grid.firstElementChild as HTMLElement | null; + if (!el) break; + this.unobserveAndRemove(el); + } + } + if (lastIndex < oldLast) { + const removeCount = oldLast - lastIndex; + for (let i = 0; i < removeCount; i++) { + const el = grid.lastElementChild as HTMLElement | null; + if (!el) break; + this.unobserveAndRemove(el); + } + } + if (firstIndex < oldFirst) { + const addEnd = Math.min(oldFirst - 1, lastIndex); + const fragment = activeDocument.createDocumentFragment(); + for (let i = firstIndex; i <= addEnd; i++) { + const card = this.buildCardElement(i, cardWidth, cardHeight); + if (card) fragment.appendChild(card); + } + grid.insertBefore(fragment, grid.firstChild); + } + if (lastIndex > oldLast) { + const addStart = Math.max(oldLast + 1, firstIndex); + const fragment = activeDocument.createDocumentFragment(); + for (let i = addStart; i <= lastIndex; i++) { + const card = this.buildCardElement(i, cardWidth, cardHeight); + if (card) fragment.appendChild(card); + } + grid.appendChild(fragment); + } + + this.setupCardsObserver(scroll); + } + + private fullRenderCards( + grid: HTMLElement, + firstIndex: number, + lastIndex: number, + cardWidth: number, + cardHeight: number, + ): void { + this.observedCards.clear(); + grid.empty(); + const fragment = activeDocument.createDocumentFragment(); + for (let i = firstIndex; i <= lastIndex; i++) { + const card = this.buildCardElement(i, cardWidth, cardHeight); + if (card) fragment.appendChild(card); + } + grid.appendChild(fragment); + } + + private buildCardElement( + index: number, + cardWidth: number, + cardHeight: number, + ): HTMLElement | null { + const item = this.cardsDisplayItems[index]; + if (!item) return null; + const tmp = activeDocument.createElement('div'); + if (item.kind === 'title') { + this.renderTitleCard(tmp, item.data, cardWidth, cardHeight); + } else { + this.renderGroupCard(tmp, item.data, item.members, cardWidth, cardHeight); + } + return tmp.firstElementChild as HTMLElement | null; + } + + private unobserveAndRemove(el: HTMLElement): void { + if (this.cardsObserver && this.observedCards.has(el)) { + this.cardsObserver.unobserve(el); + this.observedCards.delete(el); + } + el.remove(); + } + + private setupCardsObserver(container: HTMLElement): void { + if (!this.cardsObserver) { + this.cardsObserver = new IntersectionObserver( + (entries) => this.handleCardIntersection(entries), + { + root: container, + rootMargin: '0px', + threshold: 0, + }, + ); + } + const cards = (this.cardsGridEl ?? container).querySelectorAll( + '.wl-card[data-needs-poster="true"]', + ); + cards.forEach((card) => { + const el = card as HTMLElement; + if (!this.observedCards.has(el)) { + this.cardsObserver!.observe(el); + this.observedCards.add(el); + } + }); + } + + private handleCardIntersection(entries: IntersectionObserverEntry[]): void { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const card = entry.target as HTMLElement; + const titleId = card.dataset.titleId; + if (!titleId) continue; + + this.cardsObserver?.unobserve(card); + + const title = this.dataManager.getTitles().find((t) => t.id === titleId); + if (!title) continue; + // Manual override takes priority — skip auto-fetch for manual overrides + // and for titles whose auto-fetched poster is already populated. + if (title.manualPosterUrl && title.manualPosterUrl.trim() !== '') continue; + if (title.posterUrl !== '') continue; + + const placeholder = card.querySelector('.wl-card-poster-placeholder'); + placeholder?.addClass('is-loading'); + + void this.plugin.posterService.enqueue(title).then((url) => { + placeholder?.removeClass('is-loading'); + if (url) { + this.applyPosterToCard(card, url); + } + }); + } + } + + private applyPosterToCard(card: HTMLElement, url: string): void { + const img = card.querySelector<HTMLImageElement>('.wl-card-poster'); + const placeholder = card.querySelector<HTMLElement>('.wl-card-poster-placeholder'); + if (!img) return; + img.onload = () => { + img.style.display = 'block'; + if (placeholder) placeholder.style.display = 'none'; + }; + img.onerror = () => { + const titleId = card.dataset.titleId; + if (titleId) { + this.dataManager.updatePosterUrl(titleId, 'none'); + } + }; + img.src = url; + } + + private destroyCardsObserver(): void { + if (this.cardsObserver) { + this.cardsObserver.disconnect(); + this.cardsObserver = null; + } + this.observedCards.clear(); + this.plugin.posterService?.clearQueue(); + } + + private destroyVirtualScroll(): void { + if (this.cardsScrollRAF !== null) { + window.cancelAnimationFrame(this.cardsScrollRAF); + this.cardsScrollRAF = null; + } + if (this.cardsResizeObserver) { + this.cardsResizeObserver.disconnect(); + this.cardsResizeObserver = null; + } + if (this.cardsScrollContainer && this.cardsScrollHandler) { + this.cardsScrollContainer.removeEventListener('scroll', this.cardsScrollHandler); + } + this.cardsScrollHandler = null; + this.cardsScrollContainer = null; + this.cardsScrollSpacer = null; + this.cardsGridEl = null; + this.cardsDisplayItems = []; + this.cardsLastFirst = -1; + this.cardsLastLast = -1; + this.cardsLastScrollTop = 0; + this.cardsRowHeight = 0; + this.destroyCardsObserver(); + } + + private renderTitleCard( + parent: HTMLElement, + title: WatchLogTitle, + cardWidth?: number, + cardHeight?: number, + ): void { + const typeDef = this.getTagDef(title.type, this.plugin.settings.types); + const statusDef = this.getTagDef(title.status, this.plugin.settings.statuses); + const typeColor = typeDef + ? getThemedColor(title.type, typeDef.color, this.plugin.settings.colorTheme) + : '#888780'; + + const card = parent.createDiv({ cls: 'wl-card' }); + card.dataset.titleId = title.id; + // Width is distributed by the parent CSS grid (1fr columns) — do not set + // an explicit width here. Setting fractional pixel widths per card caused + // flex-wrap to overflow and shift the grid left by one slot per scroll. + if (cardHeight !== undefined) card.style.height = `${cardHeight}px`; + + const placeholder = card.createDiv({ cls: 'wl-card-poster-placeholder' }); + placeholder.style.backgroundColor = typeColor; + const letter = (title.title.trim().charAt(0) || '?').toUpperCase(); + placeholder.createSpan({ text: letter }); + + const img = card.createEl('img', { cls: 'wl-card-poster' }); + img.alt = title.title; + + const display = getDisplayPoster(title); + const isManual = !!(title.manualPosterUrl && title.manualPosterUrl.trim() !== ''); + if (display && display.startsWith('http')) { + img.src = display; + img.style.display = 'block'; + placeholder.style.display = 'none'; + img.onerror = () => { + img.style.display = 'none'; + placeholder.style.display = ''; + // Only mark the auto-fetched URL as 'none' — don't touch a user's manual choice. + if (!isManual) this.dataManager.updatePosterUrl(title.id, 'none'); + }; + } else if (!isManual && title.posterUrl === '') { + card.dataset.needsPoster = 'true'; + } + + if (statusDef) { + const statusBadge = card.createSpan({ + cls: 'wl-card-status-badge', + text: title.status, + }); + statusBadge.style.backgroundColor = getThemedColor( + title.status, + statusDef.color, + this.plugin.settings.colorTheme, + ); + } + + const overlay = card.createDiv({ cls: 'wl-card-overlay' }); + overlay.createSpan({ cls: 'wl-card-title', text: title.title }); + const typeBadge = overlay.createSpan({ + cls: 'wl-card-type-badge', + text: title.type, + }); + typeBadge.style.backgroundColor = typeColor; + + // Progress bar + const total = title.totalEpisodes; + if (total && total > 0) { + const isCompleted = title.status === 'Completed'; + const ratio = isCompleted + ? 1 + : Math.max(0, Math.min(1, title.watchedEpisodes.length / total)); + const bar = overlay.createDiv({ cls: 'wl-card-progress-bar' }); + const fill = bar.createDiv({ cls: 'wl-card-progress-fill' }); + fill.style.width = `${ratio * 100}%`; + } + + const menuBtn = card.createEl('button', { + cls: 'wl-card-menu-btn', + text: '⋮', + }); + menuBtn.setAttr('aria-label', 'More actions'); + menuBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.openCardContextMenu(card, menuBtn, title); + }); + + card.addEventListener('click', () => { + this.openDetailModalForTitle(title.id); + }); + } + + private openCardContextMenu( + card: HTMLElement, + anchorBtn: HTMLElement, + title: WatchLogTitle, + ): void { + // Close any existing menu first + this.container.querySelectorAll('.wl-card-context-menu').forEach((el) => el.remove()); + + const menu = card.createDiv({ cls: 'wl-card-context-menu' }); + const editItem = menu.createDiv({ cls: 'wl-card-context-item', text: 'Edit title' }); + const refreshItem = menu.createDiv({ + cls: 'wl-card-context-item', + text: 'Refresh poster', + }); + const refreshRatingItem = menu.createDiv({ + cls: 'wl-card-context-item', + text: 'Refresh rating', + }); + + // Edge-align if the button is close to the right edge of the container + const cardRect = card.getBoundingClientRect(); + const containerRect = this.container.getBoundingClientRect(); + if (cardRect.right > containerRect.right - 180) { + menu.classList.add('is-right-aligned'); + } + + // Capture the owning document once so add/remove can't desync across popout windows. + const doc = card.ownerDocument; + const closeMenu = (): void => { + menu.remove(); + doc.removeEventListener('click', onDocClick, true); + }; + const onDocClick = (e: MouseEvent): void => { + if (!menu.contains(e.target as Node) && e.target !== anchorBtn) { + closeMenu(); + } + }; + // Defer registration so the click that opened the menu doesn't close it + window.setTimeout(() => doc.addEventListener('click', onDocClick, true), 0); + this.activeCleanups.push(() => doc.removeEventListener('click', onDocClick, true)); + + editItem.addEventListener('click', (e) => { + e.stopPropagation(); + closeMenu(); + this.openEditModalForTitle(title.id); + }); + refreshItem.addEventListener('click', (e) => { + e.stopPropagation(); + closeMenu(); + this.refreshPosterForCard(card, title.id); + }); + refreshRatingItem.addEventListener('click', (e) => { + e.stopPropagation(); + closeMenu(); + void refreshCommunityRating(this.plugin, title.id, null, true); + }); + } + + private refreshPosterForCard(card: HTMLElement, titleId: string): void { + const fresh = this.dataManager.getTitle(titleId); + if (!fresh) return; + this.dataManager.updatePosterUrl(titleId, ''); + card.dataset.needsPoster = 'true'; + + const img = card.querySelector<HTMLImageElement>('.wl-card-poster'); + const placeholder = card.querySelector<HTMLElement>( + '.wl-card-poster-placeholder', + ); + if (img) { + img.removeAttribute('src'); + img.style.display = 'none'; + } + if (placeholder) { + placeholder.style.display = ''; + placeholder.addClass('is-loading'); + } + + // Refetch via the queue + void this.plugin.posterService.enqueue(fresh).then((url) => { + placeholder?.removeClass('is-loading'); + if (url) this.applyPosterToCard(card, url); + }); + } + + private renderGroupCard( + parent: HTMLElement, + group: WatchLogGroup, + members: WatchLogTitle[], + cardWidth?: number, + cardHeight?: number, + ): void { + const primaryType = members[0]?.type ?? ''; + const typeDef = primaryType + ? this.getTagDef(primaryType, this.plugin.settings.types) + : undefined; + const typeColor = typeDef + ? getThemedColor(primaryType, typeDef.color, this.plugin.settings.colorTheme) + : '#888780'; + + const card = parent.createDiv({ cls: 'wl-card wl-card-group' }); + if (cardHeight !== undefined) card.style.height = `${cardHeight}px`; + + const placeholder = card.createDiv({ cls: 'wl-card-poster-placeholder' }); + placeholder.style.backgroundColor = typeColor; + const letter = (group.name.trim().charAt(0) || '?').toUpperCase(); + placeholder.createSpan({ text: letter }); + + const overlay = card.createDiv({ cls: 'wl-card-overlay' }); + overlay.createSpan({ cls: 'wl-card-title', text: group.name }); + const subtitle = overlay.createSpan({ cls: 'wl-card-type-badge' }); + subtitle.style.backgroundColor = typeColor; + subtitle.textContent = `${members.length} title${members.length !== 1 ? 's' : ''}`; + + const menuBtn = card.createEl('button', { + cls: 'wl-card-menu-btn', + text: '⋮', + }); + menuBtn.setAttr('aria-label', 'Group actions'); + menuBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.openGroupCardContextMenu(card, menuBtn, group); + }); + + card.addEventListener('click', () => { + new GroupDetailModal(this.plugin.app, this.plugin, group, members, () => { + this.render(); + }).open(); + }); + } + + private openGroupCardContextMenu( + card: HTMLElement, + anchorBtn: HTMLElement, + group: WatchLogGroup, + ): void { + this.container.querySelectorAll('.wl-card-context-menu').forEach((el) => el.remove()); + + const menu = card.createDiv({ cls: 'wl-card-context-menu' }); + const renameItem = menu.createDiv({ cls: 'wl-card-context-item', text: 'Edit name' }); + const deleteItem = menu.createDiv({ + cls: 'wl-card-context-item', + text: 'Delete group', + }); + + const cardRect = card.getBoundingClientRect(); + const containerRect = this.container.getBoundingClientRect(); + if (cardRect.right > containerRect.right - 180) { + menu.classList.add('is-right-aligned'); + } + + // Capture the owning document once so add/remove can't desync across popout windows. + const doc = card.ownerDocument; + const closeMenu = (): void => { + menu.remove(); + doc.removeEventListener('click', onDocClick, true); + }; + const onDocClick = (e: MouseEvent): void => { + if (!menu.contains(e.target as Node) && e.target !== anchorBtn) { + closeMenu(); + } + }; + window.setTimeout(() => doc.addEventListener('click', onDocClick, true), 0); + this.activeCleanups.push(() => doc.removeEventListener('click', onDocClick, true)); + + renameItem.addEventListener('click', (e) => { + e.stopPropagation(); + closeMenu(); + this.openRenameGroupPrompt(group); + }); + deleteItem.addEventListener('click', (e) => { + e.stopPropagation(); + closeMenu(); + new ConfirmModal( + this.plugin.app, + `Delete group "${group.name}"? All titles inside will be returned to the main list.`, + () => { + void this.dataManager.removeGroup(group.id).then(() => { + this.expandedGroups.delete(group.id); + this.render(); + }); + }, + ).open(); + }); + } + + private openRenameGroupPrompt(group: WatchLogGroup): void { + const modal = new Modal(this.plugin.app); + modal.titleEl.setText('Rename group'); + const input = modal.contentEl.createEl('input', { + cls: 'wl-input', + attr: { type: 'text', value: group.name, placeholder: 'Group name' }, + }); + input.style.width = '100%'; + const btnRow = modal.contentEl.createDiv({ cls: 'wl-modal-btn-row' }); + const submit = (): void => { + const newName = input.value.trim(); + if (newName && newName !== group.name) { + void this.dataManager + .updateGroup({ ...group, name: newName }) + .then(() => this.render()); + } + modal.close(); + }; + btnRow + .createEl('button', { cls: 'wl-btn wl-btn-primary', text: 'Save' }) + .addEventListener('click', submit); + btnRow + .createEl('button', { cls: 'wl-btn', text: 'Cancel' }) + .addEventListener('click', () => modal.close()); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') submit(); + else if (e.key === 'Escape') modal.close(); + }); + modal.open(); + window.setTimeout(() => { + input.focus(); + input.select(); + }, 0); + } + + private openEditModalForTitle(titleId: string): void { + const current = this.dataManager.getTitle(titleId); + if (!current) return; + new EditTitleModal(this.plugin.app, this.plugin, this.dataManager, current, () => { + this.render(); + }).open(); + } + + private openDetailModalForTitle(titleId: string): void { + const current = this.dataManager.getTitle(titleId); + if (!current) return; + new TitleDetailModal(this.plugin.app, this.plugin, current, () => { + this.render(); + }).open(); + } + + // ── Header ──────────────────────────────────────────────────────────────────── + + private renderHeader(): void { + const header = this.container.createDiv({ cls: 'wl-list-header' }); + + // Import progress bar (shown while a background import is running) + if (this.plugin.importProgress) { + const { current, total, cancel } = this.plugin.importProgress; + const progWrap = header.createDiv({ cls: 'wl-header-import-progress' }); + const track = progWrap.createDiv({ cls: 'wl-header-import-track' }); + const fill = track.createDiv({ cls: 'wl-header-import-fill' }); + fill.style.width = `${total > 0 ? Math.round((current / total) * 100) : 0}%`; + progWrap.createSpan({ cls: 'wl-header-import-text', text: `${current} / ${total}` }); + const cancelBtn = progWrap.createEl('button', { cls: 'wl-btn wl-btn-sm', text: 'Cancel import' }); + cancelBtn.addEventListener('click', () => cancel()); + } + + const controls = header.createDiv({ cls: 'wl-header-controls' }); + + // Search sits inline as the first item in the toolbar row (matches Reading) + this.renderSearch(controls); + + // Saved filter preset button (shown only when a preset is stored) + const preset = this.dataManager.getSavedFilterPreset(); + if (preset) { + const savedBtn = controls.createEl('button', { + cls: `wl-btn wl-btn-sm${this.savedFilterActive ? ' wl-btn-preset-active' : ''}`, + text: 'Saved filter', + }); + this.savedFilterBtnEl = savedBtn; + savedBtn.addEventListener('click', () => { + this.applyPreset(preset); + this.savedFilterActive = true; + savedBtn.addClass('wl-btn-preset-active'); + this.saveFiltersToSettings(); + // Full render so the filter bar (Clear button, filter dot) is + // rebuilt with the new active-filter state on whichever sub-tab + // is current — rerenderTable/rerenderActiveSubTab only refresh + // the content area, not the header. + this.render(); + }); + } else { + this.savedFilterBtnEl = null; + } + + // Filters dropdown button + this.renderFiltersDropdown(controls); + + // Sorting dropdown button + this.renderSortingDropdown(controls); + + // Action bar — visible when in selection mode AND items are selected + if (this.selectionMode && this.selectedItems.size > 0) { + this.renderActionBar(controls); + } + + // Select All / Deselect All button — visible in selection mode + if (this.selectionMode) { + const hasAny = this.selectedItems.size > 0; + const selAllBtn = controls.createEl('button', { cls: 'wl-btn wl-btn-sm', text: hasAny ? 'None' : 'All' }); + selAllBtn.title = hasAny ? 'Deselect all' : 'Select all visible'; + selAllBtn.addEventListener('click', () => { + if (this.selectedItems.size > 0) { + this.selectedItems.clear(); + } else { + for (const item of this.getDisplayItems()) { + this.selectedItems.add(item.data.id); + } + } + this.render(); + }); + } + + // Selection mode toggle button + const selBtn = controls.createEl('button', { + cls: `wl-btn wl-btn-sm${this.selectionMode ? ' is-active' : ''}`, + text: 'Select', + }); + selBtn.addEventListener('click', () => { + this.selectionMode = !this.selectionMode; + this.selectedItems.clear(); + this.render(); + }); + + // Add buttons pinned to the far right of the toolbar row + 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(); + }); + } + + 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 ▼' }); + const dotEl = btn.createSpan({ cls: 'wl-filter-dot' }); + if (this.hasActiveFilter()) { dotEl.show(); } else { dotEl.hide(); } + + const clearBtn = parent.createEl('button', { cls: 'wl-btn wl-btn-sm wl-btn-clear-filters', text: '✕ clear' }); + clearBtn.title = 'Clear all filters'; + if (this.hasActiveFilter()) { clearBtn.show(); } else { clearBtn.hide(); } + clearBtn.addEventListener('click', () => { + this.clearAllFilters(); + this.saveFiltersToSettings(); + this.deactivateSavedFilter(); + dotEl.hide(); + clearBtn.hide(); + this.rerenderTable(); + }); + + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const existing = activeDocument.querySelector('.wl-filters-panel'); + if (existing) { existing.remove(); return; } + + const panel = activeDocument.body.createDiv({ cls: 'wl-dropdown wl-filters-panel wl-filters-panel-popup' }); + const rect = btn.getBoundingClientRect(); + panel.style.top = `${rect.bottom + 4}px`; + panel.style.left = `${rect.left}px`; + + const typeOpts = this.plugin.settings.types.map((t) => t.name); + const statusOpts = this.plugin.settings.statuses.map((s) => s.name); + const ratingOpts = ['1★', '2★', '3★', '4★', '5★']; + const priorityOpts = this.plugin.settings.priorities.map((p) => p.name); + const groupOpts = this.dataManager.getGroups().map((g) => g.name); + + // Draft — live filter state is not mutated until Apply is clicked + const draft = { + typeExclude: new Set(this.filterTypeExclude), + statusExclude: new Set(this.filterStatusExclude), + ratingExclude: new Set(this.filterRatingExclude), + priorityExclude: new Set(this.filterPriorityExclude), + groupExclude: new Set(this.filterGroupExclude), + filterRatingEmptyOnly: this.filterRatingEmptyOnly, + filterPriorityEmptyOnly: this.filterPriorityEmptyOnly, + filterRecentlyArrivedOnly: this.filterRecentlyArrivedOnly, + filterGroupsOnly: this.filterGroupsOnly, + }; + + const sections: Array<{ label: string; opts: string[]; excludeSet: Set<string> }> = [ + { label: 'Type', opts: typeOpts, excludeSet: draft.typeExclude }, + { label: 'Status', opts: statusOpts, excludeSet: draft.statusExclude }, + { label: 'Rating', opts: ratingOpts, excludeSet: draft.ratingExclude }, + { label: 'Priority', opts: priorityOpts, excludeSet: draft.priorityExclude }, + ]; + if (groupOpts.length > 0) { + sections.push({ label: 'Group', opts: groupOpts, excludeSet: draft.groupExclude }); + } + + const allCheckboxRefs: Array<{ cb: HTMLInputElement }> = []; + + // Global buttons + const globalRow = panel.createDiv({ cls: 'wl-filter-global-btns' }); + let recentlyArrivedCbRef: HTMLInputElement | null = null; + + const selectAllBtn = globalRow.createEl('button', { cls: 'wl-btn wl-btn-sm', text: 'Select all' }); + selectAllBtn.addEventListener('click', (ev) => { + ev.stopPropagation(); + for (const { excludeSet } of sections) excludeSet.clear(); + for (const { cb } of allCheckboxRefs) cb.checked = true; + draft.filterRecentlyArrivedOnly = false; + if (recentlyArrivedCbRef) recentlyArrivedCbRef.checked = false; + }); + const deselectAllBtn = globalRow.createEl('button', { cls: 'wl-btn wl-btn-sm', text: 'Deselect all' }); + deselectAllBtn.addEventListener('click', (ev) => { + ev.stopPropagation(); + for (const { opts, excludeSet } of sections) { + for (const opt of opts) excludeSet.add(opt); + } + for (const { cb } of allCheckboxRefs) cb.checked = false; + draft.filterRecentlyArrivedOnly = false; + if (recentlyArrivedCbRef) recentlyArrivedCbRef.checked = false; + }); + + // Save / Delete preset button + const existingPreset = this.dataManager.getSavedFilterPreset(); + const saveDeleteBtn = globalRow.createEl('button', { + cls: 'wl-btn wl-btn-sm', + text: existingPreset ? 'Delete' : 'Save', + }); + saveDeleteBtn.addEventListener('click', (ev) => { + void (async () => { + ev.stopPropagation(); + if (saveDeleteBtn.textContent === 'Save') { + await this.dataManager.setSavedFilterPreset({ + typeExclude: Array.from(draft.typeExclude), + statusExclude: Array.from(draft.statusExclude), + groupExclude: Array.from(draft.groupExclude), + ratingExclude: Array.from(draft.ratingExclude), + priorityExclude: Array.from(draft.priorityExclude), + ratingEmptyOnly: draft.filterRatingEmptyOnly, + priorityEmptyOnly: draft.filterPriorityEmptyOnly, + recentlyArrivedOnly: draft.filterRecentlyArrivedOnly, + groupsOnly: draft.filterGroupsOnly, + }); + } else { + await this.dataManager.setSavedFilterPreset(null); + this.savedFilterActive = false; + } + panel.remove(); + this.render(); + })(); + }); + + // Collapsed state per section (collapsed by default) + const sectionCollapsed: Record<string, boolean> = {}; + for (const { label } of sections) { + sectionCollapsed[label] = true; + } + + for (const { label, opts, excludeSet } of sections) { + const section = panel.createDiv({ cls: 'wl-filter-section' }); + + // Collapsible header + const sectionHeader = section.createDiv({ cls: 'wl-filter-section-header' }); + sectionHeader.createSpan({ cls: 'wl-filter-label', text: label }); + const chevron = sectionHeader.createSpan({ cls: 'wl-filter-chevron', text: '▼' }); + + // Content container — hidden by default + const content = section.createDiv({ cls: 'wl-filter-section-content wl-hidden' }); + + sectionHeader.addEventListener('click', (ev) => { + ev.stopPropagation(); + sectionCollapsed[label] = !sectionCollapsed[label]; + const collapsed = sectionCollapsed[label] ?? true; + content.toggleClass('wl-hidden', collapsed); + chevron.textContent = collapsed ? '▼' : '▲'; + }); + + // "All" toggle — first item inside the section, styled like + // "Recently arrived" and "Groups only". + const allRow = content.createDiv({ cls: 'wl-filter-checkbox-row' }); + const allCb = allRow.createEl('input', { attr: { type: 'checkbox' } }); + allRow.createSpan({ cls: 'wl-filter-all-toggle', text: '◆ All' }); + + // "Recently arrived" checkbox in Status section + if (label === 'Status') { + const raRow = content.createDiv({ cls: 'wl-filter-checkbox-row' }); + const raCb = raRow.createEl('input', { attr: { type: 'checkbox' } }); + raCb.checked = draft.filterRecentlyArrivedOnly; + raRow.createSpan({ cls: 'wl-recently-arrived-filter', text: '✦ Recently arrived' }); + recentlyArrivedCbRef = raCb; + + raCb.addEventListener('change', () => { + draft.filterRecentlyArrivedOnly = raCb.checked; + if (raCb.checked) { + // Clear status exclude set when Recently arrived is active + excludeSet.clear(); + for (const optCb of statusOptionCbs) optCb.checked = true; + } + }); + } + + // "Empty" checkbox for Rating and Priority sections + let emptyCb: HTMLInputElement | null = null; + const optionCbs: HTMLInputElement[] = []; + const statusOptionCbs: HTMLInputElement[] = []; + + if (label === 'Rating' || label === 'Priority') { + const emptyRow = content.createDiv({ cls: 'wl-filter-checkbox-row' }); + emptyCb = emptyRow.createEl('input', { attr: { type: 'checkbox' } }); + emptyCb.checked = label === 'Rating' ? draft.filterRatingEmptyOnly : draft.filterPriorityEmptyOnly; + emptyRow.createSpan({ text: 'Empty' }); + const localEmptyCb = emptyCb; + + emptyCb.addEventListener('change', () => { + if (label === 'Rating') { + draft.filterRatingEmptyOnly = localEmptyCb.checked; + } else { + draft.filterPriorityEmptyOnly = localEmptyCb.checked; + } + if (localEmptyCb.checked) { + for (const optCb of optionCbs) { optCb.checked = true; } + excludeSet.clear(); + } + }); + } + + // "Groups only" toggle at top of Group section + if (label === 'Group') { + const goRow = content.createDiv({ cls: 'wl-filter-checkbox-row' }); + const goCb = goRow.createEl('input', { attr: { type: 'checkbox' } }); + goCb.checked = draft.filterGroupsOnly; + goRow.createSpan({ cls: 'wl-recently-arrived-filter', text: '◇ Groups only' }); + goCb.addEventListener('change', () => { + draft.filterGroupsOnly = goCb.checked; + }); + content.createDiv({ cls: 'wl-filter-section-divider' }); + } + + for (const opt of opts) { + const cbRow = content.createDiv({ cls: 'wl-filter-checkbox-row' }); + const cb = cbRow.createEl('input', { attr: { type: 'checkbox' } }); + cb.checked = !excludeSet.has(opt); + cbRow.createSpan({ text: opt }); + optionCbs.push(cb); + if (label === 'Status') statusOptionCbs.push(cb); + allCheckboxRefs.push({ cb }); + const localEmptyCb = emptyCb; + + cb.addEventListener('change', () => { + if (cb.checked) excludeSet.delete(opt); + else excludeSet.add(opt); + // Deactivate Recently arrived when a specific status is interacted with + if (label === 'Status' && recentlyArrivedCbRef) { + draft.filterRecentlyArrivedOnly = false; + recentlyArrivedCbRef.checked = false; + } + // Deactivate Empty when a specific option is interacted with + if ((label === 'Rating' || label === 'Priority') && localEmptyCb) { + if (label === 'Rating') draft.filterRatingEmptyOnly = false; + else draft.filterPriorityEmptyOnly = false; + localEmptyCb.checked = false; + } + }); + } + + // Reflect current state in the "All" checkbox. + const syncAllCb = (): void => { + allCb.checked = optionCbs.length > 0 && optionCbs.every((c) => c.checked); + }; + syncAllCb(); + + const toggleAll = (): void => { + const allChecked = optionCbs.length > 0 && optionCbs.every((c) => c.checked); + if (allChecked) { + for (const optCb of optionCbs) optCb.checked = false; + for (const opt of opts) excludeSet.add(opt); + } else { + for (const optCb of optionCbs) optCb.checked = true; + excludeSet.clear(); + } + syncAllCb(); + }; + allCb.addEventListener('click', (ev) => { + ev.stopPropagation(); + // We drive the visual + data state ourselves; cancel the native toggle. + toggleAll(); + }); + allRow.addEventListener('click', (ev) => { + if (ev.target === allCb) return; + ev.stopPropagation(); + toggleAll(); + }); + } + + // Apply — commits draft to live state, re-renders, and closes the panel + const applyBtn = panel.createEl('button', { cls: 'wl-btn wl-btn-sm wl-filter-apply-btn', text: 'Apply' }); + applyBtn.addEventListener('click', (ev) => { + ev.stopPropagation(); + this.filterTypeExclude = draft.typeExclude; + this.filterStatusExclude = draft.statusExclude; + this.filterRatingExclude = draft.ratingExclude; + this.filterPriorityExclude = draft.priorityExclude; + this.filterGroupExclude = draft.groupExclude; + this.filterRatingEmptyOnly = draft.filterRatingEmptyOnly; + this.filterPriorityEmptyOnly = draft.filterPriorityEmptyOnly; + this.filterRecentlyArrivedOnly = draft.filterRecentlyArrivedOnly; + this.filterGroupsOnly = draft.filterGroupsOnly; + this.saveFiltersToSettings(); + this.deactivateSavedFilter(); + const active = this.hasActiveFilter(); + if (active) { dotEl.show(); clearBtn.show(); } else { dotEl.hide(); clearBtn.hide(); } + this.rerenderTable(); + panel.remove(); + activeDocument.removeEventListener('mousedown', closer, false); + }); + + const closer = (ev: MouseEvent) => { + const target = ev.target as Node; + if (panel.contains(target) || wrap.contains(target)) return; + panel.remove(); + activeDocument.removeEventListener('mousedown', closer, false); + }; + window.setTimeout(() => activeDocument.addEventListener('mousedown', closer, false), 0); + }); + } + + private renderSortingDropdown(parent: HTMLElement): void { + const wrap = parent.createDiv({ cls: 'wl-add-btn-wrap' }); + const btn = wrap.createEl('button', { cls: 'wl-btn wl-btn-sm', text: 'Sorting ▼' }); + + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const existing = wrap.querySelector('.wl-dropdown'); + if (existing) { existing.remove(); return; } + + const panel = wrap.createDiv({ cls: 'wl-dropdown wl-sorting-panel' }); + + if (window.innerWidth <= 600) { + const rect = btn.getBoundingClientRect(); + panel.addClass('wl-sorting-panel-mobile'); + panel.style.top = `${rect.bottom + 4}px`; + } + + const sortOptions = [ + 'Title', 'Date added', 'Progress', 'Rating', 'Priority', + 'Started', 'Status', 'Date watched', 'Time left', 'Time watched', + ]; + + const addSortRow = (labelText: string, currentKey: string, currentDir: 'asc' | 'desc', isPrimary: boolean): void => { + const row = panel.createDiv({ cls: 'wl-filter-row' }); + row.createSpan({ cls: 'wl-filter-label', text: labelText }); + const sel = row.createEl('select', { cls: 'wl-select' }); + + if (!isPrimary) { + const noneOpt = sel.createEl('option', { text: 'None', value: 'none' }); + noneOpt.selected = currentKey === 'none'; + } + for (const opt of sortOptions) { + const el = sel.createEl('option', { text: opt, value: opt }); + if (this.sortLabelToKey(opt) === currentKey) el.selected = true; + } + + const dirBtn = row.createEl('button', { + cls: 'wl-btn wl-btn-sm wl-sort-dir-btn', + text: currentDir === 'asc' ? '↑' : '↓', + }); + dirBtn.title = currentDir === 'asc' + ? 'Ascending — click to switch to descending' + : 'Descending — click to switch to ascending'; + + sel.addEventListener('change', () => { + const newKey = sel.value === 'none' ? 'none' : this.sortLabelToKey(sel.value); + if (isPrimary) { + this.filterSort = newKey; + this.filterSortDir = ListTab.SORT_DEFAULT_DIR[newKey] ?? 'asc'; + } else { + this.filterSecondSort = newKey; + this.filterSecondSortDir = ListTab.SORT_DEFAULT_DIR[newKey] ?? 'asc'; + } + panel.remove(); + activeDocument.removeEventListener('click', closer, true); + this.saveFiltersToSettings(); + this.render(); + }); + + dirBtn.addEventListener('click', (ev) => { + ev.stopPropagation(); + if (isPrimary) { + this.filterSortDir = this.filterSortDir === 'asc' ? 'desc' : 'asc'; + } else { + this.filterSecondSortDir = this.filterSecondSortDir === 'asc' ? 'desc' : 'asc'; + } + panel.remove(); + activeDocument.removeEventListener('click', closer, true); + this.saveFiltersToSettings(); + this.render(); + }); + }; + + addSortRow('Sort by', this.filterSort, this.filterSortDir, true); + addSortRow('Then by', this.filterSecondSort, this.filterSecondSortDir, false); + + const closer = (ev: MouseEvent) => { + if (!wrap.contains(ev.target as Node)) { + panel.remove(); + activeDocument.removeEventListener('click', closer, true); + } + }; + window.setTimeout(() => activeDocument.addEventListener('click', closer, true), 0); + }); + } + + private renderActionBar(parent: HTMLElement): void { + const bar = parent.createDiv({ cls: 'wl-action-bar' }); + + // Delete button — same style as group delete button + const deleteBtn = bar.createEl('button', { + cls: 'wl-group-action-btn wl-group-action-btn-delete wl-btn-danger', + text: '✕', + }); + deleteBtn.title = 'Delete selected'; + deleteBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const count = this.selectedItems.size; + new ConfirmModal(this.plugin.app, `Delete ${count} selected item${count !== 1 ? 's' : ''}? This cannot be undone.`, () => { + void (async () => { + const groupIds = new Set(this.dataManager.getGroups().map((g) => g.id)); + const titleIds: string[] = []; + for (const id of Array.from(this.selectedItems)) { + if (groupIds.has(id)) { + await this.dataManager.removeGroup(id); + this.expandedGroups.delete(id); + } else { + titleIds.push(id); + if (this.expandedId === id) this.expandedId = null; + } + } + if (titleIds.length > 0) { + await this.dataManager.removeTitlesBatch(titleIds); + } + this.selectedItems.clear(); + this.render(); + })(); + }).open(); + }); + + // Change Status dropdown + const statusSelect = bar.createEl('select', { cls: 'wl-select wl-select-sm' }); + statusSelect.createEl('option', { text: 'Status…', value: '' }); + for (const s of this.plugin.settings.statuses.filter((s) => s.name !== 'To be released')) { + statusSelect.createEl('option', { text: s.name, value: s.name }); + } + statusSelect.addEventListener('change', () => { + void (async () => { + const newStatus = statusSelect.value; + if (!newStatus) return; + const groupIds = new Set(this.dataManager.getGroups().map((g) => g.id)); + const updated: WatchLogTitle[] = []; + for (const id of this.selectedItems) { + if (groupIds.has(id)) continue; + const t = this.dataManager.getTitle(id); + if (!t) continue; + t.status = newStatus; + this.dataManager.updateTitleSilent(t); + updated.push(t); + } + if (updated.length > 0) { + await this.dataManager.save(); + for (const t of updated) { + await this.dataManager.updateMarkdownFile(t); + } + } + statusSelect.value = ''; + this.render(); + })(); + }); + + // Move to Group dropdown + const allGroups = this.dataManager.getGroups(); + const hasGroupSelected = Array.from(this.selectedItems).some((id) => + this.dataManager.getGroups().some((g) => g.id === id), + ); + const moveSelect = bar.createEl('select', { cls: 'wl-select wl-select-sm' }); + if (hasGroupSelected) { + moveSelect.disabled = true; + moveSelect.title = 'A group is selected — cannot move groups into other groups'; + } + moveSelect.createEl('option', { text: 'Group…', value: '' }); + for (const g of allGroups) { + moveSelect.createEl('option', { text: g.name, value: g.id }); + } + moveSelect.createEl('option', { text: 'Create new group…', value: '__new__' }); + moveSelect.addEventListener('change', () => { + void (async () => { + const value = moveSelect.value; + if (!value) return; + const groupIds = new Set(allGroups.map((g) => g.id)); + const titleIds = Array.from(this.selectedItems).filter((id) => !groupIds.has(id)); + + if (value === '__new__') { + // Inline group-name input (prompt() is not available in Obsidian/Electron) + moveSelect.value = ''; + moveSelect.hide(); + const nameInput = bar.createEl('input', { + cls: 'wl-modal-input wl-group-name-input', + attr: { type: 'text', placeholder: 'Group name…', maxlength: '64' }, + }); + const confirmBtn = bar.createEl('button', { cls: 'wl-group-action-btn', text: '✓' }); + confirmBtn.title = 'Create group'; + const cancelBtn = bar.createEl('button', { cls: 'wl-group-action-btn', text: '✕' }); + cancelBtn.title = 'Cancel'; + + const doCreate = async () => { + const name = nameInput.value.trim(); + if (!name) return; + const newGroup: WatchLogGroup = { + id: this.dataManager.generateGroupId(name), + name, + titleIds, + dateAdded: new Date().toISOString(), + }; + await this.dataManager.addGroup(newGroup); + this.selectedItems.clear(); + this.selectionMode = false; + this.render(); + }; + confirmBtn.addEventListener('click', (e) => { e.stopPropagation(); void doCreate(); }); + cancelBtn.addEventListener('click', (e) => { e.stopPropagation(); this.render(); }); + nameInput.addEventListener('keydown', (e) => { + e.stopPropagation(); + if (e.key === 'Enter') void doCreate(); + if (e.key === 'Escape') this.render(); + }); + nameInput.addEventListener('click', (e) => e.stopPropagation()); + window.setTimeout(() => nameInput.focus(), 0); + return; + } + + for (const id of titleIds) { + await this.dataManager.addTitleToGroup(value, id); + } + moveSelect.value = ''; + this.selectedItems.clear(); + this.selectionMode = false; + this.render(); + })(); + }); + } + + // ── Modal helpers ───────────────────────────────────────────────────────────── + + private openAddTitleModal(): void { + new AddTitleModal(this.plugin.app, this.plugin, this.dataManager, () => { + this.render(); + }).open(); + } + + // ── Search & Controls ───────────────────────────────────────────────────────── + + private renderSearch(parent: HTMLElement): void { + // Inline compact search inside the toolbar row, reusing Reading's classes + const searchWrap = parent.createDiv({ cls: 'wl-reading-search-wrap' }); + const input = searchWrap.createEl('input', { + cls: 'wl-reading-search-input', + attr: { type: 'text', placeholder: 'Search titles...' }, + }); + input.value = this.searchQuery; + let searchDebounce = 0; + input.addEventListener('input', () => { + this.searchQuery = input.value; + window.clearTimeout(searchDebounce); + searchDebounce = window.setTimeout(() => { + this.rerenderActiveSubTab(); + }, 250); + }); + } + + + private sortLabelToKey(label: string): string { + const map: Record<string, string> = { + 'Title': 'title', + 'Date added': 'dateAdded', + 'Progress': 'progress', + 'Rating': 'rating', + 'Priority': 'priority', + 'Started': 'started', + 'Status': 'status', + 'Date watched': 'dateWatched', + 'Time left': 'timeLeft', + 'Time watched': 'timeWatched', + }; + return map[label] ?? 'dateAdded'; + } + + private static readonly SORT_DEFAULT_DIR: Record<string, 'asc' | 'desc'> = { + title: 'asc', + dateAdded: 'desc', + progress: 'desc', + rating: 'desc', + priority: 'asc', + started: 'asc', + status: 'asc', + dateWatched: 'desc', + timeLeft: 'desc', + timeWatched: 'desc', + }; + + private static migrateSortKey(oldKey: string): { key: string; dir: 'asc' | 'desc' } { + const map: Record<string, { key: string; dir: 'asc' | 'desc' }> = { + 'title-asc': { key: 'title', dir: 'asc' }, + 'title-desc': { key: 'title', dir: 'desc' }, + 'dateAdded-newest': { key: 'dateAdded', dir: 'desc' }, + 'dateAdded-oldest': { key: 'dateAdded', dir: 'asc' }, + 'progress-high': { key: 'progress', dir: 'desc' }, + 'progress-low': { key: 'progress', dir: 'asc' }, + 'rating-high': { key: 'rating', dir: 'desc' }, + 'rating-low': { key: 'rating', dir: 'asc' }, + 'priority': { key: 'priority', dir: 'asc' }, + 'started-asc': { key: 'started', dir: 'asc' }, + 'started-desc': { key: 'started', dir: 'desc' }, + 'status-asc': { key: 'status', dir: 'asc' }, + 'status-desc': { key: 'status', dir: 'desc' }, + 'dateWatched-newest': { key: 'dateWatched', dir: 'desc' }, + 'dateWatched-oldest': { key: 'dateWatched', dir: 'asc' }, + 'timeLeft-high': { key: 'timeLeft', dir: 'desc' }, + 'timeLeft-low': { key: 'timeLeft', dir: 'asc' }, + 'timeWatched-high': { key: 'timeWatched', dir: 'desc' }, + 'timeWatched-low': { key: 'timeWatched', dir: 'asc' }, + 'none': { key: 'none', dir: 'asc' }, + }; + return map[oldKey] ?? { key: oldKey, dir: 'desc' }; + } + + private static sortBaseAndDirToKey(base: string, dir: 'asc' | 'desc'): string { + const map: Record<string, { asc: string; desc: string }> = { + title: { asc: 'title-asc', desc: 'title-desc' }, + dateAdded: { asc: 'dateAdded-oldest', desc: 'dateAdded-newest' }, + progress: { asc: 'progress-low', desc: 'progress-high' }, + rating: { asc: 'rating-low', desc: 'rating-high' }, + priority: { asc: 'priority', desc: 'priority' }, + started: { asc: 'started-asc', desc: 'started-desc' }, + status: { asc: 'status-asc', desc: 'status-desc' }, + dateWatched: { asc: 'dateWatched-oldest', desc: 'dateWatched-newest' }, + timeLeft: { asc: 'timeLeft-low', desc: 'timeLeft-high' }, + timeWatched: { asc: 'timeWatched-low', desc: 'timeWatched-high' }, + }; + return map[base]?.[dir] ?? 'dateAdded-newest'; + } + + // ── Table rendering ─────────────────────────────────────────────────────────── + + rerenderTable(): void { + // Route callers to the renderer matching the active sub-tab so callers + // (filter apply, saved filter, sort change, row interactions, etc.) work + // whether the user is on List or Cards. Otherwise a List table would be + // appended underneath the Cards grid. + if (this.currentSubTab !== 'list') { + this.rerenderActiveSubTab(); + return; + } + const savedScroll = this._lastScrollTop; + const existing = this.container.querySelector('.wl-table-section'); + if (existing) existing.remove(); + this.renderTable(savedScroll); + } + + private rerenderActiveSubTab(): void { + if (this.currentSubTab === 'cards') { + this.destroyVirtualScroll(); + // Strip both cards-view DOM and any stray list table so the two views + // can never coexist in the container. + this.container + .querySelectorAll('.wl-cards-scroll-container, .wl-cards-empty, .wl-table-section, .wl-results-count') + .forEach((el) => el.remove()); + this.renderCardsView(); + } else { + const savedScroll = this._lastScrollTop; + const existing = this.container.querySelector('.wl-table-section'); + if (existing) existing.remove(); + this.renderTable(savedScroll); + } + } + + /** Renders the "N titles, M groups" count line. Shared by List and Cards views. */ + private renderResultsCount(parent: HTMLElement, items: DisplayItem[]): void { + const titleCount = items.reduce((n, item) => n + (item.kind === 'title' ? 1 : 0), 0); + const groupCount = items.reduce((n, item) => n + (item.kind === 'group' ? 1 : 0), 0); + const countEl = parent.createDiv({ cls: 'wl-results-count' }); + const parts: string[] = []; + if (titleCount > 0) parts.push(`${titleCount} title${titleCount !== 1 ? 's' : ''}`); + if (groupCount > 0) parts.push(`${groupCount} group${groupCount !== 1 ? 's' : ''}`); + countEl.textContent = parts.length > 0 ? parts.join(', ') : '0 titles'; + } + + private renderTable(restoreScroll?: number): void { + // Tear down any previous scroll listener before rebuilding the DOM + if (this.scrollCleanup) { + this.scrollCleanup(); + this.scrollCleanup = null; + } + + const section = this.container.createDiv({ cls: 'wl-table-section' }); + const items = this.getDisplayItems(); + + this.renderResultsCount(section, items); + + const tableCls = this.selectionMode ? 'wl-table wl-selection-mode' : 'wl-table'; + const table = section.createDiv({ cls: tableCls }); + const colHeader = table.createDiv({ cls: 'wl-table-header-row' }); + if (this.selectionMode) { + colHeader.createDiv({ cls: 'wl-col-select-h' }); + } + colHeader.createDiv({ cls: 'wl-col-title-h', text: 'Title' }); + colHeader.createDiv({ cls: 'wl-col-priority-h', text: 'Priority' }); + colHeader.createDiv({ cls: 'wl-col-started-h', text: 'Started' }); + colHeader.createDiv({ cls: 'wl-col-rating-h', text: 'Rating' }); + colHeader.createDiv({ cls: 'wl-col-status-h', text: 'Status' }); + + if (items.length === 0) { + table.createDiv({ cls: 'wl-empty-state', text: 'No titles match your filters.' }); + return; + } + + this.mountVirtualRows(table, items, restoreScroll); + } + + /** + * Virtualised list renderer. + * + * Phase 1 — all items are rendered into a hidden measurement container so we + * read their *actual* heights in one synchronous batch (single forced layout). + * The container is removed before the virtual one is created. + * + * Phase 2 — a single fixed-height `wl-virt-container` div is inserted. Its + * height is set once from the cached offset table and never changes during + * scroll, so the scrollable area is perfectly stable (CLS = 0 while scrolling). + * + * Phase 3 — on each scroll event (rAF-throttled, passive) we binary-search the + * offset table to find which items are visible and absolutely-position only + * those inside the container. No layout reads happen inside the handler. + * + * On expand/collapse `rerenderTable()` is called, which destroys and recreates + * the entire virtualisation — remeasuring affected rows automatically. + */ + private mountVirtualRows(table: HTMLElement, items: DisplayItem[], restoreScroll?: number): void { + const BUFFER_ROWS = 5; + const INTER_ITEM_GAP = 8; // px — mirrors the CSS margin-top between top-level rows + const FALLBACK_H = 44; // px — used when offsetHeight returns 0 and for buffer math + + // ── Phase 1: uniform row heights ────────────────────────────────────────── + const ROW_H = 68; // px — covers a standard unexpanded title row with badge + progress bar + const heights: number[] = items.map(() => ROW_H); + + // ── Phase 1b: measure the expanded title row (accordion included) ───────── + // Only one title can be expanded at a time, so this is a single DOM measurement. + if (this.expandedId !== null) { + const expandedIdx = items.findIndex( + (item) => item.kind === 'title' && item.data.id === this.expandedId, + ); + if (expandedIdx !== -1) { + const item = items[expandedIdx]; + if (item?.kind === 'title') { + const probe = activeDocument.body.createDiv(); + probe.style.cssText = `position:absolute;left:-9999px;top:0;width:${table.clientWidth}px;visibility:hidden;`; + this.renderRow(probe, item.data); + const measured = probe.offsetHeight; + probe.remove(); + if (measured > 0) heights[expandedIdx] = measured; + } + } + } + + // ── Phase 1c: measure expanded group rows ──────────────────────────────── + // Multiple groups can be expanded simultaneously, so check every group item. + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item?.kind !== 'group') continue; + if (!this.expandedGroups.has(item.data.id)) continue; + const probe = activeDocument.body.createDiv(); + probe.style.cssText = `position:absolute;left:-9999px;top:0;width:${table.clientWidth}px;visibility:hidden;`; + this.renderGroupRow(probe, item.data, item.members); + const measured = probe.offsetHeight; + probe.remove(); + if (measured > 0) heights[i] = measured; + } + + // ── Phase 2: cumulative offset table (includes inter-item gap) ──────────── + const offsets: number[] = []; + let totalHeight = 0; + for (let i = 0; i < items.length; i++) { + offsets.push(totalHeight); + totalHeight += heights[i] ?? FALLBACK_H; + if (i < items.length - 1) totalHeight += INTER_ITEM_GAP; + } + + // ── Phase 3: fixed-height container ────────────────────────────────────── + // Height is set once and never mutated during scroll — this is what keeps + // the total scrollable area stable and CLS at zero. + const virt = table.createDiv({ cls: 'wl-virt-container' }); + virt.style.height = `${totalHeight}px`; + + // Distance from the scroll container's content-top to virt's top edge. + // Computed once here (outside the scroll handler) using getBoundingClientRect. + const scrollEl = this.container; + const containerTopOffset = + virt.getBoundingClientRect().top - + scrollEl.getBoundingClientRect().top + + scrollEl.scrollTop; + + // Restore scroll AFTER containerTopOffset is computed (which needs scrollTop=0 + // on a fresh DOM) but BEFORE renderWindow() so it sees the correct position. + if (restoreScroll !== undefined) { + scrollEl.scrollTop = restoreScroll; + } + + // ── Phase 4: rAF-throttled scroll handler ───────────────────────────────── + let rafId = 0; + let lastStart = -1; + let lastEnd = -1; + + const renderWindow = (): void => { + // Read scroll state first — no DOM writes until after all reads + const scrollTop = scrollEl.scrollTop; + const viewH = scrollEl.clientHeight || 600; + + const bufferPx = BUFFER_ROWS * FALLBACK_H; + const relTop = scrollTop - containerTopOffset; + const windowTop = relTop - bufferPx; + const windowBot = relTop + viewH + bufferPx; + + // Binary search: first item whose bottom edge is past windowTop + let lo = 0; + let hi = items.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >>> 1; + if ((offsets[mid] ?? 0) + (heights[mid] ?? FALLBACK_H) <= windowTop) { + lo = mid + 1; + } else { + hi = mid - 1; + } + } + const startIdx = Math.max(0, lo); + + // Linear scan from startIdx: last item whose top edge is before windowBot + let endIdx = startIdx; + for (let i = startIdx; i < items.length; i++) { + if ((offsets[i] ?? 0) >= windowBot) break; + endIdx = i; + } + + // Skip DOM work entirely if the visible range has not changed + if (startIdx === lastStart && endIdx === lastEnd) return; + lastStart = startIdx; + lastEnd = endIdx; + + // Write phase — clear old rows, insert visible slice + virt.empty(); + for (let i = startIdx; i <= endIdx; i++) { + const item = items[i]; + if (!item) continue; // guard: items array may be stale on concurrent re-render + const rowEl = virt.createDiv({ cls: 'wl-virt-item' }); + rowEl.style.top = `${offsets[i] ?? 0}px`; + if (item.kind === 'title') { + this.renderRow(rowEl, item.data); + } else { + this.renderGroupRow(rowEl, item.data, item.members); + } + } + }; + + renderWindow(); + + const onScroll = (): void => { + if (rafId !== 0) return; + rafId = window.requestAnimationFrame(() => { + rafId = 0; + renderWindow(); + }); + }; + + scrollEl.addEventListener('scroll', onScroll, { passive: true }); + this.scrollCleanup = (): void => { + scrollEl.removeEventListener('scroll', onScroll); + if (rafId !== 0) { + window.cancelAnimationFrame(rafId); + rafId = 0; + } + }; + } + + // ── Display items (merged groups + top-level titles) ────────────────────────── + + private getDisplayItems(): DisplayItem[] { + const groups = this.dataManager.getGroups(); + const groupedIds = this.dataManager.getGroupedTitleIds(); + const allTitles = this.dataManager.getTitles(); + const titleMap = new Map(allTitles.map((t) => [t.id, t])); + const q = this.searchQuery.trim().toLowerCase(); + + // Top-level titles (not in any group), filtered. Hidden entirely when + // "Groups only" is active. + const topLevel = this.filterGroupsOnly + ? [] + : this.getFilteredSortedTitles().filter((t) => !groupedIds.has(t.id)); + + // Groups: skip those excluded by group filter; show if any member passes filters + const displayGroups: Array<{ kind: 'group'; data: WatchLogGroup; members: WatchLogTitle[] }> = []; + for (const group of groups) { + if (this.filterGroupExclude.size > 0 && this.filterGroupExclude.has(group.name)) continue; + + const members = group.titleIds + .map((id) => titleMap.get(id)) + .filter((t): t is WatchLogTitle => t !== undefined); + + // Search: show group if name matches OR any member title matches + if (q) { + const nameMatch = group.name.toLowerCase().includes(q); + const memberMatch = members.some((m) => m.title.toLowerCase().includes(q)); + if (!nameMatch && !memberMatch) continue; + } + + // Show group if any member passes type/status/rating/priority filters + const anyFilterActive = this.filterTypeExclude.size > 0 || + this.filterStatusExclude.size > 0 || + this.filterRatingExclude.size > 0 || + this.filterPriorityExclude.size > 0; + if (anyFilterActive && !members.some((m) => this.titlePassesFilters(m))) continue; + + displayGroups.push({ kind: 'group', data: group, members }); + } + + const allItems: DisplayItem[] = [ + ...displayGroups, + ...topLevel.map((t) => ({ kind: 'title' as const, data: t })), + ]; + + return allItems.sort((a, b) => { + // Pinned item always first + const aPinned = (a.kind === 'title' && a.data.pinned) || (a.kind === 'group' && a.data.id === this.pinnedGroupId); + const bPinned = (b.kind === 'title' && b.data.pinned) || (b.kind === 'group' && b.data.id === this.pinnedGroupId); + if (aPinned && !bPinned) return -1; + if (!aPinned && bPinned) return 1; + return this.compareDisplayItems(a, b); + }); + } + + // Status sort order (ascending): Watching → Plan to watch → Completed → To be released → Dropped + private readonly STATUS_ORDER = ['Watching', 'Plan to watch', 'Completed', 'To be released', 'Dropped']; + + private getStatusIndex(status: string): number { + const i = this.STATUS_ORDER.indexOf(status); + return i === -1 ? 99 : i; + } + + private compareDisplayItemsByKey(key: string, a: DisplayItem, b: DisplayItem): number { + + const getName = (item: DisplayItem): string => + item.kind === 'title' ? item.data.title : item.data.name; + + const getProgress = (item: DisplayItem): number => + item.kind === 'title' + ? this.dataManager.getProgress(item.data) + : this.getGroupProgress(item.members); + + const getPriorityIndex = (item: DisplayItem): number => { + if (item.kind === 'title') { + const i = PRIORITY_ORDER.indexOf(item.data.priority as typeof PRIORITY_ORDER[number]); + return i === -1 ? 99 : i; + } + const indices = item.members + .map((m) => PRIORITY_ORDER.indexOf(m.priority as typeof PRIORITY_ORDER[number])) + .filter((i) => i !== -1); + return indices.length > 0 ? Math.min(...indices) : 99; + }; + + const getDateAdded = (item: DisplayItem): number => { + if (item.kind === 'title') return new Date(item.data.dateAdded).getTime(); + const times = item.members.map((m) => new Date(m.dateAdded).getTime()); + return times.length > 0 ? Math.max(...times) : 0; + }; + + const getStartedDate = (item: DisplayItem): number | null => { + if (item.kind === 'title') { + return item.data.dateStarted ? new Date(item.data.dateStarted).getTime() : null; + } + const dates = item.members + .map((m) => (m.dateStarted ? new Date(m.dateStarted).getTime() : null)) + .filter((d): d is number => d !== null); + return dates.length > 0 ? Math.min(...dates) : null; + }; + + const getStatusIdx = (item: DisplayItem): number => { + if (item.kind === 'title') return this.getStatusIndex(item.data.status); + return this.getStatusIndex(this.getGroupStatus(item.members)); + }; + + const getRating = (item: DisplayItem): number => { + if (item.kind === 'title') return item.data.rating; + const rated = item.members.filter((m) => m.rating > 0); + if (rated.length === 0) return 0; + return rated.reduce((s, m) => s + m.rating, 0) / rated.length; + }; + + const getDateWatched = (item: DisplayItem): number => { + if (item.kind === 'title') { + return item.data.dateFinished ? new Date(item.data.dateFinished).getTime() : 0; + } + const dates = item.members + .map((m) => m.dateFinished ? new Date(m.dateFinished).getTime() : 0) + .filter((d) => d > 0); + return dates.length > 0 ? Math.max(...dates) : 0; + }; + + const getTimeLeft = (item: DisplayItem): number => + item.kind === 'title' + ? this.dataManager.calcTimeRemaining(item.data) + : item.members.reduce((s, m) => s + this.dataManager.calcTimeRemaining(m), 0); + + const getTimeWatched = (item: DisplayItem): number => + item.kind === 'title' + ? this.dataManager.calcTimeWatched(item.data) + : item.members.reduce((s, m) => s + this.dataManager.calcTimeWatched(m), 0); + + switch (key) { + case 'title-asc': return getName(a).localeCompare(getName(b)); + case 'title-desc': return getName(b).localeCompare(getName(a)); + case 'dateAdded-newest': return getDateAdded(b) - getDateAdded(a); + case 'dateAdded-oldest': return getDateAdded(a) - getDateAdded(b); + case 'progress-high': return getProgress(b) - getProgress(a); + case 'progress-low': return getProgress(a) - getProgress(b); + case 'rating-high': { + const ra = getRating(a), rb = getRating(b); + if (ra === 0 && rb === 0) return 0; + if (ra === 0) return 1; + if (rb === 0) return -1; + return rb - ra; + } + case 'rating-low': { + const ra = getRating(a), rb = getRating(b); + if (ra === 0 && rb === 0) return 0; + if (ra === 0) return 1; + if (rb === 0) return -1; + return ra - rb; + } + case 'priority': return getPriorityIndex(a) - getPriorityIndex(b); + case 'status-asc': return getStatusIdx(a) - getStatusIdx(b); + case 'status-desc': return getStatusIdx(b) - getStatusIdx(a); + case 'started-asc': { + const da = getStartedDate(a); + const db = getStartedDate(b); + if (da == null && db == null) return 0; + if (da == null) return 1; + if (db == null) return -1; + return da - db; + } + case 'started-desc': { + const da = getStartedDate(a); + const db = getStartedDate(b); + if (da == null && db == null) return 0; + if (da == null) return 1; + if (db == null) return -1; + return db - da; + } + case 'dateWatched-newest': return getDateWatched(b) - getDateWatched(a); + case 'dateWatched-oldest': { + const da = getDateWatched(a) || Infinity; + const db = getDateWatched(b) || Infinity; + return da - db; + } + case 'timeLeft-high': return getTimeLeft(b) - getTimeLeft(a); + case 'timeLeft-low': return getTimeLeft(a) - getTimeLeft(b); + case 'timeWatched-high': return getTimeWatched(b) - getTimeWatched(a); + case 'timeWatched-low': return getTimeWatched(a) - getTimeWatched(b); + default: return 0; + } + } + + private compareDisplayItems(a: DisplayItem, b: DisplayItem): number { + const primaryKey = ListTab.sortBaseAndDirToKey(this.filterSort, this.filterSortDir); + const primary = this.compareDisplayItemsByKey(primaryKey, a, b); + if (primary !== 0 || this.filterSecondSort === 'none') return primary; + const secondKey = ListTab.sortBaseAndDirToKey(this.filterSecondSort, this.filterSecondSortDir); + return this.compareDisplayItemsByKey(secondKey, a, b); + } + + // ── Individual title row ────────────────────────────────────────────────────── + + private renderRow(parent: HTMLElement, title: WatchLogTitle, indented = false): void { + const isExpanded = this.expandedId === title.id; + const isSelected = this.selectedItems.has(title.id); + const classes = ['wl-row', isExpanded ? 'is-expanded' : '', indented ? 'wl-row-indented' : '', isSelected ? 'wl-row-selected' : ''] + .filter(Boolean) + .join(' '); + const row = parent.createDiv({ cls: classes }); + row.dataset['titleId'] = title.id; + + // Selection checkbox column + if (this.selectionMode) { + const cbCol = row.createDiv({ cls: 'wl-col-select' }); + const cb = cbCol.createEl('input', { attr: { type: 'checkbox' } }); + cb.checked = isSelected; + cb.addEventListener('click', (e) => e.stopPropagation()); + cb.addEventListener('change', () => { + if (cb.checked) { this.selectedItems.add(title.id); } + else { this.selectedItems.delete(title.id); } + this.render(); + }); + } + + // Title + type badge + progress bar + const colTitle = row.createDiv({ cls: 'wl-col-title' }); + colTitle.createDiv({ cls: 'wl-row-title-text', text: title.title }); + const typeDef = this.getTagDef(title.type, this.plugin.settings.types); + const colored = this.plugin.settings.coloredTypeBadges; + const badgeRow = colTitle.createDiv({ cls: 'wl-badge-row' }); + const typeBadge = badgeRow.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); + if (ListTab.isRecentlyArrived(title)) { + badgeRow.createSpan({ cls: 'wl-recently-arrived', text: '· Recently arrived' }); + } + const barWrap = colTitle.createDiv({ cls: 'wl-progress-wrap' }); + const bar = barWrap.createDiv({ cls: 'wl-progress-bar' }); + bar.style.width = `${this.dataManager.getProgress(title)}%`; + + // Priority + const colPriority = row.createDiv({ cls: 'wl-col-priority' }); + if (title.priority) { + const priorityDef = this.getTagDef(title.priority, this.plugin.settings.priorities); + const pBadge = colPriority.createSpan({ cls: 'wl-priority-badge', text: title.priority }); + if (priorityDef) pBadge.style.color = priorityDef.color; + } + + // Started date + const colStarted = row.createDiv({ cls: 'wl-col-started' }); + colStarted.textContent = title.dateStarted ? formatDateDisplay(title.dateStarted) : '—'; + + // Rating + const colRating = row.createDiv({ cls: 'wl-col-rating' }); + if (title.rating > 0) { + colRating.createSpan({ cls: 'wl-row-rating', text: `★ ${title.rating}/5` }); + } else { + colRating.createSpan({ cls: 'wl-row-rating wl-row-rating-empty', text: '—' }); + } + + // Status badge (respects coloredTypeBadges toggle) + const colStatus = row.createDiv({ cls: 'wl-col-status' }); + const statusDef = this.getTagDef(title.status, this.plugin.settings.statuses); + const statusBadge = colStatus.createSpan({ + cls: colored ? 'wl-badge' : 'wl-badge-plain', + text: title.status, + }); + if (colored && statusDef) statusBadge.style.backgroundColor = getThemedColor(title.status, statusDef.color, this.plugin.settings.colorTheme); + + // Pin icon + const colPin = row.createDiv({ cls: 'wl-col-pin' }); + const pinIcon = colPin.createSpan({ + cls: `wl-pin-icon${title.pinned ? ' is-pinned' : ''}`, + text: '📌', + }); + pinIcon.title = title.pinned ? 'Unpin' : 'Pin to top'; + pinIcon.addEventListener('click', (e) => { + e.stopPropagation(); + void (async () => { + const t = this.dataManager.getTitle(title.id); + if (!t) return; + const newPinned = !t.pinned; + // Unpin all others first + if (newPinned) { + for (const other of this.dataManager.getTitles()) { + if (other.id !== t.id && other.pinned) { + other.pinned = false; + await this.dataManager.updateTitle(other); + } + } + } + t.pinned = newPinned; + await this.dataManager.updateTitle(t); + this.rerenderTable(); + })(); + }); + + row.addEventListener('click', () => { + if (this.selectionMode) { + if (this.selectedItems.has(title.id)) { this.selectedItems.delete(title.id); } + else { this.selectedItems.add(title.id); } + this.render(); + return; + } + this.expandedId = isExpanded ? null : title.id; + if (!isExpanded) { + this.collapsedSeasons = this.dataManager.getCollapsedSeasonsForTitle(title.id); + } + this.rerenderTable(); + }); + + if (isExpanded && !this.selectionMode) { + this.renderAccordion(parent, title); + } + } + + // ── Group header row ────────────────────────────────────────────────────────── + + private renderGroupRow( + parent: HTMLElement, + group: WatchLogGroup, + members: WatchLogTitle[], + ): void { + const isExpanded = this.expandedGroups.has(group.id); + const isSelected = this.selectedItems.has(group.id); + const row = parent.createDiv({ + cls: `wl-row wl-group-row${isExpanded ? ' is-expanded' : ''}${isSelected ? ' wl-row-selected' : ''}`, + }); + + // Add data-* attributes so filters can evaluate the group row the same way as title rows + row.dataset['type'] = members[0]?.type ?? ''; + row.dataset['status'] = this.getGroupStatus(members); + row.dataset['priority'] = this.getGroupHighestPriority(members) ?? ''; + row.dataset['rating'] = `${Math.round(this.getGroupRating(members))}★`; + row.dataset['group'] = group.name; + + // Selection checkbox column + if (this.selectionMode) { + const cbCol = row.createDiv({ cls: 'wl-col-select' }); + const cb = cbCol.createEl('input', { attr: { type: 'checkbox' } }); + cb.checked = isSelected; + cb.addEventListener('click', (e) => e.stopPropagation()); + cb.addEventListener('change', () => { + if (cb.checked) { this.selectedItems.add(group.id); } + else { this.selectedItems.delete(group.id); } + this.render(); + }); + } + + // Col: Title + type badge + group stats + progress bar + const colTitle = row.createDiv({ cls: 'wl-col-title' }); + + // Group name row: name (or rename input) + action buttons + if (this.renamingGroupId === group.id) { + // Rename mode + const renameRow = colTitle.createDiv({ cls: 'wl-group-name-row' }); + const nameInput = renameRow.createEl('input', { + cls: 'wl-group-rename-input', + attr: { type: 'text', value: group.name }, + }); + nameInput.addEventListener('click', (e) => e.stopPropagation()); + nameInput.addEventListener('keydown', (e) => { + e.stopPropagation(); + if (e.key === 'Enter') { + void (async () => { + const newName = nameInput.value.trim(); + if (newName && newName !== group.name) { + const updated = { ...group, name: newName }; + await this.dataManager.updateGroup(updated); + } + this.renamingGroupId = null; + this.rerenderTable(); + })(); + } else if (e.key === 'Escape') { + this.renamingGroupId = null; + this.rerenderTable(); + } + }); + const saveBtn = renameRow.createEl('button', { cls: 'wl-group-action-btn', text: '✓' }); + saveBtn.title = 'Save'; + saveBtn.addEventListener('click', (e) => { + e.stopPropagation(); + void (async () => { + const newName = nameInput.value.trim(); + if (newName && newName !== group.name) { + const updated = { ...group, name: newName }; + await this.dataManager.updateGroup(updated); + } + this.renamingGroupId = null; + this.rerenderTable(); + })(); + }); + const cancelBtn = renameRow.createEl('button', { cls: 'wl-group-action-btn', text: '✕' }); + cancelBtn.title = 'Cancel'; + cancelBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.renamingGroupId = null; + this.rerenderTable(); + }); + // Focus the input + window.setTimeout(() => nameInput.focus(), 0); + } else { + // Normal mode: name + pencil + delete buttons + const nameRow = colTitle.createDiv({ cls: 'wl-group-name-row' }); + nameRow.createSpan({ cls: 'wl-row-title-text wl-group-name', text: group.name }); + const renameBtn = nameRow.createEl('button', { cls: 'wl-group-action-btn', text: '✏' }); + renameBtn.title = 'Rename group'; + renameBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.renamingGroupId = group.id; + this.rerenderTable(); + }); + const deleteBtn = nameRow.createEl('button', { cls: 'wl-group-action-btn wl-group-action-btn-delete wl-btn-danger', text: '✕' }); + deleteBtn.title = 'Delete group (titles are kept)'; + deleteBtn.addEventListener('click', (e) => { + e.stopPropagation(); + new ConfirmModal(this.plugin.app, `Delete group "${group.name}"? All titles inside will be returned to the main list.`, () => { + void this.dataManager.removeGroup(group.id).then(() => { + this.expandedGroups.delete(group.id); + this.rerenderTable(); + }); + }).open(); + }); + } + + const primaryType = members[0]?.type ?? ''; + if (primaryType) { + const typeDef = this.getTagDef(primaryType, this.plugin.settings.types); + const colored = this.plugin.settings.coloredTypeBadges; + const typeBadge = colTitle.createSpan({ + cls: colored ? 'wl-badge wl-badge-sm' : 'wl-badge-plain', + text: primaryType, + }); + if (colored && typeDef) typeBadge.style.backgroundColor = getThemedColor(primaryType, typeDef.color, this.plugin.settings.colorTheme); + } + + // Group stats (always visible) + const statsEl = colTitle.createDiv({ cls: 'wl-group-stats' }); + const timeWatched = members.reduce((s, t) => s + this.dataManager.calcTimeWatched(t), 0); + const timeLeft = members.reduce((s, t) => s + this.dataManager.calcTimeRemaining(t), 0); + statsEl.createSpan({ text: `${members.length} title${members.length !== 1 ? 's' : ''}` }); + statsEl.createSpan({ text: ` · ${formatTime(timeWatched)} watched` }); + statsEl.createSpan({ text: ` · ${formatTime(timeLeft)} left` }); + + const barWrap = colTitle.createDiv({ cls: 'wl-progress-wrap' }); + barWrap.createDiv({ cls: 'wl-progress-bar' }).style.width = + `${this.getGroupProgress(members)}%`; + + // Col: Priority (highest among members) + const colGrpPriority = row.createDiv({ cls: 'wl-col-priority' }); + const highestPriority = this.getGroupHighestPriority(members); + if (highestPriority) { + const pDef = this.getTagDef(highestPriority, this.plugin.settings.priorities); + const pBadge = colGrpPriority.createSpan({ cls: 'wl-priority-badge', text: highestPriority }); + if (pDef) pBadge.style.color = pDef.color; + } + + // Col: Started (earliest among members) + const colStarted = row.createDiv({ cls: 'wl-col-started' }); + const groupStarted = this.getGroupStartedDate(members); + colStarted.textContent = groupStarted ? formatDateDisplay(groupStarted) : '—'; + + // Col: Rating (average) + const colRating = row.createDiv({ cls: 'wl-col-rating' }); + const avgRating = this.getGroupRating(members); + if (avgRating > 0) { + colRating.createSpan({ + cls: 'wl-row-rating', + text: `★ ${avgRating.toFixed(1)}/5`, + }); + } else { + colRating.createSpan({ cls: 'wl-row-rating wl-row-rating-empty', text: '—' }); + } + + // Col: Status (computed per priority rule, respects coloredTypeBadges) + const colStatus = row.createDiv({ cls: 'wl-col-status' }); + const groupStatus = this.getGroupStatus(members); + const groupStatusDef = this.getTagDef(groupStatus, this.plugin.settings.statuses); + const groupColored = this.plugin.settings.coloredTypeBadges; + const statusBadge = colStatus.createSpan({ + cls: groupColored ? 'wl-badge' : 'wl-badge-plain', + text: groupStatus, + }); + if (groupColored) { + if (groupStatusDef) { + statusBadge.style.backgroundColor = getThemedColor(groupStatus, groupStatusDef.color, this.plugin.settings.colorTheme); + } else if (groupStatus === 'In Progress') { + statusBadge.style.backgroundColor = getThemedColor('In Progress', '#724CF9', this.plugin.settings.colorTheme); + } + } + + // Col: Pin + const isGrpPinned = this.pinnedGroupId === group.id; + const colGrpPin = row.createDiv({ cls: 'wl-col-pin' }); + const grpPinIcon = colGrpPin.createSpan({ + cls: `wl-pin-icon${isGrpPinned ? ' is-pinned' : ''}`, + text: '📌', + }); + grpPinIcon.title = isGrpPinned ? 'Unpin' : 'Pin to top'; + grpPinIcon.addEventListener('click', (e) => { + e.stopPropagation(); + this.pinnedGroupId = isGrpPinned ? null : group.id; + void this.dataManager.setPinnedGroupId(this.pinnedGroupId); + // Unpin any pinned title + if (this.pinnedGroupId) { + for (const t of this.dataManager.getTitles()) { + if (t.pinned) { + t.pinned = false; + void this.dataManager.updateTitle(t); + } + } + } + this.rerenderTable(); + }); + + // Toggle expand/collapse (disabled while renaming or in selection mode) + row.addEventListener('click', () => { + if (this.renamingGroupId === group.id) return; + if (this.selectionMode) { + if (this.selectedItems.has(group.id)) { this.selectedItems.delete(group.id); } + else { this.selectedItems.add(group.id); } + this.render(); + return; + } + if (isExpanded) { + this.expandedGroups.delete(group.id); + } else { + this.expandedGroups.add(group.id); + } + this.rerenderTable(); + }); + + // Render member titles indented when expanded + if (isExpanded) { + for (const member of members) { + this.renderRow(parent, member, true); + } + } + } + + // ── Group helper calculations ───────────────────────────────────────────────── + + private getGroupProgress(members: WatchLogTitle[]): number { + const totalWatched = members.reduce((s, t) => s + t.watchedEpisodes.length, 0); + const totalEps = members.reduce((s, t) => s + this.dataManager.getEffectiveTotal(t), 0); + if (totalEps === 0) return 0; + return Math.min(100, Math.round((totalWatched / totalEps) * 100)); + } + + private getGroupStatus(members: WatchLogTitle[]): string { + if (members.length === 0) return 'Plan to watch'; + const statuses = members.map((t) => t.status); + if (statuses.some((s) => s === 'Watching')) return 'Watching'; + if (statuses.every((s) => s === 'Completed')) return 'Completed'; + if (statuses.every((s) => s === 'Plan to watch')) return 'Plan to watch'; + return 'In Progress'; + } + + private getGroupStartedDate(members: WatchLogTitle[]): string | null { + const dates = members.map((t) => t.dateStarted).filter((d): d is string => d !== null); + if (dates.length === 0) return null; + return dates.sort()[0] ?? null; + } + + private getGroupRating(members: WatchLogTitle[]): number { + const rated = members.filter((t) => t.rating > 0); + if (rated.length === 0) return 0; + return rated.reduce((s, t) => s + t.rating, 0) / rated.length; + } + + private getGroupHighestPriority(members: WatchLogTitle[]): string | null { + let best: string | null = null; + let bestIdx = Infinity; + for (const m of members) { + const idx = PRIORITY_ORDER.indexOf(m.priority as typeof PRIORITY_ORDER[number]); + if (idx !== -1 && idx < bestIdx) { + bestIdx = idx; + best = m.priority; + } + } + return best; + } + + // ── Accordion ───────────────────────────────────────────────────────────────── + + private renderAccordion(parent: HTMLElement, title: WatchLogTitle): void { + const accordion = parent.createDiv({ cls: 'wl-accordion' }); + accordion.addEventListener('click', (e) => e.stopPropagation()); + + // Header + const accHeader = accordion.createDiv({ cls: 'wl-accordion-header' }); + const accLeft = accHeader.createDiv({ cls: 'wl-acc-header-left' }); + const episodeTotal = title.totalEpisodes > 0 ? ` · ${title.totalEpisodes} eps total` : ''; + const startedStr = title.dateStarted + ? `Started ${formatDateDisplay(title.dateStarted)}` + : 'Not started'; + const subtitleRow = accLeft.createDiv({ cls: 'wl-acc-subtitle' }); + subtitleRow.createSpan({ cls: 'wl-acc-subtitle-text', text: `${startedStr}${episodeTotal}` }); + if (title.externalLink) { + const linkIcon = subtitleRow.createEl('a', { cls: 'wl-acc-link-icon', text: '🌐' }); + linkIcon.href = title.externalLink; + linkIcon.title = 'Open external link'; + linkIcon.target = '_blank'; + linkIcon.rel = 'noopener noreferrer'; + linkIcon.addEventListener('click', (e) => e.stopPropagation()); + } + + // Stats row: 3 new blocks + existing progress block, all in one right-aligned row + const watchedEps = title.watchedEpisodes.length; + const effectiveTotal = this.dataManager.getEffectiveTotal(title); + const timeWatched = this.dataManager.calcTimeWatched(title); + const timeLeft = this.dataManager.calcTimeRemainingForModal(title); + const progress = this.dataManager.getProgress(title); + + const accRightGroup = accHeader.createDiv({ cls: 'wl-acc-right-group' }); + + const makeStatBlock = (value: string, label: string): void => { + const block = accRightGroup.createDiv({ cls: 'wl-acc-stat-block' }); + block.createDiv({ cls: 'wl-acc-percent', text: value }); + block.createDiv({ cls: 'wl-acc-progress-label', text: label }); + }; + makeStatBlock(formatTime(timeLeft), 'left'); + makeStatBlock(formatTime(timeWatched), 'watched'); + makeStatBlock(`${watchedEps} / ${effectiveTotal}`, 'episodes'); + + const accRight = accRightGroup.createDiv({ cls: 'wl-acc-header-right' }); + accRight.createDiv({ cls: 'wl-acc-percent', text: `${progress}%` }); + accRight.createDiv({ cls: 'wl-acc-progress-label', text: 'progress' }); + const accBarWrap = accRight.createDiv({ cls: 'wl-acc-progress-wrap' }); + accBarWrap.createDiv({ cls: 'wl-progress-bar' }).style.width = `${progress}%`; + + // Body + const accBody = accordion.createDiv({ cls: 'wl-accordion-body' }); + if (title.type === 'Movie') { + this.renderMovieBody(accBody, title); + } else { + this.renderEpisodesBody(accBody, title); + } + + // Footer + this.renderAccordionFooter(accordion, title); + } + + private renderMovieBody(parent: HTMLElement, title: WatchLogTitle): void { + const row = parent.createDiv({ cls: 'wl-movie-row' }); + const cb = row.createEl('input', { + cls: 'wl-movie-checkbox', + attr: { type: 'checkbox' }, + }); + cb.checked = title.watchedEpisodes.includes(1); + row.createSpan({ cls: 'wl-movie-label', text: 'Watched' }); + + cb.addEventListener('change', (e) => { + e.stopPropagation(); + void this.dataManager.markEpisodeWatched(title.id, 1, cb.checked).then(() => this.rerenderTable()); + }); + } + + private renderEpisodesBody(parent: HTMLElement, title: WatchLogTitle): void { + if (title.seasons.length === 0) { + if (title.totalEpisodes > 0) { + this.renderEpisodeGrid(parent, title, null); + } + return; + } + + title.seasons.forEach((season, seasonIdx) => { + let isCollapsed = this.collapsedSeasons.has(seasonIdx); + const seasonWrap = parent.createDiv({ cls: 'wl-season-wrap' }); + + const seasonHeader = seasonWrap.createDiv({ cls: 'wl-season-header' }); + const badge = seasonHeader.createSpan({ cls: 'wl-season-badge' }); + badge.textContent = season.name; + const palette = this.plugin.settings.seasonPalette; + badge.style.backgroundColor = palette[seasonIdx % palette.length] ?? '#888780'; + const skipCount = (season.skippedEpisodes ?? []).length; + const skipSuffix = skipCount > 0 ? ` (${skipCount} to skip)` : ''; + seasonHeader.createSpan({ cls: 'wl-season-ep-count', text: `${season.episodes} eps${skipSuffix}` }); + const chevron = seasonHeader.createSpan({ + cls: `wl-chevron${isCollapsed ? '' : ' is-open'}`, + text: '›', + }); + + // Render grid immediately only if this season is expanded + if (!isCollapsed) { + this.renderEpisodeGrid(seasonWrap, title, season); + } + + seasonHeader.addEventListener('click', (e) => { + e.stopPropagation(); + if (isCollapsed) { + // Expanding: create grid in-place + isCollapsed = false; + this.collapsedSeasons.delete(seasonIdx); + chevron.classList.add('is-open'); + this.renderEpisodeGrid(seasonWrap, title, season); + } else { + // Collapsing: remove grid from DOM entirely + isCollapsed = true; + this.collapsedSeasons.add(seasonIdx); + chevron.classList.remove('is-open'); + seasonWrap.querySelector('.wl-episode-grid')?.remove(); + } + if (this.expandedId) { + void this.dataManager.persistCollapsedSeasons(this.expandedId, this.collapsedSeasons); + } + }); + }); + } + + private renderEpisodeGrid( + parent: HTMLElement, + title: WatchLogTitle, + season: Season | null, + ): void { + const grid = parent.createDiv({ cls: 'wl-episode-grid' }); + const count = season ? season.episodes : title.totalEpisodes; + const offset = season ? season.offset : 0; + const seasonEps = Array.from({ length: count }, (_, i) => offset + i + 1); + + // Fill/clear toggle button (first element in the row). Its visual state is + // always recomputed from the live `title.watchedEpisodes`. + const fillBtn = grid.createDiv({ cls: 'wl-season-fill-btn' }); + const refreshFillBtn = (): void => { + const watched = new Set(title.watchedEpisodes); + const allWatched = seasonEps.length > 0 && seasonEps.every((ep) => watched.has(ep)); + fillBtn.classList.toggle('is-clear', allWatched); + fillBtn.classList.toggle('is-fill', !allWatched); + fillBtn.textContent = allWatched ? '✗' : '✓'; + fillBtn.title = allWatched + ? 'Clear all episodes in this season' + : 'Mark all episodes in this season as watched'; + }; + refreshFillBtn(); + fillBtn.addEventListener('click', (e) => { + e.stopPropagation(); + 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()); + }); + + const perSeason = this.plugin.settings.episodeNumbering === 'per-season'; + + for (let i = 0; i < count; i++) { + const epNum = offset + i + 1; + const relNum = i + 1; + const displayNum = perSeason ? relNum : epNum; + + const box = grid.createDiv({ cls: 'wl-episode-box' }); + + // Read fresh state every time — closures over isWatched/isSkipped would + // go stale after the first targeted update. + const refreshBox = (): void => { + const isWatched = title.watchedEpisodes.includes(epNum); + const isSkipped = season ? (season.skippedEpisodes ?? []).includes(relNum) : false; + box.classList.toggle('is-watched', isWatched); + box.classList.toggle('is-skipped', isSkipped); + box.textContent = isSkipped && !isWatched ? '—' : (isWatched ? '✓' : String(displayNum)); + box.title = `Episode ${epNum}${isSkipped ? ' (skipped)' : ''}`; + }; + refreshBox(); + + // Clicking toggles watched/unwatched only. The season's skippedEpisodes + // definition is read-only at runtime — it is set exclusively from the + // Edit modal's season text (e.g. "Season 1: 48 (33-37)"). + box.addEventListener('click', (e) => { + e.stopPropagation(); + const isWatched = title.watchedEpisodes.includes(epNum); + this.dataManager.applyEpisodeWatchedToggle(title.id, epNum, !isWatched); + refreshBox(); + refreshFillBtn(); + this.updateAccordionStats(box, title, season); + }); + } + } + + /** + * Targeted DOM update for the accordion stats row (left / watched / episodes + * / progress %) and the enclosing season header's skip-count suffix. Avoids + * a full table re-render on every episode click. + */ + private updateAccordionStats( + box: HTMLElement, + title: WatchLogTitle, + season: Season | null, + ): void { + const accordion = box.closest('.wl-accordion'); + if (!accordion) return; + + const timeLeft = this.dataManager.calcTimeRemainingForModal(title); + const timeWatched = this.dataManager.calcTimeWatched(title); + const watchedEps = title.watchedEpisodes.length; + const effectiveTotal = this.dataManager.getEffectiveTotal(title); + const progress = this.dataManager.getProgress(title); + + const statValues = accordion.querySelectorAll<HTMLElement>('.wl-acc-stat-block .wl-acc-percent'); + if (statValues[0]) statValues[0].textContent = formatTime(timeLeft); + if (statValues[1]) statValues[1].textContent = formatTime(timeWatched); + if (statValues[2]) statValues[2].textContent = `${watchedEps} / ${effectiveTotal}`; + + const progressText = accordion.querySelector<HTMLElement>('.wl-acc-header-right .wl-acc-percent'); + if (progressText) progressText.textContent = `${progress}%`; + const progressBar = accordion.querySelector<HTMLElement>('.wl-acc-header-right .wl-progress-bar'); + if (progressBar) progressBar.style.width = `${progress}%`; + + // Season header's "(N to skip)" suffix only changes on skip-toggle, but + // it's cheap to refresh either way. + if (season) { + const grid = box.closest('.wl-episode-grid'); + const seasonWrap = grid?.parentElement; + const countEl = seasonWrap?.querySelector<HTMLElement>('.wl-season-header .wl-season-ep-count'); + if (countEl) { + const skipCount = (season.skippedEpisodes ?? []).length; + const skipSuffix = skipCount > 0 ? ` (${skipCount} to skip)` : ''; + countEl.textContent = `${season.episodes} eps${skipSuffix}`; + } + } + } + + private renderAccordionFooter(parent: HTMLElement, title: WatchLogTitle): void { + const footer = parent.createDiv({ cls: 'wl-accordion-footer' }); + + // Star rating + const starsRow = footer.createDiv({ cls: 'wl-stars-row' }); + starsRow.createSpan({ cls: 'wl-stars-label', text: 'Rating' }); + const starsWrap = starsRow.createDiv({ cls: 'wl-stars' }); + for (let i = 1; i <= 5; i++) { + const star = starsWrap.createSpan({ + cls: `wl-star${title.rating >= i ? ' is-active' : ''}`, + text: '★', + }); + star.addEventListener('click', (e) => { + e.stopPropagation(); + void (async () => { + const t = this.dataManager.getTitle(title.id); + if (!t) return; + t.rating = t.rating === i ? 0 : i; + await this.dataManager.updateTitle(t); + this.rerenderTable(); + })(); + }); + } + + const rerenderCommunity = (): void => { + const next = starsRow.querySelector('.wl-rating-divider'); + let n: ChildNode | null = next; + while (n) { + const toRemove = n; + n = n.nextSibling; + toRemove.parentNode?.removeChild(toRemove); + } + renderCommunityRating(starsRow, this.plugin, title.id, rerenderCommunity); + }; + renderCommunityRating(starsRow, this.plugin, title.id, rerenderCommunity); + maybeAutoRefreshCommunityRating(this.plugin, title.id, rerenderCommunity); + + // Notes + const notesRow = footer.createDiv({ cls: 'wl-footer-row' }); + const notesInput = notesRow.createEl('input', { + cls: 'wl-notes-input', + attr: { type: 'text', placeholder: 'Add a note...' }, + }); + notesInput.value = title.notes; + notesInput.addEventListener('click', (e) => e.stopPropagation()); + notesInput.addEventListener('change', () => { + void (async () => { + const t = this.dataManager.getTitle(title.id); + if (!t) return; + t.notes = notesInput.value; + await this.dataManager.updateTitle(t); + this.rerenderTable(); + })(); + }); + + // Date watched + const dateRow = footer.createDiv({ cls: 'wl-footer-row' }); + dateRow.createSpan({ cls: 'wl-footer-label', text: 'Date watched' }); + const todayBtn = dateRow.createEl('button', { + cls: 'wl-btn wl-btn-sm wl-footer-today-btn', + text: 'Today', + attr: { title: 'Fill with today’s date' }, + }); + const dateInput = dateRow.createEl('input', { + cls: 'wl-footer-date', + attr: { type: 'text', placeholder: 'Dd/mm/yyyy', maxlength: '10' }, + }); + dateInput.value = formatDateDisplay(title.dateFinished); + const refreshTodayBtnState = (): void => { + todayBtn.toggleClass('is-dimmed', !!dateInput.value.trim()); + }; + refreshTodayBtnState(); + dateInput.addEventListener('click', (e) => e.stopPropagation()); + dateInput.addEventListener('change', () => { + void (async () => { + const t = this.dataManager.getTitle(title.id); + if (!t) return; + const parsed = parseDateInput(dateInput.value); + if (dateInput.value.trim() && !parsed) { + dateInput.addClass('wl-input-error'); + return; + } + dateInput.removeClass('wl-input-error'); + t.dateFinished = parsed; + await this.dataManager.updateTitle(t); + this.rerenderTable(); + })(); + }); + todayBtn.addEventListener('click', (e) => { + e.stopPropagation(); + if (dateInput.value.trim()) return; // no-op when already filled + const now = new Date(); + const dd = String(now.getDate()).padStart(2, '0'); + const mm = String(now.getMonth() + 1).padStart(2, '0'); + const yyyy = now.getFullYear(); + dateInput.value = `${dd}/${mm}/${yyyy}`; + refreshTodayBtnState(); + dateInput.dispatchEvent(new Event('change')); + }); + + // Status + const statusRow = footer.createDiv({ cls: 'wl-footer-row' }); + statusRow.createSpan({ cls: 'wl-footer-label', text: 'Status' }); + const statusSelect = statusRow.createEl('select', { cls: 'wl-select wl-select-sm' }); + for (const s of this.plugin.settings.statuses) { + if (s.name === 'To be released') continue; // auto-managed; not user-selectable here + const opt = statusSelect.createEl('option', { text: s.name, value: s.name }); + if (s.name === title.status) opt.selected = true; + } + statusSelect.addEventListener('click', (e) => e.stopPropagation()); + statusSelect.addEventListener('change', () => { + void (async () => { + const t = this.dataManager.getTitle(title.id); + if (!t) return; + t.status = statusSelect.value; + await this.dataManager.updateTitle(t); + this.rerenderTable(); + })(); + }); + + // Delete (left) + Edit dropdown (right) + const actionRow = footer.createDiv({ cls: 'wl-footer-row wl-footer-action-row' }); + + const deleteBtn = actionRow.createEl('button', { cls: 'wl-delete-btn wl-btn-danger', text: 'Remove' }); + deleteBtn.addEventListener('click', (e) => { + e.stopPropagation(); + new ConfirmModal(this.plugin.app, `Remove "${title.title}" from watchlog?`, () => { + void this.dataManager.removeTitle(title.id).then(() => { + this.expandedId = null; + this.rerenderTable(); + }); + }).open(); + }); + + const editBtnWrap = actionRow.createDiv({ cls: 'wl-add-btn-wrap' }); + const editBtn = editBtnWrap.createEl('button', { cls: 'wl-edit-btn', text: 'Edit' }); + editBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const current = this.dataManager.getTitle(title.id); + if (!current) return; + new EditTitleModal(this.plugin.app, this.plugin, this.dataManager, current, () => { + this.rerenderTable(); + }).open(); + }); + } + + // ── Filter + sort ───────────────────────────────────────────────────────────── + + private compareTitlesByKey(key: string, a: WatchLogTitle, b: WatchLogTitle): number { + switch (key) { + case 'title-asc': return a.title.localeCompare(b.title); + case 'title-desc': return b.title.localeCompare(a.title); + case 'dateAdded-newest': return new Date(b.dateAdded).getTime() - new Date(a.dateAdded).getTime(); + case 'dateAdded-oldest': return new Date(a.dateAdded).getTime() - new Date(b.dateAdded).getTime(); + case 'progress-high': return this.dataManager.getProgress(b) - this.dataManager.getProgress(a); + case 'progress-low': return this.dataManager.getProgress(a) - this.dataManager.getProgress(b); + case 'rating-high': { + const ra = a.rating === 0 ? -1 : a.rating; + const rb = b.rating === 0 ? -1 : b.rating; + return rb - ra; + } + case 'rating-low': { + if (a.rating === 0 && b.rating === 0) return 0; + if (a.rating === 0) return 1; + if (b.rating === 0) return -1; + return a.rating - b.rating; + } + case 'priority': { + const ai = PRIORITY_ORDER.indexOf(a.priority as typeof PRIORITY_ORDER[number]); + const bi = PRIORITY_ORDER.indexOf(b.priority as typeof PRIORITY_ORDER[number]); + return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi); + } + case 'started-asc': { + const da = a.dateStarted ? new Date(a.dateStarted).getTime() : Infinity; + const db = b.dateStarted ? new Date(b.dateStarted).getTime() : Infinity; + return da - db; + } + case 'started-desc': { + const da2 = a.dateStarted ? new Date(a.dateStarted).getTime() : null; + const db2 = b.dateStarted ? new Date(b.dateStarted).getTime() : null; + if (da2 == null && db2 == null) return 0; + if (da2 == null) return 1; + if (db2 == null) return -1; + return db2 - da2; + } + case 'status-asc': return this.getStatusIndex(a.status) - this.getStatusIndex(b.status); + case 'status-desc': return this.getStatusIndex(b.status) - this.getStatusIndex(a.status); + case 'dateWatched-newest': { + const da = a.dateFinished ? new Date(a.dateFinished).getTime() : 0; + const db = b.dateFinished ? new Date(b.dateFinished).getTime() : 0; + return db - da; + } + case 'dateWatched-oldest': { + const da = a.dateFinished ? new Date(a.dateFinished).getTime() : Infinity; + const db = b.dateFinished ? new Date(b.dateFinished).getTime() : Infinity; + return da - db; + } + case 'timeLeft-high': return this.dataManager.calcTimeRemaining(b) - this.dataManager.calcTimeRemaining(a); + case 'timeLeft-low': return this.dataManager.calcTimeRemaining(a) - this.dataManager.calcTimeRemaining(b); + case 'timeWatched-high': return this.dataManager.calcTimeWatched(b) - this.dataManager.calcTimeWatched(a); + case 'timeWatched-low': return this.dataManager.calcTimeWatched(a) - this.dataManager.calcTimeWatched(b); + default: return 0; + } + } + + private getFilteredSortedTitles(): WatchLogTitle[] { + let titles = this.dataManager.getTitles(); + + if (this.searchQuery.trim()) { + const q = this.searchQuery.toLowerCase(); + titles = titles.filter((t) => t.title.toLowerCase().includes(q)); + } + + titles = titles.filter((t) => this.titlePassesFilters(t)); + + const primaryKey = ListTab.sortBaseAndDirToKey(this.filterSort, this.filterSortDir); + const secondKey = this.filterSecondSort === 'none' + ? 'none' + : ListTab.sortBaseAndDirToKey(this.filterSecondSort, this.filterSecondSortDir); + titles = [...titles].sort((a, b) => { + const primary = this.compareTitlesByKey(primaryKey, a, b); + if (primary !== 0 || secondKey === 'none') return primary; + return this.compareTitlesByKey(secondKey, a, b); + }); + + return titles; + } + + // ── Utilities ───────────────────────────────────────────────────────────────── + + private getTagDef(name: string, tags: TagDefinition[]): TagDefinition | undefined { + return tags.find((t) => t.name === name); + } + +} + diff --git a/src/LogTab.ts b/src/LogTab.ts new file mode 100644 index 0000000..0bec2f9 --- /dev/null +++ b/src/LogTab.ts @@ -0,0 +1,426 @@ +import type WatchLogPlugin from './main'; +import type { HistoryEntry, HistorySource, HistoryAction } from './HistoryManager'; + +type SourceFilter = 'all' | 'Watchlist' | 'Reading'; + +const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; +const MONTHS = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +]; + +const ACTION_COLORS: Record<string, string> = { + completed: '#1D9E75', + watched: '#1D9E75', + added: '#378ADD', + deleted: '#E24B4A', + status: '#BA7517', + rating: '#BA7517', +}; + +// Leading source dot is colored by source (not action): green Reading, blue Watchlist. +const SOURCE_COLORS: Record<string, string> = { + Reading: '#1D9E75', + Watchlist: '#378ADD', +}; + +function inferAction(entry: HistoryEntry): HistoryAction { + if (entry.action) return entry.action; + const m = entry.message.toLowerCase(); + if (m.includes('was added')) return 'added'; + if (m.includes('was deleted')) return 'deleted'; + if (m.includes('was reviewed') || m.includes('rating changed')) return 'rating'; + if (m.includes('episode') && m.includes('watched')) return 'watched'; + if (m.includes('was marked as watched') || m.includes('completed')) return 'completed'; + if (m.includes('status')) return 'status'; + return 'added'; +} + +function inferSource(entry: HistoryEntry): HistorySource { + if (entry.source) return entry.source; + return 'Watchlist'; +} + +function actionLabel(entry: HistoryEntry): string { + const action = inferAction(entry); + const m = entry.message; + switch (action) { + case 'added': return 'Added'; + case 'deleted': return 'Deleted'; + case 'rating': { + const rm = m.match(/Rating → (\d+\/5)/); + if (rm) return `Rating → ${rm[1]}`; + return 'Rating changed'; + } + case 'status': { + const sm = m.match(/status changed to (.+)$/); + return sm ? `Status → ${sm[1]}` : 'Status changed'; + } + case 'completed': return 'Completed'; + case 'watched': { + const pm = m.match(/At (page|chapter|volume) (\d+) \/ (\d+)/i); + if (pm) return `At ${pm[1]} ${pm[2]} / ${pm[3]}`; + const em = m.match(/episode (\d+)/i); + return em ? `Episode ${em[1]} watched` : 'Watched'; + } + } + return 'Updated'; +} + +function typeFromEntry(entry: HistoryEntry): string { + const m = entry.message.match(/\(([^)]+)\)/); + return m ? m[1]! : ''; +} + +function titleFromEntry(entry: HistoryEntry): string { + let title: string; + if (entry.titleName) { + title = entry.titleName; + } else { + const m = entry.message.match(/^(.+?)\s*\(/); + title = m ? m[1]! : entry.message; + } + const type = typeFromEntry(entry); + if (type) return `${title} (${type})`; + return title; +} + +function formatDayHeader(dateStr: string): string { + const d = new Date(dateStr + 'T12:00:00'); + return `${DAYS[d.getDay()]}, ${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()}`; +} + +function formatTime(isoTs: string): string { + const d = new Date(isoTs); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +} + +function dateKey(isoTs: string): string { + const d = new Date(isoTs); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +interface VirtualRow { + type: 'header' | 'entry'; + date?: string; + entry?: HistoryEntry; + isLastInGroup?: boolean; +} + +const ROW_HEIGHT = 32; +const HEADER_HEIGHT = 40; + +export class LogTab { + private container: HTMLElement; + private plugin: WatchLogPlugin; + private sourceFilter: SourceFilter = 'all'; + + private scrollContainer: HTMLElement | null = null; + private spacer: HTMLElement | null = null; + private viewport: HTMLElement | null = null; + private rows: VirtualRow[] = []; + private rowOffsets: number[] = []; + private totalHeight = 0; + private scrollHandler: (() => void) | null = null; + private scrollRAF: number | null = null; + private resizeObserver: ResizeObserver | null = null; + private lastFirst = -1; + private lastLast = -1; + private renderedNodes: Map<number, HTMLElement> = new Map(); + + constructor(container: HTMLElement, plugin: WatchLogPlugin) { + this.container = container; + this.plugin = plugin; + } + + destroy(): void { + this.destroyVirtualScroll(); + } + + render(): void { + this.destroyVirtualScroll(); + this.container.empty(); + this.container.addClass('wl-log-tab'); + + this.renderFilterBar(); + + const allEntries = this.plugin.historyManager?.getEntries() ?? []; + const filtered = this.sourceFilter === 'all' + ? allEntries + : allEntries.filter((e) => inferSource(e) === this.sourceFilter); + + if (filtered.length === 0) { + this.container.createDiv({ + cls: 'wl-log-empty', + text: this.sourceFilter === 'all' + ? 'No events yet. Actions from Watchlist and Reading will appear here.' + : `No ${this.sourceFilter} events yet.`, + }); + return; + } + + this.buildVirtualRows(filtered); + this.renderVirtualTimeline(); + } + + private renderFilterBar(): void { + const bar = this.container.createDiv({ cls: 'wl-log-filter-bar' }); + + const filters: { label: string; value: SourceFilter }[] = [ + { label: 'All', value: 'all' }, + { label: 'Watchlist', value: 'Watchlist' }, + { label: 'Reading', value: 'Reading' }, + ]; + + for (const f of filters) { + const btn = bar.createEl('button', { + cls: `wl-log-filter-btn${this.sourceFilter === f.value ? ' is-active' : ''}`, + text: f.label, + }); + btn.addEventListener('click', () => { + if (this.sourceFilter === f.value) return; + this.sourceFilter = f.value; + this.render(); + }); + } + } + + private buildVirtualRows(entries: HistoryEntry[]): void { + this.rows = []; + this.rowOffsets = []; + + const map = new Map<string, HistoryEntry[]>(); + for (const entry of entries) { + const key = dateKey(entry.timestamp); + let list = map.get(key); + if (!list) { + list = []; + map.set(key, list); + } + list.push(entry); + } + + const groups: { date: string; entries: HistoryEntry[] }[] = []; + for (const [date, list] of map) { + groups.push({ date, entries: list }); + } + + let offset = 0; + for (const group of groups) { + this.rows.push({ type: 'header', date: group.date }); + this.rowOffsets.push(offset); + offset += HEADER_HEIGHT; + + for (let i = 0; i < group.entries.length; i++) { + this.rows.push({ + type: 'entry', + entry: group.entries[i], + isLastInGroup: i === group.entries.length - 1, + }); + this.rowOffsets.push(offset); + offset += ROW_HEIGHT; + } + } + this.totalHeight = offset; + } + + private renderVirtualTimeline(): void { + const scroll = this.container.createDiv({ cls: 'wl-log-timeline wl-log-virtual-scroll' }); + const spacer = scroll.createDiv({ cls: 'wl-log-spacer' }); + const viewport = spacer.createDiv({ cls: 'wl-log-viewport' }); + + this.scrollContainer = scroll; + this.spacer = spacer; + this.viewport = viewport; + this.renderedNodes.clear(); + this.lastFirst = -1; + this.lastLast = -1; + + spacer.style.height = `${this.totalHeight}px`; + spacer.style.position = 'relative'; + viewport.style.position = 'absolute'; + viewport.style.top = '0'; + viewport.style.left = '0'; + viewport.style.right = '0'; + viewport.style.height = `${this.totalHeight}px`; + + this.scrollHandler = () => { + if (this.scrollRAF !== null) return; + this.scrollRAF = window.requestAnimationFrame(() => { + this.scrollRAF = null; + this.renderVisibleRows(); + }); + }; + scroll.addEventListener('scroll', this.scrollHandler, { passive: true }); + + this.resizeObserver = new ResizeObserver(() => { + this.renderedNodes.clear(); + if (this.viewport) this.viewport.empty(); + this.lastFirst = -1; + this.lastLast = -1; + this.renderVisibleRows(); + }); + this.resizeObserver.observe(scroll); + + this.renderVisibleRows(); + } + + private renderVisibleRows(): void { + const scroll = this.scrollContainer; + const viewport = this.viewport; + if (!scroll || !viewport) return; + + const scrollTop = scroll.scrollTop; + const viewHeight = scroll.clientHeight; + if (viewHeight <= 0) return; + + const first = this.findFirstVisible(scrollTop); + const last = this.findLastVisible(scrollTop + viewHeight); + + if (first === this.lastFirst && last === this.lastLast) return; + + this.lastFirst = first; + this.lastLast = last; + + // Remove rows that left the visible window + for (const [idx, el] of this.renderedNodes) { + if (idx < first || idx > last) { + el.remove(); + this.renderedNodes.delete(idx); + } + } + + // Add rows that newly entered the visible window + for (let i = first; i <= last; i++) { + if (this.renderedNodes.has(i)) continue; + const row = this.rows[i]; + if (!row) continue; + + let el: HTMLElement; + if (row.type === 'header') { + el = activeDocument.createElement('div'); + el.className = 'wl-log-day-header'; + el.style.height = `${HEADER_HEIGHT}px`; + el.textContent = formatDayHeader(row.date!); + } else { + el = this.buildEntryRow(row); + } + + const offset = this.rowOffsets[i] ?? 0; + el.style.position = 'absolute'; + el.style.top = `${offset}px`; + el.style.left = '0'; + el.style.right = '0'; + + viewport.appendChild(el); + this.renderedNodes.set(i, el); + } + } + + private buildEntryRow(row: VirtualRow): HTMLElement { + const entry = row.entry!; + const action = inferAction(entry); + const color = ACTION_COLORS[action] ?? '#888780'; + const source = inferSource(entry); + const dotColor = SOURCE_COLORS[source] ?? '#888780'; + + const el = activeDocument.createElement('div'); + el.className = 'wl-log-entry'; + el.style.height = `${ROW_HEIGHT}px`; + + const dotCol = activeDocument.createElement('div'); + dotCol.className = 'wl-log-dot-col'; + const dot = activeDocument.createElement('div'); + dot.className = 'wl-log-dot'; + dot.style.backgroundColor = dotColor; + dotCol.appendChild(dot); + if (!row.isLastInGroup) { + const connector = activeDocument.createElement('div'); + connector.className = 'wl-log-connector'; + dotCol.appendChild(connector); + } + el.appendChild(dotCol); + + const content = activeDocument.createElement('div'); + content.className = 'wl-log-content'; + const srcSpan = activeDocument.createElement('span'); + srcSpan.className = 'wl-log-source'; + srcSpan.textContent = source; + content.appendChild(srcSpan); + const sep1 = activeDocument.createElement('span'); + sep1.className = 'wl-log-sep'; + sep1.textContent = ' · '; + content.appendChild(sep1); + const titleSpan = activeDocument.createElement('span'); + titleSpan.className = 'wl-log-title'; + titleSpan.textContent = titleFromEntry(entry); + content.appendChild(titleSpan); + const sep2 = activeDocument.createElement('span'); + sep2.className = 'wl-log-sep'; + sep2.textContent = ' — '; + content.appendChild(sep2); + const actionSpan = activeDocument.createElement('span'); + actionSpan.className = 'wl-log-action'; + actionSpan.textContent = actionLabel(entry); + actionSpan.style.color = color; + content.appendChild(actionSpan); + el.appendChild(content); + + const timeEl = activeDocument.createElement('div'); + timeEl.className = 'wl-log-time'; + timeEl.textContent = formatTime(entry.timestamp); + el.appendChild(timeEl); + + return el; + } + + private findFirstVisible(scrollTop: number): number { + let lo = 0; + let hi = this.rows.length - 1; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + const rowEnd = this.rowOffsets[mid]! + (this.rows[mid]!.type === 'header' ? HEADER_HEIGHT : ROW_HEIGHT); + if (rowEnd <= scrollTop) { + lo = mid + 1; + } else { + hi = mid; + } + } + return Math.max(0, lo - 2); + } + + private findLastVisible(bottom: number): number { + let lo = 0; + let hi = this.rows.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >>> 1; + if (this.rowOffsets[mid]! >= bottom) { + hi = mid - 1; + } else { + lo = mid; + } + } + return Math.min(this.rows.length - 1, lo + 2); + } + + private destroyVirtualScroll(): void { + if (this.scrollRAF !== null) { + window.cancelAnimationFrame(this.scrollRAF); + this.scrollRAF = null; + } + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + if (this.scrollContainer && this.scrollHandler) { + this.scrollContainer.removeEventListener('scroll', this.scrollHandler); + } + this.scrollHandler = null; + this.scrollContainer = null; + this.spacer = null; + this.viewport = null; + this.renderedNodes.clear(); + this.lastFirst = -1; + this.lastLast = -1; + } +} diff --git a/src/MaybeAddModal.ts b/src/MaybeAddModal.ts index eb09e7e..c5cdd23 100644 --- a/src/MaybeAddModal.ts +++ b/src/MaybeAddModal.ts @@ -1,8 +1,8 @@ import { App, Modal, Notice } from 'obsidian'; import type WatchLogPlugin from './main'; import type { DataManager } from './DataManager'; -import type { MaybeTitle, AnimeSearchResult, MediaSearchResult, Season } from './types'; -import { parseReleaseDateInput } from './types'; +import type { MaybeTitle, AnimeSearchResult, MediaSearchResult } from './types'; +import { parseReleaseDateInput, getApiGroupForType } from './types'; type SearchResult = AnimeSearchResult | MediaSearchResult; @@ -19,14 +19,13 @@ export class MaybeAddModal extends Modal { private searchQuery = ''; private searchResults: SearchResult[] = []; private isSearching = false; - private autoSearch = false; + private searchGeneration = 0; private fieldTitle = ''; private fieldEpisodes = 0; private fieldDuration = 0; private fieldReleaseDate = ''; private fieldLink = ''; - private fieldSeasons: Season[] = []; private resultsEl: HTMLElement | null = null; private formEl: HTMLElement | null = null; @@ -95,26 +94,38 @@ export class MaybeAddModal extends Modal { private async performSearch(): Promise<void> { if (!this.searchQuery.trim()) return; + const gen = ++this.searchGeneration; this.isSearching = true; this.renderResults(); try { const api = this.plugin.apiService; const activeApi = this.plugin.settings.activeApi ?? 'OMDb'; - const isAnime = this.selectedType === 'Anime'; + const apiGroup = getApiGroupForType(this.selectedType, this.plugin.settings.typeApiMapping); const isMovie = this.selectedType === 'Movie'; - if (isAnime) { - this.searchResults = await api.searchAnime(this.searchQuery); + let results: SearchResult[] = []; + if (apiGroup === '') { + new Notice(`No API configured for type "${this.selectedType}". Configure it in Settings → API.`); + } else if (apiGroup === 'anime') { + const animeSource = this.plugin.settings.animeApiSource ?? 'jikan'; + results = animeSource === 'anilist' + ? await api.searchAniList(this.searchQuery) + : await api.searchAnime(this.searchQuery); } else if (activeApi === 'TMDB') { - this.searchResults = await api.searchTmdb(this.searchQuery, isMovie ? 'movie' : 'series'); + results = await api.searchTmdb(this.searchQuery, isMovie ? 'movie' : 'series'); } else { - this.searchResults = await api.searchOmdb(this.searchQuery, isMovie ? 'movie' : 'series'); + results = await api.searchOmdb(this.searchQuery, isMovie ? 'movie' : 'series'); } + if (gen !== this.searchGeneration) return; // stale + this.searchResults = results; } catch { + if (gen !== this.searchGeneration) return; new Notice('Search failed. Check your connection and API settings.'); this.searchResults = []; } finally { - this.isSearching = false; - this.renderResults(); + if (gen === this.searchGeneration) { + this.isSearching = false; + this.renderResults(); + } } } @@ -138,26 +149,31 @@ export class MaybeAddModal extends Modal { for (const child of Array.from(this.resultsEl.children)) child.removeClass('is-selected'); } itemEl.addClass('is-selected'); + const gen = ++this.searchGeneration; const api = this.plugin.apiService; const activeApi = this.plugin.settings.activeApi ?? 'OMDb'; try { if (!isAnimeResult(result) && result.mediaType === 'tv') { const full = activeApi === 'TMDB' ? await api.getTmdbTvDetails(result.imdbId) : await api.getOmdbTvDetails(result.imdbId); - if (full) { this.fieldTitle = full.title; this.fieldEpisodes = full.episodes; this.fieldDuration = full.episodeDuration; this.fieldReleaseDate = full.releaseDate; this.fieldLink = full.url; this.fieldSeasons = full.seasons; } + if (gen !== this.searchGeneration) return; + if (full) { this.fieldTitle = full.title; this.fieldEpisodes = full.episodes; this.fieldDuration = full.episodeDuration; this.fieldReleaseDate = full.releaseDate; this.fieldLink = full.url; } } else if (!isAnimeResult(result) && result.mediaType === 'movie') { const full = activeApi === 'TMDB' ? await api.getTmdbMovieDetails(result.imdbId) : await api.getOmdbMovieDetails(result.imdbId); - if (full) { this.fieldTitle = full.title; this.fieldEpisodes = full.episodes; this.fieldDuration = full.episodeDuration; this.fieldReleaseDate = full.releaseDate; this.fieldLink = full.url; this.fieldSeasons = full.seasons; } + if (gen !== this.searchGeneration) return; + if (full) { this.fieldTitle = full.title; this.fieldEpisodes = full.episodes; this.fieldDuration = full.episodeDuration; this.fieldReleaseDate = full.releaseDate; this.fieldLink = full.url; } } else { const anime = result as AnimeSearchResult; this.fieldTitle = anime.title; this.fieldEpisodes = anime.episodes; this.fieldDuration = anime.duration; - this.fieldReleaseDate = anime.releaseDate; this.fieldLink = anime.url; this.fieldSeasons = anime.seasons; + this.fieldReleaseDate = anime.releaseDate; this.fieldLink = anime.url; } } catch { + if (gen !== this.searchGeneration) return; this.fieldTitle = result.title; - this.fieldEpisodes = isAnimeResult(result) ? result.episodes : result.episodes; + this.fieldEpisodes = result.episodes; this.fieldDuration = isAnimeResult(result) ? result.duration : result.episodeDuration; - this.fieldReleaseDate = result.releaseDate; this.fieldLink = result.url; this.fieldSeasons = result.seasons; + this.fieldReleaseDate = result.releaseDate; this.fieldLink = result.url; } + if (gen !== this.searchGeneration) return; this.renderForm(); } diff --git a/src/PosterService.ts b/src/PosterService.ts new file mode 100644 index 0000000..8fd86be --- /dev/null +++ b/src/PosterService.ts @@ -0,0 +1,259 @@ +import { requestUrl } from 'obsidian'; +import type { DataManager } from './DataManager'; +import type { WatchLogTitle, WatchLogPluginSettings } from './types'; +import { getApiGroupForType } from './types'; + +const JIKAN_BASE = 'https://api.jikan.moe/v4'; +const OMDB_BASE = 'https://www.omdbapi.com'; +const TMDB_BASE = 'https://api.themoviedb.org/3'; +const TMDB_IMG_BASE = 'https://image.tmdb.org/t/p/w300'; +const ANILIST_ENDPOINT = 'https://graphql.anilist.co'; +const API_TIMEOUT_MS = 8000; + +// Rate-limit delays between consecutive queued requests +const JIKAN_DELAY_MS = 400; // ≈2.5 req/sec (Jikan caps at 3/sec) +const ANILIST_DELAY_MS = 700; // AniList caps at 90/min +const TMDB_DELAY_MS = 30; // ≈33 req/sec (TMDB caps at 40/sec) +const OMDB_DELAY_MS = 100; + +interface QueueItem { + title: WatchLogTitle; + resolve: (url: string | null) => void; +} + +interface JikanImagesShape { + data?: { + images?: { + jpg?: { + image_url?: string; + large_image_url?: string; + }; + }; + }; + // Search shape: data is an array +} + +interface JikanSearchShape { + data?: Array<{ + images?: { + jpg?: { + image_url?: string; + large_image_url?: string; + }; + }; + }>; +} + +interface TmdbMultiResult { + results?: Array<{ poster_path?: string | null }>; +} + +interface OmdbResult { + Poster?: string; + Response?: string; +} + +interface AniListPosterResponse { + data?: { + Media?: { + coverImage?: { large?: string | null; medium?: string | null } | null; + } | null; + }; +} + +export class PosterService { + private dataManager: DataManager; + private getSettings: () => WatchLogPluginSettings; + private queue: QueueItem[] = []; + private isProcessing = false; + private disposed = false; + + constructor( + dataManager: DataManager, + getSettings: () => WatchLogPluginSettings, + ) { + this.dataManager = dataManager; + this.getSettings = getSettings; + } + + destroy(): void { + this.disposed = true; + this.clearQueue(); + } + + /** + * Enqueue a poster fetch for the given title. Resolves with the URL or null. + * The result is also persisted via DataManager.updatePosterUrl (silent + debounced). + */ + enqueue(title: WatchLogTitle): Promise<string | null> { + return new Promise((resolve) => { + if (this.disposed) { resolve(null); return; } + this.queue.push({ title, resolve }); + void this.processQueue(); + }); + } + + /** Empty the queue and resolve all pending promises with null. */ + clearQueue(): void { + const pending = this.queue.splice(0); + for (const item of pending) { + item.resolve(null); + } + } + + private async processQueue(): Promise<void> { + if (this.isProcessing) return; + this.isProcessing = true; + + while (this.queue.length > 0) { + if (this.disposed) break; + const item = this.queue.shift(); + if (!item) break; + // Manual override: don't auto-fetch, don't touch posterUrl. + if (item.title.manualPosterUrl && item.title.manualPosterUrl.trim() !== '') { + item.resolve(item.title.manualPosterUrl); + continue; + } + try { + const url = await this.fetchPosterForTitle(item.title); + if (this.disposed) { item.resolve(null); break; } + const finalUrl = url || 'none'; + this.dataManager.updatePosterUrl(item.title.id, finalUrl); + item.resolve(url); + } catch { + if (!this.disposed) this.dataManager.updatePosterUrl(item.title.id, 'none'); + item.resolve(null); + } + + const delay = this.getDelayForTitle(item.title); + if (delay > 0) { + await new Promise<void>((r) => window.setTimeout(r, delay)); + } + } + + this.isProcessing = false; + } + + private getDelayForTitle(title: WatchLogTitle): number { + const settings = this.getSettings(); + const group = getApiGroupForType(title.type, settings.typeApiMapping); + if (group === 'anime') { + if (title.anilistId && title.anilistId > 0) return ANILIST_DELAY_MS; + return JIKAN_DELAY_MS; + } + if (settings.tmdbApiKey) return TMDB_DELAY_MS; + if (settings.omdbApiKey) return OMDB_DELAY_MS; + return 0; + } + + private async fetchPosterForTitle(title: WatchLogTitle): Promise<string | null> { + // Already cached + if (title.posterUrl && title.posterUrl.startsWith('http')) return title.posterUrl; + if (title.posterUrl === 'none') return null; + + const settings = this.getSettings(); + const group = getApiGroupForType(title.type, settings.typeApiMapping); + if (group === 'anime') return this.fetchAnimePoster(title); + if (group === 'movie') return this.fetchMediaPoster(title); + return null; + } + + private cleanTitleForSearch(name: string): string { + const cleaned = name + .replace(/\s*-?\s*[Ss]eason\s*\d+/gi, '') + .replace(/\s*-?\s*[Ss]eries\s*\d+/gi, '') + .replace(/\s*-?\s*[Pp]art\s*\d+/gi, '') + .replace(/\s*-?\s*[Vv]ol(ume)?\.?\s*\d+/gi, '') + .replace(/\s*\(\d{4}\)/g, '') + .replace(/\s*-?\s*[Ss]\d+/gi, '') + .replace(/\s+/g, ' ') + .trim(); + return cleaned || name; + } + + private async fetchAnimePoster(title: WatchLogTitle): Promise<string | null> { + if (title.anilistId && title.anilistId > 0) { + const gql = `query ($id: Int) { Media(id: $id, type: ANIME) { coverImage { large medium } } }`; + const data = (await this.postJson(ANILIST_ENDPOINT, { + query: gql, + variables: { id: title.anilistId }, + })) as AniListPosterResponse | null; + const cover = data?.data?.Media?.coverImage; + return cover?.large ?? cover?.medium ?? null; + } + if (title.malId) { + const url = `${JIKAN_BASE}/anime/${title.malId}`; + const data = (await this.fetchJson(url)) as JikanImagesShape | null; + const jpg = data?.data?.images?.jpg; + return jpg?.large_image_url ?? jpg?.image_url ?? null; + } + const q = encodeURIComponent(this.cleanTitleForSearch(title.title)); + const url = `${JIKAN_BASE}/anime?q=${q}&limit=1`; + const data = (await this.fetchJson(url)) as JikanSearchShape | null; + const first = data?.data?.[0]; + const jpg = first?.images?.jpg; + return jpg?.large_image_url ?? jpg?.image_url ?? null; + } + + private async fetchMediaPoster(title: WatchLogTitle): Promise<string | null> { + const settings = this.getSettings(); + const searchName = this.cleanTitleForSearch(title.title); + if (settings.tmdbApiKey) { + const q = encodeURIComponent(searchName); + const url = `${TMDB_BASE}/search/multi?query=${q}&page=1`; + const data = (await this.fetchJson(url, { + Authorization: `Bearer ${settings.tmdbApiKey}`, + })) as TmdbMultiResult | null; + const poster = data?.results?.[0]?.poster_path; + if (poster) return `${TMDB_IMG_BASE}${poster}`; + return null; + } + if (settings.omdbApiKey) { + const q = encodeURIComponent(searchName); + const url = `${OMDB_BASE}/?t=${q}&apikey=${encodeURIComponent(settings.omdbApiKey)}`; + const data = (await this.fetchJson(url)) as OmdbResult | null; + const poster = data?.Poster; + if (poster && poster !== 'N/A') return poster; + return null; + } + return null; + } + + private async fetchJson( + url: string, + headers?: Record<string, string>, + ): Promise<unknown> { + let timer: number | null = null; + const timeoutPromise = new Promise<never>((_, reject) => { + timer = window.setTimeout(() => reject(new Error('Request timed out')), API_TIMEOUT_MS); + }); + try { + const fetchPromise = requestUrl({ url, headers }).then((r) => r.json as unknown); + return await Promise.race([fetchPromise, timeoutPromise]); + } finally { + if (timer !== null) window.clearTimeout(timer); + } + } + + private async postJson( + url: string, + body: unknown, + headers?: Record<string, string>, + ): Promise<unknown> { + let timer: number | null = null; + const timeoutPromise = new Promise<never>((_, reject) => { + timer = window.setTimeout(() => reject(new Error('Request timed out')), API_TIMEOUT_MS); + }); + try { + const fetchPromise = requestUrl({ + url, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(headers ?? {}) }, + body: JSON.stringify(body), + }).then((r) => r.json as unknown); + return await Promise.race([fetchPromise, timeoutPromise]); + } finally { + if (timer !== null) window.clearTimeout(timer); + } + } +} diff --git a/src/ReadingCsvChoiceModal.ts b/src/ReadingCsvChoiceModal.ts new file mode 100644 index 0000000..527c512 --- /dev/null +++ b/src/ReadingCsvChoiceModal.ts @@ -0,0 +1,50 @@ +import { App, Modal } from 'obsidian'; + +export type ReadingCsvKind = 'book' | 'manga'; + +/** + * Gate modal for Reading CSV import/export. Mirrors the "Add draft" choice modal + * (title top-left, × top-right, a context line, equal-width horizontal buttons): + * the user first picks Books or Manga, then the kind-specific Reading CSV modal opens. + */ +export class ReadingCsvChoiceModal extends Modal { + constructor( + app: App, + private mode: 'export' | 'import', + private onChoice: (kind: ReadingCsvKind) => void, + ) { + super(app); + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('wl-draft-choice-modal'); + + this.titleEl.setText(this.mode === 'export' ? 'Export to CSV' : 'Import from CSV'); + contentEl.createDiv({ + cls: 'wl-draft-choice-subtitle', + text: this.mode === 'export' + ? 'Which library do you want to export?' + : 'Which library do you want to import into?', + }); + + const grid = contentEl.createDiv({ cls: 'wl-draft-choice-grid' }); + + const makeOption = (label: string, kind: ReadingCsvKind): void => { + const btn = grid.createEl('button', { cls: 'wl-draft-choice-btn' }); + btn.createDiv({ cls: 'wl-draft-choice-label', text: label }); + btn.addEventListener('click', () => { + this.close(); + this.onChoice(kind); + }); + }; + + makeOption('Books', 'book'); + makeOption('Manga', 'manga'); + } + + onClose(): void { + this.contentEl.empty(); + } +} diff --git a/src/ReadingCsvModal.ts b/src/ReadingCsvModal.ts new file mode 100644 index 0000000..b74a516 --- /dev/null +++ b/src/ReadingCsvModal.ts @@ -0,0 +1,838 @@ +import { App, Modal, Notice } from 'obsidian'; +import type WatchLogPlugin from './main'; +import type { Book, Manga, ReadingStatus } from './types'; +import { READING_STATUSES, SELECTABLE_READING_STATUSES } from './types'; +import type { ReadingCsvKind } from './ReadingCsvChoiceModal'; + +// ── CSV helpers (self-contained — deliberately not shared with the watch CsvModal) ── + +function escapeCsvField(value: string | number | null | undefined): string { + const s = String(value ?? ''); + if (s.includes(',') || s.includes('"') || s.includes('\n')) { + return `"${s.replace(/"/g, '""')}"`; + } + return s; +} + +/** Full CSV parser: quoted fields with embedded newlines, escaped quotes, comma delimiter, CRLF. */ +function parseCSV(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let cur = ''; + let inQuotes = false; + const t = text.replace(/\r\n?/g, '\n'); + for (let i = 0; i < t.length; i++) { + const ch = t[i]; + if (inQuotes) { + if (ch === '"') { + if (t[i + 1] === '"') { cur += '"'; i++; } + else { inQuotes = false; } + } else { + cur += ch; + } + } else { + if (ch === '"') { + inQuotes = true; + } else if (ch === ',') { + row.push(cur); cur = ''; + } else if (ch === '\n') { + row.push(cur); cur = ''; + rows.push(row); row = []; + } else { + cur += ch; + } + } + } + if (cur.length > 0 || row.length > 0) { + row.push(cur); + rows.push(row); + } + while (rows.length > 0 && rows[rows.length - 1]!.every((c) => c === '')) rows.pop(); + return rows; +} + +// ── Date parsing (same logic as the watch CSV path) ───────────────────────────── + +const MONTH_NAMES: Record<string, number> = { + jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, + jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12, +}; + +/** Parses a date string in any common format and returns YYYY-MM-DD, or null if unparseable. */ +function parseCsvDate(raw: string): string | null { + const s = raw.trim(); + if (!s) return null; + try { + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; + + const slash = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); + if (slash) { + const a = parseInt(slash[1]!), b = parseInt(slash[2]!), y = parseInt(slash[3]!); + let dd: number, mm: number; + if (a > 12) { dd = a; mm = b; } + else if (b > 12) { mm = a; dd = b; } + else { dd = a; mm = b; } + if (mm < 1 || mm > 12 || dd < 1 || dd > 31) return null; + return `${y}-${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`; + } + + const dmy = s.match(/^(\d{1,2})\s+([A-Za-z]{3,})\s+(\d{4})$/); + if (dmy) { + const mm = MONTH_NAMES[dmy[2]!.toLowerCase().slice(0, 3)]; + const dd = parseInt(dmy[1]!), y = parseInt(dmy[3]!); + if (mm) return `${y}-${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`; + } + + const mdy = s.match(/^([A-Za-z]{3,})\s+(\d{1,2}),?\s+(\d{4})$/); + if (mdy) { + const mm = MONTH_NAMES[mdy[1]!.toLowerCase().slice(0, 3)]; + const dd = parseInt(mdy[2]!), y = parseInt(mdy[3]!); + if (mm) return `${y}-${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`; + } + + const dotDash = s.match(/^(\d{1,2})[.-](\d{1,2})[.-](\d{4})$/); + if (dotDash) { + const dd = parseInt(dotDash[1]!), mm = parseInt(dotDash[2]!), y = parseInt(dotDash[3]!); + if (mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) + return `${y}-${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`; + } + + const parsed = new Date(s); + if (!isNaN(parsed.getTime())) { + const dd = parsed.getDate(), mm = parsed.getMonth() + 1, y = parsed.getFullYear(); + return `${y}-${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`; + } + + return null; + } catch { + return null; + } +} + +// ── Schemas (Books and Manga kept fully separate) ─────────────────────────────── + +type Coercion = 'string' | 'int' | 'rating' | 'date'; + +interface ReadingFieldDef { + key: string; + label: string; + coercion: Coercion; + autoDetect: string[]; +} + +// Export column order (includes dateAdded, which is export-only). +const BOOK_EXPORT_COLUMNS = [ + 'title', 'author', 'status', 'rating', + 'pagesRead', 'totalPages', 'chaptersRead', 'totalChapters', + 'dateStarted', 'dateFinished', 'releaseDate', 'dateAdded', + 'externalLink', 'googleBooksId', +] as const; + +const MANGA_EXPORT_COLUMNS = [ + 'title', 'author', 'status', 'rating', + 'chaptersRead', 'totalChapters', 'volumesRead', 'totalVolumes', + 'dateStarted', 'dateFinished', 'releaseDate', 'dateAdded', + 'externalLink', 'malId', +] as const; + +// Import targets — same as export minus dateAdded (auto-set on insert). +const COMMON_HEAD: ReadingFieldDef[] = [ + { key: 'title', label: 'Title', coercion: 'string', autoDetect: ['title', 'name'] }, + { key: 'author', label: 'Author', coercion: 'string', autoDetect: ['author', 'writer', 'by'] }, + { key: 'status', label: 'Status', coercion: 'string', autoDetect: ['status'] }, + { key: 'rating', label: 'Rating', coercion: 'rating', autoDetect: ['rating', 'score'] }, +]; + +const COMMON_TAIL: ReadingFieldDef[] = [ + { key: 'dateStarted', label: 'Date Started', coercion: 'date', autoDetect: ['started', 'datestarted', 'date started', 'date_started', 'start date'] }, + { key: 'dateFinished', label: 'Date Finished', coercion: 'date', autoDetect: ['finished', 'datefinished', 'date finished', 'date_finished', 'end date', 'finish date', 'completed date'] }, + { key: 'releaseDate', label: 'Release Date', coercion: 'date', autoDetect: ['releasedate', 'release date', 'release_date', 'published', 'publish date'] }, + { key: 'externalLink', label: 'Link', coercion: 'string', autoDetect: ['link', 'externallink', 'external link', 'external_link', 'url'] }, +]; + +const BOOK_IMPORT_FIELDS: ReadingFieldDef[] = [ + ...COMMON_HEAD, + { key: 'pagesRead', label: 'Pages Read', coercion: 'int', autoDetect: ['pagesread', 'pages read', 'read pages', 'page'] }, + { key: 'totalPages', label: 'Total Pages', coercion: 'int', autoDetect: ['totalpages', 'total pages', 'pages', 'page count'] }, + { key: 'chaptersRead', label: 'Chapters Read', coercion: 'int', autoDetect: ['chaptersread', 'chapters read', 'read chapters'] }, + { key: 'totalChapters', label: 'Total Chapters', coercion: 'int', autoDetect: ['totalchapters', 'total chapters', 'chapters', 'chapter count'] }, + ...COMMON_TAIL, + { key: 'googleBooksId', label: 'Google Books ID', coercion: 'string', autoDetect: ['googlebooksid', 'google books id', 'gbid', 'volumeid'] }, +]; + +const MANGA_IMPORT_FIELDS: ReadingFieldDef[] = [ + ...COMMON_HEAD, + { key: 'chaptersRead', label: 'Chapters Read', coercion: 'int', autoDetect: ['chaptersread', 'chapters read', 'read chapters'] }, + { key: 'totalChapters', label: 'Total Chapters', coercion: 'int', autoDetect: ['totalchapters', 'total chapters', 'chapters', 'chapter count'] }, + { key: 'volumesRead', label: 'Volumes Read', coercion: 'int', autoDetect: ['volumesread', 'volumes read', 'read volumes'] }, + { key: 'totalVolumes', label: 'Total Volumes', coercion: 'int', autoDetect: ['totalvolumes', 'total volumes', 'volumes', 'volume count'] }, + ...COMMON_TAIL, + { key: 'malId', label: 'MAL ID', coercion: 'string', autoDetect: ['malid', 'mal id', 'myanimelist id', 'mal'] }, +]; + +interface ReadingSchema { + exportColumns: readonly string[]; + importFields: ReadingFieldDef[]; +} + +function schemaFor(kind: ReadingCsvKind): ReadingSchema { + return kind === 'book' + ? { exportColumns: BOOK_EXPORT_COLUMNS, importFields: BOOK_IMPORT_FIELDS } + : { exportColumns: MANGA_EXPORT_COLUMNS, importFields: MANGA_IMPORT_FIELDS }; +} + +function autoMap(fields: ReadingFieldDef[], headers: string[]): Record<string, string> { + const mapping: Record<string, string> = {}; + for (const field of fields) { + const match = headers.find((h) => field.autoDetect.includes(h.toLowerCase())); + mapping[field.key] = match ?? ''; + } + return mapping; +} + +// ── Modal ─────────────────────────────────────────────────────────────────────── + +type ReadingEntry = Partial<Book & Manga>; + +export class ReadingCsvModal extends Modal { + private plugin: WatchLogPlugin; + private kind: ReadingCsvKind; + private mode: 'export' | 'import'; + private schema: ReadingSchema; + + private get kindLabel(): string { + return this.kind === 'book' ? 'Books' : 'Manga'; + } + + private get itemNoun(): string { + return this.kind === 'book' ? 'book' : 'manga'; + } + + // Export state + private selectedIds = new Set<string>(); + + // Import state + private csvRows: string[][] = []; + private csvHeaders: string[] = []; + private columnMapping: Record<string, string> = {}; + private importRows: { entry: ReadingEntry; isDuplicate: boolean; selected: boolean }[] = []; + + // Value-mapping state + private statusValueMap: Record<string, string> = {}; // csvValue → resolved status ('' = blank) + private ratingValueMap: Record<string, string> = {}; // csvValue → '1'–'5' or '' (blank) + + // Step containers + private stepUpload: HTMLElement | null = null; + private stepMapping: HTMLElement | null = null; + private stepValueMap: HTMLElement | null = null; + private stepPreview: HTMLElement | null = null; + private importBtn: HTMLButtonElement | null = null; + private cancelBtn: HTMLButtonElement | null = null; + + // Progress state + private isImporting = false; + private importCancelled = false; + private progressWrap: HTMLElement | null = null; + private progressBarFill: HTMLElement | null = null; + private progressText: HTMLElement | null = null; + + constructor(app: App, plugin: WatchLogPlugin, kind: ReadingCsvKind, mode: 'export' | 'import') { + super(app); + this.plugin = plugin; + this.kind = kind; + this.mode = mode; + this.schema = schemaFor(kind); + } + + private getItems(): (Book | Manga)[] { + return this.kind === 'book' + ? this.plugin.readingDataManager.getBooks() + : this.plugin.readingDataManager.getMangaList(); + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('wl-csv-modal'); + if (this.mode === 'export') this.renderExport(contentEl); + else this.renderImport(contentEl); + } + + onClose(): void { + this.contentEl.empty(); + } + + // ── Export ────────────────────────────────────────────────────────────────── + + private itemsToCsv(items: (Book | Manga)[]): string { + const cols = this.schema.exportColumns; + const header = cols.join(','); + const rows = items.map((it) => + cols.map((col) => escapeCsvField((it as unknown as Record<string, string | number | null | undefined>)[col])).join(','), + ); + return [header, ...rows].join('\n'); + } + + private renderExport(el: HTMLElement): void { + el.createEl('h2', { cls: 'wl-modal-title', text: `Export ${this.kindLabel} to CSV` }); + + const items = this.getItems(); + items.forEach((it) => this.selectedIds.add(it.id)); + + const ctrlRow = el.createDiv({ cls: 'wl-csv-ctrl-row' }); + const selectAllBtn = ctrlRow.createEl('button', { cls: 'wl-btn', text: 'Select all' }); + const selectNoneBtn = ctrlRow.createEl('button', { cls: 'wl-btn', text: 'Select none' }); + const countEl = ctrlRow.createSpan({ cls: 'wl-csv-count', text: `${items.length} selected` }); + + const listEl = el.createDiv({ cls: 'wl-csv-list' }); + const checkboxes: HTMLInputElement[] = []; + + const updateCount = (): void => { + countEl.textContent = `${this.selectedIds.size} selected`; + }; + + for (const item of items) { + const row = listEl.createDiv({ cls: 'wl-csv-row' }); + const cb = row.createEl('input', { attr: { type: 'checkbox' } }); + cb.checked = true; + checkboxes.push(cb); + row.createSpan({ cls: 'wl-csv-row-title', text: item.title }); + const meta = [item.author, item.status].filter(Boolean).join(' · '); + if (meta) row.createSpan({ cls: 'wl-csv-row-meta', text: meta }); + + cb.addEventListener('change', () => { + if (cb.checked) this.selectedIds.add(item.id); + else this.selectedIds.delete(item.id); + updateCount(); + }); + } + + selectAllBtn.addEventListener('click', () => { + items.forEach((it) => this.selectedIds.add(it.id)); + checkboxes.forEach((cb) => { cb.checked = true; }); + updateCount(); + }); + + selectNoneBtn.addEventListener('click', () => { + this.selectedIds.clear(); + checkboxes.forEach((cb) => { cb.checked = false; }); + updateCount(); + }); + + const btnRow = el.createDiv({ cls: 'wl-modal-btn-row wl-csv-btn-row' }); + const cancelBtn = btnRow.createEl('button', { cls: 'wl-btn', text: 'Cancel' }); + const exportBtn = btnRow.createEl('button', { cls: 'wl-btn wl-btn-primary', text: 'Export CSV' }); + + cancelBtn.addEventListener('click', () => this.close()); + exportBtn.addEventListener('click', () => { + const toExport = items.filter((it) => this.selectedIds.has(it.id)); + if (toExport.length === 0) { + new Notice(`No ${this.itemNoun} selected.`); + return; + } + const csv = this.itemsToCsv(toExport); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = activeDocument.createElement('a'); + const today = new Date(); + const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + a.href = url; + a.download = `watchlog-${this.itemNoun === 'book' ? 'books' : 'manga'}-export-${dateStr}.csv`; + a.click(); + URL.revokeObjectURL(url); + new Notice(`Exported ${toExport.length} ${this.kindLabel.toLowerCase()}.`); + this.close(); + }); + } + + // ── Import ────────────────────────────────────────────────────────────────── + + private renderImport(el: HTMLElement): void { + el.createEl('h2', { cls: 'wl-modal-title', text: `Import ${this.kindLabel} from CSV` }); + + this.stepUpload = el.createDiv({ cls: 'wl-csv-step' }); + const dropZone = this.stepUpload.createDiv({ cls: 'wl-csv-drop-zone' }); + dropZone.createDiv({ cls: 'wl-csv-drop-label', text: 'Select a CSV file to import' }); + const fileInput = dropZone.createEl('input', { + attr: { type: 'file', accept: '.csv' }, + cls: 'wl-csv-file-input', + }); + + this.stepMapping = el.createDiv({ cls: 'wl-csv-step' }); + this.stepMapping.hide(); + this.stepValueMap = el.createDiv({ cls: 'wl-csv-step' }); + this.stepValueMap.hide(); + this.stepPreview = el.createDiv({ cls: 'wl-csv-step' }); + this.stepPreview.hide(); + + const btnRow = el.createDiv({ cls: 'wl-modal-btn-row wl-csv-btn-row' }); + + this.progressWrap = btnRow.createDiv({ cls: 'wl-csv-progress-wrap' }); + this.progressWrap.hide(); + this.progressWrap.createSpan({ cls: 'wl-csv-bg-note', text: 'You can close this window — import will continue in the background.' }); + const progressTrack = this.progressWrap.createDiv({ cls: 'wl-csv-progress-track' }); + this.progressBarFill = progressTrack.createDiv({ cls: 'wl-csv-progress-fill' }); + this.progressText = this.progressWrap.createSpan({ cls: 'wl-csv-progress-text', text: '0 / 0' }); + + this.cancelBtn = btnRow.createEl('button', { cls: 'wl-btn', text: 'Cancel' }); + this.importBtn = btnRow.createEl('button', { cls: 'wl-btn wl-btn-primary', text: 'Import selected' }); + this.importBtn.disabled = true; + this.importBtn.hide(); + + this.cancelBtn.addEventListener('click', () => { + if (this.isImporting) { + this.importCancelled = true; + if (this.cancelBtn) { + this.cancelBtn.disabled = true; + this.cancelBtn.textContent = 'Cancelling…'; + } + } else { + this.close(); + } + }); + this.importBtn.addEventListener('click', () => void this.doImport()); + + fileInput.addEventListener('change', () => { + const file = fileInput.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + const text = ev.target?.result as string; + this.csvRows = parseCSV(text); + this.csvHeaders = (this.csvRows[0] ?? []).map((h) => h.trim()); + this.columnMapping = autoMap(this.schema.importFields, this.csvHeaders); + this.showMappingStep(); + }; + reader.readAsText(file); + }); + } + + // ── Step: Column Mapping ────────────────────────────────────────────────────── + + private showMappingStep(): void { + if (!this.stepUpload || !this.stepMapping) return; + this.stepUpload.hide(); + this.stepMapping.show(); + this.renderMappingStep(this.stepMapping); + } + + private renderMappingStep(el: HTMLElement): void { + el.empty(); + el.createDiv({ cls: 'wl-csv-step-title', text: 'Map your columns' }); + el.createDiv({ + cls: 'wl-csv-step-desc', + text: `Match each ${this.itemNoun === 'book' ? 'Book' : 'Manga'} field to a column from your CSV. Select "— skip —" to leave a field empty.`, + }); + el.createDiv({ + cls: 'wl-csv-info-note', + text: 'Rows with empty fields will be skipped automatically. If you see missing entries, check that your CSV has no empty rows at the top.', + }); + + const SKIP_VALUE = ''; + const grid = el.createDiv({ cls: 'wl-csv-map-grid' }); + const selectEls: Record<string, HTMLSelectElement> = {}; + + for (const field of this.schema.importFields) { + const row = grid.createDiv({ cls: 'wl-csv-map-row' }); + row.createSpan({ cls: 'wl-csv-map-label', text: field.label }); + const sel = row.createEl('select', { cls: 'wl-select wl-csv-map-select' }); + selectEls[field.key] = sel; + sel.createEl('option', { value: SKIP_VALUE, text: '— skip —' }); + for (const col of this.csvHeaders) { + sel.createEl('option', { value: col, text: col }); + } + sel.value = this.columnMapping[field.key] ?? SKIP_VALUE; + } + + const previewBtn = el.createEl('button', { cls: 'wl-btn wl-btn-primary wl-csv-preview-btn', text: 'Next →' }); + previewBtn.addEventListener('click', () => { + for (const field of this.schema.importFields) { + this.columnMapping[field.key] = selectEls[field.key]?.value ?? ''; + } + this.importRows = this.applyMapping() + .map((r) => ({ ...r, selected: !r.isDuplicate })); + + // Unknown statuses and non-numeric ratings → value-mapping step. + const knownStatuses = new Set<string>(READING_STATUSES as string[]); + const unknownStatuses: string[] = []; + const unknownRatings: string[] = []; + const seenStatuses = new Set<string>(); + const seenRatings = new Set<string>(); + + const headers = (this.csvRows[0] ?? []).map((h) => h.trim()); + const ratingColName = this.columnMapping['rating']; + const ratingIdx = ratingColName ? headers.indexOf(ratingColName) : -1; + + for (const r of this.importRows) { + const sv = (r.entry.status as string | undefined)?.trim(); + if (sv && !knownStatuses.has(sv) && !seenStatuses.has(sv)) { + unknownStatuses.push(sv); + seenStatuses.add(sv); + } + if (ratingIdx >= 0) { + const lineIdx = this.importRows.indexOf(r); + const rawValues = this.csvRows[lineIdx + 1] ?? []; + const rawRating = (rawValues[ratingIdx] ?? '').trim(); + if (rawRating && isNaN(parseFloat(rawRating)) && !seenRatings.has(rawRating)) { + unknownRatings.push(rawRating); + seenRatings.add(rawRating); + } + } + } + + if (unknownStatuses.length > 0 || unknownRatings.length > 0) { + this.showValueMapStep(unknownStatuses, unknownRatings); + } else { + this.showPreviewStep(); + } + }); + } + + private applyMapping(): { entry: ReadingEntry; isDuplicate: boolean }[] { + if (this.csvRows.length < 2) return []; + const headers = (this.csvRows[0] ?? []).map((h) => h.trim()); + const existing = this.getItems(); + + return this.csvRows.slice(1) + .filter((r) => r.some((c) => c.trim())) + .map((values): { entry: ReadingEntry; isDuplicate: boolean } | null => { + const get = (fieldKey: string): string => { + const colName = this.columnMapping[fieldKey]; + if (!colName) return ''; + const idx = headers.indexOf(colName); + return idx >= 0 ? (values[idx] ?? '').trim() : ''; + }; + + const entry: ReadingEntry = {}; + for (const field of this.schema.importFields) { + const raw = get(field.key); + if (!raw) continue; + switch (field.coercion) { + case 'string': + (entry as Record<string, unknown>)[field.key] = raw; + break; + case 'int': + (entry as Record<string, unknown>)[field.key] = parseInt(raw) || 0; + break; + case 'rating': { + const v = parseFloat(raw) || 0; + entry.rating = Math.max(0, Math.min(5, v)); + break; + } + case 'date': { + const d = parseCsvDate(raw); + if (d) (entry as Record<string, unknown>)[field.key] = d; + break; + } + } + } + + if (!entry.title || !entry.title.trim()) return null; + + const isDuplicate = !!existing.find( + (it) => it.title.toLowerCase() === (entry.title ?? '').toLowerCase(), + ); + return { entry, isDuplicate }; + }) + .filter((r): r is { entry: ReadingEntry; isDuplicate: boolean } => r !== null); + } + + // ── Step: Value Mapping ─────────────────────────────────────────────────────── + + private showValueMapStep(unknownStatuses: string[], unknownRatings: string[]): void { + if (!this.stepMapping || !this.stepValueMap) return; + this.stepMapping.hide(); + this.stepValueMap.show(); + this.renderValueMapStep(this.stepValueMap, unknownStatuses, unknownRatings); + } + + private renderValueMapStep(el: HTMLElement, unknownStatuses: string[], unknownRatings: string[]): void { + el.empty(); + el.createDiv({ cls: 'wl-csv-step-title', text: 'Map unknown values' }); + el.createDiv({ + cls: 'wl-csv-step-desc', + text: 'Some values in your CSV were not recognized. Map each to an existing value, or leave it blank.', + }); + + // Reading statuses are a fixed set — no "create new status" option (unlike watch types). + const statusSelects: Record<string, HTMLSelectElement> = {}; + const ratingSelects: Record<string, HTMLSelectElement> = {}; + + if (unknownStatuses.length > 0) { + el.createDiv({ cls: 'wl-csv-valmap-section-title', text: 'Status' }); + for (const sv of unknownStatuses) { + const row = el.createDiv({ cls: 'wl-csv-valmap-row' }); + row.createSpan({ cls: 'wl-csv-valmap-orig', text: `"${sv}"` }); + row.createSpan({ cls: 'wl-csv-valmap-arrow', text: '→' }); + const sel = row.createEl('select', { cls: 'wl-select wl-csv-valmap-select' }); + statusSelects[sv] = sel; + sel.createEl('option', { value: '', text: '— leave blank —' }); + for (const name of SELECTABLE_READING_STATUSES) { + sel.createEl('option', { value: name, text: name }); + } + const lc = sv.toLowerCase(); + const match = SELECTABLE_READING_STATUSES.find((s) => s.toLowerCase() === lc); + if (match) sel.value = match; + } + } + + if (unknownRatings.length > 0) { + el.createDiv({ cls: 'wl-csv-valmap-section-title', text: 'Rating' }); + for (const rv of unknownRatings) { + const row = el.createDiv({ cls: 'wl-csv-valmap-row' }); + row.createSpan({ cls: 'wl-csv-valmap-orig', text: `"${rv}"` }); + row.createSpan({ cls: 'wl-csv-valmap-arrow', text: '→' }); + const sel = row.createEl('select', { cls: 'wl-select wl-csv-valmap-select' }); + ratingSelects[rv] = sel; + sel.createEl('option', { value: '', text: '— leave blank —' }); + for (let i = 1; i <= 5; i++) { + sel.createEl('option', { value: String(i), text: `${i}/5` }); + } + } + } + + const btnRow = el.createDiv({ cls: 'wl-csv-valmap-btn-row' }); + const backBtn = btnRow.createEl('button', { cls: 'wl-btn', text: '← back' }); + const confirmBtn = btnRow.createEl('button', { cls: 'wl-btn wl-btn-primary', text: 'Preview →' }); + + backBtn.addEventListener('click', () => { + if (!this.stepValueMap || !this.stepMapping) return; + this.stepValueMap.hide(); + this.stepMapping.show(); + }); + + confirmBtn.addEventListener('click', () => { + this.statusValueMap = {}; + for (const sv of unknownStatuses) this.statusValueMap[sv] = statusSelects[sv]?.value ?? ''; + this.ratingValueMap = {}; + for (const rv of unknownRatings) this.ratingValueMap[rv] = ratingSelects[rv]?.value ?? ''; + this.applyValueMapsToRows(); + this.showPreviewStep(); + }); + } + + private applyValueMapsToRows(): void { + const headers = (this.csvRows[0] ?? []).map((h) => h.trim()); + const ratingColName = this.columnMapping['rating']; + const ratingIdx = ratingColName ? headers.indexOf(ratingColName) : -1; + + for (const r of this.importRows) { + const sv = (r.entry.status as string | undefined)?.trim() ?? ''; + if (sv && Object.prototype.hasOwnProperty.call(this.statusValueMap, sv)) { + const resolved = this.statusValueMap[sv] ?? ''; + r.entry.status = (resolved || undefined) as ReadingStatus | undefined; + } + + if (ratingIdx >= 0) { + const rowIdx = this.importRows.indexOf(r); + const rawValues = this.csvRows[rowIdx + 1] ?? []; + const rawRating = (rawValues[ratingIdx] ?? '').trim(); + if (rawRating && Object.prototype.hasOwnProperty.call(this.ratingValueMap, rawRating)) { + const resolved = this.ratingValueMap[rawRating] ?? ''; + r.entry.rating = resolved ? parseInt(resolved) : 0; + } + } + } + } + + // ── Step: Preview ───────────────────────────────────────────────────────────── + + private showPreviewStep(): void { + if (!this.stepValueMap || !this.stepMapping || !this.stepPreview || !this.importBtn) return; + this.stepValueMap.hide(); + this.stepMapping.hide(); + this.stepPreview.show(); + this.importBtn.show(); + this.importBtn.disabled = this.importRows.filter((r) => r.selected).length === 0; + this.renderPreviewStep(this.stepPreview); + } + + private renderPreviewStep(el: HTMLElement): void { + el.empty(); + + const dupeCount = this.importRows.filter((r) => r.isDuplicate).length; + if (dupeCount > 0) { + el.createDiv({ + cls: 'wl-csv-dupe-warning', + text: `${dupeCount} duplicate${dupeCount !== 1 ? 's' : ''} found (highlighted). These are deselected by default.`, + }); + } + + const ctrlRow = el.createDiv({ cls: 'wl-csv-ctrl-row' }); + const backBtn = ctrlRow.createEl('button', { cls: 'wl-btn', text: '← back' }); + const selectAllBtn = ctrlRow.createEl('button', { cls: 'wl-btn', text: 'Select all' }); + const selectNoneBtn = ctrlRow.createEl('button', { cls: 'wl-btn', text: 'Select none' }); + const countEl = ctrlRow.createSpan({ cls: 'wl-csv-count', text: '' }); + + backBtn.addEventListener('click', () => { + if (!this.stepPreview || !this.stepValueMap || !this.stepMapping || !this.importBtn) return; + this.stepPreview.hide(); + this.importBtn.hide(); + this.importBtn.disabled = true; + const hadValueMap = Object.keys(this.statusValueMap).length > 0 || Object.keys(this.ratingValueMap).length > 0; + if (hadValueMap) this.stepValueMap.show(); + else this.stepMapping.show(); + }); + + const listEl = el.createDiv({ cls: 'wl-csv-list' }); + const checkboxes: HTMLInputElement[] = []; + + const updateCount = (): void => { + const sel = this.importRows.filter((r) => r.selected).length; + countEl.textContent = `${sel} of ${this.importRows.length} selected`; + if (this.importBtn) this.importBtn.disabled = sel === 0; + }; + + for (let i = 0; i < this.importRows.length; i++) { + const r = this.importRows[i]!; + const row = listEl.createDiv({ cls: `wl-csv-row${r.isDuplicate ? ' wl-csv-row-dupe' : ''}` }); + const cb = row.createEl('input', { attr: { type: 'checkbox' } }); + cb.checked = r.selected; + checkboxes.push(cb); + row.createSpan({ cls: 'wl-csv-row-title', text: r.entry.title ?? '(no title)' }); + const metaParts = [r.entry.author, r.entry.status].filter(Boolean).join(' · '); + if (metaParts) row.createSpan({ cls: 'wl-csv-row-meta', text: metaParts }); + if (r.isDuplicate) row.createSpan({ cls: 'wl-csv-dupe-badge', text: 'duplicate' }); + const idx = i; + cb.addEventListener('change', () => { + this.importRows[idx]!.selected = cb.checked; + updateCount(); + }); + } + + selectAllBtn.addEventListener('click', () => { + this.importRows.forEach((r) => { r.selected = true; }); + checkboxes.forEach((cb) => { cb.checked = true; }); + updateCount(); + }); + + selectNoneBtn.addEventListener('click', () => { + this.importRows.forEach((r) => { r.selected = false; }); + checkboxes.forEach((cb) => { cb.checked = false; }); + updateCount(); + }); + + updateCount(); + } + + // ── Import ────────────────────────────────────────────────────────────────── + + private buildBook(entry: ReadingEntry, defaultStatus: ReadingStatus): Book { + const titleName = entry.title!.trim(); + const now = new Date().toISOString(); + return { + id: this.plugin.readingDataManager.generateBookId(titleName), + title: titleName, + author: entry.author ?? '', + status: (entry.status) || defaultStatus, + rating: entry.rating ?? 0, + pagesRead: entry.pagesRead ?? 0, + totalPages: entry.totalPages ?? 0, + chaptersRead: entry.chaptersRead ?? 0, + totalChapters: entry.totalChapters ?? 0, + coverUrl: '', + googleBooksId: entry.googleBooksId ?? '', + externalLink: entry.externalLink ?? '', + vaultPage: '', + dateStarted: entry.dateStarted ?? null, + dateFinished: entry.dateFinished ?? null, + releaseDate: entry.releaseDate ?? null, + dateAdded: now, + dateModified: now, + customFields: {}, + }; + } + + private buildManga(entry: ReadingEntry, defaultStatus: ReadingStatus): Manga { + const titleName = entry.title!.trim(); + const now = new Date().toISOString(); + return { + id: this.plugin.readingDataManager.generateMangaId(titleName), + title: titleName, + author: entry.author ?? '', + status: (entry.status) || defaultStatus, + rating: entry.rating ?? 0, + chaptersRead: entry.chaptersRead ?? 0, + totalChapters: entry.totalChapters ?? 0, + volumesRead: entry.volumesRead ?? 0, + totalVolumes: entry.totalVolumes ?? 0, + coverUrl: '', + malId: entry.malId ?? '', + externalLink: entry.externalLink ?? '', + vaultPage: '', + dateStarted: entry.dateStarted ?? null, + dateFinished: entry.dateFinished ?? null, + releaseDate: entry.releaseDate ?? null, + dateAdded: now, + dateModified: now, + customFields: {}, + }; + } + + private async doImport(): Promise<void> { + const toImport = this.importRows.filter((r) => r.selected && r.entry.title?.trim()); + if (toImport.length === 0) { + new Notice(`No ${this.itemNoun} selected to import.`); + return; + } + + this.isImporting = true; + this.importCancelled = false; + if (this.importBtn) this.importBtn.disabled = true; + if (this.cancelBtn) this.cancelBtn.textContent = 'Cancel import'; + if (this.progressWrap) this.progressWrap.show(); + if (this.progressText) this.progressText.textContent = `0 / ${toImport.length}`; + if (this.progressBarFill) this.progressBarFill.style.width = `0%`; + + const reading = this.plugin.readingDataManager; + const defaultStatus: ReadingStatus = reading.getSettings().defaultStatus ?? 'Plan to Read'; + + const CHUNK_SIZE = 10; + let added = 0; + + try { + for (let chunkStart = 0; chunkStart < toImport.length; chunkStart += CHUNK_SIZE) { + if (this.importCancelled) break; + const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, toImport.length); + + if (this.kind === 'book') { + const chunk: Book[] = []; + for (let i = chunkStart; i < chunkEnd; i++) { + if (this.importCancelled) break; + chunk.push(this.buildBook(toImport[i]!.entry, defaultStatus)); + } + if (chunk.length === 0) break; + await reading.addBookBatch(chunk); + added += chunk.length; + } else { + const chunk: Manga[] = []; + for (let i = chunkStart; i < chunkEnd; i++) { + if (this.importCancelled) break; + chunk.push(this.buildManga(toImport[i]!.entry, defaultStatus)); + } + if (chunk.length === 0) break; + await reading.addMangaBatch(chunk); + added += chunk.length; + } + + const pct = Math.round((added / toImport.length) * 100); + if (this.progressBarFill) this.progressBarFill.style.width = `${pct}%`; + if (this.progressText) this.progressText.textContent = `${added} / ${toImport.length}`; + } + } finally { + this.isImporting = false; + reading.notifyChange(); + } + + if (this.importCancelled) { + if (this.progressText) this.progressText.textContent = `Cancelled — ${added} / ${toImport.length} imported`; + if (this.cancelBtn) { this.cancelBtn.disabled = false; this.cancelBtn.textContent = 'Close'; } + new Notice(`Import cancelled. ${added} ${this.itemNoun}${added !== 1 ? 's' : ''} imported.`); + } else { + if (this.progressText) this.progressText.textContent = `Done — ${added} imported`; + if (this.cancelBtn) { this.cancelBtn.disabled = false; this.cancelBtn.textContent = 'Close'; } + new Notice(`Imported ${added} ${this.itemNoun}${added !== 1 ? 's' : ''}.`); + this.close(); + } + } +} diff --git a/src/ReadingDataManager.ts b/src/ReadingDataManager.ts new file mode 100644 index 0000000..4286672 --- /dev/null +++ b/src/ReadingDataManager.ts @@ -0,0 +1,850 @@ +import { normalizePath, TFile } from 'obsidian'; +import type WatchLogPlugin from './main'; +import type { HistoryManager } from './HistoryManager'; +import { + Book, + Manga, + ReadingCustomColumn, + ReadingData, + ReadingSettings, + DEFAULT_READING_SETTINGS, + isReleaseDateFuture, +} from './types'; + +export type ReadingKind = 'book' | 'manga'; + +function sanitizeFilename(input: string): string { + return input.replace(/[*"\\/<>:|?]/g, '-').trim(); +} + +function yamlEscape(value: string): string { + if (value === '') return '""'; + // Double-quote and escape backslashes and double quotes. + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +function slugify(input: string): string { + return input + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); +} + +export class ReadingDataManager { + private plugin: WatchLogPlugin; + private data: ReadingData; + private changeListeners: Array<() => void> = []; + private historyManager: HistoryManager | null = null; + + constructor(plugin: WatchLogPlugin) { + this.plugin = plugin; + this.data = this.emptyData(); + } + + setHistoryManager(hm: HistoryManager): void { + this.historyManager = hm; + } + + private emptyData(): ReadingData { + return { + books: [], + manga: [], + bookColumns: [], + mangaColumns: [], + settings: { ...DEFAULT_READING_SETTINGS }, + }; + } + + private get filePath(): string { + return normalizePath(`${this.plugin.app.vault.configDir}/plugins/watchlog/reading.json`); + } + + async load(): Promise<void> { + try { + const exists = await this.plugin.app.vault.adapter.exists(this.filePath); + if (exists) { + const raw = await this.plugin.app.vault.adapter.read(this.filePath); + const parsed = JSON.parse(raw) as Partial<ReadingData>; + this.data = { + books: Array.isArray(parsed.books) ? parsed.books : [], + manga: Array.isArray(parsed.manga) ? parsed.manga : [], + bookColumns: Array.isArray(parsed.bookColumns) ? parsed.bookColumns : [], + mangaColumns: Array.isArray(parsed.mangaColumns) ? parsed.mangaColumns : [], + settings: { ...DEFAULT_READING_SETTINGS, ...(parsed.settings ?? {}) }, + }; + } else { + this.data = this.emptyData(); + } + } catch (e) { + console.warn('[WL] ReadingDataManager.load failed:', e); + this.data = this.emptyData(); + } + const changed = this.migrate(); + if (changed) { + await this.saveOnly(); + } + // Additive reconcile: ensure every future-dated reading item has an Upcoming + // entry (mirrors the watchlist auto-add). Purely additive — never removes, so + // manually-scheduled recurring entries are preserved across reloads. + const dm = this.plugin.dataManager; + if (dm) { + for (const b of this.data.books) if (isReleaseDateFuture(b.releaseDate)) await dm.autoAddReadingToUpcoming(b, 'book'); + for (const m of this.data.manga) if (isReleaseDateFuture(m.releaseDate)) await dm.autoAddReadingToUpcoming(m, 'manga'); + } + } + + /** Ensures every record has the full set of fields with sensible defaults. */ + private migrate(): boolean { + let changed = false; + + // "On Hold" was removed; never leave it as the default for new entries. + if ((this.data.settings.defaultStatus as string) === 'On Hold') { + this.data.settings.defaultStatus = 'Plan to Read'; + changed = true; + } + + for (const b of this.data.books) { + if (typeof b.author !== 'string') { b.author = ''; changed = true; } + if (typeof b.rating !== 'number') { b.rating = 0; changed = true; } + if (typeof b.pagesRead !== 'number') { b.pagesRead = 0; changed = true; } + if (typeof b.totalPages !== 'number') { b.totalPages = 0; changed = true; } + if (typeof b.chaptersRead !== 'number') { b.chaptersRead = 0; changed = true; } + if (typeof b.totalChapters !== 'number') { b.totalChapters = 0; changed = true; } + if (typeof b.coverUrl !== 'string') { b.coverUrl = ''; changed = true; } + // Books now use Google Books volume ids; pre-existing entries keep their + // old openLibraryId value untouched and simply get an empty googleBooksId. + if (typeof b.googleBooksId !== 'string') { b.googleBooksId = ''; changed = true; } + if (typeof b.vaultPage !== 'string') { b.vaultPage = ''; changed = true; } + if (b.dateStarted === undefined) { b.dateStarted = null; changed = true; } + if (b.dateFinished === undefined) { b.dateFinished = null; changed = true; } + if (b.releaseDate === undefined) { b.releaseDate = null; changed = true; } + // "On Hold" was removed; migrate any legacy value to "Plan to Read". + if ((b.status as string) === 'On Hold') { b.status = 'Plan to Read'; changed = true; } + if (!b.dateAdded) { b.dateAdded = new Date().toISOString(); changed = true; } + if (!b.dateModified) { b.dateModified = b.dateAdded; changed = true; } + if (!b.customFields || typeof b.customFields !== 'object') { + b.customFields = {}; + changed = true; + } + } + + for (const m of this.data.manga) { + if (typeof m.author !== 'string') { m.author = ''; changed = true; } + if (typeof m.rating !== 'number') { m.rating = 0; changed = true; } + if (typeof m.chaptersRead !== 'number') { m.chaptersRead = 0; changed = true; } + if (typeof m.totalChapters !== 'number') { m.totalChapters = 0; changed = true; } + if (typeof m.volumesRead !== 'number') { m.volumesRead = 0; changed = true; } + if (typeof m.totalVolumes !== 'number') { m.totalVolumes = 0; changed = true; } + if (typeof m.coverUrl !== 'string') { m.coverUrl = ''; changed = true; } + if (typeof m.malId !== 'string') { m.malId = ''; changed = true; } + if (typeof m.vaultPage !== 'string') { m.vaultPage = ''; changed = true; } + if (m.dateStarted === undefined) { m.dateStarted = null; changed = true; } + if (m.dateFinished === undefined) { m.dateFinished = null; changed = true; } + if (m.releaseDate === undefined) { m.releaseDate = null; changed = true; } + // "On Hold" was removed; migrate any legacy value to "Plan to Read". + if ((m.status as string) === 'On Hold') { m.status = 'Plan to Read'; changed = true; } + if (!m.dateAdded) { m.dateAdded = new Date().toISOString(); changed = true; } + if (!m.dateModified) { m.dateModified = m.dateAdded; changed = true; } + if (!m.customFields || typeof m.customFields !== 'object') { + m.customFields = {}; + changed = true; + } + } + + for (const c of this.data.bookColumns) { + if (!Array.isArray(c.options)) { c.options = []; changed = true; } + if (!c.color) { c.color = '#5F5E5A'; changed = true; } + } + for (const c of this.data.mangaColumns) { + if (!Array.isArray(c.options)) { c.options = []; changed = true; } + if (!c.color) { c.color = '#5F5E5A'; changed = true; } + } + + // Reconcile auto "To be released" status against each release date. + for (const b of this.data.books) if (this.applyReleaseStatus(b)) changed = true; + for (const m of this.data.manga) if (this.applyReleaseStatus(m)) changed = true; + + return changed; + } + + /** + * Forces "To be released" when the item's release date is in the future, and + * reverts it to "Plan to Read" once the date has passed (or is cleared). + * Mirrors the watchlist auto-status mechanism. Returns true if status changed. + */ + private applyReleaseStatus(item: Book | Manga): boolean { + const future = isReleaseDateFuture(item.releaseDate); + if (future) { + if (item.status !== 'To be released') { + item.status = 'To be released'; + return true; + } + } else if (item.status === 'To be released') { + item.status = 'Plan to Read'; + return true; + } + return false; + } + + /** + * Mirrors the watchlist auto-add: a reading item with a future release date is + * added to the shared Upcoming tracker (owned by DataManager), and removed once + * the date passes or is cleared. Connects Reading into the same Upcoming pathway. + */ + private async syncUpcoming(item: Book | Manga, kind: ReadingKind): Promise<void> { + const dm = this.plugin.dataManager; + if (!dm) return; + if (isReleaseDateFuture(item.releaseDate)) { + await dm.autoAddReadingToUpcoming(item, kind); + } else { + await dm.removeReadingAirtimeEntries(item.id); + } + } + + getData(): ReadingData { + return this.data; + } + + getSettings(): ReadingSettings { + return this.data.settings; + } + + async updateSettings(patch: Partial<ReadingSettings>): Promise<void> { + this.data.settings = { ...this.data.settings, ...patch }; + await this.save(); + } + + // ── Persistence ─────────────────────────────────────────────────────────── + + private async saveOnly(): Promise<void> { + try { + await this.plugin.app.vault.adapter.write( + this.filePath, + JSON.stringify(this.data, null, 2), + ); + } catch (e) { + console.warn('[WL] ReadingDataManager.save failed:', e); + } + } + + private async save(): Promise<void> { + await this.saveOnly(); + this.notifyListeners(); + } + + // ── Listeners ───────────────────────────────────────────────────────────── + + onChange(listener: () => void): void { + this.changeListeners.push(listener); + } + + offChange(listener: () => void): void { + this.changeListeners = this.changeListeners.filter((l) => l !== listener); + } + + notifyChange(): void { + this.notifyListeners(); + } + + async saveAndNotify(): Promise<void> { + await this.saveOnly(); + this.notifyListeners(); + } + + /** + * Overwrite the entire reading dataset from a backup, then reload through the + * normal load() path (migration + Upcoming reconcile) and notify listeners. + */ + async restore(data: ReadingData): Promise<void> { + await this.plugin.app.vault.adapter.write( + this.filePath, + JSON.stringify(data, null, 2), + ); + await this.load(); + this.notifyListeners(); + } + + private notifyListeners(): void { + for (const listener of this.changeListeners) { + listener(); + } + } + + private coverSaveTimer: number | null = null; + private readonly COVER_SAVE_DEBOUNCE_MS = 5000; + + /** + * Silent cover URL update for the lazy Cards re-fetch. Writes the value in + * memory and schedules a debounced disk save (5s). Does NOT notify listeners — + * the card that triggered the fetch updates its own <img> directly, so the open + * grid is not rebuilt (which would reset scroll position). + */ + updateCoverUrl(kind: ReadingKind, id: string, url: string): void { + const item = kind === 'book' ? this.getBook(id) : this.getManga(id); + if (!item) return; + item.coverUrl = url; + if (this.coverSaveTimer !== null) return; + this.coverSaveTimer = window.setTimeout(() => { + this.coverSaveTimer = null; + void this.saveOnly(); + }, this.COVER_SAVE_DEBOUNCE_MS); + } + + /** + * Like updateCoverUrl, but also persists a source ID that was just resolved via a + * title-based lookup (for CSV-imported entries that arrived with neither cover nor + * ID). Stores googleBooksId / malId so subsequent loads use the cheap ID path. + * Silent + debounced — same scroll-preserving guarantees as updateCoverUrl. + */ + updateCoverAndSource(kind: ReadingKind, id: string, url: string, sourceId: string): void { + const item = kind === 'book' ? this.getBook(id) : this.getManga(id); + if (!item) return; + item.coverUrl = url; + if (kind === 'book') (item as Book).googleBooksId = sourceId; + else (item as Manga).malId = sourceId; + if (this.coverSaveTimer !== null) return; + this.coverSaveTimer = window.setTimeout(() => { + this.coverSaveTimer = null; + void this.saveOnly(); + }, this.COVER_SAVE_DEBOUNCE_MS); + } + + /** Flush a pending debounced cover save now (e.g. on plugin unload). */ + flushCoverSave(): void { + if (this.coverSaveTimer !== null) { + window.clearTimeout(this.coverSaveTimer); + this.coverSaveTimer = null; + void this.saveOnly(); + } + } + + // ── Books CRUD ──────────────────────────────────────────────────────────── + + getBooks(): Book[] { + return this.data.books; + } + + getBook(id: string): Book | undefined { + return this.data.books.find((b) => b.id === id); + } + + async addBook(book: Book): Promise<void> { + this.applyReleaseStatus(book); + this.data.books.push(book); + await this.save(); + await this.syncUpcoming(book, 'book'); + try { await this.writeReadingNote('book', book); } catch (e) { console.warn('[WL] writeReadingNote failed:', e); } + void this.historyManager?.log( + `${book.title} (Book) was added`, + { source: 'Reading', action: 'added', titleName: book.title }, + ); + } + + async addBookSilent(book: Book): Promise<void> { + this.applyReleaseStatus(book); + this.data.books.push(book); + await this.saveOnly(); + } + + async updateBook(updated: Book): Promise<void> { + updated.dateModified = new Date().toISOString(); + this.applyReleaseStatus(updated); + const idx = this.data.books.findIndex((b) => b.id === updated.id); + if (idx >= 0) { + const old = this.data.books[idx]!; + if (old.status !== updated.status) { + const act = updated.status === 'Completed' ? 'completed' as const : 'status' as const; + // Mirror the watch behavior: stamp today's finish date on the transition to Completed. + if (updated.status === 'Completed' && this.plugin.settings.setFinishDateAutomatically && !updated.dateFinished) { + updated.dateFinished = new Date().toISOString().split('T')[0] ?? null; + } + void this.historyManager?.log( + `${updated.title} (Book) status changed to ${updated.status}`, + { source: 'Reading', action: act, titleName: updated.title }, + ); + } + if (old.rating !== updated.rating) { + void this.historyManager?.log( + `${updated.title} (Book) Rating → ${updated.rating}/5`, + { source: 'Reading', action: 'rating', titleName: updated.title }, + ); + } + this.data.books[idx] = updated; + await this.save(); + // Only reconcile Upcoming when the release date actually changed, so + // rating/status/progress edits never disturb a manual recurring entry. + if (old.releaseDate !== updated.releaseDate) await this.syncUpcoming(updated, 'book'); + try { await this.writeReadingNote('book', updated); } catch (e) { console.warn('[WL] writeReadingNote failed:', e); } + } + } + + updateBookSilent(updated: Book): void { + updated.dateModified = new Date().toISOString(); + this.applyReleaseStatus(updated); + const idx = this.data.books.findIndex((b) => b.id === updated.id); + if (idx >= 0) { + this.data.books[idx] = updated; + } + } + + async removeBook(id: string): Promise<void> { + const book = this.getBook(id); + this.data.books = this.data.books.filter((b) => b.id !== id); + await this.save(); + await this.plugin.dataManager?.removeReadingAirtimeEntries(id); + if (book) { + void this.historyManager?.log( + `${book.title} (Book) was deleted`, + { source: 'Reading', action: 'deleted', titleName: book.title }, + ); + try { await this.deleteReadingNote('book', book); } catch (e) { console.warn('[WL] deleteReadingNote failed:', e); } + } + } + + /** + * Insert many books in one shot: single persist, then per-item Upcoming reconcile + * and note-file write (mirrors the watch addTitleBatch). Used by the chunked CSV import. + */ + async addBookBatch(books: Book[]): Promise<void> { + for (const book of books) { + this.applyReleaseStatus(book); + this.data.books.push(book); + } + await this.saveOnly(); + for (const book of books) { + await this.syncUpcoming(book, 'book'); + try { await this.writeReadingNote('book', book); } catch (e) { console.warn('[WL] writeReadingNote failed:', e); } + void this.historyManager?.log( + `${book.title} (Book) was added`, + { source: 'Reading', action: 'added', titleName: book.title }, + ); + } + } + + generateBookId(title: string): string { + const base = slugify(title) || 'book'; + const existing = new Set(this.data.books.map((b) => b.id)); + if (!existing.has(base)) return base; + let counter = 2; + while (existing.has(`${base}-${counter}`)) counter++; + return `${base}-${counter}`; + } + + // ── Manga CRUD ──────────────────────────────────────────────────────────── + + getMangaList(): Manga[] { + return this.data.manga; + } + + getManga(id: string): Manga | undefined { + return this.data.manga.find((m) => m.id === id); + } + + async addManga(manga: Manga): Promise<void> { + this.applyReleaseStatus(manga); + this.data.manga.push(manga); + await this.save(); + await this.syncUpcoming(manga, 'manga'); + try { await this.writeReadingNote('manga', manga); } catch (e) { console.warn('[WL] writeReadingNote failed:', e); } + void this.historyManager?.log( + `${manga.title} (Manga) was added`, + { source: 'Reading', action: 'added', titleName: manga.title }, + ); + } + + async addMangaSilent(manga: Manga): Promise<void> { + this.applyReleaseStatus(manga); + this.data.manga.push(manga); + await this.saveOnly(); + } + + async updateManga(updated: Manga): Promise<void> { + updated.dateModified = new Date().toISOString(); + this.applyReleaseStatus(updated); + const idx = this.data.manga.findIndex((m) => m.id === updated.id); + if (idx >= 0) { + const old = this.data.manga[idx]!; + if (old.status !== updated.status) { + const act = updated.status === 'Completed' ? 'completed' as const : 'status' as const; + // Mirror the watch behavior: stamp today's finish date on the transition to Completed. + if (updated.status === 'Completed' && this.plugin.settings.setFinishDateAutomatically && !updated.dateFinished) { + updated.dateFinished = new Date().toISOString().split('T')[0] ?? null; + } + void this.historyManager?.log( + `${updated.title} (Manga) status changed to ${updated.status}`, + { source: 'Reading', action: act, titleName: updated.title }, + ); + } + if (old.rating !== updated.rating) { + void this.historyManager?.log( + `${updated.title} (Manga) Rating → ${updated.rating}/5`, + { source: 'Reading', action: 'rating', titleName: updated.title }, + ); + } + this.data.manga[idx] = updated; + await this.save(); + // Only reconcile Upcoming when the release date actually changed, so + // rating/status/progress edits never disturb a manual recurring entry. + if (old.releaseDate !== updated.releaseDate) await this.syncUpcoming(updated, 'manga'); + try { await this.writeReadingNote('manga', updated); } catch (e) { console.warn('[WL] writeReadingNote failed:', e); } + } + } + + updateMangaSilent(updated: Manga): void { + updated.dateModified = new Date().toISOString(); + this.applyReleaseStatus(updated); + const idx = this.data.manga.findIndex((m) => m.id === updated.id); + if (idx >= 0) { + this.data.manga[idx] = updated; + } + } + + async removeManga(id: string): Promise<void> { + const manga = this.getManga(id); + this.data.manga = this.data.manga.filter((m) => m.id !== id); + await this.save(); + await this.plugin.dataManager?.removeReadingAirtimeEntries(id); + if (manga) { + void this.historyManager?.log( + `${manga.title} (Manga) was deleted`, + { source: 'Reading', action: 'deleted', titleName: manga.title }, + ); + try { await this.deleteReadingNote('manga', manga); } catch (e) { console.warn('[WL] deleteReadingNote failed:', e); } + } + } + + async removeBooksBatch(ids: string[]): Promise<void> { + const CHUNK = 10; + const toDelete: Book[] = []; + for (const id of ids) { + const book = this.getBook(id); + if (book) toDelete.push(book); + } + this.data.books = this.data.books.filter((b) => !ids.includes(b.id)); + await this.save(); + for (const id of ids) await this.plugin.dataManager?.removeReadingAirtimeEntries(id); + for (const book of toDelete) { + void this.historyManager?.log( + `${book.title} (Book) was deleted`, + { source: 'Reading', action: 'deleted', titleName: book.title }, + ); + } + for (let i = 0; i < toDelete.length; i += CHUNK) { + const chunk = toDelete.slice(i, i + CHUNK); + await Promise.all(chunk.map((b) => this.deleteReadingNote('book', b).catch(() => {}))); + } + } + + async removeMangaBatch(ids: string[]): Promise<void> { + const CHUNK = 10; + const toDelete: Manga[] = []; + for (const id of ids) { + const manga = this.getManga(id); + if (manga) toDelete.push(manga); + } + this.data.manga = this.data.manga.filter((m) => !ids.includes(m.id)); + await this.save(); + for (const id of ids) await this.plugin.dataManager?.removeReadingAirtimeEntries(id); + for (const manga of toDelete) { + void this.historyManager?.log( + `${manga.title} (Manga) was deleted`, + { source: 'Reading', action: 'deleted', titleName: manga.title }, + ); + } + for (let i = 0; i < toDelete.length; i += CHUNK) { + const chunk = toDelete.slice(i, i + CHUNK); + await Promise.all(chunk.map((m) => this.deleteReadingNote('manga', m).catch(() => {}))); + } + } + + /** + * Insert many manga in one shot: single persist, then per-item Upcoming reconcile + * and note-file write (mirrors the watch addTitleBatch). Used by the chunked CSV import. + */ + async addMangaBatch(mangaList: Manga[]): Promise<void> { + for (const manga of mangaList) { + this.applyReleaseStatus(manga); + this.data.manga.push(manga); + } + await this.saveOnly(); + for (const manga of mangaList) { + await this.syncUpcoming(manga, 'manga'); + try { await this.writeReadingNote('manga', manga); } catch (e) { console.warn('[WL] writeReadingNote failed:', e); } + void this.historyManager?.log( + `${manga.title} (Manga) was added`, + { source: 'Reading', action: 'added', titleName: manga.title }, + ); + } + } + + generateMangaId(title: string): string { + const base = slugify(title) || 'manga'; + const existing = new Set(this.data.manga.map((m) => m.id)); + if (!existing.has(base)) return base; + let counter = 2; + while (existing.has(`${base}-${counter}`)) counter++; + return `${base}-${counter}`; + } + + // ── Custom columns CRUD ─────────────────────────────────────────────────── + + getBookColumns(): ReadingCustomColumn[] { + return this.data.bookColumns; + } + + getMangaColumns(): ReadingCustomColumn[] { + return this.data.mangaColumns; + } + + private columnsFor(kind: 'book' | 'manga'): ReadingCustomColumn[] { + return kind === 'book' ? this.data.bookColumns : this.data.mangaColumns; + } + + async addColumn(kind: 'book' | 'manga', column: ReadingCustomColumn): Promise<void> { + this.columnsFor(kind).push(column); + await this.save(); + } + + async updateColumn(kind: 'book' | 'manga', updated: ReadingCustomColumn): Promise<void> { + const cols = this.columnsFor(kind); + const idx = cols.findIndex((c) => c.id === updated.id); + if (idx >= 0) { + cols[idx] = updated; + await this.save(); + } + } + + async removeColumn(kind: 'book' | 'manga', id: string): Promise<void> { + if (kind === 'book') { + this.data.bookColumns = this.data.bookColumns.filter((c) => c.id !== id); + for (const b of this.data.books) { + if (b.customFields && Object.prototype.hasOwnProperty.call(b.customFields, id)) { + delete b.customFields[id]; + } + } + } else { + this.data.mangaColumns = this.data.mangaColumns.filter((c) => c.id !== id); + for (const m of this.data.manga) { + if (m.customFields && Object.prototype.hasOwnProperty.call(m.customFields, id)) { + delete m.customFields[id]; + } + } + } + await this.save(); + } + + generateColumnId(kind: 'book' | 'manga', name: string): string { + const base = `col-${slugify(name) || 'field'}`; + const existing = new Set(this.columnsFor(kind).map((c) => c.id)); + if (!existing.has(base)) return base; + let counter = 2; + while (existing.has(`${base}-${counter}`)) counter++; + return `${base}-${counter}`; + } + + async reorderColumns(kind: ReadingKind, fromIdx: number, toIdx: number): Promise<void> { + const cols = this.columnsFor(kind); + if (fromIdx < 0 || fromIdx >= cols.length || toIdx < 0 || toIdx >= cols.length) return; + const [moved] = cols.splice(fromIdx, 1); + if (!moved) return; + cols.splice(toIdx, 0, moved); + await this.save(); + } + + // ── Custom field values ────────────────────────────────────────────────── + + async setCustomField( + kind: ReadingKind, + itemId: string, + columnId: string, + value: string | number | null, + ): Promise<void> { + const item = kind === 'book' ? this.getBook(itemId) : this.getManga(itemId); + if (!item) return; + if (!item.customFields) item.customFields = {}; + if (value === null || value === '') { + delete item.customFields[columnId]; + } else { + item.customFields[columnId] = value; + } + item.dateModified = new Date().toISOString(); + await this.save(); + } + + // ── Reading note files ─────────────────────────────────────────────────── + + private kindFolder(kind: ReadingKind): string { + const root = this.data.settings.defaultFolder || 'WatchLog/Reading'; + return normalizePath(`${root}/${kind === 'book' ? 'Books' : 'Manga'}`); + } + + noteFilePath(kind: ReadingKind, title: string): string { + const safe = sanitizeFilename(title) || (kind === 'book' ? 'book' : 'manga'); + return normalizePath(`${this.kindFolder(kind)}/${safe}.md`); + } + + private async ensureFolder(path: string): Promise<void> { + const normalized = normalizePath(path); + if (!this.plugin.app.vault.getAbstractFileByPath(normalized)) { + try { + await this.plugin.app.vault.createFolder(normalized); + } catch { + // folder may already exist + } + } + } + + private buildFrontmatter(kind: ReadingKind, item: Book | Manga): string { + const lines: string[] = ['---']; + lines.push(`title: ${yamlEscape(sanitizeFilename(item.title))}`); + lines.push(`author: ${yamlEscape(item.author ?? '')}`); + lines.push(`status: ${yamlEscape(item.status)}`); + lines.push(`rating: ${item.rating | 0}`); + if (kind === 'book') { + const b = item as Book; + lines.push(`pagesRead: ${b.pagesRead | 0}`); + lines.push(`totalPages: ${b.totalPages | 0}`); + lines.push(`chaptersRead: ${b.chaptersRead | 0}`); + lines.push(`totalChapters: ${b.totalChapters | 0}`); + if (b.googleBooksId) lines.push(`googleBooksId: ${yamlEscape(b.googleBooksId)}`); + } else { + const m = item as Manga; + lines.push(`chaptersRead: ${m.chaptersRead | 0}`); + lines.push(`totalChapters: ${m.totalChapters | 0}`); + lines.push(`volumesRead: ${m.volumesRead | 0}`); + lines.push(`totalVolumes: ${m.totalVolumes | 0}`); + if (m.malId) lines.push(`malId: ${yamlEscape(m.malId)}`); + } + lines.push(`dateStarted: ${item.dateStarted ?? 'null'}`); + lines.push(`dateFinished: ${item.dateFinished ?? 'null'}`); + lines.push(`releaseDate: ${item.releaseDate ?? 'null'}`); + lines.push(`dateAdded: ${yamlEscape(item.dateAdded)}`); + lines.push(`type: ${kind === 'book' ? 'book' : 'manga'}`); + lines.push('---'); + return lines.join('\n'); + } + + private buildInitialNoteContent(kind: ReadingKind, item: Book | Manga): string { + return `${this.buildFrontmatter(kind, item)}\n\n## Notes\n\n\n## Quotes\n`; + } + + private rebuildWithFrontmatter(existing: string, frontmatter: string): string { + const fmMatch = existing.match(/^---\n[\s\S]*?\n---\n?/); + if (fmMatch) { + return frontmatter + '\n' + existing.slice(fmMatch[0].length); + } + // No frontmatter found — prepend it. + return frontmatter + '\n\n' + existing; + } + + private lastNotePathById = new Map<string, string>(); + + /** Writes (creates or updates) the note file. Preserves Notes/Quotes body when the file exists. */ + async writeReadingNote(kind: ReadingKind, item: Book | Manga): Promise<string> { + await this.ensureFolder(this.kindFolder(kind)); + const filePath = this.noteFilePath(kind, item.title); + const frontmatter = this.buildFrontmatter(kind, item); + + // Handle rename: trash the previous file if the path changed. + const prevKey = `${kind}:${item.id}`; + const previousPath = this.lastNotePathById.get(prevKey); + if (previousPath && previousPath !== filePath) { + const oldFile = this.plugin.app.vault.getAbstractFileByPath(previousPath); + if (oldFile instanceof TFile) { + try { + await this.plugin.app.fileManager.trashFile(oldFile); + } catch { + // best-effort + } + } + } + + const existing = this.plugin.app.vault.getAbstractFileByPath(filePath); + if (existing instanceof TFile) { + const current = await this.plugin.app.vault.read(existing); + const updated = this.rebuildWithFrontmatter(current, frontmatter); + if (updated !== current) { + await this.plugin.app.vault.modify(existing, updated); + } + } else { + await this.plugin.app.vault.create(filePath, this.buildInitialNoteContent(kind, item)); + } + this.lastNotePathById.set(prevKey, filePath); + return filePath; + } + + async ensureReadingNote(kind: ReadingKind, item: Book | Manga): Promise<string> { + const filePath = this.noteFilePath(kind, item.title); + const existing = this.plugin.app.vault.getAbstractFileByPath(filePath); + if (existing instanceof TFile) { + this.lastNotePathById.set(`${kind}:${item.id}`, filePath); + return filePath; + } + return this.writeReadingNote(kind, item); + } + + /** Creates the note file only if it's missing; returns true when a file was created. */ + async createReadingNoteIfMissing(kind: ReadingKind, item: Book | Manga): Promise<boolean> { + const filePath = this.noteFilePath(kind, item.title); + if (this.plugin.app.vault.getAbstractFileByPath(filePath) instanceof TFile) return false; + await this.writeReadingNote(kind, item); + return true; + } + + async readReadingNote(kind: ReadingKind, item: Book | Manga): Promise<string | null> { + const filePath = this.noteFilePath(kind, item.title); + const file = this.plugin.app.vault.getAbstractFileByPath(filePath); + if (!(file instanceof TFile)) return null; + return this.plugin.app.vault.read(file); + } + + /** Appends a `> [!quote]` callout to the ## Quotes section. Creates the section if missing. */ + async appendQuote( + kind: ReadingKind, + item: Book | Manga, + text: string, + reference: string, + ): Promise<void> { + await this.ensureReadingNote(kind, item); + const filePath = this.noteFilePath(kind, item.title); + const file = this.plugin.app.vault.getAbstractFileByPath(filePath); + if (!(file instanceof TFile)) return; + + const current = await this.plugin.app.vault.read(file); + const refTrim = reference.trim(); + const titlePart = refTrim ? ` ${refTrim}` : ''; + const bodyLines = text + .split(/\r?\n/) + .map((l) => `> ${l}`) + .join('\n'); + const callout = `> [!quote]${titlePart}\n${bodyLines}`; + + let updated: string; + const quotesMatch = current.match(/(^|\n)## Quotes[ \t]*\r?\n/); + if (quotesMatch && quotesMatch.index !== undefined) { + const insertAt = quotesMatch.index + quotesMatch[0].length; + const before = current.slice(0, insertAt); + const after = current.slice(insertAt); + const needsLeadingNl = before.length > 0 && !before.endsWith('\n\n'); + updated = `${before}${needsLeadingNl ? '\n' : ''}${callout}\n${after.startsWith('\n') ? '' : '\n'}${after}`; + } else { + const sep = current.endsWith('\n') ? '' : '\n'; + updated = `${current}${sep}\n## Quotes\n\n${callout}\n`; + } + + await this.plugin.app.vault.modify(file, updated); + } + + async deleteReadingNote(kind: ReadingKind, item: Book | Manga): Promise<void> { + const filePath = this.noteFilePath(kind, item.title); + const file = this.plugin.app.vault.getAbstractFileByPath(filePath); + if (file instanceof TFile) { + try { + await this.plugin.app.fileManager.trashFile(file); + } catch { + // best-effort + } + } + this.lastNotePathById.delete(`${kind}:${item.id}`); + } +} diff --git a/src/ReadingDetailModal.ts b/src/ReadingDetailModal.ts new file mode 100644 index 0000000..096eb35 --- /dev/null +++ b/src/ReadingDetailModal.ts @@ -0,0 +1,1411 @@ +import { App, Modal, Notice, TFile } from 'obsidian'; +import type WatchLogPlugin from './main'; +import type { ReadingDataManager } from './ReadingDataManager'; +import { + Book, Manga, ReadingCustomColumn, ReadingStatus, SELECTABLE_READING_STATUSES, + FIELD_COLOR_50, DEFAULT_FIELD_COLOR, + formatDateDisplay, parseDateInput, +} from './types'; +import { googleBooksErrorMessage } from './ApiService'; +import { ConfirmModal } from './ConfirmModal'; +import { VaultFilePicker } from './AddReadingModal'; +import { readingStatusColor, coverFallbackColor } from './ReadingTab'; +import { ReadingManageColumnsModal } from './ReadingManageColumnsModal'; + +interface ParsedQuote { + reference: string; + text: string; +} + +function parseQuotesSection(content: string): ParsedQuote[] { + const match = content.match(/(^|\n)## Quotes[ \t]*\r?\n([\s\S]*?)(?=\n## |\n# |$)/); + if (!match) return []; + const section = match[2] ?? ''; + const quotes: ParsedQuote[] = []; + + const lines = section.split(/\r?\n/); + let current: { reference: string; body: string[] } | null = null; + const flush = (): void => { + if (current) { + const text = current.body.join('\n').trim(); + if (text) quotes.push({ reference: current.reference, text }); + } + current = null; + }; + + for (const raw of lines) { + const header = raw.match(/^>\s*\[!quote\](.*)$/i); + if (header) { + flush(); + current = { reference: (header[1] ?? '').trim(), body: [] }; + continue; + } + if (current) { + const cont = raw.match(/^>\s?(.*)$/); + if (cont) { + current.body.push(cont[1] ?? ''); + } else if (raw.trim() === '') { + flush(); + } else { + flush(); + } + } + } + flush(); + return quotes; +} + +interface ProgressDraft { + pagesRead: number; + chaptersRead: number; + volumesRead: number; +} + +interface ProgressSnapshot { + pagesRead: number; + chaptersRead: number; + volumesRead: number; +} + +export class ReadingDetailModal extends Modal { + private plugin: WatchLogPlugin; + private readingData: ReadingDataManager; + private mode: 'book' | 'manga'; + private id: string; + private onChanged: () => void; + private draft: ProgressDraft; + private openSnapshot: ProgressSnapshot; + private starsWrapEl: HTMLElement | null = null; + + constructor( + app: App, + plugin: WatchLogPlugin, + readingData: ReadingDataManager, + mode: 'book' | 'manga', + id: string, + onChanged: () => void, + ) { + super(app); + this.plugin = plugin; + this.readingData = readingData; + this.mode = mode; + this.id = id; + this.onChanged = onChanged; + const item = this.getItem(); + this.draft = { + pagesRead: item && this.mode === 'book' ? (item as Book).pagesRead : 0, + chaptersRead: item ? (item).chaptersRead : 0, + volumesRead: item && this.mode === 'manga' ? (item as Manga).volumesRead : 0, + }; + this.openSnapshot = { ...this.draft }; + } + + private getItem(): Book | Manga | undefined { + return this.mode === 'book' + ? this.readingData.getBook(this.id) + : this.readingData.getManga(this.id); + } + + onOpen(): void { + const colorTheme = this.plugin.settings.colorTheme ?? 'default'; + this.modalEl.setAttribute('data-theme', colorTheme); + this.contentEl.setAttribute('data-theme', colorTheme); + this.contentEl.addClass('wl-view'); + this.contentEl.addClass('wl-detail-modal'); + this.contentEl.addClass('wl-reading-detail'); + this.renderAll(); + } + + onClose(): void { + void this.commitOnClose(); + this.contentEl.empty(); + } + + private async commitOnClose(): Promise<void> { + const draftChanged = + this.draft.pagesRead !== this.openSnapshot.pagesRead || + this.draft.chaptersRead !== this.openSnapshot.chaptersRead || + this.draft.volumesRead !== this.openSnapshot.volumesRead; + if (!draftChanged) return; + + const item = this.getItem(); + if (!item) return; + + if (this.mode === 'book') { + const book = item as Book; + const updated: Book = { + ...book, + pagesRead: this.draft.pagesRead, + chaptersRead: this.draft.chaptersRead, + }; + this.applyAutoComplete(updated); + await this.readingData.updateBook(updated); + } else { + const manga = item as Manga; + const updated: Manga = { + ...manga, + chaptersRead: this.draft.chaptersRead, + volumesRead: this.draft.volumesRead, + }; + this.applyAutoComplete(updated); + await this.readingData.updateManga(updated); + } + + this.logProgressChange(item); + this.onChanged(); + } + + private logProgressChange(item: Book | Manga): void { + const snap = this.openSnapshot; + const typeLabel = this.mode === 'book' ? 'Book' : 'Manga'; + + if (this.mode === 'book') { + const book = item as Book; + if (this.draft.pagesRead !== snap.pagesRead && book.totalPages > 0) { + void this.plugin.historyManager?.log( + `${item.title} (${typeLabel}) At page ${this.draft.pagesRead} / ${book.totalPages}`, + { source: 'Reading', action: 'watched', titleName: item.title }, + ); + } + if (this.draft.chaptersRead !== snap.chaptersRead && book.totalChapters > 0) { + void this.plugin.historyManager?.log( + `${item.title} (${typeLabel}) At chapter ${this.draft.chaptersRead} / ${book.totalChapters}`, + { source: 'Reading', action: 'watched', titleName: item.title }, + ); + } + } else { + const manga = item as Manga; + if (this.draft.chaptersRead !== snap.chaptersRead && manga.totalChapters > 0) { + void this.plugin.historyManager?.log( + `${item.title} (${typeLabel}) At chapter ${this.draft.chaptersRead} / ${manga.totalChapters}`, + { source: 'Reading', action: 'watched', titleName: item.title }, + ); + } + if (this.draft.volumesRead !== snap.volumesRead && manga.totalVolumes > 0) { + void this.plugin.historyManager?.log( + `${item.title} (${typeLabel}) At volume ${this.draft.volumesRead} / ${manga.totalVolumes}`, + { source: 'Reading', action: 'watched', titleName: item.title }, + ); + } + } + } + + private renderAll(): void { + this.contentEl.empty(); + const item = this.getItem(); + if (!item) { + this.contentEl.createDiv({ text: 'Item no longer exists.' }); + return; + } + this.renderHeader(item); + this.renderProgressSection(item); + this.renderDetailsSection(item); + this.contentEl.createDiv({ cls: 'wl-reading-modal-divider' }); + this.renderCustomFieldsSection(item); + this.contentEl.createDiv({ cls: 'wl-reading-modal-divider' }); + void this.renderQuotesSection(item); + this.renderFooter(item); + } + + private renderHeader(item: Book | Manga): void { + const header = this.contentEl.createDiv({ cls: 'wl-reading-detail-header' }); + + // Cover + const cover = header.createDiv({ cls: 'wl-reading-detail-cover' }); + if (item.coverUrl) { + cover.createEl('img', { + cls: 'wl-reading-detail-cover-img', + attr: { src: item.coverUrl, alt: item.title }, + }); + } else { + cover.style.backgroundColor = coverFallbackColor(item.id); + cover.createSpan({ + cls: 'wl-reading-detail-cover-icon', + text: this.mode === 'book' ? '📖' : '📓', + }); + } + + // Info + const info = header.createDiv({ cls: 'wl-reading-detail-info' }); + + // Editable title + const titleWrap = info.createDiv({ cls: 'wl-reading-detail-title-wrap' }); + this.renderTitleDisplay(titleWrap); + + // Editable author + const authorWrap = info.createDiv({ cls: 'wl-reading-detail-author-wrap' }); + this.renderAuthorDisplay(authorWrap); + + const meta = info.createDiv({ cls: 'wl-reading-detail-meta-row' }); + + // Editable status badge + const statusWrap = meta.createSpan({ cls: 'wl-reading-detail-status-wrap' }); + this.renderStatusBadge(statusWrap); + + this.starsWrapEl = meta.createDiv({ cls: 'wl-stars wl-reading-detail-stars' }); + this.renderStars(); + + // External link + Link vault page + const actions = info.createDiv({ cls: 'wl-reading-detail-actions' }); + this.renderExternalLinkActions(actions); + + const vaultRow = info.createDiv({ cls: 'wl-reading-detail-vault-row' }); + this.renderVaultRow(vaultRow); + } + + private renderTitleDisplay(wrap: HTMLElement): void { + wrap.empty(); + const fresh = this.getItem(); + const titleEl = wrap.createEl('h2', { + cls: 'wl-reading-detail-title wl-reading-detail-editable-text', + text: fresh?.title ?? '', + }); + titleEl.title = 'Click to edit'; + titleEl.addEventListener('click', () => this.editTitle(wrap)); + } + + private editTitle(wrap: HTMLElement): void { + wrap.empty(); + const fresh = this.getItem(); + const input = wrap.createEl('input', { + cls: 'wl-modal-input wl-reading-detail-title-input', + attr: { type: 'text' }, + }); + input.value = fresh?.title ?? ''; + const commit = (): void => { + const val = input.value.trim(); + if (!val) { this.renderTitleDisplay(wrap); return; } + void (async () => { + const curr = this.getItem(); + if (curr && val !== curr.title) { + if (this.mode === 'book') { + await this.readingData.updateBook({ ...(curr as Book), title: val }); + } else { + await this.readingData.updateManga({ ...(curr as Manga), title: val }); + } + this.onChanged(); + } + this.renderTitleDisplay(wrap); + })(); + }; + input.addEventListener('blur', commit); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); input.blur(); } + else if (e.key === 'Escape') { e.preventDefault(); this.renderTitleDisplay(wrap); } + }); + input.focus(); + input.select(); + } + + private renderAuthorDisplay(wrap: HTMLElement): void { + wrap.empty(); + const fresh = this.getItem(); + const authorEl = wrap.createDiv({ + cls: 'wl-reading-detail-author wl-reading-detail-editable-text', + text: fresh?.author || '—', + }); + authorEl.title = 'Click to edit'; + authorEl.addEventListener('click', () => this.editAuthor(wrap)); + } + + private editAuthor(wrap: HTMLElement): void { + wrap.empty(); + const fresh = this.getItem(); + const input = wrap.createEl('input', { + cls: 'wl-modal-input wl-reading-detail-author-input', + attr: { type: 'text' }, + }); + input.value = fresh?.author ?? ''; + const commit = (): void => { + const val = input.value.trim(); + void (async () => { + const curr = this.getItem(); + if (curr && val !== curr.author) { + if (this.mode === 'book') { + await this.readingData.updateBook({ ...(curr as Book), author: val }); + } else { + await this.readingData.updateManga({ ...(curr as Manga), author: val }); + } + this.onChanged(); + } + this.renderAuthorDisplay(wrap); + })(); + }; + input.addEventListener('blur', commit); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); input.blur(); } + else if (e.key === 'Escape') { e.preventDefault(); this.renderAuthorDisplay(wrap); } + }); + input.focus(); + input.select(); + } + + private renderStatusBadge(wrap: HTMLElement): void { + wrap.empty(); + const fresh = this.getItem(); + const status = fresh?.status ?? 'Plan to Read'; + const badge = wrap.createSpan({ cls: 'wl-reading-detail-status', text: status }); + badge.style.backgroundColor = readingStatusColor(status); + badge.title = 'Click to change status'; + badge.addEventListener('click', (e) => { + e.stopPropagation(); + this.openStatusDropdown(badge, wrap); + }); + } + + private openStatusDropdown(anchor: HTMLElement, wrap: HTMLElement): void { + this.contentEl.querySelectorAll('.wl-reading-status-dropdown').forEach((el) => el.remove()); + const rect = anchor.getBoundingClientRect(); + const dropdown = this.contentEl.createDiv({ cls: 'wl-reading-status-dropdown' }); + dropdown.style.top = `${rect.bottom + 4}px`; + dropdown.style.left = `${rect.left}px`; + // Capture the owning document once so add/remove can't desync across popout windows. + const doc = this.contentEl.ownerDocument; + + for (const status of SELECTABLE_READING_STATUSES) { + const opt = dropdown.createDiv({ cls: 'wl-reading-status-option' }); + const dot = opt.createSpan({ cls: 'wl-reading-status-option-dot' }); + dot.style.backgroundColor = readingStatusColor(status); + opt.createSpan({ text: status }); + opt.addEventListener('click', () => { + dropdown.remove(); + doc.removeEventListener('mousedown', closeListener, true); + void this.saveStatus(status).then(() => this.renderStatusBadge(wrap)); + }); + } + + const closeListener = (e: MouseEvent): void => { + if (!dropdown.contains(e.target as Node)) { + dropdown.remove(); + doc.removeEventListener('mousedown', closeListener, true); + } + }; + window.setTimeout(() => doc.addEventListener('mousedown', closeListener, true), 0); + } + + private async saveStatus(status: ReadingStatus): Promise<void> { + const curr = this.getItem(); + if (!curr) return; + const patch: Partial<Book & Manga> = { status }; + if (status === 'Completed' && !curr.dateFinished) { + const now = new Date(); + const yyyy = now.getFullYear(); + const mm = String(now.getMonth() + 1).padStart(2, '0'); + const dd = String(now.getDate()).padStart(2, '0'); + patch.dateFinished = `${yyyy}-${mm}-${dd}`; + } + if (this.mode === 'book') { + await this.readingData.updateBook({ ...(curr as Book), ...patch }); + } else { + await this.readingData.updateManga({ ...(curr as Manga), ...patch }); + } + this.onChanged(); + } + + private renderExternalLinkActions(container: HTMLElement): void { + container.empty(); + const item = this.getItem(); + if (!item) return; + + const hasLink = !!item.externalLink; + + const linkBtn = container.createEl('button', { + cls: 'wl-btn wl-btn-sm', + text: hasLink ? 'Change link' : 'External link', + }); + linkBtn.addEventListener('click', () => { + container.empty(); + const inputWrap = container.createDiv({ cls: 'wl-reading-external-link-wrap' }); + const inp = inputWrap.createEl('input', { + cls: 'wl-modal-input wl-reading-external-link-input', + attr: { type: 'url', placeholder: 'https://…' }, + }); + inp.value = item.externalLink ?? ''; + const saveBtn = inputWrap.createEl('button', { cls: 'wl-reading-add-btn wl-btn-success', text: 'Save' }); + const cancelBtn = inputWrap.createEl('button', { cls: 'wl-btn wl-btn-sm', text: 'Cancel' }); + const commit = (): void => { + const url = inp.value.trim(); + void (async () => { + const curr = this.getItem(); + if (!curr) return; + if (this.mode === 'book') { + await this.readingData.updateBook({ ...(curr as Book), externalLink: url }); + } else { + await this.readingData.updateManga({ ...(curr as Manga), externalLink: url }); + } + this.onChanged(); + this.renderExternalLinkActions(container); + })(); + }; + saveBtn.addEventListener('click', commit); + cancelBtn.addEventListener('click', () => this.renderExternalLinkActions(container)); + inp.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); commit(); } + else if (e.key === 'Escape') { e.preventDefault(); this.renderExternalLinkActions(container); } + }); + inp.focus(); + }); + + if (hasLink) { + const openBtn = container.createEl('button', { + cls: 'wl-btn wl-btn-sm', + text: 'Open', + }); + openBtn.title = item.externalLink ?? ''; + openBtn.addEventListener('click', () => { + const curr = this.getItem(); + if (curr?.externalLink) { + activeWindow.open(curr.externalLink, '_blank', 'noopener,noreferrer'); + } + }); + } + } + + private renderVaultRow(row: HTMLElement): void { + row.empty(); + const item = this.getItem(); + if (!item) return; + + const linkBtn = row.createEl('button', { + cls: 'wl-btn wl-btn-sm', + text: item.vaultPage ? 'Change' : 'Link', + }); + linkBtn.title = item.vaultPage + ? `Linked: ${item.vaultPage}` + : 'Link a vault note to this entry'; + linkBtn.addEventListener('click', () => { + const files = this.plugin.app.vault.getMarkdownFiles(); + new VaultFilePicker(this.plugin.app, files, (file) => { + void this.updateVaultPage(file.path).then(() => this.renderVaultRow(row)); + }).open(); + }); + + if (item.vaultPage) { + const openBtn = row.createEl('button', { + cls: 'wl-btn wl-btn-sm', + text: 'Open', + }); + openBtn.title = 'Open the linked vault page in a new tab'; + openBtn.addEventListener('click', () => this.openLinkedVaultPage()); + } + + row.createSpan({ cls: 'wl-reading-detail-vault-label', text: 'Vault page:' }); + + if (item.vaultPage) { + const filename = item.vaultPage.split('/').pop() || item.vaultPage; + const pathSpan = row.createSpan({ + cls: 'wl-reading-detail-vault-path', + text: filename, + }); + pathSpan.title = item.vaultPage; + } else { + row.createSpan({ + cls: 'wl-reading-detail-vault-empty', + text: 'not linked', + }); + } + } + + private async updateVaultPage(newPath: string): Promise<void> { + const item = this.getItem(); + if (!item) return; + if (this.mode === 'book') { + const updated: Book = { ...(item as Book), vaultPage: newPath }; + await this.readingData.updateBook(updated); + } else { + const updated: Manga = { ...(item as Manga), vaultPage: newPath }; + await this.readingData.updateManga(updated); + } + } + + private openLinkedVaultPage(): void { + const item = this.getItem(); + if (!item || !item.vaultPage) return; + const file = this.plugin.app.vault.getAbstractFileByPath(item.vaultPage); + if (file instanceof TFile) { + void this.plugin.app.workspace.getLeaf('tab').openFile(file); + this.close(); + } else { + new Notice('Linked vault page no longer exists.'); + } + } + + private renderStars(): void { + if (!this.starsWrapEl) return; + this.starsWrapEl.empty(); + const item = this.getItem(); + if (!item) return; + for (let i = 1; i <= 5; i++) { + const star = this.starsWrapEl.createSpan({ + cls: `wl-star${item.rating >= i ? ' is-active' : ''}`, + text: '★', + }); + star.addEventListener('click', () => { + const fresh = this.getItem(); + if (!fresh) return; + const next = fresh.rating === i ? 0 : i; + void (async () => { + if (this.mode === 'book') { + const updated = { ...(fresh as Book), rating: next }; + await this.readingData.updateBook(updated); + } else { + const updated = { ...(fresh as Manga), rating: next }; + await this.readingData.updateManga(updated); + } + this.renderStars(); + })(); + }); + } + } + + private renderProgressSection(item: Book | Manga): void { + const section = this.contentEl.createDiv({ cls: 'wl-reading-detail-progress' }); + section.createDiv({ cls: 'wl-reading-section-label', text: 'Progress' }); + + const gaugeRow = section.createDiv({ cls: 'wl-reading-arc-row' }); + + // Rings + controls always render, regardless of the total (even when total is 0). + if (this.mode === 'book') { + this.renderArcGauge( + gaugeRow, 'pages', '#8b5cf6', + () => this.draft.pagesRead, + (v) => { this.draft.pagesRead = v; }, + () => (this.getItem() as Book | undefined)?.totalPages ?? 0, + (v) => this.commitTotal('totalPages', v), + ); + this.renderArcGauge( + gaugeRow, 'chapters', '#06b6d4', + () => this.draft.chaptersRead, + (v) => { this.draft.chaptersRead = v; }, + () => (this.getItem() as Book | undefined)?.totalChapters ?? 0, + (v) => this.commitTotal('totalChapters', v), + ); + } else { + this.renderArcGauge( + gaugeRow, 'chapters', '#06b6d4', + () => this.draft.chaptersRead, + (v) => { this.draft.chaptersRead = v; }, + () => (this.getItem() as Manga | undefined)?.totalChapters ?? 0, + (v) => this.commitTotal('totalChapters', v), + ); + this.renderArcGauge( + gaugeRow, 'volumes', '#f59e0b', + () => this.draft.volumesRead, + (v) => { this.draft.volumesRead = v; }, + () => (this.getItem() as Manga | undefined)?.totalVolumes ?? 0, + (v) => this.commitTotal('totalVolumes', v), + ); + } + } + + /** Persists an edited total (pages/chapters/volumes) to the underlying item. */ + private async commitTotal(field: 'totalPages' | 'totalChapters' | 'totalVolumes', value: number): Promise<void> { + const curr = this.getItem(); + if (!curr) return; + if (this.mode === 'book') { + await this.readingData.updateBook({ ...(curr as Book), [field]: value }); + } else { + await this.readingData.updateManga({ ...(curr as Manga), [field]: value }); + } + this.onChanged(); + } + + private renderArcGauge( + parent: HTMLElement, + unit: string, + color: string, + getValue: () => number, + setValue: (v: number) => void, + getTotal: () => number, + commitTotal: (v: number) => Promise<void>, + ): void { + const col = parent.createDiv({ cls: 'wl-reading-arc-col' }); + + const svgSize = 100; + const strokeWidth = 8; + const radius = (svgSize - strokeWidth) / 2; + const cx = svgSize / 2; + const cy = svgSize / 2; + + const svgWrap = col.createDiv({ cls: 'wl-reading-arc-svg-wrap' }); + const svg = activeDocument.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', `0 0 ${svgSize} ${svgSize}`); + svg.setAttribute('class', 'wl-reading-arc-svg'); + svgWrap.appendChild(svg); + + const arcLength = Math.PI * radius; + + const bgArc = activeDocument.createElementNS('http://www.w3.org/2000/svg', 'path'); + const arcPath = `M ${cx - radius} ${cy} A ${radius} ${radius} 0 1 1 ${cx + radius} ${cy}`; + bgArc.setAttribute('d', arcPath); + bgArc.setAttribute('fill', 'none'); + bgArc.setAttribute('stroke', '#2a2a2a'); + bgArc.setAttribute('stroke-width', String(strokeWidth)); + bgArc.setAttribute('stroke-linecap', 'round'); + svg.appendChild(bgArc); + + const fgArc = activeDocument.createElementNS('http://www.w3.org/2000/svg', 'path'); + fgArc.setAttribute('d', arcPath); + fgArc.setAttribute('fill', 'none'); + fgArc.setAttribute('stroke', color); + fgArc.setAttribute('stroke-width', String(strokeWidth)); + fgArc.setAttribute('stroke-linecap', 'round'); + fgArc.setAttribute('stroke-dasharray', String(arcLength)); + svg.appendChild(fgArc); + + const pctText = activeDocument.createElementNS('http://www.w3.org/2000/svg', 'text'); + pctText.setAttribute('x', String(cx)); + pctText.setAttribute('y', String(cy + 4)); + pctText.setAttribute('text-anchor', 'middle'); + pctText.setAttribute('class', 'wl-reading-arc-pct'); + svg.appendChild(pctText); + + const clamp = (n: number): number => { + const total = getTotal(); + if (isNaN(n) || n < 0) return 0; + if (total > 0 && n > total) return total; + return n; + }; + + const controls = col.createDiv({ cls: 'wl-reading-arc-controls' }); + const decBtn = controls.createEl('button', { cls: 'wl-btn wl-btn-sm wl-reading-step-btn', text: '−1' }); + const input = controls.createEl('input', { + cls: 'wl-modal-input wl-modal-input-sm wl-reading-arc-input', + attr: { type: 'number', min: '0', max: String(getTotal()) }, + }); + const incBtn = controls.createEl('button', { cls: 'wl-btn wl-btn-sm wl-reading-step-btn', text: '+1' }); + + const label = col.createDiv({ cls: 'wl-reading-arc-label' }); + + const sync = (): void => { + const total = getTotal(); + const v = getValue(); + input.value = String(v); + input.setAttribute('max', String(total)); + const pct = total > 0 ? Math.min(1, v / total) : 0; + const dashOffset = arcLength * (1 - pct); + fgArc.setAttribute('stroke-dashoffset', String(dashOffset)); + pctText.textContent = `${Math.round(pct * 100)}%`; + }; + + // Static "of … <unit>" line with only the total number inline-editable. + const renderLabel = (): void => { + label.empty(); + label.createSpan({ text: 'of ' }); + const numEl = label.createSpan({ + cls: 'wl-reading-arc-total wl-reading-detail-editable-text', + text: String(getTotal()), + }); + numEl.title = 'Click to edit'; + numEl.addEventListener('click', () => editTotal()); + label.createSpan({ text: ` ${unit}` }); + }; + + const editTotal = (): void => { + label.empty(); + label.createSpan({ text: 'of ' }); + const totalInput = label.createEl('input', { + cls: 'wl-modal-input wl-modal-input-sm wl-reading-arc-total-input', + attr: { type: 'number', min: '0' }, + }); + totalInput.value = String(getTotal()); + label.createSpan({ text: ` ${unit}` }); + let committed = false; + const commit = (): void => { + if (committed) return; + committed = true; + const raw = parseInt(totalInput.value, 10); + const v = isNaN(raw) || raw < 0 ? 0 : raw; + void (async () => { + if (v !== getTotal()) { + await commitTotal(v); + sync(); + } + renderLabel(); + })(); + }; + totalInput.addEventListener('blur', commit); + totalInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); totalInput.blur(); } + else if (e.key === 'Escape') { e.preventDefault(); committed = true; renderLabel(); } + }); + totalInput.focus(); + totalInput.select(); + }; + + renderLabel(); + sync(); + + input.addEventListener('input', () => { + const v = clamp(parseInt(input.value, 10)); + setValue(v); + sync(); + }); + decBtn.addEventListener('click', () => { + setValue(clamp(getValue() - 1)); + sync(); + }); + incBtn.addEventListener('click', () => { + setValue(clamp(getValue() + 1)); + sync(); + }); + } + + private renderDetailsSection(item: Book | Manga): void { + const section = this.contentEl.createDiv({ cls: 'wl-reading-detail-details' }); + section.createDiv({ cls: 'wl-reading-section-label', text: 'Details' }); + + const grid = section.createDiv({ cls: 'wl-reading-detail-grid' }); + + this.renderIdCell(grid, item); + + this.renderEditableDateCell( + grid, 'Started', + () => this.getItem()?.dateStarted ?? null, + async (val) => { + const curr = this.getItem(); + if (!curr) return; + if (this.mode === 'book') { + await this.readingData.updateBook({ ...(curr as Book), dateStarted: val }); + } else { + await this.readingData.updateManga({ ...(curr as Manga), dateStarted: val }); + } + this.onChanged(); + }, + ); + + this.renderAddedReleaseCell(grid, item); + + this.renderEditableDateCell( + grid, 'Finished', + () => this.getItem()?.dateFinished ?? null, + async (val) => { + const curr = this.getItem(); + if (!curr) return; + if (this.mode === 'book') { + await this.readingData.updateBook({ ...(curr as Book), dateFinished: val }); + } else { + await this.readingData.updateManga({ ...(curr as Manga), dateFinished: val }); + } + this.onChanged(); + }, + ); + } + + /** + * DETAILS ID row (Google Books ID / MAL ID). The ID is a clickable link to the + * title's web page — pointing at the editable `externalLink` when set, otherwise + * a default URL derived from the ID. A small edit button to the right sets or + * replaces the source ID itself (`googleBooksId` / `malId`) — distinct from the + * External link control above; editing it re-fetches the cover. + */ + private renderIdCell(grid: HTMLElement, item: Book | Manga): void { + const label = this.mode === 'book' ? 'Google Books ID' : 'MAL ID'; + const value = this.makeDetailCell(grid, label); + value.addClass('wl-reading-detail-id-value'); + this.fillIdValue(value, item); + } + + private idText(item: Book | Manga): string { + return this.mode === 'book' ? (item as Book).googleBooksId : (item as Manga).malId; + } + + /** Resolves the URL the clickable ID points to: explicit link, else derived from the ID. */ + private resolveItemLink(item: Book | Manga): string { + if (item.externalLink) return item.externalLink; + if (this.mode === 'manga') { + const malId = (item as Manga).malId; + return malId ? `https://myanimelist.net/manga/${encodeURIComponent(malId)}` : ''; + } + const volumeId = (item as Book).googleBooksId; + if (!volumeId) return ''; + return `https://books.google.com/books?id=${encodeURIComponent(volumeId)}`; + } + + private fillIdValue(value: HTMLElement, item: Book | Manga): void { + value.empty(); + const idText = this.idText(item); + const linkUrl = this.resolveItemLink(item); + + if (idText) { + if (linkUrl) { + value.createEl('a', { + cls: 'wl-reading-detail-link', + text: idText, + attr: { href: linkUrl, target: '_blank', rel: 'noopener noreferrer' }, + }); + } else { + value.createSpan({ text: idText }); + } + } else { + value.createSpan({ text: '—' }); + } + + const editBtn = value.createEl('button', { + cls: 'wl-btn wl-btn-sm wl-reading-detail-id-edit', + text: '✎', + attr: { title: this.mode === 'book' ? 'Edit Google Books ID' : 'Edit MAL ID' }, + }); + editBtn.addEventListener('click', () => this.editIdLink(value, item)); + } + + private editIdLink(value: HTMLElement, item: Book | Manga): void { + value.empty(); + const wrap = value.createDiv({ cls: 'wl-reading-external-link-wrap' }); + const inp = wrap.createEl('input', { + cls: 'wl-modal-input wl-reading-external-link-input', + attr: { + type: 'text', + placeholder: this.mode === 'book' ? 'Google Books volume ID' : 'MAL manga ID', + }, + }); + inp.value = this.idText(item); + const saveBtn = wrap.createEl('button', { cls: 'wl-reading-add-btn wl-btn-success', text: 'Save' }); + const cancelBtn = wrap.createEl('button', { cls: 'wl-btn wl-btn-sm', text: 'Cancel' }); + + const restore = (): void => { + const fresh = this.getItem(); + if (fresh) this.fillIdValue(value, fresh); + }; + const commit = (): void => { + const newId = inp.value.trim(); + void (async () => { + const curr = this.getItem(); + if (!curr) return; + if (newId === this.idText(curr)) { restore(); return; } + if (this.mode === 'book') { + await this.readingData.updateBook({ ...(curr as Book), googleBooksId: newId }); + } else { + await this.readingData.updateManga({ ...(curr as Manga), malId: newId }); + } + this.onChanged(); + restore(); + if (newId) await this.refetchCoverFromId(newId); + })(); + }; + saveBtn.addEventListener('click', commit); + cancelBtn.addEventListener('click', restore); + inp.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); commit(); } + else if (e.key === 'Escape') { e.preventDefault(); restore(); } + }); + inp.focus(); + } + + /** + * Re-resolves ONLY the cover from a freshly-edited source ID (Google Books volume + * for books, MAL id for manga) and persists it — pages/dates/other fields untouched. + * `onChanged()` re-renders the card so the new cover shows without a scroll. Notifies + * the user when the volume/manga simply has no cover image, or when the lookup fails. + */ + private async refetchCoverFromId(newId: string): Promise<void> { + try { + if (this.mode === 'book') { + if (!this.plugin.apiService.hasGoogleBooksKey()) { + new Notice('Google Books API key required — add one in Settings → API → Books.'); + return; + } + const result = await this.plugin.apiService.getGoogleBookById(newId); + if (!result || !result.coverUrl) { + new Notice("This volume doesn't have a cover — try a different edition's ID."); + return; + } + const fresh = this.getItem(); + if (!fresh) return; + await this.readingData.updateBook({ ...(fresh as Book), coverUrl: result.coverUrl }); + } else { + const result = await this.plugin.apiService.getMangaByMalId(Number(newId)); + if (!result || !result.coverUrl) { + new Notice("This manga doesn't have a cover — try a different ID."); + return; + } + const fresh = this.getItem(); + if (!fresh) return; + await this.readingData.updateManga({ ...(fresh as Manga), coverUrl: result.coverUrl }); + } + this.onChanged(); + } catch (err) { + new Notice(this.mode === 'book' ? googleBooksErrorMessage(err) : 'Failed to fetch cover.'); + } + } + + /** + * Renders ADDED (static) and RELEASE (editable) side by side in a single grid + * cell so the modal keeps its height. Release date drives the auto + * "To be released" status, so a change re-renders the modal. + */ + private renderAddedReleaseCell(grid: HTMLElement, item: Book | Manga): void { + const cell = grid.createDiv({ cls: 'wl-reading-detail-cell wl-reading-detail-cell-pair' }); + + const added = cell.createDiv({ cls: 'wl-reading-detail-pair-col' }); + added.createDiv({ cls: 'wl-reading-detail-cell-label', text: 'Added' }); + added.createDiv({ + cls: 'wl-reading-detail-cell-value', + text: formatDateDisplay(item.dateAdded.slice(0, 10)), + }); + + const rel = cell.createDiv({ cls: 'wl-reading-detail-pair-col' }); + rel.createDiv({ cls: 'wl-reading-detail-cell-label', text: 'Release' }); + const input = rel.createEl('input', { + cls: 'wl-reading-detail-date-input', + attr: { type: 'text', placeholder: 'Dd/mm/yyyy', maxlength: '10' }, + }); + const cur = this.getItem()?.releaseDate ?? null; + input.value = cur ? formatDateDisplay(cur) : ''; + + input.addEventListener('change', () => { + const raw = input.value.trim(); + let parsed: string | null = null; + if (raw) { + parsed = parseDateInput(raw); + if (!parsed) { input.addClass('wl-input-error'); return; } + } + input.removeClass('wl-input-error'); + void (async () => { + const curr = this.getItem(); + if (!curr) return; + if (this.mode === 'book') { + await this.readingData.updateBook({ ...(curr as Book), releaseDate: parsed }); + } else { + await this.readingData.updateManga({ ...(curr as Manga), releaseDate: parsed }); + } + this.onChanged(); + // Status may have flipped to/from "To be released". + this.renderAll(); + })(); + }); + } + + private makeDetailCell(parent: HTMLElement, label: string): HTMLElement { + const cell = parent.createDiv({ cls: 'wl-reading-detail-cell' }); + cell.createDiv({ cls: 'wl-reading-detail-cell-label', text: label }); + const value = cell.createDiv({ cls: 'wl-reading-detail-cell-value' }); + return value; + } + + private renderEditableDateCell( + parent: HTMLElement, + label: string, + getValue: () => string | null, + save: (val: string | null) => Promise<void>, + ): void { + const cell = parent.createDiv({ cls: 'wl-reading-detail-cell' }); + cell.createDiv({ cls: 'wl-reading-detail-cell-label', text: label }); + const valueArea = cell.createDiv({ cls: 'wl-reading-detail-cell-date' }); + + const todayBtn = valueArea.createEl('button', { + cls: 'wl-btn wl-btn-sm wl-reading-detail-today-btn', + text: 'Today', + attr: { title: 'Fill with today\'s date' }, + }); + const dateInput = valueArea.createEl('input', { + cls: 'wl-reading-detail-date-input', + attr: { type: 'text', placeholder: 'Dd/mm/yyyy', maxlength: '10' }, + }); + const curVal = getValue(); + dateInput.value = curVal ? formatDateDisplay(curVal) : ''; + + const refreshTodayBtn = (): void => { + todayBtn.toggleClass('is-dimmed', !!dateInput.value.trim()); + }; + refreshTodayBtn(); + + todayBtn.addEventListener('click', () => { + if (dateInput.value.trim()) return; + const now = new Date(); + const dd = String(now.getDate()).padStart(2, '0'); + const mm = String(now.getMonth() + 1).padStart(2, '0'); + dateInput.value = `${dd}/${mm}/${now.getFullYear()}`; + refreshTodayBtn(); + dateInput.dispatchEvent(new Event('change')); + }); + + dateInput.addEventListener('change', () => { + const raw = dateInput.value.trim(); + if (!raw) { + dateInput.removeClass('wl-input-error'); + refreshTodayBtn(); + void save(null); + return; + } + const parsed = parseDateInput(raw); + if (!parsed) { + dateInput.addClass('wl-input-error'); + return; + } + dateInput.removeClass('wl-input-error'); + refreshTodayBtn(); + void save(parsed); + }); + } + + private renderFooter(item: Book | Manga): void { + const footer = this.contentEl.createDiv({ cls: 'wl-reading-detail-footer' }); + + const left = footer.createDiv({ cls: 'wl-reading-detail-footer-left' }); + const deleteBtn = left.createEl('button', { + cls: 'wl-delete-btn wl-btn-danger wl-reading-detail-delete', + text: 'Delete', + }); + deleteBtn.addEventListener('click', () => { + new ConfirmModal( + this.plugin.app, + `Delete "${item.title}"?`, + () => { + void (async () => { + if (this.mode === 'book') { + await this.readingData.removeBook(item.id); + } else { + await this.readingData.removeManga(item.id); + } + this.onChanged(); + this.close(); + })(); + }, + ).open(); + }); + + const right = footer.createDiv({ cls: 'wl-reading-detail-footer-right' }); + + const updateBtn = right.createEl('button', { + cls: 'wl-reading-add-btn wl-btn-success', + text: 'Update progress', + }); + updateBtn.addEventListener('click', () => void this.commitDraft()); + } + + private getColumns(): ReadingCustomColumn[] { + return this.mode === 'book' + ? this.readingData.getBookColumns() + : this.readingData.getMangaColumns(); + } + + private openManageColumns(): void { + new ReadingManageColumnsModal( + this.plugin.app, + this.plugin, + this.readingData, + this.mode, + () => this.renderAll(), + ).open(); + } + + private renderCustomFieldsSection(item: Book | Manga): void { + const section = this.contentEl.createDiv({ cls: 'wl-reading-detail-custom' }); + + const heading = section.createDiv({ cls: 'wl-reading-detail-custom-heading' }); + heading.createSpan({ cls: 'wl-reading-section-label', text: 'Custom fields' }); + const manageLink = heading.createEl('a', { + cls: 'wl-reading-detail-custom-manage', + text: 'Manage', + }); + manageLink.addEventListener('click', (e) => { + e.preventDefault(); + this.openManageColumns(); + }); + + const cols = this.getColumns(); + if (cols.length === 0) { + const empty = section.createDiv({ cls: 'wl-reading-detail-custom-empty' }); + empty.createSpan({ text: 'No custom fields — add some via ' }); + const link = empty.createEl('a', { + cls: 'wl-reading-detail-custom-manage', + text: 'Manage', + }); + link.addEventListener('click', (e) => { + e.preventDefault(); + this.openManageColumns(); + }); + empty.createSpan({ text: '.' }); + return; + } + + const settings = this.readingData.getSettings(); + const fieldStyle = this.mode === 'book' + ? (settings.bookCustomFieldStyle ?? 'fill') + : (settings.mangaCustomFieldStyle ?? 'fill'); + const tableCls = fieldStyle === 'border' + ? 'wl-reading-custom-table is-border-mode' + : 'wl-reading-custom-table'; + const table = section.createDiv({ cls: tableCls }); + for (const col of cols) { + this.renderCustomFieldRow(table, item, col); + } + } + + private renderCustomFieldRow(parent: HTMLElement, item: Book | Manga, col: ReadingCustomColumn): void { + const row = parent.createDiv({ cls: 'wl-reading-custom-row' }); + const labelCell = row.createDiv({ cls: 'wl-reading-custom-label', text: col.name }); + const valueCell = row.createDiv({ cls: 'wl-reading-custom-value' }); + + const settings = this.readingData.getSettings(); + const style = this.mode === 'book' + ? (settings.bookCustomFieldStyle ?? 'fill') + : (settings.mangaCustomFieldStyle ?? 'fill'); + const color600 = col.color ?? DEFAULT_FIELD_COLOR; + + if (style === 'fill') { + const color50 = FIELD_COLOR_50[color600] ?? '#F1EFE8'; + labelCell.style.backgroundColor = color50; + labelCell.style.color = '#1e1e1e'; + } else { + labelCell.style.border = `1.5px solid ${color600}`; + labelCell.style.boxSizing = 'border-box'; + valueCell.style.border = '0.5px solid var(--background-modifier-border)'; + valueCell.style.boxSizing = 'border-box'; + } + + const raw = item.customFields?.[col.id]; + this.renderCustomValueDisplay(valueCell, item, col, raw); + } + + private renderCustomValueDisplay( + cell: HTMLElement, + item: Book | Manga, + col: ReadingCustomColumn, + raw: string | number | undefined, + ): void { + cell.empty(); + const hasValue = raw !== undefined && raw !== null && raw !== ''; + const display = cell.createSpan({ + cls: `wl-reading-custom-display${hasValue ? '' : ' is-placeholder'}`, + text: hasValue ? String(raw) : '—', + }); + display.addEventListener('click', () => { + this.renderCustomValueEditor(cell, item, col, raw); + }); + } + + private renderCustomValueEditor( + cell: HTMLElement, + item: Book | Manga, + col: ReadingCustomColumn, + current: string | number | undefined, + ): void { + cell.empty(); + if (col.type === 'select') { + const select = cell.createEl('select', { cls: 'wl-select wl-reading-custom-input' }); + const blank = select.createEl('option', { value: '', text: '—' }); + if (current === undefined || current === null || current === '') blank.selected = true; + for (const opt of col.options) { + const o = select.createEl('option', { value: opt, text: opt }); + if (String(current ?? '') === opt) o.selected = true; + } + const commit = (): void => { + const v = select.value; + void this.saveCustomField(item, col, v === '' ? null : v).then(() => { + const fresh = this.getItem(); + this.renderCustomValueDisplay(cell, fresh ?? item, col, fresh?.customFields?.[col.id]); + }); + }; + select.addEventListener('change', commit); + select.addEventListener('blur', commit); + select.focus(); + return; + } + + const input = cell.createEl('input', { + cls: 'wl-modal-input wl-reading-custom-input', + attr: { + type: col.type === 'number' ? 'number' : 'text', + placeholder: 'Enter value…', + }, + }); + if (current !== undefined && current !== null) input.value = String(current); + + const commit = (): void => { + const raw = input.value.trim(); + let value: string | number | null; + if (raw === '') { + value = null; + } else if (col.type === 'number') { + const n = Number(raw); + value = isNaN(n) ? null : n; + } else { + value = raw; + } + void this.saveCustomField(item, col, value).then(() => { + const fresh = this.getItem(); + this.renderCustomValueDisplay(cell, fresh ?? item, col, fresh?.customFields?.[col.id]); + }); + }; + input.addEventListener('blur', commit); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + input.blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + const fresh = this.getItem(); + this.renderCustomValueDisplay(cell, fresh ?? item, col, fresh?.customFields?.[col.id]); + } + }); + input.focus(); + input.select(); + } + + private async saveCustomField( + item: Book | Manga, + col: ReadingCustomColumn, + value: string | number | null, + ): Promise<void> { + await this.readingData.setCustomField(this.mode, item.id, col.id, value); + } + + private async renderQuotesSection(item: Book | Manga): Promise<void> { + const section = this.contentEl.createDiv({ cls: 'wl-reading-detail-quotes' }); + + const heading = section.createDiv({ cls: 'wl-reading-detail-quotes-heading' }); + heading.createSpan({ cls: 'wl-reading-section-label', text: 'Favorite quotes' }); + const addBtn = heading.createEl('button', { + cls: 'wl-btn wl-btn-sm wl-reading-detail-quotes-add', + text: 'Add quote', + }); + + const listEl = section.createDiv({ cls: 'wl-reading-quote-list' }); + const formHost = section.createDiv({ cls: 'wl-reading-quote-form-host' }); + + const refresh = async (): Promise<void> => { + listEl.empty(); + try { + const content = await this.readingData.readReadingNote(this.mode, item); + const quotes = content ? parseQuotesSection(content) : []; + if (quotes.length === 0) { + listEl.createDiv({ + cls: 'wl-reading-quote-empty', + text: 'No quotes yet.', + }); + } else { + for (const q of quotes) { + this.renderQuoteCard(listEl, q); + } + } + } catch (e) { + console.warn('[WL] quotes read failed:', e); + } + }; + + addBtn.addEventListener('click', () => { + formHost.empty(); + this.renderAddQuoteForm(formHost, item, async () => { + await refresh(); + }); + }); + + void refresh(); + } + + private renderQuoteCard(parent: HTMLElement, q: ParsedQuote): void { + const card = parent.createDiv({ cls: 'wl-reading-quote-card' }); + card.createDiv({ cls: 'wl-reading-quote-text', text: q.text }); + if (q.reference) { + card.createDiv({ cls: 'wl-reading-quote-ref', text: q.reference }); + } + } + + private renderAddQuoteForm( + parent: HTMLElement, + item: Book | Manga, + onSaved: () => Promise<void>, + ): void { + parent.empty(); + const form = parent.createDiv({ cls: 'wl-reading-quote-form' }); + + const textarea = form.createEl('textarea', { + cls: 'wl-modal-input wl-reading-quote-textarea', + attr: { placeholder: 'Quote text…', rows: '3' }, + }); + + const refInput = form.createEl('input', { + cls: 'wl-modal-input wl-reading-quote-ref-input', + attr: { type: 'text', placeholder: 'p. 123 or ch. 5 (optional)' }, + }); + + const actions = form.createDiv({ cls: 'wl-reading-quote-form-actions' }); + const cancelBtn = actions.createEl('button', { cls: 'wl-btn wl-btn-sm', text: 'Cancel' }); + cancelBtn.addEventListener('click', () => parent.empty()); + + const saveBtn = actions.createEl('button', { + cls: 'wl-reading-add-btn wl-btn-success wl-reading-quote-save', + text: 'Save quote', + }); + saveBtn.addEventListener('click', () => { + const text = textarea.value.trim(); + if (!text) { + new Notice('Enter the quote text first.'); + return; + } + void (async () => { + try { + await this.readingData.appendQuote(this.mode, item, text, refInput.value); + parent.empty(); + await onSaved(); + } catch (e) { + console.warn('[WL] appendQuote failed:', e); + new Notice('Could not save quote.'); + } + })(); + }); + + textarea.focus(); + } + + private async commitDraft(): Promise<void> { + const item = this.getItem(); + if (!item) return; + + if (this.mode === 'book') { + const book = item as Book; + const updated: Book = { + ...book, + pagesRead: this.draft.pagesRead, + chaptersRead: this.draft.chaptersRead, + }; + this.applyAutoComplete(updated); + await this.readingData.updateBook(updated); + } else { + const manga = item as Manga; + const updated: Manga = { + ...manga, + chaptersRead: this.draft.chaptersRead, + volumesRead: this.draft.volumesRead, + }; + this.applyAutoComplete(updated); + await this.readingData.updateManga(updated); + } + + this.logProgressChange(item); + this.openSnapshot = { ...this.draft }; + + new Notice('Progress updated.'); + this.onChanged(); + this.close(); + } + + private applyAutoComplete(item: Book | Manga): void { + const isComplete = this.mode === 'book' + ? this.isBookComplete(item as Book) + : this.isMangaComplete(item as Manga); + if (isComplete && item.status !== 'Completed') { + item.status = 'Completed'; + } + if (item.status === 'Completed' && !item.dateFinished) { + const now = new Date(); + const yyyy = now.getFullYear(); + const mm = String(now.getMonth() + 1).padStart(2, '0'); + const dd = String(now.getDate()).padStart(2, '0'); + item.dateFinished = `${yyyy}-${mm}-${dd}`; + } + } + + private isBookComplete(book: Book): boolean { + if (book.totalPages > 0 && book.pagesRead >= book.totalPages) return true; + if (book.totalChapters > 0 && book.chaptersRead >= book.totalChapters) return true; + return false; + } + + private isMangaComplete(manga: Manga): boolean { + if (manga.totalChapters > 0 && manga.chaptersRead >= manga.totalChapters) return true; + return false; + } +} diff --git a/src/ReadingManageColumnsModal.ts b/src/ReadingManageColumnsModal.ts new file mode 100644 index 0000000..042444d --- /dev/null +++ b/src/ReadingManageColumnsModal.ts @@ -0,0 +1,350 @@ +import { App, Modal, Notice } from 'obsidian'; +import type WatchLogPlugin from './main'; +import type { ReadingDataManager } from './ReadingDataManager'; +import { ReadingCustomColumn, FIELD_COLORS, DEFAULT_FIELD_COLOR } from './types'; +import { ConfirmModal } from './ConfirmModal'; + +type Kind = 'book' | 'manga'; + +export class ReadingManageColumnsModal extends Modal { + private plugin: WatchLogPlugin; + private readingData: ReadingDataManager; + private kind: Kind; + private onChanged: () => void; + private listEl: HTMLElement | null = null; + private styleGroupEl: HTMLElement | null = null; + private dragFromIndex = -1; + + constructor( + app: App, + plugin: WatchLogPlugin, + readingData: ReadingDataManager, + kind: Kind, + onChanged: () => void, + ) { + super(app); + this.plugin = plugin; + this.readingData = readingData; + this.kind = kind; + this.onChanged = onChanged; + } + + onOpen(): void { + const colorTheme = this.plugin.settings.colorTheme ?? 'default'; + this.modalEl.setAttribute('data-theme', colorTheme); + this.contentEl.setAttribute('data-theme', colorTheme); + this.contentEl.addClass('wl-view'); + this.contentEl.addClass('wl-reading-modal'); + this.contentEl.addClass('wl-reading-manage-cols'); + this.titleEl.setText(this.kind === 'book' ? 'Manage book fields' : 'Manage manga fields'); + this.build(); + } + + onClose(): void { + this.contentEl.empty(); + } + + private build(): void { + const c = this.contentEl; + c.empty(); + + const header = c.createDiv({ cls: 'wl-reading-modal-header' }); + header.createSpan({ + cls: 'wl-reading-modal-header-icon', + text: this.kind === 'book' ? '📖' : '📓', + }); + header.createSpan({ + cls: 'wl-reading-modal-header-title', + text: this.kind === 'book' ? 'Manage book fields' : 'Manage manga fields', + }); + + c.createDiv({ + cls: 'wl-reading-manage-cols-desc', + text: 'Custom fields appear in the title detail modal. Changes save automatically.', + }); + + this.renderStyleToggle(c); + + this.listEl = c.createDiv({ cls: 'wl-reading-manage-cols-list' }); + this.renderList(); + + c.createDiv({ cls: 'wl-reading-modal-divider' }); + this.renderAddSection(c); + + const footer = c.createDiv({ cls: 'wl-reading-modal-footer' }); + const closeBtn = footer.createEl('button', { cls: 'wl-btn', text: 'Close' }); + closeBtn.addEventListener('click', () => this.close()); + } + + private renderStyleToggle(parent: HTMLElement): void { + const settings = this.readingData.getSettings(); + const currentStyle = this.kind === 'book' + ? (settings.bookCustomFieldStyle ?? 'fill') + : (settings.mangaCustomFieldStyle ?? 'fill'); + + const row = parent.createDiv({ cls: 'wl-reading-cols-style-row' }); + row.createSpan({ cls: 'wl-reading-cols-style-label', text: 'Display style:' }); + + this.styleGroupEl = row.createDiv({ cls: 'wl-reading-cols-style-group' }); + + for (const opt of ['fill', 'border'] as const) { + const btn = this.styleGroupEl.createEl('button', { + cls: `wl-reading-cols-style-btn${currentStyle === opt ? ' is-active' : ''}`, + text: opt.charAt(0).toUpperCase() + opt.slice(1), + }); + btn.addEventListener('click', () => { + void (async () => { + const key = this.kind === 'book' ? 'bookCustomFieldStyle' : 'mangaCustomFieldStyle'; + await this.readingData.updateSettings({ [key]: opt }); + this.onChanged(); + this.styleGroupEl?.querySelectorAll('.wl-reading-cols-style-btn').forEach((b) => { + b.removeClass('is-active'); + }); + btn.addClass('is-active'); + })(); + }); + } + } + + private getColumns(): ReadingCustomColumn[] { + return this.kind === 'book' + ? this.readingData.getBookColumns() + : this.readingData.getMangaColumns(); + } + + private renderList(): void { + if (!this.listEl) return; + this.listEl.empty(); + const cols = this.getColumns(); + + if (cols.length === 0) { + this.listEl.createDiv({ + cls: 'wl-reading-manage-cols-empty', + text: 'No custom fields yet. Add one below.', + }); + return; + } + + cols.forEach((col, idx) => { + const card = this.listEl!.createDiv({ cls: 'wl-editcol-card wl-reading-col-card' }); + + const handle = card.createDiv({ cls: 'wl-editcol-card-handle', text: '⠿' }); + handle.title = 'Drag to reorder'; + handle.addEventListener('mousedown', () => card.setAttribute('draggable', 'true')); + card.addEventListener('dragstart', (e) => { + this.dragFromIndex = idx; + e.dataTransfer?.setData('text/plain', String(idx)); + card.addClass('wl-cl-dragging'); + }); + card.addEventListener('dragend', () => { + card.removeClass('wl-cl-dragging'); + card.setAttribute('draggable', 'false'); + }); + card.addEventListener('dragover', (e) => { + e.preventDefault(); + card.addClass('wl-cl-drag-over'); + }); + card.addEventListener('dragleave', () => card.removeClass('wl-cl-drag-over')); + card.addEventListener('drop', (e) => { + e.preventDefault(); + card.removeClass('wl-cl-drag-over'); + const from = this.dragFromIndex; + const to = idx; + this.dragFromIndex = -1; + if (from === to || from < 0) return; + void this.reorder(from, to); + }); + + const nameInput = card.createEl('input', { + cls: 'wl-modal-input wl-editcol-card-name', + attr: { type: 'text', placeholder: 'Field name' }, + }); + nameInput.value = col.name; + nameInput.addEventListener('change', () => { + const next = nameInput.value.trim(); + if (!next) { + nameInput.value = col.name; + return; + } + if (next === col.name) return; + void this.updateColumn({ ...col, name: next }); + }); + + const typeSelect = card.createEl('select', { cls: 'wl-select wl-editcol-card-type' }); + for (const t of ['text', 'number', 'select'] as const) { + const opt = typeSelect.createEl('option', { + value: t, + text: t.charAt(0).toUpperCase() + t.slice(1), + }); + if (col.type === t) opt.selected = true; + } + + const colorDot = card.createEl('button', { cls: 'wl-reading-col-color-dot' }); + colorDot.style.backgroundColor = col.color ?? DEFAULT_FIELD_COLOR; + colorDot.title = 'Choose field color'; + colorDot.addEventListener('click', (e) => { + e.stopPropagation(); + this.openColorPalette(colorDot, col); + }); + + const optsInput = card.createEl('input', { + cls: 'wl-modal-input wl-reading-col-opts-input', + attr: { type: 'text', placeholder: 'Comma-separated values' }, + }); + optsInput.value = col.options.join(', '); + optsInput.style.visibility = col.type === 'select' ? '' : 'hidden'; + optsInput.addEventListener('change', () => { + const parsed = optsInput.value + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + void this.updateColumn({ ...col, options: parsed }); + }); + + typeSelect.addEventListener('change', () => { + const type = typeSelect.value as ReadingCustomColumn['type']; + optsInput.style.visibility = type === 'select' ? '' : 'hidden'; + void this.updateColumn({ ...col, type }); + }); + + const delBtn = card.createEl('button', { + cls: 'wl-btn wl-btn-sm wl-editcol-card-del', + text: '✕', + }); + delBtn.title = 'Delete this field'; + delBtn.addEventListener('click', () => { + new ConfirmModal( + this.plugin.app, + `Delete field "${col.name}"? This removes its data from every ${ + this.kind === 'book' ? 'book' : 'manga' + } entry.`, + () => void this.deleteColumn(col.id), + ).open(); + }); + }); + } + + private openColorPalette(anchor: HTMLElement, col: ReadingCustomColumn): void { + // Remove any existing palette + this.contentEl.querySelectorAll('.wl-reading-col-palette').forEach((el) => el.remove()); + + const rect = anchor.getBoundingClientRect(); + const palette = this.contentEl.createDiv({ cls: 'wl-reading-col-palette' }); + palette.style.top = `${rect.bottom + 4}px`; + palette.style.left = `${rect.left}px`; + // Capture the owning document once so add/remove can't desync across popout windows. + const doc = this.contentEl.ownerDocument; + + const currentColor = col.color ?? DEFAULT_FIELD_COLOR; + for (const { color600, color50 } of FIELD_COLORS) { + const swatch = palette.createEl('button', { cls: 'wl-reading-col-palette-swatch' }); + swatch.style.backgroundColor = color50; + swatch.style.borderColor = color600; + if (currentColor === color600) swatch.addClass('is-active'); + swatch.title = color600; + swatch.addEventListener('click', (e) => { + e.stopPropagation(); + palette.remove(); + void this.updateColumn({ ...col, color: color600 }); + }); + } + + const close = (e: MouseEvent): void => { + if (!palette.contains(e.target as Node) && e.target !== anchor) { + palette.remove(); + doc.removeEventListener('mousedown', close, true); + } + }; + window.setTimeout(() => doc.addEventListener('mousedown', close, true), 0); + } + + private renderAddSection(parent: HTMLElement): void { + const wrap = parent.createDiv({ cls: 'wl-reading-add-col' }); + wrap.createDiv({ cls: 'wl-reading-section-label', text: 'Add field' }); + + const row = wrap.createDiv({ cls: 'wl-reading-add-col-row' }); + + const nameInput = row.createEl('input', { + cls: 'wl-modal-input wl-reading-add-col-name', + attr: { type: 'text', placeholder: 'Field name' }, + }); + + const typeSelect = row.createEl('select', { cls: 'wl-select wl-reading-add-col-type' }); + for (const t of ['text', 'number', 'select'] as const) { + typeSelect.createEl('option', { + value: t, + text: t.charAt(0).toUpperCase() + t.slice(1), + }); + } + + const optsInput = row.createEl('input', { + cls: 'wl-modal-input wl-reading-add-col-opts', + attr: { + type: 'text', + placeholder: 'Options (comma-separated)', + }, + }); + const refreshOpts = (): void => { + optsInput.style.display = typeSelect.value === 'select' ? '' : 'none'; + }; + typeSelect.addEventListener('change', refreshOpts); + refreshOpts(); + + const addBtn = row.createEl('button', { + cls: 'wl-reading-add-btn wl-btn-success wl-reading-add-col-btn', + text: 'Add', + }); + addBtn.addEventListener('click', () => { + const name = nameInput.value.trim(); + if (!name) { + new Notice('Enter a field name first.'); + return; + } + const type = typeSelect.value as ReadingCustomColumn['type']; + const options = + type === 'select' + ? optsInput.value + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : []; + const col: ReadingCustomColumn = { + id: this.readingData.generateColumnId(this.kind, name), + name, + type, + options, + color: DEFAULT_FIELD_COLOR, + }; + void this.addColumn(col).then(() => { + nameInput.value = ''; + optsInput.value = ''; + typeSelect.value = 'text'; + refreshOpts(); + }); + }); + } + + private async addColumn(col: ReadingCustomColumn): Promise<void> { + await this.readingData.addColumn(this.kind, col); + this.renderList(); + this.onChanged(); + } + + private async updateColumn(col: ReadingCustomColumn): Promise<void> { + await this.readingData.updateColumn(this.kind, col); + this.renderList(); + this.onChanged(); + } + + private async deleteColumn(id: string): Promise<void> { + await this.readingData.removeColumn(this.kind, id); + this.renderList(); + this.onChanged(); + } + + private async reorder(from: number, to: number): Promise<void> { + await this.readingData.reorderColumns(this.kind, from, to); + this.renderList(); + this.onChanged(); + } +} diff --git a/src/ReadingScheduleModal.ts b/src/ReadingScheduleModal.ts new file mode 100644 index 0000000..ac99a93 --- /dev/null +++ b/src/ReadingScheduleModal.ts @@ -0,0 +1,210 @@ +import { App, Modal, Notice } from 'obsidian'; +import type { Book, Manga, AirtimeSchedule, AirtimeRecurrence } from './types'; +import { parseDateInput } from './types'; + +/** + * Schedule modal for Reading titles (Books & Manga). Derived from the watchlist + * AirtimeScheduleModal's series branch — same recurrence + auto-increment logic — + * but with: + * - no "Time (HH:MM)" field, + * - chapter/volume counters instead of episode/season counters. + * + * The four counters map onto the shared AirtimeEntry slots in AirtimeTab: + * current volume ↔ currentSeason total volumes ↔ totalSeasons + * current chapter ↔ currentEpisode total chapters ↔ totalEpisodes + * so reading entries reuse the existing scheduler/countdown machinery unchanged. + */ +export class ReadingScheduleModal extends Modal { + private item: Book | Manga; + private kind: 'book' | 'manga'; + private schedule: AirtimeSchedule; + private currentVolume: number | null; + private currentChapter: number | null; + private totalVolumes: number | null; + private totalChapters: number | null; + private onSave: ( + schedule: AirtimeSchedule, + volume: number | null, + chapter: number | null, + totalVolumes: number | null, + totalChapters: number | null, + ) => Promise<void>; + + constructor( + app: App, + item: Book | Manga, + kind: 'book' | 'manga', + existingSchedule: AirtimeSchedule | null, + currentVolume: number | null, + currentChapter: number | null, + totalVolumes: number | null, + totalChapters: number | null, + onSave: ( + schedule: AirtimeSchedule, + volume: number | null, + chapter: number | null, + totalVolumes: number | null, + totalChapters: number | null, + ) => Promise<void>, + ) { + super(app); + this.item = item; + this.kind = kind; + this.schedule = existingSchedule ? { ...existingSchedule } : { recurrence: 'once' }; + // Time is never used for reading; strip any inherited value. + this.schedule.releaseTime = undefined; + // Pre-fill releaseDate from the item if the schedule doesn't have one yet. + if (!this.schedule.releaseDate && item.releaseDate && /^\d{4}-\d{2}-\d{2}$/.test(item.releaseDate)) { + this.schedule.releaseDate = item.releaseDate; + } + this.currentVolume = currentVolume; + this.currentChapter = currentChapter; + // Pre-fill totals from the item's tracked counts if not yet set. + const itemTotalChapters = item.totalChapters ?? 0; + const itemTotalVolumes = kind === 'manga' ? ((item as Manga).totalVolumes ?? 0) : 0; + this.totalChapters = totalChapters ?? (itemTotalChapters > 0 ? itemTotalChapters : null); + this.totalVolumes = totalVolumes ?? (itemTotalVolumes > 0 ? itemTotalVolumes : null); + this.onSave = onSave; + } + + onOpen(): void { + this.titleEl.setText('Set reading schedule'); + this.contentEl.addClass('wl-add-modal'); + this.renderForm(); + } + + onClose(): void { + this.contentEl.empty(); + } + + private renderForm(): void { + this.contentEl.empty(); + this.contentEl.addClass('wl-add-modal'); + const content = this.contentEl; + + const makeRow = (label: string): HTMLElement => { + const row = content.createDiv({ cls: 'wl-modal-row' }); + row.createSpan({ cls: 'wl-modal-label', text: label }); + return row; + }; + + const makeNumberRow = ( + label: string, + value: number | null, + placeholder: string, + onInput: (v: number | null) => void, + ): void => { + const row = makeRow(label); + const inp = row.createEl('input', { + cls: 'wl-modal-input wl-modal-input-sm', + attr: { type: 'number', min: '0', placeholder }, + }); + if (value !== null) inp.value = String(value); + inp.addEventListener('input', () => { onInput(parseInt(inp.value) || null); }); + }; + + // ── Recurrence (no time field) ────────────────────────────────────────────── + const recRow = makeRow('Recurrence'); + const recSelect = recRow.createEl('select', { cls: 'wl-select' }); + const recOptions: Array<[AirtimeRecurrence, string]> = [ + ['once', 'Once'], + ['daily', 'Daily'], + ['weekly', 'Weekly'], + ['monthly', 'Monthly'], + ]; + for (const [val, label] of recOptions) { + const opt = recSelect.createEl('option', { text: label, value: val }); + if (val === this.schedule.recurrence) opt.selected = true; + } + + const extraEl = content.createDiv(); + const renderExtra = (): void => { + extraEl.empty(); + const rec = this.schedule.recurrence; + + if (rec === 'once') { + const r = extraEl.createDiv({ cls: 'wl-modal-row' }); + r.createSpan({ cls: 'wl-modal-label', text: 'Date' }); + const inp = r.createEl('input', { + cls: 'wl-modal-input', + attr: { type: 'text', placeholder: 'Dd/mm/yyyy', maxlength: '10' }, + }); + inp.value = this.schedule.releaseDate + ? this.schedule.releaseDate.split('-').reverse().join('/') + : ''; + inp.addEventListener('change', () => { + const parsed = parseDateInput(inp.value); + if (parsed) this.schedule.releaseDate = parsed; + }); + } + + if (rec === 'weekly') { + const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + const r = extraEl.createDiv({ cls: 'wl-modal-row' }); + r.createSpan({ cls: 'wl-modal-label', text: 'Day of week' }); + const daySelect = r.createEl('select', { cls: 'wl-select' }); + DAYS.forEach((d, i) => { + const opt = daySelect.createEl('option', { text: d, value: String(i) }); + if (i === (this.schedule.dayOfWeek ?? 6)) opt.selected = true; + }); + daySelect.addEventListener('change', () => { + this.schedule.dayOfWeek = parseInt(daySelect.value); + }); + } + + if (rec === 'monthly') { + const r = extraEl.createDiv({ cls: 'wl-modal-row' }); + r.createSpan({ cls: 'wl-modal-label', text: 'Day of month' }); + const inp = r.createEl('input', { + cls: 'wl-modal-input wl-modal-input-sm', + attr: { type: 'number', min: '1', max: '31', placeholder: '1' }, + }); + inp.value = String(this.schedule.dayOfMonth ?? 1); + inp.addEventListener('input', () => { this.schedule.dayOfMonth = parseInt(inp.value) || 1; }); + } + }; + + recSelect.addEventListener('change', () => { + this.schedule.recurrence = recSelect.value as AirtimeRecurrence; + renderExtra(); + }); + renderExtra(); + + // ── Chapter / volume counters ─────────────────────────────────────────────── + // Books typically use only chapters; Manga use chapters + volumes. The user + // fills whichever applies and leaves the rest empty. + makeNumberRow('Current chapter', this.currentChapter, 'E.g. 12', (v) => { this.currentChapter = v; }); + makeNumberRow('Current volume', this.currentVolume, 'E.g. 2', (v) => { this.currentVolume = v; }); + makeNumberRow('Total chapters', this.totalChapters, 'E.g. 120', (v) => { this.totalChapters = v; }); + makeNumberRow('Total volumes', this.totalVolumes, 'E.g. 12', (v) => { this.totalVolumes = v; }); + + // Static hint note (adapted for reading). + content.createDiv({ + cls: 'wl-modal-info wl-schedule-hint', + text: 'Titles with 0 or 1 total chapters will be treated as a single release date, like a book.', + }); + + // ── Buttons ───────────────────────────────────────────────────────────────── + const btnRow = content.createDiv({ cls: 'wl-modal-btn-row' }); + const cancelBtn = btnRow.createEl('button', { cls: 'wl-btn wl-btn-mr', text: 'Cancel' }); + cancelBtn.addEventListener('click', () => this.close()); + + const saveBtn = btnRow.createEl('button', { cls: 'wl-btn wl-btn-primary', text: 'Save' }); + saveBtn.addEventListener('click', () => { + void (async () => { + if (this.schedule.recurrence === 'once' && !this.schedule.releaseDate) { + new Notice('Set a release date.'); + return; + } + await this.onSave( + this.schedule, + this.currentVolume, + this.currentChapter, + this.totalVolumes, + this.totalChapters, + ); + this.close(); + })(); + }); + } +} diff --git a/src/ReadingTab.ts b/src/ReadingTab.ts new file mode 100644 index 0000000..2e1e335 --- /dev/null +++ b/src/ReadingTab.ts @@ -0,0 +1,1503 @@ +import { Notice } from 'obsidian'; +import { googleBooksErrorMessage } from './ApiService'; +import Fuse from 'fuse.js'; +import type WatchLogPlugin from './main'; +import type { ReadingDataManager, ReadingKind } from './ReadingDataManager'; +import { Book, Manga, ReadingStatus, ReadingCustomColumn, ReadingSavedFilterPreset, READING_STATUSES, SELECTABLE_READING_STATUSES } from './types'; +import { AddReadingModal } from './AddReadingModal'; +import { ReadingDetailModal } from './ReadingDetailModal'; +import { ReadingManageColumnsModal } from './ReadingManageColumnsModal'; +import { ConfirmModal } from './ConfirmModal'; + +type SubTab = 'books' | 'manga'; + +// Built-in sort keys, plus dynamic `custom:<columnId>` keys for custom fields. +type SortKey = string; +type SortDir = 'asc' | 'desc'; + +// Synthetic option keys for the "Has value" / "No value" choices on text/number +// custom-field filter sections. +const HAS_VALUE = '__has__'; +const NO_VALUE = '__none__'; + +function customFieldHasValue(item: Book | Manga, columnId: string): boolean { + const v = item.customFields?.[columnId]; + return v !== undefined && v !== null && v !== ''; +} + +interface ReadingFilters { + // Excluded statuses (mirrors the Watchlist popover, where a ticked checkbox = + // included). Empty = every status shown. Converted to the preset's + // `statusInclude` shape only when a preset is saved/loaded. + statusExclude: ReadingStatus[]; + ratingMode: 'all' | 'has' | 'none'; + // Per custom-field exclusions keyed by column id. For 'select' columns the + // excluded values are option strings; for 'text'/'number' columns they are the + // synthetic HAS_VALUE / NO_VALUE predicate keys. + customExclude: Record<string, string[]>; +} + +interface ReadingSort { + key: SortKey; + dir: SortDir; + secondKey: SortKey; + secondDir: SortDir; +} + +// Staged filter edits (mirror of ReadingFilters but with Sets for in-place +// mutation while the popover is open). +interface FilterDraft { + statusExclude: Set<ReadingStatus>; + ratingMode: 'all' | 'has' | 'none'; + customExclude: Map<string, Set<string>>; +} + +// Lets the global Select all / Deselect all buttons drive a section's checkboxes. +interface SectionController { + selectAll: () => void; + deselectAll: () => void; +} + +function defaultFilters(): ReadingFilters { + return { statusExclude: [], ratingMode: 'all', customExclude: {} }; +} + +function defaultSort(): ReadingSort { + return { key: 'dateAdded', dir: 'desc', secondKey: 'none', secondDir: 'asc' }; +} + +function filtersActive(f: ReadingFilters): number { + let n = 0; + if (f.statusExclude.length > 0) n++; + if (f.ratingMode !== 'all') n++; + for (const k of Object.keys(f.customExclude)) { + if ((f.customExclude[k]?.length ?? 0) > 0) n++; + } + return n; +} + +export function readingStatusColor(status: ReadingStatus): string { + switch (status) { + case 'Reading': return '#1D9E75'; + case 'Completed': return '#7F77DD'; + case 'Plan to Read': return '#E8873A'; + case 'To be released': return '#3A86C8'; + case 'Dropped': return '#E24B4A'; + default: return '#888780'; + } +} + +const COVER_PALETTE = [ + '#3a4a6b', '#4a3a6b', '#6b3a4a', '#6b5a3a', + '#3a6b5a', '#5a6b3a', '#3a5a6b', '#6b3a5a', +]; + +export function coverFallbackColor(id: string): string { + let hash = 0; + for (let i = 0; i < id.length; i++) { + hash = (hash * 31 + id.charCodeAt(i)) >>> 0; + } + return COVER_PALETTE[hash % COVER_PALETTE.length] ?? '#3a4a6b'; +} + +function bookProgress(b: Book): number { + if (b.totalPages > 0) return Math.min(100, (b.pagesRead / b.totalPages) * 100); + return 0; +} + +function mangaProgress(m: Manga): number { + if (m.totalChapters > 0) return Math.min(100, (m.chaptersRead / m.totalChapters) * 100); + return 0; +} + +export class ReadingTab { + // Session-persistent state. Survives tab switches and view rebuilds + // within the same plugin load — resets only when the plugin unloads. + private static sessionSubTab: SubTab | null = null; + private static sessionFilters: Record<SubTab, ReadingFilters> = { + books: defaultFilters(), + manga: defaultFilters(), + }; + private static sessionSort: Record<SubTab, ReadingSort> = { + books: defaultSort(), + manga: defaultSort(), + }; + + private container: HTMLElement; + private plugin: WatchLogPlugin; + private readingData: ReadingDataManager; + private searchQuery = ''; + private dataChangeListener: () => void; + private contentEl: HTMLElement | null = null; + private filterBtn: HTMLButtonElement | null = null; + private sortBtn: HTMLButtonElement | null = null; + private resetBtn: HTMLButtonElement | null = null; + // Single saved filter (mirrors the Watchlist saved-filter flow). The external + // "Saved filter" button applies it; the in-popover button toggles Save/Delete. + private savedFilterActive = false; + private savedFilterBtnEl: HTMLButtonElement | null = null; + private openPopover: HTMLElement | null = null; + private popoverCleanup: (() => void) | null = null; + private activeCleanups: (() => void)[] = []; + + // Selection mode + private selectionMode = false; + private selectedIds: Set<string> = new Set(); + + // Virtual scroll state + private vsScrollContainer: HTMLElement | null = null; + private vsScrollSpacer: HTMLElement | null = null; + private vsGridEl: HTMLElement | null = null; + private vsDisplayItems: Array<Book | Manga> = []; + private vsScrollHandler: (() => void) | null = null; + private vsScrollRAF: number | null = null; + private vsResizeObserver: ResizeObserver | null = null; + private vsLastFirst = -1; + private vsLastLast = -1; + private vsLastScrollTop = 0; + private vsPersistentScrollTop = 0; + private vsRowHeight = 0; + + // Lazy cover re-fetch (mirrors the watch Cards IntersectionObserver pattern). + private coverObserver: IntersectionObserver | null = null; + private observedCovers: Set<HTMLElement> = new Set(); + // Items whose cover fetch already failed this session — don't retry on every scroll. + private coverFetchFailed: Set<string> = new Set(); + + private get activeSubTab(): SubTab { + if (ReadingTab.sessionSubTab !== null) return ReadingTab.sessionSubTab; + return this.readingData.getSettings().defaultSubTab ?? 'books'; + } + private set activeSubTab(value: SubTab) { + ReadingTab.sessionSubTab = value; + } + + private get filters(): ReadingFilters { + return ReadingTab.sessionFilters[this.activeSubTab]; + } + private get sort(): ReadingSort { + return ReadingTab.sessionSort[this.activeSubTab]; + } + + // Custom columns for the active sub-tab (Books → bookColumns, Manga → mangaColumns). + private activeColumns(): ReadingCustomColumn[] { + return this.activeSubTab === 'books' + ? this.readingData.getBookColumns() + : this.readingData.getMangaColumns(); + } + + constructor(container: HTMLElement, plugin: WatchLogPlugin, readingData: ReadingDataManager) { + this.container = container; + this.plugin = plugin; + this.readingData = readingData; + this.dataChangeListener = () => this.renderContent(); + this.readingData.onChange(this.dataChangeListener); + } + + destroy(): void { + this.readingData.offChange(this.dataChangeListener); + this.closePopover(); + this.destroyVirtualScroll(); + for (const fn of this.activeCleanups) fn(); + this.activeCleanups = []; + } + + render(): void { + this.container.empty(); + this.container.addClass('wl-reading-tab'); + this.renderSubTabs(); + this.renderToolbar(); + this.contentEl = this.container.createDiv({ cls: 'wl-reading-content' }); + this.renderContent(); + } + + private renderSubTabs(): void { + const bar = this.container.createDiv({ cls: 'wl-reading-subtabs' }); + + const booksBtn = bar.createEl('button', { + cls: `wl-reading-subtab${this.activeSubTab === 'books' ? ' is-active' : ''}`, + text: 'Books', + }); + + const mangaBtn = bar.createEl('button', { + cls: `wl-reading-subtab${this.activeSubTab === 'manga' ? ' is-active' : ''}`, + text: 'Manga', + }); + + booksBtn.addEventListener('click', () => { + if (this.activeSubTab === 'books') return; + this.activeSubTab = 'books'; + this.searchQuery = ''; + this.render(); + }); + + mangaBtn.addEventListener('click', () => { + if (this.activeSubTab === 'manga') return; + this.activeSubTab = 'manga'; + this.searchQuery = ''; + this.render(); + }); + } + + private renderToolbar(): void { + const toolbar = this.container.createDiv({ cls: 'wl-reading-toolbar' }); + + const left = toolbar.createDiv({ cls: 'wl-reading-toolbar-left' }); + + const searchWrap = left.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', + attr: { type: 'text', placeholder }, + }); + searchInput.value = this.searchQuery; + let debounce = 0; + searchInput.addEventListener('input', () => { + this.searchQuery = searchInput.value; + window.clearTimeout(debounce); + debounce = window.setTimeout(() => this.renderContent(), 200); + }); + + // 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', { + cls: `wl-btn wl-btn-sm${this.savedFilterActive ? ' wl-btn-preset-active' : ''}`, + text: 'Saved filter', + }); + this.savedFilterBtnEl = savedBtn; + savedBtn.addEventListener('click', () => { + ReadingTab.sessionFilters[this.activeSubTab] = { + statusExclude: READING_STATUSES.filter((s) => !savedFilter.statusInclude.includes(s)), + ratingMode: savedFilter.ratingMode, + customExclude: {}, + }; + this.savedFilterActive = true; + savedBtn.addClass('wl-btn-preset-active'); + this.refreshFilterButtonBadge(); + this.render(); + }); + } else { + this.savedFilterBtnEl = null; + } + + this.filterBtn = left.createEl('button', { + cls: 'wl-btn wl-btn-sm wl-reading-filter-btn', + text: 'Filter', + }); + this.refreshFilterButtonBadge(); + this.filterBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleFilterPopover(); + }); + + this.resetBtn = left.createEl('button', { + cls: 'wl-btn wl-btn-sm wl-reading-reset-btn', + text: 'Reset', + }); + this.resetBtn.addEventListener('click', () => { + ReadingTab.sessionFilters[this.activeSubTab] = defaultFilters(); + this.deactivateSavedFilter(); + this.closePopover(); + this.filterBtn?.removeClass('is-popover-open'); + this.refreshFilterButtonBadge(); + this.renderContent(); + }); + this.refreshFilterButtonBadge(); + + this.sortBtn = left.createEl('button', { + cls: 'wl-btn wl-btn-sm wl-reading-sort-btn', + text: 'Sort', + }); + this.sortBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleSortPopover(); + }); + + // Selection mode action bar + if (this.selectionMode && this.selectedIds.size > 0) { + this.renderSelectionActionBar(left); + } + + // Select All / Deselect All + if (this.selectionMode) { + const hasAny = this.selectedIds.size > 0; + const selAllBtn = left.createEl('button', { + cls: 'wl-btn wl-btn-sm', + text: hasAny ? 'None' : 'All', + }); + selAllBtn.title = hasAny ? 'Deselect all' : 'Select all visible'; + selAllBtn.addEventListener('click', () => { + if (this.selectedIds.size > 0) { + this.selectedIds.clear(); + } else { + for (const item of this.vsDisplayItems) { + this.selectedIds.add(item.id); + } + } + this.render(); + }); + } + + // Selection mode toggle + const selBtn = left.createEl('button', { + cls: `wl-btn wl-btn-sm${this.selectionMode ? ' is-active' : ''}`, + text: 'Select', + }); + selBtn.addEventListener('click', () => { + this.selectionMode = !this.selectionMode; + this.selectedIds.clear(); + this.render(); + }); + + const right = toolbar.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' }, + text: '⚙', + }); + manageBtn.addEventListener('click', () => this.openManageColumns()); + + // Add button sits at the far right, after the gear + const addBtn = right.createEl('button', { + cls: 'wl-reading-add-btn wl-btn-success', + text: this.activeSubTab === 'books' ? '+ Add book' : '+ Add manga', + }); + addBtn.addEventListener('click', () => this.openAddModal()); + } + + private renderSelectionActionBar(parent: HTMLElement): void { + const bar = parent.createDiv({ cls: 'wl-reading-action-bar' }); + + // Batch delete + const deleteBtn = bar.createEl('button', { + cls: 'wl-group-action-btn wl-group-action-btn-delete wl-btn-danger', + text: '✕', + }); + deleteBtn.title = 'Delete selected'; + deleteBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const count = this.selectedIds.size; + new ConfirmModal( + this.plugin.app, + `Delete ${count} selected item${count !== 1 ? 's' : ''}? This cannot be undone.`, + () => { + void (async () => { + const ids = Array.from(this.selectedIds); + if (this.activeSubTab === 'books') { + await this.readingData.removeBooksBatch(ids); + } else { + await this.readingData.removeMangaBatch(ids); + } + this.selectedIds.clear(); + this.selectionMode = false; + this.render(); + })(); + }, + ).open(); + }); + + // Batch status change + const statusSelect = bar.createEl('select', { cls: 'wl-select wl-select-sm' }); + statusSelect.createEl('option', { text: 'Status…', value: '' }); + for (const s of SELECTABLE_READING_STATUSES) { + statusSelect.createEl('option', { text: s, value: s }); + } + statusSelect.addEventListener('change', () => { + void (async () => { + const newStatus = statusSelect.value as ReadingStatus; + if (!newStatus) return; + const isBooks = this.activeSubTab === 'books'; + for (const id of this.selectedIds) { + if (isBooks) { + const book = this.readingData.getBook(id); + if (book) { + this.readingData.updateBookSilent({ ...book, status: newStatus }); + } + } else { + const manga = this.readingData.getManga(id); + if (manga) { + this.readingData.updateMangaSilent({ ...manga, status: newStatus }); + } + } + } + await this.readingData.saveAndNotify(); + statusSelect.value = ''; + this.selectedIds.clear(); + this.selectionMode = false; + this.render(); + })(); + }); + } + + private refreshFilterButtonBadge(): void { + if (!this.filterBtn) return; + const count = filtersActive(this.filters); + this.filterBtn.empty(); + this.filterBtn.createSpan({ text: 'Filter' }); + if (count > 0) { + this.filterBtn.createSpan({ cls: 'wl-reading-filter-badge', text: String(count) }); + } + if (this.resetBtn) { + this.resetBtn.style.display = count > 0 ? '' : 'none'; + } + } + + private closePopover(): void { + if (this.openPopover) { + this.openPopover.remove(); + this.openPopover = null; + } + if (this.popoverCleanup) { + this.popoverCleanup(); + this.popoverCleanup = null; + } + } + + private openPopoverAt(anchor: HTMLElement, cls: string, build: (el: HTMLElement) => void): void { + this.closePopover(); + const pop = this.container.createDiv({ cls }); + const rect = anchor.getBoundingClientRect(); + const parentRect = this.container.getBoundingClientRect(); + pop.style.position = 'absolute'; + pop.style.top = `${rect.bottom - parentRect.top + 4}px`; + pop.style.left = `${rect.left - parentRect.left}px`; + pop.style.right = 'auto'; + build(pop); + this.openPopover = pop; + + const onDocClick = (ev: MouseEvent): void => { + if (pop.contains(ev.target as Node)) return; + this.closePopover(); + }; + const onKey = (ev: KeyboardEvent): void => { + if (ev.key === 'Escape') this.closePopover(); + }; + window.setTimeout(() => activeDocument.addEventListener('click', onDocClick), 0); + activeDocument.addEventListener('keydown', onKey); + this.popoverCleanup = () => { + activeDocument.removeEventListener('click', onDocClick); + activeDocument.removeEventListener('keydown', onKey); + }; + } + + private toggleFilterPopover(): void { + if (this.openPopover && this.filterBtn?.hasClass('is-popover-open')) { + this.closePopover(); + this.filterBtn.removeClass('is-popover-open'); + return; + } + this.sortBtn?.removeClass('is-popover-open'); + this.filterBtn?.addClass('is-popover-open'); + this.openPopoverAt(this.filterBtn!, 'wl-dropdown wl-filters-panel', (el) => this.buildFilterPopover(el)); + } + + private toggleSortPopover(): void { + if (this.openPopover && this.sortBtn?.hasClass('is-popover-open')) { + this.closePopover(); + this.sortBtn.removeClass('is-popover-open'); + return; + } + this.filterBtn?.removeClass('is-popover-open'); + this.sortBtn?.addClass('is-popover-open'); + this.openPopoverAt(this.sortBtn!, 'wl-dropdown wl-sorting-panel', (el) => this.buildSortPopover(el)); + } + + // ── Saved filter (single, Watchlist-style) ───────────────────────────────── + private getSavedFilter(): ReadingSavedFilterPreset | null { + return this.readingData.getSettings().savedFilters?.[this.activeSubTab] ?? null; + } + + private filtersMatchSaved(saved: ReadingSavedFilterPreset): boolean { + const f = this.filters; + // A saved filter only captures status + rating, so any active custom-field + // filter means the live state no longer matches it. + if (Object.values(f.customExclude).some((arr) => arr.length > 0)) return false; + const savedExclude = new Set(READING_STATUSES.filter((s) => !saved.statusInclude.includes(s))); + const cur = new Set(f.statusExclude); + if (cur.size !== savedExclude.size) return false; + for (const s of cur) if (!savedExclude.has(s)) return false; + return f.ratingMode === saved.ratingMode; + } + + private deactivateSavedFilter(): void { + if (this.savedFilterActive) { + this.savedFilterActive = false; + this.savedFilterBtnEl?.removeClass('wl-btn-preset-active'); + } + } + + private buildFilterPopover(el: HTMLElement): void { + // Draft state — Reading's live filters are NOT mutated until Apply is + // clicked (the key behavioral parity with the Watchlist popover). Status + // and custom fields use exclude semantics for the UI; Status is converted + // to the preset's `statusInclude` shape only on save. + const draft: FilterDraft = { + statusExclude: new Set<ReadingStatus>(this.filters.statusExclude), + ratingMode: this.filters.ratingMode, + customExclude: new Map<string, Set<string>>(), + }; + for (const col of this.activeColumns()) { + draft.customExclude.set(col.id, new Set(this.filters.customExclude[col.id] ?? [])); + } + // Collapsed state persists across body re-renders. Keyed by section id + // (`Status`, `Rating`, `custom:<columnId>`); default collapsed. + const collapsed: Record<string, boolean> = {}; + this.renderFilterPopoverBody(el, draft, collapsed); + } + + // Builds a collapsible section shell (Watchlist-style header + chevron) and + // invokes `build` to fill its content. Returns nothing; the section is appended + // to `el`. + private addFilterSection( + el: HTMLElement, + collapsed: Record<string, boolean>, + id: string, + label: string, + build: (content: HTMLElement) => void, + ): void { + const section = el.createDiv({ cls: 'wl-filter-section' }); + const header = section.createDiv({ cls: 'wl-filter-section-header' }); + header.createSpan({ cls: 'wl-filter-label', text: label }); + const chevron = header.createSpan({ + cls: 'wl-filter-chevron', + text: (collapsed[id] ?? true) ? '▼' : '▲', + }); + const content = section.createDiv({ cls: 'wl-filter-section-content' }); + content.toggleClass('wl-hidden', collapsed[id] ?? true); + header.addEventListener('click', (ev) => { + ev.stopPropagation(); + const next = !(collapsed[id] ?? true); + collapsed[id] = next; + content.toggleClass('wl-hidden', next); + chevron.textContent = next ? '▼' : '▲'; + }); + build(content); + } + + // Multi-select section body (checkbox list + "◆ All" toggle), shared by Status + // and `select` custom fields. Returns a controller so the global Select all / + // Deselect all buttons can drive it. + private addMultiSelectBody( + content: HTMLElement, + options: string[], + excludeSet: Set<string>, + ): SectionController { + const cbs: HTMLInputElement[] = []; + const allRow = content.createDiv({ cls: 'wl-filter-checkbox-row' }); + const allCb = allRow.createEl('input', { attr: { type: 'checkbox' } }); + allRow.createSpan({ cls: 'wl-filter-all-toggle', text: '◆ All' }); + + const syncAll = (): void => { + allCb.checked = cbs.length > 0 && cbs.every((c) => c.checked); + }; + + for (const opt of options) { + const row = content.createDiv({ cls: 'wl-filter-checkbox-row' }); + const cb = row.createEl('input', { attr: { type: 'checkbox' } }); + cb.checked = !excludeSet.has(opt); + row.createSpan({ text: opt }); + cbs.push(cb); + cb.addEventListener('change', () => { + if (cb.checked) excludeSet.delete(opt); + else excludeSet.add(opt); + syncAll(); + }); + } + syncAll(); + + const toggleAll = (): void => { + const allChecked = cbs.length > 0 && cbs.every((c) => c.checked); + if (allChecked) { + for (const cb of cbs) cb.checked = false; + for (const o of options) excludeSet.add(o); + } else { + for (const cb of cbs) cb.checked = true; + excludeSet.clear(); + } + syncAll(); + }; + allCb.addEventListener('click', (ev) => { ev.stopPropagation(); toggleAll(); }); + allRow.addEventListener('click', (ev) => { + if (ev.target === allCb) return; + ev.stopPropagation(); + toggleAll(); + }); + + return { + selectAll: () => { for (const cb of cbs) cb.checked = true; excludeSet.clear(); syncAll(); }, + deselectAll: () => { for (const cb of cbs) cb.checked = false; for (const o of options) excludeSet.add(o); syncAll(); }, + }; + } + + // "Has value" / "No value" section body for text/number custom fields. + private addHasValueBody(content: HTMLElement, excludeSet: Set<string>): SectionController { + const entries: Array<{ key: string; cb: HTMLInputElement }> = []; + for (const [key, label] of [[HAS_VALUE, 'Has value'], [NO_VALUE, 'No value']] as const) { + const row = content.createDiv({ cls: 'wl-filter-checkbox-row' }); + const cb = row.createEl('input', { attr: { type: 'checkbox' } }); + cb.checked = !excludeSet.has(key); + row.createSpan({ text: label }); + cb.addEventListener('change', () => { + if (cb.checked) excludeSet.delete(key); + else excludeSet.add(key); + }); + entries.push({ key, cb }); + } + return { + selectAll: () => { for (const e of entries) { e.cb.checked = true; excludeSet.delete(e.key); } }, + deselectAll: () => { for (const e of entries) { e.cb.checked = false; excludeSet.add(e.key); } }, + }; + } + + private renderFilterPopoverBody( + el: HTMLElement, + draft: FilterDraft, + collapsed: Record<string, boolean>, + ): void { + el.empty(); + + // Multi-select-style sections register here so the global Select all / + // Deselect all buttons can drive every one of them at once (Watchlist UX). + const controllers: SectionController[] = []; + + // ── Global buttons row (Select all / Deselect all / Save-or-Delete) ── + const globalRow = el.createDiv({ cls: 'wl-filter-global-btns' }); + + const selectAllBtn = globalRow.createEl('button', { cls: 'wl-btn wl-btn-sm', text: 'Select all' }); + selectAllBtn.addEventListener('click', (ev) => { + ev.stopPropagation(); + for (const c of controllers) c.selectAll(); + }); + const deselectAllBtn = globalRow.createEl('button', { cls: 'wl-btn wl-btn-sm', text: 'Deselect all' }); + deselectAllBtn.addEventListener('click', (ev) => { + ev.stopPropagation(); + for (const c of controllers) c.deselectAll(); + }); + + // Save / Delete — a single saved filter, exactly like the Watchlist popover. + // Save stores the current draft (without applying it); Delete clears it. Both + // re-render so the external "Saved filter" button appears/disappears. + const saveDeleteBtn = globalRow.createEl('button', { + cls: 'wl-btn wl-btn-sm', + text: this.getSavedFilter() ? 'Delete' : 'Save', + }); + saveDeleteBtn.addEventListener('click', (ev) => { + ev.stopPropagation(); + void (async () => { + // Preserve the other sub-tab's saved filter when updating this one. + const current = { ...(this.readingData.getSettings().savedFilters ?? {}) }; + if (saveDeleteBtn.textContent === 'Save') { + current[this.activeSubTab] = { + name: 'Saved filter', + // Stored in include shape: the statuses that remain visible. + statusInclude: READING_STATUSES.filter((s) => !draft.statusExclude.has(s)), + ratingMode: draft.ratingMode, + }; + await this.readingData.updateSettings({ savedFilters: current }); + } else { + delete current[this.activeSubTab]; + await this.readingData.updateSettings({ savedFilters: current }); + this.savedFilterActive = false; + } + this.closePopover(); + this.filterBtn?.removeClass('is-popover-open'); + this.render(); + })(); + }); + + // ── Status section (multi-select with "All") ── + this.addFilterSection(el, collapsed, 'Status', 'Status', (content) => { + controllers.push(this.addMultiSelectBody(content, READING_STATUSES, draft.statusExclude)); + }); + + // ── Rating section (single-select mode; not part of Select/Deselect all) ── + this.addFilterSection(el, collapsed, 'Rating', 'Rating', (content) => { + const ratingOpts: Array<{ key: 'all' | 'has' | 'none'; label: string }> = [ + { key: 'all', label: 'All' }, + { key: 'has', label: 'Has rating' }, + { key: 'none', label: 'No rating' }, + ]; + const groupName = `wl-reading-rating-${Date.now()}`; + for (const opt of ratingOpts) { + const row = content.createDiv({ cls: 'wl-filter-checkbox-row' }); + const radio = row.createEl('input', { attr: { type: 'radio', name: groupName } }); + radio.checked = draft.ratingMode === opt.key; + row.createSpan({ text: opt.label }); + radio.addEventListener('change', () => { + if (radio.checked) draft.ratingMode = opt.key; + }); + } + }); + + // ── Custom-field sections (per active sub-tab) ── + for (const col of this.activeColumns()) { + const excludeSet = draft.customExclude.get(col.id) ?? new Set<string>(); + draft.customExclude.set(col.id, excludeSet); + this.addFilterSection(el, collapsed, `custom:${col.id}`, col.name, (content) => { + if (col.type === 'select') { + controllers.push(this.addMultiSelectBody(content, col.options, excludeSet)); + } else { + controllers.push(this.addHasValueBody(content, excludeSet)); + } + }); + } + + // ── Apply — commits the staged draft to live state and re-renders ── + const applyWrap = el.createDiv(); + applyWrap.style.padding = '6px 12px 2px'; + const applyBtn = applyWrap.createEl('button', { + cls: 'wl-btn wl-btn-sm wl-filter-apply-btn', + text: 'Apply', + }); + applyBtn.addEventListener('click', (ev) => { + ev.stopPropagation(); + const customExclude: Record<string, string[]> = {}; + for (const [id, set] of draft.customExclude) { + if (set.size > 0) customExclude[id] = Array.from(set); + } + ReadingTab.sessionFilters[this.activeSubTab] = { + statusExclude: Array.from(draft.statusExclude), + ratingMode: draft.ratingMode, + customExclude, + }; + this.deactivateSavedFilter(); + this.refreshFilterButtonBadge(); + this.closePopover(); + this.filterBtn?.removeClass('is-popover-open'); + this.renderContent(); + }); + } + + private buildSortPopover(el: HTMLElement): void { + const keys: Array<{ key: SortKey; label: string }> = [ + { key: 'dateAdded', label: 'Date added' }, + { key: 'title', label: 'Title' }, + { key: 'author', label: 'Author' }, + { key: 'rating', label: 'Rating' }, + { key: 'progress', label: 'Progress' }, + { key: 'status', label: 'Status' }, + { key: 'dateStarted', label: 'Date started' }, + { key: 'dateFinished', label: 'Date finished' }, + ]; + // Append custom fields for the active sub-tab as `custom:<columnId>` keys. + for (const col of this.activeColumns()) { + keys.push({ key: `custom:${col.id}`, label: col.name }); + } + + const addRow = ( + labelText: string, + currentKey: SortKey, + currentDir: SortDir, + isPrimary: boolean, + ): void => { + const row = el.createDiv({ cls: 'wl-filter-row' }); + row.createSpan({ cls: 'wl-filter-label', text: labelText }); + const sel = row.createEl('select', { cls: 'wl-select' }); + + if (!isPrimary) { + const noneOpt = sel.createEl('option', { text: 'None', value: 'none' }); + if (currentKey === 'none') noneOpt.selected = true; + } + for (const k of keys) { + const opt = sel.createEl('option', { text: k.label, value: k.key }); + if (k.key === currentKey) opt.selected = true; + } + + const dirBtn = row.createEl('button', { + cls: 'wl-btn wl-btn-sm wl-sort-dir-btn', + text: currentDir === 'asc' ? '↑' : '↓', + }); + dirBtn.title = currentDir === 'asc' + ? 'Ascending — click to switch to descending' + : 'Descending — click to switch to ascending'; + + sel.addEventListener('change', () => { + if (isPrimary) { + this.sort.key = sel.value; + } else { + this.sort.secondKey = (sel.value === 'none' ? 'none' : sel.value); + } + this.closePopover(); + this.sortBtn?.removeClass('is-popover-open'); + this.renderContent(); + }); + + dirBtn.addEventListener('click', (ev) => { + ev.stopPropagation(); + if (isPrimary) { + this.sort.dir = this.sort.dir === 'asc' ? 'desc' : 'asc'; + } else { + this.sort.secondDir = this.sort.secondDir === 'asc' ? 'desc' : 'asc'; + } + this.closePopover(); + this.sortBtn?.removeClass('is-popover-open'); + this.renderContent(); + }); + }; + + addRow('Sort by', this.sort.key, this.sort.dir, true); + addRow('Then by', this.sort.secondKey, this.sort.secondDir, false); + } + + private applyFilters(items: Array<Book | Manga>): Array<Book | Manga> { + const f = this.filters; + const columns = this.activeColumns(); + return items.filter((it) => { + if (f.statusExclude.includes(it.status)) return false; + if (f.ratingMode === 'has' && !(it.rating > 0)) return false; + if (f.ratingMode === 'none' && it.rating > 0) return false; + for (const col of columns) { + const ex = f.customExclude[col.id]; + if (!ex || ex.length === 0) continue; + if (col.type === 'select') { + // Like Status: only items whose value is an excluded option are + // hidden; items with no value for this field are unaffected. + const v = it.customFields?.[col.id]; + const sv = v === undefined || v === null ? '' : String(v); + if (sv !== '' && ex.includes(sv)) return false; + } else { + const has = customFieldHasValue(it, col.id); + if (has && ex.includes(HAS_VALUE)) return false; + if (!has && ex.includes(NO_VALUE)) return false; + } + } + return true; + }); + } + + private applySearch(items: Array<Book | Manga>): Array<Book | Manga> { + const q = this.searchQuery.trim(); + if (!q) return items; + const fuse = new Fuse(items, { + keys: ['title', 'author'], + threshold: 0.4, + ignoreLocation: true, + }); + return fuse.search(q).map((r) => r.item); + } + + private compareBySortKey( + a: Book | Manga, + b: Book | Manga, + key: SortKey, + factor: number, + isBooks: boolean, + ): number { + if (key.startsWith('custom:')) { + return this.compareCustom(a, b, key.slice('custom:'.length), factor); + } + switch (key) { + case 'title': + return a.title.localeCompare(b.title) * factor; + case 'author': + return (a.author || '').localeCompare(b.author || '') * factor; + case 'rating': + return (a.rating - b.rating) * factor; + case 'dateAdded': + return a.dateAdded.localeCompare(b.dateAdded) * factor; + case 'dateStarted': + return (a.dateStarted ?? '').localeCompare(b.dateStarted ?? '') * factor; + case 'dateFinished': + return (a.dateFinished ?? '').localeCompare(b.dateFinished ?? '') * factor; + case 'status': + return a.status.localeCompare(b.status) * factor; + case 'progress': { + const pa = isBooks ? bookProgress(a as Book) : mangaProgress(a as Manga); + const pb = isBooks ? bookProgress(b as Book) : mangaProgress(b as Manga); + return (pa - pb) * factor; + } + default: + return 0; + } + } + + // Compares two items by a custom field value. Number columns compare + // numerically, text/select alphabetically. Items with no value always sort + // last, regardless of direction (the `factor` is not applied to them). + private compareCustom(a: Book | Manga, b: Book | Manga, columnId: string, factor: number): number { + const col = this.activeColumns().find((c) => c.id === columnId); + const av = a.customFields?.[columnId]; + const bv = b.customFields?.[columnId]; + const aEmpty = av === undefined || av === null || av === ''; + const bEmpty = bv === undefined || bv === null || bv === ''; + if (aEmpty && bEmpty) return 0; + if (aEmpty) return 1; + if (bEmpty) return -1; + if (col?.type === 'number') { + return (Number(av) - Number(bv)) * factor; + } + return String(av).localeCompare(String(bv)) * factor; + } + + private applySort(items: Array<Book | Manga>): Array<Book | Manga> { + const { key, dir, secondKey, secondDir } = this.sort; + const isBooks = this.activeSubTab === 'books'; + const f1 = dir === 'asc' ? 1 : -1; + const f2 = secondDir === 'asc' ? 1 : -1; + return [...items].sort((a, b) => { + const primary = this.compareBySortKey(a, b, key, f1, isBooks); + if (primary !== 0 || secondKey === 'none') return primary; + return this.compareBySortKey(a, b, secondKey, f2, isBooks); + }); + } + + private renderContent(): void { + if (!this.contentEl) return; + this.destroyVirtualScroll(); + for (const fn of this.activeCleanups) fn(); + this.activeCleanups = []; + this.contentEl.empty(); + + const isBooks = this.activeSubTab === 'books'; + const items: Array<Book | Manga> = isBooks + ? this.readingData.getBooks() + : this.readingData.getMangaList(); + + if (items.length === 0) { + this.renderEmptyState(); + return; + } + + const filtered = this.applyFilters(items); + const searched = this.applySearch(filtered); + const sorted = this.applySort(searched); + + if (sorted.length === 0) { + const q = this.searchQuery.trim(); + this.contentEl.createDiv({ + cls: 'wl-reading-empty-search', + text: q ? `No matches for "${q}".` : 'No items match the current filters.', + }); + return; + } + + this.renderResultsCount(sorted.length); + + this.vsDisplayItems = sorted; + this.renderVirtualGrid(); + } + + /** + * Renders the "N titles" count line above the grid, mirroring the Watchlist + * Cards count. Reading has no groups, so only the title count is shown. The + * count reflects the post-filter/search result set. + */ + private renderResultsCount(titleCount: number): void { + if (!this.contentEl) return; + const countEl = this.contentEl.createDiv({ cls: 'wl-results-count' }); + countEl.textContent = `${titleCount} title${titleCount !== 1 ? 's' : ''}`; + } + + // ── Virtual scroll ──────────────────────────────────────────────────────── + + private destroyVirtualScroll(): void { + this.destroyCoverObserver(); + if (this.vsScrollRAF !== null) { + window.cancelAnimationFrame(this.vsScrollRAF); + this.vsScrollRAF = null; + } + if (this.vsResizeObserver) { + this.vsResizeObserver.disconnect(); + this.vsResizeObserver = null; + } + if (this.vsScrollContainer && this.vsScrollHandler) { + this.vsScrollContainer.removeEventListener('scroll', this.vsScrollHandler); + } + this.vsScrollHandler = null; + this.vsScrollContainer = null; + this.vsScrollSpacer = null; + this.vsGridEl = null; + this.vsDisplayItems = []; + this.vsLastFirst = -1; + this.vsLastLast = -1; + this.vsLastScrollTop = 0; + this.vsRowHeight = 0; + } + + private renderVirtualGrid(): void { + if (!this.contentEl) return; + + const scroll = this.contentEl.createDiv({ cls: 'wl-reading-scroll-container' }); + const spacer = scroll.createDiv({ cls: 'wl-reading-scroll-spacer' }); + const grid = spacer.createDiv({ cls: 'wl-reading-grid wl-reading-grid-virtual' }); + this.vsScrollContainer = scroll; + this.vsScrollSpacer = spacer; + this.vsGridEl = grid; + this.vsLastFirst = -1; + this.vsLastLast = -1; + + this.vsScrollHandler = () => { + const scrollTop = scroll.scrollTop; + this.vsPersistentScrollTop = scrollTop; + const threshold = this.vsRowHeight > 0 ? this.vsRowHeight / 2 : 50; + if (Math.abs(scrollTop - this.vsLastScrollTop) < threshold) return; + this.vsLastScrollTop = scrollTop; + if (this.vsScrollRAF !== null) return; + this.vsScrollRAF = window.requestAnimationFrame(() => { + this.vsScrollRAF = null; + this.renderVisibleCards(); + }); + }; + scroll.addEventListener('scroll', this.vsScrollHandler, { passive: true }); + + this.vsResizeObserver = new ResizeObserver(() => { + this.renderVisibleCards(); + }); + this.vsResizeObserver.observe(scroll); + + if (this.vsPersistentScrollTop > 0) { + this.renderVisibleCards(); + scroll.scrollTop = this.vsPersistentScrollTop; + this.vsLastScrollTop = this.vsPersistentScrollTop; + this.renderVisibleCards(); + } + } + + private getGridMetrics(containerWidth: number): { + cols: number; + cardWidth: number; + cardHeight: number; + rowHeight: number; + } { + const gap = 12; + const minCardWidth = 140; + const cols = Math.max(1, Math.floor((containerWidth + gap) / (minCardWidth + gap))); + const cardWidth = (containerWidth - gap * (cols - 1)) / cols; + const coverHeight = cardWidth * 1.5; + const cardHeight = coverHeight + 72; + const rowHeight = cardHeight + gap; + return { cols, cardWidth, cardHeight, rowHeight }; + } + + private renderVisibleCards(): void { + const scroll = this.vsScrollContainer; + const spacer = this.vsScrollSpacer; + const grid = this.vsGridEl; + if (!scroll || !spacer || !grid) return; + + const scrollTop = scroll.scrollTop; + const viewportHeight = scroll.clientHeight; + const width = scroll.clientWidth; + if (width <= 0 || viewportHeight <= 0) return; + + const { cols, cardHeight, rowHeight } = this.getGridMetrics(width); + this.vsRowHeight = rowHeight; + const totalItems = this.vsDisplayItems.length; + const totalRows = Math.ceil(totalItems / cols); + + const firstVisibleRow = Math.max(0, Math.floor(scrollTop / rowHeight) - 2); + const lastVisibleRow = Math.min( + totalRows - 1, + Math.ceil((scrollTop + viewportHeight) / rowHeight) + 2, + ); + const firstIndex = firstVisibleRow * cols; + const lastIndex = Math.min(totalItems - 1, (lastVisibleRow + 1) * cols - 1); + + spacer.style.height = `${totalRows * rowHeight}px`; + grid.style.transform = `translateY(${firstVisibleRow * rowHeight}px)`; + grid.style.setProperty('--wl-reading-cols', String(cols)); + grid.style.setProperty('--wl-reading-card-height', `${cardHeight}px`); + + const oldFirst = this.vsLastFirst; + const oldLast = this.vsLastLast; + if (firstIndex === oldFirst && lastIndex === oldLast) return; + + this.vsLastFirst = firstIndex; + this.vsLastLast = lastIndex; + + grid.empty(); + const isBooks = this.activeSubTab === 'books'; + const fragment = activeDocument.createDocumentFragment(); + for (let i = firstIndex; i <= lastIndex; i++) { + const item = this.vsDisplayItems[i]; + if (!item) continue; + const tmp = activeDocument.createElement('div'); + if (isBooks) { + this.renderBookCard(tmp, item as Book); + } else { + this.renderMangaCard(tmp, item as Manga); + } + const card = tmp.firstElementChild as HTMLElement | null; + if (card) fragment.appendChild(card); + } + grid.appendChild(fragment); + this.setupCoverObserver(); + } + + // ── Lazy cover re-fetch ───────────────────────────────────────────────────── + + private setupCoverObserver(): void { + const scroll = this.vsScrollContainer; + const grid = this.vsGridEl; + if (!scroll || !grid) return; + if (!this.coverObserver) { + this.coverObserver = new IntersectionObserver( + (entries) => this.handleCoverIntersection(entries), + { root: scroll, rootMargin: '0px', threshold: 0 }, + ); + } + // The grid was just rebuilt (grid.empty()), so previously observed cards are + // detached. Drop their (strong) refs before observing the current card set. + this.observedCovers.clear(); + grid.querySelectorAll('.wl-reading-card[data-needs-cover="true"]').forEach((card) => { + const el = card as HTMLElement; + if (!this.observedCovers.has(el)) { + this.coverObserver!.observe(el); + this.observedCovers.add(el); + } + }); + } + + private handleCoverIntersection(entries: IntersectionObserverEntry[]): void { + const kind: ReadingKind = this.activeSubTab === 'books' ? 'book' : 'manga'; + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const card = entry.target as HTMLElement; + this.coverObserver?.unobserve(card); + this.observedCovers.delete(card); + + const id = card.dataset.itemId; + if (!id) continue; + const item = kind === 'book' ? this.readingData.getBook(id) : this.readingData.getManga(id); + // Skip if it vanished, already has a cover, or failed earlier this session. + if (!item || item.coverUrl || this.coverFetchFailed.has(id)) continue; + + const cover = card.querySelector('.wl-reading-card-cover'); + cover?.addClass('is-loading'); + void this.resolveCover(kind, item).then(({ url, sourceId }) => { + cover?.removeClass('is-loading'); + if (url) { + // Persist silently (no re-render) and update this card's <img> directly. + // When a title-based lookup resolved a new ID, store it too so future + // loads use the cheap ID path instead of re-searching. + if (sourceId) this.readingData.updateCoverAndSource(kind, id, url, sourceId); + else this.readingData.updateCoverUrl(kind, id, url); + this.applyCoverToCard(card, url, item.title); + } else { + this.coverFetchFailed.add(id); + } + }); + } + } + + /** + * Re-resolves a cover through Reading's OWN sources — Google Books for books, + * Jikan for manga — never the watch PosterService. When the entry has a source ID + * it's used directly; otherwise (CSV-imported entries with neither cover nor ID) it + * falls back to a title-based search, taking the first result and returning its ID so + * the caller can persist it. `sourceId` is '' when nothing new needs persisting. + * Returns an empty url when unavailable. + */ + private async resolveCover(kind: ReadingKind, item: Book | Manga): Promise<{ url: string; sourceId: string }> { + try { + if (kind === 'book') { + const gid = (item as Book).googleBooksId; + if (gid) { + const result = await this.plugin.apiService.getGoogleBookById(gid); + return { url: result?.coverUrl ?? '', sourceId: '' }; + } + const query = (item as Book).author + ? `${item.title} ${(item as Book).author}` + : item.title; + const results = await this.plugin.apiService.searchGoogleBooks(query); + const first = results[0]; + if (!first || !first.coverUrl || !first.googleBooksId) return { url: '', sourceId: '' }; + return { url: first.coverUrl, sourceId: first.googleBooksId }; + } + const malId = (item as Manga).malId; + if (malId) { + const result = await this.plugin.apiService.getMangaByMalId(Number(malId)); + return { url: result?.coverUrl ?? '', sourceId: '' }; + } + const results = await this.plugin.apiService.searchManga(item.title); + const first = results[0]; + if (!first || !first.coverUrl || !first.malId) return { url: '', sourceId: '' }; + return { url: first.coverUrl, sourceId: String(first.malId) }; + } catch { + return { url: '', sourceId: '' }; + } + } + + private applyCoverToCard(card: HTMLElement, url: string, alt: string): void { + const cover = card.querySelector<HTMLElement>('.wl-reading-card-cover'); + if (!cover) return; + const icon = cover.querySelector('.wl-reading-card-cover-icon'); + const img = activeDocument.createElement('img'); + img.className = 'wl-reading-card-cover-img'; + img.alt = alt; + img.loading = 'lazy'; + img.onload = () => { + cover.style.backgroundColor = ''; + icon?.remove(); + }; + img.onerror = () => { img.remove(); }; + cover.insertBefore(img, cover.firstChild); + img.src = url; + delete card.dataset.needsCover; + } + + private destroyCoverObserver(): void { + if (this.coverObserver) { + this.coverObserver.disconnect(); + this.coverObserver = null; + } + this.observedCovers.clear(); + } + + private renderBookCard(grid: HTMLElement, book: Book): void { + const card = this.renderCardShell(grid, book, '📖'); + const progress = book.totalPages > 0 + ? Math.min(100, Math.round((book.pagesRead / book.totalPages) * 100)) + : 0; + this.renderProgressBar(card, progress, book.status); + const text = card.createDiv({ cls: 'wl-reading-card-progress-text' }); + if (book.totalPages > 0) { + text.textContent = `${book.pagesRead} / ${book.totalPages} pages`; + } else if (book.pagesRead > 0) { + text.textContent = `${book.pagesRead} pages`; + } else { + text.textContent = 'No progress'; + } + this.bindCardClick(card, 'book', book.id); + } + + private renderMangaCard(grid: HTMLElement, manga: Manga): void { + const card = this.renderCardShell(grid, manga, '📓'); + const progress = manga.totalChapters > 0 + ? Math.min(100, Math.round((manga.chaptersRead / manga.totalChapters) * 100)) + : 0; + this.renderProgressBar(card, progress, manga.status); + const text = card.createDiv({ cls: 'wl-reading-card-progress-text' }); + const chPart = manga.totalChapters > 0 + ? `Ch. ${manga.chaptersRead}/${manga.totalChapters}` + : manga.chaptersRead > 0 ? `Ch. ${manga.chaptersRead}` : ''; + const volPart = manga.totalVolumes > 0 + ? `Vol. ${manga.volumesRead}/${manga.totalVolumes}` + : manga.volumesRead > 0 ? `Vol. ${manga.volumesRead}` : ''; + if (chPart) text.createSpan({ cls: 'wl-reading-card-progress-piece', text: chPart }); + if (volPart) text.createSpan({ cls: 'wl-reading-card-progress-piece', text: volPart }); + if (!chPart && !volPart) text.textContent = 'No progress'; + this.bindCardClick(card, 'manga', manga.id); + } + + private bindCardClick(card: HTMLElement, mode: 'book' | 'manga', id: string): void { + if (this.selectionMode) { + if (this.selectedIds.has(id)) { + card.addClass('is-selected'); + } + card.addEventListener('click', (e) => { + e.stopPropagation(); + if (this.selectedIds.has(id)) { + this.selectedIds.delete(id); + card.removeClass('is-selected'); + } else { + this.selectedIds.add(id); + card.addClass('is-selected'); + } + this.render(); + }); + } else { + card.addEventListener('click', () => this.openDetailModal(mode, id)); + } + } + + private renderCardShell(grid: HTMLElement, item: Book | Manga, fallbackIcon: string): HTMLElement { + const card = grid.createDiv({ cls: 'wl-reading-card' }); + + const cover = card.createDiv({ cls: 'wl-reading-card-cover' }); + if (item.coverUrl) { + cover.createEl('img', { + cls: 'wl-reading-card-cover-img', + attr: { src: item.coverUrl, alt: item.title, loading: 'lazy' }, + }); + } else { + cover.style.backgroundColor = coverFallbackColor(item.id); + cover.createSpan({ + cls: 'wl-reading-card-cover-icon', + text: fallbackIcon, + }); + // Flag cards with a missing cover for lazy re-fetch when they scroll into view. + // resolveCover uses the source ID when present, otherwise falls back to a + // title-based lookup (CSV-imported entries with neither cover nor ID). Skip + // only titles that already failed to resolve this session. + if (!this.coverFetchFailed.has(item.id)) { + card.dataset.needsCover = 'true'; + card.dataset.itemId = item.id; + } + } + + const statusBadge = cover.createSpan({ cls: 'wl-reading-card-status-badge', text: item.status }); + statusBadge.style.backgroundColor = readingStatusColor(item.status); + + const menuBtn = card.createEl('button', { + cls: 'wl-reading-card-menu-btn', + text: '⋮', + }); + menuBtn.setAttr('aria-label', 'More actions'); + menuBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.openCardContextMenu(card, menuBtn, item); + }); + + const title = card.createDiv({ cls: 'wl-reading-card-title', text: item.title }); + title.title = item.title; + const author = card.createDiv({ + cls: 'wl-reading-card-author', + text: item.author || '—', + }); + author.title = item.author || ''; + + return card; + } + + private openCardContextMenu( + card: HTMLElement, + anchorBtn: HTMLElement, + item: Book | Manga, + ): void { + this.container.querySelectorAll('.wl-reading-card-context-menu').forEach((el) => el.remove()); + + const menu = card.createDiv({ cls: 'wl-reading-card-context-menu' }); + const refreshItem = menu.createDiv({ + cls: 'wl-reading-card-context-item', + text: 'Refresh cover', + }); + + const cardRect = card.getBoundingClientRect(); + const containerRect = this.container.getBoundingClientRect(); + if (cardRect.right > containerRect.right - 180) { + menu.classList.add('is-right-aligned'); + } + + // Capture the owning document once so add/remove can't desync across popout windows. + const doc = card.ownerDocument; + const closeMenu = (): void => { + menu.remove(); + doc.removeEventListener('click', onDocClick, true); + }; + const onDocClick = (e: MouseEvent): void => { + if (!menu.contains(e.target as Node) && e.target !== anchorBtn) { + closeMenu(); + } + }; + window.setTimeout(() => doc.addEventListener('click', onDocClick, true), 0); + this.activeCleanups.push(() => doc.removeEventListener('click', onDocClick, true)); + + refreshItem.addEventListener('click', (e) => { + e.stopPropagation(); + closeMenu(); + this.refreshCover(item); + }); + } + + private refreshCover(item: Book | Manga): void { + const isBooks = this.activeSubTab === 'books'; + const kind = isBooks ? 'book' : 'manga'; + + if (kind === 'book') { + const book = item as Book; + if (!book.googleBooksId) { + new Notice('No Google Books ID — cannot refresh cover.'); + return; + } + if (!this.plugin.apiService.hasGoogleBooksKey()) { + new Notice('Google Books API key required — add one in Settings → API → Books.'); + return; + } + void (async () => { + try { + const result = await this.plugin.apiService.getGoogleBookById(book.googleBooksId); + if (!result || !result.coverUrl) { + new Notice('Could not fetch cover from API.'); + return; + } + const fresh = this.readingData.getBook(book.id); + if (!fresh) return; + await this.readingData.updateBook({ ...fresh, coverUrl: result.coverUrl }); + new Notice('Cover refreshed.'); + } catch (err) { + new Notice(googleBooksErrorMessage(err)); + } + })(); + } else { + const manga = item as Manga; + if (!manga.malId) { + new Notice('No MAL ID — cannot refresh cover.'); + return; + } + void (async () => { + try { + const result = await this.plugin.apiService.getMangaByMalId(Number(manga.malId)); + if (!result || !result.coverUrl) { + new Notice('Could not fetch cover from API.'); + return; + } + const fresh = this.readingData.getManga(manga.id); + if (!fresh) return; + await this.readingData.updateManga({ ...fresh, coverUrl: result.coverUrl }); + new Notice('Cover refreshed.'); + } catch { + new Notice('Failed to refresh cover.'); + } + })(); + } + } + + private renderProgressBar(card: HTMLElement, percent: number, status: ReadingStatus): void { + const bar = card.createDiv({ cls: 'wl-reading-card-progress-bar' }); + const fill = bar.createDiv({ cls: 'wl-reading-card-progress-fill' }); + fill.style.width = `${percent}%`; + fill.style.backgroundColor = readingStatusColor(status); + } + + private renderEmptyState(): void { + if (!this.contentEl) return; + const empty = this.contentEl.createDiv({ cls: 'wl-reading-empty' }); + const isBooks = this.activeSubTab === 'books'; + empty.createSpan({ cls: 'wl-reading-empty-icon', text: isBooks ? '📖' : '📓' }); + const msg = empty.createDiv({ cls: 'wl-reading-empty-msg' }); + msg.createSpan({ text: isBooks ? 'No books yet — ' : 'No manga yet — ' }); + const link = msg.createEl('a', { + cls: 'wl-reading-empty-link', + text: isBooks ? 'add your first book' : 'add your first manga', + }); + link.addEventListener('click', (e) => { + e.preventDefault(); + this.openAddModal(); + }); + } + + private openAddModal(): void { + new AddReadingModal( + this.plugin.app, + this.plugin, + this.readingData, + this.activeSubTab === 'books' ? 'book' : 'manga', + () => this.renderContent(), + ).open(); + } + + private openManageColumns(): void { + new ReadingManageColumnsModal( + this.plugin.app, + this.plugin, + this.readingData, + this.activeSubTab === 'books' ? 'book' : 'manga', + () => this.renderContent(), + ).open(); + } + + private openDetailModal(mode: 'book' | 'manga', id: string): void { + new ReadingDetailModal( + this.plugin.app, + this.plugin, + this.readingData, + mode, + id, + () => this.renderContent(), + ).open(); + } +} diff --git a/src/SettingsTab.ts b/src/SettingsTab.ts index 56f1117..a95fad4 100644 --- a/src/SettingsTab.ts +++ b/src/SettingsTab.ts @@ -1,916 +1,1394 @@ -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(); - activeDocument.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(); - activeDocument.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('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 = activeDocument.createElement('a'); - a.href = url; - a.download = filename; - activeDocument.body.appendChild(a); - a.click(); - activeDocument.body.removeChild(a); - URL.revokeObjectURL(url); - new Notice(`Backup exported as ${filename}`); - }); - } - - private openRestoreDialog(): void { - const input = activeDocument.createElement('input'); - input.type = 'file'; - input.accept = '.json'; - input.hide(); - activeDocument.body.appendChild(input); - input.addEventListener('change', () => { - const file = input.files?.[0]; - if (!file) { activeDocument.body.removeChild(input); return; } - const reader = new FileReader(); - reader.onload = (e) => { - activeDocument.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(); - activeDocument.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'); - activeDocument.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!'; - window.setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); - }); - }); - } - } -} +import { App, Notice, Platform, PluginSettingTab, Setting, normalizePath } from 'obsidian'; +import type WatchLogPlugin from './main'; +import type { TagDefinition, ReadingStatus, ReadingData } from './types'; +import { SELECTABLE_READING_STATUSES } from './types'; +import { CsvModal } from './CsvModal'; +import { ReadingCsvChoiceModal } from './ReadingCsvChoiceModal'; +import { ReadingCsvModal } from './ReadingCsvModal'; +import { WatchLogView } from './WatchLogView'; +import { CustomListManager, DefaultColumnsModal } from './CustomListsTab'; +import { ConfirmModal } from './ConfirmModal'; +import { googleBooksErrorMessage } from './ApiService'; +import type { HistoryEntry } from './HistoryManager'; + +type SettingsSection = 'general' | 'api' | 'customize' | 'watchlist' | 'drafts' | 'custom-lists' | 'reading' | 'widgets' | 'quick-info'; + +export class WatchLogSettingsTab extends PluginSettingTab { + private plugin: WatchLogPlugin; + private textSaveTimer: number | null = null; + + private debouncedSaveSettings(): void { + if (this.textSaveTimer !== null) window.clearTimeout(this.textSaveTimer); + this.textSaveTimer = window.setTimeout(() => { + this.textSaveTimer = null; + void this.plugin.saveSettings(); + }, 500); + } + 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: 'watchlist', label: 'Watchlist' }, + { key: 'drafts', label: 'Drafts' }, + { key: 'custom-lists', label: 'Custom Lists' }, + { key: 'reading', label: 'Reading' }, + { 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 'watchlist': this.renderWatchlist(body); break; + case 'drafts': this.renderDrafts(body); break; + case 'custom-lists': this.renderCustomLists(body); break; + case 'reading': this.renderReading(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('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(); + activeDocument.dispatchEvent(new CustomEvent('watchlog-data-changed')); + }), + ); + + new Setting(el) + .setName('Set finish date automatically') + .setDesc("Record today's date as the finish date when a watch title or reading entry 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('Re-fetch all posters') + .setDesc('Clears all cached poster and reading cover URLs. Watch posters reload lazily when you scroll the Cards view; reading covers can be re-fetched from each card\'s ⋮ menu.') + .addButton((b) => + b.setButtonText('Re-fetch posters').onClick(() => { + const titles = this.plugin.dataManager.getTitles(); + const readingCount = this.plugin.readingDataManager.getBooks().length + this.plugin.readingDataManager.getMangaList().length; + new ConfirmModal( + this.app, + `This will clear cached images for all ${titles.length} watch titles and ${readingCount} reading entries. Items without API access will show placeholder cards. Continue?`, + () => { void this.refetchAllPosters(); }, + ).open(); + }), + ); + + new Setting(el) + .setName('Show hint banners') + .setDesc('Show informational hint banners in Upcoming, Custom Lists, and Drafts tabs.') + .addToggle((t) => + t + .setValue(this.plugin.settings.showHintBanners) + .onChange(async (v) => { + this.plugin.settings.showHintBanners = v; + await this.plugin.saveSettings(); + activeDocument.dispatchEvent(new CustomEvent('watchlog-data-changed')); + }), + ); + + new Setting(el) + .setName('Show Upcoming count in status bar') + .setDesc('Show a "N due" counter in the status bar when Upcoming entries are due. Click it to open the Upcoming tab. (Desktop only.)') + .addToggle((t) => + t + .setValue(this.plugin.settings.showUpcomingStatusBar) + .onChange(async (v) => { + this.plugin.settings.showUpcomingStatusBar = v; + await this.plugin.saveSettings(); + this.plugin.updateStatusBar(); + }), + ); + + // ── Backup & Restore ────────────────────────────────────────────────────────── + el.createDiv({ cls: 'wl-settings-section-title', text: 'Backup & Restore' }); + + new Setting(el) + .setName('Export backup') + .setDesc('Export all watchlog data — watchlist, reading (books & manga) and activity log — into a single timestamped .JSON file.') + .addButton((b) => + b.setButtonText('Export backup').onClick(() => void this.exportBackup()), + ); + + new Setting(el) + .setName('Restore from backup') + .setDesc('Restore from a .JSON backup file. This replaces your current watchlist, reading and activity-log data with the contents of the backup. Older watch-only backups restore the watchlist only and leave reading and activity log untouched.') + .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; + } + + private async exportBackup(): Promise<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`; + + // Version-2 snapshot: a self-describing wrapper around all three data sources. + const snapshot = { + version: 2, + exportedAt: today.toISOString(), + watch: this.plugin.dataManager.getData() ?? {}, + reading: this.plugin.readingDataManager.getData(), + history: this.plugin.historyManager.exportEntries(), + }; + const json = JSON.stringify(snapshot, null, 2); + + // On mobile the browser download lands in an inaccessible sandbox, so write + // the backup into the vault instead. Desktop keeps the existing download path. + if (Platform.isMobile) { + await this.exportBackupToVault(filename, json); + return; + } + + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = activeDocument.createElement('a'); + a.href = url; + a.download = filename; + activeDocument.body.appendChild(a); + a.click(); + activeDocument.body.removeChild(a); + URL.revokeObjectURL(url); + new Notice(`Backup exported as ${filename}`); + } + + /** Mobile path: write the backup into WatchLog/backups/, disambiguating the name on collision. */ + private async exportBackupToVault(filename: string, json: string): Promise<void> { + const adapter = this.app.vault.adapter; + const folder = normalizePath('WatchLog/backups'); + if (!(await adapter.exists(folder))) { + try { + await adapter.mkdir(folder); + } catch { + // folder may already exist + } + } + + // Mirror the reading-id slug disambiguation: keep the base name, then -2, -3, … on collision. + const dotIndex = filename.lastIndexOf('.'); + const base = dotIndex === -1 ? filename : filename.slice(0, dotIndex); + const ext = dotIndex === -1 ? '' : filename.slice(dotIndex); + let path = normalizePath(`${folder}/${filename}`); + if (await adapter.exists(path)) { + let counter = 2; + while (await adapter.exists(normalizePath(`${folder}/${base}-${counter}${ext}`))) counter++; + path = normalizePath(`${folder}/${base}-${counter}${ext}`); + } + + await adapter.write(path, json); + new Notice(`Backup saved to ${path}`); + } + + private openRestoreDialog(): void { + const input = activeDocument.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.hide(); + activeDocument.body.appendChild(input); + let consumed = false; + const cleanupInput = (): void => { + if (input.parentElement) { + try { activeDocument.body.removeChild(input); } catch { /* ignore */ } + } + }; + // If the picker is cancelled, focus returns to window. Clean up shortly after. + const onFocus = (): void => { + window.setTimeout(() => { + if (!consumed) cleanupInput(); + window.removeEventListener('focus', onFocus); + }, 200); + }; + window.addEventListener('focus', onFocus); + input.addEventListener('change', () => { + consumed = true; + const file = input.files?.[0]; + if (!file) { cleanupInput(); return; } + const reader = new FileReader(); + reader.onload = (e) => { + cleanupInput(); + 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; + } + if (typeof parsed !== 'object' || parsed === null) { + new Notice('Invalid backup file — not an object.'); + return; + } + const p = parsed as Record<string, unknown>; + + // Validate the WHOLE file before touching disk — restore is all-or-nothing. + if (p['version'] === 2) { + const watch = p['watch']; + const reading = p['reading']; + const history = p['history']; + if (!this.isValidWatchData(watch)) { + new Notice('Invalid backup file — watch data missing required fields (titles, groups, settings).'); + return; + } + if (!this.isValidReadingData(reading)) { + new Notice('Invalid backup file — reading data missing required fields (books, manga).'); + return; + } + if (!Array.isArray(history)) { + new Notice('Invalid backup file — activity log is not a list.'); + return; + } + 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); + await this.plugin.loadSettings(); + await this.plugin.dataManager.load(); + await this.plugin.readingDataManager.restore(reading as ReadingData); + await this.plugin.historyManager.restore(history as HistoryEntry[]); + activeDocument.dispatchEvent(new CustomEvent('watchlog-data-changed')); + new Notice('Backup restored successfully.'); + })(); + }).open(); + return; + } + + // Legacy (v1) — the file IS the watch data; reading & history are left untouched. + if (!this.isValidWatchData(parsed)) { + new Notice('Invalid backup file — missing required fields (titles, groups, settings).'); + 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 () => { + await this.plugin.loadSettings(); + await this.plugin.dataManager.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); + }); + input.click(); + } + + private isValidWatchData(value: unknown): boolean { + if (typeof value !== 'object' || value === null) return false; + const w = value as Record<string, unknown>; + return Array.isArray(w['titles']) + && Array.isArray(w['groups']) + && typeof w['settings'] === 'object' + && w['settings'] !== null; + } + + private isValidReadingData(value: unknown): boolean { + if (typeof value !== 'object' || value === null) return false; + const r = value as Record<string, unknown>; + return Array.isArray(r['books']) + && Array.isArray(r['manga']) + && typeof r['settings'] === 'object' + && r['settings'] !== null; + } + + private async refetchAllPosters(): Promise<void> { + const titles = this.plugin.dataManager.getTitles(); + for (const t of titles) t.posterUrl = ''; + await this.plugin.dataManager.save(); + + // Also clear cached Reading cover URLs (Books + Manga) so they can be re-fetched. + const reading = this.plugin.readingDataManager; + for (const b of reading.getBooks()) b.coverUrl = ''; + for (const m of reading.getMangaList()) m.coverUrl = ''; + await reading.saveAndNotify(); + + new Notice('Cached posters and covers cleared. Watch posters reload as you browse; refresh reading covers from each card\'s ⋮ menu.'); + } + + private async runRegenerate( + progressWrap: HTMLElement, + progressBarFill: HTMLElement, + progressText: HTMLElement, + ): Promise<void> { + const titles = this.plugin.dataManager.getTitles(); + const reading = this.plugin.readingDataManager; + const books = reading.getBooks(); + const manga = reading.getMangaList(); + const total = titles.length + books.length + manga.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; + let done = 0; + const tick = (wasCreated: boolean): void => { + if (wasCreated) created++; else skipped++; + done++; + const pct = Math.round((done / total) * 100); + progressBarFill.style.width = `${pct}%`; + progressText.textContent = `${done} / ${total}`; + }; + for (const title of titles) { + tick(await this.plugin.dataManager.createMarkdownFileIfMissing(title)); + } + for (const book of books) { + tick(await reading.createReadingNoteIfMissing('book', book)); + } + for (const m of manga) { + tick(await reading.createReadingNoteIfMissing('manga', m)); + } + if (created === 0) { + progressText.textContent = 'Done — no missing files found'; + } else { + progressText.textContent = `Done — ${created} file${created !== 1 ? 's' : ''} created, ${skipped} already existed`; + } + } + + // ─── API ──────────────────────────────────────────────────────────────────── + + /** Creates a faded, Obsidian-callout-style box and returns its body element for content. */ + private apiCallout(parent: HTMLElement, title: string, variant: 'movies' | 'anime' | 'books'): HTMLElement { + const box = parent.createDiv({ cls: `wl-api-callout wl-api-callout--${variant}` }); + box.createDiv({ cls: 'wl-api-callout-title', text: title }); + return box.createDiv({ cls: 'wl-api-callout-body' }); + } + + private renderApi(el: HTMLElement): void { + // ── 1. Movies & TV Shows ────────────────────────────────────────────── + const movies = this.apiCallout(el, 'Movies & TV Shows', 'movies'); + + new Setting(movies) + .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 key + const omdbInfo = movies.createDiv({ cls: 'wl-settings-info' }); + omdbInfo.createSpan({ text: 'Get a free OMDb 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(movies) + .setName('Omdb API key') + .addText((t) => { + t + .setPlaceholder('Paste your omdb key here') + .setValue(this.plugin.settings.omdbApiKey) + .onChange((v) => { + this.plugin.settings.omdbApiKey = v; + this.plugin.apiService.setOmdbKey(v); + this.debouncedSaveSettings(); + }); + 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 = movies.createDiv({ + cls: 'wl-status-indicator', + text: this.plugin.settings.omdbApiKey ? '(not tested)' : 'Not set', + }); + + // TMDB token + const tmdbInfo = movies.createDiv({ cls: 'wl-settings-info' }); + tmdbInfo.createSpan({ text: 'Get a free TMDB read access token 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(movies) + .setName('Tmdb API read access token') + .addText((t) => { + t + .setPlaceholder('Paste your tmdb key here') + .setValue(this.plugin.settings.tmdbApiKey ?? '') + .onChange((v) => { + this.plugin.settings.tmdbApiKey = v; + this.plugin.apiService.setTmdbKey(v); + this.debouncedSaveSettings(); + }); + 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 = movies.createDiv({ + cls: 'wl-status-indicator', + text: this.plugin.settings.tmdbApiKey ? '(not tested)' : 'Not set', + }); + + // ── 2. Anime ────────────────────────────────────────────────────────── + const anime = this.apiCallout(el, 'Anime', 'anime'); + + new Setting(anime) + .setName('Anime API source') + .setDesc('Which API to use for new anime titles.') + .addDropdown((d) => + d + .addOptions({ jikan: 'Jikan (MyAnimeList)', anilist: 'AniList' }) + .setValue(this.plugin.settings.animeApiSource ?? 'jikan') + .onChange(async (v) => { + this.plugin.settings.animeApiSource = v as 'jikan' | 'anilist'; + await this.plugin.saveSettings(); + }), + ); + + anime.createDiv({ + cls: 'wl-settings-info', + text: + 'Jikan uses MyAnimeList\'s database (larger catalog, no API key needed). ' + + 'AniList has its own database with more precise airing schedules and a GraphQL API. ' + + 'This setting only affects new titles — existing titles keep their original API source.', + }); + + // ── 3. Books ────────────────────────────────────────────────────────── + const books = this.apiCallout(el, 'Books', 'books'); + + let googleStatus: HTMLElement; + new Setting(books) + .setName('Google Books API key') + .addText((t) => { + t + .setPlaceholder('Paste your Google Books key here') + .setValue(this.plugin.settings.googleBooksApiKey ?? '') + .onChange((v) => { + this.plugin.settings.googleBooksApiKey = v; + this.plugin.apiService.setGoogleBooksKey(v); + this.debouncedSaveSettings(); + }); + t.inputEl.type = 'password'; + return t; + }) + .addButton((b) => + b.setButtonText('Test').onClick(async () => { + googleStatus.textContent = 'Testing...'; + googleStatus.className = 'wl-status-indicator'; + try { + const ok = await this.plugin.apiService.checkGoogleBooksConnection(); + googleStatus.textContent = ok ? '✓ Connected' : '✗ Failed'; + googleStatus.className = ok ? 'wl-status-ok' : 'wl-status-error'; + } catch (err) { + googleStatus.textContent = `✗ ${googleBooksErrorMessage(err)}`; + googleStatus.className = 'wl-status-error'; + } + }), + ); + googleStatus = books.createDiv({ + cls: 'wl-status-indicator', + text: this.plugin.settings.googleBooksApiKey ? '(not tested)' : 'Not set', + }); + + const googleHelp = books.createDiv({ cls: 'wl-settings-info' }); + googleHelp.createSpan({ text: 'Get a free key in the Google Cloud Console: create a project, enable the Books API, then create an API key under ' }); + googleHelp.createEl('a', { + text: 'APIs & Services → Credentials', + href: 'https://console.cloud.google.com/apis/credentials', + attr: { target: '_blank', rel: 'noopener noreferrer' }, + }); + googleHelp.createSpan({ text: '.' }); + + // ── API routing (always last) ───────────────────────────────────────── + this.renderTypeApiMapping(el); + } + + private renderTypeApiMapping(parent: HTMLElement): void { + const card = parent.createDiv({ cls: 'wl-settings-card' }); + card.createDiv({ cls: 'wl-settings-card-title', text: 'API routing by type in Watchlist' }); + card.createDiv({ + cls: 'wl-settings-card-desc', + text: 'Choose which API group to use for each title type. Anime, Movie, and TV Show are locked to their default APIs.', + }); + + const list = card.createDiv({ cls: 'wl-type-api-list' }); + const allTypes = this.plugin.settings.types ?? []; + const mapping = this.plugin.settings.typeApiMapping ?? {}; + + // Prune stale entries (types that no longer exist) + const validNames = new Set(allTypes.map((t) => t.name)); + let pruned = false; + for (const key of Object.keys(mapping)) { + if (!validNames.has(key) || key === 'Anime' || key === 'Movie' || key === 'TV Show' || key === 'TvShow') { + delete mapping[key]; + pruned = true; + } + } + if (pruned) void this.plugin.saveSettings(); + + for (const t of allTypes) { + const row = list.createDiv({ cls: 'wl-type-api-row' }); + row.createSpan({ cls: 'wl-type-api-name', text: t.name }); + + if (t.name === 'Anime') { + row.createSpan({ cls: 'wl-type-api-locked', text: 'Jikan / AniList (locked)' }); + } else if (t.name === 'Movie' || t.name === 'TV Show' || t.name === 'TvShow') { + row.createSpan({ cls: 'wl-type-api-locked', text: 'OMDb / TMDB (locked)' }); + } else { + const select = row.createEl('select', { cls: 'wl-select wl-type-api-select' }); + const current = mapping[t.name] ?? ''; + const opts: Array<{ value: '' | 'anime' | 'movie'; label: string }> = [ + { value: '', label: '— Not set —' }, + { value: 'anime', label: 'Anime API (Jikan / AniList)' }, + { value: 'movie', label: 'Movie / TV API (OMDb / TMDB)' }, + ]; + for (const o of opts) { + const optEl = select.createEl('option', { text: o.label, value: o.value }); + if (current === o.value) optEl.selected = true; + } + select.addEventListener('change', () => { + const v = select.value as '' | 'anime' | 'movie'; + if (!this.plugin.settings.typeApiMapping) this.plugin.settings.typeApiMapping = {}; + if (v === '') { + delete this.plugin.settings.typeApiMapping[t.name]; + } else { + this.plugin.settings.typeApiMapping[t.name] = v; + } + void this.plugin.saveSettings(); + }); + } + } + } + + // ─── 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); + this.renderReadingColors(el); + } + + /** + * Reading type badge colors — two fixed entries (Manga, Book), each a color + * picker only (no add/remove/rename), reusing the locked tag-pill row style. + */ + private renderReadingColors(el: HTMLElement): void { + const wrap = el.createDiv({ cls: 'wl-tag-section' }); + wrap.createDiv({ cls: 'wl-settings-section-title', text: 'Reading' }); + + const entries: Array<{ label: string; kind: 'manga' | 'book' }> = [ + { label: 'Manga', kind: 'manga' }, + { label: 'Book', kind: 'book' }, + ]; + + const tagsEl = wrap.createDiv({ cls: 'wl-tags-list' }); + for (const { label, kind } of entries) { + const pill = tagsEl.createDiv({ cls: 'wl-tag-pill' }); + + // Clickable color dot — opens native color picker (matches tag rows). + const dotWrap = pill.createDiv({ cls: 'wl-tag-dot-wrap' }); + const dot = dotWrap.createSpan({ cls: 'wl-tag-dot' }); + dot.style.backgroundColor = this.plugin.settings.readingTypeColors[kind]; + const colorPicker = dotWrap.createEl('input', { + cls: 'wl-tag-color-picker', + attr: { type: 'color', value: this.plugin.settings.readingTypeColors[kind] }, + }); + dot.addEventListener('click', () => colorPicker.click()); + colorPicker.addEventListener('change', () => { + void (async () => { + this.plugin.settings.readingTypeColors[kind] = colorPicker.value; + dot.style.backgroundColor = colorPicker.value; + await this.plugin.saveSettings(); + })(); + }); + + pill.createSpan({ cls: 'wl-tag-name', text: label }); + } + } + + 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'); + activeDocument.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 wl-btn-success', 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 wl-btn-success', 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 renderWatchlist(el: HTMLElement): void { + new Setting(el) + .setName('Default watchlist view') + .setDesc('Which sub-tab opens by default when entering the Watchlist.') + .addDropdown((d) => + d + .addOptions({ list: 'List', cards: 'Cards' }) + .setValue(this.plugin.settings.defaultWatchlistView ?? 'cards') + .onChange(async (v) => { + this.plugin.settings.defaultWatchlistView = v as 'list' | 'cards'; + await this.plugin.saveSettings(); + }), + ); + + 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(); + activeDocument.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('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(); + }), + ); + + // ── 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(); + }), + ); + + // ── Folders ─────────────────────────────────────────────────────────────────── + el.createDiv({ cls: 'wl-settings-section-title', text: 'Folders' }); + + 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.'); + }), + ); + } + + // ─── Reading ───────────────────────────────────────────────────────────────── + + private renderReading(el: HTMLElement): void { + const readingData = this.plugin.readingDataManager; + const settings = readingData.getSettings(); + + el.createDiv({ cls: 'wl-settings-section-title', text: 'Reading' }); + + new Setting(el) + .setName('Reading folder path') + .setDesc('Vault folder where reading note files are generated. Changing this will not move existing files.') + .addText((t) => + t + .setPlaceholder('WatchLog/Reading') + .setValue(settings.defaultFolder) + .onChange((v) => { + const next = v.trim() || 'WatchLog/Reading'; + void readingData.updateSettings({ defaultFolder: next }); + }), + ); + + new Setting(el) + .setName('Default status for new entries') + .setDesc('Status pre-selected in the Add book / Add manga modal.') + .addDropdown((d) => { + const opts: Record<string, string> = {}; + for (const s of SELECTABLE_READING_STATUSES) opts[s] = s; + d + .addOptions(opts) + .setValue(settings.defaultStatus) + .onChange((v) => { + void readingData.updateSettings({ defaultStatus: v as ReadingStatus }); + }); + }); + + new Setting(el) + .setName('Default sub-tab') + .setDesc('Which sub-tab opens by default when you switch to the Reading tab.') + .addDropdown((d) => { + d + .addOptions({ books: 'Books', manga: 'Manga' }) + .setValue(settings.defaultSubTab ?? 'books') + .onChange((v) => { + void readingData.updateSettings({ defaultSubTab: v as 'books' | 'manga' }); + }); + }); + + // ── Import / Export CSV ─────────────────────────────────────────────────────── + el.createDiv({ cls: 'wl-settings-section-title', text: 'Import / Export CSV' }); + + new Setting(el) + .setName('Export to CSV') + .setDesc('Export your Books or Manga library as a CSV file.') + .addButton((b) => + b.setButtonText('Export CSV').onClick(() => { + new ReadingCsvChoiceModal(this.app, 'export', (kind) => { + new ReadingCsvModal(this.app, this.plugin, kind, 'export').open(); + }).open(); + }), + ); + + new Setting(el) + .setName('Import from CSV') + .setDesc('Import Books or Manga from a CSV file, with column mapping and duplicate detection.') + .addButton((b) => + b.setButtonText('Import CSV').onClick(() => { + new ReadingCsvChoiceModal(this.app, 'import', (kind) => { + new ReadingCsvModal(this.app, this.plugin, kind, 'import').open(); + }).open(); + }), + ); + + } + + // ─── 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: 'Which API is used for what', + body: 'WatchLog uses a different API per content type:\n' + + '• Anime — Jikan (MyAnimeList data) or AniList, whichever you set under Settings → API → Anime. No key is needed for either.\n' + + '• Movies & TV Shows — OMDb or TMDB, whichever is set as active under Settings → API. Both need a free key/token.\n' + + '• Books (Reading) — Google Books. A free API key is required; add it under Settings → API → Books.\n' + + '• Manga (Reading) — Jikan (MyAnimeList data). No key needed.\n\n' + + 'The anime API choice only affects new titles — existing titles keep the source they were added with.\n\n' + + 'Make sure the correct type is selected before searching. If Anime is selected and you search for a movie or TV show (or vice versa), it most likely won\'t be found, because each type queries its own API. APIs are a convenience, not a requirement — you can always add a title manually. The "Add from URL" button also relies on an API.', + }, + ], + }, + { + title: '📚 Reading', + items: [ + { + heading: 'Books vs Manga', + body: 'Reading has two independent sub-tabs: Books and Manga. Each keeps its own entries, custom columns, filters, sort order, and saved filter. Set which one opens by default under Settings → Reading.', + }, + { + heading: 'Statuses', + body: 'Reading entries use five statuses: Reading, Completed, Plan to Read, To be released, and Dropped. "To be released" is set automatically when an entry has a release date in the future and cannot be chosen manually — once that date passes, the entry reverts to Plan to Read. Future-dated entries also appear in the shared Upcoming tab.', + }, + { + heading: 'Progress tracking', + body: 'Books track pages read out of total pages. Manga track chapters read out of total chapters, plus volumes read out of total volumes. The card progress bar fills based on pages (books) or chapters (manga); entries with no total show "No progress" instead of a percentage.', + }, + { + heading: 'Notes & favorite quotes', + body: 'Each entry generates a Markdown note file (under your Reading folder) with frontmatter plus Notes and Quotes sections. Favorite quotes you add from an entry\'s detail view are saved into that note as quote callouts, so they live alongside your own notes.', + }, + { + heading: 'Covers', + body: 'Covers are fetched automatically as cards scroll into view — Google Books for books, Jikan for manga. If a cover is missing or wrong, use "Refresh cover" from a card\'s ⋮ menu. (Refreshing a book cover needs the Google Books API key.)', + }, + { + heading: 'Custom columns', + body: 'Use the ⚙ button in the Reading toolbar to add custom columns (text, number, or select). Columns are per sub-tab, and you can filter and sort by them just like the built-in fields.', + }, + ], + }, + { + 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!'; + window.setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); + }); + }); + } + } +} diff --git a/src/TitleDetailModal.ts b/src/TitleDetailModal.ts new file mode 100644 index 0000000..f563bde --- /dev/null +++ b/src/TitleDetailModal.ts @@ -0,0 +1,668 @@ +import { App, Modal } from 'obsidian'; +import type WatchLogPlugin from './main'; +import type { DataManager } from './DataManager'; +import type { WatchLogTitle, WatchLogGroup, Season, TagDefinition } from './types'; +import { formatTime, formatDateDisplay, parseDateInput, getThemedColor, getDisplayPoster } from './types'; +import { EditTitleModal } from './EditTitleModal'; +import { ConfirmModal } from './ConfirmModal'; +import { renderCommunityRating, maybeAutoRefreshCommunityRating } from './CommunityRating'; + +export class TitleDetailModal extends Modal { + private plugin: WatchLogPlugin; + private dataManager: DataManager; + private title: WatchLogTitle; + private onChanged?: () => void; + private changed = false; + private collapsedSeasons: Set<number> = new Set(); + + // Section anchors so we can re-render incrementally + private statsBoxEl: HTMLElement | null = null; + private episodesSectionEl: HTMLElement | null = null; + private starsWrapEl: HTMLElement | null = null; + + constructor( + app: App, + plugin: WatchLogPlugin, + title: WatchLogTitle, + onChanged?: () => void, + ) { + super(app); + this.plugin = plugin; + this.dataManager = plugin.dataManager; + this.title = title; + this.onChanged = onChanged; + this.collapsedSeasons = this.dataManager.getCollapsedSeasonsForTitle(title.id); + } + + onOpen(): void { + const colorTheme = this.plugin.settings.colorTheme ?? 'default'; + // The theme CSS variables (--wl-watched-color, --wl-rating-color, ...) + // are scoped to `.wl-view` and `.wl-view[data-theme=...]`. Mark the + // content root with both so episode boxes, stars, and progress bars + // pick up the active theme just like List view's expand does. + this.modalEl.setAttribute('data-theme', colorTheme); + this.contentEl.setAttribute('data-theme', colorTheme); + this.contentEl.addClass('wl-view'); + this.contentEl.addClass('wl-detail-modal'); + this.renderAll(); + } + + onClose(): void { + this.contentEl.empty(); + if (this.changed && this.onChanged) { + this.onChanged(); + } + } + + private getTagDef(name: string, list: TagDefinition[]): TagDefinition | undefined { + return list.find((d) => d.name === name); + } + + private refreshTitle(): void { + const fresh = this.dataManager.getTitle(this.title.id); + if (fresh) this.title = fresh; + } + + private markChanged(): void { + this.changed = true; + } + + private renderAll(): void { + this.contentEl.empty(); + this.renderHeader(this.contentEl); + this.renderEpisodesSection(this.contentEl); + this.renderRatingSection(this.contentEl); + this.renderNotesSection(this.contentEl); + this.renderDateSection(this.contentEl); + this.renderFooter(this.contentEl); + } + + // ── Header ──────────────────────────────────────────────────────────────── + private renderHeader(parent: HTMLElement): void { + const header = parent.createDiv({ cls: 'wl-detail-header' }); + const left = header.createDiv({ cls: 'wl-detail-header-left' }); + left.createEl('h2', { cls: 'wl-detail-title', text: this.title.title }); + + const badgeRow = left.createDiv({ cls: 'wl-detail-badge-row' }); + const typeDef = this.getTagDef(this.title.type, this.plugin.settings.types); + const typeColor = typeDef + ? getThemedColor(this.title.type, typeDef.color, this.plugin.settings.colorTheme) + : '#888780'; + const typeBadge = badgeRow.createSpan({ + cls: 'wl-card-type-badge', + text: this.title.type, + }); + typeBadge.style.backgroundColor = typeColor; + + if (this.title.externalLink) { + const linkIcon = badgeRow.createEl('a', { cls: 'wl-acc-link-icon', text: '🌐' }); + linkIcon.href = this.title.externalLink; + linkIcon.title = 'Open external link'; + linkIcon.target = '_blank'; + linkIcon.rel = 'noopener noreferrer'; + } + + // Editable status badge, inline to the right of the type badge / link icon. + // Mirrors the Reading detail modal's clickable badge + dropdown, but draws + // from the user-configured watchlist statuses and their colors. + const statusWrap = badgeRow.createSpan({ cls: 'wl-reading-detail-status-wrap' }); + this.renderStatusBadge(statusWrap); + + this.statsBoxEl = header.createDiv({ cls: 'wl-detail-stats-box' }); + this.renderStatsBox(); + } + + private renderStatsBox(): void { + if (!this.statsBoxEl) return; + this.statsBoxEl.empty(); + const t = this.title; + const timeLeft = this.dataManager.calcTimeRemainingForModal(t); + const timeWatched = this.dataManager.calcTimeWatched(t); + const watchedEps = t.watchedEpisodes.length; + const effectiveTotal = this.dataManager.getEffectiveTotal(t); + const progress = this.dataManager.getProgress(t); + + const makeBlock = (value: string, label: string): void => { + const block = this.statsBoxEl!.createDiv({ cls: 'wl-acc-stat-block' }); + block.createDiv({ cls: 'wl-acc-percent', text: value }); + block.createDiv({ cls: 'wl-acc-progress-label', text: label }); + }; + makeBlock(formatTime(timeLeft), 'left'); + makeBlock(formatTime(timeWatched), 'watched'); + makeBlock(`${watchedEps} / ${effectiveTotal}`, 'episodes'); + + const progBlock = this.statsBoxEl.createDiv({ cls: 'wl-acc-header-right' }); + progBlock.createDiv({ cls: 'wl-acc-percent', text: `${progress}%` }); + progBlock.createDiv({ cls: 'wl-acc-progress-label', text: 'progress' }); + const barWrap = progBlock.createDiv({ cls: 'wl-acc-progress-wrap' }); + barWrap.createDiv({ cls: 'wl-progress-bar' }).style.width = `${progress}%`; + } + + // ── Episodes / Seasons ──────────────────────────────────────────────────── + private renderEpisodesSection(parent: HTMLElement): void { + this.episodesSectionEl = parent.createDiv({ cls: 'wl-detail-episodes' }); + this.renderEpisodesBody(); + } + + private renderEpisodesBody(): void { + if (!this.episodesSectionEl) return; + this.episodesSectionEl.empty(); + const t = this.title; + if (t.type === 'Movie') { + const row = this.episodesSectionEl.createDiv({ cls: 'wl-movie-row' }); + const cb = row.createEl('input', { + cls: 'wl-movie-checkbox', + attr: { type: 'checkbox' }, + }); + cb.checked = t.watchedEpisodes.includes(1); + row.createSpan({ cls: 'wl-movie-label', text: 'Watched' }); + cb.addEventListener('change', () => { + void this.dataManager.markEpisodeWatched(t.id, 1, cb.checked).then(() => { + this.refreshTitle(); + this.markChanged(); + this.renderStatsBox(); + }); + }); + return; + } + if (t.seasons.length === 0) { + if (t.totalEpisodes > 0) { + this.renderEpisodeGrid(this.episodesSectionEl, null); + } + return; + } + t.seasons.forEach((season, seasonIdx) => { + let isCollapsed = this.collapsedSeasons.has(seasonIdx); + const seasonWrap = this.episodesSectionEl!.createDiv({ cls: 'wl-season-wrap' }); + const seasonHeader = seasonWrap.createDiv({ cls: 'wl-season-header' }); + const badge = seasonHeader.createSpan({ cls: 'wl-season-badge' }); + badge.textContent = season.name; + const palette = this.plugin.settings.seasonPalette; + badge.style.backgroundColor = palette[seasonIdx % palette.length] ?? '#888780'; + const skipCount = (season.skippedEpisodes ?? []).length; + const skipSuffix = skipCount > 0 ? ` (${skipCount} to skip)` : ''; + seasonHeader.createSpan({ + cls: 'wl-season-ep-count', + text: `${season.episodes} eps${skipSuffix}`, + }); + const chevron = seasonHeader.createSpan({ + cls: `wl-chevron${isCollapsed ? '' : ' is-open'}`, + text: '›', + }); + + if (!isCollapsed) { + this.renderEpisodeGrid(seasonWrap, season); + } + + seasonHeader.addEventListener('click', () => { + if (isCollapsed) { + isCollapsed = false; + this.collapsedSeasons.delete(seasonIdx); + chevron.classList.add('is-open'); + this.renderEpisodeGrid(seasonWrap, season); + } else { + isCollapsed = true; + this.collapsedSeasons.add(seasonIdx); + chevron.classList.remove('is-open'); + seasonWrap.querySelector('.wl-episode-grid')?.remove(); + } + void this.dataManager.persistCollapsedSeasons(this.title.id, this.collapsedSeasons); + }); + }); + } + + private renderEpisodeGrid(parent: HTMLElement, season: Season | null): void { + const t = this.title; + const grid = parent.createDiv({ cls: 'wl-episode-grid' }); + const count = season ? season.episodes : t.totalEpisodes; + const offset = season ? season.offset : 0; + const seasonEps = Array.from({ length: count }, (_, i) => offset + i + 1); + + const fillBtn = grid.createDiv({ cls: 'wl-season-fill-btn' }); + const refreshFillBtn = (): void => { + const watched = new Set(this.title.watchedEpisodes); + const allWatched = seasonEps.length > 0 && seasonEps.every((ep) => watched.has(ep)); + fillBtn.classList.toggle('is-clear', allWatched); + fillBtn.classList.toggle('is-fill', !allWatched); + fillBtn.textContent = allWatched ? '✗' : '✓'; + fillBtn.title = allWatched + ? 'Clear all episodes in this season' + : 'Mark all episodes in this season as watched'; + }; + refreshFillBtn(); + fillBtn.addEventListener('click', () => { + const watched = new Set(this.title.watchedEpisodes); + const allWatched = seasonEps.length > 0 && seasonEps.every((ep) => watched.has(ep)); + void this.dataManager + .markSeasonWatched(this.title.id, seasonEps, !allWatched) + .then(() => { + this.refreshTitle(); + this.markChanged(); + this.renderEpisodesBody(); + this.renderStatsBox(); + }); + }); + + const perSeason = this.plugin.settings.episodeNumbering === 'per-season'; + for (let i = 0; i < count; i++) { + const epNum = offset + i + 1; + const relNum = i + 1; + const displayNum = perSeason ? relNum : epNum; + const box = grid.createDiv({ cls: 'wl-episode-box' }); + const refreshBox = (): void => { + const isWatched = this.title.watchedEpisodes.includes(epNum); + const isSkipped = season ? (season.skippedEpisodes ?? []).includes(relNum) : false; + box.classList.toggle('is-watched', isWatched); + box.classList.toggle('is-skipped', isSkipped); + box.textContent = isSkipped && !isWatched ? '—' : isWatched ? '✓' : String(displayNum); + box.title = `Episode ${epNum}${isSkipped ? ' (skipped)' : ''}`; + }; + refreshBox(); + box.addEventListener('click', () => { + const isWatched = this.title.watchedEpisodes.includes(epNum); + this.dataManager.applyEpisodeWatchedToggle(this.title.id, epNum, !isWatched); + this.refreshTitle(); + this.markChanged(); + refreshBox(); + refreshFillBtn(); + this.renderStatsBox(); + }); + } + } + + // ── Rating ──────────────────────────────────────────────────────────────── + private renderRatingSection(parent: HTMLElement): void { + const wrap = parent.createDiv({ cls: 'wl-detail-rating' }); + wrap.createSpan({ cls: 'wl-stars-label', text: 'Rating' }); + this.starsWrapEl = wrap.createDiv({ cls: 'wl-stars wl-detail-stars' }); + this.renderStars(); + + const rerenderCommunity = (): void => { + const next = wrap.querySelector('.wl-rating-divider'); + let n: ChildNode | null = next; + while (n) { + const toRemove = n; + n = n.nextSibling; + toRemove.parentNode?.removeChild(toRemove); + } + renderCommunityRating(wrap, this.plugin, this.title.id, rerenderCommunity); + this.refreshTitle(); + }; + renderCommunityRating(wrap, this.plugin, this.title.id, rerenderCommunity); + maybeAutoRefreshCommunityRating(this.plugin, this.title.id, rerenderCommunity); + } + + private renderStars(): void { + if (!this.starsWrapEl) return; + this.starsWrapEl.empty(); + for (let i = 1; i <= 5; i++) { + const star = this.starsWrapEl.createSpan({ + cls: `wl-star${this.title.rating >= i ? ' is-active' : ''}`, + text: '★', + }); + star.addEventListener('click', () => { + void (async () => { + const t = this.dataManager.getTitle(this.title.id); + if (!t) return; + t.rating = t.rating === i ? 0 : i; + await this.dataManager.updateTitle(t); + this.refreshTitle(); + this.markChanged(); + this.renderStars(); + })(); + }); + } + } + + // ── Notes ───────────────────────────────────────────────────────────────── + private renderNotesSection(parent: HTMLElement): void { + const wrap = parent.createDiv({ cls: 'wl-detail-notes' }); + wrap.createSpan({ cls: 'wl-stars-label', text: 'Notes' }); + const textarea = wrap.createEl('textarea', { + cls: 'wl-detail-notes-input', + attr: { placeholder: 'Add notes...', rows: '3' }, + }); + textarea.value = this.title.notes; + const autoResize = (): void => { + textarea.style.height = 'auto'; + textarea.style.height = `${textarea.scrollHeight}px`; + }; + textarea.addEventListener('input', autoResize); + window.setTimeout(autoResize, 0); + textarea.addEventListener('blur', () => { + if (textarea.value === this.title.notes) return; + void (async () => { + const t = this.dataManager.getTitle(this.title.id); + if (!t) return; + t.notes = textarea.value; + await this.dataManager.updateTitle(t); + this.refreshTitle(); + this.markChanged(); + })(); + }); + } + + // ── Date watched ────────────────────────────────────────────────────────── + private renderDateSection(parent: HTMLElement): void { + const wrap = parent.createDiv({ cls: 'wl-detail-date' }); + wrap.createSpan({ cls: 'wl-stars-label', text: 'Date watched' }); + const todayBtn = wrap.createEl('button', { + cls: 'wl-btn wl-btn-sm wl-footer-today-btn', + text: 'Today', + }); + const dateInput = wrap.createEl('input', { + cls: 'wl-footer-date', + attr: { type: 'text', placeholder: 'Dd/mm/yyyy', maxlength: '10' }, + }); + dateInput.value = formatDateDisplay(this.title.dateFinished); + const refreshDimmed = (): void => { + todayBtn.toggleClass('is-dimmed', !!dateInput.value.trim()); + }; + refreshDimmed(); + dateInput.addEventListener('change', () => { + void (async () => { + const t = this.dataManager.getTitle(this.title.id); + if (!t) return; + const parsed = parseDateInput(dateInput.value); + if (dateInput.value.trim() && !parsed) { + dateInput.addClass('wl-input-error'); + return; + } + dateInput.removeClass('wl-input-error'); + t.dateFinished = parsed; + await this.dataManager.updateTitle(t); + this.refreshTitle(); + this.markChanged(); + refreshDimmed(); + })(); + }); + todayBtn.addEventListener('click', () => { + if (dateInput.value.trim()) return; + const now = new Date(); + const dd = String(now.getDate()).padStart(2, '0'); + const mm = String(now.getMonth() + 1).padStart(2, '0'); + const yyyy = now.getFullYear(); + dateInput.value = `${dd}/${mm}/${yyyy}`; + refreshDimmed(); + dateInput.dispatchEvent(new Event('change')); + }); + } + + // ── Status ──────────────────────────────────────────────────────────────── + private statusColor(name: string): string { + const def = this.getTagDef(name, this.plugin.settings.statuses); + return def + ? getThemedColor(name, def.color, this.plugin.settings.colorTheme) + : '#888780'; + } + + private renderStatusBadge(wrap: HTMLElement): void { + wrap.empty(); + const status = this.title.status; + const badge = wrap.createSpan({ cls: 'wl-reading-detail-status', text: status }); + badge.style.backgroundColor = this.statusColor(status); + badge.title = 'Click to change status'; + badge.addEventListener('click', (e) => { + e.stopPropagation(); + this.openStatusDropdown(badge, wrap); + }); + } + + private openStatusDropdown(anchor: HTMLElement, wrap: HTMLElement): void { + this.contentEl.querySelectorAll('.wl-reading-status-dropdown').forEach((el) => el.remove()); + const rect = anchor.getBoundingClientRect(); + const dropdown = this.contentEl.createDiv({ cls: 'wl-reading-status-dropdown' }); + dropdown.style.top = `${rect.bottom + 4}px`; + dropdown.style.left = `${rect.left}px`; + // Capture the owning document once so add/remove can't desync across popout windows. + const doc = this.contentEl.ownerDocument; + + for (const s of this.plugin.settings.statuses) { + if (s.name === 'To be released') continue; + const opt = dropdown.createDiv({ cls: 'wl-reading-status-option' }); + const dot = opt.createSpan({ cls: 'wl-reading-status-option-dot' }); + dot.style.backgroundColor = this.statusColor(s.name); + opt.createSpan({ text: s.name }); + opt.addEventListener('click', () => { + dropdown.remove(); + doc.removeEventListener('mousedown', closeListener, true); + void this.saveStatus(s.name).then(() => this.renderStatusBadge(wrap)); + }); + } + + const closeListener = (e: MouseEvent): void => { + if (!dropdown.contains(e.target as Node)) { + dropdown.remove(); + doc.removeEventListener('mousedown', closeListener, true); + } + }; + window.setTimeout(() => doc.addEventListener('mousedown', closeListener, true), 0); + } + + private async saveStatus(status: string): Promise<void> { + const t = this.dataManager.getTitle(this.title.id); + if (!t) return; + t.status = status; + await this.dataManager.updateTitle(t); + this.refreshTitle(); + this.markChanged(); + } + + // ── Footer ──────────────────────────────────────────────────────────────── + private renderFooter(parent: HTMLElement): void { + const footer = parent.createDiv({ cls: 'wl-detail-footer' }); + const removeBtn = footer.createEl('button', { + cls: 'wl-delete-btn wl-btn-danger wl-detail-remove', + text: 'Remove', + }); + removeBtn.addEventListener('click', () => { + new ConfirmModal( + this.plugin.app, + `Remove "${this.title.title}" from watchlog?`, + () => { + void this.dataManager.removeTitle(this.title.id).then(() => { + this.markChanged(); + this.close(); + }); + }, + ).open(); + }); + + const editBtn = footer.createEl('button', { + cls: 'wl-edit-btn wl-detail-edit', + text: 'Edit', + }); + editBtn.addEventListener('click', () => { + const current = this.dataManager.getTitle(this.title.id); + if (!current) return; + this.close(); + new EditTitleModal(this.plugin.app, this.plugin, this.dataManager, current, () => { + if (this.onChanged) this.onChanged(); + }).open(); + }); + } +} + +// ── Group detail modal ────────────────────────────────────────────────────── +export class GroupDetailModal extends Modal { + private plugin: WatchLogPlugin; + private dataManager: DataManager; + private group: WatchLogGroup; + private members: WatchLogTitle[]; + private onChanged?: () => void; + private changed = false; + + constructor( + app: App, + plugin: WatchLogPlugin, + group: WatchLogGroup, + members: WatchLogTitle[], + onChanged?: () => void, + ) { + super(app); + this.plugin = plugin; + this.dataManager = plugin.dataManager; + this.group = group; + this.members = members; + this.onChanged = onChanged; + } + + onOpen(): void { + const colorTheme = this.plugin.settings.colorTheme ?? 'default'; + this.modalEl.setAttribute('data-theme', colorTheme); + this.contentEl.setAttribute('data-theme', colorTheme); + this.contentEl.addClass('wl-view'); + this.contentEl.addClass('wl-detail-modal'); + this.renderAll(); + } + + onClose(): void { + this.contentEl.empty(); + if (this.changed && this.onChanged) this.onChanged(); + } + + private renderAll(): void { + this.contentEl.empty(); + const header = this.contentEl.createDiv({ cls: 'wl-detail-header' }); + const left = header.createDiv({ cls: 'wl-detail-header-left' }); + left.createEl('h2', { cls: 'wl-detail-title', text: this.group.name }); + left.createDiv({ + cls: 'wl-detail-group-meta', + text: `${this.members.length} title${this.members.length !== 1 ? 's' : ''}`, + }); + + const totalEps = this.members.reduce( + (s, m) => s + this.dataManager.getEffectiveTotal(m), + 0, + ); + const watchedEps = this.members.reduce((s, m) => s + m.watchedEpisodes.length, 0); + const progress = totalEps > 0 ? Math.round((watchedEps / totalEps) * 100) : 0; + const statsBox = header.createDiv({ cls: 'wl-detail-stats-box' }); + const progBlock = statsBox.createDiv({ cls: 'wl-acc-header-right' }); + progBlock.createDiv({ cls: 'wl-acc-percent', text: `${progress}%` }); + progBlock.createDiv({ cls: 'wl-acc-progress-label', text: 'progress' }); + const barWrap = progBlock.createDiv({ cls: 'wl-acc-progress-wrap' }); + barWrap.createDiv({ cls: 'wl-progress-bar' }).style.width = `${progress}%`; + + const grid = this.contentEl.createDiv({ cls: 'wl-cards-grid wl-detail-group-grid' }); + for (const m of this.members) { + const card = buildTitleCardElement(this.plugin, m, { + onOpenDetail: () => { + this.close(); + new TitleDetailModal(this.plugin.app, this.plugin, m, () => { + this.changed = true; + if (this.onChanged) this.onChanged(); + }).open(); + }, + onEdit: () => { + this.close(); + new EditTitleModal( + this.plugin.app, + this.plugin, + this.dataManager, + m, + () => { + this.changed = true; + if (this.onChanged) this.onChanged(); + }, + ).open(); + }, + }); + grid.appendChild(card); + } + } +} + +// Builds a standalone .wl-card element matching the Cards grid look. Used by +// the Cards grid (via ListTab) and by GroupDetailModal so member tiles look +// identical to the main view. Posters with no cached URL get fetched eagerly +// here since a group modal has a small, finite member count. +export function buildTitleCardElement( + plugin: WatchLogPlugin, + title: WatchLogTitle, + handlers: { onOpenDetail: () => void; onEdit: () => void }, +): HTMLElement { + const settings = plugin.settings; + const typeDef = settings.types.find((d) => d.name === title.type); + const statusDef = settings.statuses.find((d) => d.name === title.status); + const typeColor = typeDef + ? getThemedColor(title.type, typeDef.color, settings.colorTheme) + : '#888780'; + + const tmp = activeDocument.createElement('div'); + const card = tmp.createDiv({ cls: 'wl-card' }); + card.dataset.titleId = title.id; + + const placeholder = card.createDiv({ cls: 'wl-card-poster-placeholder' }); + placeholder.style.backgroundColor = typeColor; + const letter = (title.title.trim().charAt(0) || '?').toUpperCase(); + placeholder.createSpan({ text: letter }); + + const img = card.createEl('img', { cls: 'wl-card-poster' }); + img.alt = title.title; + + const showImg = (url: string): void => { + img.src = url; + img.style.display = 'block'; + placeholder.style.display = 'none'; + }; + const showPlaceholder = (): void => { + img.style.display = 'none'; + placeholder.style.display = ''; + }; + + const display = getDisplayPoster(title); + const isManual = !!(title.manualPosterUrl && title.manualPosterUrl.trim() !== ''); + if (display && display.startsWith('http')) { + showImg(display); + img.onerror = () => { + showPlaceholder(); + if (!isManual) plugin.dataManager.updatePosterUrl(title.id, 'none'); + }; + } else if (!isManual && title.posterUrl === '') { + placeholder.addClass('is-loading'); + void plugin.posterService?.enqueue(title).then((url) => { + placeholder.removeClass('is-loading'); + if (url) showImg(url); + }); + } + + if (statusDef) { + const statusBadge = card.createSpan({ + cls: 'wl-card-status-badge', + text: title.status, + }); + statusBadge.style.backgroundColor = getThemedColor( + title.status, + statusDef.color, + settings.colorTheme, + ); + } + + const overlay = card.createDiv({ cls: 'wl-card-overlay' }); + overlay.createSpan({ cls: 'wl-card-title', text: title.title }); + const typeBadge = overlay.createSpan({ cls: 'wl-card-type-badge', text: title.type }); + typeBadge.style.backgroundColor = typeColor; + + const total = title.totalEpisodes; + if (total && total > 0) { + const isCompleted = title.status === 'Completed'; + const ratio = isCompleted + ? 1 + : Math.max(0, Math.min(1, title.watchedEpisodes.length / total)); + const bar = overlay.createDiv({ cls: 'wl-card-progress-bar' }); + const fill = bar.createDiv({ cls: 'wl-card-progress-fill' }); + fill.style.width = `${ratio * 100}%`; + } + + const menuBtn = card.createEl('button', { cls: 'wl-card-menu-btn', text: '⋮' }); + menuBtn.setAttr('aria-label', 'Edit'); + menuBtn.addEventListener('click', (e) => { + e.stopPropagation(); + handlers.onEdit(); + }); + + card.addEventListener('click', () => handlers.onOpenDetail()); + + return card; +} diff --git a/src/UpcomingFinderModal.ts b/src/UpcomingFinderModal.ts new file mode 100644 index 0000000..e9c156e --- /dev/null +++ b/src/UpcomingFinderModal.ts @@ -0,0 +1,40 @@ +import { App, FuzzySuggestModal } from 'obsidian'; + +/** A unified finder entry spanning watchlist titles and reading items. */ +export interface UpcomingFinderItem { + source: 'watchlist' | 'reading'; + kind?: 'book' | 'manga'; + id: string; + title: string; + /** Type/kind label shown in parentheses (e.g. "Anime", "Book", "Manga"). */ + typeLabel: string; +} + +/** + * The shared "+ add" finder for the Upcoming tab. Extends the watchlist finder to + * also surface Reading items, so a reading title can be added to Upcoming through + * the exact same pathway — never a separate add flow. + */ +export class UpcomingFinderModal extends FuzzySuggestModal<UpcomingFinderItem> { + private items: UpcomingFinderItem[]; + private onSelect: (item: UpcomingFinderItem) => void; + + constructor(app: App, items: UpcomingFinderItem[], onSelect: (item: UpcomingFinderItem) => void) { + super(app); + this.items = items; + this.onSelect = onSelect; + this.setPlaceholder('Search a title to add to Upcoming...'); + } + + getItems(): UpcomingFinderItem[] { + return this.items; + } + + getItemText(item: UpcomingFinderItem): string { + return `${item.title} (${item.typeLabel})`; + } + + onChooseItem(item: UpcomingFinderItem): void { + this.onSelect(item); + } +} diff --git a/src/WatchLogView.ts b/src/WatchLogView.ts index be0d4ea..6d99234 100644 --- a/src/WatchLogView.ts +++ b/src/WatchLogView.ts @@ -1,240 +1,555 @@ -import { ItemView, WorkspaceLeaf } from 'obsidian'; -import type WatchLogPlugin from './main'; -import type { DataManager } from './DataManager'; -import { DashboardTab } from './DashboardTab'; -import { ListTab } from './ListTab'; -import { AirtimeTab } from './AirtimeTab'; -import { CustomListsTab } from './CustomListsTab'; -import { DraftsTab } from './DraftsTab'; - -export const WATCHLOG_VIEW_TYPE = 'watchlog-view'; - -type TabName = 'dashboard' | 'watchlist' | 'upcoming' | 'custom-lists' | 'drafts'; - -export class WatchLogView extends ItemView { - private plugin: WatchLogPlugin; - private dataManager: DataManager; - private activeTab: TabName; - private dashboardTab: DashboardTab | null = null; - private listTab: ListTab | null = null; - private airtimeTab: AirtimeTab | null = null; - private customListsTab: CustomListsTab | null = null; - private draftsTab: DraftsTab | null = null; - private airtimeBtn: HTMLButtonElement | null = null; - private draftsBtn: HTMLButtonElement | null = null; - private tabContentEl: HTMLElement | null = null; - private dataChangeListener: () => void; - - constructor(leaf: WorkspaceLeaf, plugin: WatchLogPlugin, dataManager: DataManager) { - super(leaf); - this.plugin = plugin; - this.dataManager = dataManager; - // defaultView is 'dashboard' | 'watchlist'; map to internal TabName - const dv = plugin.settings.defaultView as string; - this.activeTab = (dv === 'watchlist' || dv === 'dashboard') ? dv as TabName : 'watchlist'; - // Re-render in-place so tab instances (and their state) are preserved - this.dataChangeListener = () => this.refreshActiveTab(); - } - - getViewType(): string { - return WATCHLOG_VIEW_TYPE; - } - - getDisplayText(): string { - return 'Watchlog'; - } - - getIcon(): string { - return 'tv'; - } - - async onOpen(): Promise<void> { - await super.onOpen(); - this.dataManager.onChange(this.dataChangeListener); - this.buildUI(); - } - - async onClose(): Promise<void> { - await super.onClose(); - this.dataManager.offChange(this.dataChangeListener); - this.destroyDraftsTab(); - } - - /** Reapplies the active colour theme and rebuilds the UI. Used by SettingsTab. */ - refreshUI(): void { - this.applyColorTheme(this.contentEl); - this.buildUI(); - } - - private applyColorTheme(root: HTMLElement): void { - const colorTheme = this.plugin.settings.colorTheme ?? 'default'; - if (colorTheme === 'default') { - root.removeAttribute('data-theme'); - } else { - root.setAttribute('data-theme', colorTheme); - } - } - - private destroyDraftsTab(): void { - if (this.draftsTab) { - this.draftsTab.destroy(); - this.draftsTab = null; - } - } - - private buildUI(): void { - // Destroy any live drafts listener before rebuilding the UI - this.destroyDraftsTab(); - - const root = this.contentEl; - root.empty(); - root.addClass('wl-view'); - this.applyColorTheme(root); - - // Tab bar - const tabBar = root.createDiv({ cls: 'wl-tab-bar' }); - const dashBtn = tabBar.createEl('button', { - cls: `wl-tab-btn${this.activeTab === 'dashboard' ? ' is-active' : ''}`, - text: 'Dashboard', - }); - const listBtn = tabBar.createEl('button', { - cls: `wl-tab-btn${this.activeTab === 'watchlist' ? ' is-active' : ''}`, - text: 'Watchlist', - }); - this.airtimeBtn = tabBar.createEl('button', { - cls: `wl-tab-btn${this.activeTab === 'upcoming' ? ' is-active' : ''}`, - text: 'Upcoming', - }); - const customListsBtn = tabBar.createEl('button', { - cls: `wl-tab-btn${this.activeTab === 'custom-lists' ? ' is-active' : ''}`, - text: 'Custom lists', - }); - this.draftsBtn = tabBar.createEl('button', { - cls: `wl-tab-btn${this.activeTab === 'drafts' ? ' is-active' : ''}`, - text: 'Drafts', - }); - - const allBtns = [dashBtn, listBtn, this.airtimeBtn, customListsBtn, this.draftsBtn]; - - dashBtn.addEventListener('click', () => { - if (this.activeTab === 'dashboard') return; - this.destroyDraftsTab(); - this.activeTab = 'dashboard'; - allBtns.forEach((b) => b.removeClass('is-active')); - dashBtn.addClass('is-active'); - this.renderTabContent(); - }); - - listBtn.addEventListener('click', () => { - if (this.activeTab === 'watchlist') return; - this.destroyDraftsTab(); - this.activeTab = 'watchlist'; - allBtns.forEach((b) => b.removeClass('is-active')); - listBtn.addClass('is-active'); - this.renderTabContent(); - }); - - this.airtimeBtn.addEventListener('click', () => { - if (this.activeTab === 'upcoming') return; - this.destroyDraftsTab(); - this.activeTab = 'upcoming'; - allBtns.forEach((b) => b.removeClass('is-active')); - this.airtimeBtn!.addClass('is-active'); - this.renderTabContent(); - }); - - customListsBtn.addEventListener('click', () => { - if (this.activeTab === 'custom-lists') return; - this.destroyDraftsTab(); - this.activeTab = 'custom-lists'; - allBtns.forEach((b) => b.removeClass('is-active')); - customListsBtn.addClass('is-active'); - this.renderTabContent(); - }); - - this.draftsBtn.addEventListener('click', () => { - if (this.activeTab === 'drafts') return; - this.activeTab = 'drafts'; - allBtns.forEach((b) => b.removeClass('is-active')); - this.draftsBtn!.addClass('is-active'); - this.renderTabContent(); - }); - - this.tabContentEl = root.createDiv({ cls: 'wl-tab-content' }); - this.renderTabContent(); - - // Initialize Upcoming badge immediately - this.updateUpcomingBadge(); - } - - private renderTabContent(): void { - if (!this.tabContentEl) return; - this.tabContentEl.empty(); - - if (this.activeTab === 'dashboard') { - this.dashboardTab = new DashboardTab(this.tabContentEl, this.plugin, this.dataManager); - this.dashboardTab.render(); - } else if (this.activeTab === 'watchlist') { - // Always create a new instance when switching to Watchlist tab so that the - // container element is correct; state (filters, expandedId) is preserved - // via the instance kept on this.listTab during data-change refreshes. - this.listTab?.destroy(); - this.listTab = new ListTab(this.tabContentEl, this.plugin, this.dataManager); - this.listTab.render(); - } else if (this.activeTab === 'upcoming') { - this.airtimeTab = new AirtimeTab(this.tabContentEl, this.plugin, this.dataManager, (count) => { - if (this.airtimeBtn) { - this.airtimeBtn.textContent = count > 0 ? `Upcoming (${count})` : 'Upcoming'; - } - }); - this.airtimeTab.render(); - } else if (this.activeTab === 'drafts') { - this.draftsTab = new DraftsTab( - this.tabContentEl, - this.plugin, - this.dataManager, - (count) => { - if (this.draftsBtn) { - this.draftsBtn.textContent = count > 0 ? `Drafts (${count})` : 'Drafts'; - } - }, - ); - void this.draftsTab.render(); - } else { - // custom-lists - this.customListsTab = new CustomListsTab(this.tabContentEl, this.plugin, this.dataManager); - void this.customListsTab.render(); - } - } - - /** - * Called by the data change listener. Re-renders the active tab without - * destroying the ListTab instance, so expandedId and filter state survive. - */ - private refreshActiveTab(): void { - this.applyColorTheme(this.contentEl); - if (this.activeTab === 'watchlist' && this.listTab) { - this.listTab.render(); - } else if (this.activeTab === 'dashboard' && this.dashboardTab) { - this.dashboardTab.render(); - } else if (this.activeTab === 'upcoming' && this.airtimeTab) { - this.airtimeTab.render(); - } else if (this.activeTab === 'custom-lists' && this.customListsTab) { - void this.customListsTab.render(); - } else if (this.activeTab === 'drafts' && this.draftsTab) { - void this.draftsTab.render(); - } else { - this.renderTabContent(); - } - - this.updateUpcomingBadge(); - } - - private updateUpcomingBadge(): void { - if (!this.airtimeBtn) return; - const count = AirtimeTab.getAiredDueCount(this.dataManager) + AirtimeTab.getMaybeDueCount(this.dataManager); - this.airtimeBtn.textContent = count > 0 ? `Upcoming (${count})` : 'Upcoming'; - } - -} - +import { ItemView, Platform, WorkspaceLeaf } from 'obsidian'; +import type WatchLogPlugin from './main'; +import type { DataManager } from './DataManager'; +import { DashboardTab } from './DashboardTab'; +import { ListTab } from './ListTab'; +import { AirtimeTab } from './AirtimeTab'; +import { CustomListsTab } from './CustomListsTab'; +import { DraftsTab } from './DraftsTab'; +import { ReadingTab } from './ReadingTab'; +import { LogTab } from './LogTab'; + +export const WATCHLOG_VIEW_TYPE = 'watchlog-view'; + +export type TabName = 'dashboard' | 'watchlist' | 'upcoming' | 'reading' | 'custom-lists' | 'drafts' | 'log'; + +const TAB_CLASSES = ['wl-dashboard', 'wl-list', 'wl-airtime', 'wl-reading-tab', 'wl-custom-lists', 'wl-log-tab']; + +export class WatchLogView extends ItemView { + private plugin: WatchLogPlugin; + private dataManager: DataManager; + private activeTab: TabName; + private dashboardTab: DashboardTab | null = null; + private listTab: ListTab | null = null; + private airtimeTab: AirtimeTab | null = null; + private readingTab: ReadingTab | null = null; + private customListsTab: CustomListsTab | null = null; + private draftsTab: DraftsTab | null = null; + private logTab: LogTab | null = null; + private airtimeBtn: HTMLButtonElement | null = null; + private draftsBtn: HTMLButtonElement | null = null; + private tabButtons: Partial<Record<TabName, HTMLButtonElement>> = {}; + private tabContentEl: HTMLElement | null = null; + private dataChangeListener: () => void; + private mobileResizeObserver: ResizeObserver | null = null; + private mobileSizerRaf: number | null = null; + private lastAppliedTabHeight = -1; + // --- Mobile keyboard "edit pin" state (see edit-pin block below) --- + private editPinActive = false; + private smallestEditHeight = -1; + private pinReleaseTimer: number | null = null; + private focusListenersBound = false; + + constructor(leaf: WorkspaceLeaf, plugin: WatchLogPlugin, dataManager: DataManager) { + super(leaf); + this.plugin = plugin; + this.dataManager = dataManager; + // The panel always opens on the Dashboard tab. + this.activeTab = 'dashboard'; + // Re-render in-place so tab instances (and their state) are preserved + this.dataChangeListener = () => { + this.refreshActiveTab(); + }; + } + + getViewType(): string { + return WATCHLOG_VIEW_TYPE; + } + + getDisplayText(): string { + return 'Watchlog'; + } + + getIcon(): string { + return 'tv'; + } + + async onOpen(): Promise<void> { + await super.onOpen(); + this.dataManager.onChange(this.dataChangeListener); + this.buildUI(); + + if (Platform.isMobile) { + this.setupTabContentSizer(); + } + } + + // ===================== Mobile keyboard layout fix ===================== + // Permanent fix for the iOS/Android on-screen-keyboard layout collapse. + // When the keyboard opens, this WebView freezes .wl-tab-content at its + // content height instead of distributing .wl-view's free space via + // flex-grow, leaving the tab content the wrong size until a reflow forces + // it. Fix: observe .wl-view (contentEl) and, on every settle (keyboard + // open/close, rotation, split resize), imperatively set an explicit pixel + // height (+ flex:none !important) on the *current* .wl-tab-content. The + // element is re-queried in the callback so it survives tab-content rebuilds. + // ===================================================================== + private setupTabContentSizer(): void { + // Never run more than one observer (onOpen could run again, etc.). + this.mobileResizeObserver?.disconnect(); + this.mobileResizeObserver = new ResizeObserver(() => { + // Coalesce bursts of resize events (keyboard animation fires many) + // into a single write per frame so we don't thrash the DOM. + if (this.mobileSizerRaf !== null) return; + this.mobileSizerRaf = window.requestAnimationFrame(() => { + this.mobileSizerRaf = null; + this.applyTabContentHeight(); + }); + }); + this.mobileResizeObserver.observe(this.contentEl); + + // --- Mobile keyboard fix, layer 2: the "edit pin" (focus listeners) --- + // Detect when an inline editor inside the scroller (.wl-tab-content) gains + // or loses focus, so we can pin/release the scroller height. Bound once. + if (!this.focusListenersBound) { + this.registerDomEvent(this.contentEl, 'focusin', (e: FocusEvent) => this.onScrollerFocusIn(e)); + this.registerDomEvent(this.contentEl, 'focusout', (e: FocusEvent) => this.onScrollerFocusOut(e)); + this.focusListenersBound = true; + } + } + + // ===================== Mobile keyboard fix — edit pin ===================== + // Layer 2 of the permanent mobile keyboard fix. On short lists (notably the + // Custom Lists tab) inline-editing a cell opens the keyboard, but some resize + // readings during that window momentarily report the FULL no-keyboard height. + // Honouring them re-expands .wl-tab-content below the keyboard and exposes a + // dark band under the short content. Fix: while an editor inside the scroller + // is focused, pin the scroller to the SMALLEST clientHeight observed and + // ignore any larger reading. Release is guaranteed on focusout (after a grace + // window, so cell-to-cell navigation doesn't flicker), with a safety net in + // applyTabContentHeight() for the case where focus leaves without a focusout. + // ========================================================================= + + /** True when `target` is an input/textarea/contenteditable inside .wl-tab-content. */ + private isEditorInScroller(target: EventTarget | null): boolean { + const node = target as HTMLElement | null; + if (!node || typeof node.tagName !== 'string') return false; + const tabContent = this.contentEl.querySelector(':scope > .wl-tab-content'); + if (!tabContent || !tabContent.contains(node)) return false; + const tag = node.tagName.toLowerCase(); + return tag === 'input' || tag === 'textarea' || node.isContentEditable === true; + } + + private onScrollerFocusIn(e: FocusEvent): void { + if (!Platform.isMobile || !this.isEditorInScroller(e.target)) return; + // Re-focusing within the grace window (cell navigation) — keep the pin. + if (this.pinReleaseTimer !== null) { + window.clearTimeout(this.pinReleaseTimer); + this.pinReleaseTimer = null; + } + // Start a fresh editing session: re-measure the smallest height. + if (!this.editPinActive) this.smallestEditHeight = -1; + this.editPinActive = true; + // Force the next reading (keyboard-shrunk) to be applied & pinned. + this.lastAppliedTabHeight = -1; + this.applyTabContentHeight(); + } + + private onScrollerFocusOut(e: FocusEvent): void { + if (!this.isEditorInScroller(e.target)) return; + if (this.pinReleaseTimer !== null) window.clearTimeout(this.pinReleaseTimer); + // Grace window: only release if focus has truly left every editor in the + // scroller (cell-to-cell navigation re-focuses within this window). + this.pinReleaseTimer = window.setTimeout(() => { + this.pinReleaseTimer = null; + const active = this.contentEl.ownerDocument.activeElement; + if (this.isEditorInScroller(active)) return; + this.releaseEditPin(); + }, 250); + } + + /** Drops the pin and hands sizing back to the ResizeObserver (full height). */ + private releaseEditPin(): void { + if (!this.editPinActive) return; + this.editPinActive = false; + this.smallestEditHeight = -1; + const tabContent = this.contentEl.querySelector<HTMLElement>(':scope > .wl-tab-content'); + // Remove the pinned height and let the observer re-assert the correct + // full height now the keyboard has closed. + tabContent?.style.removeProperty('height'); + this.lastAppliedTabHeight = -1; + this.applyTabContentHeight(); + } + + private applyTabContentHeight(): void { + const viewContent = this.contentEl; + if (!viewContent) return; + const tabBar = viewContent.querySelector<HTMLElement>(':scope > .wl-tab-bar'); + const tabContent = viewContent.querySelector<HTMLElement>(':scope > .wl-tab-content'); + if (!tabContent) return; + + const wlViewClientHeight = viewContent.clientHeight; + const tabBarHeight = tabBar ? tabBar.offsetHeight : 0; + let target = wlViewClientHeight - tabBarHeight; + + // Guard: don't write until wl-view has a real measured height. + if (target <= 0) return; + + // --- Edit pin layer --------------------------------------------------- + if (this.editPinActive) { + // Safety net: focus left the scroller without a focusout firing + // (e.g. the editor element was removed on save). Release now so the + // scroller doesn't stay pinned at the keyboard-shrunk height. + if (!this.isEditorInScroller(this.contentEl.ownerDocument.activeElement)) { + this.releaseEditPin(); + return; + } + // Track the smallest height seen while editing and pin to it, + // ignoring any larger (no-keyboard) reading during this window. + if (this.smallestEditHeight < 0 || target < this.smallestEditHeight) { + this.smallestEditHeight = target; + } + target = this.smallestEditHeight; + } + + // Guard: skip redundant writes when the height hasn't changed. + if (target === this.lastAppliedTabHeight) return; + this.lastAppliedTabHeight = target; + + // Explicit px height + flex:none beats the `flex: 1 1 0%` base rule, which + // this WebView fails to distribute. min-height:0 / overflow-y stay intact. + tabContent.style.setProperty('height', `${target}px`, 'important'); + tabContent.style.setProperty('flex', 'none', 'important'); + } + + /** Called after tab content changes on mobile to re-apply the height. */ + refreshMobileLayout(): void { + if (!Platform.isMobile || !this.mobileResizeObserver) return; + // Tab switch recreates .wl-tab-content with no inline height; force a + // re-apply even if the wl-view height itself hasn't changed. + this.lastAppliedTabHeight = -1; + this.applyTabContentHeight(); + } + + async onClose(): Promise<void> { + await super.onClose(); + this.dataManager.offChange(this.dataChangeListener); + this.overflowObserver?.disconnect(); + this.overflowObserver = null; + if (this.mobileResizeObserver) { + this.mobileResizeObserver.disconnect(); + this.mobileResizeObserver = null; + } + if (this.mobileSizerRaf !== null) { + window.cancelAnimationFrame(this.mobileSizerRaf); + this.mobileSizerRaf = null; + } + // Clear edit-pin timer/state (focus listeners auto-removed via registerDomEvent). + if (this.pinReleaseTimer !== null) { + window.clearTimeout(this.pinReleaseTimer); + this.pinReleaseTimer = null; + } + this.editPinActive = false; + this.smallestEditHeight = -1; + // Clear the explicit sizing the mobile keyboard fix applied to wl-tab-content. + const tabContent = this.contentEl.querySelector<HTMLElement>('.wl-tab-content'); + if (tabContent) { + tabContent.style.removeProperty('height'); + tabContent.style.removeProperty('flex'); + } + this.destroyDraftsTab(); + this.customListsTab?.destroy(); + this.listTab?.destroy(); + this.readingTab?.destroy(); + this.readingTab = null; + this.logTab?.destroy(); + this.logTab = null; + } + + /** Reapplies the active colour theme and rebuilds the UI. Used by SettingsTab. */ + refreshUI(): void { + this.applyColorTheme(this.contentEl); + this.buildUI(); + } + + private applyColorTheme(root: HTMLElement): void { + const colorTheme = this.plugin.settings.colorTheme ?? 'default'; + if (colorTheme === 'default') { + root.removeAttribute('data-theme'); + } else { + root.setAttribute('data-theme', colorTheme); + } + } + + private destroyDraftsTab(): void { + if (this.draftsTab) { + this.draftsTab.destroy(); + this.draftsTab = null; + } + } + + private buildUI(): void { + // Destroy any live drafts listener before rebuilding the UI + this.destroyDraftsTab(); + + const root = this.contentEl; + root.empty(); + root.addClass('wl-view'); + this.applyColorTheme(root); + + // Tab bar + const tabBar = root.createDiv({ cls: 'wl-tab-bar' }); + const dashBtn = tabBar.createEl('button', { + cls: `wl-tab-btn${this.activeTab === 'dashboard' ? ' is-active' : ''}`, + text: 'Dashboard', + }); + const listBtn = tabBar.createEl('button', { + cls: `wl-tab-btn${this.activeTab === 'watchlist' ? ' is-active' : ''}`, + text: 'Watchlist', + }); + const readingBtn = tabBar.createEl('button', { + cls: `wl-tab-btn${this.activeTab === 'reading' ? ' is-active' : ''}`, + text: 'Reading', + }); + this.airtimeBtn = tabBar.createEl('button', { + cls: `wl-tab-btn${this.activeTab === 'upcoming' ? ' is-active' : ''}`, + text: 'Upcoming', + }); + const customListsBtn = tabBar.createEl('button', { + cls: `wl-tab-btn${this.activeTab === 'custom-lists' ? ' is-active' : ''}`, + text: 'Custom lists', + }); + this.draftsBtn = tabBar.createEl('button', { + cls: `wl-tab-btn${this.activeTab === 'drafts' ? ' is-active' : ''}`, + text: 'Drafts', + }); + const logBtn = tabBar.createEl('button', { + cls: `wl-tab-btn${this.activeTab === 'log' ? ' is-active' : ''}`, + text: 'Log', + }); + + const allBtns = [dashBtn, listBtn, this.airtimeBtn, readingBtn, customListsBtn, this.draftsBtn, logBtn]; + this.tabButtons = { + dashboard: dashBtn, + watchlist: listBtn, + upcoming: this.airtimeBtn, + reading: readingBtn, + 'custom-lists': customListsBtn, + drafts: this.draftsBtn, + log: logBtn, + }; + + dashBtn.addEventListener('click', () => { + if (this.activeTab === 'dashboard') return; + this.destroyDraftsTab(); + this.activeTab = 'dashboard'; + allBtns.forEach((b) => b.removeClass('is-active')); + dashBtn.addClass('is-active'); + this.renderTabContent(); + }); + + listBtn.addEventListener('click', () => { + if (this.activeTab === 'watchlist') return; + this.destroyDraftsTab(); + this.activeTab = 'watchlist'; + allBtns.forEach((b) => b.removeClass('is-active')); + listBtn.addClass('is-active'); + this.renderTabContent(); + }); + + this.airtimeBtn.addEventListener('click', () => { + if (this.activeTab === 'upcoming') return; + this.destroyDraftsTab(); + this.activeTab = 'upcoming'; + allBtns.forEach((b) => b.removeClass('is-active')); + this.airtimeBtn!.addClass('is-active'); + this.renderTabContent(); + }); + + readingBtn.addEventListener('click', () => { + if (this.activeTab === 'reading') return; + this.destroyDraftsTab(); + this.activeTab = 'reading'; + allBtns.forEach((b) => b.removeClass('is-active')); + readingBtn.addClass('is-active'); + this.renderTabContent(); + }); + + customListsBtn.addEventListener('click', () => { + if (this.activeTab === 'custom-lists') return; + this.destroyDraftsTab(); + this.activeTab = 'custom-lists'; + allBtns.forEach((b) => b.removeClass('is-active')); + customListsBtn.addClass('is-active'); + this.renderTabContent(); + }); + + this.draftsBtn.addEventListener('click', () => { + if (this.activeTab === 'drafts') return; + this.activeTab = 'drafts'; + allBtns.forEach((b) => b.removeClass('is-active')); + this.draftsBtn!.addClass('is-active'); + this.renderTabContent(); + }); + + logBtn.addEventListener('click', () => { + if (this.activeTab === 'log') return; + this.destroyDraftsTab(); + this.activeTab = 'log'; + allBtns.forEach((b) => b.removeClass('is-active')); + logBtn.addClass('is-active'); + this.renderTabContent(); + }); + + this.tabContentEl = root.createDiv({ cls: 'wl-tab-content' }); + this.renderTabContent(); + + // Initialize Upcoming badge immediately + this.updateUpcomingBadge(); + + // Initialize the Drafts badge. The count needs a count-only vault scan, so it + // only becomes correct once the metadata cache is ready — gate it on 'resolved' + // (also fires for changes made while Obsidian was closed, avoiding a stale count). + // A direct call handles the case where the cache is already resolved when the + // panel is (re)opened, since 'resolved' won't fire again then. + void this.updateDraftsBadge(); + this.registerEvent(this.plugin.app.metadataCache.on('resolved', () => { + void this.updateDraftsBadge(); + })); + } + + /** + * Programmatically switch tabs (e.g. from the status-bar Upcoming counter). + * Mirrors a tab-button click: updates active state and re-renders the content. + */ + setActiveTab(tab: TabName): void { + if (this.activeTab === tab) return; + // UI not built yet — record the intent so the initial render reflects it. + if (!this.tabContentEl) { this.activeTab = tab; return; } + if (tab !== 'drafts') this.destroyDraftsTab(); + this.activeTab = tab; + for (const b of Object.values(this.tabButtons)) b?.removeClass('is-active'); + this.tabButtons[tab]?.addClass('is-active'); + this.renderTabContent(); + } + + private renderTabContent(): void { + if (!this.tabContentEl) return; + // Tear down any active inline editors / document listeners from the previous tab + this.customListsTab?.destroy(); + if (this.activeTab !== 'reading') { + this.readingTab?.destroy(); + this.readingTab = null; + } + if (this.activeTab !== 'log') { + this.logTab?.destroy(); + this.logTab = null; + } + this.tabContentEl.empty(); + for (const cls of TAB_CLASSES) this.tabContentEl.removeClass(cls); + + if (this.activeTab === 'dashboard') { + this.dashboardTab = new DashboardTab(this.tabContentEl, this.plugin, this.dataManager, this.plugin.readingDataManager); + this.dashboardTab.render(); + } else if (this.activeTab === 'watchlist') { + // Always create a new instance when switching to Watchlist tab so that the + // container element is correct; state (filters, expandedId) is preserved + // via the instance kept on this.listTab during data-change refreshes. + this.listTab?.destroy(); + this.listTab = new ListTab(this.tabContentEl, this.plugin, this.dataManager); + this.listTab.render(); + } else if (this.activeTab === 'upcoming') { + this.airtimeTab = new AirtimeTab(this.tabContentEl, this.plugin, this.dataManager, (count) => { + if (this.airtimeBtn) { + this.airtimeBtn.textContent = count > 0 ? `Upcoming (${count})` : 'Upcoming'; + } + }); + this.airtimeTab.render(); + } else if (this.activeTab === 'reading') { + this.readingTab?.destroy(); + this.readingTab = new ReadingTab( + this.tabContentEl, + this.plugin, + this.plugin.readingDataManager, + ); + this.readingTab.render(); + } else if (this.activeTab === 'drafts') { + this.draftsTab = new DraftsTab( + this.tabContentEl, + this.plugin, + this.dataManager, + (count) => this.setDraftsBadge(count), + ); + void this.draftsTab.render(); + } else if (this.activeTab === 'log') { + this.logTab = new LogTab(this.tabContentEl, this.plugin); + this.logTab.render(); + } else { + // custom-lists + this.customListsTab = new CustomListsTab(this.tabContentEl, this.plugin, this.dataManager); + void this.customListsTab.render(); + } + this.guardOverflow(); + this.refreshMobileLayout(); + } + + /** + * Called by the data change listener. Re-renders the active tab without + * destroying the ListTab instance, so expandedId and filter state survive. + */ + private refreshActiveTab(): void { + this.applyColorTheme(this.contentEl); + if (this.tabContentEl) { + for (const cls of TAB_CLASSES) this.tabContentEl.removeClass(cls); + } + if (this.activeTab === 'watchlist' && this.listTab) { + this.listTab.render(); + } else if (this.activeTab === 'dashboard' && this.dashboardTab) { + this.dashboardTab.render(); + } else if (this.activeTab === 'upcoming' && this.airtimeTab) { + this.airtimeTab.render(); + } else if (this.activeTab === 'reading' && this.readingTab) { + this.readingTab.render(); + } else if (this.activeTab === 'custom-lists' && this.customListsTab) { + void this.customListsTab.render(); + } else if (this.activeTab === 'drafts' && this.draftsTab) { + void this.draftsTab.render(); + } else if (this.activeTab === 'log' && this.logTab) { + this.logTab.render(); + } else { + this.renderTabContent(); + return; + } + this.guardOverflow(); + this.updateUpcomingBadge(); + } + + private overflowObserver: MutationObserver | null = null; + + private guardOverflow(): void { + if (!this.tabContentEl) return; + this.overflowObserver?.disconnect(); + + const el = this.tabContentEl; + const renderTime = Date.now(); + + this.overflowObserver = new MutationObserver(() => { + if (Date.now() - renderTime > 300) return; + const computed = window.getComputedStyle(el).overflow; + if (computed === 'hidden') { + el.style.overflow = 'auto'; + } + }); + this.overflowObserver.observe(el, { attributes: true, attributeFilter: ['style', 'class'] }); + } + + private updateUpcomingBadge(): void { + if (!this.airtimeBtn) return; + const count = AirtimeTab.getAiredDueCount(this.dataManager, this.plugin.readingDataManager) + AirtimeTab.getMaybeDueCount(this.dataManager); + this.airtimeBtn.textContent = count > 0 ? `Upcoming (${count})` : 'Upcoming'; + } + + private setDraftsBadge(count: number): void { + if (this.draftsBtn) { + this.draftsBtn.textContent = count > 0 ? `Drafts (${count})` : 'Drafts'; + } + } + + /** Count-only drafts scan to keep the tab badge correct even before the Drafts tab is opened. */ + private async updateDraftsBadge(): Promise<void> { + if (!this.draftsBtn) return; + const count = await DraftsTab.computePendingCount(this.plugin, this.dataManager); + this.setDraftsBadge(count); + } +} + diff --git a/src/WidgetRenderer.ts b/src/WidgetRenderer.ts index 60ba71e..79090c3 100644 --- a/src/WidgetRenderer.ts +++ b/src/WidgetRenderer.ts @@ -15,6 +15,8 @@ export class WidgetRenderer { private widgetRegistry = new Map<HTMLElement, string>(); /** Tracks which registered widgets are in mini mode (for correct re-render on data change). */ private widgetMiniRegistry = new Map<HTMLElement, boolean>(); + /** Generic re-render hooks for non-todo widgets (stats, upcoming, now-watching, now-next). */ + private widgetRerender = new Map<HTMLElement, () => void>(); private dataChangedListener: EventListener; constructor(plugin: WatchLogPlugin, dataManager: DataManager) { @@ -51,7 +53,7 @@ export class WidgetRenderer { (source, el, _ctx) => this.processWlNowNext(source, el), ); - // Re-render all active widgets when data changes (Fix 1: list → widget sync) + // Re-render all active widgets when data changes (list → widget sync) this.dataChangedListener = () => this.onDataChanged(); activeDocument.addEventListener('watchlog-data-changed', this.dataChangedListener); @@ -87,6 +89,23 @@ export class WidgetRenderer { this.widgetRegistry.delete(el); this.widgetMiniRegistry.delete(el); } + + const staleGeneric: HTMLElement[] = []; + for (const [el, rerender] of this.widgetRerender) { + if (!el.isConnected) { + staleGeneric.push(el); + continue; + } + try { rerender(); } catch (e) { console.warn('[WL] widget rerender failed:', e); } + } + for (const el of staleGeneric) this.widgetRerender.delete(el); + } + + /** Clear all widget registries (plugin unload). */ + cleanup(): void { + this.widgetRegistry.clear(); + this.widgetMiniRegistry.clear(); + this.widgetRerender.clear(); } private process( @@ -369,7 +388,13 @@ export class WidgetRenderer { // ── wl-stat ─────────────────────────────────────────────────────────────────── private processWlStat(source: string, el: HTMLElement): void { + this.widgetRerender.set(el, () => this.renderWlStat(source, el)); + this.renderWlStat(source, el); + } + + private renderWlStat(source: string, el: HTMLElement): void { const kind = source.trim().toLowerCase(); + el.empty(); el.addClass('wl-wstat'); if (kind === 'watched') { @@ -478,6 +503,12 @@ export class WidgetRenderer { // ── wl-upcoming ─────────────────────────────────────────────────────────────── private processWlUpcoming(source: string, el: HTMLElement): void { + this.widgetRerender.set(el, () => this.renderWlUpcoming(source, el)); + this.renderWlUpcoming(source, el); + } + + private renderWlUpcoming(source: string, el: HTMLElement): void { + el.empty(); const kind = source.trim().toLowerCase(); const isFull = kind === 'next full'; if (kind !== 'next' && kind !== 'next full') { @@ -487,6 +518,7 @@ export class WidgetRenderer { const entries = this.dataManager.getAirtimeEntries(); const titles = this.dataManager.getTitles(); + const titleMap = new Map(titles.map((t) => [t.id, t])); // Find the entry with the soonest future release date const now = Date.now(); @@ -494,7 +526,7 @@ export class WidgetRenderer { let bestMs = Infinity; for (const entry of entries) { - const title = titles.find((t) => t.id === entry.titleId); + const title = titleMap.get(entry.titleId); if (!title || entry.schedule.recurrence !== 'once' || !entry.schedule.releaseDate) continue; const ms = new Date(entry.schedule.releaseDate + 'T12:00:00').getTime(); if (ms >= now && ms < bestMs) { @@ -568,6 +600,12 @@ export class WidgetRenderer { // ── wl-nowwatching ──────────────────────────────────────────────────────────── private processWlNowWatching(source: string, el: HTMLElement): void { + this.widgetRerender.set(el, () => this.renderWlNowWatching(source, el)); + this.renderWlNowWatching(source, el); + } + + private renderWlNowWatching(source: string, el: HTMLElement): void { + el.empty(); const isFull = source.trim().toLowerCase() === 'full'; el.addClass('wl-wnowwatching'); @@ -592,12 +630,13 @@ export class WidgetRenderer { if (pinnedGroup) { card.createSpan({ cls: 'wl-nowwatching-title', text: pinnedGroup.name }); const allTitles = this.dataManager.getTitles(); + const titleMap = new Map(allTitles.map((t) => [t.id, t])); const members = pinnedGroup.titleIds - .map((id) => allTitles.find((t) => t.id === id)) + .map((id) => titleMap.get(id)) .filter((t): t is WatchLogTitle => t !== undefined); const totalWatched = members.reduce((s, t) => s + t.watchedEpisodes.length, 0); - const totalEps = members.reduce((s, t) => s + t.totalEpisodes, 0); - const progress = totalEps > 0 ? Math.round((totalWatched / totalEps) * 100) : 0; + const totalEps = members.reduce((s, t) => s + this.dataManager.getEffectiveTotal(t), 0); + const progress = totalEps > 0 ? Math.min(100, Math.round((totalWatched / totalEps) * 100)) : 0; card.createSpan({ cls: 'wl-nowwatching-ep', text: `${members.length} title${members.length !== 1 ? 's' : ''}` }); const barWrap = card.createDiv({ cls: 'wl-nowwatching-bar-wrap' }); barWrap.createDiv({ cls: 'wl-nowwatching-bar' }).style.width = `${progress}%`; @@ -645,12 +684,13 @@ export class WidgetRenderer { if (pinnedGroup) { card.createDiv({ cls: 'wl-full-card-title', text: pinnedGroup.name }); const allTitles = this.dataManager.getTitles(); + const titleMap = new Map(allTitles.map((t) => [t.id, t])); const members = pinnedGroup.titleIds - .map((id) => allTitles.find((t) => t.id === id)) + .map((id) => titleMap.get(id)) .filter((t): t is WatchLogTitle => t !== undefined); const totalWatched = members.reduce((s, t) => s + t.watchedEpisodes.length, 0); - const totalEps = members.reduce((s, t) => s + t.totalEpisodes, 0); - const progress = totalEps > 0 ? Math.round((totalWatched / totalEps) * 100) : 0; + const totalEps = members.reduce((s, t) => s + this.dataManager.getEffectiveTotal(t), 0); + const progress = totalEps > 0 ? Math.min(100, Math.round((totalWatched / totalEps) * 100)) : 0; const bottomRow = card.createDiv({ cls: 'wl-nw-bottom-row' }); bottomRow.createSpan({ cls: 'wl-full-card-sub', text: `${members.length} title${members.length !== 1 ? 's' : ''}` }); const barCol = bottomRow.createDiv({ cls: 'wl-nw-bar-col' }); @@ -680,7 +720,13 @@ export class WidgetRenderer { // ── wl-now-next ─────────────────────────────────────────────────────────────── - private processWlNowNext(_source: string, el: HTMLElement): void { + private processWlNowNext(source: string, el: HTMLElement): void { + this.widgetRerender.set(el, () => this.renderWlNowNext(source, el)); + this.renderWlNowNext(source, el); + } + + private renderWlNowNext(_source: string, el: HTMLElement): void { + el.empty(); el.addClass('wl-wnownext'); const pinnedTitle = this.dataManager.getTitles().find((t) => t.pinned); @@ -691,12 +737,13 @@ export class WidgetRenderer { const entries = this.dataManager.getAirtimeEntries(); const titles = this.dataManager.getTitles(); + const titleMap = new Map(titles.map((t) => [t.id, t])); const now = Date.now(); let bestEntry: { titleName: string; type: string; releaseDate: string; daysUntil: number } | null = null; let bestMs = Infinity; for (const entry of entries) { - const title = titles.find((t) => t.id === entry.titleId); + const title = titleMap.get(entry.titleId); if (!title || entry.schedule.recurrence !== 'once' || !entry.schedule.releaseDate) continue; const ms = new Date(entry.schedule.releaseDate + 'T12:00:00').getTime(); if (ms >= now && ms < bestMs) { @@ -728,12 +775,13 @@ export class WidgetRenderer { } else if (pinnedGroup) { nowCol.createDiv({ cls: 'wl-full-card-title', text: pinnedGroup.name }); const allTitles = this.dataManager.getTitles(); + const titleMap2 = new Map(allTitles.map((t) => [t.id, t])); const members = pinnedGroup.titleIds - .map((id) => allTitles.find((t) => t.id === id)) + .map((id) => titleMap2.get(id)) .filter((t): t is WatchLogTitle => t !== undefined); const totalWatched = members.reduce((s, t) => s + t.watchedEpisodes.length, 0); - const totalEps = members.reduce((s, t) => s + t.totalEpisodes, 0); - const progress = totalEps > 0 ? Math.round((totalWatched / totalEps) * 100) : 0; + const totalEps = members.reduce((s, t) => s + this.dataManager.getEffectiveTotal(t), 0); + const progress = totalEps > 0 ? Math.min(100, Math.round((totalWatched / totalEps) * 100)) : 0; nowCol.createDiv({ cls: 'wl-full-card-sub', text: `${members.length} title${members.length !== 1 ? 's' : ''} · ${progress}%` }); } else { const pinned = pinnedTitle!; diff --git a/src/main.ts b/src/main.ts index 8f2efb1..7a4efe0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,12 @@ -import { App, Editor, FuzzySuggestModal, MarkdownView, Notice, Plugin } from 'obsidian'; +import { App, Editor, FuzzySuggestModal, MarkdownView, Notice, Platform, Plugin, setIcon } from 'obsidian'; import { DEFAULT_SETTINGS, WatchLogPluginSettings, AirtimeSchedule } from './types'; import { DataManager } from './DataManager'; +import { ReadingDataManager } from './ReadingDataManager'; import { ApiService } from './ApiService'; import { HistoryManager } from './HistoryManager'; -import { WatchLogView, WATCHLOG_VIEW_TYPE } from './WatchLogView'; +import { PosterService } from './PosterService'; +import { WatchLogView, WATCHLOG_VIEW_TYPE, TabName } from './WatchLogView'; +import { AirtimeTab } from './AirtimeTab'; import { WatchLogSettingsTab } from './SettingsTab'; import { AddTitleModal } from './AddTitleModal'; import { InsertWidgetModal } from './InsertWidgetModal'; @@ -12,8 +15,11 @@ import { WidgetRenderer } from './WidgetRenderer'; export default class WatchLogPlugin extends Plugin { settings: WatchLogPluginSettings = DEFAULT_SETTINGS; dataManager: DataManager = new DataManager(this); + readingDataManager: ReadingDataManager = new ReadingDataManager(this); apiService: ApiService = new ApiService('', ''); historyManager: HistoryManager = new HistoryManager(this); + posterService!: PosterService; + widgetRenderer?: WidgetRenderer; // Runtime import progress state (not persisted) importProgress: { current: number; total: number; cancel: () => void } | null = null; @@ -21,30 +27,37 @@ export default class WatchLogPlugin extends Plugin { // Track which entry+day combos have already fired a notification private notifiedEntries: Set<string> = new Set(); + // Status-bar Upcoming "due" counter (desktop only; null on mobile) + private statusBarEl: HTMLElement | null = null; + async onload(): Promise<void> { - // Load settings first, then data await this.loadSettings(); this.dataManager = new DataManager(this); await this.dataManager.load(); this.dataManager.startWatchingExternalChanges(); - this.apiService = new ApiService(this.settings.omdbApiKey, this.settings.tmdbApiKey); + + this.readingDataManager = new ReadingDataManager(this); + await this.readingDataManager.load(); + + this.apiService = new ApiService(this.settings.omdbApiKey, this.settings.tmdbApiKey, this.settings.googleBooksApiKey); this.historyManager = new HistoryManager(this); await this.historyManager.load(); this.dataManager.setHistoryManager(this.historyManager); + this.readingDataManager.setHistoryManager(this.historyManager); + + this.posterService = new PosterService( + this.dataManager, + () => this.settings, + ); - // Ensure vault folders exist await this.dataManager.ensureFolders(); - - // Start the airtime notification scheduler (checks every 60 seconds) this.startAirtimeScheduler(); - // Register the sidebar view this.registerView(WATCHLOG_VIEW_TYPE, (leaf) => { return new WatchLogView(leaf, this, this.dataManager); }); - // Register the inline widget code block processor - new WidgetRenderer(this, this.dataManager); + this.widgetRenderer = new WidgetRenderer(this, this.dataManager); // Ribbon icon this.addRibbonIcon('tv', 'Watchlog', () => { @@ -94,14 +107,29 @@ export default class WatchLogPlugin extends Plugin { // Settings tab this.addSettingTab(new WatchLogSettingsTab(this.app, this)); + + // Status-bar Upcoming "due" counter + this.setupStatusBar(); + // Keep the status bar in sync with data edits (no new timer — the 60s + // airtime interval drives the time-based refresh). 'watchlog-data-changed' + // is a custom event, so register the listener directly (like WidgetRenderer). + const statusBarListener = (): void => this.updateStatusBar(); + activeDocument.addEventListener('watchlog-data-changed', statusBarListener); + this.register(() => activeDocument.removeEventListener('watchlog-data-changed', statusBarListener)); } onunload(): void { - + // Synchronously flush any pending debounced saves so no in-memory edits are lost. + this.dataManager.flushPendingSaveSync(); + this.dataManager.flushPosterSaveSync(); + this.dataManager.flushQueuedSaveSync(); + this.readingDataManager?.flushCoverSave(); + this.posterService?.destroy(); + this.widgetRenderer?.cleanup(); + } private startAirtimeScheduler(): void { - // Check immediately on startup, then every 60 seconds this.checkAirtimeNotifications(); this.registerInterval(window.setInterval(() => { this.checkAirtimeNotifications(); @@ -134,21 +162,74 @@ export default class WatchLogPlugin extends Plugin { if (this.notifiedEntries.has(notifKey)) continue; this.notifiedEntries.add(notifKey); - const title = this.dataManager.getTitle(entry.titleId); - if (!title) continue; - new Notice(title.title, 8000); + // Resolve the entry's subject — a watch title or a reading item. + let name: string; + let maxUnits: number; + if (entry.source === 'reading') { + const kind = entry.readingKind ?? 'book'; + const item = kind === 'book' + ? this.readingDataManager.getBook(entry.titleId) + : this.readingDataManager.getManga(entry.titleId); + if (!item) continue; + name = item.title; + maxUnits = entry.totalEpisodes ?? item.totalChapters ?? 0; + } else { + const title = this.dataManager.getTitle(entry.titleId); + if (!title) continue; + name = title.title; + maxUnits = entry.totalEpisodes ?? title.totalEpisodes; + } + new Notice(name, 8000); - // Auto-increment episode for series/anime - if ((title.totalEpisodes ?? 0) > 1 && entry.currentEpisode !== undefined) { - const maxEps = entry.totalEpisodes ?? title.totalEpisodes; + // Auto-increment the unit (episode/chapter) for multi-part titles + if (maxUnits > 1 && entry.currentEpisode !== undefined) { const nextEp = entry.currentEpisode + 1; - if (nextEp <= maxEps) { + if (nextEp <= maxUnits) { entry.currentEpisode = nextEp; void this.dataManager.updateAirtimeEntry(entry); } - // If final episode: leave in Upcoming, you handle via tick button + // If final unit: leave in Upcoming, you handle via tick button } } + + // The due count is time-based, so refresh the status bar on every tick. + this.updateStatusBar(); + } + + // ── Status bar: Upcoming "due" counter ────────────────────────────────────── + private setupStatusBar(): void { + // The status bar doesn't exist on mobile — skip entirely there. + if (Platform.isMobile) return; + this.statusBarEl = this.addStatusBarItem(); + this.statusBarEl.addClass('wl-statusbar-upcoming'); + this.statusBarEl.style.cursor = 'pointer'; + this.statusBarEl.setAttribute('aria-label', 'WatchLog — Upcoming due'); + this.statusBarEl.addEventListener('click', () => void this.activateView('upcoming')); + this.updateStatusBar(); + } + + /** Refreshes the status-bar Upcoming counter. Hidden when off or when 0 due. */ + updateStatusBar(): void { + const el = this.statusBarEl; + if (!el) return; + if (!this.settings.showUpcomingStatusBar) { + el.hide(); + return; + } + const count = + AirtimeTab.getAiredDueCount(this.dataManager, this.readingDataManager) + + AirtimeTab.getMaybeDueCount(this.dataManager); + if (count <= 0) { + el.hide(); + return; + } + el.empty(); + el.show(); + // Orange icon + "N due" — reuse the ribbon's 'tv' icon. + el.style.color = 'var(--color-orange)'; + const icon = el.createSpan({ cls: 'wl-statusbar-icon' }); + setIcon(icon, 'tv'); + el.createSpan({ cls: 'wl-statusbar-text', text: `${count} due` }); } /** @@ -180,7 +261,7 @@ export default class WatchLogPlugin extends Plugin { async loadSettings(): Promise<void> { const saved = (await this.loadData()) as { - settings?: Partial<WatchLogPluginSettings> & { defaultView?: string }; + settings?: Partial<WatchLogPluginSettings>; } | null; this.settings = Object.assign({}, DEFAULT_SETTINGS, saved?.settings ?? {}); if (saved?.settings?.listFilters) { @@ -190,9 +271,26 @@ export default class WatchLogPlugin extends Plugin { saved.settings.listFilters, ); } - // Migrate old defaultView value 'list' → 'watchlist' - if ((this.settings.defaultView as string) === 'list') { - this.settings.defaultView = 'watchlist'; + if (this.settings.animeApiSource === undefined) { + this.settings.animeApiSource = 'jikan'; + } + if (this.settings.typeApiMapping === undefined) { + this.settings.typeApiMapping = {}; + } + // Reading type badge colors (Manga / Book) — backfill defaults for old installs. + if (!this.settings.readingTypeColors) { + this.settings.readingTypeColors = { manga: '#D4537E', book: '#D85A30' }; + } else { + if (!this.settings.readingTypeColors.manga) this.settings.readingTypeColors.manga = '#D4537E'; + if (!this.settings.readingTypeColors.book) this.settings.readingTypeColors.book = '#D85A30'; + } + // Default for the defaultWatchlistView setting (Cards) + if (this.settings.defaultWatchlistView !== 'list' && this.settings.defaultWatchlistView !== 'cards') { + this.settings.defaultWatchlistView = 'cards'; + } + // Backfill the status-bar Upcoming counter toggle for existing installs (default ON). + if (this.settings.showUpcomingStatusBar === undefined) { + this.settings.showUpcomingStatusBar = true; } // Ensure array fields are never undefined after a partial merge if (!this.settings.types?.length) this.settings.types = DEFAULT_SETTINGS.types; @@ -213,8 +311,7 @@ export default class WatchLogPlugin extends Plugin { } async saveSettings(): Promise<void> { - const current = ((await this.loadData()) as Record<string, unknown> | null) ?? {}; - await this.saveData({ ...current, settings: this.settings }); + await this.dataManager.saveSettings(this.settings); } private openWidgetPalette(editor: Editor): void { @@ -263,18 +360,21 @@ export default class WatchLogPlugin extends Plugin { }).open(); } - private async activateView(): Promise<void> { + private async activateView(tab?: TabName): Promise<void> { const { workspace } = this.app; const existing = workspace.getLeavesOfType(WATCHLOG_VIEW_TYPE); if (existing.length > 0) { - void workspace.revealLeaf(existing[0]!); + const leaf = existing[0]!; + void workspace.revealLeaf(leaf); + if (tab && leaf.view instanceof WatchLogView) leaf.view.setActiveTab(tab); return; } const leaf = workspace.getLeaf('tab'); await leaf.setViewState({ type: WATCHLOG_VIEW_TYPE, active: true }); void workspace.revealLeaf(leaf); + if (tab && leaf.view instanceof WatchLogView) leaf.view.setActiveTab(tab); } } diff --git a/src/types.ts b/src/types.ts index 24957ac..b072b86 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,513 +1,727 @@ -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; - omdbApiKey: string; - tmdbApiKey: string; - activeApi: 'OMDb' | 'TMDB'; - types: TagDefinition[]; - statuses: TagDefinition[]; - reviews: TagDefinition[]; - priorities: TagDefinition[]; - rootFolder: string; - autoCreateFolders: boolean; - coloredTypeBadges: boolean; - seasonPalette: string[]; - dashboardCardStyle: 'circles' | 'rectangles'; - episodeNumbering: 'absolute' | 'per-season'; - customListsFolder: string; - defaultCustomColumns: CustomListColumn[]; - listFilters: { - typeExclude: string[]; - statusExclude: string[]; - groupExclude: string[]; - ratingExclude: string[]; - priorityExclude: string[]; - sort: string; - sortDir?: 'asc' | 'desc'; - secondSort?: string; - secondSortDir?: 'asc' | 'desc'; - ratingEmptyOnly?: boolean; - priorityEmptyOnly?: boolean; - recentlyArrivedOnly?: boolean; - }; - draftsVaultTag: string; - draftsAfterAdding: 'remove' | 'keep'; - customListTabOrder: string[]; -} - -// ── Airtime types ───────────────────────────────────────────────────────────── - -export type AirtimeRecurrence = 'once' | 'daily' | 'weekly' | 'monthly'; - -export interface AirtimeSchedule { - recurrence: AirtimeRecurrence; - releaseDate?: string; // YYYY-MM-DD, for 'once' - releaseTime?: string; // HH:MM, for any recurrence - dayOfWeek?: number; // 0=Sunday … 6=Saturday, for 'weekly' - dayOfMonth?: number; // 1–31, for 'monthly' -} - -export interface AirtimeEntry { - id: string; - titleId: string; - schedule: AirtimeSchedule; - currentSeason?: number; - currentEpisode?: number; - /** Total episodes to track for final-episode detection (synced from Watchlist). */ - totalEpisodes?: number; - /** Total seasons (informational). */ - totalSeasons?: number; - /** YYYY-MM-DD: set when you tick an episode, so the countdown resets to the next occurrence. */ - lastAcknowledgedDate?: string; - dateAdded: string; -} - -export interface SavedFilterPreset { - typeExclude: string[]; - statusExclude: string[]; - groupExclude: string[]; - ratingExclude: string[]; - priorityExclude: string[]; - ratingEmptyOnly?: boolean; - priorityEmptyOnly?: boolean; - recentlyArrivedOnly?: boolean; -} - -export interface WatchLogData { - titles: WatchLogTitle[]; - groups: WatchLogGroup[]; - settings: Partial<WatchLogPluginSettings>; - airtime?: AirtimeEntry[]; - maybe?: MaybeTitle[]; - pinnedGroupId?: string | null; - drafts?: DraftPersistState; - savedFilterPreset?: SavedFilterPreset | null; -} - -// ── Airtime utility functions ───────────────────────────────────────────────── - -export function getAirtimeNextDate(schedule: AirtimeSchedule): Date | null { - const now = new Date(); - const todayMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - - if (schedule.recurrence === 'once') { - if (!schedule.releaseDate) return null; - const d = new Date(schedule.releaseDate + 'T12:00:00'); - if (schedule.releaseTime) { - const parts = schedule.releaseTime.split(':'); - d.setHours(parseInt(parts[0] ?? '0'), parseInt(parts[1] ?? '0'), 0, 0); - } - return d; - } - - if (schedule.recurrence === 'daily') { - const d = new Date(todayMidnight); - if (schedule.releaseTime) { - const parts = schedule.releaseTime.split(':'); - d.setHours(parseInt(parts[0] ?? '0'), parseInt(parts[1] ?? '0'), 0, 0); - if (d <= now) d.setDate(d.getDate() + 1); - } - return d; - } - - if (schedule.recurrence === 'weekly' && schedule.dayOfWeek !== undefined) { - const currentDay = now.getDay(); - let daysUntil = (schedule.dayOfWeek - currentDay + 7) % 7; - const d = new Date(todayMidnight); - if (schedule.releaseTime) { - const parts = schedule.releaseTime.split(':'); - d.setHours(parseInt(parts[0] ?? '0'), parseInt(parts[1] ?? '0'), 0, 0); - if (daysUntil === 0 && d <= now) daysUntil = 7; - } - d.setDate(d.getDate() + daysUntil); - return d; - } - - if (schedule.recurrence === 'monthly' && schedule.dayOfMonth) { - const d = new Date(now.getFullYear(), now.getMonth(), schedule.dayOfMonth); - if (schedule.releaseTime) { - const parts = schedule.releaseTime.split(':'); - d.setHours(parseInt(parts[0] ?? '0'), parseInt(parts[1] ?? '0'), 0, 0); - } - if (d <= now) d.setMonth(d.getMonth() + 1); - return d; - } - - return null; -} - -export function getAirtimeCountdown( - schedule: AirtimeSchedule, -): { label: string; kind: 'today' | 'tomorrow' | 'days' | 'missed' } { - const next = getAirtimeNextDate(schedule); - if (!next) return { label: '—', kind: 'days' }; - - const now = new Date(); - if (schedule.recurrence === 'once' && next < now) { - return { label: 'Missed', kind: 'missed' }; - } - - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const tomorrow = new Date(today); - tomorrow.setDate(tomorrow.getDate() + 1); - const nextMidnight = new Date(next.getFullYear(), next.getMonth(), next.getDate()); - - if (nextMidnight.getTime() === today.getTime()) return { label: 'Today', kind: 'today' }; - if (nextMidnight.getTime() === tomorrow.getTime()) return { label: 'Tomorrow', kind: 'tomorrow' }; - - const daysUntil = Math.round((nextMidnight.getTime() - today.getTime()) / 86400000); - if (daysUntil < 0) return { label: 'Missed', kind: 'missed' }; - return { label: `in ${daysUntil} days`, kind: 'days' }; -} - -export function getAirtimeScheduleString(schedule: AirtimeSchedule): string { - const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; - const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - const time = schedule.releaseTime ? ` · ${schedule.releaseTime}` : ''; - - if (schedule.recurrence === 'once') { - if (!schedule.releaseDate) return 'No date set'; - const d = new Date(schedule.releaseDate + 'T12:00:00'); - return `Release date · ${d.getDate()} ${MONTHS[d.getMonth()] ?? ''} ${d.getFullYear()}${time}`; - } - if (schedule.recurrence === 'daily') return `Every day${time}`; - if (schedule.recurrence === 'weekly') { - const dayName = - schedule.dayOfWeek !== undefined ? (DAYS[schedule.dayOfWeek] ?? 'Unknown') : 'Unknown'; - return `Every ${dayName}${time}`; - } - if (schedule.recurrence === 'monthly') { - return `Monthly on day ${schedule.dayOfMonth ?? '?'}${time}`; - } - return '—'; -} - -export const DEFAULT_SETTINGS: WatchLogPluginSettings = { - colorTheme: 'default', - defaultView: 'watchlist', - autoCompleteOnLastEpisode: true, - setFinishDateAutomatically: false, - 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 -- forward slash escaped for visual clarity in date regex - 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[]; -} +export interface Season { + name: string; + episodes: number; + offset: number; + /** Season-relative episode numbers that are excluded from progress (filler, recaps, etc.) */ + skippedEpisodes?: 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; + anilistId?: number; + communityRating?: number; + communityVotes?: number; + communitySource?: '' | 'imdb' | 'mal' | 'anilist' | 'tmdb'; + communityRatingLastFetched?: string; + pinned?: boolean; + /** '' = unfetched, 'none' = API returned nothing, or a URL string */ + posterUrl?: string; + /** User-supplied override — takes priority over posterUrl when non-empty. */ + manualPosterUrl?: string; +} + +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' + autoTime?: boolean; // only for type: 'number' — auto-populate with remaining watch time +} + +export interface CustomListRow { + id: string; + checked?: boolean; + [key: string]: string | number | boolean | undefined; +} + +// ── Maybe types ───────────────────────────────────────────────────────────── + +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'; + defaultWatchlistView: 'list' | 'cards'; + autoCompleteOnLastEpisode: boolean; + setFinishDateAutomatically: boolean; + omdbApiKey: string; + tmdbApiKey: string; + googleBooksApiKey: string; + activeApi: 'OMDb' | 'TMDB'; + animeApiSource: 'jikan' | 'anilist'; + typeApiMapping: Record<string, 'anime' | 'movie' | ''>; + types: TagDefinition[]; + statuses: TagDefinition[]; + reviews: TagDefinition[]; + priorities: TagDefinition[]; + rootFolder: string; + autoCreateFolders: boolean; + coloredTypeBadges: boolean; + seasonPalette: string[]; + dashboardCardStyle: 'circles' | 'rectangles'; + episodeNumbering: 'absolute' | 'per-season'; + /** User-configurable colors for the Reading type badges (Manga / Book). */ + readingTypeColors: { manga: string; book: string }; + 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; + groupsOnly?: boolean; + }; + draftsVaultTag: string; + draftsAfterAdding: 'remove' | 'keep'; + customListTabOrder: string[]; + showHintBanners: boolean; + showUpcomingStatusBar: boolean; +} + +// ── Reading types ───────────────────────────────────────────────────────────── + +export type ReadingStatus = 'Reading' | 'Completed' | 'Plan to Read' | 'To be released' | 'Dropped'; + +export const READING_STATUSES: ReadingStatus[] = [ + 'Reading', + 'Completed', + 'Plan to Read', + 'To be released', + 'Dropped', +]; + +/** + * Statuses the user may pick manually. "To be released" is auto-managed from the + * release date (mirrors the watchlist), so it is excluded from every dropdown. + */ +export const SELECTABLE_READING_STATUSES: ReadingStatus[] = READING_STATUSES.filter( + (s) => s !== 'To be released', +); + +export interface Book { + id: string; + title: string; + author: string; + status: ReadingStatus; + rating: number; + pagesRead: number; + totalPages: number; + chaptersRead: number; + totalChapters: number; + coverUrl: string; + googleBooksId: string; + externalLink?: string; + vaultPage: string; + dateStarted: string | null; + dateFinished: string | null; + releaseDate: string | null; + dateAdded: string; + dateModified: string; + customFields: Record<string, string | number>; +} + +export interface Manga { + id: string; + title: string; + author: string; + status: ReadingStatus; + rating: number; + chaptersRead: number; + totalChapters: number; + volumesRead: number; + totalVolumes: number; + coverUrl: string; + malId: string; + externalLink?: string; + vaultPage: string; + dateStarted: string | null; + dateFinished: string | null; + releaseDate: string | null; + dateAdded: string; + dateModified: string; + customFields: Record<string, string | number>; +} + +export interface ReadingCustomColumn { + id: string; + name: string; + type: 'text' | 'number' | 'select'; + options: string[]; + color?: string; // 600-stop hex; defaults to FIELD_COLORS gray +} + +export interface ReadingSavedFilterPreset { + name: string; + statusInclude: ReadingStatus[]; + ratingMode: 'all' | 'has' | 'none'; +} + +export interface ReadingSettings { + defaultFolder: string; + defaultStatus: ReadingStatus; + defaultSubTab?: 'books' | 'manga'; + bookCustomFieldStyle?: 'fill' | 'border'; + mangaCustomFieldStyle?: 'fill' | 'border'; + // Single saved filter scoped per sub-tab (Books / Manga keep their own). + savedFilters?: Partial<Record<'books' | 'manga', ReadingSavedFilterPreset>>; +} + +export interface FieldColorEntry { + color600: string; + color50: string; +} + +export const FIELD_COLORS: FieldColorEntry[] = [ + { color600: '#534AB7', color50: '#EEEDFE' }, + { color600: '#1D9E75', color50: '#E1F5EE' }, + { color600: '#EF9F27', color50: '#FAEEDA' }, + { color600: '#378ADD', color50: '#E6F1FB' }, + { color600: '#D85A30', color50: '#FAECE7' }, + { color600: '#993556', color50: '#FBEAF0' }, + { color600: '#639922', color50: '#EAF3DE' }, + { color600: '#5F5E5A', color50: '#F1EFE8' }, +]; + +export const FIELD_COLOR_50: Record<string, string> = Object.fromEntries( + FIELD_COLORS.map(({ color600, color50 }) => [color600, color50]), +); + +export const DEFAULT_FIELD_COLOR = '#5F5E5A'; + +export interface ReadingData { + books: Book[]; + manga: Manga[]; + bookColumns: ReadingCustomColumn[]; + mangaColumns: ReadingCustomColumn[]; + settings: ReadingSettings; +} + +export const DEFAULT_READING_SETTINGS: ReadingSettings = { + defaultFolder: 'WatchLog/Reading', + defaultStatus: 'Plan to Read', + bookCustomFieldStyle: 'fill', + mangaCustomFieldStyle: 'fill', +}; + +// ── Airtime types ───────────────────────────────────────────────────────────── + +export type AirtimeRecurrence = 'once' | 'daily' | 'weekly' | 'monthly'; + +export interface AirtimeSchedule { + recurrence: AirtimeRecurrence; + releaseDate?: string; // YYYY-MM-DD, for 'once' + releaseTime?: string; // HH:MM, for any recurrence + dayOfWeek?: number; // 0=Sunday … 6=Saturday, for 'weekly' + dayOfMonth?: number; // 1–31, for 'monthly' +} + +export interface AirtimeEntry { + id: string; + /** For watchlist entries: a WatchLogTitle id. For reading entries: a Book/Manga id. */ + titleId: string; + /** Absent or 'watchlist' = a watch title; 'reading' = a Book/Manga entry. */ + source?: 'watchlist' | 'reading'; + /** Only set when source === 'reading'. */ + readingKind?: 'book' | 'manga'; + schedule: AirtimeSchedule; + /** + * Watch entries: current season. Reading entries reuse this slot for the + * current volume (the "season" analog). + */ + currentSeason?: number; + /** + * Watch entries: current episode. Reading entries reuse this slot for the + * current chapter (the "episode" analog — this is what auto-increments). + */ + currentEpisode?: number; + /** + * Total episodes to track for final-episode detection (synced from Watchlist). + * Reading entries reuse this slot for total chapters; a value of 0 or 1 marks + * a single-release item (like a movie/book). + */ + totalEpisodes?: number; + /** Total seasons (informational). Reading entries reuse this slot for total volumes. */ + 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; + groupsOnly?: boolean; +} + +export interface WatchLogData { + titles: WatchLogTitle[]; + groups: WatchLogGroup[]; + settings: Partial<WatchLogPluginSettings>; + airtime?: AirtimeEntry[]; + maybe?: MaybeTitle[]; + pinnedGroupId?: string | null; + drafts?: DraftPersistState; + savedFilterPreset?: SavedFilterPreset | null; + posterRetryDone?: boolean; +} + +// ── Airtime utility functions ───────────────────────────────────────────────── + +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', + defaultWatchlistView: 'cards', + autoCompleteOnLastEpisode: true, + setFinishDateAutomatically: false, + omdbApiKey: '', + tmdbApiKey: '', + googleBooksApiKey: '', + activeApi: 'OMDb', + animeApiSource: 'jikan', + typeApiMapping: {}, + 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', + readingTypeColors: { manga: '#D4537E', book: '#D85A30' }, + customListsFolder: 'WatchLog/CustomLists', + defaultCustomColumns: [], + draftsVaultTag: '#watchlog', + draftsAfterAdding: 'keep', + customListTabOrder: [], + showHintBanners: true, + showUpcomingStatusBar: true, + 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; +} + +/** + * Returns the user-configured badge color for a Reading type (Book / Manga). + * Mirrors how watchlist badges resolve `settings.types[].color`, but for the two + * fixed Reading kinds whose colors live in `settings.readingTypeColors`. + */ +export function getReadingTypeColor( + kind: 'book' | 'manga', + settings: WatchLogPluginSettings, +): string { + const colors = settings.readingTypeColors ?? DEFAULT_SETTINGS.readingTypeColors; + return kind === 'manga' ? colors.manga : colors.book; +} + +// ── 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; + // Reject impossible dates (e.g., Feb 31): Date constructor rolls over, so verify components match. + const check = new Date(y, mo - 1, d); + if (check.getFullYear() !== y || check.getMonth() !== mo - 1 || check.getDate() !== d) 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 -- forward slash escaped for visual clarity in date regex + 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; +} + +/** + * True when a `YYYY-MM-DD` release date is strictly in the future (after today). + * Mirrors the watchlist's auto "To be released" check (see EditTitleModal). + */ +export function isReleaseDateFuture(releaseDate: string | null | undefined): boolean { + if (!releaseDate || !/^\d{4}-\d{2}-\d{2}$/.test(releaseDate)) return false; + const today = new Date(); + today.setHours(0, 0, 0, 0); + const releaseMs = new Date(releaseDate + 'T12:00:00').getTime(); + return releaseMs > today.getTime(); +} + +/** + * Resolves the API group that should be used for a given title type. + * - The three built-in types are hardcoded and never live in the mapping. + * - Any other type is routed via `settings.typeApiMapping`; missing entries + * return `''` (no API configured). + */ +export function getApiGroupForType( + type: string, + mapping: Record<string, 'anime' | 'movie' | ''> | undefined, +): 'anime' | 'movie' | '' { + if (type === 'Anime') return 'anime'; + if (type === 'Movie' || type === 'TV Show' || type === 'TvShow') return 'movie'; + return mapping?.[type] ?? ''; +} + +/** + * Returns the poster URL to render for a title. A non-empty `manualPosterUrl` + * takes priority over the auto-fetched `posterUrl`. + */ +export function getDisplayPoster(title: WatchLogTitle): string { + if (title.manualPosterUrl && title.manualPosterUrl.trim() !== '') { + return title.manualPosterUrl; + } + return title.posterUrl ?? ''; +} + +export function formatVoteCount(votes: number): string { + if (!votes || votes < 0) return '0'; + if (votes >= 1_000_000) return (votes / 1_000_000).toFixed(1) + 'M'; + if (votes >= 1_000) return (votes / 1_000).toFixed(1) + 'K'; + return votes.toString(); +} + +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; + anilistId?: number; + title: string; + episodes: number; + duration: number; + releaseDate: string; + url: string; + seasons: Season[]; + description?: string; + averageScore?: number; + genres?: string[]; + posterUrl?: string; +} + +// ── AniList API shapes ─────────────────────────────────────────────────────── + +export interface AniListTitle { + romaji?: string | null; + english?: string | null; + native?: string | null; +} + +export interface AniListDate { + year?: number | null; + month?: number | null; + day?: number | null; +} + +export interface AniListCoverImage { + large?: string | null; + medium?: string | null; +} + +export interface AniListMedia { + id: number; + title?: AniListTitle | null; + episodes?: number | null; + duration?: number | null; + status?: string | null; + season?: string | null; + seasonYear?: number | null; + startDate?: AniListDate | null; + averageScore?: number | null; + popularity?: number | null; + coverImage?: AniListCoverImage | null; + description?: string | null; + genres?: string[] | null; + nextAiringEpisode?: { + airingAt?: number | null; + episode?: number | null; + timeUntilAiring?: number | null; + } | null; +} + +export interface AniListSearchResponse { + data?: { Page?: { media?: AniListMedia[] } }; + errors?: Array<{ message: string }>; +} + +export interface AniListMediaResponse { + data?: { Media?: AniListMedia }; + errors?: Array<{ message: string }>; +} + +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; + imdbRating?: string; + imdbVotes?: 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[]; +} diff --git a/styles.css b/styles.css index bdcc8eb..33f9226 100644 --- a/styles.css +++ b/styles.css @@ -11,6 +11,11 @@ overflow: hidden; font-size: var(--font-ui-small); color: var(--text-normal); + /* Default theme: plugin-specific accent vars (themed variants override below) */ + --wl-accent: hsl(258deg 6.48% 13.98%); + --wl-rating-color: #BA7517; + --wl-watched-color: #1D9E75; + --wl-group-border-overlay: rgb(0 0 0 / 20%); } /* ── Tab bar ────────────────────────────────────────────── */ @@ -46,7 +51,7 @@ /* ── Tab content ────────────────────────────────────────── */ .wl-tab-content { - flex: 1; + flex: 1 1 0%; min-height: 0; overflow-y: auto; padding: 12px 10px; @@ -71,7 +76,64 @@ margin-bottom: 16px; } +/* ── Unified Total / Time card ──────────────────────────── */ +/* The two vertical dividers are rendered as 1px grid column-gaps that reveal the + card's background colour. A 1px child border-left did not paint reliably at the + exact column boundary, so the gap-reveal technique is used instead: the gaps + always span the full row height and are guaranteed to paint. */ +.wl-dash-unified { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(3, 1fr); + align-items: stretch; + column-gap: 1px; + background-color: var(--background-modifier-border); /* shows through the gaps */ + border-radius: 8px; + overflow: hidden; +} + +.wl-dash-unified-bordered { + border: 2px solid var(--wl-accent); +} + +.wl-dash-seg { + display: flex; + flex-direction: column; + justify-content: center; + padding: 10px 12px; + min-width: 0; + background-color: var(--background-secondary-alt); /* covers card bg except gaps */ +} + +.wl-dash-seg .wl-rect-top { + justify-content: space-between; + align-items: baseline; + gap: 8px; +} + +.wl-dash-seg-center { + align-items: center; + text-align: center; +} + +.wl-dash-seg-label { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + margin-bottom: 6px; +} + +.wl-dash-seg-value { + font-size: 18px; + font-weight: 700; + color: var(--text-normal); + line-height: 1.1; +} + .wl-ring-item { + /* Single source of truth for the gauge circle size; the SVG and the accent + ring (::before) both derive from it so they stay locked together. */ + --wl-ring-size: 110px; display: flex; flex-direction: column; align-items: center; @@ -79,20 +141,27 @@ position: relative; } +/* Accent ring: anchored to the SVG circle rather than the whole card. + The SVG is the first child (so its centre is one half-size down from the + item top) and its viewBox is 120 with the arc's outer edge at radius 50, + i.e. 100/120 of the box. Sizing the ring just outside that keeps it flush + against the arc at any gauge size. */ .wl-ring-item::before { content: ''; position: absolute; - width: 94px; - height: 94px; + width: calc(var(--wl-ring-size) * 0.855); + height: calc(var(--wl-ring-size) * 0.855); border-radius: 50%; border: 2px solid var(--wl-accent); opacity: 0.9; - top: 36.5%; + top: calc(var(--wl-ring-size) / 2); left: 50%; transform: translate(-50%, -50%); } .wl-ring-svg { + width: var(--wl-ring-size); + height: var(--wl-ring-size); overflow: visible; } @@ -126,6 +195,18 @@ color: var(--text-muted); } +/* Single-line reading subline under a ring card (reserved on type cards too, + so every ring card stays the same height). */ +.wl-ring-subline { + font-size: 11px; + min-height: 1.3em; + line-height: 1.3; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* ── Summary metrics ────────────────────────────────────── */ .wl-summary-metrics { margin-bottom: 16px; @@ -166,7 +247,7 @@ .wl-section-title { font-size: var(--font-ui-small); font-weight: 600; - color: #ffffff; + color: var(--text-normal); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 8px; @@ -251,20 +332,14 @@ } .wl-add-btn { + /* color via .wl-btn-success */ padding: 4px 10px; - background: var(--interactive-accent); - color: var(--text-on-accent); - border: none; border-radius: 4px; cursor: pointer; font-size: var(--font-ui-small); font-weight: 500; } -.wl-add-btn:hover { - background: var(--interactive-accent-hover); -} - /* ── Search ─────────────────────────────────────────────── */ .wl-search-wrap { width: 100%; @@ -286,13 +361,6 @@ border-color: var(--interactive-accent); } -/* ── Controls row ───────────────────────────────────────── */ -.wl-controls-row { - display: flex; - gap: 4px; - flex-wrap: wrap; -} - .wl-select { padding: 4px 6px; border-radius: 4px; @@ -326,7 +394,7 @@ .wl-results-count { font-size: 14px; font-weight: 700; - color: #ffffff; + color: var(--text-normal); padding: 2px 0; } @@ -343,12 +411,6 @@ overflow: hidden; } -/* Virtual-scroll: measurement container (hidden, in-flow, same width as table) */ -.wl-virt-measure { - visibility: hidden; - pointer-events: none; -} - /* Virtual-scroll: fixed-height container; children are absolutely positioned */ .wl-virt-container { position: relative; @@ -533,27 +595,15 @@ min-width: 0; } -.wl-acc-title { - font-size: 14px; - font-weight: 600; - margin-bottom: 2px; -} - .wl-acc-subtitle { font-size: 14px; font-weight: 600; - color: #ffffff; + color: var(--text-normal); display: flex; align-items: center; gap: 5px; } -.wl-acc-eps-total { - font-size: 12px; - color: var(--text-muted); - margin-top: 2px; -} - .wl-acc-header-right { flex-shrink: 0; text-align: right; @@ -655,6 +705,17 @@ font-size: 14px; } +.wl-episode-box.is-skipped { + border-color: #6b2972; + color: #6b2972; + font-size: 14px; +} + +/* Watched + skipped: keep watched background, override border to purple */ +.wl-episode-box.is-watched.is-skipped { + border-color: #6b2972; +} + /* ── Season fill/clear button ───────────────────────────── */ .wl-season-fill-btn { width: 20px; @@ -705,15 +766,6 @@ font-weight: 500; } -.wl-movie-date { - padding: 3px 6px; - border: 0.5px solid var(--background-modifier-border); - border-radius: 4px; - background: var(--background-primary); - color: var(--text-normal); - font-size: var(--font-ui-small); -} - /* ── Accordion footer ───────────────────────────────────── */ .wl-accordion-footer { border-top: 0.5px solid var(--background-modifier-border); @@ -786,6 +838,17 @@ font-size: var(--font-ui-small); } +.wl-footer-today-btn { + font-size: 11px; + padding: 2px 8px; + line-height: 1.4; +} + +.wl-footer-today-btn.is-dimmed { + opacity: 0.4; + cursor: default; +} + .wl-footer-action-row { margin-top: 4px; gap: 6px; @@ -807,19 +870,13 @@ } .wl-delete-btn { + /* color via .wl-btn-danger */ padding: 3px 10px; - background: transparent; - border: 0.5px solid var(--text-error); - color: var(--text-error); border-radius: 4px; cursor: pointer; font-size: 11px; } -.wl-delete-btn:hover { - background: var(--background-modifier-error); -} - /* ════════════════════════════════════════════════════════ INLINE WIDGET ════════════════════════════════════════════════════════ */ @@ -1075,13 +1132,16 @@ } .wl-btn-primary { - background: var(--interactive-accent); - color: var(--text-on-accent); - border-color: var(--interactive-accent); + /* green color scheme — matches .wl-btn-success */ + background: transparent; + color: var(--color-green); + border-color: var(--color-green); } .wl-btn-primary:hover { - background: var(--interactive-accent-hover); + background: var(--color-green); + color: var(--text-on-accent); + border-color: var(--color-green); } /* ════════════════════════════════════════════════════════ @@ -1153,6 +1213,43 @@ font-weight: 600; } +/* ── API settings callouts ──────────────────────────────── */ +.wl-api-callout { + border: 1px solid var(--background-modifier-border); + border-left-width: 3px; + border-radius: 8px; + padding: 10px 14px 14px; + margin: 14px 0; + background: var(--background-secondary); +} + +.wl-api-callout-title { + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 4px; +} + +/* Muted, theme-aware tints — discreet rather than loud. */ +.wl-api-callout--movies { + border-left-color: rgba(74, 137, 220, 0.55); + background: rgba(74, 137, 220, 0.06); +} +.wl-api-callout--movies .wl-api-callout-title { color: rgba(74, 137, 220, 0.95); } + +.wl-api-callout--anime { + border-left-color: rgba(45, 168, 115, 0.55); + background: rgba(45, 168, 115, 0.06); +} +.wl-api-callout--anime .wl-api-callout-title { color: rgba(45, 168, 115, 0.95); } + +.wl-api-callout--books { + border-left-color: rgba(214, 158, 46, 0.6); + background: rgba(214, 158, 46, 0.07); +} +.wl-api-callout--books .wl-api-callout-title { color: rgba(214, 158, 46, 0.95); } + /* ── Tag sections ───────────────────────────────────────── */ .wl-tag-section { margin-bottom: 16px; @@ -1321,9 +1418,8 @@ gap: 8px; } - .wl-ring-svg { - width: 90px; - height: 90px; + .wl-ring-item { + --wl-ring-size: 90px; } .wl-table-header-row, @@ -1340,10 +1436,6 @@ display: none; } - .wl-controls-row { - gap: 3px; - } - .wl-episode-grid { gap: 3px; } @@ -1390,19 +1482,6 @@ padding: 4px 0; } -.wl-dropdown-item { - padding: 7px 14px; - cursor: pointer; - font-size: var(--font-ui-small); - color: var(--text-normal); - transition: background 0.1s; - white-space: nowrap; -} - -.wl-dropdown-item:hover { - background: var(--background-modifier-hover); -} - /* ── Group header row ────────────────────────────────────── */ .wl-table .wl-group-row { background: transparent; @@ -1504,15 +1583,7 @@ border-color: var(--interactive-accent); } -.wl-group-action-btn-delete { - border-color: transparent; -} - -.wl-group-action-btn-delete:hover { - background: var(--background-modifier-error); - color: white; - border-color: var(--text-error); -} +/* .wl-group-action-btn-delete — color via .wl-btn-danger */ .wl-group-rename-input { flex: 1; @@ -1565,11 +1636,6 @@ background: var(--background-modifier-hover); } -.wl-airtime-card-missed { - background: color-mix(in srgb, var(--background-secondary) 92%, #E24B4A 8%); - border-color: color-mix(in srgb, var(--background-modifier-border) 70%, #E24B4A 30%); -} - /* ── Left section ────────────────────────────────────── */ .wl-airtime-card-left { flex: 1; @@ -1599,18 +1665,6 @@ color: var(--text-muted); } -.wl-airtime-ep-info { - font-size: 11px; - font-weight: 600; - color: var(--text-muted); - background: var(--background-primary); - border: 0.5px solid var(--background-modifier-border); - border-radius: 4px; - padding: 1px 6px; - display: inline-block; - width: fit-content; -} - /* ── Right section ───────────────────────────────────── */ .wl-airtime-card-right { flex-shrink: 0; @@ -1645,12 +1699,6 @@ border: 0.5px solid var(--background-modifier-border); } -.wl-airtime-pill-missed { - background: transparent; - color: #E24B4A; - border: 0.5px solid #E24B4A; -} - .wl-airtime-pill-today-series { background: #EEEDFE; color: #3C3489; @@ -1736,11 +1784,7 @@ border-color: var(--interactive-accent); } -.wl-airtime-action-btn-delete:hover { - background: #E24B4A; - color: #ffffff; - border-color: #E24B4A; -} +/* .wl-airtime-action-btn-delete — color via .wl-btn-danger */ /* ── Tick (complete) button ───────────────────────────────── */ .wl-airtime-action-btn-tick { @@ -1850,16 +1894,38 @@ transition: width 0.3s ease; } +/* Single-line caption under a rect card's bar (e.g. "X / Y pages"). + min-height reserves the line so cards without a sublabel stay the same height. */ +.wl-rect-subline { + font-size: 10px; + color: var(--text-muted); + min-height: 1.3em; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* ════════════════════════════════════════════════════════ SELECTION MODE ════════════════════════════════════════════════════════ */ /* Header controls area (right side of header) */ .wl-header-controls { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; +} + +/* Add buttons pinned to the far right of the toolbar row */ +.wl-header-controls-right { display: flex; align-items: center; gap: 6px; margin-left: auto; + flex-shrink: 0; } /* Small button variant */ @@ -2094,10 +2160,6 @@ white-space: nowrap; } -.wl-full-card-sub-sep { - color: var(--text-faint); -} - .wl-full-card-meta { display: flex; align-items: center; @@ -2373,14 +2435,6 @@ THEME SYSTEM — CSS VARIABLES ════════════════════════════════════════════════════════ */ -/* Default theme: plugin-specific accent vars only */ -.wl-view { - --wl-accent: hsl(258deg 6.48% 13.98%); - --wl-rating-color: #BA7517; - --wl-watched-color: #1D9E75; - --wl-group-border-overlay: rgb(0 0 0 / 20%); -} - /* Nightfall theme — accent colors only, no background/text overrides */ .wl-view[data-theme="nightfall"] { --wl-accent: #4b246c; @@ -2550,13 +2604,6 @@ margin-bottom: 16px; } -.wl-suggestions-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; -} - .wl-suggestions-grid { display: grid; grid-template-columns: repeat(2, 1fr); @@ -3152,6 +3199,17 @@ text-overflow: ellipsis; } +.wl-cl-autotime-refresh { + cursor: pointer; + margin-left: 4px; + font-size: 13px; + opacity: 0.6; + transition: opacity 0.15s; +} +.wl-cl-autotime-refresh:hover { + opacity: 1; +} + .wl-cl-th-num { flex: 0 0 28px; text-align: center; @@ -3343,118 +3401,237 @@ EDIT COLUMNS MODAL (v0.8.0) ════════════════════════════════════════════════════════ */ +.wl-editcol-modal { + --dialog-width: 820px; +} + .wl-editcols-modal { - min-width: 460px; + min-width: 90%; + width: calc(100% - 40px); + max-width: calc(100% - 40px); } .wl-editcols-list { - display: flex; - flex-direction: column; - gap: 6px; margin-bottom: 10px; - max-height: 380px; + max-height: 460px; + overflow-x: auto; overflow-y: auto; } -.wl-editcols-row { +/* ── Horizontal card layout for Edit Columns ─────────────────────── */ + +.wl-editcol-card-grid { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; + gap: 8px; + padding: 4px; +} + +.wl-editcol-card { display: flex; flex-direction: column; + align-items: stretch; gap: 4px; - padding: 6px 8px; + width: 130px; + flex: 0 0 130px; + padding: 6px; border: 0.5px solid var(--background-modifier-border); border-radius: 5px; background: var(--background-secondary); + box-sizing: border-box; } -.wl-editcols-row-header { - display: flex; - align-items: center; - gap: 6px; - flex-wrap: nowrap; +.wl-editcol-card.wl-cl-dragging { + opacity: 0.5; } -.wl-editcols-row.wl-cl-drag-over { - border-top-color: var(--interactive-accent); - border-top-width: 2px; +.wl-editcol-card.wl-cl-drag-over { + border-left: 2px solid var(--interactive-accent); } -.wl-editcols-handle { +.wl-editcol-card-handle { cursor: grab; color: var(--text-faint); - font-size: 16px; - padding: 2px 0; - flex-shrink: 0; + font-size: 11px; + text-align: center; user-select: none; - line-height: 1.4; + line-height: 1; } -.wl-editcols-handle:hover { +.wl-editcol-card-handle:hover { color: var(--text-muted); } -.wl-editcols-name { - flex: 1; - min-width: 100px; - max-width: 300px; -} - -.wl-editcols-type { - flex: 0 0 90px; -} - -.wl-editcols-extra { - display: flex; - flex-wrap: wrap; - gap: 4px; - align-items: center; -} - -.wl-editcols-toggle { +.wl-editcol-card-name { + width: 100%; + box-sizing: border-box; font-size: 12px; - padding: 2px 7px; + height: 26px; + padding: 2px 6px; } -.wl-editcols-opts-area { - padding-left: 28px; - display: flex; - flex-direction: column; - gap: 4px; - max-width: 150px; -} - -.wl-editcols-opts { - display: flex; - flex-direction: column; - gap: 4px; +.wl-editcol-card-name-wrap { width: 100%; } -.wl-editcols-opt-row { - display: flex; - gap: 4px; - align-items: center; - margin-left: 282px; +.wl-editcol-card-name-label { + width: 100%; + padding: 2px 6px; + border: 0.5px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-primary-alt); + color: var(--text-muted); + font-size: 12px; + text-align: left; + box-sizing: border-box; } -.wl-editcols-opt-del { - flex-shrink: 0; - padding: 1px 5px; +.wl-editcol-card-type { + width: 100%; + box-sizing: border-box; font-size: 12px; - line-height: 1.4; + height: 26px; + padding: 2px 6px; +} + +.wl-editcol-card-type-locked { + width: 100%; + padding: 3px 6px; + border: 0.5px dashed var(--background-modifier-border); + border-radius: 4px; + color: var(--text-muted); + font-size: 11px; + font-style: italic; + text-align: center; + box-sizing: border-box; +} + +.wl-editcol-card-toggles { + display: flex; + gap: 4px; + justify-content: center; +} + +.wl-editcol-card-toggle { + flex: 0 0 22px; + width: 22px; + height: 22px; + font-size: 11px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.wl-editcol-card-del { + align-self: center; + width: 22px; + height: 22px; + padding: 0; + font-size: 11px; + display: flex; + align-items: center; + justify-content: center; color: var(--text-muted); } -.wl-editcols-opt-del:hover { +.wl-editcol-card-del:hover { color: var(--text-error); } -.wl-editcols-del { - margin-left: auto; - flex-shrink: 0; +.wl-editcol-card-spacer { + flex: 1; + min-height: 4px; } -.wl-editcols-add-btn { - margin-bottom: 10px; +.wl-editcol-card-opts { + display: flex; + flex-direction: column; + gap: 4px; + border-top: 0.5px dashed var(--background-modifier-border); + padding-top: 6px; + margin-top: 2px; +} + +.wl-editcol-card-opt-row { + display: flex; + gap: 4px; + align-items: center; +} + +.wl-editcol-card-opt-input { + flex: 1; + min-width: 0; + font-size: 11px; + padding: 1px 4px; + height: 22px; +} + +.wl-editcol-card-opt-del { + flex: 0 0 auto; + width: 18px; + height: 18px; + padding: 0; + font-size: 10px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); +} + +.wl-editcol-card-opt-del:hover { + color: var(--text-error); +} + +.wl-editcol-card-opt-add { + font-size: 11px; + padding: 1px 4px; + height: 22px; +} + +/* Auto card (# and Name) — sized individually via data-auto attr */ +.wl-editcol-card.wl-editcol-card-auto { + opacity: 0.5; + pointer-events: none; + background: var(--background-modifier-hover); +} + +.wl-editcol-card.wl-editcol-card-auto[data-auto="#"] { + width: 40px; + flex: 0 0 40px; + padding: 4px; +} + +.wl-editcol-card.wl-editcol-card-auto[data-auto="Name"] { + width: 70px; + flex: 0 0 70px; + padding: 4px 6px; +} + +/* Add-column card */ +.wl-editcol-card.wl-editcol-card-add { + width: 80px; + flex: 0 0 80px; + border: 1px dashed var(--background-modifier-border); + background: transparent; + align-items: center; + justify-content: center; + min-height: unset; + cursor: pointer; + color: var(--text-muted); + padding: 6px; +} + +.wl-editcol-card.wl-editcol-card-add:hover { + border-color: var(--interactive-accent); + color: var(--text-normal); +} + +.wl-editcol-card-add-label { + font-size: 12px; + text-align: center; } .wl-cl-name-error { @@ -3511,22 +3688,11 @@ /* ── Edit Columns modal — mobile responsive ──────────────── */ -@media (max-width: 480px) { +@media (max-width: 720px) { .wl-editcols-modal { min-width: unset; - width: 100%; - } - - .wl-editcols-row-header { - flex-wrap: wrap; - } - - .wl-editcols-name { - min-width: 80px; - } - - .wl-editcols-type { - flex: 0 0 80px; + width: calc(100% - 16px); + max-width: calc(100% - 16px); } .wl-editcols-list { @@ -3704,6 +3870,13 @@ font-style: italic; } +/* ── Skip count inline labels next to Total Episodes input ── */ +.wl-modal-skip-inline { + font-size: var(--font-ui-small); + color: var(--text-muted); + white-space: nowrap; +} + /* ── Quick Info tab ──────────────────────────────────── */ .wl-qi-section { margin-bottom: 6px; @@ -3918,14 +4091,142 @@ font-style: italic; } -/* ── Feature 2: Upcoming History sub-tab ────────────────────────────────────── */ -.wl-history-banner { +/* ── Log Tab (timeline) ───────────────────────────────────────────────────── */ +.wl-log-tab { + padding: 12px 0 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.wl-log-filter-bar { + display: flex; + gap: 6px; + padding: 10px 12px 6px; + border-bottom: 1px solid var(--background-modifier-border); + flex-shrink: 0; +} +.wl-log-filter-btn { + font-size: 12px; + font-weight: 500; + padding: 4px 12px; + border-radius: 14px; + border: 1px solid var(--background-modifier-border); + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.wl-log-filter-btn:hover { + color: var(--text-normal); background: var(--background-modifier-hover); } +.wl-log-filter-btn.is-active { + background: var(--interactive-accent); + color: var(--text-on-accent); + border-color: var(--interactive-accent); +} +.wl-log-empty { + padding: 24px 16px; + font-size: 13px; + color: var(--text-muted); + font-style: italic; + text-align: center; +} +.wl-log-timeline { + padding: 8px 12px 16px; + flex: 1; + overflow-y: auto; + min-height: 0; +} +.wl-log-virtual-scroll { + overflow-y: auto; +} +.wl-log-spacer { + width: 100%; +} +.wl-log-viewport { + width: 100%; +} +.wl-log-day { + margin-bottom: 4px; +} +.wl-log-day-header { + font-size: 12px; + font-weight: 600; + color: var(--text-normal); + padding: 10px 0 6px; + border-bottom: 1px solid var(--background-modifier-border); + margin-bottom: 4px; +} +.wl-log-entries { + padding-left: 4px; +} +.wl-log-entry { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 4px 0; + min-height: 24px; +} +.wl-log-dot-col { + display: flex; + flex-direction: column; + align-items: center; + width: 10px; + flex-shrink: 0; + padding-top: 6px; +} +.wl-log-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} +.wl-log-connector { + width: 2px; + flex: 1; + min-height: 12px; + background: var(--background-modifier-border); +} +.wl-log-content { + flex: 1; + min-width: 0; + line-height: 1.4; + padding-top: 2px; +} +.wl-log-source { + font-size: 12px; + color: var(--text-muted); +} +.wl-log-sep { + font-size: 12px; + color: var(--text-faint); +} +.wl-log-title { + font-size: 14px; + font-weight: 500; + color: var(--text-normal); +} +.wl-log-action { + font-size: 13px; + font-weight: 500; +} +.wl-log-time { + font-size: 12px; + color: var(--text-faint); + flex-shrink: 0; + padding-top: 3px; +} +/* ── Feature 2: Upcoming History sub-tab ────────────────────────────────────── */ .wl-airtime-history-card { opacity: 0.92; } +.wl-airtime-history-right { + flex-direction: row; + align-items: center; + gap: 4px; +} /* ── Feature 2c: Maybe cards ─────────────────────────────────────────────────── */ .wl-maybe-card { @@ -4019,3 +4320,2094 @@ .wl-hidden { display: none !important; } + +/* ── Cards view ─────────────────────────────────────────────────────────── */ + +.wl-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 12px; + padding: 8px; +} + +.wl-cards-scroll-container { + position: relative; + width: 100%; + overflow-y: auto; + /* Fill remaining vertical space inside the watchlist container. */ + flex: 1 1 auto; + min-height: 0; +} + +.wl-cards-scroll-spacer { + position: relative; + width: 100%; +} + +.wl-cards-grid.wl-cards-grid-virtual { + position: absolute; + top: 0; + left: 0; + right: 0; + display: grid; + grid-template-columns: repeat(var(--wl-cards-cols, 1), 1fr); + gap: 12px; + padding: 0; + box-sizing: border-box; + will-change: transform; +} + +.wl-cards-grid.wl-cards-grid-virtual .wl-card { + width: 100%; + height: var(--wl-card-height, auto); + aspect-ratio: auto; +} + +.wl-detail-modal { + display: flex; + flex-direction: column; + gap: 16px; + max-height: calc(85vh - 40px); + overflow-y: auto; +} + +.wl-detail-header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + flex-wrap: wrap; +} + +.wl-detail-header-left { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.wl-detail-title { + margin: 0; + font-size: 1.3rem; + font-weight: 700; +} + +.wl-detail-badge-row { + display: flex; + align-items: center; + gap: 8px; +} + +.wl-detail-group-meta { + font-size: 0.85rem; + opacity: 0.7; +} + +.wl-detail-stats-box { + display: flex; + flex-direction: row; + align-items: center; + gap: 16px; + padding: 8px 12px; + border: 1px solid var(--background-modifier-border, rgba(255,255,255,0.1)); + border-radius: 6px; +} + +.wl-detail-episodes { + display: flex; + flex-direction: column; + gap: 8px; +} + +.wl-detail-rating, +.wl-detail-notes, +.wl-detail-date { + display: flex; + flex-direction: column; + gap: 6px; +} + +.wl-detail-rating { + flex-direction: row; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.wl-detail-date { + flex-direction: row; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.wl-detail-status { + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.wl-detail-notes-input { + width: 100%; + min-height: 60px; + resize: vertical; + font-family: inherit; + font-size: 0.9rem; +} + +.wl-detail-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 12px; + gap: 12px; +} + +.wl-detail-group-grid { + /* Scrolls inside the parent .wl-detail-modal, no own scroll container. */ +} + +.wl-card { + position: relative; + aspect-ratio: 2 / 3; + border-radius: 8px; + overflow: hidden; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.wl-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.wl-card-poster-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 3rem; + font-weight: 700; + color: rgba(255, 255, 255, 0.85); +} + +.wl-card-poster-placeholder.is-loading { + animation: wl-poster-pulse 1.5s ease-in-out infinite; +} + +@keyframes wl-poster-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +.wl-card-poster-placeholder span { + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); +} + +.wl-card-progress-bar { + margin-top: 6px; + width: 100%; + height: 3px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.2); + overflow: hidden; +} + +.wl-card-progress-fill { + height: 100%; + border-radius: 2px; + background: rgba(255, 255, 255, 0.8); + transition: width 0.2s ease; +} + +.wl-cards-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 16px; + gap: 8px; + color: var(--text-muted, rgba(255, 255, 255, 0.55)); +} + +.wl-cards-empty-icon { + font-size: 2.5rem; + opacity: 0.6; +} + +.wl-cards-empty-msg { + margin: 0; + font-size: 0.95rem; +} + +.wl-card-context-menu { + position: absolute; + top: 36px; + right: 8px; + z-index: 20; + min-width: 150px; + padding: 4px; + border-radius: 6px; + background: var(--background-secondary, #2a2a2a); + border: 1px solid var(--background-modifier-border, rgba(255, 255, 255, 0.1)); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + display: flex; + flex-direction: column; +} + +.wl-card-context-menu.is-right-aligned { + right: 8px; + left: auto; +} + +.wl-card-context-item { + padding: 6px 10px; + cursor: pointer; + border-radius: 4px; + font-size: 0.9rem; + color: var(--text-normal); +} + +.wl-card-context-item:hover { + background: var(--background-modifier-hover, rgba(255, 255, 255, 0.08)); +} + +.is-mobile .wl-card:hover { + transform: none; + box-shadow: none; +} + +.is-mobile .wl-card:active { + transform: scale(0.97); +} + +.wl-card:focus-visible { + outline: 2px solid var(--interactive-accent); + outline-offset: 2px; +} + +.wl-detail-episodes .wl-episode-grid { + flex-wrap: wrap; +} + +.wl-card-poster { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: none; +} + +.wl-card-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 8px; + padding-top: 40px; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.85)); + display: flex; + flex-direction: column; + gap: 4px; +} + +.wl-card-title { + color: #fff; + font-size: 0.8rem; + font-weight: 600; + line-height: 1.2; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.wl-card-type-badge { + font-size: 0.65rem; + padding: 1px 6px; + border-radius: 3px; + color: #fff; + width: fit-content; +} + +.wl-card-status-badge { + position: absolute; + top: 6px; + left: 6px; + font-size: 0.6rem; + padding: 2px 6px; + border-radius: 3px; + color: #fff; +} + +.wl-card-menu-btn { + position: absolute; + top: 4px; + right: 4px; + background: rgba(0, 0, 0, 0.5); + border: none; + color: #fff; + border-radius: 50%; + width: 24px; + height: 24px; + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.15s; + padding: 0; +} + +.wl-card:hover .wl-card-menu-btn { + opacity: 1; +} + +.is-mobile .wl-card-menu-btn { + opacity: 1; +} + +/* ── Community rating ────────────────────────────────────────────────── */ +.wl-rating-divider { + display: inline-block; + width: 1px; + height: 18px; + background-color: var(--background-modifier-border); + margin: 0 8px; + vertical-align: middle; +} + +.wl-community-badge { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.wl-community-source { + font-size: 11px; + font-weight: 500; + padding: 2px 6px; + border-radius: 4px; + line-height: 1.2; +} + +.wl-community-source--imdb { background-color: #F5C518; color: #000000; } +.wl-community-source--mal { background-color: #2E51A2; color: #FFFFFF; } +.wl-community-source--anilist { background-color: #3DB4F2; color: #FFFFFF; } +.wl-community-source--tmdb { background-color: #01B4E4; color: #FFFFFF; } + +.wl-community-score { + font-size: 15px; + font-weight: 500; + color: var(--text-normal); +} + +.wl-community-votes { + font-size: 12px; + color: var(--text-faint); +} + +.wl-community-empty { + font-size: 12px; + color: var(--text-faint); + opacity: 0.6; + font-style: italic; +} + +.wl-community-refresh { + background: transparent; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + width: 22px; + height: 22px; + padding: 0; + cursor: pointer; + font-size: 13px; + line-height: 1; + color: var(--text-muted); + display: inline-flex; + align-items: center; + justify-content: center; +} + +.wl-community-refresh:hover { + color: var(--text-normal); + background-color: var(--background-modifier-hover); +} + +.wl-community-refresh.is-loading { + animation: wl-spin 0.9s linear infinite; + pointer-events: none; +} + +@keyframes wl-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* ── API routing card ────────────────────────────────────────────────── */ +.wl-settings-card { + margin-top: 24px; + padding: 14px 16px; + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + background-color: var(--background-secondary); +} + +.wl-settings-card-title { + font-size: 14px; + font-weight: 600; + margin-bottom: 4px; + color: var(--text-normal); +} + +.wl-settings-card-desc { + font-size: 12px; + color: var(--text-muted); + margin-bottom: 12px; +} + +.wl-type-api-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.wl-type-api-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 6px 0; + border-bottom: 1px solid var(--background-modifier-border); +} + +.wl-type-api-row:last-child { + border-bottom: none; +} + +.wl-type-api-name { + font-size: 13px; + color: var(--text-normal); +} + +.wl-type-api-locked { + font-size: 12px; + color: var(--text-muted); + opacity: 0.55; + font-style: italic; +} + +.wl-type-api-select { + max-width: 240px; +} + +/* AddTitleModal: "no API configured" message */ +.wl-modal-no-api { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 14px 12px; + color: var(--text-muted); + font-size: 13px; + text-align: center; +} + +.wl-modal-no-api-icon { + font-size: 16px; + opacity: 0.7; +} + +/* ── Filter panel: per-section "All" toggle + Groups-only divider ─────── */ +.wl-filter-all-toggle { + color: var(--interactive-accent); + font-weight: 500; +} + +.wl-filter-section-divider { + height: 1px; + background-color: var(--background-modifier-border); + margin: 6px 0; +} + +/* ════════════════════════════════════════════════════════ + READING TAB + ════════════════════════════════════════════════════════ */ + +.wl-reading-tab { + display: flex; + flex-direction: column; + gap: 10px; + position: relative; + height: 100%; + min-height: 0; + overflow: hidden; +} + +.wl-reading-popover { + z-index: 50; + min-width: 200px; + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.wl-reading-popover-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-top: 4px; +} + +.wl-reading-popover-label:first-child { + margin-top: 0; +} + +.wl-reading-popover-checks { + display: flex; + flex-direction: column; + gap: 2px; +} + +.wl-reading-popover-check { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 4px; + border-radius: 4px; + cursor: pointer; + font-size: 13px; +} + +.wl-reading-popover-check:hover { + background: var(--background-modifier-hover); +} + +.wl-reading-popover-dot { + width: 8px; + height: 8px; + border-radius: 50%; + margin-left: auto; +} + +.wl-reading-popover-footer { + margin-top: 6px; + display: flex; + justify-content: flex-end; +} + +.wl-reading-filter-badge { + display: inline-block; + margin-left: 4px; + padding: 0 5px; + border-radius: 8px; + background: var(--interactive-accent); + color: var(--text-on-accent); + font-size: 10px; + font-weight: 600; + min-width: 14px; + text-align: center; +} + +/* ── Sub-tab bar ───────────────────────────────────────── */ +.wl-reading-subtabs { + display: flex; + gap: 2px; + border-bottom: 0.5px solid var(--background-modifier-border); + margin: 8px -2px 0; +} + +.wl-reading-subtab { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-muted); + cursor: pointer; + font-size: var(--font-ui-small); + font-weight: 500; + margin-bottom: -0.5px; + transition: color 0.15s, border-color 0.15s; +} + +.wl-reading-subtab:hover { + color: var(--text-normal); +} + +.wl-reading-subtab.is-active { + color: #1D9E75; + border-bottom-color: #1D9E75; +} + +.wl-reading-subtab-icon { + font-size: 1rem; + line-height: 1; +} + +.wl-reading-subtab-label { + line-height: 1; +} + +/* ── Toolbar ──────────────────────────────────────────── */ +.wl-reading-toolbar { + 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; +} + +.wl-reading-toolbar-right { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.wl-reading-search-wrap { + flex: 1; + min-width: 0; + max-width: 240px; +} + +.wl-reading-search-input { + width: 100%; + padding: 6px 10px; + border-radius: 5px; + border: 0.5px solid var(--background-modifier-border); + background: var(--background-primary); + color: var(--text-normal); + font-size: var(--font-ui-small); + box-sizing: border-box; +} + +.wl-reading-search-input:focus { + outline: none; + border-color: var(--interactive-accent); +} + +.wl-reading-add-btn { + /* color via .wl-btn-success */ + padding: 4px 12px; + border-radius: 4px; + cursor: pointer; + font-size: var(--font-ui-small); + font-weight: 500; +} + +.wl-reading-manage-btn { + padding: 4px 8px; + font-size: 14px; +} + +/* ── Card grid (placeholder for Phase 2 cards) ───────── */ +.wl-reading-content { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.wl-reading-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 12px; +} + +/* ── Empty state ─────────────────────────────────────── */ +.wl-reading-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 16px; + gap: 8px; + color: var(--text-muted); +} + +.wl-reading-empty-icon { + font-size: 2.5rem; + opacity: 0.6; +} + +.wl-reading-empty-msg { + margin: 0; + font-size: 0.95rem; +} + +.wl-reading-empty-link { + color: var(--interactive-accent); + cursor: pointer; + text-decoration: underline; +} + +.wl-reading-empty-link:hover { + filter: brightness(1.15); +} + +.wl-reading-empty-search { + padding: 24px 16px; + text-align: center; + color: var(--text-muted); + font-size: var(--font-ui-small); +} + +/* ── Reading cards ───────────────────────────────────── */ +.wl-reading-card { + position: relative; + display: flex; + flex-direction: column; + gap: 4px; + cursor: pointer; + border-radius: 6px; + padding: 4px; + transition: background 0.15s; +} + +.wl-reading-card:hover { + background: var(--background-modifier-hover); +} + +.wl-reading-card-cover { + position: relative; + width: 100%; + aspect-ratio: 2 / 3; + border-radius: 5px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: var(--background-secondary); +} + +.wl-reading-card-cover.is-loading { + animation: wl-poster-pulse 1.5s ease-in-out infinite; +} + +.wl-reading-card-cover-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.wl-reading-card-cover-icon { + font-size: 2.5rem; + opacity: 0.75; +} + +.wl-reading-card-status-badge { + position: absolute; + top: 6px; + left: 6px; + font-size: 0.6rem; + font-weight: 500; + padding: 2px 6px; + border-radius: 3px; + color: #fff; +} + +.wl-reading-card-title { + margin-top: 4px; + font-weight: 600; + font-size: var(--font-ui-small); + color: var(--text-normal); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wl-reading-card-author { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wl-reading-card-progress-bar { + margin-top: 4px; + width: 100%; + height: 3px; + background: var(--background-modifier-border); + border-radius: 2px; + overflow: hidden; +} + +.wl-reading-card-progress-fill { + height: 100%; + border-radius: 2px; + transition: width 0.2s ease; +} + +.wl-reading-card-progress-text { + display: flex; + gap: 8px; + font-size: 10.5px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wl-reading-card-progress-piece { + white-space: nowrap; +} + +/* ── Reading virtual scroll ─────────────────────────── */ +.wl-reading-scroll-container { + position: relative; + width: 100%; + overflow-y: auto; + flex: 1 1 auto; + min-height: 0; +} + +.wl-reading-scroll-spacer { + position: relative; + width: 100%; +} + +.wl-reading-grid.wl-reading-grid-virtual { + position: absolute; + top: 0; + left: 0; + right: 0; + display: grid; + grid-template-columns: repeat(var(--wl-reading-cols, 1), 1fr); + gap: 12px; + padding: 0; + box-sizing: border-box; + will-change: transform; +} + +.wl-reading-grid.wl-reading-grid-virtual .wl-reading-card { + width: 100%; + height: var(--wl-reading-card-height, auto); + overflow: hidden; +} + +.wl-reading-grid.wl-reading-grid-virtual .wl-reading-card-cover { + flex-shrink: 1; + min-height: 0; +} + +/* ── Reading card context menu ──────────────────────── */ +.wl-reading-card-menu-btn { + position: absolute; + top: 4px; + right: 4px; + background: rgba(0, 0, 0, 0.5); + border: none; + color: #fff; + border-radius: 50%; + width: 24px; + height: 24px; + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.15s; + padding: 0; + z-index: 5; +} + +.wl-reading-card:hover .wl-reading-card-menu-btn { + opacity: 1; +} + +.is-mobile .wl-reading-card-menu-btn { + opacity: 1; +} + +.wl-reading-card-context-menu { + position: absolute; + top: 36px; + right: 8px; + z-index: 20; + min-width: 150px; + padding: 4px; + border-radius: 6px; + background: var(--background-secondary, #2a2a2a); + border: 1px solid var(--background-modifier-border, rgba(255, 255, 255, 0.1)); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + display: flex; + flex-direction: column; +} + +.wl-reading-card-context-menu.is-right-aligned { + right: 8px; + left: auto; +} + +.wl-reading-card-context-item { + padding: 6px 10px; + cursor: pointer; + border-radius: 4px; + font-size: 0.9rem; + color: var(--text-normal); +} + +.wl-reading-card-context-item:hover { + background: var(--background-modifier-hover, rgba(255, 255, 255, 0.08)); +} + +/* ── Reading selection mode ─────────────────────────── */ +.wl-reading-card.is-selected { + outline: 2px solid var(--interactive-accent); + outline-offset: -2px; + border-radius: 6px; +} + +.wl-reading-action-bar { + display: flex; + align-items: center; + gap: 6px; +} + +/* ── Reading filter presets ─────────────────────────── */ +.wl-reading-popover-presets { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 4px; +} + +.wl-reading-preset-row { + display: flex; + align-items: center; + gap: 4px; +} + +.wl-reading-preset-load { + flex: 1; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wl-reading-preset-del { + flex-shrink: 0; + padding: 2px 6px; + font-size: 11px; + opacity: 0.6; +} + +.wl-reading-preset-del:hover { + opacity: 1; +} + +.wl-reading-preset-save-row { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 6px; +} + +.wl-reading-preset-name-input { + flex: 1; + min-width: 0; +} + +/* ════════════════════════════════════════════════════════ + READING MODALS + ════════════════════════════════════════════════════════ */ + +.wl-reading-modal { + display: flex; + flex-direction: column; + gap: 10px; + max-height: calc(85vh - 40px); + overflow-y: auto; + padding: 4px 2px; +} + +.wl-reading-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 1.05rem; + font-weight: 600; + color: var(--text-normal); + padding: 0 2px 4px; +} + +.wl-reading-modal-header-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.wl-reading-modal-header-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.wl-reading-modal-header-actions .wl-btn.is-hidden { + display: none; +} + +.wl-reading-modal-header-icon { + font-size: 1.2rem; + line-height: 1; +} + +.wl-reading-lookup { + display: flex; + align-items: center; + gap: 6px; +} + +.wl-reading-lookup-input { + flex: 1; + min-width: 0; +} + +.wl-reading-lookup-btn { + flex-shrink: 0; +} + +.wl-reading-lookup-results { + display: flex; + flex-direction: column; + gap: 2px; + min-height: 260px; + max-height: 380px; + overflow-y: auto; + margin-top: 6px; + padding: 4px; + border: 0.5px solid var(--background-modifier-border); + border-radius: 5px; + background: var(--background-secondary); +} + +.wl-reading-lookup-results:empty { + display: none; +} + +.wl-reading-lookup-loading, +.wl-reading-lookup-empty { + padding: 8px; + color: var(--text-muted); + font-size: 12px; + font-style: italic; +} + +.wl-reading-lookup-item { + padding: 8px 10px; + border-radius: 4px; + cursor: pointer; + border: 1px solid transparent; +} + +.wl-reading-status-rating-row { + display: flex; + flex-direction: row; + align-items: center; + flex-wrap: wrap; + gap: 16px; +} + +.wl-reading-inline-group { + display: flex; + align-items: center; + gap: 8px; +} + +.wl-reading-status-select { + width: auto; + min-width: 0; + flex: 0 0 auto; + padding-right: 24px; +} + +.wl-reading-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + align-items: start; +} + +.wl-reading-form-grid-col { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.wl-reading-form-grid-col .wl-reading-section-label { + margin-top: 0; +} + +.wl-reading-lookup-item:hover { + background: var(--background-modifier-hover); + border-color: var(--background-modifier-border); +} + +.wl-reading-lookup-item-title-row { + display: flex; + align-items: center; + gap: 6px; +} + +.wl-reading-lookup-item-title { + font-weight: 500; + font-size: 13px; +} + +.wl-reading-lookup-item-meta { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} + +.wl-reading-modal-divider { + height: 1px; + background: var(--background-modifier-border); + margin: 4px 0; +} + +.wl-reading-form { + display: flex; + flex-direction: column; + gap: 8px; +} + +.wl-reading-section-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-top: 8px; + padding-bottom: 2px; + border-bottom: 0.5px solid var(--background-modifier-border); +} + +.wl-reading-row { + display: flex; + flex-direction: column; + gap: 4px; +} + +.wl-reading-row-split { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.wl-reading-col { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.wl-reading-label { + font-size: 12px; + font-weight: 500; + color: var(--text-muted); +} + +.wl-reading-stars { + display: flex; + gap: 2px; +} + +.wl-reading-vault-wrap { + display: flex; + align-items: center; + gap: 8px; +} + +.wl-reading-vault-display { + flex: 1; + min-width: 0; + font-size: var(--font-ui-small); +} + +.wl-reading-vault-path { + color: var(--text-normal); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + max-width: 100%; + vertical-align: bottom; +} + +.wl-reading-vault-empty { + color: var(--text-muted); + font-style: italic; +} + +.wl-reading-modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 8px; + border-top: 0.5px solid var(--background-modifier-border); +} + +.wl-reading-modal-save { + /* uses .wl-reading-add-btn — color via .wl-btn-success */ +} + +/* ── Reading detail modal ────────────────────────────── */ +.wl-reading-detail { + display: flex; + flex-direction: column; + gap: 14px; +} + +.wl-reading-detail-header { + display: flex; + gap: 14px; + align-items: flex-start; +} + +.wl-reading-detail-cover { + position: relative; + width: 90px; + aspect-ratio: 2 / 3; + flex-shrink: 0; + border-radius: 5px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: var(--background-secondary); +} + +.wl-reading-detail-cover-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.wl-reading-detail-cover-icon { + font-size: 2.2rem; + opacity: 0.75; +} + +.wl-reading-detail-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.wl-reading-detail-title { + margin: 0; + font-size: 16px; + font-weight: 700; + color: var(--text-normal); +} + +.wl-reading-detail-author { + font-size: 13px; + color: var(--text-muted); +} + +.wl-reading-detail-meta-row { + display: flex; + align-items: center; + gap: 10px; + margin-top: 2px; +} + +.wl-reading-detail-status { + padding: 2px 8px; + border-radius: 999px; + color: #fff; + font-size: 11px; + font-weight: 500; + cursor: pointer; +} + +.wl-reading-detail-stars { + display: flex; + gap: 2px; +} + +.wl-reading-detail-actions { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; +} + +.wl-reading-external-link-wrap { + display: flex; + align-items: center; + gap: 4px; +} + +.wl-reading-external-link-input { + flex: 1; + min-width: 160px; + font-size: 12px; +} + +.wl-reading-detail-vault-row { + display: flex; + align-items: center; + gap: 6px; + margin-top: 2px; + font-size: 12px; + color: var(--text-muted); +} + +.wl-reading-detail-vault-label { + flex-shrink: 0; +} + +.wl-reading-detail-vault-path { + color: var(--text-normal); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.wl-reading-detail-vault-empty { + font-style: italic; +} + +/* ── Detail progress ─────────────────────────────────── */ +.wl-reading-detail-progress { + display: flex; + flex-direction: column; + gap: 6px; +} + +.wl-reading-arc-row { + display: flex; + justify-content: center; + gap: 24px; + flex-wrap: wrap; +} + +.wl-reading-arc-col { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + min-width: 110px; +} + +.wl-reading-arc-svg-wrap { + width: 100px; + height: 56px; + overflow: hidden; +} + +.wl-reading-arc-svg { + width: 100px; + height: 100px; + display: block; +} + +.wl-reading-arc-pct { + font-size: 14px; + font-weight: 600; + fill: var(--text-normal); +} + +.wl-reading-arc-controls { + display: flex; + align-items: center; + gap: 4px; +} + +.wl-reading-step-btn { + min-width: 32px; + padding: 2px 8px; +} + +.wl-reading-arc-input { + width: 52px; + text-align: center; +} + +.wl-reading-arc-label { + font-size: 13px; + color: var(--text-muted); + text-align: center; +} + +.wl-reading-arc-total-input { + width: 48px; + text-align: center; +} + +/* ── Detail details grid ─────────────────────────────── */ +.wl-reading-detail-details { + display: flex; + flex-direction: column; + gap: 6px; +} + +.wl-reading-detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 16px; +} + +.wl-reading-detail-cell { + display: flex; + flex-direction: column; + gap: 2px; +} + +/* ADDED + RELEASE share one grid cell so the modal keeps its height. */ +.wl-reading-detail-cell-pair { + flex-direction: row; + gap: 16px; +} + +.wl-reading-detail-pair-col { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.wl-reading-detail-cell-label { + font-size: 10.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); +} + +.wl-reading-detail-cell-value { + font-size: 13px; + color: var(--text-normal); +} + +.wl-reading-detail-link { + color: var(--interactive-accent); + text-decoration: underline; +} + +.wl-reading-detail-id-value { + display: flex; + align-items: center; + gap: 6px; +} + +.wl-reading-detail-id-edit { + flex: 0 0 auto; + padding: 0 6px; + min-width: 0; + line-height: 1.6; +} + +/* ── Inline-editable title / author ─────────────────── */ +.wl-reading-detail-title-wrap { + line-height: 1; +} + +.wl-reading-detail-author-wrap { + line-height: 1; +} + +.wl-reading-detail-editable-text { + cursor: text; +} + +.wl-reading-detail-editable-text:hover { + text-decoration: underline dotted var(--text-faint); +} + +.wl-reading-detail-title-input { + width: 100%; + box-sizing: border-box; + font-size: 16px; + font-weight: 700; + background: transparent; + border: none; + border-bottom: 1.5px solid var(--interactive-accent); + border-radius: 0; + padding: 0; + color: var(--text-normal); + outline: none; + box-shadow: none; + height: auto; + line-height: 1.2; +} + +.wl-reading-detail-author-input { + width: 100%; + box-sizing: border-box; + font-size: 13px; + background: transparent; + border: none; + border-bottom: 1px solid var(--background-modifier-border); + border-radius: 0; + padding: 0; + color: var(--text-muted); + outline: none; + box-shadow: none; + height: auto; +} + +/* ── Inline status badge & dropdown ─────────────────── */ +.wl-reading-detail-status-wrap { + display: inline-block; +} + +.wl-reading-status-dropdown { + position: fixed; + background: var(--background-primary); + border: 0.5px solid var(--background-modifier-border); + border-radius: 6px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); + overflow: hidden; + min-width: 148px; + z-index: 1000; +} + +.wl-reading-status-option { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 12px; + font-size: 12px; + cursor: pointer; + color: var(--text-normal); +} + +.wl-reading-status-option:hover { + background: var(--background-modifier-hover); +} + +.wl-reading-status-option-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +/* ── Editable date cells ─────────────────────────────── */ +.wl-reading-detail-cell-date { + display: flex; + align-items: center; + gap: 5px; + margin-top: 2px; +} + +.wl-reading-detail-today-btn { + font-size: 10px; + padding: 1px 6px; + line-height: 1.4; +} + +.wl-reading-detail-today-btn.is-dimmed { + opacity: 0.4; + cursor: default; +} + +.wl-reading-detail-date-input { + font-size: 12px; + width: 88px; + padding: 1px 5px; + border: 0.5px solid var(--background-modifier-border); + border-radius: 3px; + background: var(--background-primary); + color: var(--text-normal); + box-sizing: border-box; +} + +/* ── Detail footer ───────────────────────────────────── */ +.wl-reading-detail-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding-top: 10px; + border-top: 0.5px solid var(--background-modifier-border); +} + +.wl-reading-detail-footer-left, +.wl-reading-detail-footer-right { + display: flex; + gap: 8px; +} + +.wl-reading-detail-delete { + /* uses .wl-delete-btn — color via .wl-btn-danger */ +} + +/* ── Reading custom fields ──────────────────────────────────── */ +.wl-reading-detail-custom-heading, +.wl-reading-detail-quotes-heading { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.wl-reading-detail-custom-manage { + font-size: 12px; + color: var(--interactive-accent); + cursor: pointer; + text-decoration: underline; +} + +.wl-reading-detail-custom-empty { + font-size: 12px; + color: var(--text-muted); + padding: 4px 0; +} + +.wl-reading-custom-table { + display: flex; + flex-direction: column; + border: 0.5px solid var(--background-modifier-border); + border-radius: 4px; + overflow: hidden; +} + +.wl-reading-custom-row { + display: grid; + grid-template-columns: 140px 1fr; + align-items: center; + min-height: 32px; + border-top: 0.5px solid var(--background-modifier-border); +} + +.wl-reading-custom-row:first-child { + border-top: none; +} + +.wl-reading-custom-table.is-border-mode .wl-reading-custom-row { + border-top: none; +} + +.wl-reading-custom-label { + background: var(--background-secondary); + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; + padding: 6px 10px; + font-size: 12px; + color: var(--text-muted); + font-weight: 500; +} + +.wl-reading-custom-value { + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; + padding: 4px 10px; + font-size: 13px; + color: var(--text-normal); +} + +.wl-reading-custom-display { + display: inline-block; + min-height: 22px; + padding: 2px 4px; + cursor: text; + border-radius: 3px; +} + +.wl-reading-custom-display:hover { + background: var(--background-modifier-hover); +} + +.wl-reading-custom-display.is-placeholder { + color: var(--text-faint); +} + +.wl-reading-custom-input { + width: 100%; + font-size: 13px; +} + +/* ── Reading favorite quotes ────────────────────────────────── */ +.wl-reading-detail-quotes { + display: flex; + flex-direction: column; + gap: 8px; +} + +.wl-reading-detail-quotes-add { + font-size: 12px; +} + +.wl-reading-quote-empty { + font-size: 12px; + color: var(--text-muted); + padding: 6px 0; +} + +.wl-reading-quote-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.wl-reading-quote-card { + background: var(--background-secondary); + border-left: 3px solid var(--interactive-accent); + border-radius: 0 4px 4px 0; + padding: 8px 12px; +} + +.wl-reading-quote-text { + font-size: 13px; + line-height: 1.5; + color: var(--text-normal); + white-space: pre-wrap; +} + +.wl-reading-quote-ref { + font-size: 11px; + color: var(--text-muted); + margin-top: 4px; + font-style: italic; +} + +.wl-reading-quote-form-host { + margin-top: 4px; +} + +.wl-reading-quote-form { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + background: var(--background-secondary); + border-radius: 4px; + border: 0.5px solid var(--background-modifier-border); +} + +.wl-reading-quote-textarea { + width: 100%; + resize: vertical; + min-height: 60px; + font-family: inherit; +} + +.wl-reading-quote-ref-input { + width: 100%; + max-width: 240px; +} + +.wl-reading-quote-form-actions { + display: flex; + justify-content: flex-end; + gap: 6px; +} + +/* ── Manage columns — style toggle ────────────────────────── */ +.wl-reading-cols-style-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + font-size: 12px; +} + +.wl-reading-cols-style-label { + color: var(--text-muted); + flex-shrink: 0; +} + +.wl-reading-cols-style-group { + display: flex; + gap: 4px; +} + +.wl-reading-cols-style-btn { + font-size: 11px; + padding: 2px 12px; + height: 24px; + border-radius: 4px; + border: 0.5px solid var(--background-modifier-border); + background: transparent; + color: var(--text-muted); + cursor: pointer; + line-height: 1; +} + +.wl-reading-cols-style-btn.is-active { + background: var(--interactive-accent); + color: var(--text-on-accent); + border-color: var(--interactive-accent); +} + +/* ── Manage columns — color dot & palette ──────────────────── */ +.wl-reading-col-color-dot { + flex: 0 0 18px; + width: 18px; + height: 18px; + border-radius: 50%; + border: 2px solid rgba(0, 0, 0, 0.15); + cursor: pointer; + padding: 0; + transition: transform 0.1s, border-color 0.1s; +} + +.wl-reading-col-color-dot:hover { + transform: scale(1.2); + border-color: rgba(0, 0, 0, 0.35); +} + +.wl-reading-col-palette { + position: fixed; + display: grid; + grid-template-columns: repeat(4, 24px); + gap: 5px; + padding: 8px; + background: var(--background-primary); + border: 0.5px solid var(--background-modifier-border); + border-radius: 7px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); + z-index: 1000; +} + +.wl-reading-col-palette-swatch { + width: 24px; + height: 24px; + border-radius: 50%; + border: 2px solid; + cursor: pointer; + padding: 0; + transition: transform 0.1s; +} + +.wl-reading-col-palette-swatch:hover { + transform: scale(1.18); +} + +.wl-reading-col-palette-swatch.is-active { + outline: 2px solid var(--text-normal); + outline-offset: 1px; +} + +/* ── Manage columns modal ──────────────────────────────────── */ +.wl-reading-manage-cols-desc { + font-size: 12px; + color: var(--text-muted); + margin-bottom: 12px; +} + +.wl-reading-manage-cols-list { + display: flex; + flex-direction: column; + gap: 0; + border: 0.5px solid var(--background-modifier-border); + border-radius: 5px; + overflow: hidden; + margin-bottom: 4px; +} + +.wl-reading-manage-cols-empty { + font-size: 12px; + color: var(--text-muted); + padding: 10px 8px; + text-align: center; +} + +/* Row layout — overrides the vertical card from .wl-editcol-card */ +.wl-reading-col-card { + flex-direction: row; + align-items: center; + width: 100%; + box-sizing: border-box; + flex: none; + border: none; + border-bottom: 0.5px solid var(--background-modifier-border); + border-radius: 0; + background: transparent; + padding: 5px 6px; + gap: 8px; +} + +.wl-reading-col-card:last-child { + border-bottom: none; +} + +.wl-reading-col-card.wl-cl-dragging { + opacity: 0.5; +} + +.wl-reading-col-card.wl-cl-drag-over { + border-left: 2px solid var(--interactive-accent); +} + +.wl-reading-col-card .wl-editcol-card-handle { + flex: 0 0 16px; + width: 16px; +} + +.wl-reading-col-card .wl-editcol-card-name { + flex: 1 1 130px; + min-width: 0; + width: auto; + height: 26px; +} + +.wl-reading-col-card .wl-editcol-card-type { + flex: 0 0 90px; + width: 90px; + height: 26px; +} + +.wl-reading-col-card .wl-reading-col-color-dot { + flex: 0 0 18px; + width: 18px; + height: 18px; +} + +.wl-reading-col-opts-input { + flex: 1 1 130px; + min-width: 0; + font-size: 12px; + height: 26px; + box-sizing: border-box; +} + +.wl-reading-col-card .wl-editcol-card-del { + flex: 0 0 26px; + width: 26px; + margin-left: 0; +} + +.wl-reading-add-col { + margin-top: 12px; +} + +.wl-reading-add-col-row { + display: flex; + gap: 6px; + align-items: center; +} + +.wl-reading-add-col-name { + flex: 1 1 140px; + min-width: 0; +} + +.wl-reading-add-col-type { + flex: 0 0 95px; +} + +.wl-reading-add-col-opts { + flex: 1 1 130px; + min-width: 0; +} + +.wl-reading-add-col-btn { + flex: 0 0 auto; +} + +/* ── Drafts: Add choice modal ─────────────────────────────────────────────── */ +.wl-draft-choice-subtitle { + margin-bottom: 14px; + font-size: 0.9em; + color: var(--text-muted); + text-align: center; + word-break: break-word; +} + +.wl-draft-choice-grid { + display: flex; + flex-direction: row; + gap: 10px; + justify-content: center; +} + +.wl-draft-choice-btn { + flex: 1 1 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-width: 0; + min-height: 65px; + padding: 12px 8px; + border: 0.5px solid var(--background-modifier-border); + border-radius: 10px; + background: var(--background-secondary); + color: var(--text-normal); + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease; +} + +.wl-draft-choice-btn:hover { + background: var(--background-modifier-hover); + border-color: var(--interactive-accent); +} + +.wl-draft-choice-label { + font-size: 1.05em; + font-weight: 600; + text-align: center; +} + +/* ════════════════════════════════════════════════════════ + SHARED BUTTON COLOR UTILITIES + Color-only — layer on top of any geometry/button class. + Resting state is border-only; hover fills with the color. + ════════════════════════════════════════════════════════ */ +.wl-btn-danger { + background: transparent; + border: 0.5px solid rgba(var(--color-red-rgb), 0.65); + color: var(--text-error); +} + +.wl-btn-danger:hover { + background: var(--text-error); + border-color: var(--text-error); + color: var(--text-on-accent); +} + +.wl-btn-success { + background: transparent; + border: 0.5px solid rgba(var(--color-green-rgb), 0.65); + color: var(--color-green); +} + +.wl-btn-success:hover { + background: var(--color-green); + border-color: var(--color-green); + color: var(--text-on-accent); +} diff --git a/tsconfig.json b/tsconfig.json index 2edc471..e753f62 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,30 +1,30 @@ -{ - "compilerOptions": { - "baseUrl": "src", - "inlineSourceMap": true, - "inlineSources": true, - "module": "ESNext", - "target": "ES2017", - "allowJs": true, - "noImplicitAny": true, - "noImplicitThis": true, - "noImplicitReturns": true, - "moduleResolution": "node", - "importHelpers": true, - "noUncheckedIndexedAccess": true, - "isolatedModules": true, - "strictNullChecks": true, - "strictBindCallApply": true, - "allowSyntheticDefaultImports": true, - "useUnknownInCatchVariables": true, - "lib": [ - "DOM", - "ES5", - "ES6", - "ES2017" - ] - }, - "include": [ - "src/**/*.ts" - ] -} +{ + "compilerOptions": { + "baseUrl": "src", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES2017", + "allowJs": true, + "noImplicitAny": true, + "noImplicitThis": true, + "noImplicitReturns": true, + "moduleResolution": "node", + "importHelpers": true, + "noUncheckedIndexedAccess": true, + "isolatedModules": true, + "strictNullChecks": true, + "strictBindCallApply": true, + "allowSyntheticDefaultImports": true, + "useUnknownInCatchVariables": true, + "lib": [ + "DOM", + "ES5", + "ES6", + "ES2017" + ] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/version-bump.mjs b/version-bump.mjs index 55d631f..6670109 100644 --- a/version-bump.mjs +++ b/version-bump.mjs @@ -1,17 +1,17 @@ -import { readFileSync, writeFileSync } from "fs"; - -const targetVersion = process.env.npm_package_version; - -// read minAppVersion from manifest.json and bump version to target version -const manifest = JSON.parse(readFileSync("manifest.json", "utf8")); -const { minAppVersion } = manifest; -manifest.version = targetVersion; -writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); - -// update versions.json with target version and minAppVersion from manifest.json -// but only if the target version is not already in versions.json -const versions = JSON.parse(readFileSync('versions.json', 'utf8')); -if (!Object.values(versions).includes(minAppVersion)) { - versions[targetVersion] = minAppVersion; - writeFileSync('versions.json', JSON.stringify(versions, null, '\t')); -} +import { readFileSync, writeFileSync } from "fs"; + +const targetVersion = process.env.npm_package_version; + +// read minAppVersion from manifest.json and bump version to target version +const manifest = JSON.parse(readFileSync("manifest.json", "utf8")); +const { minAppVersion } = manifest; +manifest.version = targetVersion; +writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); + +// update versions.json with target version and minAppVersion from manifest.json +// but only if the target version is not already in versions.json +const versions = JSON.parse(readFileSync('versions.json', 'utf8')); +if (!Object.values(versions).includes(minAppVersion)) { + versions[targetVersion] = minAppVersion; + writeFileSync('versions.json', JSON.stringify(versions, null, '\t')); +} diff --git a/versions.json b/versions.json index d9cc86b..f51cb5c 100644 --- a/versions.json +++ b/versions.json @@ -1,14 +1,15 @@ -{ - "1.1.0": "1.4.0", - "1.0.9": "1.4.0", - "1.0.8": "1.4.0", - "1.0.7": "1.4.0", - "1.0.6": "1.4.0", - "1.0.5": "1.4.0", - "1.0.4": "1.4.0", - "1.0.0": "0.15.0", - "0.9.8": "1.4.0", - "0.9.7": "1.4.0", - "0.9.5": "1.4.0", - "0.7.0": "1.4.0" -} +{ + "2.0.0": "1.4.0", + "1.1.0": "1.4.0", + "1.0.9": "1.4.0", + "1.0.8": "1.4.0", + "1.0.7": "1.4.0", + "1.0.6": "1.4.0", + "1.0.5": "1.4.0", + "1.0.4": "1.4.0", + "1.0.0": "1.4.0", + "0.9.8": "1.4.0", + "0.9.7": "1.4.0", + "0.9.5": "1.4.0", + "0.7.0": "1.4.0" +}