import { App, Modal, Notice, TFile } from "obsidian"; interface LibraryItem { baseName: string; // without extension, e.g., recording_2025-01-01_12-00-00 audioPath: string; transcriptPath: string; logPath: string; mtimeMs: number; sizeBytes: number; } export class LibraryModal extends Modal { private pluginId: string; private listEl!: HTMLElement; private items: LibraryItem[] = []; private toolbarEl!: HTMLElement; private fromDate: string = ""; // YYYY-MM-DD private toDate: string = ""; // YYYY-MM-DD private sortMode: "date-desc" | "date-asc" | "size-desc" | "size-asc" | "name-asc" | "name-desc" = "date-desc"; private selected: Set = new Set(); private selectAllCb: HTMLInputElement | null = null; private visibleItems: LibraryItem[] = []; private deleteBtn: HTMLButtonElement | null = null; constructor(app: App, pluginId: string) { super(app); this.pluginId = pluginId; } onOpen(): void { const { contentEl } = this; contentEl.empty(); contentEl.addClass("resonance-modal"); this.modalEl.addClass("resonance-wide"); contentEl.createEl("h2", { text: "Recordings Library" }); const hint = contentEl.createEl("p", { text: "Review, listen, download or delete your recordings." }); hint.style.marginTop = "0"; this.toolbarEl = contentEl.createEl("div", { cls: "resonance-toolbar" }); this.buildToolbar(); this.listEl = contentEl.createEl("div", { cls: "resonance-list" }); void this.refresh(); } private getRecordingsDir(): string { const path = (window as any).require("path"); const adapter = (this.app.vault as any).adapter; const basePath: string = adapter?.getBasePath?.() ?? adapter?.basePath ?? ""; const configDir: string = (this.app.vault as any).configDir ?? ".obsidian"; return path.join(basePath, configDir, "plugins", this.pluginId, "recordings"); } private async scan(): Promise { const fs = (window as any).require("fs"); const path = (window as any).require("path"); const dir = this.getRecordingsDir(); try { if (!fs.existsSync(dir)) return []; } catch { return []; } const files: string[] = fs.readdirSync(dir) ?? []; const mp3s = files.filter(f => /\.mp3$/i.test(f)); const items: LibraryItem[] = []; for (const f of mp3s) { const audioPath = path.join(dir, f); let stat: any; try { stat = fs.statSync(audioPath); } catch { continue; } const base = f.replace(/\.mp3$/i, ""); const transcriptPath = path.join(dir, `${base}.txt`); const logPath = path.join(dir, `${base}.log`); items.push({ baseName: base, audioPath, transcriptPath, logPath, mtimeMs: stat.mtimeMs || stat.ctimeMs || 0, sizeBytes: stat.size || 0, }); } items.sort((a, b) => b.mtimeMs - a.mtimeMs); return items; } private buildToolbar() { this.toolbarEl.empty(); const fromWrap = this.toolbarEl.createEl("div", { cls: "date" }); const fromLbl = fromWrap.createEl("label", { text: "From" }); fromLbl.style.marginRight = "6px"; const from = fromWrap.createEl("input", { type: "date" }) as HTMLInputElement; from.value = this.fromDate; from.addEventListener("change", () => { this.fromDate = from.value || ""; this.render(); }); const toWrap = this.toolbarEl.createEl("div", { cls: "date" }); const toLbl = toWrap.createEl("label", { text: "To" }); toLbl.style.marginRight = "6px"; const to = toWrap.createEl("input", { type: "date" }) as HTMLInputElement; to.value = this.toDate; to.addEventListener("change", () => { this.toDate = to.value || ""; this.render(); }); const sort = this.toolbarEl.createEl("select"); const addOpt = (v: typeof this.sortMode, l: string) => { const o = document.createElement('option'); o.value=v; o.text=l; sort.appendChild(o); }; addOpt("date-desc", "Newest first"); addOpt("date-asc", "Oldest first"); addOpt("size-desc", "Size: large → small"); addOpt("size-asc", "Size: small → large"); addOpt("name-asc", "Name A→Z"); addOpt("name-desc", "Name Z→A"); sort.value = this.sortMode; sort.addEventListener("change", () => { this.sortMode = sort.value as any; this.render(); }); const selAllWrap = this.toolbarEl.createEl("label"); const selAll = selAllWrap.createEl("input", { type: "checkbox" }) as HTMLInputElement; this.selectAllCb = selAll; selAll.addEventListener("change", () => this.toggleSelectAll(selAll.checked)); selAllWrap.appendText(" Select all"); const spacer = this.toolbarEl.createEl("div", { cls: "spacer" }); const delSel = this.toolbarEl.createEl("button", { cls: "resonance-btn danger" }); this.deleteBtn = delSel; delSel.addEventListener("click", () => this.deleteSelected()); delSel.disabled = this.selected.size === 0; delSel.appendChild(createIcon('trash')); delSel.appendText(' Delete selected'); const refreshBtn = this.toolbarEl.createEl("button", { cls: "resonance-btn secondary" }); refreshBtn.appendChild(createIcon('refresh')); refreshBtn.appendText(' Refresh'); refreshBtn.addEventListener("click", () => this.refresh()); } private updateToolbarSelectionState() { const btns = Array.from(this.toolbarEl.querySelectorAll('button')) as HTMLButtonElement[]; const del = btns.find(b => b.innerText.trim().toLowerCase() === 'delete selected'); if (this.deleteBtn) this.deleteBtn.disabled = this.selected.size === 0; this.updateSelectAllState(); } private updateSelectAllState() { if (!this.selectAllCb) return; if (this.visibleItems.length === 0) { this.selectAllCb.indeterminate = false; this.selectAllCb.checked = false; return; } const visiblePaths = new Set(this.visibleItems.map(it => it.audioPath)); let checkedCount = 0; for (const p of visiblePaths) if (this.selected.has(p)) checkedCount++; if (checkedCount === 0) { this.selectAllCb.indeterminate = false; this.selectAllCb.checked = false; } else if (checkedCount === visiblePaths.size) { this.selectAllCb.indeterminate = false; this.selectAllCb.checked = true; } else { this.selectAllCb.indeterminate = true; } } private toggleSelectAll(checked: boolean) { const paths = this.visibleItems.map(it => it.audioPath); if (checked) paths.forEach(p => this.selected.add(p)); else paths.forEach(p => this.selected.delete(p)); // Non ricreare tutta la lista per non perdere stato; aggiorna checkbox visibili e toolbar const checkboxes = Array.from(this.listEl.querySelectorAll('input[type="checkbox"]')) as HTMLInputElement[]; const pathByRow: string[] = this.visibleItems.map(it => it.audioPath); for (let i = 0, j = 0; i < checkboxes.length && j < pathByRow.length; i++) { const cb = checkboxes[i]; // Solo i checkbox delle righe (salta eventuali altri input) if (cb.type === 'checkbox') { cb.checked = this.selected.has(pathByRow[j++]); } } this.updateToolbarSelectionState(); } private async deleteSelected() { if (this.selected.size === 0) return; const ok = confirm(`Delete ${this.selected.size} selected item(s)?`); if (!ok) return; const fs = (window as any).require("fs"); const path = (window as any).require("path"); try { for (const audioPath of Array.from(this.selected)) { try { const dir = path.dirname(audioPath); const base = path.basename(audioPath).replace(/\.mp3$/i, ""); const txt = path.join(dir, `${base}.txt`); const log = path.join(dir, `${base}.log`); try { fs.unlinkSync(audioPath); } catch {} try { fs.unlinkSync(txt); } catch {} try { fs.unlinkSync(log); } catch {} } catch {} } new Notice("Selected items deleted"); } finally { this.selected.clear(); await this.refresh(); this.updateToolbarSelectionState(); } } private async refresh() { this.items = await this.scan(); this.render(); } private render() { this.listEl.empty(); const filtered = this.items.filter(it => { if (this.fromDate) { const ts = new Date(this.fromDate + 'T00:00:00').getTime(); if (it.mtimeMs < ts) return false; } if (this.toDate) { const ts2 = new Date(this.toDate + 'T23:59:59').getTime(); if (it.mtimeMs > ts2) return false; } return true; }); const items = filtered.sort((a,b)=>{ switch (this.sortMode) { case "date-asc": return a.mtimeMs - b.mtimeMs; case "size-desc": return b.sizeBytes - a.sizeBytes; case "size-asc": return a.sizeBytes - b.sizeBytes; case "name-asc": return a.baseName.localeCompare(b.baseName); case "name-desc": return b.baseName.localeCompare(a.baseName); default: return b.mtimeMs - a.mtimeMs; // date-desc } }); this.visibleItems = items; if (items.length === 0) { this.listEl.createEl("p", { text: "No recordings found." }); return; } for (const it of items) this.renderItem(it); this.updateSelectAllState(); } private renderItem(it: LibraryItem) { const row = this.listEl.createEl("div", { cls: "resonance-item" }); const meta = row.createEl("div", { cls: "resonance-meta" }); const date = new Date(it.mtimeMs); const left = meta.createEl("div", { cls: "resonance-left" }); const cb = left.createEl("input", { type: "checkbox" }) as HTMLInputElement; cb.addEventListener("change", () => { if (cb.checked) this.selected.add(it.audioPath); else this.selected.delete(it.audioPath); this.updateToolbarSelectionState(); }); cb.checked = this.selected.has(it.audioPath); const titleText = date.toLocaleString(); const title = left.createEl("div", { cls: "resonance-title", text: titleText }); title.setAttr("title", it.baseName); left.createEl("div", { cls: "resonance-sub", text: `${(it.sizeBytes/1024/1024).toFixed(2)} MB` }); const actions = row.createEl("div", { cls: "resonance-actions" }); const playBtn = actions.createEl("button", { cls: "resonance-btn secondary" }); playBtn.appendChild(createIcon('play')); playBtn.appendText(' Listen'); const openTrBtn = actions.createEl("button", { cls: "resonance-btn secondary" }); openTrBtn.appendChild(createIcon('file-text')); openTrBtn.appendText(' Open transcript'); const dlAudio = actions.createEl("button", { cls: "resonance-btn secondary" }); dlAudio.appendChild(createIcon('download')); dlAudio.appendText(' Download audio'); const dlTxt = actions.createEl("button", { cls: "resonance-btn secondary" }); dlTxt.appendChild(createIcon('download')); dlTxt.appendText(' Download text'); const showBtn = actions.createEl("button", { cls: "resonance-btn secondary" }); showBtn.appendChild(createIcon('folder')); showBtn.appendText(' Show in folder'); const openNoteBtn = actions.createEl("button", { cls: "resonance-btn secondary" }); openNoteBtn.appendChild(createIcon('note')); openNoteBtn.appendText(' Open note'); const delBtn = actions.createEl("button", { cls: "resonance-btn danger" }); delBtn.appendChild(createIcon('trash')); delBtn.appendText(' Delete'); const audioWrap = this.listEl.createEl("div", { cls: "resonance-audio-wrap" }); audioWrap.hide(); playBtn.addEventListener("click", async () => { if (!audioWrap.isShown()) { audioWrap.empty(); const el = await this.createAudioElement(it.audioPath); if (el) audioWrap.appendChild(el); audioWrap.show(); } else { audioWrap.hide(); } }); openTrBtn.addEventListener("click", async () => { const text = await this.readTextSafe(it.transcriptPath); if (!text) { new Notice("Transcript not found"); return; } const sub = new TextPreviewModal(this.app, it.baseName, text); sub.open(); }); // Note opener (from log) (async () => { const notePath = await this.findNoteFromLog(it.logPath); if (!notePath) { openNoteBtn.disabled = true; openNoteBtn.setAttr("title", "No note reference found in log"); return; } openNoteBtn.addEventListener("click", async () => { try { const file = this.app.vault.getAbstractFileByPath(notePath); if (file && (file as any).extension === 'md') { const leaf = this.app.workspace.getLeaf(true); await leaf.openFile(file as TFile); } else { new Notice("Note not found in vault"); } } catch { new Notice("Failed to open note"); } }); })(); dlAudio.addEventListener("click", async () => { await this.downloadFile(it.audioPath, `${it.baseName}.mp3`, "audio/mpeg"); }); dlTxt.addEventListener("click", async () => { await this.downloadFile(it.transcriptPath, `${it.baseName}.txt`, "text/plain;charset=utf-8"); }); showBtn.addEventListener("click", () => { try { const electron = (window as any).require("electron"); electron?.shell?.showItemInFolder?.(it.audioPath); } catch {} }); delBtn.addEventListener("click", async () => { const ok = confirm("Delete this recording and related files?"); if (!ok) return; const fs = (window as any).require("fs"); try { try { fs.unlinkSync(it.audioPath); } catch {}; try { fs.unlinkSync(it.transcriptPath); } catch {}; try { fs.unlinkSync(it.logPath); } catch {}; new Notice("Deleted"); } catch (e: any) { new Notice(`Delete error: ${e?.message ?? e}`); } await this.refresh(); }); } private async readTextSafe(filePath: string): Promise { try { const fs = (window as any).require("fs"); if (!fs.existsSync(filePath)) return null; return fs.readFileSync(filePath, { encoding: "utf8" }); } catch { return null; } } private async createAudioElement(filePath: string): Promise { try { const fs = (window as any).require("fs"); if (!fs.existsSync(filePath)) return null; const buf: any = fs.readFileSync(filePath); const uint = buf instanceof Uint8Array ? buf : new Uint8Array(buf?.buffer || buf); const blob = new Blob([uint], { type: "audio/mpeg" }); const url = URL.createObjectURL(blob); const audio = document.createElement("audio"); audio.controls = true; audio.src = url; audio.preload = "metadata"; return audio; } catch { return null; } } private async downloadFile(filePath: string, downloadName: string, mime: string) { try { const fs = (window as any).require("fs"); if (!fs.existsSync(filePath)) { new Notice("File not found"); return; } const buf: any = fs.readFileSync(filePath); const uint = buf instanceof Uint8Array ? buf : new Uint8Array(buf?.buffer || buf); const blob = new Blob([uint], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = downloadName; a.click(); setTimeout(() => URL.revokeObjectURL(url), 5000); } catch (e: any) { new Notice(`Download error: ${e?.message ?? e}`); } } private async findNoteFromLog(logPath: string): Promise { try { const fs = (window as any).require("fs"); if (!fs.existsSync(logPath)) return null; const txt: string = fs.readFileSync(logPath, { encoding: "utf8" }); // Look for the last occurrence of "Note created: " const matches = Array.from(txt.matchAll(/Note created:\s*(.+)$/gmi)); if (matches.length === 0) return null; const last = matches[matches.length - 1][1].trim(); return last; } catch { return null; } } } class TextPreviewModal extends Modal { private titleText: string; private contentText: string; constructor(app: App, titleText: string, contentText: string) { super(app); this.titleText = titleText; this.contentText = contentText; } onOpen(): void { const { contentEl } = this; contentEl.empty(); contentEl.addClass("resonance-modal"); contentEl.createEl("h3", { text: this.titleText }); const pre = contentEl.createEl("pre"); pre.style.whiteSpace = "pre-wrap"; pre.style.maxHeight = "50vh"; pre.style.overflow = "auto"; pre.setText(this.contentText); } } type IconName = 'play' | 'file-text' | 'download' | 'folder' | 'note' | 'trash' | 'refresh'; function createIcon(name: IconName): HTMLElement { const span = document.createElement('span'); span.addClass('resonance-icon'); span.innerHTML = name === 'play' ? '' : name === 'file-text' ? '' : name === 'download' ? '' : name === 'folder' ? '' : name === 'note' ? '' : name === 'trash' ? '' : /* refresh */ ''; return span; }