// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'chrome://resources/ash/common/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_icons.css.js';
import 'chrome://resources/ash/common/cr_elements/icons.html.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import 'chrome://resources/polymer/v3_0/iron-media-query/iron-media-query.js';
import 'chrome://resources/polymer/v3_0/paper-progress/paper-progress.js';
import './icons.html.js';
import './print_management_fonts.css.js';
import './print_management_shared.css.js';
import './strings.m.js';
import {loadTimeData} from 'chrome://resources/ash/common/load_time_data.m.js';
import {FocusRowMixin} from 'chrome://resources/ash/common/cr_elements/focus_row_mixin.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {assert, assertNotReached} from 'chrome://resources/js/assert.js';
import {String16} from 'chrome://resources/mojo/mojo/public/mojom/base/string16.mojom-webui.js';
import {Time} from 'chrome://resources/mojo/mojo/public/mojom/base/time.mojom-webui.js';
import {IronA11yAnnouncer} from 'chrome://resources/polymer/v3_0/iron-a11y-announcer/iron-a11y-announcer.js';
import {PolymerElementProperties} from 'chrome://resources/polymer/v3_0/polymer/interfaces.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {getMetadataProvider} from './mojo_interface_provider.js';
import {getTemplate} from './print_job_entry.html.js';
import {PrinterErrorCode, PrintingMetadataProviderInterface, PrintJobCompletionStatus, PrintJobInfo} from './printing_manager.mojom-webui.js';
const GENERIC_FILE_EXTENSION_ICON = 'print-management:file-generic';
// Lookup table maps icons to the correct display class.
const ICON_CLASS_MAP = new Map([
['print-management:file-gdoc', 'file-icon-blue'],
['print-management:file-word', 'file-icon-blue'],
['print-management:file-generic', 'file-icon-gray'],
['print-management:file-excel', 'file-icon-green'],
['print-management:file-gform', 'file-icon-green'],
['print-management:file-gsheet', 'file-icon-green'],
['print-management:file-image', 'file-icon-red'],
['print-management:file-gdraw', 'file-icon-red'],
['print-management:file-gslide', 'file-icon-yellow'],
['print-management:file-pdf', 'file-icon-red'],
['print-management:file-ppt', 'file-icon-red'],
]);
// Converts a mojo time to a JS time.
function convertMojoTimeToJS(mojoTime: Time): Date {
// The JS Date() is based off of the number of milliseconds since the
// UNIX epoch (1970-01-01 00::00:00 UTC), while |internalValue| of the
// base::Time (represented in mojom.Time) represents the number of
// microseconds since the Windows FILETIME epoch (1601-01-01 00:00:00 UTC).
// This computes the final JS time by computing the epoch delta and the
// conversion from microseconds to milliseconds.
const windowsEpoch = Date.UTC(1601, 0, 1, 0, 0, 0, 0);
const unixEpoch = Date.UTC(1970, 0, 1, 0, 0, 0, 0);
// |epochDeltaInMs| equals to base::Time::kTimeTToMicrosecondsOffset.
const epochDeltaInMs = unixEpoch - windowsEpoch;
const timeInMs = Number(mojoTime.internalValue) / 1000;
return new Date(timeInMs - epochDeltaInMs);
}
// Returns true if |date| is today, false otherwise.
function isToday(date: Date): boolean {
const todayDate = new Date();
return date.getDate() === todayDate.getDate() &&
date.getMonth() === todayDate.getMonth() &&
date.getFullYear() === todayDate.getFullYear();
}
/**
* Best effort attempt of finding the file icon name based off of the file's
* name extension. If extension is not available, return an empty string. If
* file name does have an extension but we don't have an icon for it, return a
* generic icon name.
*/
function getFileExtensionIconName(fileName: string): string {
// Get file extension delimited by '.'.
const ext = fileName.split('.').pop();
// Return empty string if file has no extension.
if (ext === fileName || !ext) {
return '';
}
switch (ext) {
case 'pdf':
case 'xps':
return 'print-management:file-pdf';
case 'doc':
case 'docx':
case 'docm':
return 'print-management:file-word';
case 'png':
case 'jpeg':
case 'gif':
case 'raw':
case 'heic':
case 'svg':
return 'print-management:file-image';
case 'ppt':
case 'pptx':
case 'pptm':
return 'print-management:file-ppt';
case 'xlsx':
case 'xltx':
case 'xlr':
return 'print-management:file-excel';
default:
return GENERIC_FILE_EXTENSION_ICON;
}
}
/**
* Best effort to get the file icon name for a Google-file
* (e.g. Google docs, Google sheets, Google forms). Returns an empty
* string if |fileName| is not a Google-file.
*/
function getGFileIconName(fileName: string): string {
// Google-files are delimited by '-'.
const ext = fileName.split('-').pop();
// Return empty string if this doesn't have a Google-file delimiter.
if (ext === fileName || !ext) {
return '';
}
// Eliminate space that appears infront of Google-file file names.
const gExt = ext.substring(1);
switch (gExt) {
case 'Google Docs':
return 'print-management:file-gdoc';
case 'Google Sheets':
return 'print-management:file-gsheet';
case 'Google Forms':
return 'print-management:file-gform';
case 'Google Drawings':
return 'print-management:file-gdraw';
case 'Google Slides':
return 'print-management:file-gslide';
default:
return '';
}
}
/**
* @fileoverview
* 'print-job-entry' is contains a single print job entry and is used as a list
* item.
*/
const PrintJobEntryElementBase = FocusRowMixin(I18nMixin(PolymerElement));
export class PrintJobEntryElement extends PrintJobEntryElementBase {
static get is(): string {
return 'print-job-entry';
}
static get template(): HTMLTemplateElement {
return getTemplate();
}
static get properties(): PolymerElementProperties {
return {
jobEntry: {
type: Object,
},
jobTitle: {
type: String,
computed: 'decodeString16(jobEntry.title)',
},
printerName: {
type: String,
computed: 'decodeString16(jobEntry.printerName)',
},
creationTime: {
type: String,
computed: 'computeDate(jobEntry.creationTime)',
},
completionStatus: {
type: String,
computed: 'computeCompletionStatus(jobEntry.completedInfo)',
},
// Empty if there is no ongoing error.
ongoingErrorStatus: {
type: String,
computed: 'getOngoingErrorStatus(jobEntry.printerErrorCode)',
},
/**
* A representation in fraction form of pages printed versus total number
* of pages to be printed. E.g. 5/7 (5 pages printed / 7 total pages to
* print).
*/
readableProgress: {
type: String,
computed: 'computeReadableProgress(jobEntry.activePrintJobInfo)',
},
jobEntryAriaLabel: {
type: String,
computed: 'getJobEntryAriaLabel(jobEntry, jobTitle, printerName, ' +
'creationTime, completionStatus, ' +
'jobEntry.activePrintJobinfo.printedPages, jobEntry.numberOfPages)',
},
// This is only updated by media queries from window width changes.
showFullOngoingStatus: Boolean,
fileIcon: {
type: String,
computed: 'computeFileIcon(jobTitle)',
},
fileIconClass: {
type: String,
computed: 'computeFileIconClass(fileIcon)',
},
};
}
jobEntry: PrintJobInfo;
private mojoInterfaceProvider: PrintingMetadataProviderInterface;
private jobTitle: string;
private printerName: string;
private creationTime: string;
private completionStatus: string;
private ongoingErrorStatus: string;
private readableProgress: string;
private jobEntryAriaLabel: string;
private showFullOngoingStatus: boolean;
private fileIcon: string;
private fileIconClass: string;
static get observers(): string[] {
return [
'printJobEntryDataChanged(jobTitle, printerName, creationTime, ' +
'completionStatus)',
];
}
constructor() {
super();
this.mojoInterfaceProvider = getMetadataProvider();
this.addEventListener('click', () => this.onClick());
}
// Return private property this.fileIconClass for usage in browser tests.
getFileIconClass(): string {
return this.fileIconClass;
}
/**
* Check if any elements with the class "overflow-ellipsis" needs to
* add/remove the title attribute.
*/
private printJobEntryDataChanged(): void {
if (!this.shadowRoot) {
return;
}
Array
.from(
this.shadowRoot.querySelectorAll<HTMLElement>('.overflow-ellipsis'),
)
.forEach((e) => {
// Checks if text is truncated
if (e.offsetWidth < e.scrollWidth) {
e.setAttribute('title', e.textContent || '');
} else {
e.removeAttribute('title');
}
});
}
private onClick(): void {
if (!this.shadowRoot) {
return;
}
// Since the status or cancel button has the focus-row-control attribute,
// this will trigger the iron-list focus behavior and highlight the entire
// entry.
if (this.isCompletedPrintJob()) {
this.shadowRoot.querySelector<HTMLElement>('#completionStatus')?.focus();
return;
}
// Focus on the cancel button when clicking on the entry.
this.shadowRoot
.querySelector<HTMLElement>(
'#cancelPrintJobButton',
)
?.focus();
}
override connectedCallback(): void {
super.connectedCallback();
IronA11yAnnouncer.requestAvailability();
}
private computeCompletionStatus(): string {
if (!this.jobEntry.completedInfo) {
return '';
}
return this.convertStatusToString(
this.jobEntry.completedInfo.completionStatus);
}
private computeReadableProgress(): string {
if (!this.jobEntry.activePrintJobInfo) {
return '';
}
return loadTimeData.getStringF(
'printedPagesFraction',
this.jobEntry.activePrintJobInfo.printedPages.toString(),
this.jobEntry.numberOfPages.toString());
}
private onCancelPrintJobClicked(): void {
this.mojoInterfaceProvider.cancelPrintJob(this.jobEntry.id)
.then((() => this.onPrintJobCanceled()));
}
private onPrintJobCanceled(): void {
// TODO(crbug/1093527): Handle error case in which attempted cancellation
// failed. Need to discuss with UX on error states.
this.dispatchEvent(new CustomEvent('iron-announce', {
bubbles: true,
composed: true,
detail:
{text: loadTimeData.getStringF('cancelledPrintJob', this.jobTitle)},
}));
this.dispatchEvent(new CustomEvent(
'remove-print-job',
{bubbles: true, composed: true, detail: this.jobEntry.id}));
}
private decodeString16(arr: String16): string {
return arr.data.map(ch => String.fromCodePoint(ch)).join('');
}
/**
* Converts mojo time to JS time. Returns "Today" if |mojoTime| is at the
* current day.
*/
private computeDate(mojoTime: Time): string {
const jsDate = convertMojoTimeToJS(mojoTime);
// Date() is constructed with the current time in UTC. If the Date() matches
// |jsDate|'s date, display the 12hour time of the current date.
if (isToday(jsDate)) {
return jsDate.toLocaleTimeString(
/*locales=*/ undefined, {hour: 'numeric', minute: 'numeric'});
}
// Remove the day of the week from the date.
return jsDate.toLocaleDateString(
/*locales=*/ undefined,
{month: 'short', day: 'numeric', year: 'numeric'});
}
private convertStatusToString(mojoCompletionStatus: PrintJobCompletionStatus):
string {
switch (mojoCompletionStatus) {
case PrintJobCompletionStatus.kFailed:
return this.getFailedStatusString(this.jobEntry.printerErrorCode);
case PrintJobCompletionStatus.kCanceled:
return loadTimeData.getString('completionStatusCanceled');
case PrintJobCompletionStatus.kPrinted:
return loadTimeData.getString('completionStatusPrinted');
default:
assertNotReached();
}
}
/**
* Returns true if the job entry is a completed print job.
* Returns false otherwise.
*/
private isCompletedPrintJob(): boolean {
return !!this.jobEntry.completedInfo && !this.jobEntry.activePrintJobInfo;
}
private getJobEntryAriaLabel(): string {
if (!this.jobEntry || this.jobEntry.numberOfPages === undefined ||
this.printerName === undefined || this.jobTitle === undefined ||
!this.creationTime) {
return '';
}
// |completionStatus| and |jobEntry.activePrintJobInfo| are mutually
// exclusive and one of which has to be non-null. Assert that if
// |completionStatus| is non-null that |jobEntry.activePrintJobInfo| is
// null and vice-versa.
assert(
this.completionStatus ? !this.jobEntry.activePrintJobInfo :
this.jobEntry.activePrintJobInfo);
if (this.isCompletedPrintJob()) {
return loadTimeData.getStringF(
'completePrintJobLabel', this.jobTitle, this.printerName,
this.creationTime, this.completionStatus);
}
if (this.ongoingErrorStatus) {
return loadTimeData.getStringF(
'stoppedOngoingPrintJobLabel', this.jobTitle, this.printerName,
this.creationTime, this.ongoingErrorStatus);
}
return loadTimeData.getStringF(
'ongoingPrintJobLabel', this.jobTitle, this.printerName,
this.creationTime,
this.jobEntry.activePrintJobInfo ?
this.jobEntry.activePrintJobInfo.printedPages.toString() :
'',
this.jobEntry.numberOfPages.toString());
}
/**
* Returns the percentage, out of 100, of the pages printed versus total
* number of pages.
*/
private computePrintPagesProgress(
printedPages: number,
totalPages: number,
): number {
assert(printedPages >= 0);
// TODO(b/235534580): Remove print statements once resolved.
if (totalPages <= 0) {
console.error('Total pages should be > 0. totalPages: ' + totalPages);
}
assert(totalPages > 0);
if (printedPages > totalPages) {
console.error(
'Total pages should be more than printed pages. totalPages: ' +
totalPages + ' printedPages: ' + printedPages);
}
assert(printedPages <= totalPages);
return (printedPages * 100) / totalPages;
}
/**
* The full icon name provided by the containing iron-iconset-svg
* (i.e. [iron-iconset-svg name]:[SVG <g> tag id]) for a given file.
* This is a best effort approach, as we are only given the file name and
* not necessarily its extension.
*/
private computeFileIcon(): string {
const fileExtension = getFileExtensionIconName(this.jobTitle);
// It's valid for a file to have '.' in its name and not be its extension.
// If this is the case and we don't have a non-generic file icon, attempt to
// see if this is a Google file.
if (fileExtension && fileExtension !== GENERIC_FILE_EXTENSION_ICON) {
return fileExtension;
}
const gfileExtension = getGFileIconName(this.jobTitle);
if (gfileExtension) {
return gfileExtension;
}
return GENERIC_FILE_EXTENSION_ICON;
}
/**
* Uses file-icon SVG id to determine correct class to apply for file icon.
*/
private computeFileIconClass(): string {
const iconClass = ICON_CLASS_MAP.get(this.fileIcon);
return `flex-center ${iconClass}`;
}
private getFailedStatusString(
mojoPrinterErrorCode: PrinterErrorCode,
): string {
switch (mojoPrinterErrorCode) {
case PrinterErrorCode.kNoError:
return loadTimeData.getString('completionStatusPrinted');
case PrinterErrorCode.kPaperJam:
return loadTimeData.getString('paperJam');
case PrinterErrorCode.kOutOfPaper:
return loadTimeData.getString('outOfPaper');
case PrinterErrorCode.kOutOfInk:
return loadTimeData.getString('outOfInk');
case PrinterErrorCode.kDoorOpen:
return loadTimeData.getString('doorOpen');
case PrinterErrorCode.kPrinterUnreachable:
return loadTimeData.getString('printerUnreachable');
case PrinterErrorCode.kTrayMissing:
return loadTimeData.getString('trayMissing');
case PrinterErrorCode.kOutputFull:
return loadTimeData.getString('outputFull');
case PrinterErrorCode.kStopped:
return loadTimeData.getString('stopped');
case PrinterErrorCode.kFilterFailed:
return loadTimeData.getString('filterFailed');
case PrinterErrorCode.kUnknownError:
return loadTimeData.getString('unknownPrinterError');
case PrinterErrorCode.kClientUnauthorized:
return loadTimeData.getString('clientUnauthorized');
case PrinterErrorCode.kExpiredCertificate:
return loadTimeData.getString('expiredCertificate');
default:
assertNotReached();
}
}
private getOngoingErrorStatus(
mojoPrinterErrorCode: PrinterErrorCode,
): string {
if (this.isCompletedPrintJob()) {
return '';
}
switch (mojoPrinterErrorCode) {
case PrinterErrorCode.kNoError:
return '';
case PrinterErrorCode.kPaperJam:
return loadTimeData.getString('paperJamStopped');
case PrinterErrorCode.kOutOfPaper:
return loadTimeData.getString('outOfPaperStopped');
case PrinterErrorCode.kOutOfInk:
return loadTimeData.getString('outOfInkStopped');
case PrinterErrorCode.kDoorOpen:
return loadTimeData.getString('doorOpenStopped');
case PrinterErrorCode.kTrayMissing:
return loadTimeData.getString('trayMissingStopped');
case PrinterErrorCode.kOutputFull:
return loadTimeData.getString('outputFullStopped');
case PrinterErrorCode.kStopped:
return loadTimeData.getString('stoppedGeneric');
case PrinterErrorCode.kFilterFailed:
return loadTimeData.getString('filterFailed');
case PrinterErrorCode.kUnknownError:
return loadTimeData.getString('unknownPrinterErrorStopped');
case PrinterErrorCode.kClientUnauthorized:
return loadTimeData.getString('clientUnauthorized');
case PrinterErrorCode.kExpiredCertificate:
return loadTimeData.getString('expiredCertificate');
case PrinterErrorCode.kPrinterUnreachable:
return loadTimeData.getString('printerUnreachableStopped');
default:
assertNotReached();
}
}
}
declare global {
interface HTMLElementTagNameMap {
'print-job-entry': PrintJobEntryElement;
}
}
customElements.define(PrintJobEntryElement.is, PrintJobEntryElement);