support receiving images from the Web Share Target API Level 2 and directly opening AI image recognition on mobile version
This commit is contained in:
@@ -70,12 +70,12 @@ const cancelRecognizingUuid = ref<string | undefined>(undefined);
|
|||||||
const imageFile = ref<File | null>(null);
|
const imageFile = ref<File | null>(null);
|
||||||
const imageSrc = ref<string | undefined>(undefined);
|
const imageSrc = ref<string | undefined>(undefined);
|
||||||
|
|
||||||
function loadImage(file: File): void {
|
function loadImage(image: Blob): void {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
imageFile.value = null;
|
imageFile.value = null;
|
||||||
imageSrc.value = undefined;
|
imageSrc.value = undefined;
|
||||||
|
|
||||||
compressJpgImage(file, 1280, 1280, 0.8).then(blob => {
|
compressJpgImage(image, 1280, 1280, 0.8).then(blob => {
|
||||||
imageFile.value = KnownFileType.JPG.createFileFromBlob(blob, "image");
|
imageFile.value = KnownFileType.JPG.createFileFromBlob(blob, "image");
|
||||||
imageSrc.value = URL.createObjectURL(blob);
|
imageSrc.value = URL.createObjectURL(blob);
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -184,6 +184,10 @@ function onSheetOpen(): void {
|
|||||||
function onSheetClosed(): void {
|
function onSheetClosed(): void {
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
loadImage
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export const SW_RUNTIME_CACHE_NAME_PREFIX: string = 'workbox-runtime-';
|
|||||||
export const SW_ASSETS_CACHE_NAME: string = 'ezbookkeeping-assets-cache';
|
export const SW_ASSETS_CACHE_NAME: string = 'ezbookkeeping-assets-cache';
|
||||||
export const SW_CODE_CACHE_NAME: string = 'ezbookkeeping-code-cache';
|
export const SW_CODE_CACHE_NAME: string = 'ezbookkeeping-code-cache';
|
||||||
export const SW_MAP_CACHE_NAME: string = 'ezbookkeeping-map-cache';
|
export const SW_MAP_CACHE_NAME: string = 'ezbookkeeping-map-cache';
|
||||||
|
export const SW_SHARE_CACHE_NAME: string = 'ezbookkeeping-share-cache';
|
||||||
|
|
||||||
export const SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG: string = 'UPDATE_MAP_CACHE_CONFIG';
|
export const SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG: string = 'UPDATE_MAP_CACHE_CONFIG';
|
||||||
export const SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG_RESPONSE: string = 'UPDATE_MAP_CACHE_CONFIG_RESPONSE';
|
export const SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG_RESPONSE: string = 'UPDATE_MAP_CACHE_CONFIG_RESPONSE';
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
SW_ASSETS_CACHE_NAME,
|
SW_ASSETS_CACHE_NAME,
|
||||||
SW_CODE_CACHE_NAME,
|
SW_CODE_CACHE_NAME,
|
||||||
SW_MAP_CACHE_NAME,
|
SW_MAP_CACHE_NAME,
|
||||||
|
SW_SHARE_CACHE_NAME,
|
||||||
SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG,
|
SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG,
|
||||||
SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG_RESPONSE,
|
SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG_RESPONSE,
|
||||||
MAP_CACHE_MAX_ENTRIES
|
MAP_CACHE_MAX_ENTRIES
|
||||||
@@ -103,6 +104,42 @@ async function getCacheTotalSize(cacheName: string): Promise<number> {
|
|||||||
return totalSize;
|
return totalSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getShareCacheImageBlob(): Promise<Blob | undefined> {
|
||||||
|
if (!window.caches) {
|
||||||
|
logger.error('caches API is not supported in this browser');
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
window.caches.open(SW_SHARE_CACHE_NAME).then(cache => {
|
||||||
|
cache.match(SW_SHARE_CACHE_NAME).then(response => {
|
||||||
|
if (!response) {
|
||||||
|
resolve(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.blob().then(blob => {
|
||||||
|
cache.delete(SW_SHARE_CACHE_NAME).then(() => {
|
||||||
|
resolve(blob);
|
||||||
|
}).catch(error => {
|
||||||
|
logger.warn('failed to delete share cache image blob', error);
|
||||||
|
resolve(blob);
|
||||||
|
});
|
||||||
|
}).catch(error => {
|
||||||
|
logger.error('failed to read share cache image blob', error);
|
||||||
|
resolve(undefined);
|
||||||
|
});
|
||||||
|
}).catch(error => {
|
||||||
|
logger.error('failed to match share cache image blob', error);
|
||||||
|
resolve(undefined);
|
||||||
|
});
|
||||||
|
}).catch(error => {
|
||||||
|
logger.error('failed to open share cache', error);
|
||||||
|
resolve(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function loadBrowserCacheStatistics(): Promise<BrowserCacheStatistics> {
|
export function loadBrowserCacheStatistics(): Promise<BrowserCacheStatistics> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const caches = window.caches;
|
const caches = window.caches;
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ export function startDownloadFile(fileName: string, fileData: Blob): void {
|
|||||||
dataLink.click();
|
dataLink.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compressJpgImage(file: File, maxWidth: number, maxHeight: number, quality: number): Promise<Blob> {
|
export function compressJpgImage(blob: Blob, maxWidth: number, maxHeight: number, quality: number): Promise<Blob> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
|
|
||||||
@@ -242,6 +242,6 @@ export function compressJpgImage(file: File, maxWidth: number, maxHeight: number
|
|||||||
reject(error);
|
reject(error);
|
||||||
};
|
};
|
||||||
|
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(blob);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,9 @@ declare const self: ServiceWorkerGlobalScope;
|
|||||||
const SW_ASSETS_CACHE_NAME: string = 'ezbookkeeping-assets-cache';
|
const SW_ASSETS_CACHE_NAME: string = 'ezbookkeeping-assets-cache';
|
||||||
const SW_CODE_CACHE_NAME: string = 'ezbookkeeping-code-cache';
|
const SW_CODE_CACHE_NAME: string = 'ezbookkeeping-code-cache';
|
||||||
const SW_MAP_CACHE_NAME: string = 'ezbookkeeping-map-cache';
|
const SW_MAP_CACHE_NAME: string = 'ezbookkeeping-map-cache';
|
||||||
|
const SW_SHARE_CACHE_NAME: string = 'ezbookkeeping-share-cache';
|
||||||
|
const SW_SHARE_IMAGE_URL_PATHNAME: string = '__share__image__';
|
||||||
|
const SW_SHARE_IMAGE_PARAM_NAME: string = 'image';
|
||||||
|
|
||||||
const SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG: string = 'UPDATE_MAP_CACHE_CONFIG';
|
const SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG: string = 'UPDATE_MAP_CACHE_CONFIG';
|
||||||
const SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG_RESPONSE: string = 'UPDATE_MAP_CACHE_CONFIG_RESPONSE';
|
const SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG_RESPONSE: string = 'UPDATE_MAP_CACHE_CONFIG_RESPONSE';
|
||||||
@@ -275,6 +278,43 @@ registerRoute(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event: FetchEvent) => {
|
||||||
|
const request: Request = event.request;
|
||||||
|
|
||||||
|
if (request.method !== 'POST' || !request.url.endsWith(SW_SHARE_IMAGE_URL_PATHNAME)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith((async (): Promise<Response> => {
|
||||||
|
let redirectUrl = request.url;
|
||||||
|
let lastShareIndex = redirectUrl.lastIndexOf(SW_SHARE_IMAGE_URL_PATHNAME);
|
||||||
|
redirectUrl = redirectUrl.substring(0, lastShareIndex);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const image = formData.get(SW_SHARE_IMAGE_PARAM_NAME);
|
||||||
|
|
||||||
|
if (image instanceof Blob) {
|
||||||
|
const cache: Cache = await caches.open(SW_SHARE_CACHE_NAME);
|
||||||
|
const response = new Response(image, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': image.type
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const putPromise = cache.put(SW_SHARE_CACHE_NAME, response.clone());
|
||||||
|
event.waitUntil(putPromise);
|
||||||
|
await putPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.redirect(redirectUrl, 303);
|
||||||
|
} catch (ex) {
|
||||||
|
console.error('failed to handle share image upload in service worker', ex);
|
||||||
|
return Response.redirect(redirectUrl, 303);
|
||||||
|
}
|
||||||
|
})());
|
||||||
|
});
|
||||||
|
|
||||||
self.addEventListener('message', (event: ExtendableMessageEvent) => {
|
self.addEventListener('message', (event: ExtendableMessageEvent) => {
|
||||||
try {
|
try {
|
||||||
if (event.data && event.data.type === SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG && 'payload' in event.data) {
|
if (event.data && event.data.type === SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG && 'payload' in event.data) {
|
||||||
|
|||||||
@@ -220,8 +220,11 @@ import { useDesktopPageStore } from '@/stores/desktopPage.ts';
|
|||||||
|
|
||||||
import { APPLICATION_LOGO_PATH } from '@/consts/asset.ts';
|
import { APPLICATION_LOGO_PATH } from '@/consts/asset.ts';
|
||||||
import { ThemeType } from '@/core/theme.ts';
|
import { ThemeType } from '@/core/theme.ts';
|
||||||
|
|
||||||
|
import { getShareCacheImageBlob } from '@/lib/cache.ts';
|
||||||
import { isUserScheduledTransactionEnabled } from '@/lib/server_settings.ts';
|
import { isUserScheduledTransactionEnabled } from '@/lib/server_settings.ts';
|
||||||
import { getSystemTheme, setExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts';
|
import { getSystemTheme, setExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts';
|
||||||
|
import logger from '@/lib/logger.ts';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
mdiMenu,
|
mdiMenu,
|
||||||
@@ -300,6 +303,14 @@ function handleNavScroll(e: Event): void {
|
|||||||
isVerticalNavScrolled.value = (e.target as HTMLElement).scrollTop > 0;
|
isVerticalNavScrolled.value = (e.target as HTMLElement).scrollTop > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearShareImageCache(): void {
|
||||||
|
getShareCacheImageBlob().then(blob => {
|
||||||
|
if (blob) {
|
||||||
|
logger.warn('desktop version does not support receving shared image, the share image cache has been cleared');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function lock(): void {
|
function lock(): void {
|
||||||
rootStore.lock();
|
rootStore.lock();
|
||||||
router.replace('/unlock');
|
router.replace('/unlock');
|
||||||
@@ -334,6 +345,8 @@ function logout(): void {
|
|||||||
function showAddDialogInTransactionListPage(): void {
|
function showAddDialogInTransactionListPage(): void {
|
||||||
desktopPageStore.setShowAddTransactionDialogInTransactionList();
|
desktopPageStore.setShowAddTransactionDialogInTransactionList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearShareImageCache();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -206,13 +206,16 @@
|
|||||||
</f7-list>
|
</f7-list>
|
||||||
</f7-popover>
|
</f7-popover>
|
||||||
|
|
||||||
<a-i-image-recognition-sheet v-model:show="showAIReceiptImageRecognitionSheet"
|
<a-i-image-recognition-sheet ref="aiImageRecognitionSheet"
|
||||||
|
v-model:show="showAIReceiptImageRecognitionSheet"
|
||||||
@recognition:change="onReceiptRecognitionChanged"/>
|
@recognition:change="onReceiptRecognitionChanged"/>
|
||||||
</f7-page>
|
</f7-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue';
|
import AIImageRecognitionSheet from '@/components/mobile/AIImageRecognitionSheet.vue';
|
||||||
|
|
||||||
|
import { ref, computed, useTemplateRef } from 'vue';
|
||||||
import type { Router } from 'framework7/types';
|
import type { Router } from 'framework7/types';
|
||||||
|
|
||||||
import { useI18n } from '@/locales/helpers.ts';
|
import { useI18n } from '@/locales/helpers.ts';
|
||||||
@@ -230,8 +233,11 @@ import { TransactionTemplate } from '@/models/transaction_template.ts';
|
|||||||
import type { RecognizedReceiptImageResponse } from '@/models/large_language_model.ts';
|
import type { RecognizedReceiptImageResponse } from '@/models/large_language_model.ts';
|
||||||
|
|
||||||
import { isUserLogined, isUserUnlocked } from '@/lib/userstate.ts';
|
import { isUserLogined, isUserUnlocked } from '@/lib/userstate.ts';
|
||||||
|
import { getShareCacheImageBlob } from '@/lib/cache.ts';
|
||||||
import { isTransactionFromAIImageRecognitionEnabled } from '@/lib/server_settings.ts';
|
import { isTransactionFromAIImageRecognitionEnabled } from '@/lib/server_settings.ts';
|
||||||
|
|
||||||
|
type AIImageRecognitionSheetType = InstanceType<typeof AIImageRecognitionSheet>;
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
f7router: Router.Router;
|
f7router: Router.Router;
|
||||||
}>();
|
}>();
|
||||||
@@ -252,6 +258,8 @@ const transactionCategoriesStore = useTransactionCategoriesStore();
|
|||||||
const transactionTemplatesStore = useTransactionTemplatesStore();
|
const transactionTemplatesStore = useTransactionTemplatesStore();
|
||||||
const overviewStore = useOverviewStore();
|
const overviewStore = useOverviewStore();
|
||||||
|
|
||||||
|
const aiImageRecognitionSheet = useTemplateRef<AIImageRecognitionSheetType>('aiImageRecognitionSheet');
|
||||||
|
|
||||||
const loading = ref<boolean>(true);
|
const loading = ref<boolean>(true);
|
||||||
const showTransactionTemplatePopover = ref<boolean>(false);
|
const showTransactionTemplatePopover = ref<boolean>(false);
|
||||||
const showAIReceiptImageRecognitionSheet = ref<boolean>(false);
|
const showAIReceiptImageRecognitionSheet = ref<boolean>(false);
|
||||||
@@ -272,13 +280,19 @@ function init(): void {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
const promises = [
|
const promises = [
|
||||||
|
getShareCacheImageBlob(),
|
||||||
accountsStore.loadAllAccounts({ force: false }),
|
accountsStore.loadAllAccounts({ force: false }),
|
||||||
transactionCategoriesStore.loadAllCategories({ force: false }),
|
transactionCategoriesStore.loadAllCategories({ force: false }),
|
||||||
transactionTemplatesStore.loadAllTemplates({ templateType: TemplateType.Normal.type, force: false }),
|
transactionTemplatesStore.loadAllTemplates({ templateType: TemplateType.Normal.type, force: false }),
|
||||||
overviewStore.loadTransactionOverview({ force: false })
|
overviewStore.loadTransactionOverview({ force: false })
|
||||||
];
|
];
|
||||||
|
|
||||||
Promise.all(promises).then(() => {
|
Promise.all(promises).then(responses => {
|
||||||
|
if (responses[0] && responses[0] instanceof Blob) {
|
||||||
|
aiImageRecognitionSheet.value?.loadImage(responses[0]);
|
||||||
|
showAIReceiptImageRecognitionSheet.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|||||||
+14
-1
@@ -139,7 +139,20 @@ export default defineConfig(() => {
|
|||||||
sizes: '512x512',
|
sizes: '512x512',
|
||||||
type: 'image/png'
|
type: 'image/png'
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
share_target: {
|
||||||
|
action: './__share__image__',
|
||||||
|
method: 'POST',
|
||||||
|
enctype: 'multipart/form-data',
|
||||||
|
params: {
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
'name': 'image',
|
||||||
|
'accept': ['image/*']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
injectManifest: {
|
injectManifest: {
|
||||||
globDirectory: 'dist/',
|
globDirectory: 'dist/',
|
||||||
|
|||||||
Reference in New Issue
Block a user