@cavulsqa/create 2.9.3 → 2.10.1
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/README.md +12 -1
- package/bin/create.mjs +18 -25
- package/lib/templates.mjs +56 -0
- package/package.json +13 -5
- package/templates/f7-app/auto-imports.d.ts +9 -0
- package/templates/f7-app/package.json +6 -3
- package/templates/f7-app/src/env.d.ts +1 -0
- package/templates/f7-app/src/main.ts +3 -0
- package/templates/f7-app/src/plugins/bootstrapError.ts +2 -0
- package/templates/f7-app/src/plugins/recorder.plugin.ts +155 -0
- package/templates/f7-app/src/shared/recorder/capacitorSink.ts +124 -0
- package/templates/f7-app/vite.config.ts +1 -0
- package/templates/m3e-app/.claude/rules/data-fetching.md +68 -0
- package/templates/m3e-app/.claude/rules/database.md +106 -0
- package/templates/m3e-app/.claude/rules/m3e-ui.md +48 -0
- package/templates/m3e-app/.claude/rules/modules.md +43 -0
- package/templates/m3e-app/.claude/rules/native.md +60 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/SKILL.md +68 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/color.md +44 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/components.md +325 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/layout.md +42 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/motion.md +51 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/shapes-type.md +48 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/sources.md +53 -0
- package/templates/m3e-app/.claude/skills/module-architecture/SKILL.md +65 -0
- package/templates/m3e-app/.claude/skills/module-architecture/file-templates.md +178 -0
- package/templates/m3e-app/.claude/skills/reactive-data/SKILL.md +90 -0
- package/templates/m3e-app/.claude/skills/reactive-data/testing.md +55 -0
- package/templates/m3e-app/.env.example +32 -0
- package/templates/m3e-app/CLAUDE.md +93 -0
- package/templates/m3e-app/auto-imports.d.ts +851 -0
- package/templates/m3e-app/capacitor.config.ts +44 -0
- package/templates/m3e-app/components.d.ts +252 -0
- package/templates/m3e-app/index.html +16 -0
- package/templates/m3e-app/package.json +108 -0
- package/templates/m3e-app/src/App.vue +129 -0
- package/templates/m3e-app/src/app/pragmas.config.ts +35 -0
- package/templates/m3e-app/src/app/scroll.config.ts +20 -0
- package/templates/m3e-app/src/app/storage.config.ts +65 -0
- package/templates/m3e-app/src/app/tabs.ts +33 -0
- package/templates/m3e-app/src/app/theme.config.ts +51 -0
- package/templates/m3e-app/src/assets/css/app.css +18 -0
- package/templates/m3e-app/src/assets/css/base.css +53 -0
- package/templates/m3e-app/src/assets/css/layout/container-transform.css +113 -0
- package/templates/m3e-app/src/assets/css/layout/shell.css +69 -0
- package/templates/m3e-app/src/assets/css/layout/transitions.css +121 -0
- package/templates/m3e-app/src/assets/css/m3e.css +6 -0
- package/templates/m3e-app/src/assets/css/theme/framework7.css +18 -0
- package/templates/m3e-app/src/assets/css/theme/tailwind.css +365 -0
- package/templates/m3e-app/src/domains/benchmark/benchmark.dataset.ts +209 -0
- package/templates/m3e-app/src/domains/benchmark/benchmark.suite.ts +662 -0
- package/templates/m3e-app/src/domains/sales/sales.repository.ts +458 -0
- package/templates/m3e-app/src/env.d.ts +40 -0
- package/templates/m3e-app/src/locales/ar.json +1260 -0
- package/templates/m3e-app/src/locales/en.json +1260 -0
- package/templates/m3e-app/src/locales/fr.json +1260 -0
- package/templates/m3e-app/src/main.ts +41 -0
- package/templates/m3e-app/src/modules/demo/components/DemoBenchmark.vue +119 -0
- package/templates/m3e-app/src/modules/demo/components/DemoBusLog.vue +63 -0
- package/templates/m3e-app/src/modules/demo/components/DemoCreateOrderSheet.vue +203 -0
- package/templates/m3e-app/src/modules/demo/components/DemoMetricsSheet.vue +75 -0
- package/templates/m3e-app/src/modules/demo/components/DemoOrderList.vue +75 -0
- package/templates/m3e-app/src/modules/demo/components/DemoPipelineBenchmark.vue +65 -0
- package/templates/m3e-app/src/modules/demo/components/DemoStatCards.vue +71 -0
- package/templates/m3e-app/src/modules/demo/composables/useBenchmark.ts +139 -0
- package/templates/m3e-app/src/modules/demo/composables/useOrderStatus.ts +64 -0
- package/templates/m3e-app/src/modules/demo/composables/useReactiveDemo.ts +171 -0
- package/templates/m3e-app/src/modules/demo/router/routes/demo.routes.ts +22 -0
- package/templates/m3e-app/src/modules/demo/views/DemoView.vue +210 -0
- package/templates/m3e-app/src/modules/demo/views/OrderDetailView.vue +136 -0
- package/templates/m3e-app/src/modules/demo/views/OrderSearchView.vue +74 -0
- package/templates/m3e-app/src/modules/gallery/components/GalleryBlock.vue +16 -0
- package/templates/m3e-app/src/modules/gallery/components/GalleryCarouselTile.vue +71 -0
- package/templates/m3e-app/src/modules/gallery/components/GalleryCustomerForm.vue +76 -0
- package/templates/m3e-app/src/modules/gallery/components/GalleryProofOfDelivery.vue +69 -0
- package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaDay.vue +106 -0
- package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaMonth.vue +40 -0
- package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaVisitList.vue +45 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatAttachSheet.vue +106 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatContactPicker.vue +85 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatInviteComposer.vue +131 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatPollComposer.vue +148 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatRecentPhotos.vue +62 -0
- package/templates/m3e-app/src/modules/gallery/components/inputs/InputsAccount.vue +79 -0
- package/templates/m3e-app/src/modules/gallery/components/inputs/InputsTags.vue +53 -0
- package/templates/m3e-app/src/modules/gallery/components/inputs/InputsVerification.vue +73 -0
- package/templates/m3e-app/src/modules/gallery/components/inputs/PasswordStrength.vue +37 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryButtons.vue +128 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryCarousels.vue +236 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryCharts.vue +84 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryFabs.vue +70 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryInputs.vue +219 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryNavigation.vue +141 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryOverlays.vue +368 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryPickers.vue +175 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryProgress.vue +118 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryScale.vue +103 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GallerySelection.vue +219 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryShapes.vue +48 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GallerySurfaces.vue +213 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryTables.vue +185 -0
- package/templates/m3e-app/src/modules/gallery/components/surfaces/SurfacesContainerTransform.vue +46 -0
- package/templates/m3e-app/src/modules/gallery/composables/chatTeam.ts +18 -0
- package/templates/m3e-app/src/modules/gallery/composables/composeKind.ts +2 -0
- package/templates/m3e-app/src/modules/gallery/composables/currentPosition.ts +35 -0
- package/templates/m3e-app/src/modules/gallery/composables/demoOrders.ts +29 -0
- package/templates/m3e-app/src/modules/gallery/composables/featuredStories.ts +73 -0
- package/templates/m3e-app/src/modules/gallery/composables/fieldFormats.ts +36 -0
- package/templates/m3e-app/src/modules/gallery/composables/openExternal.ts +11 -0
- package/templates/m3e-app/src/modules/gallery/composables/routePlan.ts +90 -0
- package/templates/m3e-app/src/modules/gallery/composables/useAgenda.ts +61 -0
- package/templates/m3e-app/src/modules/gallery/composables/useChatCustomers.ts +31 -0
- package/templates/m3e-app/src/modules/gallery/composables/useChatDemo.ts +299 -0
- package/templates/m3e-app/src/modules/gallery/composables/useGallerySections.ts +230 -0
- package/templates/m3e-app/src/modules/gallery/composables/useLocalAttachments.ts +71 -0
- package/templates/m3e-app/src/modules/gallery/composables/useLocationShare.ts +38 -0
- package/templates/m3e-app/src/modules/gallery/composables/usePhotoScenes.ts +82 -0
- package/templates/m3e-app/src/modules/gallery/composables/useTreeDemo.ts +67 -0
- package/templates/m3e-app/src/modules/gallery/composables/wilayas.ts +67 -0
- package/templates/m3e-app/src/modules/gallery/router/routes/gallery.routes.ts +52 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryAgendaView.vue +117 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryChatView.vue +292 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryContactsView.vue +77 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryFeaturedView.vue +60 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryLoginView.vue +135 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryOnboardingView.vue +116 -0
- package/templates/m3e-app/src/modules/gallery/views/GallerySectionView.vue +27 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryTabsView.vue +135 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryView.vue +50 -0
- package/templates/m3e-app/src/modules/home/components/HomeHero.vue +57 -0
- package/templates/m3e-app/src/modules/home/composables/useHomeFeatures.ts +128 -0
- package/templates/m3e-app/src/modules/home/composables/useOpenFeature.ts +22 -0
- package/templates/m3e-app/src/modules/home/router/routes/home.routes.ts +21 -0
- package/templates/m3e-app/src/modules/home/views/FeatureDetailView.vue +66 -0
- package/templates/m3e-app/src/modules/home/views/HomeView.vue +47 -0
- package/templates/m3e-app/src/modules/settings/components/RolePalette.vue +38 -0
- package/templates/m3e-app/src/modules/settings/components/SeedSwatches.vue +41 -0
- package/templates/m3e-app/src/modules/settings/components/SettingsChoice.vue +43 -0
- package/templates/m3e-app/src/modules/settings/components/StudioPreview.vue +52 -0
- package/templates/m3e-app/src/modules/settings/router/routes/settings.routes.ts +17 -0
- package/templates/m3e-app/src/modules/settings/types.ts +7 -0
- package/templates/m3e-app/src/modules/settings/views/ColorStudioView.vue +166 -0
- package/templates/m3e-app/src/modules/settings/views/SettingsView.vue +157 -0
- package/templates/m3e-app/src/plugins/bootstrapError.ts +62 -0
- package/templates/m3e-app/src/plugins/capacitor/index.ts +14 -0
- package/templates/m3e-app/src/plugins/capacitor/useAndroidBackButton.ts +36 -0
- package/templates/m3e-app/src/plugins/capacitor/useKeyboard.ts +48 -0
- package/templates/m3e-app/src/plugins/capacitor/useSplashScreen.ts +43 -0
- package/templates/m3e-app/src/plugins/capacitor/useStatusBar.ts +16 -0
- package/templates/m3e-app/src/plugins/framework7.plugin.ts +36 -0
- package/templates/m3e-app/src/plugins/i18n.plugin.ts +87 -0
- package/templates/m3e-app/src/plugins/m3e.plugin.ts +22 -0
- package/templates/m3e-app/src/plugins/seed.plugin.ts +7 -0
- package/templates/m3e-app/src/plugins/sqlite.plugin.ts +32 -0
- package/templates/m3e-app/src/router/global/global.routes.ts +12 -0
- package/templates/m3e-app/src/router/index.ts +20 -0
- package/templates/m3e-app/src/shared/components/error/404.vue +13 -0
- package/templates/m3e-app/src/shared/components/layout/EmptyState.vue +30 -0
- package/templates/m3e-app/src/shared/components/layout/SectionHeader.vue +10 -0
- package/templates/m3e-app/src/shared/components/page/AppPage.vue +104 -0
- package/templates/m3e-app/src/shared/composables/navigation/useActiveTab.ts +39 -0
- package/templates/m3e-app/src/shared/composables/navigation/useContainerTransform.ts +117 -0
- package/templates/m3e-app/src/shared/composables/navigation/useNavigationGuard.ts +48 -0
- package/templates/m3e-app/src/shared/composables/navigation/useNavigationVisibility.ts +64 -0
- package/templates/m3e-app/src/shared/composables/navigation/useViewRouter.ts +25 -0
- package/templates/m3e-app/src/shared/composables/navigation/useWindowClass.ts +22 -0
- package/templates/m3e-app/src/shared/composables/theme/useThemeSettings.ts +155 -0
- package/templates/m3e-app/src/shared/database/candidates/capacitorSqlite.ts +36 -0
- package/templates/m3e-app/src/shared/database/candidates/index.ts +4 -0
- package/templates/m3e-app/src/shared/database/candidates/opfsSahPool.ts +25 -0
- package/templates/m3e-app/src/shared/database/candidates/types.ts +56 -0
- package/templates/m3e-app/src/shared/database/candidates/waSqlite.ts +56 -0
- package/templates/m3e-app/src/shared/database/database.ts +219 -0
- package/templates/m3e-app/src/shared/database/index.ts +3 -0
- package/templates/m3e-app/src/shared/database/migrations.ts +223 -0
- package/templates/m3e-app/src/shared/database/opfs.worker.ts +5 -0
- package/templates/m3e-app/src/shared/database/queries.ts +26 -0
- package/templates/m3e-app/src/shared/database/schema.ts +153 -0
- package/templates/m3e-app/src/shared/database/storage.ts +64 -0
- package/templates/m3e-app/src/shared/database/wa.worker.ts +5 -0
- package/templates/m3e-app/src/shared/utils/lazyRoute.ts +41 -0
- package/templates/m3e-app/src/shared/utils/resolvers/resolvers.ts +42 -0
- package/templates/m3e-app/src/shared/utils/textDirection.ts +22 -0
- package/templates/m3e-app/src/shared/utils/theme/themeSettings.ts +59 -0
- package/templates/m3e-app/src/shared/utils/tone.ts +8 -0
- package/templates/m3e-app/tests/benchmark.suite.test.ts +104 -0
- package/templates/m3e-app/tests/containerTransform.test.ts +58 -0
- package/templates/m3e-app/tests/fieldFormats.test.ts +25 -0
- package/templates/m3e-app/tests/lazyRoute.test.ts +30 -0
- package/templates/m3e-app/tests/locales.test.ts +73 -0
- package/templates/m3e-app/tests/migrations.test.ts +74 -0
- package/templates/m3e-app/tests/navigationGuard.test.ts +82 -0
- package/templates/m3e-app/tests/openDatabase.test.ts +54 -0
- package/templates/m3e-app/tests/sales.repository.test.ts +329 -0
- package/templates/m3e-app/tests/storage.test.ts +117 -0
- package/templates/m3e-app/tests/textDirection.test.ts +11 -0
- package/templates/m3e-app/tooling/linkedPackages.ts +112 -0
- package/templates/m3e-app/tsconfig.json +21 -0
- package/templates/m3e-app/vite.config.ts +144 -0
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ pnpm create @cavulsqa --name caputa --app-id com.example.caputa --yes
|
|
|
29
29
|
| flag | default | |
|
|
30
30
|
| ------------ | ---------------- | ------------------------------------------------------- |
|
|
31
31
|
| `--name` | asked | package and directory name |
|
|
32
|
-
| `--template` |
|
|
32
|
+
| `--template` | `f7-app` | `f7-app` or `m3e-app`; asked from a numbered menu |
|
|
33
33
|
| `--dir` | `./<name>` | where to write it |
|
|
34
34
|
| `--app-name` | `Name` | launcher name, window title, Settings screen |
|
|
35
35
|
| `--app-id` | `com.ayb.<name>` | Android application id |
|
|
@@ -40,6 +40,13 @@ pnpm create @cavulsqa --name caputa --app-id com.example.caputa --yes
|
|
|
40
40
|
|
|
41
41
|
## What you get
|
|
42
42
|
|
|
43
|
+
Two templates, one data layer:
|
|
44
|
+
|
|
45
|
+
| Template | Look and feel |
|
|
46
|
+
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
47
|
+
| `f7-app` | Framework7's own components, native on iOS and on Material (`theme: auto`) |
|
|
48
|
+
| `m3e-app` | Material 3 Expressive: `@cavulsqa/m3e-vue` components on 2025 dynamic colour, Google Sans Flex, spring motion, a colour studio and a component gallery. Framework7 stays underneath as the navigation engine only, so it is Android-first by design |
|
|
49
|
+
|
|
43
50
|
The `f7-app` template: a tabbed shell, a `domains` / `modules` / `shared` layout, and a working
|
|
44
51
|
sales demo over a six-table schema — dashboard aggregates, search, an order sheet, swipe actions,
|
|
45
52
|
a detail screen — with tests against real SQLite.
|
|
@@ -49,6 +56,10 @@ under Kysely, `@cavulsqa/reactive-db` for the change bus, and `@cavulsqa/reactiv
|
|
|
49
56
|
`useReactiveQuery`. A write announces the tables it touched and every query watching them refetches;
|
|
50
57
|
nothing in a screen asks for a refresh.
|
|
51
58
|
|
|
59
|
+
`m3e-app` has the same layout and data layer, with every screen built from `M3*` components and an
|
|
60
|
+
`m3-expressive` skill that carries the design system: which component, which colour role, which
|
|
61
|
+
spring, and a link to the spec page for each.
|
|
62
|
+
|
|
52
63
|
`CLAUDE.md`, `.claude/rules/` and `.claude/skills/` come with it, so an agent opening the generated
|
|
53
64
|
repository knows the architecture, the conventions, and the traps that have already cost someone a
|
|
54
65
|
day.
|
package/bin/create.mjs
CHANGED
|
@@ -7,11 +7,17 @@
|
|
|
7
7
|
* silent default, because a generated app named "my-app" in the wrong directory is worse than a
|
|
8
8
|
* failed command.
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
11
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
12
12
|
import { createInterface } from "node:readline/promises";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { scaffold } from "../lib/scaffold.mjs";
|
|
15
|
+
import {
|
|
16
|
+
defaultTemplate,
|
|
17
|
+
listTemplates,
|
|
18
|
+
resolveTemplateChoice,
|
|
19
|
+
templateMenu,
|
|
20
|
+
} from "../lib/templates.mjs";
|
|
15
21
|
|
|
16
22
|
const PACKAGE = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
17
23
|
const BUNDLED = join(PACKAGE, "templates");
|
|
@@ -38,7 +44,7 @@ function usage() {
|
|
|
38
44
|
|
|
39
45
|
Options:
|
|
40
46
|
--name NAME package and directory name
|
|
41
|
-
--template NAME which template (default: the
|
|
47
|
+
--template NAME which template (default: the manifest's default, or you are asked)
|
|
42
48
|
--dir PATH where to write it (default: ./<name>)
|
|
43
49
|
--app-name NAME launcher name and window title (default: Name)
|
|
44
50
|
--app-id ID android application id (default: com.ayb.<name>)
|
|
@@ -49,24 +55,7 @@ Options:
|
|
|
49
55
|
--help this
|
|
50
56
|
|
|
51
57
|
Templates bundled in this package:
|
|
52
|
-
${listTemplates()
|
|
53
|
-
.map((entry) => ` ${entry.name} ${entry.description}`)
|
|
54
|
-
.join("\n")}`);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function listTemplates() {
|
|
58
|
-
const manifest = JSON.parse(readFileSync(join(PACKAGE, "package.json"), "utf8"));
|
|
59
|
-
const declared = manifest.createConfig?.templates ?? [];
|
|
60
|
-
if (!existsSync(BUNDLED)) return declared;
|
|
61
|
-
|
|
62
|
-
// The manifest is what `vp create` reads; the directory is what actually shipped. Trust the
|
|
63
|
-
// directory, and let the manifest supply the descriptions.
|
|
64
|
-
return readdirSync(BUNDLED, { withFileTypes: true })
|
|
65
|
-
.filter((entry) => entry.isDirectory())
|
|
66
|
-
.map((entry) => ({
|
|
67
|
-
name: entry.name,
|
|
68
|
-
description: declared.find((listed) => listed.name === entry.name)?.description ?? "",
|
|
69
|
-
}));
|
|
58
|
+
${templateMenu(listTemplates(PACKAGE))}`);
|
|
70
59
|
}
|
|
71
60
|
|
|
72
61
|
/**
|
|
@@ -109,15 +98,19 @@ async function main() {
|
|
|
109
98
|
throw new Error(`"${String(name)}" is not a usable package name`);
|
|
110
99
|
}
|
|
111
100
|
|
|
112
|
-
const templates = listTemplates();
|
|
101
|
+
const templates = listTemplates(PACKAGE);
|
|
113
102
|
if (!templates.length) throw new Error("this build of @cavulsqa/create bundles no templates");
|
|
114
103
|
|
|
115
104
|
let template = flags.get("template");
|
|
116
105
|
if (typeof template !== "string") {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
106
|
+
const fallback = defaultTemplate(templates);
|
|
107
|
+
if (templates.length === 1 || !rl) {
|
|
108
|
+
template = fallback.name;
|
|
109
|
+
} else {
|
|
110
|
+
console.log(`\nTemplates:\n${templateMenu(templates)}\n`);
|
|
111
|
+
const answer = await ask("Template", String(templates.indexOf(fallback) + 1));
|
|
112
|
+
template = resolveTemplateChoice(answer, templates) ?? answer;
|
|
113
|
+
}
|
|
121
114
|
}
|
|
122
115
|
if (!templates.some((entry) => entry.name === template)) {
|
|
123
116
|
throw new Error(
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The templates this build offers, in the manifest's order, with the manifest's descriptions.
|
|
6
|
+
*
|
|
7
|
+
* The manifest is what `vp create` reads; the directory is what actually shipped. A template is
|
|
8
|
+
* offered only when both agree, and a shipped directory the manifest forgot still appears - last,
|
|
9
|
+
* without a description - rather than silently vanishing.
|
|
10
|
+
*/
|
|
11
|
+
export function listTemplates(packageDir) {
|
|
12
|
+
const manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
|
|
13
|
+
const declared = manifest.createConfig?.templates ?? [];
|
|
14
|
+
const bundled = join(packageDir, "templates");
|
|
15
|
+
if (!existsSync(bundled)) return declared.map(toEntry);
|
|
16
|
+
|
|
17
|
+
const shipped = new Set(
|
|
18
|
+
readdirSync(bundled, { withFileTypes: true })
|
|
19
|
+
.filter((entry) => entry.isDirectory())
|
|
20
|
+
.map((entry) => entry.name),
|
|
21
|
+
);
|
|
22
|
+
const listed = declared.filter((entry) => shipped.has(entry.name)).map(toEntry);
|
|
23
|
+
const unlisted = [...shipped]
|
|
24
|
+
.filter((name) => !declared.some((entry) => entry.name === name))
|
|
25
|
+
.sort((a, b) => a.localeCompare(b))
|
|
26
|
+
.map((name) => toEntry({ name }));
|
|
27
|
+
return [...listed, ...unlisted];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function toEntry(entry) {
|
|
31
|
+
return {
|
|
32
|
+
name: entry.name,
|
|
33
|
+
description: entry.description ?? "",
|
|
34
|
+
isDefault: entry.default === true,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The one marked `default` in the manifest, else the first - never whatever readdir returns first. */
|
|
39
|
+
export function defaultTemplate(templates) {
|
|
40
|
+
return templates.find((entry) => entry.isDefault) ?? templates[0];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A typed answer as a template: its number in the list, or its name. Null when it is neither. */
|
|
44
|
+
export function resolveTemplateChoice(answer, templates) {
|
|
45
|
+
const value = String(answer).trim();
|
|
46
|
+
if (/^\d+$/.test(value)) return templates[Number(value) - 1]?.name ?? null;
|
|
47
|
+
return templates.some((entry) => entry.name === value) ? value : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The numbered menu the interactive prompt prints. */
|
|
51
|
+
export function templateMenu(templates) {
|
|
52
|
+
const width = Math.max(...templates.map((entry) => entry.name.length));
|
|
53
|
+
return templates
|
|
54
|
+
.map((entry, index) => ` ${index + 1}) ${entry.name.padEnd(width)} ${entry.description}`)
|
|
55
|
+
.join("\n");
|
|
56
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cavulsqa/create",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Create a Vue +
|
|
3
|
+
"version": "2.10.1",
|
|
4
|
+
"description": "Create a Vue + Capacitor + SQLite app from the cavulsqa templates: Framework7 (iOS and Material) or Material 3 Expressive.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"capacitor",
|
|
7
7
|
"create",
|
|
8
8
|
"framework7",
|
|
9
|
+
"material-3",
|
|
10
|
+
"material-expressive",
|
|
9
11
|
"scaffold",
|
|
10
12
|
"sqlite",
|
|
11
13
|
"template",
|
|
@@ -47,12 +49,18 @@
|
|
|
47
49
|
"templates": [
|
|
48
50
|
{
|
|
49
51
|
"name": "f7-app",
|
|
50
|
-
"description": "Vue 3 + Framework7 + Capacitor + SQLite, tabbed shell, offline-first data layer",
|
|
51
|
-
"template": "./templates/f7-app"
|
|
52
|
+
"description": "Vue 3 + Framework7 + Capacitor + SQLite, tabbed shell, iOS and Material themes, offline-first data layer",
|
|
53
|
+
"template": "./templates/f7-app",
|
|
54
|
+
"default": true
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"name": "m3e-app",
|
|
58
|
+
"description": "Material 3 Expressive: m3e-vue components, 2025 dynamic colour with a colour studio, adaptive bar/rail shell, offline-first data layer",
|
|
59
|
+
"template": "./templates/m3e-app"
|
|
52
60
|
}
|
|
53
61
|
]
|
|
54
62
|
},
|
|
55
|
-
"templatesFingerprint": "sha256-
|
|
63
|
+
"templatesFingerprint": "sha256-rGqnILJ0mL3Tjp8wsotbcajPFpJWqAEtS9G80QBcA/Q=",
|
|
56
64
|
"scripts": {
|
|
57
65
|
"build": "node scripts/bundleTemplates.mjs",
|
|
58
66
|
"check": "vp check",
|
|
@@ -98,6 +98,8 @@ declare global {
|
|
|
98
98
|
const reactiveOmit: typeof import('@vueuse/core').reactiveOmit
|
|
99
99
|
const reactivePick: typeof import('@vueuse/core').reactivePick
|
|
100
100
|
const readonly: typeof import('vue').readonly
|
|
101
|
+
const recorderDiagnostics: typeof import('./src/plugins/recorder.plugin').recorderDiagnostics
|
|
102
|
+
const recorderPlugin: typeof import('./src/plugins/recorder.plugin').recorderPlugin
|
|
101
103
|
const ref: typeof import('vue').ref
|
|
102
104
|
const refAutoReset: typeof import('@vueuse/core').refAutoReset
|
|
103
105
|
const refDebounced: typeof import('@vueuse/core').refDebounced
|
|
@@ -106,6 +108,7 @@ declare global {
|
|
|
106
108
|
const refThrottled: typeof import('@vueuse/core').refThrottled
|
|
107
109
|
const refWithControl: typeof import('@vueuse/core').refWithControl
|
|
108
110
|
const renderBootstrapError: typeof import('./src/plugins/bootstrapError').renderBootstrapError
|
|
111
|
+
const reportProblem: typeof import('./src/plugins/recorder.plugin').reportProblem
|
|
109
112
|
const request: typeof import('framework7/lite').request
|
|
110
113
|
const resolveComponent: typeof import('vue').resolveComponent
|
|
111
114
|
const seedPlugin: typeof import('./src/plugins/seed.plugin').seedPlugin
|
|
@@ -341,6 +344,9 @@ declare global {
|
|
|
341
344
|
export type { TabbarVisibility } from './src/shared/composables/useTabbarVisibility'
|
|
342
345
|
import('./src/shared/composables/useTabbarVisibility')
|
|
343
346
|
// @ts-ignore
|
|
347
|
+
export type { RecorderBackend, RecorderDiagnostics } from './src/plugins/recorder.plugin'
|
|
348
|
+
import('./src/plugins/recorder.plugin')
|
|
349
|
+
// @ts-ignore
|
|
344
350
|
export type { CaseComparison } from './src/modules/demo/composables/useBenchmark'
|
|
345
351
|
import('./src/modules/demo/composables/useBenchmark')
|
|
346
352
|
// @ts-ignore
|
|
@@ -446,6 +452,8 @@ declare module 'vue' {
|
|
|
446
452
|
readonly reactiveOmit: UnwrapRef<typeof import('@vueuse/core')['reactiveOmit']>
|
|
447
453
|
readonly reactivePick: UnwrapRef<typeof import('@vueuse/core')['reactivePick']>
|
|
448
454
|
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
|
|
455
|
+
readonly recorderDiagnostics: UnwrapRef<typeof import('./src/plugins/recorder.plugin')['recorderDiagnostics']>
|
|
456
|
+
readonly recorderPlugin: UnwrapRef<typeof import('./src/plugins/recorder.plugin')['recorderPlugin']>
|
|
449
457
|
readonly ref: UnwrapRef<typeof import('vue')['ref']>
|
|
450
458
|
readonly refAutoReset: UnwrapRef<typeof import('@vueuse/core')['refAutoReset']>
|
|
451
459
|
readonly refDebounced: UnwrapRef<typeof import('@vueuse/core')['refDebounced']>
|
|
@@ -454,6 +462,7 @@ declare module 'vue' {
|
|
|
454
462
|
readonly refThrottled: UnwrapRef<typeof import('@vueuse/core')['refThrottled']>
|
|
455
463
|
readonly refWithControl: UnwrapRef<typeof import('@vueuse/core')['refWithControl']>
|
|
456
464
|
readonly renderBootstrapError: UnwrapRef<typeof import('./src/plugins/bootstrapError')['renderBootstrapError']>
|
|
465
|
+
readonly reportProblem: UnwrapRef<typeof import('./src/plugins/recorder.plugin')['reportProblem']>
|
|
457
466
|
readonly request: UnwrapRef<typeof import('framework7/lite')['request']>
|
|
458
467
|
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
|
|
459
468
|
readonly seedPlugin: UnwrapRef<typeof import('./src/plugins/seed.plugin')['seedPlugin']>
|
|
@@ -16,13 +16,16 @@
|
|
|
16
16
|
"@capacitor/android": "^8.5.0",
|
|
17
17
|
"@capacitor/app": "^8.0.0",
|
|
18
18
|
"@capacitor/core": "^8.0.0",
|
|
19
|
+
"@capacitor/filesystem": "^8.0.0",
|
|
19
20
|
"@capacitor/keyboard": "^8.0.0",
|
|
20
21
|
"@capacitor/preferences": "^8.0.0",
|
|
22
|
+
"@capacitor/share": "^8.0.0",
|
|
21
23
|
"@capacitor/splash-screen": "^8.0.0",
|
|
22
24
|
"@capacitor/status-bar": "^8.0.0",
|
|
23
|
-
"@cavulsqa/mobile-db": "^1.
|
|
24
|
-
"@cavulsqa/reactive-db": "^1.
|
|
25
|
-
"@cavulsqa/reactive-vue": "^1.
|
|
25
|
+
"@cavulsqa/mobile-db": "^1.4.0",
|
|
26
|
+
"@cavulsqa/reactive-db": "^1.4.0",
|
|
27
|
+
"@cavulsqa/reactive-vue": "^1.4.0",
|
|
28
|
+
"@cavulsqa/recorder": "^1.4.0",
|
|
26
29
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
27
30
|
"@tailwindcss/vite": "^4.3.3",
|
|
28
31
|
"@vueuse/core": "^14.4.0",
|
|
@@ -9,6 +9,7 @@ import "./assets/css/app.css";
|
|
|
9
9
|
import App from "./App.vue";
|
|
10
10
|
import { i18n } from "./plugins/i18n.plugin";
|
|
11
11
|
import { renderBootstrapError } from "./plugins/bootstrapError";
|
|
12
|
+
import { recorderPlugin } from "./plugins/recorder.plugin";
|
|
12
13
|
import { seedPlugin } from "./plugins/seed.plugin";
|
|
13
14
|
import { sqlitePlugin } from "./plugins/sqlite.plugin";
|
|
14
15
|
|
|
@@ -31,6 +32,8 @@ async function bootstrap(): Promise<void> {
|
|
|
31
32
|
return;
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
void recorderPlugin();
|
|
36
|
+
|
|
34
37
|
// Components arrive through Framework7VueResolver, so nothing is registered by hand.
|
|
35
38
|
const app = createApp(App);
|
|
36
39
|
app.use(i18n);
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
webviewLikelyTooOld,
|
|
4
4
|
webviewVersion,
|
|
5
5
|
} from "@/shared/database/storage";
|
|
6
|
+
import { hideSplashScreen } from "@/plugins/capacitor/useSplashScreen";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The screen shown when the app cannot start, which in practice means the database did not open.
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
* nothing they could act on.
|
|
16
17
|
*/
|
|
17
18
|
export function renderBootstrapError(error: unknown): void {
|
|
19
|
+
hideSplashScreen();
|
|
18
20
|
const root = document.getElementById("app");
|
|
19
21
|
if (!root) return;
|
|
20
22
|
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createConsoleTrack,
|
|
3
|
+
createDatabasesTrack,
|
|
4
|
+
createNetworkTrack,
|
|
5
|
+
createPerfTrack,
|
|
6
|
+
createRecorder,
|
|
7
|
+
createRrwebTrack,
|
|
8
|
+
type Recorder,
|
|
9
|
+
type TrackContext,
|
|
10
|
+
type TrackRecorder,
|
|
11
|
+
} from "@cavulsqa/recorder";
|
|
12
|
+
import { changeBus } from "@/shared/database/database";
|
|
13
|
+
import { createCapacitorSink } from "@/shared/recorder/capacitorSink";
|
|
14
|
+
|
|
15
|
+
const RRWEB_VERIFY_WINDOW_MS = 2000;
|
|
16
|
+
|
|
17
|
+
export type RecorderBackend = "memory" | "opfs" | null;
|
|
18
|
+
|
|
19
|
+
export interface RecorderDiagnostics {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
recording: boolean;
|
|
22
|
+
backend: RecorderBackend;
|
|
23
|
+
rrwebAvailable: boolean;
|
|
24
|
+
lastError: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const recorderDiagnostics = ref<RecorderDiagnostics>({
|
|
28
|
+
enabled: false,
|
|
29
|
+
recording: false,
|
|
30
|
+
backend: null,
|
|
31
|
+
rrwebAvailable: true,
|
|
32
|
+
lastError: null,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
let recorder: Recorder | null = null;
|
|
36
|
+
|
|
37
|
+
function resolveFieldRecorderMode(): "on" | "off" {
|
|
38
|
+
const raw = import.meta.env.VITE_FIELD_RECORDER;
|
|
39
|
+
if (raw === "on" || raw === "off") return raw;
|
|
40
|
+
return import.meta.env.DEV ? "on" : "off";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isCapuError(data: unknown): boolean {
|
|
44
|
+
return typeof data === "object" && data !== null && "__capuError" in data;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Wraps `createRrwebTrack` with a runtime check: rrweb's published package resolves to an empty
|
|
49
|
+
* module outside a bundler, and a `record()` call against that module fails silently inside the
|
|
50
|
+
* track's own try/catch, so the recorder would otherwise report a healthy `rrweb` track that never
|
|
51
|
+
* emitted a frame. This watches the first events pushed and flips `rrwebAvailable` to `false` if
|
|
52
|
+
* nothing but a `__capuError` payload arrives within {@link RRWEB_VERIFY_WINDOW_MS}.
|
|
53
|
+
*/
|
|
54
|
+
function createVerifiedRrwebTrack(): TrackRecorder {
|
|
55
|
+
const inner = createRrwebTrack();
|
|
56
|
+
let sawRealFrame = false;
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
name: "rrweb",
|
|
60
|
+
async start(ctx: TrackContext<unknown>) {
|
|
61
|
+
const wrapped: TrackContext<unknown> = {
|
|
62
|
+
...ctx,
|
|
63
|
+
push(data, wallMs) {
|
|
64
|
+
if (!sawRealFrame && !isCapuError(data)) sawRealFrame = true;
|
|
65
|
+
ctx.push(data, wallMs);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
await inner.start(wrapped);
|
|
69
|
+
setTimeout(() => {
|
|
70
|
+
recorderDiagnostics.value = { ...recorderDiagnostics.value, rrwebAvailable: sawRealFrame };
|
|
71
|
+
if (!sawRealFrame) {
|
|
72
|
+
console.error(
|
|
73
|
+
"[recorder] rrweb produced no frames; recording is running without screen replay.",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}, RRWEB_VERIFY_WINDOW_MS);
|
|
77
|
+
},
|
|
78
|
+
async stop() {
|
|
79
|
+
await inner.stop();
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildTracks(): TrackRecorder[] {
|
|
85
|
+
return [
|
|
86
|
+
createVerifiedRrwebTrack(),
|
|
87
|
+
createConsoleTrack(),
|
|
88
|
+
createNetworkTrack(),
|
|
89
|
+
createPerfTrack(),
|
|
90
|
+
createDatabasesTrack({ changeBus }),
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function syncDiagnostics(): void {
|
|
95
|
+
if (!recorder) return;
|
|
96
|
+
const status = recorder.status;
|
|
97
|
+
recorderDiagnostics.value = {
|
|
98
|
+
...recorderDiagnostics.value,
|
|
99
|
+
recording: status.recording,
|
|
100
|
+
backend: status.backend,
|
|
101
|
+
lastError: status.lastError,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Builds and starts the field recorder, gated by `VITE_FIELD_RECORDER` (`"on"` or `"off"`,
|
|
107
|
+
* defaulting to `on` in dev and `off` in a production build). Recovers a leftover session from a
|
|
108
|
+
* previous run before starting a new one. Never throws: every failure lands in
|
|
109
|
+
* {@link recorderDiagnostics}.`lastError` instead, so a broken recorder can never block `main.ts`
|
|
110
|
+
* from mounting the app.
|
|
111
|
+
*/
|
|
112
|
+
export async function recorderPlugin(): Promise<void> {
|
|
113
|
+
const mode = resolveFieldRecorderMode();
|
|
114
|
+
recorderDiagnostics.value = { ...recorderDiagnostics.value, enabled: mode === "on" };
|
|
115
|
+
if (mode === "off") return;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
recorder = createRecorder({
|
|
119
|
+
tracks: buildTracks(),
|
|
120
|
+
sink: createCapacitorSink(),
|
|
121
|
+
label: "field recording",
|
|
122
|
+
appId: __APP_NAME__,
|
|
123
|
+
appVersion: __APP_VERSION__,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await recorder.recover();
|
|
127
|
+
await recorder.start();
|
|
128
|
+
syncDiagnostics();
|
|
129
|
+
} catch (error) {
|
|
130
|
+
recorderDiagnostics.value = {
|
|
131
|
+
...recorderDiagnostics.value,
|
|
132
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Finalizes the current buffer into a `.capu` and hands it to the sink without stopping the
|
|
139
|
+
* recorder, for the Settings "Report a problem" action. Returns `true` when the capture reached
|
|
140
|
+
* the sink with no error.
|
|
141
|
+
*/
|
|
142
|
+
export async function reportProblem(): Promise<boolean> {
|
|
143
|
+
if (!recorder) return false;
|
|
144
|
+
try {
|
|
145
|
+
await recorder.capture("user report");
|
|
146
|
+
} catch (error) {
|
|
147
|
+
recorderDiagnostics.value = {
|
|
148
|
+
...recorderDiagnostics.value,
|
|
149
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
150
|
+
};
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
syncDiagnostics();
|
|
154
|
+
return recorderDiagnostics.value.lastError === null;
|
|
155
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { Capacitor } from "@capacitor/core";
|
|
2
|
+
import { Directory, Filesystem } from "@capacitor/filesystem";
|
|
3
|
+
import { Share } from "@capacitor/share";
|
|
4
|
+
import type { RecorderSink, SessionManifest } from "@cavulsqa/recorder";
|
|
5
|
+
|
|
6
|
+
const CAPTURE_DIR = "capu";
|
|
7
|
+
const BASE64_CHUNK = 0x8000;
|
|
8
|
+
|
|
9
|
+
export interface CaptureFile {
|
|
10
|
+
name: string;
|
|
11
|
+
size: number;
|
|
12
|
+
modifiedAt: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface BrowserCapture {
|
|
16
|
+
name: string;
|
|
17
|
+
blob: Blob;
|
|
18
|
+
size: number;
|
|
19
|
+
savedAt: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const browserCaptures = new Map<string, BrowserCapture>();
|
|
23
|
+
|
|
24
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
25
|
+
let binary = "";
|
|
26
|
+
for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK) {
|
|
27
|
+
const chunk = bytes.subarray(offset, offset + BASE64_CHUNK);
|
|
28
|
+
binary += String.fromCharCode(...chunk);
|
|
29
|
+
}
|
|
30
|
+
return btoa(binary);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fileNameFor(manifest: SessionManifest): string {
|
|
34
|
+
return `${manifest.sessionId}-${String(Date.now())}.capu`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function downloadBlob(name: string, blob: Blob): void {
|
|
38
|
+
const url = URL.createObjectURL(blob);
|
|
39
|
+
const link = document.createElement("a");
|
|
40
|
+
link.href = url;
|
|
41
|
+
link.download = name;
|
|
42
|
+
document.body.appendChild(link);
|
|
43
|
+
link.click();
|
|
44
|
+
link.remove();
|
|
45
|
+
URL.revokeObjectURL(url);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function saveNative(archive: Uint8Array, manifest: SessionManifest): Promise<void> {
|
|
49
|
+
const path = `${CAPTURE_DIR}/${fileNameFor(manifest)}`;
|
|
50
|
+
await Filesystem.writeFile({
|
|
51
|
+
path,
|
|
52
|
+
data: bytesToBase64(archive),
|
|
53
|
+
directory: Directory.External,
|
|
54
|
+
recursive: true,
|
|
55
|
+
});
|
|
56
|
+
const { uri } = await Filesystem.getUri({ path, directory: Directory.External });
|
|
57
|
+
await Share.share({ url: uri, title: manifest.label });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function saveBrowser(archive: Uint8Array, manifest: SessionManifest): void {
|
|
61
|
+
const name = fileNameFor(manifest);
|
|
62
|
+
const blob = new Blob([archive as Uint8Array<ArrayBuffer>], { type: "application/zip" });
|
|
63
|
+
browserCaptures.set(name, { name, blob, size: blob.size, savedAt: Date.now() });
|
|
64
|
+
downloadBlob(name, blob);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* On native, writes a finished `.capu` under `capu/<sessionId>-<ts>.capu` in the app's external
|
|
69
|
+
* storage and opens the Android share sheet. Off-device (`vp dev` in a browser) it triggers a blob
|
|
70
|
+
* download instead, since `Filesystem`/`Share` have nothing to write to there.
|
|
71
|
+
*/
|
|
72
|
+
export function createCapacitorSink(): RecorderSink {
|
|
73
|
+
return {
|
|
74
|
+
async save(archive, manifest) {
|
|
75
|
+
if (Capacitor.isNativePlatform()) {
|
|
76
|
+
await saveNative(archive, manifest);
|
|
77
|
+
} else {
|
|
78
|
+
saveBrowser(archive, manifest);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Lists previously captured `.capu` files for the Settings diagnostics list, newest first. */
|
|
85
|
+
export async function listCaptures(): Promise<CaptureFile[]> {
|
|
86
|
+
if (Capacitor.isNativePlatform()) {
|
|
87
|
+
try {
|
|
88
|
+
const result = await Filesystem.readdir({
|
|
89
|
+
path: CAPTURE_DIR,
|
|
90
|
+
directory: Directory.External,
|
|
91
|
+
});
|
|
92
|
+
return result.files
|
|
93
|
+
.filter((file) => file.type === "file")
|
|
94
|
+
.map((file) => ({ name: file.name, size: file.size, modifiedAt: file.mtime }))
|
|
95
|
+
.sort((a, b) => b.modifiedAt - a.modifiedAt);
|
|
96
|
+
} catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return [...browserCaptures.values()]
|
|
101
|
+
.map((capture) => ({ name: capture.name, size: capture.size, modifiedAt: capture.savedAt }))
|
|
102
|
+
.sort((a, b) => b.modifiedAt - a.modifiedAt);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Re-opens the Android share sheet for a previously captured file; on browser, re-triggers the download. */
|
|
106
|
+
export async function shareCapture(name: string): Promise<void> {
|
|
107
|
+
if (Capacitor.isNativePlatform()) {
|
|
108
|
+
const path = `${CAPTURE_DIR}/${name}`;
|
|
109
|
+
const { uri } = await Filesystem.getUri({ path, directory: Directory.External });
|
|
110
|
+
await Share.share({ url: uri });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const capture = browserCaptures.get(name);
|
|
114
|
+
if (capture) downloadBlob(name, capture.blob);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Permanently deletes a previously captured file. */
|
|
118
|
+
export async function deleteCapture(name: string): Promise<void> {
|
|
119
|
+
if (Capacitor.isNativePlatform()) {
|
|
120
|
+
await Filesystem.deleteFile({ path: `${CAPTURE_DIR}/${name}`, directory: Directory.External });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
browserCaptures.delete(name);
|
|
124
|
+
}
|
|
@@ -52,6 +52,7 @@ export default defineConfig({
|
|
|
52
52
|
*/
|
|
53
53
|
AutoImport({
|
|
54
54
|
include: [/\.[tj]sx?$/, /\.vue$/, /\.vue\?vue/],
|
|
55
|
+
exclude: [/[\\/]node_modules[\\/]/, /[\\/]\.git[\\/]/, /[\\/]dist[\\/]/],
|
|
55
56
|
imports: ["vue", "vue-i18n", "@vueuse/core", getFramework7AutoImports()],
|
|
56
57
|
dirs: [
|
|
57
58
|
"src/shared/composables/**",
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Reading and writing data
|
|
2
|
+
|
|
3
|
+
Every read is a reactive query or a deliberate on-demand call. Every write goes through `rdb`. A
|
|
4
|
+
third shape — a hand-rolled cache, or a raw write — produces "stale screen, no error, no symptom",
|
|
5
|
+
which is the one failure an offline app cannot afford: the user has no network to blame.
|
|
6
|
+
|
|
7
|
+
## Reads
|
|
8
|
+
|
|
9
|
+
`useReactiveQuery(fn, { tables, queryKey, debounce })` from `@/shared/database/queries`.
|
|
10
|
+
|
|
11
|
+
- **`tables` is the invalidation contract.** It must list exactly the tables `fn` reads — no more,
|
|
12
|
+
no less. Under-list and a write to the missing table leaves the screen stale with no error.
|
|
13
|
+
Over-list and an unrelated write re-runs an expensive query for nothing.
|
|
14
|
+
|
|
15
|
+
A join means every joined table. `loadOrderDetail` reads the order, its lines, the customer, the
|
|
16
|
+
product names and the customer's tags, so it lists all five. Count the tables in the SQL, not the
|
|
17
|
+
ones you were thinking about.
|
|
18
|
+
|
|
19
|
+
- **`queryKey` is a process-wide identity built from arguments, not a label.** It is an array, and
|
|
20
|
+
two mounted queries whose keys match await one request and share its result. Framework7 keeps
|
|
21
|
+
pages mounted, so two instances of one screen genuinely coexist — which is exactly why the key
|
|
22
|
+
has to carry what distinguishes them: `["demo:order", orderId]`, never `["demo:order"]`.
|
|
23
|
+
|
|
24
|
+
Put every value the query reads in the key. A ref belongs there directly — `["demo:search", term]`
|
|
25
|
+
— and the query re-runs through its own `debounce` when the ref moves, so a filtered screen never
|
|
26
|
+
calls `refetch()` by hand.
|
|
27
|
+
|
|
28
|
+
- **`debounce`** collapses a burst. A loop of twenty inserts should refetch once, not twenty times.
|
|
29
|
+
|
|
30
|
+
- Reads may take the database directly: `searchOrders(getDatabase().db, term)`. Repositories take
|
|
31
|
+
the database as a parameter rather than reaching for the singleton, which is what makes them
|
|
32
|
+
testable — `tests/sales.repository.test.ts` runs them against sql.js.
|
|
33
|
+
|
|
34
|
+
## Writes
|
|
35
|
+
|
|
36
|
+
Always `rdb`, never `getDatabase().db`:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
await saveOrder(rdb, { customerId, reference, lines }); // announces sales_order, order_line
|
|
40
|
+
await saveOrder(getDatabase().db, …); // writes, and nothing notices
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`rdb` wraps Kysely so a mutation publishes the tables it touched on the change bus. A raw write
|
|
44
|
+
lands in SQLite and no query hears about it, so the screen keeps showing the old rows until
|
|
45
|
+
something unrelated triggers a refetch. There is no error and nothing to see in review.
|
|
46
|
+
|
|
47
|
+
Anything that must be all-or-nothing goes in one transaction. `saveOrder` writes the order and its
|
|
48
|
+
lines together because a half-written order is worse than no order.
|
|
49
|
+
|
|
50
|
+
## Forbidden
|
|
51
|
+
|
|
52
|
+
A module-scope `Map` or `ref` holding fetched rows with neither a TTL nor a bus subscription. Once
|
|
53
|
+
populated it never refreshes for the session, and the next write is invisible until restart.
|
|
54
|
+
In-flight dedup (a `Map<key, Promise>` cleared in `finally`) is concurrency control, not a cache,
|
|
55
|
+
and is fine — `useReactiveQuery` already does it.
|
|
56
|
+
|
|
57
|
+
## Seed and demo helpers
|
|
58
|
+
|
|
59
|
+
Anything a person can press twice must survive being pressed twice. `seedSampleData` inserts the
|
|
60
|
+
catalogue only where missing and upserts tags, because a plain insert threw
|
|
61
|
+
`UNIQUE constraint failed: tag.label` on the second press.
|
|
62
|
+
|
|
63
|
+
## Proof obligations
|
|
64
|
+
|
|
65
|
+
- A new query: state which tables its SQL touches and that `tables` matches.
|
|
66
|
+
- A new write: state that it goes through `rdb`, and whether it needs a transaction.
|
|
67
|
+
- Either: a test in `tests/` exercising it against sql.js. Type-checking a query proves nothing
|
|
68
|
+
about what it returns.
|