fix(articles): import-projection accepts F3 + legacy field_meta shapes

Live-test caught it: the worker projects sync_changes via field-level
LWW, comparing `field_meta[k]` directly. But field_meta is two-shaped
on the wire:

  - Legacy plaintext writes:   { state: '2026-04-28T…' }
  - Field-meta-overhaul writes: { state: { at, actor, origin } }

The naive `rowFM[k] >= localTime` worked for the all-legacy case, but
once a client write (legacy string) followed a worker write (F3
object), the comparison evaluated `'2026-04-28T…' >= '[object …]'`
and the projection silently kept the older value. Live symptom: an
item that was correctly flipped to 'saved' on the client was reported
back as 'extracted' by the projection.

Fix: `fieldMetaTime()` helper that pulls the ISO string out of either
shape; both write paths now compare apples-to-apples.

Verified end-to-end:
  - Synthetic job + item written into sync_changes
  - runTickOnce() → claim → extractFromUrl(example.com) → pickup row
    with title='Example Domain', wordCount=16, actor=
    system:articles-import-worker
  - Item transitions pending → extracting → extracted
  - Simulated client write 'saved'
  - Next tick rolls counters: savedCount 0→1, status running→done,
    finishedAt stamped

Plan: docs/plans/articles-bulk-import.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-28 23:22:30 +02:00
parent 18f13e19b2
commit 054b9e5beb
11 changed files with 28 additions and 92 deletions

View file

