Merge branch 'develop' of https://github.com/hcengineering/platform into staging-new

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artem Savchenko
2026-02-02 14:27:28 +07:00
20 changed files with 427 additions and 20 deletions
+2
View File
@@ -279,6 +279,8 @@ export function createModel (builder: Builder): void {
)
builder.mixin(activity.class.Reaction, core.class.Class, core.mixin.TxAccessLevel, {
createAccessLevel: AccountRole.Guest,
updateAccessLevel: AccountRole.Guest,
removeAccessLevel: AccountRole.Guest
})
+5 -1
View File
@@ -22,7 +22,7 @@ import type {
Photo,
SavedAttachments
} from '@hcengineering/attachment'
import { IndexKind, type Blob, type Class, type Doc, type Domain, type Ref } from '@hcengineering/core'
import { AccountRole, IndexKind, type Blob, type Class, type Doc, type Domain, type Ref } from '@hcengineering/core'
import {
Hidden,
Index,
@@ -124,6 +124,10 @@ export function createModel (builder: Builder): void {
editor: attachment.component.Attachments
})
builder.mixin(attachment.class.Attachment, core.class.Class, core.mixin.TxAccessLevel, {
createAccessLevel: AccountRole.Guest
})
builder.mixin(attachment.class.Photo, core.class.Class, view.mixin.CollectionEditor, {
editor: attachment.component.Photos
})
+6 -1
View File
@@ -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,
@@ -1069,6 +1069,11 @@ export function createModel (builder: Builder): void {
export function defineNotifications (builder: Builder): void {
builder.mixin(documents.class.ControlledDocument, core.class.Class, activity.mixin.ActivityDoc, {})
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: documents.class.ControlledDocument,
components: { input: { component: chunter.component.ChatMessageInput } }
})
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: documents.class.DocumentComment,
components: { input: { component: chunter.component.ChatMessageInput } }
+1 -1
View File
@@ -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
@@ -286,8 +286,9 @@
props={{
object: $controlledDocument,
showCommenInput: true,
boundary,
focusIndex: 1000
boundary: boundary ?? undefined,
focusIndex: 1000,
shouldScroll: false
}}
/>
</div>
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -1,5 +1,8 @@
{
"string": {
"PrintToPDF": "PDFとして印刷"
"PrintToPDF": "PDFとして印刷",
"PrintingDocumentOf": "ドキュメント {current} / {total} を印刷中",
"DownloadAll": "すべてダウンロード",
"PrintFailed": "印刷に失敗しました"
}
}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -1,5 +1,8 @@
{
"string": {
"PrintToPDF": "Печать в PDF"
"PrintToPDF": "Печать в PDF",
"PrintingDocumentOf": "Печать документа {current} из {total}",
"DownloadAll": "Скачать все",
"PrintFailed": "Ошибка печати"
}
}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -1,5 +1,8 @@
{
"string": {
"PrintToPDF": "打印为 PDF"
"PrintToPDF": "打印为 PDF",
"PrintingDocumentOf": "正在打印文档 {current} / {total}",
"DownloadAll": "全部下载",
"PrintFailed": "打印失败"
}
}
@@ -0,0 +1,203 @@
<!--
// 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.
-->
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte'
import type { Blob, Doc, Ref } from '@hcengineering/core'
import presentation, { Card, getClient, PDFViewer } from '@hcengineering/presentation'
import view from '@hcengineering/view'
import { Button, Label, Loading, Scroller } from '@hcengineering/ui'
import print from '../plugin'
import { type PdfResult, printAll, downloadPdf, downloadAllPdfs } from '../printUtils'
export let objects: Doc[] = []
export let signed: boolean = false
const client = getClient()
let results: PdfResult[] = []
let currentIndex = 0
let cancelled = false
let processing = true
let downloadAllLoading = false
let selectedResult: PdfResult | undefined = undefined
$: viewerFile = (selectedResult !== undefined ? selectedResult.blobId : undefined) as Ref<Blob> | undefined
$: total = objects.length
$: successCount = results.filter((r) => r.error === undefined).length
$: failedList = results.filter((r) => r.error !== undefined)
function cancel (): void {
cancelled = true
close()
}
function openPdf (result: PdfResult): void {
if (result.error !== undefined) return
selectedResult = result
}
async function doDownloadAll (): Promise<void> {
downloadAllLoading = true
try {
await downloadAllPdfs(results)
} finally {
downloadAllLoading = false
}
}
const dispatch = createEventDispatcher()
function close (): void {
dispatch('close')
}
onMount(() => {
if (objects.length > 0) {
results = []
currentIndex = 0
cancelled = false
processing = true
void printAll(client, objects, signed, {
onProgress: (current) => {
currentIndex = current
},
getCancelled: () => cancelled
}).then((r) => {
results = r
processing = false
})
}
})
</script>
<svelte:window
on:keydown={(e) => {
if (e.key === 'Escape') {
if (selectedResult !== undefined) {
selectedResult = undefined
} else {
close()
}
}
}}
/>
{#if selectedResult === undefined}
<div class="flex p-4">
<Card
label={print.string.PrintToPDF}
okAction={() => {}}
canSave={false}
hideFooter={true}
width="medium"
on:close={close}
>
<div class="flex flex-col gap-2">
{#if processing && currentIndex <= total}
<div class="flex flex-col gap-2 py-2">
<div class="flex items-start gap-2">
<Label label={print.string.PrintingDocumentOf} params={{ current: currentIndex, total }} />
<div class="flex pl-2">
<Loading shrink={true} size="small" />
</div>
</div>
<div class="flex justify-end">
<Button kind="secondary" label={presentation.string.Cancel} on:click={cancel} />
</div>
</div>
{:else}
<div class="results-scroller">
<Scroller>
<div class="flex flex-col gap-1 w-full">
{#each results as result}
<div
class="flex items-center justify-between gap-2 py-1 border-b border-gray-200 dark:border-gray-700"
>
<span class="truncate flex-1 min-w-0 secondary-textColor" title={result.title ?? ''}>
{#if result.error !== undefined}
{result.title} – <Label label={print.string.PrintFailed} />
{:else}
{result.title}
{/if}
</span>
<div class="flex gap-1 shrink-0 w-[11rem] justify-end">
{#if result.error === undefined}
<Button
kind="ghost"
size="small"
label={presentation.string.Download}
on:click={() => {
downloadPdf(result)
}}
/>
<Button
kind="ghost"
size="small"
label={view.string.Open}
on:click={() => {
openPdf(result)
}}
/>
{/if}
</div>
</div>
{/each}
</div>
</Scroller>
</div>
{#if successCount > 0}
<div class="flex justify-end pt-2">
<Button
kind="primary"
label={print.string.DownloadAll}
disabled={downloadAllLoading}
on:click={doDownloadAll}
/>
</div>
{/if}
{#if failedList.length > 0}
<p class="secondary-textColor text-sm">
{successCount} succeeded, {failedList.length} failed.
</p>
{/if}
{/if}
</div>
</Card>
</div>
{:else}
<PDFViewer
file={viewerFile}
name={selectedResult.title}
contentType="application/pdf"
showIcon={false}
isLoading={false}
on:close={() => {
selectedResult = undefined
}}
on:fullsize
/>
{/if}
<style lang="scss">
.results-scroller {
max-height: 16rem;
min-height: 0;
flex: 1;
overflow: hidden;
}
</style>
+20 -3
View File
@@ -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<void> {
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<boolean> {
export default async (): Promise<Resources> => ({
component: {
PrintToPDF,
PrintBulkToPDF,
DOCXViewer
},
actionImpl: {
+138
View File
@@ -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<PublicLink | undefined> {
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<Location> {
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<Doc>,
docClass: Ref<Class<Doc>>,
doc?: Doc
): Promise<string> {
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<PdfResult[]> {
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<void> {
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))
}
}
}
+5 -1
View File
@@ -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: {