diff --git a/app/components/edit/DocumentEdit.svelte b/app/components/edit/DocumentEdit.svelte index bc87970b..d5ffc587 100644 --- a/app/components/edit/DocumentEdit.svelte +++ b/app/components/edit/DocumentEdit.svelte @@ -771,7 +771,7 @@ - cropEdit()} /> + cropEdit()} /> rotateImageLeft()} /> rotateImageRight()} /> diff --git a/app/components/edit/EnhancementsPopover.svelte b/app/components/edit/EnhancementsPopover.svelte index 6ccd17de..9f181882 100644 --- a/app/components/edit/EnhancementsPopover.svelte +++ b/app/components/edit/EnhancementsPopover.svelte @@ -7,6 +7,7 @@ import { ColorMatricesTypes, getColorMatrix } from '~/utils/matrix'; import { lc } from '@nativescript-community/l'; import { colors, fontScale } from '~/variables'; + import { hasOriginalImage } from '~/utils/originals'; const filters = ColorMatricesTypes.map((k) => ({ ...k, text: lc(k.id), @@ -29,11 +30,14 @@ const value = transforms.indexOf(transformId) !== -1; return { type: 'checkbox', id: transformId, value, data: value }; } + // transforms are recomputed from the original image: without it only the color filters are available const options = ( - TRANSFORMS.map((i) => ({ - ...getData(i.id), - ...i - })) as any[] + hasOriginalImage(item) + ? (TRANSFORMS.map((i) => ({ + ...getData(i.id), + ...i + })) as any[]) + : [] ).concat({ type: 'filters' }); diff --git a/app/components/list/DocumentsList.svelte b/app/components/list/DocumentsList.svelte index 6760b8ec..9cbd3fbf 100644 --- a/app/components/list/DocumentsList.svelte +++ b/app/components/list/DocumentsList.svelte @@ -7,12 +7,12 @@ import { openFile, openUrl, throttle } from '@nativescript/core/utils'; import { showError } from '@shared/utils/showError'; import dayjs from 'dayjs'; - import { filesize } from 'filesize'; import { Template } from '@nativescript-community/svelte-native/components'; import { NativeViewElementNode } from '@nativescript-community/svelte-native/dom'; import { isEInk } from '~/helpers/theme'; import { DocFolder } from '~/models/OCRDocument'; - import { importImageFromCamera } from '~/utils/ui'; + import { formatStorageSizes, importImageFromCamera } from '~/utils/ui'; + import { computeStorageSizes } from '~/utils/originals'; import { colors, fontScale, hasCamera, windowInset } from '~/variables'; import PageIndicator from '../common/PageIndicator.svelte'; import RotableImageView from '../common/RotableImageView.svelte'; @@ -65,15 +65,7 @@ textPaint.color = colorOnSurfaceVariant; const { doc } = item; textPaint.textSize = condensed ? 11 : 14 * $fontScale; - canvas.drawText( - filesize( - doc.pages.reduce((acc, v) => acc + v.size, 0), - { output: 'string' } - ), - dx, - h - (condensed ? 0 : 16) - 10, - textPaint - ); + canvas.drawText(formatStorageSizes(computeStorageSizes(doc.pages)), dx, h - (condensed ? 0 : 16) - 10, textPaint); textPaint.color = colorOnBackground; const topText = createNativeAttributedString({ spans: [ diff --git a/app/components/list/MainList.svelte b/app/components/list/MainList.svelte index 7bf01d83..a37d655d 100644 --- a/app/components/list/MainList.svelte +++ b/app/components/list/MainList.svelte @@ -1,12 +1,10 @@ + + + + + + + + + + + {#if nbSelected > 0} + + {/if} + + + + + diff --git a/app/components/view/DocumentView.svelte b/app/components/view/DocumentView.svelte index b77ec003..d1dfc8b5 100644 --- a/app/components/view/DocumentView.svelte +++ b/app/components/view/DocumentView.svelte @@ -6,7 +6,6 @@ import { AnimationDefinition, Application, ApplicationSettings, ContentView, EventData, ObservableArray, Page, PageTransition, SharedTransition, StackLayout } from '@nativescript/core'; import { AndroidActivityBackPressedEventData } from '@nativescript/core/application'; import { throttle } from '@nativescript/core/utils'; - import { filesize } from 'filesize'; import { onDestroy, onMount } from 'svelte'; import { Template } from '@nativescript-community/svelte-native/components'; import { NativeViewElementNode } from '@nativescript-community/svelte-native/dom'; @@ -41,8 +40,11 @@ } from '~/utils/constants'; import { showError } from '@shared/utils/showError'; import { goBack, navigate, showModal } from '@shared/utils/svelte/ui'; + import { computeStorageSizes } from '~/utils/originals'; import { + deleteOriginalImages, detectOCR, + formatStorageSizes, hideLoading, importAndScanImage, importImageFromCamera, @@ -556,6 +558,7 @@ { icon: 'mdi-auto-fix', id: 'transform', name: lc('transform_images') }, { icon: 'mdi-text-recognition', id: 'ocr', name: lc('ocr_document') }, { id: 'select_all', name: lc('select_all'), icon: 'mdi-select-all' }, + { icon: 'mdi-image-remove', id: 'delete_originals', name: lc('delete_original_images') }, { color: colorError, icon: 'mdi-delete', id: 'delete', name: lc('delete') } ]; } @@ -595,6 +598,12 @@ unselectAll(); } break; + case 'delete_originals': + result = await deleteOriginalImages({ pages: getSelectedPagesWithData() }); + if (result) { + unselectAll(); + } + break; } } catch (error) { showError(error); @@ -609,6 +618,7 @@ { id: 'reorder', name: lc('reorder_pages'), icon: 'mdi-reorder-horizontal' }, { id: 'transform', name: lc('transform_images'), icon: 'mdi-auto-fix' }, { id: 'ocr', name: lc('ocr_document'), icon: 'mdi-text-recognition' }, + { id: 'delete_originals', name: lc('delete_original_images'), icon: 'mdi-image-remove' }, { id: 'delete', name: lc('delete'), icon: 'mdi-delete', color: colorError } ] as any); return showPopoverMenu({ @@ -635,6 +645,10 @@ await transformPages({ documents: [document] }); unselectAll(); break; + case 'delete_originals': + await deleteOriginalImages({ documents: [document] }); + unselectAll(); + break; case 'delete': await deleteDoc(); break; @@ -786,7 +800,7 @@ diff --git a/app/i18n/en.json b/app/i18n/en.json index 15284238..668e5090 100644 --- a/app/i18n/en.json +++ b/app/i18n/en.json @@ -109,6 +109,8 @@ "confirm_delete_document": "are you sure you want to delete this document?", "confirm_delete_documents_one": "are you sure you want to delete this document?", "confirm_delete_documents_other": "are you sure you want to delete those %1$s documents?", + "confirm_delete_original_images_one": "delete the original image of this page? it can't be cropped or transformed anymore.", + "confirm_delete_original_images_other": "delete the original images of those %1$s pages? they can't be cropped or transformed anymore.", "confirm_delete_page": "are you sure you want to delete page %1$s?", "confirm_delete_pages_one": "are you sure you want to delete this page?", "confirm_delete_pages_other": "are you sure you want to delete those %1$s pages?", @@ -142,6 +144,8 @@ "debugging_settings_desc": "debugging settings", "default_value": "default", "delete": "delete", + "delete_all_original_images": "delete all original images", + "delete_original_images": "delete original images", "delete_page": "delete", "delete_permanently": "delete permanently", "detect_qrcode": "detect QRCode", @@ -162,6 +166,8 @@ "documents_count_one": "%1$s document", "documents_count_other": "%1$s documents", "documents_settings": "documents settings", + "documents_storage": "documents storage", + "documents_storage_desc": "space used by your documents, tap to see the detail", "donate": "Please consider making a small donation to help fund the project", "download": "download", "downloading": "downloading (%1$s%%)…", @@ -282,6 +288,8 @@ "items_per_page": "pages per sheet", "jpeg_quality": "JPEG quality", "just_once": "just once", + "keep_original_images": "keep original images", + "keep_original_images_desc": "keep a copy of the image each page was created from. needed to crop or transform a page again later. disable to use less storage", "landscape": "landscape", "language": "language", "languages": "languages", @@ -330,6 +338,7 @@ "no_document_found": "no document found", "no_document_yet": "add a document using the camera or import one from an image", "no_network": "no network", + "no_original_image_to_delete": "no original image left to delete", "no_pages_in_pdf": "there was no pages to export in the PDF", "no_qrcode": "no QRCode found", "no_qrcode_found": "no QRCode found", @@ -361,6 +370,8 @@ "optional_pdf_password": "PDF password (optional)", "or": "or", "orientation": "orientation", + "original_images_deleted": "original images deleted, %1$s freed", + "originals_size": "%1$s of original images", "page": "page", "page_layout": "page layout", "page_margin": "page margin", @@ -473,8 +484,11 @@ "start_app_on_cam_desc": "the app will always start on camera. Changing this setting requires an app restart", "start_on_cam": "start the app on camera screen", "startup_error": "there was an error while starting the app. Please restart the app to try again. If it persists please report it", + "size_with_total": "%1$s (%2$s total)", + "storage": "storage", "storage_location": "storage location", "storage_permission_needed": "storage permission is needed", + "storage_settings_desc": "space used by the app, and where data is stored", "successful": "successful", "sure_cancel_import": "are you sure you want to cancel?", "sure_go_back": "are you sure you want to go back?", diff --git a/app/models/OCRDocument.ts b/app/models/OCRDocument.ts index ca22e928..5e23e804 100644 --- a/app/models/OCRDocument.ts +++ b/app/models/OCRDocument.ts @@ -30,7 +30,8 @@ import { OCR_ITERATOR_LEVEL, SEPARATOR, SETTINGS_DOCUMENT_NAME_FORMAT, - getImageExportSettings + getImageExportSettings, + keepOriginalImages } from '../utils/constants'; import { getImagePipeline } from '@nativescript-community/ui-image'; import { PKPass } from './PKPass'; @@ -150,6 +151,12 @@ export interface DocumentExtra { }; } +/** Size of a file on disk, 0 when the path is empty or the file is gone. */ +function getFileSize(filePath?: string) { + // File.fromPath creates the file if it does not exist so we check first + return filePath && File.exists(filePath) ? File.fromPath(filePath).size : 0; +} + export class OCRDocument extends Observable implements Document { // id: string; createdDate: number; @@ -237,12 +244,18 @@ export class OCRDocument extends Observable implements Document { baseName += '.' + IMG_FORMAT; } const actualSourceImagePath = path.join(pageFileData.path, baseName); - attributes.sourceImagePath = actualSourceImagePath; if (actualSourceImagePath !== sourceImagePath) { - const file = File.fromPath(sourceImagePath); - await file.copy(actualSourceImagePath); + // the original lives outside the page folder: this is a new capture/import + if (keepOriginalImages()) { + attributes.sourceImagePath = actualSourceImagePath; + const file = File.fromPath(sourceImagePath); + await file.copy(actualSourceImagePath); + } + } else { + attributes.sourceImagePath = actualSourceImagePath; } } + attributes.sourceSize = getFileSize(attributes.sourceImagePath); // we add 1000 to each pageIndex so that we can reorder them // if (!attributes.pageIndex) { // attributes.pageIndex = index + 1000; @@ -325,12 +338,18 @@ export class OCRDocument extends Observable implements Document { } const actualSourceImagePath = path.join(pageFileData.path, baseName); // if the same nothing to do, must be while syncing - attributes.sourceImagePath = actualSourceImagePath; if (actualSourceImagePath !== sourceImagePath) { - const file = File.fromPath(sourceImagePath); - await file.copy(actualSourceImagePath); + // the original lives outside the page folder: this is a new capture/import + if (keepOriginalImages()) { + attributes.sourceImagePath = actualSourceImagePath; + const file = File.fromPath(sourceImagePath); + await file.copy(actualSourceImagePath); + } + } else { + attributes.sourceImagePath = actualSourceImagePath; } } + attributes.sourceSize = getFileSize(attributes.sourceImagePath); if (id) { try { const page = await documentsService.pageRepository.get(id); @@ -379,6 +398,25 @@ export class OCRDocument extends Observable implements Document { return docData.remove(); } + /** + * Deletes the original (pre-crop) image of a page to free storage. + * The page keeps its processed image but can't be re-cropped/transformed anymore. + * Returns the number of freed bytes. + */ + async deletePageOriginal(pageIndex: number) { + const page = this.pages[pageIndex]; + if (!page?.sourceImagePath) { + return 0; + } + const freedSize = getFileSize(page.sourceImagePath); + if (File.exists(page.sourceImagePath)) { + await File.fromPath(page.sourceImagePath).remove(); + } + DEV_LOG && console.log('deletePageOriginal', this.id, pageIndex, page.sourceImagePath, freedSize); + await this.updatePage(pageIndex, { sourceImagePath: null, sourceSize: 0 }, false); + return freedSize; + } + async deletePage(pageIndex: number) { if (pageIndex < 0 || pageIndex >= this.pages.length) { return; @@ -540,6 +578,10 @@ export class OCRDocument extends Observable implements Document { async updatePageCrop(pageIndex: number, quad: Quad) { const page = this.pages[pageIndex]; + // the crop is recomputed from the original image which can have been deleted + if (!page?.sourceImagePath) { + return; + } // DEV_LOG && console.log('updatePageCrop', this.id, pageIndex, quad, page.imagePath); const file = File.fromPath(page.imagePath); const imageExportSettings = getImageExportSettings(); @@ -584,7 +626,8 @@ export class OCRDocument extends Observable implements Document { const file = File.fromPath(page.imagePath); DEV_LOG && console.log('updatePageTransforms', this.id, pageIndex, this.pages.length, page?.imagePath, transforms, file.path, file.parent.path); const imageExportSettings = getImageExportSettings(); - if (transforms === page.transforms) { + // without the original image the transforms can't be recomputed: only the other updates are applied + if (transforms === page.transforms || !page.sourceImagePath) { await this.updatePage( pageIndex, { @@ -684,6 +727,8 @@ export interface Page { width: number; height: number; size: number; + /** size of the original image file, 0 when it is not kept */ + sourceSize: number; sourceImagePath: string; sourceImageWidth: number; sourceImageHeight: number; @@ -729,6 +774,7 @@ export class OCRPage extends Observable implements Page { width: number; height: number; size: number; + sourceSize: number; sourceImagePath: string; sourceImageWidth: number; diff --git a/app/services/backup.ts b/app/services/backup.ts index 60a23ae8..efde3192 100644 --- a/app/services/backup.ts +++ b/app/services/backup.ts @@ -123,7 +123,10 @@ export class BackupService { if (pages && pages.length > 0) { pages.forEach((page) => { const pageDataFolder = targetDocFolder.getFolder(page.id); - page.sourceImagePath = path.join(pageDataFolder.path, basename(page.sourceImagePath)); + // the original image can be missing: it is not kept for every page + if (page.sourceImagePath) { + page.sourceImagePath = path.join(pageDataFolder.path, basename(page.sourceImagePath)); + } page.imagePath = path.join(pageDataFolder.path, basename(page.imagePath)); }); await doc.addPages(pages, true, true); diff --git a/app/services/documents.ts b/app/services/documents.ts index 0781cd7e..0d48c9d7 100644 --- a/app/services/documents.ts +++ b/app/services/documents.ts @@ -9,6 +9,7 @@ import { DocFolder, Document, IDocFolder, OCRDocument, OCRPage, Page, Tag } from import { PKPass, PKPassType } from '~/models/PKPass'; import { EVENT_DOCUMENT_DELETED, EVENT_DOCUMENT_RESTORED, EVENT_DOCUMENT_TRASHED, EVENT_DOCUMENT_USE_COUNT, SETTINGS_ROOT_DATA_FOLDER } from '~/utils/constants'; import { groupByArray } from '@shared/utils'; +import { StorageSizes } from '~/utils/originals'; import DatabaseInterface from 'kiss-orm/dist/Databases/DatabaseInterface'; import QueryIdentifier from 'kiss-orm/dist/Queries/QueryIdentifier'; export const sql = SqlQuery.createFromTemplateString; @@ -360,6 +361,27 @@ export class PageRepository extends BaseRepository { console.error('Error filling Page search indexes', e); reject(e); } + }), + + addPageSourceSize: (sequenceDb: DatabaseInterface) => + new Promise(async (resolve, reject) => { + try { + await sequenceDb.query(sql`ALTER TABLE Page ADD COLUMN sourceSize INTEGER`); + const pages = await this.search(); + DEV_LOG && console.log('filling sourceSize for Pages', pages.length); + await doInBatch( + pages, + async (page: OCRPage) => { + const sourceSize = page.sourceImagePath && File.exists(page.sourceImagePath) ? File.fromPath(page.sourceImagePath).size : 0; + await super.update(page, { sourceSize }); + }, + 10 + ); + resolve(); + } catch (e) { + console.error('Error filling Page sourceSize', e); + reject(e); + } }) }, CARD_APP @@ -389,6 +411,7 @@ export class PageRepository extends BaseRepository { width INTEGER, height INTEGER, size INTEGER, + sourceSize INTEGER, sourceImagePath TEXT, imagePath TEXT, document_id TEXT, @@ -451,7 +474,8 @@ export class PageRepository extends BaseRepository { } else if (k === 'name') { toUpdate[k] = value; toUpdate.nameSearch = normalizeSearchString(value); - } else if (typeof value === 'object' || Array.isArray(value)) { + // `typeof null` is 'object': null must be stored as SQL NULL, not as the "null" string + } else if (value !== null && (typeof value === 'object' || Array.isArray(value))) { toUpdate[k] = JSON.stringify(value); } else { toUpdate[k] = value; @@ -954,6 +978,15 @@ export class DocumentsService extends Observable { this.notify({ eventName: 'started' }); this.started = true; } + /** What every page of every document uses on disk, split between processed and original images. */ + async getStorageSizes(): Promise { + const result = await this.db.query(sql`SELECT SUM(p.size) AS size, SUM(p.sourceSize) AS sourceSize, COUNT(DISTINCT p.document_id) AS documentsCount +FROM Page p +LEFT JOIN Document d ON d.id = p.document_id +WHERE d.trashedDate IS NULL`); + const { documentsCount, size, sourceSize } = result[0] ?? {}; + return { size: size || 0, sourceSize: sourceSize || 0, total: (size || 0) + (sourceSize || 0), documentsCount: documentsCount || 0 }; + } async deleteDocuments(documents: OCRDocument[]) { DEV_LOG && console.log( diff --git a/app/services/sync/BaseDataSyncService.ts b/app/services/sync/BaseDataSyncService.ts index 260e2d4a..ba4375e2 100644 --- a/app/services/sync/BaseDataSyncService.ts +++ b/app/services/sync/BaseDataSyncService.ts @@ -95,7 +95,10 @@ export abstract class BaseDataSyncService extends BaseSyncService { docDataFolder = getDocumentsService().dataFolder.getFolder(docId); pages.forEach((page) => { const pageDataFolder = docDataFolder.getFolder(page.id); - page.sourceImagePath = path.join(pageDataFolder.path, basename(page.sourceImagePath)); + // the original image can be missing: it is not kept for every page + if (page.sourceImagePath) { + page.sourceImagePath = path.join(pageDataFolder.path, basename(page.sourceImagePath)); + } page.imagePath = path.join(pageDataFolder.path, basename(page.imagePath)); }); pageIds = pages.map((p) => p.id); diff --git a/app/utils/constants.ts b/app/utils/constants.ts index d6a36ed1..bf56e9df 100644 --- a/app/utils/constants.ts +++ b/app/utils/constants.ts @@ -23,6 +23,7 @@ export const SETTINGS_REMOTE_AUTO_SYNC = 'webdav_auto_sync'; // we cant rename a export const SETTINGS_SYNC_SERVICES = 'sync_services'; // we cant rename as it was already used before export const SETTINGS_IMAGE_EXPORT_FORMAT = 'image_export_format'; export const SETTINGS_IMAGE_EXPORT_QUALITY = 'image_export_quality'; +export const SETTINGS_KEEP_ORIGINAL_IMAGES = 'keep_original_images'; export const SETTINGS_TRANSFORM_BATCH_SIZE = 'transform_batch_size'; export const SETTINGS_DEFAULT_TRANSFORM = 'defaultTransforms'; export const SETTINGS_DEFAULT_COLORTYPE = 'defaultColorType'; @@ -77,6 +78,7 @@ export const PDF_IMPORT_IMAGES = PDFImportImages.ask; export const USE_SYSTEM_CAMERA = false; export const CROP_ENABLED = true; export const ALWAYS_PROMPT_CROP_EDIT = false; +export const KEEP_ORIGINAL_IMAGES = true; export const MAGNIFIER_SENSITIVITY = 0.8; export const TRANSFORM_BATCH_SIZE = 3; export const AUTO_SCAN_ENABLED = true; @@ -132,6 +134,11 @@ export const DEFAULT_EXPORT_DIRECTORY = __ANDROID__ : undefined : undefined; +/** Whether a copy of the image a page was created from is kept, allowing later re-crop/transforms. */ +export function keepOriginalImages() { + return ApplicationSettings.getBoolean(SETTINGS_KEEP_ORIGINAL_IMAGES, KEEP_ORIGINAL_IMAGES); +} + export function getImageExportSettings() { return { imageFormat: ApplicationSettings.getString(SETTINGS_IMAGE_EXPORT_FORMAT, IMG_FORMAT) as 'png' | 'jpeg' | 'jpg', diff --git a/app/utils/originals.test.ts b/app/utils/originals.test.ts new file mode 100644 index 00000000..63bfb4d0 --- /dev/null +++ b/app/utils/originals.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from 'vitest'; +import { computeStorageSizes, filterPagesWithOriginal, hasOriginalImage } from './originals'; + +// hasOriginalImage + +test('hasOriginalImage returns false for a page without sourceImagePath', () => { + expect(hasOriginalImage({})).toBe(false); +}); + +test('hasOriginalImage returns false when sourceImagePath is null', () => { + expect(hasOriginalImage({ sourceImagePath: null })).toBe(false); +}); + +test('hasOriginalImage returns false when sourceImagePath is an empty string', () => { + expect(hasOriginalImage({ sourceImagePath: '' })).toBe(false); +}); + +test('hasOriginalImage returns false for a missing page', () => { + expect(hasOriginalImage(undefined)).toBe(false); +}); + +test('hasOriginalImage returns true when sourceImagePath is set', () => { + expect(hasOriginalImage({ sourceImagePath: '/data/doc/page/20260811.jpg' })).toBe(true); +}); + +// filterPagesWithOriginal + +test('filterPagesWithOriginal keeps only entries whose page has an original', () => { + const items = [{ page: { sourceImagePath: '/data/a.jpg' } }, { page: {} }, { page: { sourceImagePath: null } }, { page: { sourceImagePath: '/data/b.jpg' } }]; + expect(filterPagesWithOriginal(items)).toEqual([{ page: { sourceImagePath: '/data/a.jpg' } }, { page: { sourceImagePath: '/data/b.jpg' } }]); +}); + +test('filterPagesWithOriginal returns an empty array when no page has an original', () => { + expect(filterPagesWithOriginal([{ page: {} }, { page: { sourceImagePath: '' } }])).toEqual([]); +}); + +test('filterPagesWithOriginal keeps the other entry properties untouched', () => { + const items = [{ page: { sourceImagePath: '/data/a.jpg' }, pageIndex: 3, document: { id: 'doc' } }]; + expect(filterPagesWithOriginal(items)).toEqual(items); +}); + +// computeStorageSizes + +test('computeStorageSizes sums processed and original sizes', () => { + const pages = [ + { size: 100, sourceSize: 400 }, + { size: 50, sourceSize: 250 } + ]; + expect(computeStorageSizes(pages)).toEqual({ size: 150, sourceSize: 650, total: 800 }); +}); + +test('computeStorageSizes counts pages without an original', () => { + const pages = [{ size: 100, sourceSize: 0 }, { size: 50 }]; + expect(computeStorageSizes(pages)).toEqual({ size: 150, sourceSize: 0, total: 150 }); +}); + +test('computeStorageSizes ignores undefined sizes', () => { + expect(computeStorageSizes([{}, { sourceSize: 10 }])).toEqual({ size: 0, sourceSize: 10, total: 10 }); +}); + +test('computeStorageSizes returns zeros for no page', () => { + expect(computeStorageSizes([])).toEqual({ size: 0, sourceSize: 0, total: 0 }); + expect(computeStorageSizes(undefined)).toEqual({ size: 0, sourceSize: 0, total: 0 }); +}); diff --git a/app/utils/originals.ts b/app/utils/originals.ts new file mode 100644 index 00000000..c23400c5 --- /dev/null +++ b/app/utils/originals.ts @@ -0,0 +1,53 @@ +/** + * Original (pre-crop) image availability. + * + * A page keeps a copy of the image it was created from (`sourceImagePath`) so + * that the crop and the transforms can be recomputed later. That copy can be + * missing: either it was never kept (`SETTINGS_KEEP_ORIGINAL_IMAGES` off) or the + * user deleted it to free storage. + * + * These helpers stay free of any NativeScript dependency so they can be unit-tested. + */ + +/** Minimal shape needed to know whether a page can still be re-cropped/transformed. */ +export interface PageWithOriginal { + sourceImagePath?: string; +} + +/** Minimal shape needed to compute what a page uses on disk. */ +export interface PageWithSizes { + /** size of the processed image */ + size?: number; + /** size of the original image, 0 when it is not kept */ + sourceSize?: number; +} + +export interface StorageSizes { + /** size of the processed images */ + size: number; + /** size of the original images */ + sourceSize: number; + /** what the pages actually use on disk */ + total: number; +} + +/** What a set of pages uses on disk, split between processed and original images. */ +export function computeStorageSizes(pages?: PageWithSizes[]): StorageSizes { + let size = 0; + let sourceSize = 0; + (pages ?? []).forEach((page) => { + size += page.size || 0; + sourceSize += page.sourceSize || 0; + }); + return { size, sourceSize, total: size + sourceSize }; +} + +/** True when the page still references an original image. */ +export function hasOriginalImage(page?: PageWithOriginal): boolean { + return !!page?.sourceImagePath?.length; +} + +/** Keeps only the entries whose page can still be re-cropped/transformed. */ +export function filterPagesWithOriginal(items: T[]): T[] { + return items.filter((item) => hasOriginalImage(item.page)); +} diff --git a/app/utils/ui/index.common.ts b/app/utils/ui/index.common.ts index 9aa501e7..29f5722e 100644 --- a/app/utils/ui/index.common.ts +++ b/app/utils/ui/index.common.ts @@ -3,6 +3,8 @@ import { share } from '@akylas/nativescript-app-utils/share'; import { MultiResult, Permissions, Status, isPermResultAuthorized, openSettings, request } from '@nativescript-community/perms'; import { ComponentInstanceInfo, resolveComponentElement } from '@nativescript-community/svelte-native/dom'; import { openFilePicker, pickFolder } from '@nativescript-community/ui-document-picker'; +import { GestureRootView } from '@nativescript-community/gesturehandler'; +import { CheckBox } from '@nativescript-community/ui-checkbox'; import { Label } from '@nativescript-community/ui-label'; import { showBottomSheet } from '@nativescript-community/ui-material-bottomsheet/svelte'; import { MDCAlertControlerOptions, alert, confirm, prompt } from '@nativescript-community/ui-material-dialogs'; @@ -34,6 +36,7 @@ import { showError } from '@shared/utils/showError'; import { goBack, navigate, showModal } from '@shared/utils/svelte/ui'; import { hideLoading, showLoading, showSliderPopover, showSnack, updateLoadingProgress } from '@shared/utils/ui'; import dayjs from 'dayjs'; +import { filesize } from 'filesize'; import { CropResult, Quads, @@ -54,7 +57,7 @@ import type OptionSelect__SvelteComponent_ from '@shared/components/OptionSelect import type BottomSnack__SvelteComponent_ from '~/components/widgets/BottomSnack.svelte'; import BottomSnack from '~/components/widgets/BottomSnack.svelte'; import { getFileNameForDocument, getFormatedDateForFilename, getLocaleDisplayName, l, lang, lc, lcp, pluralKey } from '~/helpers/locale'; -import { DocFolder, ImportImageData, OCRDocument, OCRPage, PageData } from '~/models/OCRDocument'; +import { DocFolder, ImportImageData, OCRDocument, OCRPage, PageData, getDocumentsService } from '~/models/OCRDocument'; import { PKPassType } from '~/models/PKPass'; import { OCRLanguages, ocrService } from '~/services/ocr'; import { getPDFDefaultExportOptions } from '~/services/pdf/PDFCanvas'; @@ -74,6 +77,8 @@ import { DEFAULT_EXPORT_DIRECTORY, DEFAULT_OCR_COPY_USE_SPACE, DEFAULT_TRANSFORM, + DEFAULT_TRASH_ENABLED, + DEFAULT_TRASH_REMEMBERED_DELETE_MODE, DOCUMENT_NOT_DETECTED_MARGIN, ESPASS_EXT, PDFImportImages, @@ -93,12 +98,15 @@ import { SETTINGS_IMPORT_PDF_IMAGES, SETTINGS_OCR_COPY_USE_SPACE, SETTINGS_TRANSFORM_BATCH_SIZE, + SETTINGS_TRASH_ENABLED, + SETTINGS_TRASH_REMEMBERED_DELETE_MODE, TRANSFORMS_SPLIT, TRANSFORM_BATCH_SIZE, USE_SYSTEM_CAMERA, getImageExportSettings } from '~/utils/constants'; import { recycleImages } from '~/utils/images'; +import { StorageSizes, filterPagesWithOriginal } from '~/utils/originals'; import { buildPassArchive, getStoredPassFormat } from '~/utils/pkpass'; import { importPKPassFiles } from '~/utils/pkpass-import'; import { showToast } from '~/utils/ui'; @@ -1403,6 +1411,115 @@ interface PageTransformData { pageIndex: number; document: OCRDocument; } +/** + * Deletes documents, asking the user first. When the trash is enabled the user chooses + * between moving to the trash and deleting for good, and can remember that choice. + * Returns true when something was actually deleted. + */ +export async function deleteDocumentsWithConfirm(documents: OCRDocument[]) { + if (!documents.length) { + return false; + } + const trashEnabled = ApplicationSettings.getBoolean(SETTINGS_TRASH_ENABLED, DEFAULT_TRASH_ENABLED); + if (!trashEnabled) { + const confirmed = await confirm({ + cancelButtonText: lc('cancel'), + message: lcp('confirm_delete_documents', documents.length), + okButtonText: lc('delete'), + title: lc('delete') + }); + if (confirmed) { + await getDocumentsService().deleteDocuments(documents); + return true; + } + return false; + } + const rememberedMode = ApplicationSettings.getString(SETTINGS_TRASH_REMEMBERED_DELETE_MODE, DEFAULT_TRASH_REMEMBERED_DELETE_MODE); + let result: boolean = null; + if (rememberedMode) { + result = rememberedMode === 'trash'; + } else { + const view = createView(GestureRootView, { + columns: 'auto,*', + rows: 'auto' + }); + const checkBox = createView(CheckBox, {}); + const label = createView(Label, { + verticalAlignment: 'center', + col: 1, + text: lc('remember_delete_choice') + }); + label.on('tap', () => { + checkBox.checked = !checkBox.checked; + }); + view.addChild(checkBox); + view.addChild(label); + result = await confirm({ + neutralButtonText: lc('cancel'), + message: lcp('confirm_move_to_trash', documents.length), + cancelButtonText: lc('delete_permanently'), + okButtonText: lc('move_to_trash'), + title: lc('delete'), + view + } as any); + if (result !== null && checkBox.checked) { + ApplicationSettings.setString(SETTINGS_TRASH_REMEMBERED_DELETE_MODE, result === true ? 'trash' : 'permament'); + } + } + if (result === true) { + await getDocumentsService().trashDocuments(documents); + return true; + } + if (result === false) { + // neutral button tapped: delete permanently + const confirmed = await confirm({ + cancelButtonText: lc('cancel'), + message: lcp('confirm_delete_permanently', documents.length), + okButtonText: lc('delete_permanently'), + title: lc('delete_permanently') + }); + if (confirmed) { + await getDocumentsService().deleteDocuments(documents); + return true; + } + } + return false; +} +/** Human readable disk usage: the processed images size (what an export weighs), then the total with the originals. */ +export function formatStorageSizes(sizes: StorageSizes) { + const size = filesize(sizes.size, { output: 'string' }); + return sizes.sourceSize ? lc('size_with_total', size, filesize(sizes.total, { output: 'string' })) : size; +} +function getPagesFromDocuments(documents: OCRDocument[]) { + const pages: PageTransformData[] = []; + documents.forEach((document) => { + pages.push(...document.pages.reduce((acc, page, pageIndex) => acc.concat([{ page, pageIndex, document }]), [])); + }); + return pages; +} +export async function deleteOriginalImages({ documents, pages }: { documents?: OCRDocument[]; pages?: PageTransformData[] }) { + if (!pages && documents) { + pages = getPagesFromDocuments(documents); + } + const pagesWithOriginal = filterPagesWithOriginal(pages); + if (!pagesWithOriginal.length) { + showSnack({ message: lc('no_original_image_to_delete') }); + return; + } + const confirmed = await confirm({ + title: lc('delete_original_images'), + message: lcp('confirm_delete_original_images', pagesWithOriginal.length), + okButtonText: lc('delete'), + cancelButtonText: lc('cancel') + }); + if (!confirmed) { + return; + } + const freedSizes = await doInBatch(pagesWithOriginal, (pageData) => pageData.document.deletePageOriginal(pageData.pageIndex)); + const freedSize = freedSizes.reduce((acc, size) => acc + size, 0); + showSnack({ message: lc('original_images_deleted', filesize(freedSize, { output: 'string' })) }); + return true; +} export async function transformPages({ documents, pages }: { documents?: OCRDocument[]; pages?: PageTransformData[] }) { try { const view = (await import('~/components/common/TransformPagesBottomSheet.svelte')).default; @@ -1416,10 +1533,7 @@ export async function transformPages({ documents, pages }: { documents?: OCRDocu // we want to ocr the full document. const progress = 0; if (!pages && documents) { - pages = []; - documents.forEach((document) => { - pages.push(...document.pages.reduce((acc, page, pageIndex) => acc.concat([{ page, pageIndex, document }]), [])); - }); + pages = getPagesFromDocuments(documents); } const totalPages = pages.length; let pagesDone = 0; diff --git a/app/workers/SyncWorker.ts b/app/workers/SyncWorker.ts index 7f29d94d..1f159624 100644 --- a/app/workers/SyncWorker.ts +++ b/app/workers/SyncWorker.ts @@ -635,7 +635,10 @@ export default class SyncWorker extends BaseWorker { for (let index = 0; index < missingLocalPages.length; index++) { const missingLocalPage = missingLocalPages[index]; const pageDataFolder = docDataFolder.getFolder(missingLocalPage.id); - missingLocalPage.sourceImagePath = path.join(pageDataFolder.path, basename(missingLocalPage.sourceImagePath)); + // the original image can be missing: it is not kept for every page + if (missingLocalPage.sourceImagePath) { + missingLocalPage.sourceImagePath = path.join(pageDataFolder.path, basename(missingLocalPage.sourceImagePath)); + } missingLocalPage.imagePath = path.join(pageDataFolder.path, basename(missingLocalPage.imagePath)); await service.importFolderFromRemote(path.join(document.id, missingLocalPage.id), pageDataFolder); @@ -668,7 +671,8 @@ export default class SyncWorker extends BaseWorker { // check if we need to recreate the image let imageChanged = false; DEV_LOG && console.log('sync page FROM webdav!', remotePageToSync.id, JSON.stringify(pageToUpdate)); - if (pageToUpdate.crop || pageToUpdate.transforms) { + // without the local original image the crop/transforms can't be recomputed + if ((pageToUpdate.crop || pageToUpdate.transforms) && localPage.sourceImagePath) { const file = File.fromPath(localPage.imagePath); const crop = pageToUpdate.crop || localPage.crop; const transforms = pageToUpdate.transforms || localPage.transforms; @@ -795,7 +799,8 @@ export default class SyncWorker extends BaseWorker { // check if we need to recreate the image DEV_LOG && console.log('sync page FROM webdav!', localPageToSync.id, JSON.stringify(pageToUpdate)); let imageChanged = false; - if (pageToUpdate.crop || pageToUpdate.transforms) { + // without the local original image the crop/transforms can't be recomputed + if ((pageToUpdate.crop || pageToUpdate.transforms) && localPageToSync.sourceImagePath) { const file = File.fromPath(localPageToSync.imagePath); const crop = pageToUpdate.crop || localPageToSync.crop;