inicontent 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/pull_request.yml +18 -0
- package/CONTRIBUTING.md +67 -0
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/app/app.vue +106 -0
- package/app/assets/main.css +974 -0
- package/app/components/Api/EndpointCard.vue +169 -0
- package/app/components/Api/SchemaDocsTable.vue +208 -0
- package/app/components/Asset/Card.vue +1363 -0
- package/app/components/Asset/Grid.vue +295 -0
- package/app/components/Asset/Icon.vue +38 -0
- package/app/components/Asset/Preview.vue +486 -0
- package/app/components/Asset/Scanner.vue +700 -0
- package/app/components/Asset/Thumb.vue +708 -0
- package/app/components/Auth/Reset.vue +99 -0
- package/app/components/Auth/index.vue +346 -0
- package/app/components/ChatInterface.vue +2326 -0
- package/app/components/ChatPagePreview.vue +34 -0
- package/app/components/Column/Array.vue +29 -0
- package/app/components/Column/Asset.vue +21 -0
- package/app/components/Column/Boolean.vue +12 -0
- package/app/components/Column/Color.vue +9 -0
- package/app/components/Column/Date.vue +57 -0
- package/app/components/Column/Edit.vue +215 -0
- package/app/components/Column/Email.vue +9 -0
- package/app/components/Column/Html.vue +27 -0
- package/app/components/Column/Id.vue +20 -0
- package/app/components/Column/Locale.vue +32 -0
- package/app/components/Column/Object.vue +18 -0
- package/app/components/Column/Password.vue +7 -0
- package/app/components/Column/Role.vue +15 -0
- package/app/components/Column/S.vue +14 -0
- package/app/components/Column/Table/Single.vue +49 -0
- package/app/components/Column/Table/index.vue +42 -0
- package/app/components/Column/Tags.vue +31 -0
- package/app/components/Column/Text.vue +9 -0
- package/app/components/Column/Url.vue +9 -0
- package/app/components/Column/index.vue +41 -0
- package/app/components/Dashboard/Editor.vue +221 -0
- package/app/components/Dashboard/Grid.vue +270 -0
- package/app/components/Dashboard/View.vue +131 -0
- package/app/components/Dashboard/Widget/BarChart.vue +73 -0
- package/app/components/Dashboard/Widget/Counter.vue +51 -0
- package/app/components/Dashboard/Widget/LineChart.vue +76 -0
- package/app/components/Dashboard/Widget/PieChart.vue +75 -0
- package/app/components/Dashboard/Widget/Table.vue +94 -0
- package/app/components/Dashboard/WidgetEditor.vue +274 -0
- package/app/components/Data/Array.vue +102 -0
- package/app/components/Data/Html.vue +9 -0
- package/app/components/Data/Icon.vue +9 -0
- package/app/components/Data/Object.vue +10 -0
- package/app/components/Data/S.vue +16 -0
- package/app/components/Data/Table.vue +53 -0
- package/app/components/Data/Value.vue +34 -0
- package/app/components/Data/index.vue +14 -0
- package/app/components/Field/Array.vue +324 -0
- package/app/components/Field/Asset/Actions.vue +97 -0
- package/app/components/Field/Asset/index.vue +639 -0
- package/app/components/Field/Boolean.vue +26 -0
- package/app/components/Field/Checkbox.vue +48 -0
- package/app/components/Field/Color.vue +27 -0
- package/app/components/Field/Date.vue +30 -0
- package/app/components/Field/Email.vue +51 -0
- package/app/components/Field/EmailTemplate.vue +420 -0
- package/app/components/Field/Html/ColorPicker.vue +127 -0
- package/app/components/Field/Html/Editor.vue +544 -0
- package/app/components/Field/Html/index.vue +25 -0
- package/app/components/Field/Icon.vue +114 -0
- package/app/components/Field/JSON.vue +113 -0
- package/app/components/Field/Mention.vue +36 -0
- package/app/components/Field/Number.vue +27 -0
- package/app/components/Field/Object.vue +92 -0
- package/app/components/Field/Password.vue +55 -0
- package/app/components/Field/Radio.vue +47 -0
- package/app/components/Field/S.vue +44 -0
- package/app/components/Field/Select.vue +49 -0
- package/app/components/Field/Table.vue +348 -0
- package/app/components/Field/Tags.vue +113 -0
- package/app/components/Field/Text.vue +31 -0
- package/app/components/Field/Textarea.vue +33 -0
- package/app/components/Field/Url.vue +40 -0
- package/app/components/Field/Wrapper.vue +128 -0
- package/app/components/Field/index.vue +68 -0
- package/app/components/FloatingChatbot.vue +73 -0
- package/app/components/Form/Card.vue +151 -0
- package/app/components/Form/Drawer.vue +195 -0
- package/app/components/Form/index.vue +621 -0
- package/app/components/Header.vue +328 -0
- package/app/components/Offline/ConflictModal.vue +200 -0
- package/app/components/Offline/SyncStatus.vue +249 -0
- package/app/components/PlatformFeatureLanding.vue +65 -0
- package/app/components/Table/Backups.vue +638 -0
- package/app/components/Table/Flows.vue +814 -0
- package/app/components/Table/Grid.vue +296 -0
- package/app/components/Table/Icon.vue +36 -0
- package/app/components/Table/Logs.vue +181 -0
- package/app/components/Table/Schedules.vue +722 -0
- package/app/components/Table/Search/Button.vue +256 -0
- package/app/components/Table/Search/Items.vue +286 -0
- package/app/components/Table/Search/index.vue +87 -0
- package/app/components/Table/Settings/Schema.vue +801 -0
- package/app/components/Table/Settings/index.vue +631 -0
- package/app/components/Table/TranslateDrawer.vue +526 -0
- package/app/components/Table/Views/Kanban.vue +289 -0
- package/app/components/Table/Views/Table.vue +1273 -0
- package/app/components/Table/index.vue +890 -0
- package/app/composables/Translation/define.ts +41 -0
- package/app/composables/Translation/fetch.ts +107 -0
- package/app/composables/Translation/languages.ts +1 -0
- package/app/composables/Translation/loadCoreTranslations.ts +38 -0
- package/app/composables/Translation/translate.ts +250 -0
- package/app/composables/databaseCookies.ts +79 -0
- package/app/composables/fieldValidator.ts +72 -0
- package/app/composables/fieldsList.ts +301 -0
- package/app/composables/formatDatabase.ts +101 -0
- package/app/composables/index.ts +444 -0
- package/app/composables/openDrawer.ts +118 -0
- package/app/composables/optimizeFile.ts +287 -0
- package/app/composables/renderLabel.ts +88 -0
- package/app/composables/schemaClipboard.ts +95 -0
- package/app/composables/search.ts +353 -0
- package/app/composables/setThemeConfig.ts +48 -0
- package/app/composables/translationValue.ts +169 -0
- package/app/composables/useAssetPreview.ts +84 -0
- package/app/composables/useAssetUploader.ts +56 -0
- package/app/composables/useDashboardData.ts +310 -0
- package/app/composables/useDocumentThumbnail.ts +351 -0
- package/app/composables/useEcharts.ts +33 -0
- package/app/composables/useLocalDatabaseConfig.ts +51 -0
- package/app/composables/useOcr.ts +300 -0
- package/app/composables/useOfflineFetch.ts +194 -0
- package/app/composables/useOfflineItem.ts +107 -0
- package/app/composables/useOfflineQueue.ts +302 -0
- package/app/composables/useOfflineSync.ts +235 -0
- package/app/composables/usePasskeyAuth.ts +156 -0
- package/app/composables/usePdfCompressor.ts +180 -0
- package/app/composables/usePlatformRedirect.ts +72 -0
- package/app/composables/useRealtimeSync.ts +233 -0
- package/app/composables/useReferencedItems.ts +180 -0
- package/app/composables/useScanner.ts +664 -0
- package/app/composables/useSubscription.ts +650 -0
- package/app/composables/useThumbnailCache.ts +136 -0
- package/app/composables/useVideoCompressor.ts +194 -0
- package/app/error.vue +112 -0
- package/app/layouts/api.vue +188 -0
- package/app/layouts/dashboard.vue +91 -0
- package/app/layouts/default.vue +228 -0
- package/app/layouts/table.vue +471 -0
- package/app/locales/ar.ts +996 -0
- package/app/locales/en.ts +655 -0
- package/app/locales/es.ts +996 -0
- package/app/locales/fr.ts +997 -0
- package/app/middleware/dashboard.ts +33 -0
- package/app/middleware/database.ts +83 -0
- package/app/middleware/global.ts +1 -0
- package/app/middleware/sid-ingest.global.ts +29 -0
- package/app/middleware/table.ts +54 -0
- package/app/middleware/user.ts +26 -0
- package/app/pages/[[database]]/admin/api/auth/index.vue +306 -0
- package/app/pages/[[database]]/admin/api/index.vue +18 -0
- package/app/pages/[[database]]/admin/api/tables/[table]/index.vue +497 -0
- package/app/pages/[[database]]/admin/api/tables/index.vue +87 -0
- package/app/pages/[[database]]/admin/billing/index.vue +16 -0
- package/app/pages/[[database]]/admin/dashboards/[id]/edit.vue +66 -0
- package/app/pages/[[database]]/admin/dashboards/[id]/index.vue +117 -0
- package/app/pages/[[database]]/admin/dashboards/index.vue +20 -0
- package/app/pages/[[database]]/admin/index.vue +28 -0
- package/app/pages/[[database]]/admin/settings.vue +526 -0
- package/app/pages/[[database]]/admin/tables/[table]/[id]/edit.vue +143 -0
- package/app/pages/[[database]]/admin/tables/[table]/[id]/index.vue +146 -0
- package/app/pages/[[database]]/admin/tables/[table]/flows.vue +10 -0
- package/app/pages/[[database]]/admin/tables/[table]/index.vue +10 -0
- package/app/pages/[[database]]/admin/tables/[table]/new.vue +12 -0
- package/app/pages/[[database]]/admin/tables/[table]/schedules.vue +10 -0
- package/app/pages/[[database]]/admin/tables/[table]/settings.vue +30 -0
- package/app/pages/[[database]]/admin/tables/assets/[...path].vue +24 -0
- package/app/pages/[[database]]/admin/tables/assets/flows.vue +10 -0
- package/app/pages/[[database]]/admin/tables/backups/index.vue +10 -0
- package/app/pages/[[database]]/admin/tables/blocks/index.vue +16 -0
- package/app/pages/[[database]]/admin/tables/index.vue +20 -0
- package/app/pages/[[database]]/admin/tables/pages/index.vue +16 -0
- package/app/pages/[[database]]/admin/tables/templates/index.vue +16 -0
- package/app/pages/[[database]]/api/index.vue +120 -0
- package/app/pages/[[database]]/auth/index.vue +12 -0
- package/app/pages/[[database]]/auth/reset.vue +10 -0
- package/app/pages/[[database]]/index.vue +10 -0
- package/app/plugins/offline-warmup.client.ts +53 -0
- package/app/plugins/template-globals.ts +9 -0
- package/app/spa-loading-template.html +93 -0
- package/biome.json +53 -0
- package/index.d.ts +380 -0
- package/modules/naiveui.ts +41 -0
- package/nuxt.config.ts +269 -0
- package/package.json +56 -0
- package/pnpm-workspace.yaml +5 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/pwa-192x192.png +0 -0
- package/public/pwa-512x512.png +0 -0
- package/public/pwa-maskable-192x192.png +0 -0
- package/public/pwa-maskable-512x512.png +0 -0
- package/public/vendor/opencv.js +48 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local persistence of the database configuration (metadata + table schemas).
|
|
3
|
+
*
|
|
4
|
+
* The middleware normally loads this from the API (served by the service
|
|
5
|
+
* worker's config cache when offline). Persisting a copy in localStorage adds
|
|
6
|
+
* a second, independent fallback: even when the SW cache is evicted or a
|
|
7
|
+
* partial cache state is served, the app can still boot with the real database
|
|
8
|
+
* config purely from local storage — i.e. the app stays accessible offline.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const STORAGE_KEY_PREFIX = "inicontent:db-config:";
|
|
12
|
+
/** Guard against pathological sizes (schemas can be large, but not this large). */
|
|
13
|
+
const MAX_STORAGE_BYTES = 2_500_000;
|
|
14
|
+
|
|
15
|
+
export function saveDatabaseConfigLocally(slug: string, database?: Database) {
|
|
16
|
+
if (typeof localStorage === "undefined" || !database || !slug) return;
|
|
17
|
+
try {
|
|
18
|
+
const serialized = JSON.stringify(database);
|
|
19
|
+
if (serialized.length > MAX_STORAGE_BYTES) {
|
|
20
|
+
console.warn(
|
|
21
|
+
"[LocalDatabaseConfig] config too large to persist locally:",
|
|
22
|
+
serialized.length,
|
|
23
|
+
);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
localStorage.setItem(STORAGE_KEY_PREFIX + slug, serialized);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.warn("[LocalDatabaseConfig] Failed to persist config:", error);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function loadDatabaseConfigLocally(slug: string): Database | undefined {
|
|
33
|
+
if (typeof localStorage === "undefined" || !slug) return undefined;
|
|
34
|
+
try {
|
|
35
|
+
const raw = localStorage.getItem(STORAGE_KEY_PREFIX + slug);
|
|
36
|
+
if (!raw) return undefined;
|
|
37
|
+
return JSON.parse(raw) as Database;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.warn("[LocalDatabaseConfig] Failed to read config:", error);
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function clearDatabaseConfigLocally(slug: string) {
|
|
45
|
+
if (typeof localStorage === "undefined" || !slug) return;
|
|
46
|
+
try {
|
|
47
|
+
localStorage.removeItem(STORAGE_KEY_PREFIX + slug);
|
|
48
|
+
} catch {
|
|
49
|
+
// ignore
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import type { Worker } from "tesseract.js";
|
|
2
|
+
|
|
3
|
+
/** OCR result for a single word with its bounding box */
|
|
4
|
+
export interface OcrWord {
|
|
5
|
+
text: string;
|
|
6
|
+
confidence: number;
|
|
7
|
+
bbox: { x0: number; y0: number; x1: number; y1: number };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Full OCR recognition result */
|
|
11
|
+
export interface OcrResult {
|
|
12
|
+
text: string;
|
|
13
|
+
confidence: number;
|
|
14
|
+
words: OcrWord[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A block of structured content parsed from OCR text */
|
|
18
|
+
export interface OcrBlock {
|
|
19
|
+
type: "heading" | "paragraph" | "bulletList" | "numberedList";
|
|
20
|
+
level?: 1 | 2;
|
|
21
|
+
items?: string[];
|
|
22
|
+
text?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Languages supported by the platform */
|
|
26
|
+
export type OcrLanguage = "eng" | "ara" | "fra" | "spa";
|
|
27
|
+
|
|
28
|
+
/** Maps UI locale codes to tesseract language codes */
|
|
29
|
+
const LOCALE_MAP: Record<string, OcrLanguage> = {
|
|
30
|
+
en: "eng",
|
|
31
|
+
ar: "ara",
|
|
32
|
+
fr: "fra",
|
|
33
|
+
es: "spa",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* OCR composable powered by tesseract.js.
|
|
38
|
+
*
|
|
39
|
+
* Lazily creates a worker on first recognition and reuses it.
|
|
40
|
+
* Returns structured text + a parseStructure() helper that converts
|
|
41
|
+
* raw OCR output into heading / paragraph / list blocks suitable
|
|
42
|
+
* for Tiptap's insertContent().
|
|
43
|
+
*
|
|
44
|
+
* Designed to be extracted into any Vue 3 project.
|
|
45
|
+
*/
|
|
46
|
+
export function useOcr() {
|
|
47
|
+
let workerPromise: Promise<Worker> | null = null;
|
|
48
|
+
|
|
49
|
+
const loading = ref(false);
|
|
50
|
+
const progress = ref(0);
|
|
51
|
+
const language = ref<OcrLanguage>("eng");
|
|
52
|
+
|
|
53
|
+
/** Set OCR language using a locale code (en, ar, fr, es) and re-init the worker */
|
|
54
|
+
async function setLanguage(locale: string) {
|
|
55
|
+
const next = LOCALE_MAP[locale] ?? "eng";
|
|
56
|
+
if (next === language.value) return;
|
|
57
|
+
language.value = next;
|
|
58
|
+
if (workerPromise) await reinitWorker();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Ensure the tesseract worker is initialised (lazy-loads tesseract.js) */
|
|
62
|
+
async function ensureWorker(): Promise<Worker> {
|
|
63
|
+
if (workerPromise) return workerPromise;
|
|
64
|
+
|
|
65
|
+
workerPromise = (async () => {
|
|
66
|
+
const { createWorker } = await import("tesseract.js");
|
|
67
|
+
const w = await createWorker(language.value, undefined, {
|
|
68
|
+
logger: (m) => {
|
|
69
|
+
if (
|
|
70
|
+
m.status === "recognizing text" &&
|
|
71
|
+
typeof m.progress === "number"
|
|
72
|
+
) {
|
|
73
|
+
progress.value = Math.round(m.progress * 100);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
return w;
|
|
78
|
+
})();
|
|
79
|
+
|
|
80
|
+
return workerPromise;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Re-initialise the worker when the language changes.
|
|
85
|
+
*/
|
|
86
|
+
async function reinitWorker(): Promise<void> {
|
|
87
|
+
if (workerPromise) {
|
|
88
|
+
const old = await workerPromise;
|
|
89
|
+
await old.terminate();
|
|
90
|
+
workerPromise = null;
|
|
91
|
+
}
|
|
92
|
+
await ensureWorker();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Run OCR on an image / canvas / data-URL.
|
|
97
|
+
*
|
|
98
|
+
* @param source - HTMLCanvasElement, HTMLImageElement, HTMLVideoElement, or string URL
|
|
99
|
+
* @returns - Parsed text, confidence, and per-word bounding boxes
|
|
100
|
+
*/
|
|
101
|
+
async function recognize(
|
|
102
|
+
source: CanvasImageSource | string,
|
|
103
|
+
): Promise<OcrResult> {
|
|
104
|
+
loading.value = true;
|
|
105
|
+
progress.value = 0;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const worker = await ensureWorker();
|
|
109
|
+
|
|
110
|
+
const { data } = await worker.recognize(source as any);
|
|
111
|
+
|
|
112
|
+
const words: OcrWord[] = (data.words ?? []).map((w: any) => ({
|
|
113
|
+
text: w.text,
|
|
114
|
+
confidence: w.confidence,
|
|
115
|
+
bbox: w.bbox,
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
text: data.text.trim(),
|
|
120
|
+
confidence: data.confidence,
|
|
121
|
+
words,
|
|
122
|
+
};
|
|
123
|
+
} finally {
|
|
124
|
+
loading.value = false;
|
|
125
|
+
progress.value = 100;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Parse raw OCR text into structured blocks for Tiptap.
|
|
131
|
+
*
|
|
132
|
+
* Detection rules:
|
|
133
|
+
* - ALL-CAPS line (≥3 chars, no digits) → heading (h1 if ≤40 chars, h2 otherwise)
|
|
134
|
+
* - Line starting with `- `, `• `, `* ` → bullet list item
|
|
135
|
+
* - Line starting with `1. `, `2. `, etc. → numbered list item
|
|
136
|
+
* - Consecutive non-empty, non-list, non-heading lines → paragraph
|
|
137
|
+
*/
|
|
138
|
+
function parseStructure(rawText: string): OcrBlock[] {
|
|
139
|
+
const lines = rawText.split("\n");
|
|
140
|
+
const blocks: OcrBlock[] = [];
|
|
141
|
+
|
|
142
|
+
let i = 0;
|
|
143
|
+
|
|
144
|
+
while (i < lines.length) {
|
|
145
|
+
const line = lines[i]!;
|
|
146
|
+
const trimmed = line.trim();
|
|
147
|
+
|
|
148
|
+
// Empty line → skip (paragraph breaks handled implicitly)
|
|
149
|
+
if (!trimmed) {
|
|
150
|
+
i++;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Heading: ALL CAPS, ≥3 chars, no digits, no punctuation-heavy
|
|
155
|
+
if (
|
|
156
|
+
trimmed.length >= 3 &&
|
|
157
|
+
trimmed === trimmed.toUpperCase() &&
|
|
158
|
+
/[A-Z\u0600-\u06FF]/.test(trimmed) &&
|
|
159
|
+
!/^\d/.test(trimmed) &&
|
|
160
|
+
trimmed.length <= 80
|
|
161
|
+
) {
|
|
162
|
+
blocks.push({
|
|
163
|
+
type: "heading",
|
|
164
|
+
level: trimmed.length <= 40 ? 1 : 2,
|
|
165
|
+
text: trimmed,
|
|
166
|
+
});
|
|
167
|
+
i++;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Bullet list
|
|
172
|
+
const bulletMatch = trimmed.match(/^[-•*]\s+(.+)/);
|
|
173
|
+
if (bulletMatch) {
|
|
174
|
+
const items: string[] = [];
|
|
175
|
+
while (i < lines.length) {
|
|
176
|
+
const lm = lines[i]!.trim().match(/^[-•*]\s+(.+)/);
|
|
177
|
+
if (!lm) break;
|
|
178
|
+
items.push(lm[1]!);
|
|
179
|
+
i++;
|
|
180
|
+
}
|
|
181
|
+
blocks.push({ type: "bulletList", items });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Numbered list
|
|
186
|
+
const numMatch = trimmed.match(/^\d+[.)]\s+(.+)/);
|
|
187
|
+
if (numMatch) {
|
|
188
|
+
const items: string[] = [];
|
|
189
|
+
while (i < lines.length) {
|
|
190
|
+
const lm = lines[i]!.trim().match(/^\d+[.)]\s+(.+)/);
|
|
191
|
+
if (!lm) break;
|
|
192
|
+
items.push(lm[1]!);
|
|
193
|
+
i++;
|
|
194
|
+
}
|
|
195
|
+
blocks.push({ type: "numberedList", items });
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Paragraph: collect consecutive non-special lines
|
|
200
|
+
const paraLines: string[] = [];
|
|
201
|
+
while (i < lines.length) {
|
|
202
|
+
const pl = lines[i]!.trim();
|
|
203
|
+
if (!pl) break;
|
|
204
|
+
// Stop if next line is a heading or list start
|
|
205
|
+
if (
|
|
206
|
+
(pl.length >= 3 &&
|
|
207
|
+
pl === pl.toUpperCase() &&
|
|
208
|
+
/[A-Z\u0600-\u06FF]/.test(pl) &&
|
|
209
|
+
!/^\d/.test(pl) &&
|
|
210
|
+
pl.length <= 80) ||
|
|
211
|
+
/^[-•*]\s+/.test(pl) ||
|
|
212
|
+
/^\d+[.)]\s+/.test(pl)
|
|
213
|
+
)
|
|
214
|
+
break;
|
|
215
|
+
paraLines.push(pl);
|
|
216
|
+
i++;
|
|
217
|
+
}
|
|
218
|
+
if (paraLines.length) {
|
|
219
|
+
blocks.push({ type: "paragraph", text: paraLines.join(" ") });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return blocks;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Convert parsed OcrBlocks to Tiptap-compatible JSON for insertContent().
|
|
228
|
+
*/
|
|
229
|
+
function blocksToTiptapJson(blocks: OcrBlock[]): any[] {
|
|
230
|
+
return blocks.map((b) => {
|
|
231
|
+
switch (b.type) {
|
|
232
|
+
case "heading":
|
|
233
|
+
return {
|
|
234
|
+
type: "heading",
|
|
235
|
+
attrs: { level: b.level ?? 1 },
|
|
236
|
+
content: [{ type: "text", text: b.text ?? "" }],
|
|
237
|
+
};
|
|
238
|
+
case "paragraph":
|
|
239
|
+
return {
|
|
240
|
+
type: "paragraph",
|
|
241
|
+
content: [{ type: "text", text: b.text ?? "" }],
|
|
242
|
+
};
|
|
243
|
+
case "bulletList":
|
|
244
|
+
return {
|
|
245
|
+
type: "bulletList",
|
|
246
|
+
content: (b.items ?? []).map((item) => ({
|
|
247
|
+
type: "listItem",
|
|
248
|
+
content: [
|
|
249
|
+
{
|
|
250
|
+
type: "paragraph",
|
|
251
|
+
content: [{ type: "text", text: item }],
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
})),
|
|
255
|
+
};
|
|
256
|
+
case "numberedList":
|
|
257
|
+
return {
|
|
258
|
+
type: "orderedList",
|
|
259
|
+
content: (b.items ?? []).map((item) => ({
|
|
260
|
+
type: "listItem",
|
|
261
|
+
content: [
|
|
262
|
+
{
|
|
263
|
+
type: "paragraph",
|
|
264
|
+
content: [{ type: "text", text: item }],
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
})),
|
|
268
|
+
};
|
|
269
|
+
default:
|
|
270
|
+
return {
|
|
271
|
+
type: "paragraph",
|
|
272
|
+
content: [{ type: "text", text: "" }],
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Terminate the worker and free resources.
|
|
280
|
+
*/
|
|
281
|
+
async function terminate(): Promise<void> {
|
|
282
|
+
if (workerPromise) {
|
|
283
|
+
const w = await workerPromise;
|
|
284
|
+
await w.terminate();
|
|
285
|
+
workerPromise = null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
loading,
|
|
291
|
+
progress,
|
|
292
|
+
language,
|
|
293
|
+
setLanguage,
|
|
294
|
+
recognize,
|
|
295
|
+
parseStructure,
|
|
296
|
+
blocksToTiptapJson,
|
|
297
|
+
reinitWorker,
|
|
298
|
+
terminate,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { enqueueMutation } from "./useOfflineQueue";
|
|
2
|
+
|
|
3
|
+
/** Bound for a single request so offline/hung connections fail fast instead of
|
|
4
|
+
* spinning the loading UI forever. Set high enough that slow-but-working API
|
|
5
|
+
* calls are not mistaken for offline failures. */
|
|
6
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
7
|
+
|
|
8
|
+
type HTTPMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
9
|
+
|
|
10
|
+
type OfflineFetchOptions = {
|
|
11
|
+
/** database slug (used for queue scoping) */
|
|
12
|
+
database?: string;
|
|
13
|
+
/** table slug (used for queue scoping) */
|
|
14
|
+
table?: string;
|
|
15
|
+
/** if true, this is a read-only request and should NOT be queued — it will just throw */
|
|
16
|
+
readonly?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Determine whether a thrown fetch error is caused by a network-level problem
|
|
21
|
+
* (no connectivity) rather than a server/4xx/5xx error that we must propagate.
|
|
22
|
+
*/
|
|
23
|
+
export function isNetworkError(error: unknown): boolean {
|
|
24
|
+
if (!error) return false;
|
|
25
|
+
const e = error as any;
|
|
26
|
+
// ofetch/nuxt fetch errors wrap the cause
|
|
27
|
+
if (e?.cause) {
|
|
28
|
+
if (e.cause.name === "FetchError" || e.cause.name === "TypeError")
|
|
29
|
+
return true;
|
|
30
|
+
if (
|
|
31
|
+
e.cause.code === "ECONNREFUSED" ||
|
|
32
|
+
e.cause.code === "ENOTFOUND" ||
|
|
33
|
+
e.cause.code === "ECONNRESET" ||
|
|
34
|
+
e.cause.code === "UND_ERR_CONNECT_TIMEOUT"
|
|
35
|
+
)
|
|
36
|
+
return true;
|
|
37
|
+
// ofetch request timeout / user abort — treat the same as a network
|
|
38
|
+
// failure so timed-out mutations get queued instead of lost.
|
|
39
|
+
if (e.cause.name === "TimeoutError" || e.cause.name === "AbortError")
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
if (e?.name === "TypeError") return true;
|
|
43
|
+
if (typeof e?.message === "string" && e.message.includes("[TimeoutError]"))
|
|
44
|
+
return true;
|
|
45
|
+
if (e?.code && ["ECONNREFUSED", "ENOTFOUND", "ECONNRESET"].includes(e.code))
|
|
46
|
+
return true;
|
|
47
|
+
// Message-based detection — wrappers on different platforms (Chrome/Safari/
|
|
48
|
+
// ofetch) sometimes only expose the failure through the message text.
|
|
49
|
+
const message = `${e?.message ?? ""} ${e?.cause?.message ?? ""}`;
|
|
50
|
+
if (
|
|
51
|
+
/(Failed to fetch|Load failed|NetworkError|Network request failed|Internet connection appears to be offline|ERR_INTERNET_DISCONNECTED|ERR_NAME_NOT_RESOLVED|ERR_CONNECTION|ERR_NETWORK_|ERR_ABORTED|socket hang up|fetch failed|getaddrinfo|network unreachable)/i.test(
|
|
52
|
+
message,
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
return true;
|
|
56
|
+
// Offline navigator check as a fallback
|
|
57
|
+
if (typeof navigator !== "undefined" && navigator.onLine === false)
|
|
58
|
+
return true;
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A failed request can also be a "junk" response — e.g. the device is on a
|
|
64
|
+
* network that answers every request with an HTML stub (captive portal,
|
|
65
|
+
* offline/local proxy, DNS-hijack page). The request technically got a
|
|
66
|
+
* response, but it's not the JSON the API would return, so it must be treated
|
|
67
|
+
* like a network failure (silent fallback) rather than a real server error.
|
|
68
|
+
*/
|
|
69
|
+
export function isJunkResponse(error: unknown): boolean {
|
|
70
|
+
if (!error) return false;
|
|
71
|
+
const e = error as any;
|
|
72
|
+
// ofetch stores the raw response body in `data` when parsing fails.
|
|
73
|
+
const data = e?.data ?? e?.cause?.data;
|
|
74
|
+
if (typeof data === "string" && /^\s*</.test(data)) return true;
|
|
75
|
+
// The response object (headers) is exposed on the FetchError or its cause.
|
|
76
|
+
const response = e?.response ?? e?.cause?.response;
|
|
77
|
+
if (!response || typeof response !== "object") return false;
|
|
78
|
+
try {
|
|
79
|
+
const contentType =
|
|
80
|
+
typeof response.headers?.get === "function"
|
|
81
|
+
? (response.headers.get("content-type") ?? "")
|
|
82
|
+
: "";
|
|
83
|
+
return /text\/html/i.test(contentType);
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* HTTP methods considered mutations — these get queued when they fail with a
|
|
91
|
+
* network error and the app is offline.
|
|
92
|
+
*/
|
|
93
|
+
const MUTATION_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Offline-aware wrapper around Nuxt `$fetch`.
|
|
97
|
+
*
|
|
98
|
+
* - For GET requests it behaves exactly like $fetch but throws a typed error
|
|
99
|
+
* the caller can catch (so UI still knows it failed).
|
|
100
|
+
* - For mutation requests (POST/PUT/PATCH/DELETE) it enqueues the request into
|
|
101
|
+
* the offline queue when a network error occurs, so changes are not lost and
|
|
102
|
+
* can be synced later.
|
|
103
|
+
*
|
|
104
|
+
* Returns an object describing what happened so callers can react (e.g. show a
|
|
105
|
+
* toast whether the write was queued).
|
|
106
|
+
*/
|
|
107
|
+
export function useOfflineFetch<T = unknown>() {
|
|
108
|
+
const config = useRuntimeConfig();
|
|
109
|
+
const apiBase = config.public.apiBase;
|
|
110
|
+
|
|
111
|
+
function buildUrl(
|
|
112
|
+
path: string,
|
|
113
|
+
localDatabase?: string,
|
|
114
|
+
): { url: string; scopeDatabase: string } {
|
|
115
|
+
// path may be a full URL or a relative API path/full apiBase URL
|
|
116
|
+
if (/^https?:\/\//i.test(path)) {
|
|
117
|
+
return { url: path, scopeDatabase: localDatabase ?? "" };
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
url: `${apiBase}${path}`,
|
|
121
|
+
scopeDatabase: localDatabase ?? "",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Perform an offline-aware request.
|
|
127
|
+
*
|
|
128
|
+
* @param pathOrUrl relative path (e.g. "db/table/123") or full URL
|
|
129
|
+
* @param options ofetch options merged with offline metadata
|
|
130
|
+
*/
|
|
131
|
+
async function request(
|
|
132
|
+
pathOrUrl: string,
|
|
133
|
+
options: {
|
|
134
|
+
method?: string;
|
|
135
|
+
body?: any;
|
|
136
|
+
params?: Record<string, any>;
|
|
137
|
+
query?: Record<string, any>;
|
|
138
|
+
credentials?: RequestCredentials;
|
|
139
|
+
offline?: OfflineFetchOptions;
|
|
140
|
+
[key: string]: any;
|
|
141
|
+
} = {},
|
|
142
|
+
): Promise<T> {
|
|
143
|
+
const method = (options.method ?? "GET").toUpperCase();
|
|
144
|
+
const offline = options.offline ?? {};
|
|
145
|
+
const { url, scopeDatabase } = buildUrl(pathOrUrl, offline.database);
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
return await $fetch<T>(url, {
|
|
149
|
+
...options,
|
|
150
|
+
timeout: options.timeout ?? REQUEST_TIMEOUT_MS,
|
|
151
|
+
} as any);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const isMutation = MUTATION_METHODS.has(method);
|
|
154
|
+
const network = isNetworkError(error);
|
|
155
|
+
|
|
156
|
+
// Reads: just rethrow — UI handles the failure.
|
|
157
|
+
if (!isMutation || offline.readonly) throw error;
|
|
158
|
+
|
|
159
|
+
// Mutations: enqueue when offline / network error, otherwise rethrow
|
|
160
|
+
// (server errors must be surfaced to the user).
|
|
161
|
+
if (network) {
|
|
162
|
+
const queued = await enqueueMutation({
|
|
163
|
+
method: method as Exclude<HTTPMethod, "GET">,
|
|
164
|
+
url,
|
|
165
|
+
body:
|
|
166
|
+
typeof options.body === "string"
|
|
167
|
+
? options.body
|
|
168
|
+
: (options.body ?? null),
|
|
169
|
+
params: options.params ?? options.query,
|
|
170
|
+
database: offline.database ?? scopeDatabase ?? "inicontent",
|
|
171
|
+
table: offline.table ?? "",
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
__offlineQueued: true,
|
|
175
|
+
queuedId: queued.id,
|
|
176
|
+
error,
|
|
177
|
+
} as unknown as T;
|
|
178
|
+
}
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { request, isNetworkError };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Type guard: is this a result that was queued for a later sync instead of being
|
|
188
|
+
* applied against the live server?
|
|
189
|
+
*/
|
|
190
|
+
export function isOfflineQueuedResult<T>(
|
|
191
|
+
value: T | { __offlineQueued: true; queuedId?: string } | null | undefined,
|
|
192
|
+
): value is { __offlineQueued: true; queuedId?: string } {
|
|
193
|
+
return !!value && typeof value === "object" && "__offlineQueued" in value;
|
|
194
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { getMutationsByScope } from "./useOfflineQueue";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a single item when its network fetch fails while offline — used by
|
|
5
|
+
* the item detail and standalone edit pages, which live *outside* the table
|
|
6
|
+
* grid and therefore don't have the row already in memory.
|
|
7
|
+
*
|
|
8
|
+
* Sources, in order:
|
|
9
|
+
* 1. A queued mutation (offline PUT of that exact item) — the user's own
|
|
10
|
+
* latest local edit is returned so the form keeps what they typed.
|
|
11
|
+
* 2. The service worker's runtime caches — either the exact item URL (if it
|
|
12
|
+
* was viewed online before) or any cached *list* response for the table
|
|
13
|
+
* that contains the item (the table view is usually what was cached).
|
|
14
|
+
*
|
|
15
|
+
* Returns undefined when nothing is available (a true offline cold miss).
|
|
16
|
+
*/
|
|
17
|
+
export async function getOfflineItem(
|
|
18
|
+
database: string,
|
|
19
|
+
table: string,
|
|
20
|
+
id: string,
|
|
21
|
+
itemUrl: string,
|
|
22
|
+
): Promise<Item | undefined> {
|
|
23
|
+
try {
|
|
24
|
+
// 1. Queued mutations for this table — a pending PUT of this item wins.
|
|
25
|
+
const mutations = await getMutationsByScope(database, table);
|
|
26
|
+
for (const mutation of mutations) {
|
|
27
|
+
if (mutation.method !== "PUT" && mutation.method !== "POST") continue;
|
|
28
|
+
let mutationId: string | undefined;
|
|
29
|
+
try {
|
|
30
|
+
const pathname = new URL(mutation.url).pathname;
|
|
31
|
+
mutationId = pathname.split("/").filter(Boolean).pop();
|
|
32
|
+
} catch {
|
|
33
|
+
mutationId = undefined;
|
|
34
|
+
}
|
|
35
|
+
if (String(mutationId ?? "") !== String(id)) continue;
|
|
36
|
+
if (mutation.method === "POST") {
|
|
37
|
+
// A POST only matches when its body carried this exact id (rare —
|
|
38
|
+
// offline creates use a synthetic pending__ id).
|
|
39
|
+
if (String((mutation.body as any)?.id ?? "") !== String(id)) continue;
|
|
40
|
+
}
|
|
41
|
+
const body = mutation.body;
|
|
42
|
+
if (body && typeof body === "object" && !Array.isArray(body))
|
|
43
|
+
return { ...(body as Record<string, any>) } as Item;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 2. SW runtime caches.
|
|
47
|
+
if (typeof caches === "undefined") return undefined;
|
|
48
|
+
const cacheNames = await caches.keys();
|
|
49
|
+
for (const cacheName of cacheNames) {
|
|
50
|
+
const cache = await caches.open(cacheName);
|
|
51
|
+
// 2a. Exact cached response for this item URL (ignore the query
|
|
52
|
+
// string — e.g. the `options`/`columns` param — only the path
|
|
53
|
+
// identifies which item this is).
|
|
54
|
+
const exact = await cache.match(itemUrl, { ignoreSearch: true });
|
|
55
|
+
const fromExact = await extractItem(exact, id);
|
|
56
|
+
if (fromExact) return fromExact;
|
|
57
|
+
// 2b. Scan cached list responses for the item (a GET of the table
|
|
58
|
+
// grid is usually what got cached while online).
|
|
59
|
+
const keys = await cache.keys();
|
|
60
|
+
for (const request of keys) {
|
|
61
|
+
const response = await cache.match(request);
|
|
62
|
+
if (!response) continue;
|
|
63
|
+
const found = await extractItemFromList(response, id);
|
|
64
|
+
if (found) return found;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.warn("[OfflineItem] Lookup failed:", error);
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function extractItem(
|
|
74
|
+
response: Response | undefined,
|
|
75
|
+
id: string,
|
|
76
|
+
): Promise<Item | undefined> {
|
|
77
|
+
if (!response) return undefined;
|
|
78
|
+
try {
|
|
79
|
+
const json = await response.json();
|
|
80
|
+
const item = json?.result;
|
|
81
|
+
if (
|
|
82
|
+
item &&
|
|
83
|
+
typeof item === "object" &&
|
|
84
|
+
String(item.id ?? "") === String(id)
|
|
85
|
+
)
|
|
86
|
+
return item as Item;
|
|
87
|
+
} catch {
|
|
88
|
+
// not JSON — skip
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function extractItemFromList(
|
|
94
|
+
response: Response,
|
|
95
|
+
id: string,
|
|
96
|
+
): Promise<Item | undefined> {
|
|
97
|
+
try {
|
|
98
|
+
const json = await response.json();
|
|
99
|
+
const list = Array.isArray(json?.result)
|
|
100
|
+
? (json.result as Item[])
|
|
101
|
+
: undefined;
|
|
102
|
+
if (!list) return undefined;
|
|
103
|
+
return list.find((item) => String(item?.id ?? "") === String(id));
|
|
104
|
+
} catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|