diff --git a/models/controlled-documents/src/index.ts b/models/controlled-documents/src/index.ts index d57b95d36a1..131a66c5e38 100644 --- a/models/controlled-documents/src/index.ts +++ b/models/controlled-documents/src/index.ts @@ -1046,7 +1046,7 @@ export function createModel (builder: Builder): void { label: print.string.PrintToPDF, icon: print.icon.Print, category: view.category.General, - input: 'focus', // NOTE: should only work for one doc for now, not bulk + input: 'selection', target, context: { mode: ['context', 'browser'], group: 'tools' }, visibilityTester: documents.function.CanPrintDocument, diff --git a/models/print/src/index.ts b/models/print/src/index.ts index 1b6e523d98a..32474a6ca27 100644 --- a/models/print/src/index.ts +++ b/models/print/src/index.ts @@ -21,7 +21,7 @@ export function createModel (builder: Builder): void { label: print.string.PrintToPDF, icon: print.icon.Print, category: view.category.General, - input: 'focus', // NOTE: should only work for one doc for now, not bulk + input: 'selection', target: core.class.Doc, context: { mode: ['context', 'browser'], group: 'tools' }, visibilityTester: print.function.CanPrint diff --git a/plugins/print-assets/lang/cs.json b/plugins/print-assets/lang/cs.json index 4661fa55bf5..35e5f9deb2e 100644 --- a/plugins/print-assets/lang/cs.json +++ b/plugins/print-assets/lang/cs.json @@ -1,5 +1,8 @@ { "string": { - "PrintToPDF": "Tisk do PDF" + "PrintToPDF": "Tisk do PDF", + "PrintingDocumentOf": "Tisk dokumentu {current} z {total}", + "DownloadAll": "Stáhnout vše", + "PrintFailed": "Tisk se nezdařil" } } diff --git a/plugins/print-assets/lang/de.json b/plugins/print-assets/lang/de.json index 2abe4fbfb89..88dbeb4e87e 100644 --- a/plugins/print-assets/lang/de.json +++ b/plugins/print-assets/lang/de.json @@ -1,5 +1,8 @@ { "string": { - "PrintToPDF": "Als PDF drucken" + "PrintToPDF": "Als PDF drucken", + "PrintingDocumentOf": "Dokument {current} von {total} wird gedruckt", + "DownloadAll": "Alle herunterladen", + "PrintFailed": "Druck fehlgeschlagen" } } diff --git a/plugins/print-assets/lang/en.json b/plugins/print-assets/lang/en.json index 5ee8824fcef..30664b88256 100644 --- a/plugins/print-assets/lang/en.json +++ b/plugins/print-assets/lang/en.json @@ -1,5 +1,8 @@ { "string": { - "PrintToPDF": "Print to PDF" + "PrintToPDF": "Print to PDF", + "PrintingDocumentOf": "Printing document {current} of {total}", + "DownloadAll": "Download all", + "PrintFailed": "Print failed" } } diff --git a/plugins/print-assets/lang/es.json b/plugins/print-assets/lang/es.json index b277828fbb7..6a93e2d61f0 100644 --- a/plugins/print-assets/lang/es.json +++ b/plugins/print-assets/lang/es.json @@ -1,5 +1,8 @@ { "string": { - "PrintToPDF": "Imprimir en PDF" + "PrintToPDF": "Imprimir en PDF", + "PrintingDocumentOf": "Imprimiendo documento {current} de {total}", + "DownloadAll": "Descargar todo", + "PrintFailed": "Error al imprimir" } } diff --git a/plugins/print-assets/lang/fr.json b/plugins/print-assets/lang/fr.json index 2f7e32533f1..e625905d4ab 100644 --- a/plugins/print-assets/lang/fr.json +++ b/plugins/print-assets/lang/fr.json @@ -1,5 +1,8 @@ { "string": { - "PrintToPDF": "Imprimer en PDF" + "PrintToPDF": "Imprimer en PDF", + "PrintingDocumentOf": "Impression du document {current} sur {total}", + "DownloadAll": "Tout télécharger", + "PrintFailed": "Échec de l'impression" } } \ No newline at end of file diff --git a/plugins/print-assets/lang/it.json b/plugins/print-assets/lang/it.json index 2873058d0e6..b061a3dd8fc 100644 --- a/plugins/print-assets/lang/it.json +++ b/plugins/print-assets/lang/it.json @@ -1,5 +1,8 @@ { "string": { - "PrintToPDF": "Stampa in PDF" + "PrintToPDF": "Stampa in PDF", + "PrintingDocumentOf": "Stampa documento {current} di {total}", + "DownloadAll": "Scarica tutto", + "PrintFailed": "Stampa non riuscita" } } diff --git a/plugins/print-assets/lang/ja.json b/plugins/print-assets/lang/ja.json index 2bcfac40922..07db0f516a4 100644 --- a/plugins/print-assets/lang/ja.json +++ b/plugins/print-assets/lang/ja.json @@ -1,5 +1,8 @@ { "string": { - "PrintToPDF": "PDFとして印刷" + "PrintToPDF": "PDFとして印刷", + "PrintingDocumentOf": "ドキュメント {current} / {total} を印刷中", + "DownloadAll": "すべてダウンロード", + "PrintFailed": "印刷に失敗しました" } } diff --git a/plugins/print-assets/lang/pt.json b/plugins/print-assets/lang/pt.json index d3d3057d1b5..1160ac18655 100644 --- a/plugins/print-assets/lang/pt.json +++ b/plugins/print-assets/lang/pt.json @@ -1,5 +1,8 @@ { "string": { - "PrintToPDF": "Imprimir em PDF" + "PrintToPDF": "Imprimir em PDF", + "PrintingDocumentOf": "Imprimindo documento {current} de {total}", + "DownloadAll": "Descarregar tudo", + "PrintFailed": "Falha na impressão" } } diff --git a/plugins/print-assets/lang/ru.json b/plugins/print-assets/lang/ru.json index ae0def39d0c..70368c16c61 100644 --- a/plugins/print-assets/lang/ru.json +++ b/plugins/print-assets/lang/ru.json @@ -1,5 +1,8 @@ { "string": { - "PrintToPDF": "Печать в PDF" + "PrintToPDF": "Печать в PDF", + "PrintingDocumentOf": "Печать документа {current} из {total}", + "DownloadAll": "Скачать все", + "PrintFailed": "Ошибка печати" } } diff --git a/plugins/print-assets/lang/tr.json b/plugins/print-assets/lang/tr.json index 5faa88e68c5..0394202be8e 100644 --- a/plugins/print-assets/lang/tr.json +++ b/plugins/print-assets/lang/tr.json @@ -1,5 +1,8 @@ { "string": { - "PrintToPDF": "PDF'e yazdır" + "PrintToPDF": "PDF'e yazdır", + "PrintingDocumentOf": "Belge yazdırılıyor {current} / {total}", + "DownloadAll": "Tümünü indir", + "PrintFailed": "Yazdırma başarısız" } } diff --git a/plugins/print-assets/lang/zh.json b/plugins/print-assets/lang/zh.json index 1835ed52fa2..f934b55a471 100644 --- a/plugins/print-assets/lang/zh.json +++ b/plugins/print-assets/lang/zh.json @@ -1,5 +1,8 @@ { "string": { - "PrintToPDF": "打印为 PDF" + "PrintToPDF": "打印为 PDF", + "PrintingDocumentOf": "正在打印文档 {current} / {total}", + "DownloadAll": "全部下载", + "PrintFailed": "打印失败" } } diff --git a/plugins/print-resources/src/components/PrintBulkToPDF.svelte b/plugins/print-resources/src/components/PrintBulkToPDF.svelte new file mode 100644 index 00000000000..76c2e1ce9de --- /dev/null +++ b/plugins/print-resources/src/components/PrintBulkToPDF.svelte @@ -0,0 +1,203 @@ + + + + + { + if (e.key === 'Escape') { + if (selectedResult !== undefined) { + selectedResult = undefined + } else { + close() + } + } + }} +/> + +{#if selectedResult === undefined} +
+ {}} + canSave={false} + hideFooter={true} + width="medium" + on:close={close} + > +
+ {#if processing && currentIndex <= total} +
+
+
+
+
+
+ {:else} +
+ +
+ {#each results as result} +
+ + {#if result.error !== undefined} + {result.title} – +
+ {#if result.error === undefined} +
+
+ {/each} +
+
+
+ {#if successCount > 0} +
+
+ {/if} + {#if failedList.length > 0} +

+ {successCount} succeeded, {failedList.length} failed. +

+ {/if} + {/if} +
+
+
+{:else} + { + selectedResult = undefined + }} + on:fullsize + /> +{/if} + + diff --git a/plugins/print-resources/src/index.ts b/plugins/print-resources/src/index.ts index 6f31a82e330..ce2492ba008 100644 --- a/plugins/print-resources/src/index.ts +++ b/plugins/print-resources/src/index.ts @@ -7,21 +7,37 @@ import { showPopup } from '@hcengineering/ui' import { getPrintBaseURL } from '@hcengineering/print' import PrintToPDF from './components/PrintToPDF.svelte' +import PrintBulkToPDF from './components/PrintBulkToPDF.svelte' import DOCXViewer from './components/DOCXViewer.svelte' export async function print ( - object: Doc, + object: Doc | Doc[], evt: Event, props: { signed: boolean } ): Promise { const signed = props?.signed ?? false + const docs = Array.isArray(object) ? object : [object] + if (docs.length === 0) { + return + } + if (docs.length === 1) { + showPopup( + PrintToPDF, + { + object: docs[0], + signed + }, + 'float' + ) + return + } showPopup( - PrintToPDF, + PrintBulkToPDF, { - object, + objects: docs, signed }, 'float' @@ -42,6 +58,7 @@ export async function canPrint (): Promise { export default async (): Promise => ({ component: { PrintToPDF, + PrintBulkToPDF, DOCXViewer }, actionImpl: { diff --git a/plugins/print-resources/src/printUtils.ts b/plugins/print-resources/src/printUtils.ts new file mode 100644 index 00000000000..05a5729b7e8 --- /dev/null +++ b/plugins/print-resources/src/printUtils.ts @@ -0,0 +1,138 @@ +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Class, Client, Doc, Ref } from '@hcengineering/core' +import type { Location } from '@hcengineering/ui' +import { Analytics } from '@hcengineering/analytics' +import guest, { type PublicLink, createPublicLink } from '@hcengineering/guest' +import view from '@hcengineering/view' +import { getDocTitle, getObjectLinkFragment } from '@hcengineering/view-resources' +import { getMetadata } from '@hcengineering/platform' +import presentation, { getFileUrl } from '@hcengineering/presentation' +import { printToPDF } from '@hcengineering/print' +import { signPDF } from '@hcengineering/sign' + +export interface PdfResult { + blobId: string + title: string + error?: string +} + +export interface PrintAllOptions { + onProgress: (current: number, total?: number) => void + getCancelled: () => boolean +} + +/** + * Get or create a public link for a document (for print/share). + * Returns the link if it exists or after creating it; the link's url may be set asynchronously by the server. + */ +export async function ensurePublicLink (client: Client, doc: Doc): Promise { + const existing = await client.findOne(guest.class.PublicLink, { attachedTo: doc._id }) + if (existing?.url !== undefined && existing.url !== '') { + return existing + } + if (existing === undefined) { + const location = await getObjectLocation(client, doc) + await createPublicLink(client as any, doc, location) + } + const link = await client.findOne(guest.class.PublicLink, { attachedTo: doc._id }) + return link?.url !== undefined && link.url !== '' ? link : undefined +} + +async function getObjectLocation (client: Client, obj: Doc): Promise { + const hierarchy = client.getHierarchy() + const panelComponent = hierarchy.classHierarchyMixin(obj._class, view.mixin.ObjectPanel) + const comp = panelComponent?.component ?? view.component.EditDoc + return await getObjectLinkFragment(hierarchy, obj, {}, comp) +} + +/** + * Get a document title suitable for use as PDF filename (with .pdf extension). + */ +export async function getDocTitleForPdf ( + client: Client, + docId: Ref, + docClass: Ref>, + doc?: Doc +): Promise { + const value = (await getDocTitle(client, docId, docClass, doc)) ?? '' + return value !== '' ? value + '.pdf' : 'document.pdf' +} + +/** + * Print multiple documents to PDF (one per document). Calls onProgress and getCancelled for progress and cancellation. + */ +export async function printAll ( + client: Client, + docs: Doc[], + signed: boolean, + options: PrintAllOptions +): Promise { + const token = getMetadata(presentation.metadata.Token) ?? '' + const results: PdfResult[] = [] + for (let i = 0; i < docs.length; i++) { + if (options.getCancelled()) break + options.onProgress(i + 1, docs.length) + const doc = docs[i] + try { + const link = await ensurePublicLink(client, doc) + if (link?.url === undefined || link.url === '') { + results.push({ blobId: '', title: '', error: 'Could not get public link' }) + continue + } + let blobId: string = await printToPDF(link.url, token) + if (signed) { + blobId = await signPDF(blobId, token) + } + const title = await getDocTitleForPdf(client, doc._id, doc._class, doc) + results.push({ blobId, title, error: undefined }) + } catch (err: any) { + Analytics.handleError(err) + const title = await getDocTitleForPdf(client, doc._id, doc._class, doc).catch(() => 'document') + results.push({ blobId: '', title, error: err?.message ?? String(err) }) + } + } + return results +} + +/** + * Trigger browser download of a single PDF result. + */ +export function downloadPdf (result: PdfResult): void { + if (result.error !== undefined) return + const url = getFileUrl(result.blobId, result.title) + const a = document.createElement('a') + a.href = url + a.download = result.title + a.style.display = 'none' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) +} + +/** + * Download all successful PDFs at once by triggering a download for each file + * with a short delay between each so the browser does not block them. + */ +export async function downloadAllPdfs (results: PdfResult[]): Promise { + const successResults = results.filter((r) => r.error === undefined) + if (successResults.length === 0) return + for (let i = 0; i < successResults.length; i++) { + const r = successResults[i] + downloadPdf(r) + if (i < successResults.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 150)) + } + } +} diff --git a/plugins/print/src/plugin.ts b/plugins/print/src/plugin.ts index 24b758bb8cb..5f774670b64 100644 --- a/plugins/print/src/plugin.ts +++ b/plugins/print/src/plugin.ts @@ -10,10 +10,14 @@ export const printId = 'print' as Plugin export const print = plugin(printId, { string: { - PrintToPDF: '' as IntlString + PrintToPDF: '' as IntlString, + PrintingDocumentOf: '' as IntlString, + DownloadAll: '' as IntlString, + PrintFailed: '' as IntlString }, component: { PrintToPDF: '' as AnyComponent, + PrintBulkToPDF: '' as AnyComponent, DOCXViewer: '' as AnyComponent }, icon: {