393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -11,11 +11,11 @@ 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 { describeWsError } from "../ws-errors";
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 { docDisplayName, CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
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";
@@ -66,6 +66,10 @@ export class MaintenanceDocumentsSection extends LitElement {
@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 {
@@ -89,34 +93,80 @@ export class MaintenanceDocumentsSection extends LitElement {
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<void> {
try {
const r = await this.hass.connection.sendMessagePromise<{ documents: MaintenanceDocument[] }>({
type: "maintenance_supporter/documents/list",
entry_id: this.entryId,
});
this._docs = r.documents || [];
this._loaded = true;
this._error = "";
this._thumbs = {};
void this._loadThumbs();
} catch (e) {
this._error = describeWsError(e, this._lang);
this._loaded = true;
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<string, unknown>, onDone?: () => void, fallbackKey?: string): Promise<void> {
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<unknown>): Promise<void> {
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(): Promise<void> {
private async _loadThumbs(stale: () => boolean = () => false): Promise<void> {
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 */
@@ -126,8 +176,7 @@ export class MaintenanceDocumentsSection extends LitElement {
}
private _category_of(doc: MaintenanceDocument): string {
const tag = (doc.tags || []).find((x) => (CATEGORIES as readonly string[]).includes(x));
return tag || "other";
return docCategory(doc);
}
/** Keyboard support for the file-picker <label>s (Enter/Space → open). */
@@ -173,6 +222,9 @@ export class MaintenanceDocumentsSection extends LitElement {
private async _uploadFiles(files: File[], category?: string): Promise<void> {
const cat = category ?? this._category;
// Captured: the element may be moved to another object mid-upload — the
// remaining files still belong to the object the user dropped them on.
const entryId = this.entryId;
this._busy = true;
this._error = "";
this._hint = "";
@@ -184,7 +236,7 @@ export class MaintenanceDocumentsSection extends LitElement {
for (const file of files) {
let doc;
try {
doc = await uploadDocument(this.hass, this.entryId, file, [cat]);
doc = await uploadDocument(this.hass, entryId, file, [cat]);
} catch (e) {
const key = e instanceof Error ? e.message : "";
if (key !== "doc_too_large" && key !== "doc_upload_failed") throw e;
@@ -194,6 +246,7 @@ export class MaintenanceDocumentsSection extends LitElement {
if (doc.duplicate_in_object) dupInObject++;
else if (doc.deduped) deduped++;
}
if (entryId !== this.entryId) return; // the list shown is another object's now
if (dupInObject) this._hint = t("doc_dup_in_object", this._lang);
else if (deduped) this._hint = t("doc_deduped", this._lang);
await this._load();
@@ -205,11 +258,7 @@ export class MaintenanceDocumentsSection extends LitElement {
}
private async _download(doc: MaintenanceDocument): Promise<void> {
try {
await downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document");
} catch (e) {
this._error = describeWsError(e, this._lang);
}
await this._signed(() => downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document"));
}
/** Open a document for viewing: images in an in-app lightbox, everything else
@@ -219,11 +268,7 @@ export class MaintenanceDocumentsSection extends LitElement {
this._lightboxUrl = this._thumbs[doc.id] || (await this._sign(doc));
return;
}
try {
await openSignedDocument(this.hass, doc.id);
} catch (e) {
this._error = describeWsError(e, this._lang);
}
await this._signed(() => openSignedDocument(this.hass, doc.id));
}
/** Open a document from a title/row click (not just the small icons): preview
@@ -259,66 +304,44 @@ export class MaintenanceDocumentsSection extends LitElement {
(x) => !(CATEGORIES as readonly string[]).includes(x),
);
const tags = doc.kind === "file" ? [this._editCategory, ...freeTags] : (doc.tags ?? []);
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
await this._write(
{
type: "maintenance_supporter/documents/update",
doc_id: doc.id,
title: this._editTitle.trim() || doc.filename || doc.url || "",
tags,
description: this._editDescription.trim(),
});
this._editingId = "";
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
},
() => { this._editingId = ""; },
);
}
private async _delete(doc: MaintenanceDocument): Promise<void> {
const name = docDisplayName(doc);
if (!window.confirm(t("doc_delete_confirm", this._lang).replace("{name}", name))) return;
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/documents/delete",
doc_id: doc.id,
});
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._busy = false;
}
await this._write({ type: "maintenance_supporter/documents/delete", doc_id: doc.id });
}
private async _addLink(): Promise<void> {
const url = this._linkUrl.trim();
if (!url) return;
this._busy = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
const entryId = this.entryId;
await this._write(
{
type: "maintenance_supporter/documents/add_link",
entry_id: this.entryId,
entry_id: entryId,
url,
title: this._linkTitle.trim() || null,
description: this._linkDescription.trim() || null,
});
this._linkUrl = "";
this._linkTitle = "";
this._linkDescription = "";
this._addingLink = false;
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang, t("doc_link_invalid", this._lang));
} finally {
this._busy = false;
}
},
() => {
this._linkUrl = "";
this._linkTitle = "";
this._linkDescription = "";
this._addingLink = false;
},
"doc_link_invalid",
);
}
render() {