diff --git a/apps/desktop/main/datastore.ts b/apps/desktop/main/datastore.ts index d9bf34c8..dd9a3233 100644 --- a/apps/desktop/main/datastore.ts +++ b/apps/desktop/main/datastore.ts @@ -3408,6 +3408,64 @@ export function addItem(type: ItemType, options: ItemOptions = {}): { id: string return { id: itemId }; } +/** + * Insert a LOCAL-ONLY onboarding/demo seed item and link its tags. + * + * Distinct from addItem because a seed needs two things a normal create + * can't express: + * - an explicit createdAt, so the seeded set keeps its authored order in the + * recent-sorted view (addItem always stamps now()); + * - a metadata.seedSource marker, which keeps the item out of sync push + * (packages/sync excludes seedSource items, same as browser-imports) so + * demo content stays on this device and never reaches the server. + * + * Does NOT record a visit, so seed items start at frecency 0 and the user's + * own activity naturally sorts above them. Unlike addItem it does not add + * device metadata (a seed is not a user authoring action). + */ +export function insertSeedItem( + type: ItemType, + opts: { content: string; metadata?: Record; tags?: string[]; createdAt: number } +): { id: string } { + const itemId = generateId('item'); + const metadata: Record = { ...(opts.metadata ?? {}), seedSource: 'onboarding' }; + + // Mirror addItem's url handling: derive domain + title column for url items. + let title = ''; + let domain = ''; + if (type === 'url' && opts.content) { + try { + domain = new URL(opts.content).hostname; + } catch { /* not a valid URL */ } + if (metadata.title) title = String(metadata.title); + } + + getDb().prepare(` + INSERT INTO items (id, type, content, mimeType, metadata, syncId, createdAt, updatedAt, deletedAt, starred, archived, title, domain) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?) + `).run( + itemId, + type, + opts.content ?? null, + '', + JSON.stringify(metadata), + '', + opts.createdAt, + opts.createdAt, + 0, + 0, + title, + domain + ); + + for (const name of opts.tags ?? []) { + const { tag } = getOrCreateTag(name); + tagItem(itemId, tag.id); + } + + return { id: itemId }; +} + /** * Get an item by ID */ diff --git a/apps/desktop/main/main.ts b/apps/desktop/main/main.ts index 089ac2bb..8db42e60 100644 --- a/apps/desktop/main/main.ts +++ b/apps/desktop/main/main.ts @@ -13,6 +13,7 @@ import fs from 'node:fs'; import { pathToFileURL } from 'node:url'; import { initDatabase, closeDatabase, getDb, getContextEntry, normalizeUrl } from './datastore.js'; +import { seedOnboardingIfNeeded } from './onboarding-seed.js'; import { installDebugFlagsPersistence } from './debug-flag-store.js'; import { registerScheme, initProtocol, registerTilePath, getTilePath, getRegisteredTileIds, registerThemePath, getRegisteredThemeIds } from './protocol.js'; import { initCore, getCoreBackgroundWindow } from './core-glue.js'; @@ -418,6 +419,12 @@ export async function initialize(): Promise { const dbPath = path.join(config.userDataPath, config.profile, 'datastore.sqlite'); initDatabase(dbPath); + // First-launch onboarding: seed demo content into a brand-new profile so the + // empty-state teaches the surface. No-op after the first run and for existing + // profiles. Runs before any window/pubsub — the fresh UI reads the seeded DB + // when it first renders, so nothing needs to be broadcast. + seedOnboardingIfNeeded(); + // Hydrate runtime debug flags from persisted state. Must run after // initDatabase so feature_settings is readable; before any feature loads // so the flags are correct from the first DEBUG && console.log call. diff --git a/apps/desktop/main/onboarding-seed.test.ts b/apps/desktop/main/onboarding-seed.test.ts new file mode 100644 index 00000000..0a35b80e --- /dev/null +++ b/apps/desktop/main/onboarding-seed.test.ts @@ -0,0 +1,101 @@ +/** + * Unit tests for the first-launch onboarding seeder. + * + * Runs against the compiled datastore (dist/main) with a temporary DB, like + * datastore.test.ts. Covers: fresh-profile seeding + local-only marker + tag, + * authored ordering, idempotency, and the "never seed a non-empty profile" + * guard. + */ + +import { describe, it, before, after, beforeEach } from 'node:test'; +import * as assert from 'node:assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const TEST_DB_PATH = path.join(__dirname, 'test-onboarding-seed.db'); + +let datastore: typeof import('./datastore.js'); +let seed: typeof import('./onboarding-seed.js'); +let fixture: typeof import('../onboarding/fixture.js'); + +function cleanupDb() { + for (const p of [TEST_DB_PATH, TEST_DB_PATH + '-wal', TEST_DB_PATH + '-shm']) { + if (fs.existsSync(p)) fs.unlinkSync(p); + } +} + +function itemCount(): number { + return (datastore.getDb().prepare('SELECT COUNT(*) AS n FROM items').get() as { n: number }).n; +} + +describe('Onboarding seeder', () => { + before(async () => { + datastore = await import('./datastore.js'); + seed = await import('./onboarding-seed.js'); + fixture = await import('../onboarding/fixture.js'); + }); + + beforeEach(() => { + datastore.closeDatabase(); + cleanupDb(); + datastore.initDatabase(TEST_DB_PATH); + }); + + after(() => { + datastore.closeDatabase(); + cleanupDb(); + }); + + it('seeds the fixture into a fresh profile, each item local-only and tagged', () => { + seed.seedOnboardingIfNeeded(); + + assert.strictEqual(itemCount(), fixture.FIXTURE.length, 'one item per fixture entry'); + + const items = datastore + .getDb() + .prepare('SELECT id, metadata FROM items') + .all() as { id: string; metadata: string }[]; + + for (const it of items) { + const meta = JSON.parse(it.metadata); + assert.strictEqual(meta.seedSource, 'onboarding', 'seed item is marked local-only'); + const tags = datastore.getItemTags(it.id).map((t) => t.name); + assert.ok(tags.includes(fixture.ONBOARDING_TAG), 'seed item carries the onboarding tag'); + } + }); + + it('orders seed items by descending createdAt (authored order preserved)', () => { + seed.seedOnboardingIfNeeded(); + + const rows = datastore + .getDb() + .prepare('SELECT createdAt FROM items ORDER BY createdAt DESC') + .all() as { createdAt: number }[]; + + for (let i = 1; i < rows.length; i++) { + assert.ok(rows[i - 1].createdAt > rows[i].createdAt, 'each seed item is strictly newer than the next'); + } + }); + + it('is idempotent — a second call seeds nothing', () => { + seed.seedOnboardingIfNeeded(); + seed.seedOnboardingIfNeeded(); + assert.strictEqual(itemCount(), fixture.FIXTURE.length, 'no duplicate seeding on the second call'); + }); + + it('never seeds a non-empty profile — adopts it as already-onboarded', () => { + // Simulate an existing user: a real item present, no sentinel yet. + datastore.addItem('text', { content: 'my real note' }); + + seed.seedOnboardingIfNeeded(); + + assert.strictEqual(itemCount(), 1, 'no demo content injected into an existing profile'); + const sentinel = datastore + .getDb() + .prepare("SELECT 1 FROM feature_settings WHERE featureId = 'onboarding' AND key = 'seeded'") + .get(); + assert.ok(sentinel, 'sentinel written even when adopting an existing profile'); + }); +}); diff --git a/apps/desktop/main/onboarding-seed.ts b/apps/desktop/main/onboarding-seed.ts new file mode 100644 index 00000000..803daeae --- /dev/null +++ b/apps/desktop/main/onboarding-seed.ts @@ -0,0 +1,90 @@ +/** + * First-launch onboarding seeder. + * + * Seeds a curated demo set (onboarding/fixture.ts) into a brand-new profile + * so the empty-state teaches the surface. Runs synchronously right after + * initDatabase, before any window or pubsub broadcaster exists — the fresh UI + * simply queries the seeded DB when it first renders, so no pubsub is needed. + * + * Guard (BOTH must hold to seed): + * 1. No onboarding sentinel row yet. + * 2. The items table is empty. + * The sentinel is written either way, giving two clean invariants: + * - an existing (non-empty) profile is adopted as "already onboarded" — + * the sentinel is written WITHOUT seeding, so demo content is never + * injected into real data (incl. an existing user's new device, where the + * DB is empty only until sync pulls; see the sync-policy note below); + * - a seeded profile is never re-seeded, even if the user later deletes + * every demo item. + * + * Sync policy: seed items are LOCAL-ONLY (insertSeedItem stamps + * metadata.seedSource), so they never sync and each device seeds its own copy. + * The sentinel is version-agnostic on purpose: a FIXTURE_VERSION bump ships + * new content to brand-new profiles only; established profiles keep the + * sentinel and are never re-seeded. + */ + +import { getDb, insertSeedItem } from './datastore.js'; +import { FIXTURE, FIXTURE_VERSION, ONBOARDING_TAG } from '../onboarding/fixture.js'; + +const SENTINEL_ID = 'onboarding-seeded'; +const SENTINEL_FEATURE = 'onboarding'; +const SENTINEL_KEY = 'seeded'; + +function sentinelPresent(): boolean { + const row = getDb() + .prepare('SELECT 1 FROM feature_settings WHERE featureId = ? AND key = ? LIMIT 1') + .get(SENTINEL_FEATURE, SENTINEL_KEY); + return row != null; +} + +function writeSentinel(seededCount: number): void { + getDb() + .prepare(` + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) + VALUES (?, ?, ?, ?, ?) + `) + .run( + SENTINEL_ID, + SENTINEL_FEATURE, + SENTINEL_KEY, + JSON.stringify({ version: FIXTURE_VERSION, seeded: seededCount }), + Date.now() + ); +} + +/** Any row (including soft-deleted tombstones) means this is not a fresh profile. */ +function itemsTableEmpty(): boolean { + const row = getDb().prepare('SELECT 1 FROM items LIMIT 1').get(); + return row == null; +} + +/** + * Seed onboarding demo content into a brand-new profile. Idempotent and safe + * to call on every boot: a no-op once the sentinel is written. + */ +export function seedOnboardingIfNeeded(): void { + if (sentinelPresent()) return; + + // Adopt an existing (non-empty) profile as already-onboarded: write the + // sentinel WITHOUT seeding so real data is never mixed with demo content. + if (!itemsTableEmpty()) { + writeSentinel(0); + return; + } + + const base = Date.now(); + FIXTURE.forEach((entry, i) => { + insertSeedItem(entry.type, { + content: entry.content, + metadata: entry.metadata, + // Descending createdAt so the fixture keeps its authored order in the + // recent-sorted view (first entry newest → shown first). + createdAt: base - i * 1000, + tags: [ONBOARDING_TAG, ...entry.tags], + }); + }); + + writeSentinel(FIXTURE.length); + console.log(`[onboarding] Seeded ${FIXTURE.length} demo items (fixture v${FIXTURE_VERSION})`); +} diff --git a/apps/desktop/onboarding/fixture.ts b/apps/desktop/onboarding/fixture.ts new file mode 100644 index 00000000..5f7ac4ce --- /dev/null +++ b/apps/desktop/onboarding/fixture.ts @@ -0,0 +1,57 @@ +/** + * Onboarding / demo fixture — the curated items seeded into a brand-new + * profile on first launch so the empty-state teaches the surface instead of + * being a blank screen. + * + * This module is DATA ONLY. The seeder (main/onboarding-seed.ts) owns the + * insert logic, the local-only marker, the sentinel guard, and ordering. + * + * Rules for editing: + * - Seed items are LOCAL-ONLY: they never sync. The seeder stamps + * metadata.seedSource='onboarding'; packages/sync excludes such items from + * push, exactly like browser-imports. So a stable syncId is NOT needed and + * each device seeds its own copy. + * - Existing users are NEVER re-seeded: once the onboarding sentinel is + * written for a profile, this file is never re-read for it. New users get + * whatever content ships in FIXTURE at their install time; established + * profiles are left untouched (see onboarding-seed.ts for the guard). + * + * WALKING SKELETON: the entries below are placeholders that prove the + * mechanism end-to-end. The real curated content set (feature notes, more + * URLs, tagset/entity demos, images) lands in a follow-up. + */ + +import type { ItemType } from '../types/index.js'; + +/** Bump when the curated content changes. Recorded in the sentinel value for diagnostics. */ +export const FIXTURE_VERSION = 1; + +/** Internal tag on every seed item, so a user can one-gesture archive the demo set. */ +export const ONBOARDING_TAG = 'peek:onboarding'; + +export interface FixtureEntry { + type: ItemType; + content: string; + /** Optional metadata (e.g. { title }). seedSource is added by the seeder, not here. */ + metadata?: Record; + /** Extra tag names to apply (ONBOARDING_TAG is added by the seeder). */ + tags: string[]; +} + +export const FIXTURE: FixtureEntry[] = [ + { + type: 'text', + content: + '# Welcome to Peek\n\n' + + 'Peek keeps what you find — pages, notes, images — and helps you get back to it.\n\n' + + 'This is demo content. Anything tagged `peek:onboarding` can be cleared in one gesture once you’re settled in.', + metadata: { title: 'Welcome to Peek' }, + tags: ['welcome'], + }, + { + type: 'url', + content: 'https://tangled.org/burrito.space/peek', + metadata: { title: 'Peek source' }, + tags: ['peek:source'], + }, +]; diff --git a/packages/sync/sync.js b/packages/sync/sync.js index d8f2b347..a3fd57f1 100644 --- a/packages/sync/sync.js +++ b/packages/sync/sync.js @@ -119,12 +119,13 @@ export class SyncEngine { ); itemsToPush = itemsToPush.concat(deletedToPush); - // Exclude browser-imported items (history, bookmarks, tabs) — they stay local + // Exclude local-only items — browser-imports (history, bookmarks, tabs) + // and onboarding/demo seed content — they stay on this device. itemsToPush = itemsToPush.filter(i => { if (!i.metadata || i.metadata === '{}') return true; try { const meta = JSON.parse(i.metadata); - return !meta.importSource; + return !meta.importSource && !meta.seedSource; } catch { return true; } diff --git a/packages/sync/test.js b/packages/sync/test.js index 5f78a1fa..a2a6b575 100644 --- a/packages/sync/test.js +++ b/packages/sync/test.js @@ -839,6 +839,28 @@ describe('SyncEngine: Push', () => { assert.strictEqual(result.pushed, 0); }); + it('should not push local-only onboarding seed items', async () => { + const { adapter, sync } = createSyncTestEngine(); + await adapter.open(); + + // Unsynced (syncedAt: 0) so it IS a push candidate — the seedSource + // marker, not the sync state, is what must keep it local. + await adapter.insertItem({ + id: 'seed-1', + type: 'text', + content: 'Welcome to Peek', + metadata: JSON.stringify({ seedSource: 'onboarding' }), + syncId: '', + syncedAt: 0, + createdAt: 1000, + updatedAt: 1000, + deletedAt: 0, + }); + + const result = await sync.pushToServer(); + assert.strictEqual(result.pushed, 0, 'onboarding seed items stay local'); + }); + it('should return zeros when not configured', async () => { const { adapter, sync } = createSyncTestEngine(); await adapter.open();