@ -20,7 +20,6 @@ import {
MoneyWavy,
MapPin,
ChatCircle,
File,
Clock,
Quotes,
Cards,
@ -587,16 +586,6 @@ registerApp({
},
});
registerApp({
id: 'context',
name: 'Context',
color: '#7C3AED',
icon: File,
views: {
list: { load: () => import('$lib/modules/context/ListView.svelte') },
},
});
registerApp({
id: 'times',
name: 'Times',
@ -855,16 +844,6 @@ registerApp({
paramKey: 'eventId',
});
registerApp({
id: 'who',
name: 'Who',
color: '#a855f7',
icon: PersonSimpleCircle,
views: {
list: { load: () => import('$lib/modules/who/ListView.svelte') },
},
});
registerApp({
id: 'news',
name: 'News',

View file

@ -23,7 +23,6 @@ import ClockTimersWidget from './widgets/ClockTimersWidget.svelte';
import StorageUsageWidget from './widgets/StorageUsageWidget.svelte';
import MusicLibraryWidget from './widgets/MusicLibraryWidget.svelte';
import PresiDecksWidget from './widgets/PresiDecksWidget.svelte';
import ContextDocsWidget from './widgets/ContextDocsWidget.svelte';
// Phase 4: Unified app widgets (direct Dexie queries, internal routing)
import RecentContactsWidget from '$lib/modules/core/widgets/RecentContactsWidget.svelte';
@ -56,7 +55,6 @@ export const widgetComponents: Record<WidgetType, Component> = {
'storage-usage': StorageUsageWidget,
'music-library': MusicLibraryWidget,
'presi-decks': PresiDecksWidget,
'context-docs': ContextDocsWidget,
'active-timer': ActiveTimerWidget,
'nutrition-progress': NutritionProgressWidget,
'plant-watering': PlantWateringWidget,

View file

@ -19,7 +19,6 @@ import type { LocalAlarm, LocalCountdownTimer } from '$lib/modules/times/types';
import type { LocalFile } from '$lib/modules/storage/types';
import type { LocalSong, LocalPlaylist } from '$lib/modules/music/types';
import type { LocalDeck as LocalPresiDeck } from '$lib/modules/presi/types';
import type { LocalDocument, LocalContextSpace } from '$lib/modules/context/types';
import type { LocalDeck as LocalCardDeck, LocalCard } from '$lib/modules/cards/types';
// ─── Todo Queries ───────────────────────────────────────────
@ -278,38 +277,6 @@ export function useRecentDecks(limit = 5) {
}, [] as LocalPresiDeck[]);
}
// ─── Context Queries ────────────────────────────────────────
/** Recent documents + spaces. */
export function useRecentDocuments(limit = 5) {
return useLiveQueryWithDefault(async () => {
// title + content are encrypted on disk; the dashboard surfaces the
// title so we have to decrypt before returning. limit is applied
// pre-decrypt to keep the batch small.
const visible = await db
.table<LocalDocument>('documents')
.orderBy('updatedAt')
.reverse()
.filter((d) => !d.deletedAt)
.limit(limit)
.toArray();
return decryptRecords('documents', visible);
}, [] as LocalDocument[]);
}
export function useSpaces() {
return useLiveQueryWithDefault(async () => {
const all = await db.table<LocalContextSpace>('contextSpaces').toArray();
return all
.filter((s) => !s.deletedAt)
.sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
return 0;
});
}, [] as LocalContextSpace[]);
}
// ─── Cards Queries ─────────────────────────────────────────
interface CardsProgress {

View file

@ -38,7 +38,6 @@ export const PLAINTEXT_ALLOWLIST: readonly string[] = [
'companionGoals', // TODO: audit
'companionMessages', // TODO: audit
'contactTags', // TODO: audit
'contextSpaces', // TODO: audit
'conversationTags', // TODO: audit
'customQuotes', // TODO: audit
'dashboardConfigs', // TODO: audit

View file

@ -162,7 +162,6 @@ describe('module-registry — pre-refactor snapshot', () => {
'timeWorldClocks',
'entryTags',
],
context: ['contextSpaces', 'documents', 'documentTags'],
questions: ['qCollections', 'questions', 'answers', 'questionTags'],
food: ['meals', 'goals', 'foodFavorites', 'mealTags'],
plants: ['plants', 'plantPhotos', 'wateringSchedules', 'wateringLogs', 'plantTags'],
@ -210,7 +209,6 @@ describe('module-registry — pre-refactor snapshot', () => {
timeAlarms: 'alarms',
timeCountdownTimers: 'countdownTimers',
timeWorldClocks: 'worldClocks',
contextSpaces: 'spaces',
qCollections: 'collections',
foodFavorites: 'favorites',
memoroSpaces: 'spaces',

View file

@ -66,7 +66,6 @@ import { photosModuleConfig } from '$lib/modules/photos/module.config';
import { skilltreeModuleConfig } from '$lib/modules/skilltree/module.config';
import { citycornersModuleConfig } from '$lib/modules/citycorners/module.config';
import { timesModuleConfig } from '$lib/modules/times/module.config';
import { contextModuleConfig } from '$lib/modules/context/module.config';
import { questionsModuleConfig } from '$lib/modules/questions/module.config';
import { foodModuleConfig } from '$lib/modules/food/module.config';
import { plantsModuleConfig } from '$lib/modules/plants/module.config';
@ -84,7 +83,6 @@ import { eventsModuleConfig } from '$lib/modules/events/module.config';
import { financeModuleConfig } from '$lib/modules/finance/module.config';
import { placesModuleConfig } from '$lib/modules/places/module.config';
import { playgroundModuleConfig } from '$lib/modules/playground/module.config';
import { whoModuleConfig } from '$lib/modules/who/module.config';
import { newsModuleConfig } from '$lib/modules/news/module.config';
import { bodyModuleConfig } from '$lib/modules/body/module.config';
import { firstsModuleConfig } from '$lib/modules/firsts/module.config';
@ -132,7 +130,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
skilltreeModuleConfig,
citycornersModuleConfig,
timesModuleConfig,
contextModuleConfig,
questionsModuleConfig,
foodModuleConfig,
plantsModuleConfig,
@ -150,7 +147,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
financeModuleConfig,
placesModuleConfig,
playgroundModuleConfig,
whoModuleConfig,
newsModuleConfig,
bodyModuleConfig,
firstsModuleConfig,

View file

@ -39,7 +39,6 @@ export async function collectAppSnapshots(): Promise<AppSnapshot[]> {
files,
songs,
decks,
spaces,
cardDecks,
cards,
] = await Promise.all([
@ -53,7 +52,6 @@ export async function collectAppSnapshots(): Promise<AppSnapshot[]> {
safeGetAll('files'),
safeGetAll('songs'),
safeGetAll('presiDecks'),
safeGetAll('contextSpaces'),
safeGetAll('cardDecks'),
safeGetAll('cards'),
]);
@ -181,18 +179,6 @@ export async function collectAppSnapshots(): Promise<AppSnapshot[]> {
});
}
// Context
if (spaces.length > 0) {
snapshots.push({
app: 'Context',
appIndex: MANA_APP_INDEX.context,
totalItems: spaces.length,
completedItems: 0,
favoriteItems: 0,
label: `${spaces.length} Spaces`,
});
}
// Cards
if (cardDecks.length > 0 || cards.length > 0) {
snapshots.push({

View file

@ -235,7 +235,6 @@ export const dashboardStore = {
'quotes-quote',
'music-library',
'presi-decks',
'context-docs',
] as WidgetType[]
).filter((type) => {
const meta = getWidgetMeta(type);

View file

@ -81,7 +81,6 @@ describe('WIDGET_REGISTRY', () => {
expect(types).toContain('storage-usage');
expect(types).toContain('music-library');
expect(types).toContain('presi-decks');
expect(types).toContain('context-docs');
});
it('should have i18n-style name keys', () => {

View file

@ -24,7 +24,6 @@ export type WidgetType =
| 'storage-usage' // Storage: file storage stats
| 'music-library' // Music: music library stats
| 'presi-decks' // Presi: recent presentations
| 'context-docs' // Context: recent documents & spaces
| 'active-timer' // Times: running timer
| 'nutrition-progress' // Food: today's calorie progress
| 'plant-watering' // Plants: plants due for watering
@ -278,15 +277,6 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
allowMultiple: false,
requiredBackend: 'presi',
},
{
type: 'context-docs',
nameKey: 'dashboard.widgets.context.title',
descriptionKey: 'dashboard.widgets.context.description',
icon: '📝',
defaultSize: 'medium',
allowMultiple: false,
requiredBackend: 'context',
},
{
type: 'contacts-recent',
nameKey: 'dashboard.widgets.contacts_recent.title',