From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 27 Jan 2026 18:54:51 +0000 (-0800) Subject: Feature: sharelink bundles (#11682) X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=1f074390e4e3ef73eb182795d91c084b090d7863;p=thirdparty%2Fpaperless-ngx.git Feature: sharelink bundles (#11682) --- diff --git a/docs/configuration.md b/docs/configuration.md index 41d43d4248..ef252ad4a2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1617,6 +1617,16 @@ processing. This only has an effect if Defaults to `0 1 * * *`, once per day. +## Share links + +#### [`PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON=`](#PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON) {#PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON} + +: Controls how often Paperless-ngx removes expired share link bundles (and their generated ZIP archives). + +: If set to the string "disable", expired bundles are not cleaned up automatically. + + Defaults to `0 2 * * *`, once per day at 02:00. + ## Binaries There are a few external software packages that Paperless expects to diff --git a/docs/usage.md b/docs/usage.md index 7da83a3e1c..f652164da0 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -308,12 +308,14 @@ or using [email](#workflow-action-email) or [webhook](#workflow-action-webhook) ### Share Links -"Share links" are shareable public links to files and can be created and managed under the 'Send' button on the document detail screen. +"Share links" are public links to files (or an archive of files) and can be created and managed under the 'Send' button on the document detail screen or from the bulk editor. -- Share links do not require a user to login and thus link directly to a file. +- Share links do not require a user to login and thus link directly to a file or bundled download. - Links are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`. - Links can optionally have an expiration time set. - After a link expires or is deleted users will be redirected to the regular paperless-ngx login. +- From the document detail screen you can create a share link for that single document. +- From the bulk editor you can create a **share link bundle** for any selection. Paperless-ngx prepares a ZIP archive in the background and exposes a single share link. You can revisit the "Manage share link bundles" dialog to monitor progress, retry failed bundles, or delete links. !!! tip diff --git a/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html new file mode 100644 index 0000000000..b7fed28e19 --- /dev/null +++ b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html @@ -0,0 +1,129 @@ + + + diff --git a/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.scss b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.spec.ts b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.spec.ts new file mode 100644 index 0000000000..da4d93c6a2 --- /dev/null +++ b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.spec.ts @@ -0,0 +1,161 @@ +import { Clipboard } from '@angular/cdk/clipboard' +import { + ComponentFixture, + TestBed, + fakeAsync, + tick, +} from '@angular/core/testing' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { FileVersion } from 'src/app/data/share-link' +import { + ShareLinkBundleStatus, + ShareLinkBundleSummary, +} from 'src/app/data/share-link-bundle' +import { ToastService } from 'src/app/services/toast.service' +import { environment } from 'src/environments/environment' +import { ShareLinkBundleDialogComponent } from './share-link-bundle-dialog.component' + +class MockToastService { + showInfo = jest.fn() + showError = jest.fn() +} + +describe('ShareLinkBundleDialogComponent', () => { + let component: ShareLinkBundleDialogComponent + let fixture: ComponentFixture + let clipboard: Clipboard + let toastService: MockToastService + let activeModal: NgbActiveModal + let originalApiBaseUrl: string + + beforeEach(() => { + originalApiBaseUrl = environment.apiBaseUrl + toastService = new MockToastService() + + TestBed.configureTestingModule({ + imports: [ + ShareLinkBundleDialogComponent, + NgxBootstrapIconsModule.pick(allIcons), + ], + providers: [ + NgbActiveModal, + { provide: ToastService, useValue: toastService }, + ], + }) + + fixture = TestBed.createComponent(ShareLinkBundleDialogComponent) + component = fixture.componentInstance + clipboard = TestBed.inject(Clipboard) + activeModal = TestBed.inject(NgbActiveModal) + fixture.detectChanges() + }) + + afterEach(() => { + jest.clearAllTimers() + environment.apiBaseUrl = originalApiBaseUrl + }) + + it('builds payload and emits confirm on submit', () => { + const confirmSpy = jest.spyOn(component.confirmClicked, 'emit') + component.documents = [ + { id: 1, title: 'Doc 1' } as any, + { id: 2, title: 'Doc 2' } as any, + ] + component.form.setValue({ + shareArchiveVersion: false, + expirationDays: 3, + }) + + component.submit() + + expect(component.payload).toEqual({ + document_ids: [1, 2], + file_version: FileVersion.Original, + expiration_days: 3, + }) + expect(component.buttonsEnabled).toBe(false) + expect(confirmSpy).toHaveBeenCalled() + + component.form.setValue({ + shareArchiveVersion: true, + expirationDays: 7, + }) + component.submit() + + expect(component.payload).toEqual({ + document_ids: [1, 2], + file_version: FileVersion.Archive, + expiration_days: 7, + }) + }) + + it('ignores submit when bundle already created', () => { + component.createdBundle = { id: 1 } as ShareLinkBundleSummary + const confirmSpy = jest.spyOn(component, 'confirm') + component.submit() + expect(confirmSpy).not.toHaveBeenCalled() + }) + + it('limits preview to ten documents', () => { + const docs = Array.from({ length: 12 }).map((_, index) => ({ + id: index + 1, + })) + component.documents = docs as any + + expect(component.selectionCount).toBe(12) + expect(component.documentPreview).toHaveLength(10) + expect(component.documentPreview[0].id).toBe(1) + }) + + it('copies share link and resets state after timeout', fakeAsync(() => { + const copySpy = jest.spyOn(clipboard, 'copy').mockReturnValue(true) + const bundle = { + slug: 'bundle-slug', + status: ShareLinkBundleStatus.Ready, + } as ShareLinkBundleSummary + + component.copy(bundle) + + expect(copySpy).toHaveBeenCalledWith(component.getShareUrl(bundle)) + expect(component.copied).toBe(true) + expect(toastService.showInfo).toHaveBeenCalled() + + tick(3000) + expect(component.copied).toBe(false) + })) + + it('generates share URLs based on API base URL', () => { + environment.apiBaseUrl = 'https://example.com/api/' + expect( + component.getShareUrl({ slug: 'abc' } as ShareLinkBundleSummary) + ).toBe('https://example.com/share/abc') + }) + + it('opens manage dialog when callback provided', () => { + const manageSpy = jest.fn() + component.onOpenManage = manageSpy + component.openManage() + expect(manageSpy).toHaveBeenCalled() + }) + + it('falls back to cancel when manage callback missing', () => { + const cancelSpy = jest.spyOn(component, 'cancel') + component.onOpenManage = undefined + component.openManage() + expect(cancelSpy).toHaveBeenCalled() + }) + + it('maps status and file version labels', () => { + expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain( + 'Processing' + ) + expect(component.fileVersionLabel(FileVersion.Archive)).toContain('Archive') + }) + + it('closes dialog when cancel invoked', () => { + const closeSpy = jest.spyOn(activeModal, 'close') + component.cancel() + expect(closeSpy).toHaveBeenCalled() + }) +}) diff --git a/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.ts b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.ts new file mode 100644 index 0000000000..37aa709500 --- /dev/null +++ b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.ts @@ -0,0 +1,118 @@ +import { Clipboard } from '@angular/cdk/clipboard' +import { CommonModule } from '@angular/common' +import { Component, Input, inject } from '@angular/core' +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms' +import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { Document } from 'src/app/data/document' +import { + FileVersion, + SHARE_LINK_EXPIRATION_OPTIONS, +} from 'src/app/data/share-link' +import { + SHARE_LINK_BUNDLE_FILE_VERSION_LABELS, + SHARE_LINK_BUNDLE_STATUS_LABELS, + ShareLinkBundleCreatePayload, + ShareLinkBundleStatus, + ShareLinkBundleSummary, +} from 'src/app/data/share-link-bundle' +import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' +import { FileSizePipe } from 'src/app/pipes/file-size.pipe' +import { ToastService } from 'src/app/services/toast.service' +import { environment } from 'src/environments/environment' +import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component' + +@Component({ + selector: 'pngx-share-link-bundle-dialog', + templateUrl: './share-link-bundle-dialog.component.html', + imports: [ + CommonModule, + ReactiveFormsModule, + NgxBootstrapIconsModule, + FileSizePipe, + DocumentTitlePipe, + ], + providers: [], +}) +export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent { + private readonly formBuilder = inject(FormBuilder) + private readonly clipboard = inject(Clipboard) + private readonly toastService = inject(ToastService) + + private _documents: Document[] = [] + + selectionCount = 0 + documentPreview: Document[] = [] + form: FormGroup = this.formBuilder.group({ + shareArchiveVersion: true, + expirationDays: [7], + }) + payload: ShareLinkBundleCreatePayload | null = null + + readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS + + createdBundle: ShareLinkBundleSummary | null = null + copied = false + onOpenManage?: () => void + readonly statuses = ShareLinkBundleStatus + + constructor() { + super() + this.loading = false + this.title = $localize`Create share link bundle` + this.btnCaption = $localize`Create link` + } + + @Input() + set documents(docs: Document[]) { + this._documents = docs.concat() + this.selectionCount = this._documents.length + this.documentPreview = this._documents.slice(0, 10) + } + + submit() { + if (this.createdBundle) return + this.payload = { + document_ids: this._documents.map((doc) => doc.id), + file_version: this.form.value.shareArchiveVersion + ? FileVersion.Archive + : FileVersion.Original, + expiration_days: this.form.value.expirationDays, + } + this.buttonsEnabled = false + super.confirm() + } + + getShareUrl(bundle: ShareLinkBundleSummary): string { + const apiURL = new URL(environment.apiBaseUrl) + return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${ + bundle.slug + }` + } + + copy(bundle: ShareLinkBundleSummary): void { + const success = this.clipboard.copy(this.getShareUrl(bundle)) + if (success) { + this.copied = true + this.toastService.showInfo($localize`Share link copied to clipboard.`) + setTimeout(() => { + this.copied = false + }, 3000) + } + } + + openManage(): void { + if (this.onOpenManage) { + this.onOpenManage() + } else { + this.cancel() + } + } + + statusLabel(status: ShareLinkBundleSummary['status']): string { + return SHARE_LINK_BUNDLE_STATUS_LABELS[status] ?? status + } + + fileVersionLabel(version: FileVersion): string { + return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version + } +} diff --git a/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html new file mode 100644 index 0000000000..2f21554126 --- /dev/null +++ b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html @@ -0,0 +1,156 @@ + + + + + diff --git a/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.scss b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.scss new file mode 100644 index 0000000000..c8ffc4d5dc --- /dev/null +++ b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.scss @@ -0,0 +1,4 @@ +:host ::ng-deep .popover { + min-width: 300px; + max-width: 400px; + } diff --git a/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.spec.ts b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.spec.ts new file mode 100644 index 0000000000..113cd65a32 --- /dev/null +++ b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.spec.ts @@ -0,0 +1,251 @@ +import { Clipboard } from '@angular/cdk/clipboard' +import { + ComponentFixture, + TestBed, + fakeAsync, + tick, +} from '@angular/core/testing' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { of, throwError } from 'rxjs' +import { FileVersion } from 'src/app/data/share-link' +import { + ShareLinkBundleStatus, + ShareLinkBundleSummary, +} from 'src/app/data/share-link-bundle' +import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service' +import { ToastService } from 'src/app/services/toast.service' +import { environment } from 'src/environments/environment' +import { ShareLinkBundleManageDialogComponent } from './share-link-bundle-manage-dialog.component' + +class MockShareLinkBundleService { + listAllBundles = jest.fn() + delete = jest.fn() + rebuildBundle = jest.fn() +} + +class MockToastService { + showInfo = jest.fn() + showError = jest.fn() +} + +describe('ShareLinkBundleManageDialogComponent', () => { + let component: ShareLinkBundleManageDialogComponent + let fixture: ComponentFixture + let service: MockShareLinkBundleService + let toastService: MockToastService + let clipboard: Clipboard + let activeModal: NgbActiveModal + let originalApiBaseUrl: string + + beforeEach(() => { + service = new MockShareLinkBundleService() + toastService = new MockToastService() + originalApiBaseUrl = environment.apiBaseUrl + + service.listAllBundles.mockReturnValue(of([])) + service.delete.mockReturnValue(of(true)) + service.rebuildBundle.mockReturnValue(of(sampleBundle())) + + TestBed.configureTestingModule({ + imports: [ + ShareLinkBundleManageDialogComponent, + NgxBootstrapIconsModule.pick(allIcons), + ], + providers: [ + NgbActiveModal, + { provide: ShareLinkBundleService, useValue: service }, + { provide: ToastService, useValue: toastService }, + ], + }) + + fixture = TestBed.createComponent(ShareLinkBundleManageDialogComponent) + component = fixture.componentInstance + clipboard = TestBed.inject(Clipboard) + activeModal = TestBed.inject(NgbActiveModal) + }) + + afterEach(() => { + component.ngOnDestroy() + fixture.destroy() + environment.apiBaseUrl = originalApiBaseUrl + jest.clearAllMocks() + }) + + const sampleBundle = (overrides: Partial = {}) => + ({ + id: 1, + slug: 'bundle-slug', + created: new Date().toISOString(), + document_count: 1, + documents: [1], + status: ShareLinkBundleStatus.Pending, + file_version: FileVersion.Archive, + last_error: undefined, + ...overrides, + }) as ShareLinkBundleSummary + + it('loads bundles on init and polls periodically', fakeAsync(() => { + const bundles = [sampleBundle({ status: ShareLinkBundleStatus.Ready })] + service.listAllBundles.mockReset() + service.listAllBundles + .mockReturnValueOnce(of(bundles)) + .mockReturnValue(of(bundles)) + + fixture.detectChanges() + tick() + + expect(service.listAllBundles).toHaveBeenCalledTimes(1) + expect(component.bundles).toEqual(bundles) + expect(component.loading).toBe(false) + expect(component.error).toBeNull() + + tick(5000) + expect(service.listAllBundles).toHaveBeenCalledTimes(2) + })) + + it('handles errors when loading bundles', fakeAsync(() => { + service.listAllBundles.mockReset() + service.listAllBundles + .mockReturnValueOnce(throwError(() => new Error('load fail'))) + .mockReturnValue(of([])) + + fixture.detectChanges() + tick() + + expect(component.error).toContain('Failed to load share link bundles.') + expect(toastService.showError).toHaveBeenCalled() + expect(component.loading).toBe(false) + + tick(5000) + expect(service.listAllBundles).toHaveBeenCalledTimes(2) + })) + + it('copies bundle links when ready', fakeAsync(() => { + jest.spyOn(clipboard, 'copy').mockReturnValue(true) + fixture.detectChanges() + tick() + + const readyBundle = sampleBundle({ + slug: 'ready-slug', + status: ShareLinkBundleStatus.Ready, + }) + component.copy(readyBundle) + + expect(clipboard.copy).toHaveBeenCalledWith( + component.getShareUrl(readyBundle) + ) + expect(component.copiedSlug).toBe('ready-slug') + expect(toastService.showInfo).toHaveBeenCalled() + + tick(3000) + expect(component.copiedSlug).toBeNull() + })) + + it('ignores copy requests for non-ready bundles', fakeAsync(() => { + const copySpy = jest.spyOn(clipboard, 'copy') + fixture.detectChanges() + tick() + component.copy(sampleBundle({ status: ShareLinkBundleStatus.Pending })) + expect(copySpy).not.toHaveBeenCalled() + })) + + it('deletes bundles and refreshes list', fakeAsync(() => { + service.listAllBundles.mockReturnValue(of([])) + service.delete.mockReturnValue(of(true)) + + fixture.detectChanges() + tick() + + component.delete(sampleBundle()) + tick() + + expect(service.delete).toHaveBeenCalled() + expect(toastService.showInfo).toHaveBeenCalledWith( + expect.stringContaining('deleted.') + ) + expect(service.listAllBundles).toHaveBeenCalledTimes(2) + expect(component.loading).toBe(false) + })) + + it('handles delete errors gracefully', fakeAsync(() => { + service.listAllBundles.mockReturnValue(of([])) + service.delete.mockReturnValue(throwError(() => new Error('delete fail'))) + + fixture.detectChanges() + tick() + + component.delete(sampleBundle()) + tick() + + expect(toastService.showError).toHaveBeenCalled() + expect(component.loading).toBe(false) + })) + + it('retries bundle build and replaces existing entry', fakeAsync(() => { + service.listAllBundles.mockReturnValue(of([])) + const updated = sampleBundle({ status: ShareLinkBundleStatus.Ready }) + service.rebuildBundle.mockReturnValue(of(updated)) + + fixture.detectChanges() + tick() + + component.bundles = [sampleBundle()] + component.retry(component.bundles[0]) + tick() + + expect(service.rebuildBundle).toHaveBeenCalledWith(updated.id) + expect(component.bundles[0].status).toBe(ShareLinkBundleStatus.Ready) + expect(toastService.showInfo).toHaveBeenCalled() + })) + + it('adds new bundle when retry returns unknown entry', fakeAsync(() => { + service.listAllBundles.mockReturnValue(of([])) + service.rebuildBundle.mockReturnValue( + of(sampleBundle({ id: 99, slug: 'new-slug' })) + ) + + fixture.detectChanges() + tick() + + component.bundles = [sampleBundle()] + component.retry({ id: 99 } as ShareLinkBundleSummary) + tick() + + expect(component.bundles.find((bundle) => bundle.id === 99)).toBeTruthy() + })) + + it('handles retry errors', fakeAsync(() => { + service.listAllBundles.mockReturnValue(of([])) + service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail'))) + + fixture.detectChanges() + tick() + + component.retry(sampleBundle()) + tick() + + expect(toastService.showError).toHaveBeenCalled() + })) + + it('maps helpers and closes dialog', fakeAsync(() => { + service.listAllBundles.mockReturnValue(of([])) + fixture.detectChanges() + tick() + + expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain( + 'Processing' + ) + expect(component.fileVersionLabel(FileVersion.Original)).toContain( + 'Original' + ) + + environment.apiBaseUrl = 'https://example.com/api/' + const url = component.getShareUrl(sampleBundle({ slug: 'sluggy' })) + expect(url).toBe('https://example.com/share/sluggy') + + const closeSpy = jest.spyOn(activeModal, 'close') + component.close() + expect(closeSpy).toHaveBeenCalled() + })) +}) diff --git a/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts new file mode 100644 index 0000000000..6eef144f96 --- /dev/null +++ b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts @@ -0,0 +1,177 @@ +import { Clipboard } from '@angular/cdk/clipboard' +import { CommonModule } from '@angular/common' +import { Component, OnDestroy, OnInit, inject } from '@angular/core' +import { NgbActiveModal, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs' +import { FileVersion } from 'src/app/data/share-link' +import { + SHARE_LINK_BUNDLE_FILE_VERSION_LABELS, + SHARE_LINK_BUNDLE_STATUS_LABELS, + ShareLinkBundleStatus, + ShareLinkBundleSummary, +} from 'src/app/data/share-link-bundle' +import { FileSizePipe } from 'src/app/pipes/file-size.pipe' +import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service' +import { ToastService } from 'src/app/services/toast.service' +import { environment } from 'src/environments/environment' +import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' +import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component' + +@Component({ + selector: 'pngx-share-link-bundle-manage-dialog', + templateUrl: './share-link-bundle-manage-dialog.component.html', + styleUrls: ['./share-link-bundle-manage-dialog.component.scss'], + imports: [ + ConfirmButtonComponent, + CommonModule, + NgbPopoverModule, + NgxBootstrapIconsModule, + FileSizePipe, + ], +}) +export class ShareLinkBundleManageDialogComponent + extends LoadingComponentWithPermissions + implements OnInit, OnDestroy +{ + private readonly activeModal = inject(NgbActiveModal) + private readonly shareLinkBundleService = inject(ShareLinkBundleService) + private readonly toastService = inject(ToastService) + private readonly clipboard = inject(Clipboard) + + title = $localize`Share link bundles` + + bundles: ShareLinkBundleSummary[] = [] + error: string | null = null + copiedSlug: string | null = null + + readonly statuses = ShareLinkBundleStatus + readonly fileVersions = FileVersion + + private readonly refresh$ = new Subject() + + ngOnInit(): void { + this.refresh$ + .pipe( + switchMap((silent) => { + if (!silent) { + this.loading = true + } + this.error = null + return this.shareLinkBundleService.listAllBundles().pipe( + catchError((error) => { + if (!silent) { + this.loading = false + } + this.error = $localize`Failed to load share link bundles.` + this.toastService.showError( + $localize`Error retrieving share link bundles.`, + error + ) + return of(null) + }) + ) + }), + takeUntil(this.unsubscribeNotifier) + ) + .subscribe((results) => { + if (results) { + this.bundles = results + this.copiedSlug = null + } + this.loading = false + }) + + this.triggerRefresh(false) + timer(5000, 5000) + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe(() => this.triggerRefresh(true)) + } + + ngOnDestroy(): void { + super.ngOnDestroy() + } + + getShareUrl(bundle: ShareLinkBundleSummary): string { + const apiURL = new URL(environment.apiBaseUrl) + return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${ + bundle.slug + }` + } + + copy(bundle: ShareLinkBundleSummary): void { + if (bundle.status !== ShareLinkBundleStatus.Ready) { + return + } + const success = this.clipboard.copy(this.getShareUrl(bundle)) + if (success) { + this.copiedSlug = bundle.slug + setTimeout(() => { + this.copiedSlug = null + }, 3000) + this.toastService.showInfo($localize`Share link copied to clipboard.`) + } + } + + delete(bundle: ShareLinkBundleSummary): void { + this.error = null + this.loading = true + this.shareLinkBundleService.delete(bundle).subscribe({ + next: () => { + this.toastService.showInfo($localize`Share link bundle deleted.`) + this.triggerRefresh(false) + }, + error: (e) => { + this.loading = false + this.toastService.showError( + $localize`Error deleting share link bundle.`, + e + ) + }, + }) + } + + retry(bundle: ShareLinkBundleSummary): void { + this.error = null + this.shareLinkBundleService.rebuildBundle(bundle.id).subscribe({ + next: (updated) => { + this.toastService.showInfo( + $localize`Share link bundle rebuild requested.` + ) + this.replaceBundle(updated) + }, + error: (e) => { + this.toastService.showError($localize`Error requesting rebuild.`, e) + }, + }) + } + + statusLabel(status: ShareLinkBundleStatus): string { + return SHARE_LINK_BUNDLE_STATUS_LABELS[status] ?? status + } + + fileVersionLabel(version: FileVersion): string { + return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version + } + + close(): void { + this.activeModal.close() + } + + private replaceBundle(updated: ShareLinkBundleSummary): void { + const index = this.bundles.findIndex((bundle) => bundle.id === updated.id) + if (index >= 0) { + this.bundles = [ + ...this.bundles.slice(0, index), + updated, + ...this.bundles.slice(index + 1), + ] + } else { + this.bundles = [updated, ...this.bundles] + } + } + + private triggerRefresh(silent: boolean): void { + this.refresh$.next(silent) + } +} diff --git a/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.html b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.html index fe3f9b9c37..e41a897a80 100644 --- a/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.html +++ b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.html @@ -51,7 +51,7 @@
diff --git a/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.ts b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.ts index ffe11808c7..9df3d438b0 100644 --- a/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.ts +++ b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.ts @@ -4,7 +4,11 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { first } from 'rxjs' -import { FileVersion, ShareLink } from 'src/app/data/share-link' +import { + FileVersion, + SHARE_LINK_EXPIRATION_OPTIONS, + ShareLink, +} from 'src/app/data/share-link' import { ShareLinkService } from 'src/app/services/rest/share-link.service' import { ToastService } from 'src/app/services/toast.service' import { environment } from 'src/environments/environment' @@ -21,12 +25,7 @@ export class ShareLinksDialogComponent implements OnInit { private toastService = inject(ToastService) private clipboard = inject(Clipboard) - EXPIRATION_OPTIONS = [ - { label: $localize`1 day`, value: 1 }, - { label: $localize`7 days`, value: 7 }, - { label: $localize`30 days`, value: 30 }, - { label: $localize`Never`, value: null }, - ] + readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS @Input() title = $localize`Share Links` diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html index 2323929d1c..6f3a84eee1 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -96,14 +96,36 @@ - @if (emailEnabled) { - - }
+
+ +
+ + + + @if (emailEnabled) { + + } +
+