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.
Files changed (205) hide show
  1. package/.github/pull_request.yml +18 -0
  2. package/CONTRIBUTING.md +67 -0
  3. package/LICENSE +21 -0
  4. package/README.md +142 -0
  5. package/app/app.vue +106 -0
  6. package/app/assets/main.css +974 -0
  7. package/app/components/Api/EndpointCard.vue +169 -0
  8. package/app/components/Api/SchemaDocsTable.vue +208 -0
  9. package/app/components/Asset/Card.vue +1363 -0
  10. package/app/components/Asset/Grid.vue +295 -0
  11. package/app/components/Asset/Icon.vue +38 -0
  12. package/app/components/Asset/Preview.vue +486 -0
  13. package/app/components/Asset/Scanner.vue +700 -0
  14. package/app/components/Asset/Thumb.vue +708 -0
  15. package/app/components/Auth/Reset.vue +99 -0
  16. package/app/components/Auth/index.vue +346 -0
  17. package/app/components/ChatInterface.vue +2326 -0
  18. package/app/components/ChatPagePreview.vue +34 -0
  19. package/app/components/Column/Array.vue +29 -0
  20. package/app/components/Column/Asset.vue +21 -0
  21. package/app/components/Column/Boolean.vue +12 -0
  22. package/app/components/Column/Color.vue +9 -0
  23. package/app/components/Column/Date.vue +57 -0
  24. package/app/components/Column/Edit.vue +215 -0
  25. package/app/components/Column/Email.vue +9 -0
  26. package/app/components/Column/Html.vue +27 -0
  27. package/app/components/Column/Id.vue +20 -0
  28. package/app/components/Column/Locale.vue +32 -0
  29. package/app/components/Column/Object.vue +18 -0
  30. package/app/components/Column/Password.vue +7 -0
  31. package/app/components/Column/Role.vue +15 -0
  32. package/app/components/Column/S.vue +14 -0
  33. package/app/components/Column/Table/Single.vue +49 -0
  34. package/app/components/Column/Table/index.vue +42 -0
  35. package/app/components/Column/Tags.vue +31 -0
  36. package/app/components/Column/Text.vue +9 -0
  37. package/app/components/Column/Url.vue +9 -0
  38. package/app/components/Column/index.vue +41 -0
  39. package/app/components/Dashboard/Editor.vue +221 -0
  40. package/app/components/Dashboard/Grid.vue +270 -0
  41. package/app/components/Dashboard/View.vue +131 -0
  42. package/app/components/Dashboard/Widget/BarChart.vue +73 -0
  43. package/app/components/Dashboard/Widget/Counter.vue +51 -0
  44. package/app/components/Dashboard/Widget/LineChart.vue +76 -0
  45. package/app/components/Dashboard/Widget/PieChart.vue +75 -0
  46. package/app/components/Dashboard/Widget/Table.vue +94 -0
  47. package/app/components/Dashboard/WidgetEditor.vue +274 -0
  48. package/app/components/Data/Array.vue +102 -0
  49. package/app/components/Data/Html.vue +9 -0
  50. package/app/components/Data/Icon.vue +9 -0
  51. package/app/components/Data/Object.vue +10 -0
  52. package/app/components/Data/S.vue +16 -0
  53. package/app/components/Data/Table.vue +53 -0
  54. package/app/components/Data/Value.vue +34 -0
  55. package/app/components/Data/index.vue +14 -0
  56. package/app/components/Field/Array.vue +324 -0
  57. package/app/components/Field/Asset/Actions.vue +97 -0
  58. package/app/components/Field/Asset/index.vue +639 -0
  59. package/app/components/Field/Boolean.vue +26 -0
  60. package/app/components/Field/Checkbox.vue +48 -0
  61. package/app/components/Field/Color.vue +27 -0
  62. package/app/components/Field/Date.vue +30 -0
  63. package/app/components/Field/Email.vue +51 -0
  64. package/app/components/Field/EmailTemplate.vue +420 -0
  65. package/app/components/Field/Html/ColorPicker.vue +127 -0
  66. package/app/components/Field/Html/Editor.vue +544 -0
  67. package/app/components/Field/Html/index.vue +25 -0
  68. package/app/components/Field/Icon.vue +114 -0
  69. package/app/components/Field/JSON.vue +113 -0
  70. package/app/components/Field/Mention.vue +36 -0
  71. package/app/components/Field/Number.vue +27 -0
  72. package/app/components/Field/Object.vue +92 -0
  73. package/app/components/Field/Password.vue +55 -0
  74. package/app/components/Field/Radio.vue +47 -0
  75. package/app/components/Field/S.vue +44 -0
  76. package/app/components/Field/Select.vue +49 -0
  77. package/app/components/Field/Table.vue +348 -0
  78. package/app/components/Field/Tags.vue +113 -0
  79. package/app/components/Field/Text.vue +31 -0
  80. package/app/components/Field/Textarea.vue +33 -0
  81. package/app/components/Field/Url.vue +40 -0
  82. package/app/components/Field/Wrapper.vue +128 -0
  83. package/app/components/Field/index.vue +68 -0
  84. package/app/components/FloatingChatbot.vue +73 -0
  85. package/app/components/Form/Card.vue +151 -0
  86. package/app/components/Form/Drawer.vue +195 -0
  87. package/app/components/Form/index.vue +621 -0
  88. package/app/components/Header.vue +328 -0
  89. package/app/components/Offline/ConflictModal.vue +200 -0
  90. package/app/components/Offline/SyncStatus.vue +249 -0
  91. package/app/components/PlatformFeatureLanding.vue +65 -0
  92. package/app/components/Table/Backups.vue +638 -0
  93. package/app/components/Table/Flows.vue +814 -0
  94. package/app/components/Table/Grid.vue +296 -0
  95. package/app/components/Table/Icon.vue +36 -0
  96. package/app/components/Table/Logs.vue +181 -0
  97. package/app/components/Table/Schedules.vue +722 -0
  98. package/app/components/Table/Search/Button.vue +256 -0
  99. package/app/components/Table/Search/Items.vue +286 -0
  100. package/app/components/Table/Search/index.vue +87 -0
  101. package/app/components/Table/Settings/Schema.vue +801 -0
  102. package/app/components/Table/Settings/index.vue +631 -0
  103. package/app/components/Table/TranslateDrawer.vue +526 -0
  104. package/app/components/Table/Views/Kanban.vue +289 -0
  105. package/app/components/Table/Views/Table.vue +1273 -0
  106. package/app/components/Table/index.vue +890 -0
  107. package/app/composables/Translation/define.ts +41 -0
  108. package/app/composables/Translation/fetch.ts +107 -0
  109. package/app/composables/Translation/languages.ts +1 -0
  110. package/app/composables/Translation/loadCoreTranslations.ts +38 -0
  111. package/app/composables/Translation/translate.ts +250 -0
  112. package/app/composables/databaseCookies.ts +79 -0
  113. package/app/composables/fieldValidator.ts +72 -0
  114. package/app/composables/fieldsList.ts +301 -0
  115. package/app/composables/formatDatabase.ts +101 -0
  116. package/app/composables/index.ts +444 -0
  117. package/app/composables/openDrawer.ts +118 -0
  118. package/app/composables/optimizeFile.ts +287 -0
  119. package/app/composables/renderLabel.ts +88 -0
  120. package/app/composables/schemaClipboard.ts +95 -0
  121. package/app/composables/search.ts +353 -0
  122. package/app/composables/setThemeConfig.ts +48 -0
  123. package/app/composables/translationValue.ts +169 -0
  124. package/app/composables/useAssetPreview.ts +84 -0
  125. package/app/composables/useAssetUploader.ts +56 -0
  126. package/app/composables/useDashboardData.ts +310 -0
  127. package/app/composables/useDocumentThumbnail.ts +351 -0
  128. package/app/composables/useEcharts.ts +33 -0
  129. package/app/composables/useLocalDatabaseConfig.ts +51 -0
  130. package/app/composables/useOcr.ts +300 -0
  131. package/app/composables/useOfflineFetch.ts +194 -0
  132. package/app/composables/useOfflineItem.ts +107 -0
  133. package/app/composables/useOfflineQueue.ts +302 -0
  134. package/app/composables/useOfflineSync.ts +235 -0
  135. package/app/composables/usePasskeyAuth.ts +156 -0
  136. package/app/composables/usePdfCompressor.ts +180 -0
  137. package/app/composables/usePlatformRedirect.ts +72 -0
  138. package/app/composables/useRealtimeSync.ts +233 -0
  139. package/app/composables/useReferencedItems.ts +180 -0
  140. package/app/composables/useScanner.ts +664 -0
  141. package/app/composables/useSubscription.ts +650 -0
  142. package/app/composables/useThumbnailCache.ts +136 -0
  143. package/app/composables/useVideoCompressor.ts +194 -0
  144. package/app/error.vue +112 -0
  145. package/app/layouts/api.vue +188 -0
  146. package/app/layouts/dashboard.vue +91 -0
  147. package/app/layouts/default.vue +228 -0
  148. package/app/layouts/table.vue +471 -0
  149. package/app/locales/ar.ts +996 -0
  150. package/app/locales/en.ts +655 -0
  151. package/app/locales/es.ts +996 -0
  152. package/app/locales/fr.ts +997 -0
  153. package/app/middleware/dashboard.ts +33 -0
  154. package/app/middleware/database.ts +83 -0
  155. package/app/middleware/global.ts +1 -0
  156. package/app/middleware/sid-ingest.global.ts +29 -0
  157. package/app/middleware/table.ts +54 -0
  158. package/app/middleware/user.ts +26 -0
  159. package/app/pages/[[database]]/admin/api/auth/index.vue +306 -0
  160. package/app/pages/[[database]]/admin/api/index.vue +18 -0
  161. package/app/pages/[[database]]/admin/api/tables/[table]/index.vue +497 -0
  162. package/app/pages/[[database]]/admin/api/tables/index.vue +87 -0
  163. package/app/pages/[[database]]/admin/billing/index.vue +16 -0
  164. package/app/pages/[[database]]/admin/dashboards/[id]/edit.vue +66 -0
  165. package/app/pages/[[database]]/admin/dashboards/[id]/index.vue +117 -0
  166. package/app/pages/[[database]]/admin/dashboards/index.vue +20 -0
  167. package/app/pages/[[database]]/admin/index.vue +28 -0
  168. package/app/pages/[[database]]/admin/settings.vue +526 -0
  169. package/app/pages/[[database]]/admin/tables/[table]/[id]/edit.vue +143 -0
  170. package/app/pages/[[database]]/admin/tables/[table]/[id]/index.vue +146 -0
  171. package/app/pages/[[database]]/admin/tables/[table]/flows.vue +10 -0
  172. package/app/pages/[[database]]/admin/tables/[table]/index.vue +10 -0
  173. package/app/pages/[[database]]/admin/tables/[table]/new.vue +12 -0
  174. package/app/pages/[[database]]/admin/tables/[table]/schedules.vue +10 -0
  175. package/app/pages/[[database]]/admin/tables/[table]/settings.vue +30 -0
  176. package/app/pages/[[database]]/admin/tables/assets/[...path].vue +24 -0
  177. package/app/pages/[[database]]/admin/tables/assets/flows.vue +10 -0
  178. package/app/pages/[[database]]/admin/tables/backups/index.vue +10 -0
  179. package/app/pages/[[database]]/admin/tables/blocks/index.vue +16 -0
  180. package/app/pages/[[database]]/admin/tables/index.vue +20 -0
  181. package/app/pages/[[database]]/admin/tables/pages/index.vue +16 -0
  182. package/app/pages/[[database]]/admin/tables/templates/index.vue +16 -0
  183. package/app/pages/[[database]]/api/index.vue +120 -0
  184. package/app/pages/[[database]]/auth/index.vue +12 -0
  185. package/app/pages/[[database]]/auth/reset.vue +10 -0
  186. package/app/pages/[[database]]/index.vue +10 -0
  187. package/app/plugins/offline-warmup.client.ts +53 -0
  188. package/app/plugins/template-globals.ts +9 -0
  189. package/app/spa-loading-template.html +93 -0
  190. package/biome.json +53 -0
  191. package/index.d.ts +380 -0
  192. package/modules/naiveui.ts +41 -0
  193. package/nuxt.config.ts +269 -0
  194. package/package.json +56 -0
  195. package/pnpm-workspace.yaml +5 -0
  196. package/public/apple-touch-icon.png +0 -0
  197. package/public/favicon-16x16.png +0 -0
  198. package/public/favicon-32x32.png +0 -0
  199. package/public/favicon.ico +0 -0
  200. package/public/pwa-192x192.png +0 -0
  201. package/public/pwa-512x512.png +0 -0
  202. package/public/pwa-maskable-192x192.png +0 -0
  203. package/public/pwa-maskable-512x512.png +0 -0
  204. package/public/vendor/opencv.js +48 -0
  205. 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
+ }