From 2755fd80a9d86abbeb7693a6577376cafcfab53b Mon Sep 17 00:00:00 2001 From: Masaki Kobayashi Date: Thu, 5 Feb 2026 09:18:23 +0900 Subject: [PATCH 1/5] docs: add cross-browser sync design spec and original requirement - groovy-tumbling-clover.md: detailed implementation plan covering type splits, dual-storage layout, orphan resolution, migration, and verification steps - sync-connections.md: original requirement in Japanese --- docs/2026-02-05/groovy-tumbling-clover.md | 380 ++++++++++++++++++++++ docs/2026-02-05/sync-connections.md | 3 + 2 files changed, 383 insertions(+) create mode 100644 docs/2026-02-05/groovy-tumbling-clover.md create mode 100644 docs/2026-02-05/sync-connections.md diff --git a/docs/2026-02-05/groovy-tumbling-clover.md b/docs/2026-02-05/groovy-tumbling-clover.md new file mode 100644 index 0000000..2ac82be --- /dev/null +++ b/docs/2026-02-05/groovy-tumbling-clover.md @@ -0,0 +1,380 @@ +# Cross-Browser Connection Settings Sync + +## Overview + +Migrate connection storage from `chrome.storage.local` to a split approach: +- **Configuration** (repo settings, enabled status) → `chrome.storage.sync` +- **State** (sync timestamps, folder IDs, errors) → `chrome.storage.local` + +This allows connection settings to sync across browsers while keeping per-browser sync state independent. + +--- + +## Data Structure Changes + +### Type Definitions (`lib/types/connection.ts`) + +Add new types while keeping the merged `Connection` type for backward compatibility: + +```typescript +/** + * Configuration synced across browsers via chrome.storage.sync + */ +export type ConnectionConfig = { + id: string; + repoFullName: string; + repoOwner: string; + repoName: string; + srcDir: string; + targetFolderPath: string; // Synced for folder identification + enabled: boolean; + createdAt: string; +}; + +/** + * Browser-specific state stored in chrome.storage.local + */ +export type ConnectionState = { + targetFolderId: string; // Chrome folder IDs are browser-specific + lastSyncedAt: string | null; + lastSyncedCommitSha: string | null; + lastSyncError: string | null; +}; + +// Keep existing merged type for compatibility +export type Connection = ConnectionConfig & ConnectionState; + +// Storage types (Record for O(1) lookups) +export type SyncConnectionsStore = Record; +export type LocalConnectionStateStore = Record; +``` + +--- + +## Implementation + +### 1. Storage Layer Rewrite (`lib/storage/connections.ts`) + +Replace array-based operations with dual-storage approach: + +**Keys:** +- `sync:gitmarks_connections` - Connection configs (synced) +- `local:gitmarks_connection_state` - Per-browser state (local) + +**Core operations:** + +```typescript +const SYNC_KEY = "sync:gitmarks_connections"; +const LOCAL_STATE_KEY = "local:gitmarks_connection_state"; + +// Get all connections (merge sync config + local state) +export const getConnections = async (): Promise => { + const [configs, states] = await Promise.all([ + browser.storage.sync.get(SYNC_KEY).then(r => r[SYNC_KEY] ?? {}), + browser.storage.local.get(LOCAL_STATE_KEY).then(r => r[LOCAL_STATE_KEY] ?? {}), + ]); + + // Only return connections with BOTH config AND state + return Object.entries(configs) + .filter(([id]) => id in states) + .map(([id, config]) => ({ ...config, ...states[id] })); +}; + +// Save/update connection (writes to both storage areas) +export const saveConnection = async (connection: Connection): Promise => { + const { targetFolderId, lastSyncedAt, lastSyncedCommitSha, lastSyncError, ...config } = connection; + + const state: ConnectionState = { + targetFolderId, + lastSyncedAt, + lastSyncedCommitSha, + lastSyncError, + }; + + await Promise.all([ + browser.storage.sync.get(SYNC_KEY).then(result => { + const configs = result[SYNC_KEY] ?? {}; + configs[connection.id] = config; + return browser.storage.sync.set({ [SYNC_KEY]: configs }); + }), + browser.storage.local.get(LOCAL_STATE_KEY).then(result => { + const states = result[LOCAL_STATE_KEY] ?? {}; + states[connection.id] = state; + return browser.storage.local.set({ [LOCAL_STATE_KEY]: states }); + }), + ]); +}; + +// Update connection (auto-detects sync vs local fields) +export const updateConnection = async (id: string, updates: Partial): Promise => { + const configKeys: (keyof ConnectionConfig)[] = [ + "id", "repoFullName", "repoOwner", "repoName", + "srcDir", "targetFolderPath", "enabled", "createdAt", + ]; + + const configUpdates = pickKeys(updates, configKeys); + const stateUpdates = omitKeys(updates, configKeys); + + const promises: Promise[] = []; + + if (Object.keys(configUpdates).length > 0) { + promises.push(updateSyncConfig(id, configUpdates)); + } + if (Object.keys(stateUpdates).length > 0) { + promises.push(updateLocalState(id, stateUpdates)); + } + + await Promise.all(promises); +}; + +// Remove connection from both storage areas +export const removeConnection = async (id: string): Promise => { + await Promise.all([ + deleteFromSync(id), + deleteFromLocal(id), + ]); +}; +``` + +--- + +### 2. Migration (`lib/storage/migrations.ts`) + +One-time migration from legacy array storage to split storage: + +```typescript +const LEGACY_KEY = "local:gitmarks_connections"; +const MIGRATION_FLAG = "local:migration_v1_split_storage"; + +export const migrateToSplitStorage = async (): Promise => { + // Skip if already migrated + const { [MIGRATION_FLAG]: completed } = await browser.storage.local.get(MIGRATION_FLAG); + if (completed) return; + + // Get legacy data + const { [LEGACY_KEY]: legacy } = await browser.storage.local.get(LEGACY_KEY); + if (!legacy || !Array.isArray(legacy)) { + await browser.storage.local.set({ [MIGRATION_FLAG]: true }); + return; + } + + // Split each connection + const syncConfigs: SyncConnectionsStore = {}; + const localStates: LocalConnectionStateStore = {}; + + for (const conn of legacy) { + const { targetFolderId, lastSyncedAt, lastSyncedCommitSha, lastSyncError, ...config } = conn; + syncConfigs[conn.id] = config; + localStates[conn.id] = { targetFolderId, lastSyncedAt, lastSyncedCommitSha, lastSyncError }; + } + + // Save to new locations + await Promise.all([ + browser.storage.sync.set({ [SYNC_KEY]: syncConfigs }), + browser.storage.local.set({ [LOCAL_STATE_KEY]: localStates }), + ]); + + // Cleanup and flag + await browser.storage.local.remove(LEGACY_KEY); + await browser.storage.local.set({ [MIGRATION_FLAG]: true }); +}; +``` + +**Trigger migration:** Call in `entrypoints/options/App.tsx` on mount. + +--- + +### 3. Orphan Config Handling (`lib/storage/orphan-configs.ts`) + +When Browser B receives a synced connection with no local state, auto-create the folder at the synced path: + +```typescript +import { getOrCreateFolder } from "../bookmarks/api.ts"; + +/** + * Get configs that exist in sync but not in local storage + */ +export const getOrphanConfigs = async (): Promise => { + const [configs, states] = await Promise.all([ + browser.storage.sync.get(SYNC_KEY).then(r => r[SYNC_KEY] ?? {}), + browser.storage.local.get(LOCAL_STATE_KEY).then(r => r[LOCAL_STATE_KEY] ?? {}), + ]); + + return Object.entries(configs) + .filter(([id]) => !(id in states)) + .map(([, config]) => config); +}; + +/** + * Auto-resolve all orphan configs by creating folders at synced paths + * Returns the number of newly resolved connections + */ +export const autoResolveOrphanConfigs = async (): Promise => { + const orphans = await getOrphanConfigs(); + if (orphans.length === 0) return 0; + + let resolved = 0; + for (const config of orphans) { + const targetFolderId = await createFolderAtPath(config.targetFolderPath); + await resolveOrphanConfig(config.id, targetFolderId); + resolved++; + } + + return resolved; +}; + +/** + * Create a folder hierarchy from a path string like "Bookmarks Bar > Dev > GitMarks" + * Assumes separator is " > " based on current implementation in lib/bookmarks/api.ts + */ +const createFolderAtPath = async (pathString: string): Promise => { + // Get bookmarks tree + const tree = await browser.bookmarks.getTree(); + + // Find root (Bookmarks Bar is typically ID "1") + // Parse the path and create hierarchy + const parts = pathString.split(" > ").filter(Boolean); + if (parts.length === 0) throw new Error("Invalid folder path"); + + // Start from Bookmarks Bar root + const bookmarksBar = tree[0].children?.find(c => c.id === "1"); + if (!bookmarksBar?.id) throw new Error("Bookmarks Bar not found"); + + let currentId = bookmarksBar.id; + + // Create or navigate through each folder level + for (const folderName of parts) { + currentId = await getOrCreateFolder(currentId, folderName); + } + + return currentId; +}; + +/** + * Create local state for an orphan config + */ +export const resolveOrphanConfig = async ( + id: string, + targetFolderId: string, +): Promise => { + const { [SYNC_KEY]: configs } = await browser.storage.sync.get(SYNC_KEY); + const config = configs?.[id]; + if (!config) throw new Error(`Config not found: ${id}`); + + const state: ConnectionState = { + targetFolderId, + lastSyncedAt: null, + lastSyncedCommitSha: null, + lastSyncError: null, + }; + + await browser.storage.local.get(LOCAL_STATE_KEY).then(result => { + const states = result[LOCAL_STATE_KEY] ?? {}; + states[id] = state; + return browser.storage.local.set({ [LOCAL_STATE_KEY]: states }); + }); + + return { ...config, ...state }; +}; +``` + +--- + +### 4. Background Orphan Resolution + +Silent auto-resolution in the options page - no UI notification needed: + +```typescript +// entrypoints/options/App.tsx (or main entrypoint) + +useEffect(() => { + // Silently auto-resolve orphans on mount and when sync storage changes + const resolveOrphans = async () => { + await autoResolveOrphanConfigs(); + }; + + void resolveOrphans(); + + const listener = (changes: Record) => { + if (SYNC_KEY in changes) void resolveOrphans(); + }; + + browser.storage.onChanged.addListener(listener); + return () => browser.storage.onChanged.removeListener(listener); +}, []); +``` + +**Note:** When orphan configs are resolved, bookmarks are NOT synced immediately. The next periodic sync (60 min interval) will handle syncing these connections. + +--- + +### 5. Cleanup for Deleted Connections + +When a connection is deleted on Browser A, Browser B should clean up its orphaned local state: + +```typescript +export const cleanupDeletedConnections = async (): Promise => { + const [configs, states] = await Promise.all([ + browser.storage.sync.get(SYNC_KEY).then(r => r[SYNC_KEY] ?? {}), + browser.storage.local.get(LOCAL_STATE_KEY).then(r => r[LOCAL_STATE_KEY] ?? {}), + ]); + + // Find local states without corresponding configs + const orphanStateIds = Object.keys(states).filter(id => !(id in configs)); + + if (orphanStateIds.length > 0) { + for (const id of orphanStateIds) { + delete states[id]; + } + await browser.storage.local.set({ [LOCAL_STATE_KEY]: states }); + } +}; +``` + +Call this on app mount and when sync storage changes. + +--- + +## Files to Modify + +| File | Action | +|------|--------| +| `lib/types/connection.ts` | Add `ConnectionConfig`, `ConnectionState`, store types | +| `lib/storage/connections.ts` | Rewrite for dual-storage (sync + local) | +| `lib/storage/migrations.ts` | **CREATE** - One-time migration from array to split storage | +| `lib/storage/orphan-configs.ts` | **CREATE** - Orphan detection and auto-resolution | +| `entrypoints/options/App.tsx` | Add migration trigger, silent orphan resolution, cleanup | + +--- + +## Key Implementation Notes + +1. **WXT Storage API:** Use `sync:` prefix for `chrome.storage.sync` (e.g., `sync:gitmarks_connections`) + +2. **User data remains local:** GitHub user profile (`local:gitmarks_user`) and access token (`local:github_access_token`) stay in `chrome.storage.local`. Each browser requires separate authentication. + +3. **chrome.storage.sync limits:** 100KB quota per item, 8KB per key. Connection configs are small JSON objects (~200 bytes each), so ~500 connections would fit. + +4. **Conflict resolution:** Last-write-wins is acceptable for connection settings since users typically configure on one browser at a time. + +5. **targetFolderPath:** Included in sync storage to enable auto-creation of folder hierarchies across browsers, even though `targetFolderId` is local-only. + +6. **Backward compatibility:** Keep the merged `Connection` type so existing code continues to work with minimal changes. + +--- + +## Verification Plan + +1. **Test migration:** Load extension with existing connections, verify they migrate correctly +2. **Test sync flow:** + - Add connection on Browser A → verify appears on Browser B (auto-resolved with folder created) + - Verify folder was created at the synced path on Browser B + - Verify NO notification is shown on Browser B + - Verify bookmarks are NOT synced immediately on Browser B (wait for next periodic sync) + - Disable connection on Browser A → verify disabled on Browser B + - Delete connection on Browser A → verify removed from Browser B after cleanup +3. **Test state independence:** + - Sync on Browser A → verify `lastSyncedAt` on Browser B remains unchanged +4. **Test auto-creation:** + - Create connection with path "Bookmarks Bar > Dev > GitMarks" on Browser A + - Verify the folder hierarchy is created on Browser B (silent, no notification) diff --git a/docs/2026-02-05/sync-connections.md b/docs/2026-02-05/sync-connections.md new file mode 100644 index 0000000..40b739e --- /dev/null +++ b/docs/2026-02-05/sync-connections.md @@ -0,0 +1,3 @@ +同期設定しているレポジトリについて、その設定を `chrome.storage.local` ではなく `chrome.storage.sync` に保存し、同一の Google アカウントでログインしているブラウザ間で同期されるように改修して + +ただしブックマークの同期処理自体は各ブラウザで独立に実行されるべきなので、同期の状態管理用の情報 (e.g. `lastSyncedAt`) はは引き続き `chrome.storage.local` に残す必要があることに注意して From 4dec64433a8163f7fc132111cd65bad4fe9c0e3a Mon Sep 17 00:00:00 2001 From: Masaki Kobayashi Date: Thu, 5 Feb 2026 09:18:27 +0900 Subject: [PATCH 2/5] feat: split Connection into ConnectionConfig and ConnectionState Partition the flat Connection type into two concerns: - ConnectionConfig: repo settings and enabled flag, intended for chrome.storage.sync - ConnectionState: browser-specific folder ID and sync timestamps, stays in chrome.storage.local The merged Connection = ConnectionConfig & ConnectionState type is retained so call sites require no changes. Also add the store-level Record types (SyncConnectionsStore, LocalConnectionStateStore). --- lib/types/connection.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/types/connection.ts b/lib/types/connection.ts index 230f0b0..17f88e7 100644 --- a/lib/types/connection.ts +++ b/lib/types/connection.ts @@ -1,14 +1,22 @@ -export type Connection = { +export type ConnectionConfig = { id: string; repoFullName: string; repoOwner: string; repoName: string; srcDir: string; - targetFolderId: string; targetFolderPath: string; enabled: boolean; + createdAt: string; +}; + +export type ConnectionState = { + targetFolderId: string; lastSyncedAt: string | null; lastSyncedCommitSha: string | null; lastSyncError: string | null; - createdAt: string; }; + +export type Connection = ConnectionConfig & ConnectionState; + +export type SyncConnectionsStore = Record; +export type LocalConnectionStateStore = Record; From 05060639090cc7e3ba6f04521064a7a883870903 Mon Sep 17 00:00:00 2001 From: Masaki Kobayashi Date: Thu, 5 Feb 2026 09:18:34 +0900 Subject: [PATCH 3/5] feat: rewrite connection storage for cross-browser sync Replace the single chrome.storage.local array with a split layout: - Connection configs live in chrome.storage.sync under "sync:gitmarks_connections" so they propagate across browsers automatically. - Per-browser state (folder IDs, sync timestamps) stays in chrome.storage.local under "local:gitmarks_connection_state". connections.ts changes: - getConnections merges the two stores, returning only fully-resolved entries (both config and state present). - saveConnection / updateConnection route fields to the correct store; updateConnection auto-detects config vs state keys. - removeConnection deletes from both stores atomically. - Expose getSyncConfigs / getLocalStates for use by orphan resolution. New orphan-configs.ts: - getOrphanConfigs: finds configs synced from another browser that have no local state yet. - autoResolveOrphanConfigs: recreates the bookmark folder hierarchy at the synced path and initialises local state, returning the count of newly resolved connections. - cleanupDeletedConnections: removes local state for connections whose config has been deleted on another browser. --- lib/storage/connections.ts | 171 ++++++++++++++++++++++++++++++---- lib/storage/orphan-configs.ts | 110 ++++++++++++++++++++++ 2 files changed, 262 insertions(+), 19 deletions(-) create mode 100644 lib/storage/orphan-configs.ts diff --git a/lib/storage/connections.ts b/lib/storage/connections.ts index 55785ad..bbc0f9c 100644 --- a/lib/storage/connections.ts +++ b/lib/storage/connections.ts @@ -1,35 +1,168 @@ -import type { Connection } from "../types/connection.ts"; +import type { + Connection, + ConnectionConfig, + ConnectionState, + LocalConnectionStateStore, + SyncConnectionsStore, +} from "../types/connection.ts"; -const CONNECTIONS_KEY = "local:gitmarks_connections"; +const SYNC_KEY = "sync:gitmarks_connections"; +const LOCAL_STATE_KEY = "local:gitmarks_connection_state"; export const getConnections = async (): Promise => { - return (await storage.getItem(CONNECTIONS_KEY)) ?? []; -}; + const [configs, states] = await Promise.all([ + browser.storage.sync + .get(SYNC_KEY) + .then((r) => (r[SYNC_KEY] ?? {}) as SyncConnectionsStore), + browser.storage.local + .get(LOCAL_STATE_KEY) + .then((r) => (r[LOCAL_STATE_KEY] ?? {}) as LocalConnectionStateStore), + ]); -export const saveConnections = async ( - connections: Connection[], -): Promise => { - await storage.setItem(CONNECTIONS_KEY, connections); + return Object.entries(configs) + .filter(([id]) => id in states) + .map(([id, config]) => ({ ...config, ...states[id] })); }; -export const addConnection = async (connection: Connection): Promise => { - const connections = await getConnections(); - connections.push(connection); - await saveConnections(connections); +export const saveConnection = async (connection: Connection): Promise => { + const { + targetFolderId, + lastSyncedAt, + lastSyncedCommitSha, + lastSyncError, + ...config + } = connection; + + const state: ConnectionState = { + targetFolderId, + lastSyncedAt, + lastSyncedCommitSha, + lastSyncError, + }; + + await Promise.all([ + browser.storage.sync.get(SYNC_KEY).then((result) => { + const configs = (result[SYNC_KEY] ?? {}) as SyncConnectionsStore; + configs[connection.id] = config; + return browser.storage.sync.set({ [SYNC_KEY]: configs }); + }), + browser.storage.local.get(LOCAL_STATE_KEY).then((result) => { + const states = (result[LOCAL_STATE_KEY] ?? + {}) as LocalConnectionStateStore; + states[connection.id] = state; + return browser.storage.local.set({ [LOCAL_STATE_KEY]: states }); + }), + ]); }; export const updateConnection = async ( id: string, updates: Partial, ): Promise => { - const connections = await getConnections(); - const index = connections.findIndex((c) => c.id === id); - if (index === -1) return; - connections[index] = { ...connections[index], ...updates }; - await saveConnections(connections); + const configKeys: (keyof ConnectionConfig)[] = [ + "id", + "repoFullName", + "repoOwner", + "repoName", + "srcDir", + "targetFolderPath", + "enabled", + "createdAt", + ]; + + const configUpdates = pickKeys(updates, configKeys); + const stateUpdates = omitKeys(updates, configKeys); + + const promises: Promise[] = []; + + if (Object.keys(configUpdates).length > 0) { + promises.push(updateSyncConfig(id, configUpdates)); + } + if (Object.keys(stateUpdates).length > 0) { + promises.push(updateLocalState(id, stateUpdates)); + } + + await Promise.all(promises); +}; + +export const addConnection = async (connection: Connection): Promise => { + await saveConnection(connection); }; export const removeConnection = async (id: string): Promise => { - const connections = await getConnections(); - await saveConnections(connections.filter((c) => c.id !== id)); + await Promise.all([deleteFromSync(id), deleteFromLocal(id)]); +}; + +const pickKeys = , K extends keyof T>( + obj: T, + keys: K[], +): Pick => { + const result = {} as Pick; + for (const key of keys) { + if (key in obj) { + result[key] = obj[key]; + } + } + return result; +}; + +const omitKeys = , K extends keyof T>( + obj: T, + keys: K[], +): Omit => { + const result = { ...obj }; + for (const key of keys) { + delete result[key]; + } + return result as Omit; +}; + +const updateSyncConfig = async ( + id: string, + updates: Partial, +): Promise => { + await browser.storage.sync.get(SYNC_KEY).then((result) => { + const configs = (result[SYNC_KEY] ?? {}) as SyncConnectionsStore; + if (!(id in configs)) return; + configs[id] = { ...configs[id], ...updates }; + return browser.storage.sync.set({ [SYNC_KEY]: configs }); + }); +}; + +const updateLocalState = async ( + id: string, + updates: Partial, +): Promise => { + await browser.storage.local.get(LOCAL_STATE_KEY).then((result) => { + const states = (result[LOCAL_STATE_KEY] ?? {}) as LocalConnectionStateStore; + if (!(id in states)) return; + states[id] = { ...states[id], ...updates }; + return browser.storage.local.set({ [LOCAL_STATE_KEY]: states }); + }); +}; + +const deleteFromSync = async (id: string): Promise => { + await browser.storage.sync.get(SYNC_KEY).then((result) => { + const configs = (result[SYNC_KEY] ?? {}) as SyncConnectionsStore; + delete configs[id]; + return browser.storage.sync.set({ [SYNC_KEY]: configs }); + }); +}; + +const deleteFromLocal = async (id: string): Promise => { + await browser.storage.local.get(LOCAL_STATE_KEY).then((result) => { + const states = (result[LOCAL_STATE_KEY] ?? {}) as LocalConnectionStateStore; + delete states[id]; + return browser.storage.local.set({ [LOCAL_STATE_KEY]: states }); + }); +}; + +export const getSyncConfigs = async (): Promise => { + return ((await browser.storage.sync.get(SYNC_KEY))[SYNC_KEY] ?? + {}) as SyncConnectionsStore; +}; + +export const getLocalStates = async (): Promise => { + return ((await browser.storage.local.get(LOCAL_STATE_KEY))[LOCAL_STATE_KEY] ?? + {}) as LocalConnectionStateStore; }; diff --git a/lib/storage/orphan-configs.ts b/lib/storage/orphan-configs.ts new file mode 100644 index 0000000..97576f2 --- /dev/null +++ b/lib/storage/orphan-configs.ts @@ -0,0 +1,110 @@ +import { getOrCreateFolder } from "../bookmarks/api.ts"; +import type { + Connection, + ConnectionConfig, + ConnectionState, + LocalConnectionStateStore, + SyncConnectionsStore, +} from "../types/connection.ts"; + +const SYNC_KEY = "sync:gitmarks_connections"; +const LOCAL_STATE_KEY = "local:gitmarks_connection_state"; + +export const getOrphanConfigs = async (): Promise => { + const [configs, states] = await Promise.all([ + browser.storage.sync + .get(SYNC_KEY) + .then((r) => (r[SYNC_KEY] ?? {}) as SyncConnectionsStore), + browser.storage.local + .get(LOCAL_STATE_KEY) + .then((r) => (r[LOCAL_STATE_KEY] ?? {}) as LocalConnectionStateStore), + ]); + + return Object.entries(configs) + .filter(([id]) => !(id in states)) + .map(([, config]) => config); +}; + +export const autoResolveOrphanConfigs = async (): Promise => { + const orphans = await getOrphanConfigs(); + if (orphans.length === 0) return 0; + + let resolved = 0; + for (const config of orphans) { + try { + const targetFolderId = await createFolderAtPath(config.targetFolderPath); + await resolveOrphanConfig(config.id, targetFolderId); + resolved++; + } catch (error) { + console.error(`Failed to resolve orphan config ${config.id}:`, error); + } + } + + return resolved; +}; + +const createFolderAtPath = async (pathString: string): Promise => { + const tree = await browser.bookmarks.getTree(); + + const parts = pathString.split(" > ").filter(Boolean); + if (parts.length === 0) throw new Error("Invalid folder path"); + + const rootChildren = tree[0].children ?? []; + const rootFolder = rootChildren.find((c) => c.title === parts[0]); + + if (!rootFolder?.id) { + throw new Error(`Root folder not found: ${parts[0]}`); + } + + let currentId = rootFolder.id; + + for (let i = 1; i < parts.length; i++) { + currentId = await getOrCreateFolder(currentId, parts[i]); + } + + return currentId; +}; + +export const resolveOrphanConfig = async ( + id: string, + targetFolderId: string, +): Promise => { + const { [SYNC_KEY]: configs } = await browser.storage.sync.get(SYNC_KEY); + const config = (configs as SyncConnectionsStore)?.[id]; + if (!config) throw new Error(`Config not found: ${id}`); + + const state: ConnectionState = { + targetFolderId, + lastSyncedAt: null, + lastSyncedCommitSha: null, + lastSyncError: null, + }; + + await browser.storage.local.get(LOCAL_STATE_KEY).then((result) => { + const states = (result[LOCAL_STATE_KEY] ?? {}) as LocalConnectionStateStore; + states[id] = state; + return browser.storage.local.set({ [LOCAL_STATE_KEY]: states }); + }); + + return { ...config, ...state }; +}; + +export const cleanupDeletedConnections = async (): Promise => { + const [configs, states] = await Promise.all([ + browser.storage.sync + .get(SYNC_KEY) + .then((r) => (r[SYNC_KEY] ?? {}) as SyncConnectionsStore), + browser.storage.local + .get(LOCAL_STATE_KEY) + .then((r) => (r[LOCAL_STATE_KEY] ?? {}) as LocalConnectionStateStore), + ]); + + const orphanStateIds = Object.keys(states).filter((id) => !(id in configs)); + + if (orphanStateIds.length > 0) { + for (const id of orphanStateIds) { + delete states[id]; + } + await browser.storage.local.set({ [LOCAL_STATE_KEY]: states }); + } +}; From 5e6a9d0267ac707bf02fdb79d40b77319f0d623b Mon Sep 17 00:00:00 2001 From: Masaki Kobayashi Date: Thu, 5 Feb 2026 09:18:39 +0900 Subject: [PATCH 4/5] feat: wire orphan resolution and cleanup into the options page On mount, run cleanupDeletedConnections (removes stale local state) and autoResolveOrphanConfigs (creates folders + local state for configs that arrived via chrome.storage.sync from another browser), then refresh the connection list. Also listen to chrome.storage.onChanged: when the sync store changes, repeat cleanup + resolution and refresh if any new connections were resolved. This keeps the UI in sync without a page reload when a connection is added or removed on another browser. --- entrypoints/options/App.tsx | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/entrypoints/options/App.tsx b/entrypoints/options/App.tsx index 122041f..02453a8 100644 --- a/entrypoints/options/App.tsx +++ b/entrypoints/options/App.tsx @@ -1,4 +1,8 @@ import { useEffect, useState } from "react"; +import { + autoResolveOrphanConfigs, + cleanupDeletedConnections, +} from "@/lib/storage/orphan-configs.ts"; import type { Connection } from "@/lib/types/connection"; import { AddConnectionModal } from "./components/AddConnectionModal.tsx"; import { ConnectionList } from "./components/ConnectionList.tsx"; @@ -11,6 +15,8 @@ import { useRepositories } from "./hooks/useRepositories.ts"; import { useSync } from "./hooks/useSync.ts"; import { type Toast, useToast } from "./hooks/useToast.ts"; +const SYNC_KEY = "sync:gitmarks_connections"; + const ToastList = ({ toasts, onRemove, @@ -89,6 +95,34 @@ const App = () => { } }, [loginOpen, authState]); + useEffect(() => { + const initializeStorage = async () => { + await cleanupDeletedConnections(); + await autoResolveOrphanConfigs(); + await refreshConnections(); + }; + + void initializeStorage(); + + const handleStorageChange = ( + changes: Record, + areaName: string, + ) => { + if (areaName === "sync" && SYNC_KEY in changes) { + void (async () => { + await cleanupDeletedConnections(); + const resolvedCount = await autoResolveOrphanConfigs(); + if (resolvedCount > 0) { + await refreshConnections(); + } + })(); + } + }; + + browser.storage.onChanged.addListener(handleStorageChange); + return () => browser.storage.onChanged.removeListener(handleStorageChange); + }, [refreshConnections]); + const handleAddClick = () => { if (!authenticated) { handleSignIn(); From 05840242fea5ba8fbc09fe0af29864e98866cb50 Mon Sep 17 00:00:00 2001 From: Masaki Kobayashi Date: Thu, 5 Feb 2026 09:18:42 +0900 Subject: [PATCH 5/5] docs: update AGENTS.md with split-storage architecture Document the new cross-browser sync model: ConnectionConfig/ ConnectionState type split, dual chrome.storage layout, orphan-config resolution flow, and the updated data-flow and storage-architecture sections. --- AGENTS.md | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 04632af..ab2cd52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,7 @@ Chrome Extension that syncs GitHub repository manifest files to Chrome bookmarks ### Key Features - GitHub OAuth Device Flow authentication - Repository connection management +- Cross-browser connection settings sync (via chrome.storage.sync) - Manifest file parsing from GitHub repos - Automatic periodic sync (hourly) + manual sync - Skip-if-unchanged optimization using commit SHA @@ -79,9 +80,16 @@ Chrome Extension that syncs GitHub repository manifest files to Chrome bookmarks ### Data Flow -1. **Auth**: GitHub OAuth Device Flow → token stored in chrome.storage +1. **Auth**: GitHub OAuth Device Flow → token stored in chrome.storage.local 2. **Add Connection**: User selects repo → target folder → connection saved -3. **Sync**: + - Configuration (repo settings, enabled status) → chrome.storage.sync + - State (folder IDs, sync timestamps) → chrome.storage.local +3. **Cross-Browser Sync**: + - Connection settings sync across browsers via chrome.storage.sync + - Browser-specific state (folder IDs) remains local + - Orphan configs (synced without local state) auto-resolve on mount + - Deleted connections cleanup orphaned local state +4. **Sync**: - Fetch latest commit SHA for `srcDir` - Skip if unchanged (unless forced) - Fetch `manifest.json` from repo @@ -92,11 +100,26 @@ Chrome Extension that syncs GitHub repository manifest files to Chrome bookmarks ### Key Types -- `Connection`: Repo config + sync state +- `ConnectionConfig`: Synced across browsers (repo settings, enabled status) +- `ConnectionState`: Browser-specific (folder IDs, sync timestamps) +- `Connection`: Merged type (ConnectionConfig & ConnectionState) for backward compatibility - `ManifestBookmark`: `{ name, location }` from manifest.json - `ResolvedBookmark`: `{ name, url }` after processing ### External APIs - GitHub REST API: Repos, contents, commits, user -- Chrome Extension API: Storage, Bookmarks, Alarms +- Chrome Extension API: Storage (sync + local), Bookmarks, Alarms + +### Storage Architecture + +**Split Storage for Cross-Browser Sync:** +- `chrome.storage.sync` - Connection configurations (synced across browsers) + - Key: `sync:gitmarks_connections` + - Includes: repo settings, target folder path, enabled status +- `chrome.storage.local` - Browser-specific state (local only) + - Key: `local:gitmarks_connection_state` + - Includes: folder IDs, sync timestamps, errors +- `chrome.storage.local` - User data (local only) + - Keys: `local:gitmarks_user`, `local:github_access_token` + - Each browser requires separate authentication