/** Documents section for an object's detail view. * * Self-contained: lists an object's documents (files + web-links), uploads a * file (multipart POST to the authenticated view — a WS frame can't carry a * binary body), downloads via a signed path (`auth/sign_path` → Companion-safe * anchor), adds web-links, and deletes. Write actions are gated by `canWrite` * (the server enforces it too); download/open is available to everyone. */ import { LitElement, html, css, nothing } from "lit"; import { isSafeHttpUrl } from "../helpers/url"; import { property, state } from "lit/decorators.js"; import { t, ensureLocale, langOf } from "../styles"; import { runWs } from "../helpers/ws-run"; import { downloadUrl } from "../helpers/download"; import { downloadSignedDocument, openSignedDocument, signDocumentPath } from "../helpers/document-url"; import { formatBytes } from "../helpers/format-bytes"; import { docCategory, docDisplayName, CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories"; import { DOC_FILTER_MIN, DOC_SORT_MODES, asDocSortMode, filterDocuments, sortDocuments, type DocSortMode } from "../helpers/document-filter"; import { LS_KEYS, lsGet, lsSet } from "../helpers/storage-keys"; import type { HomeAssistant } from "../types"; import { isAndroidCompanion } from "../helpers/companion"; import { uploadDocument } from "../helpers/photo-upload"; import "./ms-photo-picker"; import { photoPickerStyles } from "./ms-photo-picker"; interface MaintenanceDocument { id: string; kind: "file" | "weblink"; title: string; filename?: string; url?: string; mime?: string; size?: number; tags?: string[]; /** #164: a free-text note per document. */ description?: string; added_at?: string; } export class MaintenanceDocumentsSection extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public entryId!: string; @property({ type: Boolean }) public canWrite = false; @state() private _docs: MaintenanceDocument[] = []; // Local filter (#171) — shown once the list is long enough to need one. @state() private _filter = ""; @state() private _loaded = false; @state() private _busy = false; @state() private _error = ""; @state() private _hint = ""; @state() private _addingLink = false; @state() private _linkUrl = ""; @state() private _linkTitle = ""; @state() private _category = "manual"; @state() private _thumbs: Record = {}; @state() private _lightboxUrl = ""; @state() private _editingId = ""; @state() private _editTitle = ""; @state() private _editCategory = "manual"; @state() private _editDescription = ""; @state() private _linkDescription = ""; /** #164: list order, remembered per browser. */ @state() private _sort: DocSortMode = asDocSortMode(lsGet(LS_KEYS.docSort)); @state() private _dragOver = false; private _loadedFor: string | null = null; /** Only the newest list fetch may land (bug audit 2026-09-26): the panel * REUSES this element when the user moves to another object, and a slow * answer for the previous object overwrote the new one's list. */ private _loadSeq = 0; private _localeReady = false; private _isImage(doc: MaintenanceDocument): boolean { return doc.kind === "file" && (doc.mime || "").startsWith("image/"); } private async _sign(doc: MaintenanceDocument): Promise { return signDocumentPath(this.hass, doc.id); } private get _lang(): string { return langOf(this.hass); } updated(changed: Map): void { super.updated(changed); if (this.hass && !this._localeReady) { this._localeReady = true; // Re-render once the runtime locale JSON arrives (t() falls back to English // until then), so a first paint before the fetch lands still localizes. void ensureLocale(this._lang).then(() => this.requestUpdate()); } if (this.hass && this.entryId && this._loadedFor !== this.entryId) { const switched = this._loadedFor !== null; this._loadedFor = this.entryId; if (switched) this._resetForObject(); void this._load(); } } /** Another object in the same element: nothing of the previous one may * survive — its list, thumbnails, an open link / edit form (whose Save * would have written to the NEW object) or a message. */ private _resetForObject(): void { this._docs = []; this._loaded = false; this._thumbs = {}; this._filter = ""; this._error = ""; this._hint = ""; this._addingLink = false; this._linkUrl = ""; this._linkTitle = ""; this._linkDescription = ""; this._editingId = ""; this._lightboxUrl = ""; } private async _load(): Promise { const entryId = this.entryId; const seq = ++this._loadSeq; const stale = () => seq !== this._loadSeq || entryId !== this.entryId; let failure = ""; const r = await runWs<{ documents: MaintenanceDocument[] }>( this, { type: "maintenance_supporter/documents/list", entry_id: entryId }, { onError: (m) => { failure = m; } }, ); if (stale()) return; this._loaded = true; if (r === undefined) { this._error = failure; return; } this._docs = r?.documents || []; this._error = ""; this._thumbs = {}; void this._loadThumbs(stale); } /** A document write — helpers/ws-run runWs with the busy flag, the error * line and the list reload (`onDone` runs first). */ private async _write(payload: Record, onDone?: () => void, fallbackKey?: string): Promise { this._error = ""; await runWs(this, payload, { busy: (b) => { this._busy = b; }, fallbackKey, reload: async () => { onDone?.(); await this._load(); }, onError: (m) => { this._error = m; }, }); } /** The signed open / download helpers, their failure on the error line. */ private async _signed(call: () => Promise): Promise { await runWs(this, call, { onError: (m) => { this._error = m; } }); } /** Pre-sign a serve URL for each image doc so it can render as a thumbnail. */ private async _loadThumbs(stale: () => boolean = () => false): Promise { await Promise.all( this._docs.filter((d) => this._isImage(d)).map(async (d) => { try { const url = await this._sign(d); if (stale()) return; this._thumbs = { ...this._thumbs, [d.id]: url }; } catch { /* leave the fallback icon */ } }), ); } private _category_of(doc: MaintenanceDocument): string { return docCategory(doc); } /** Keyboard support for the file-picker