diff --git a/apps/weblibre/drift_schemas/tabs/drift_schema_v15.json b/apps/weblibre/drift_schemas/tabs/drift_schema_v15.json new file mode 100644 index 00000000..6e6ecf8d --- /dev/null +++ b/apps/weblibre/drift_schemas/tabs/drift_schema_v15.json @@ -0,0 +1,1338 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "container", + "was_declared_in_moor": true, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "color", + "getter_name": "color", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const ColorConverter()", + "dart_type_name": "Color" + } + }, + { + "name": "order_key", + "getter_name": "orderKey", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_pinned", + "getter_name": "isPinned", + "moor_type": "bool", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 0", + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "metadata", + "getter_name": "metadata", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const ContainerMetadataConverter()", + "dart_type_name": "ContainerMetadata" + } + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 1, + "references": [ + 0 + ], + "type": "table", + "data": { + "name": "tab", + "was_declared_in_moor": true, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "source", + "getter_name": "source", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(TabSource.values)", + "dart_type_name": "TabSource" + } + }, + { + "name": "parent_id", + "getter_name": "parentId", + "moor_type": "string", + "nullable": true, + "customConstraints": "REFERENCES tab(id)ON DELETE SET NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "container_id", + "getter_name": "containerId", + "moor_type": "string", + "nullable": true, + "customConstraints": "REFERENCES container(id)ON DELETE CASCADE", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "container", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "order_key", + "getter_name": "orderKey", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const UriConverterNullable()", + "dart_type_name": "Uri?" + } + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "tab_mode", + "getter_name": "tabMode", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 0", + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(TabModeDbValue.values)", + "dart_type_name": "TabModeDbValue" + } + }, + { + "name": "isolation_context_id", + "getter_name": "isolationContextId", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_pinned", + "getter_name": "isPinned", + "moor_type": "bool", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 0", + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_probably_readerable", + "getter_name": "isProbablyReaderable", + "moor_type": "bool", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_markdown", + "getter_name": "extractedContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_markdown", + "getter_name": "fullContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "timestamp", + "getter_name": "timestamp", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "content_hash", + "getter_name": "contentHash", + "moor_type": "int", + "nullable": true, + "customConstraints": "GENERATED ALWAYS AS (generate_content_hash(title, extracted_content_plain, full_content_plain)) VIRTUAL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "generated_as": { + "dart_expression": { + "elements": [ + "const ", + { + "lexeme": "CustomExpression", + "import_uri": "package:drift/drift.dart" + }, + "('generate_content_hash(title, extracted_content_plain, full_content_plain)')" + ] + }, + "stored": false + } + } + ] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [ + "CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))" + ] + } + }, + { + "id": 2, + "references": [], + "type": "table", + "data": { + "name": "closed_tab_tombstone", + "was_declared_in_moor": true, + "columns": [ + { + "name": "tab_id", + "getter_name": "tabId", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "closed_at", + "getter_name": "closedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 3, + "references": [ + 1 + ], + "type": "index", + "data": { + "on": 1, + "name": "idx_tab_parent_container", + "sql": "CREATE INDEX idx_tab_parent_container ON tab(parent_id, container_id);", + "unique": false, + "columns": [] + } + }, + { + "id": 4, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "capture_tab", + "was_declared_in_moor": true, + "columns": [ + { + "name": "tab_id", + "getter_name": "tabId", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL PRIMARY KEY REFERENCES tab(id)ON DELETE CASCADE", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key", + { + "foreign_key": { + "to": { + "table": "tab", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "capture_id", + "getter_name": "captureId", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "source_url", + "getter_name": "sourceUrl", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "status", + "getter_name": "status", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 'pending'", + "default_dart": "const CustomExpression('\\'pending\\'')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 5, + "references": [ + 4 + ], + "type": "index", + "data": { + "on": 4, + "name": "idx_capture_tab_capture_id", + "sql": "CREATE INDEX idx_capture_tab_capture_id ON capture_tab(capture_id);", + "unique": false, + "columns": [] + } + }, + { + "id": 6, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "tab_fts", + "was_declared_in_moor": true, + "columns": [ + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": true, + "create_virtual_stmt": "CREATE VIRTUAL TABLE \"tab_fts\" USING fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize=\"trigram\")", + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 7, + "references": [ + 1 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1 + ], + "name": "tab_maintain_parent_chain_on_delete", + "sql": "CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN\n -- Update all children of the deleted row to point to its parent (grandparent)\n -- Only if the grandparent exists, otherwise set to NULL\n UPDATE tab\n SET parent_id = CASE\n WHEN OLD.parent_id IS NOT NULL AND\n EXISTS(SELECT 1 FROM tab WHERE id = OLD.parent_id)\n THEN OLD.parent_id\n ELSE NULL\n END\n WHERE parent_id = OLD.id;\nEND;" + } + }, + { + "id": 8, + "references": [ + 1, + 6 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 6 + ], + "name": "tab_after_insert", + "sql": "CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN\n INSERT INTO \n tab_fts(rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES (new.rowid, new.title, new.url, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + }, + { + "id": 9, + "references": [ + 1, + 6 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 6 + ], + "name": "tab_after_delete", + "sql": "CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN\n INSERT INTO \n tab_fts(tab_fts, rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES('delete', old.rowid, old.title, old.url, old.extracted_content_plain, old.full_content_plain);\nEND;" + } + }, + { + "id": 10, + "references": [ + 1, + 6 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 6 + ], + "name": "tab_after_update", + "sql": "CREATE TRIGGER tab_after_update AFTER UPDATE OF\n title, url, extracted_content_plain, full_content_plain\nON tab\nWHEN OLD.content_hash IS NOT NEW.content_hash\n OR OLD.url IS NOT NEW.url\nBEGIN\n INSERT INTO\n tab_fts(tab_fts, rowid, title, url, extracted_content_plain, full_content_plain)\n VALUES('delete', old.rowid, old.title, old.url, old.extracted_content_plain, old.full_content_plain);\n INSERT INTO\n tab_fts(rowid, title, url, extracted_content_plain, full_content_plain)\n VALUES (new.rowid, new.title, new.url, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + }, + { + "id": 11, + "references": [], + "type": "table", + "data": { + "name": "local_index_setting", + "was_declared_in_moor": true, + "columns": [ + { + "name": "key", + "getter_name": "key", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "value", + "getter_name": "value", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "strict": true + } + }, + { + "id": 12, + "references": [], + "type": "table", + "data": { + "name": "history", + "was_declared_in_moor": true, + "columns": [ + { + "name": "url_canonical", + "getter_name": "urlCanonical", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "url_host", + "getter_name": "urlHost", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url_path", + "getter_name": "urlPath", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_probably_readerable", + "getter_name": "isProbablyReaderable", + "moor_type": "bool", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_markdown", + "getter_name": "extractedContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_markdown", + "getter_name": "fullContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "content_hash", + "getter_name": "contentHash", + "moor_type": "int", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "observed_at", + "getter_name": "observedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "observed_count", + "getter_name": "observedCount", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 1", + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 13, + "references": [ + 12 + ], + "type": "index", + "data": { + "on": 12, + "name": "idx_history_host", + "sql": "CREATE INDEX idx_history_host ON history(url_host);", + "unique": false, + "columns": [] + } + }, + { + "id": 14, + "references": [ + 12 + ], + "type": "index", + "data": { + "on": 12, + "name": "idx_history_observed", + "sql": "CREATE INDEX idx_history_observed ON history(observed_at DESC);", + "unique": false, + "columns": [] + } + }, + { + "id": 15, + "references": [ + 12 + ], + "type": "table", + "data": { + "name": "history_fts", + "was_declared_in_moor": true, + "columns": [ + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url_host", + "getter_name": "urlHost", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url_path", + "getter_name": "urlPath", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": true, + "create_virtual_stmt": "CREATE VIRTUAL TABLE \"history_fts\" USING fts5(title, url_host, url_path, extracted_content_plain, full_content_plain, content=history, tokenize=\"trigram\")", + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 16, + "references": [ + 12, + 15 + ], + "type": "trigger", + "data": { + "on": 12, + "references_in_body": [ + 12, + 15 + ], + "name": "history_after_insert", + "sql": "CREATE TRIGGER history_after_insert AFTER INSERT ON history BEGIN\n INSERT INTO\n history_fts(rowid, title, url_host, url_path, extracted_content_plain, full_content_plain)\n VALUES (new.rowid, new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + }, + { + "id": 17, + "references": [ + 12, + 15 + ], + "type": "trigger", + "data": { + "on": 12, + "references_in_body": [ + 12, + 15 + ], + "name": "history_after_delete", + "sql": "CREATE TRIGGER history_after_delete AFTER DELETE ON history BEGIN\n INSERT INTO\n history_fts(history_fts, rowid, title, url_host, url_path, extracted_content_plain, full_content_plain)\n VALUES('delete', old.rowid, old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);\nEND;" + } + }, + { + "id": 18, + "references": [ + 12, + 15 + ], + "type": "trigger", + "data": { + "on": 12, + "references_in_body": [ + 12, + 15 + ], + "name": "history_after_update", + "sql": "CREATE TRIGGER history_after_update AFTER UPDATE OF\n title, url_host, url_path, extracted_content_plain, full_content_plain\nON history BEGIN\n INSERT INTO\n history_fts(history_fts, rowid, title, url_host, url_path, extracted_content_plain, full_content_plain)\n VALUES('delete', old.rowid, old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);\n INSERT INTO\n history_fts(rowid, title, url_host, url_path, extracted_content_plain, full_content_plain)\n VALUES (new.rowid, new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + }, + { + "id": 19, + "references": [ + 1, + 11, + 0, + 12 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 11, + 0, + 12 + ], + "name": "tab_to_history_on_insert", + "sql": "CREATE TRIGGER tab_to_history_on_insert AFTER INSERT ON tab\nWHEN NEW.url IS NOT NULL\n AND url_indexable(CAST(NEW.url AS TEXT)) = 1\n AND (SELECT value FROM local_index_setting WHERE \"key\" = 'enabled') = 1\n AND (NEW.tab_mode != 1\n OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)\n AND NOT EXISTS (\n SELECT 1 FROM container\n WHERE container.id = NEW.container_id\n AND json_extract(container.metadata, '$.excludeFromIndex') = 1\n )\nBEGIN\n INSERT INTO history (\n url_canonical, url_host, url_path, title, is_probably_readerable,\n extracted_content_markdown, extracted_content_plain,\n full_content_markdown, full_content_plain,\n content_hash, observed_at, observed_count\n ) VALUES (\n url_canonical(CAST(NEW.url AS TEXT)),\n url_host(CAST(NEW.url AS TEXT)),\n url_path(CAST(NEW.url AS TEXT)),\n NEW.title,\n NEW.is_probably_readerable,\n NEW.extracted_content_markdown,\n NEW.extracted_content_plain,\n NEW.full_content_markdown,\n NEW.full_content_plain,\n NEW.content_hash,\n strftime('%s','now') * 1000,\n 1\n )\n ON CONFLICT(url_canonical) DO UPDATE SET\n title = COALESCE(excluded.title, history.title),\n is_probably_readerable = excluded.is_probably_readerable,\n extracted_content_markdown = excluded.extracted_content_markdown,\n extracted_content_plain = excluded.extracted_content_plain,\n full_content_markdown = excluded.full_content_markdown,\n full_content_plain = excluded.full_content_plain,\n content_hash = excluded.content_hash,\n observed_at = excluded.observed_at,\n observed_count = history.observed_count + 1\n WHERE history.content_hash IS NOT excluded.content_hash;\nEND;" + } + }, + { + "id": 20, + "references": [ + 1, + 11, + 0, + 12 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 11, + 0, + 12 + ], + "name": "tab_to_history_on_update", + "sql": "CREATE TRIGGER tab_to_history_on_update AFTER UPDATE OF\n title, url, extracted_content_plain, extracted_content_markdown,\n full_content_plain, full_content_markdown, is_probably_readerable\nON tab\nWHEN NEW.url IS NOT NULL\n AND url_indexable(CAST(NEW.url AS TEXT)) = 1\n AND (OLD.content_hash IS NOT NEW.content_hash\n OR OLD.url IS NOT NEW.url)\n AND (SELECT value FROM local_index_setting WHERE \"key\" = 'enabled') = 1\n AND (NEW.tab_mode != 1\n OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)\n AND NOT EXISTS (\n SELECT 1 FROM container\n WHERE container.id = NEW.container_id\n AND json_extract(container.metadata, '$.excludeFromIndex') = 1\n )\nBEGIN\n INSERT INTO history (\n url_canonical, url_host, url_path, title, is_probably_readerable,\n extracted_content_markdown, extracted_content_plain,\n full_content_markdown, full_content_plain,\n content_hash, observed_at, observed_count\n ) VALUES (\n url_canonical(CAST(NEW.url AS TEXT)),\n url_host(CAST(NEW.url AS TEXT)),\n url_path(CAST(NEW.url AS TEXT)),\n NEW.title,\n NEW.is_probably_readerable,\n NEW.extracted_content_markdown,\n NEW.extracted_content_plain,\n NEW.full_content_markdown,\n NEW.full_content_plain,\n NEW.content_hash,\n strftime('%s','now') * 1000,\n 1\n )\n ON CONFLICT(url_canonical) DO UPDATE SET\n title = COALESCE(excluded.title, history.title),\n is_probably_readerable = excluded.is_probably_readerable,\n extracted_content_markdown = excluded.extracted_content_markdown,\n extracted_content_plain = excluded.extracted_content_plain,\n full_content_markdown = excluded.full_content_markdown,\n full_content_plain = excluded.full_content_plain,\n content_hash = excluded.content_hash,\n observed_at = excluded.observed_at,\n observed_count = history.observed_count + 1\n WHERE history.content_hash IS NOT excluded.content_hash;\nEND;" + } + }, + { + "id": 21, + "references": [ + 1, + 11, + 12, + 0 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 11, + 12, + 0 + ], + "name": "tab_to_history_on_container_update", + "sql": "CREATE TRIGGER tab_to_history_on_container_update AFTER UPDATE OF container_id\nON tab\nWHEN NEW.url IS NOT NULL\n AND url_indexable(CAST(NEW.url AS TEXT)) = 1\n AND (SELECT value FROM local_index_setting WHERE \"key\" = 'enabled') = 1\nBEGIN\n DELETE FROM history\n WHERE url_canonical = url_canonical(CAST(NEW.url AS TEXT))\n AND NOT EXISTS (\n SELECT 1\n FROM tab AS candidate\n WHERE candidate.url IS NOT NULL\n AND url_indexable(CAST(candidate.url AS TEXT)) = 1\n AND url_canonical(CAST(candidate.url AS TEXT)) = history.url_canonical\n AND (candidate.tab_mode != 1\n OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)\n AND NOT EXISTS (\n SELECT 1 FROM container\n WHERE container.id = candidate.container_id\n AND json_extract(container.metadata, '$.excludeFromIndex') = 1\n )\n );\n\n INSERT INTO history (\n url_canonical, url_host, url_path, title, is_probably_readerable,\n extracted_content_markdown, extracted_content_plain,\n full_content_markdown, full_content_plain,\n content_hash, observed_at, observed_count\n )\n SELECT\n url_canonical(CAST(candidate.url AS TEXT)),\n url_host(CAST(candidate.url AS TEXT)),\n url_path(CAST(candidate.url AS TEXT)),\n candidate.title,\n candidate.is_probably_readerable,\n candidate.extracted_content_markdown,\n candidate.extracted_content_plain,\n candidate.full_content_markdown,\n candidate.full_content_plain,\n candidate.content_hash,\n strftime('%s','now') * 1000,\n 1\n FROM tab AS candidate\n WHERE candidate.url IS NOT NULL\n AND url_indexable(CAST(candidate.url AS TEXT)) = 1\n AND url_canonical(CAST(candidate.url AS TEXT)) = url_canonical(CAST(NEW.url AS TEXT))\n AND (candidate.tab_mode != 1\n OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)\n AND NOT EXISTS (\n SELECT 1 FROM container\n WHERE container.id = candidate.container_id\n AND json_extract(container.metadata, '$.excludeFromIndex') = 1\n )\n ORDER BY candidate.timestamp DESC, candidate.rowid DESC\n LIMIT 1\n ON CONFLICT(url_canonical) DO UPDATE SET\n title = COALESCE(excluded.title, history.title),\n is_probably_readerable = excluded.is_probably_readerable,\n extracted_content_markdown = excluded.extracted_content_markdown,\n extracted_content_plain = excluded.extracted_content_plain,\n full_content_markdown = excluded.full_content_markdown,\n full_content_plain = excluded.full_content_plain,\n content_hash = excluded.content_hash,\n observed_at = excluded.observed_at,\n observed_count = history.observed_count + 1;\nEND;" + } + }, + { + "id": 22, + "references": [ + 0, + 11, + 12, + 1 + ], + "type": "trigger", + "data": { + "on": 0, + "references_in_body": [ + 0, + 11, + 12, + 1 + ], + "name": "container_to_history_on_metadata_update", + "sql": "CREATE TRIGGER container_to_history_on_metadata_update AFTER UPDATE OF metadata\nON container\nWHEN COALESCE(json_extract(OLD.metadata, '$.excludeFromIndex') = 1, 0)\n != COALESCE(json_extract(NEW.metadata, '$.excludeFromIndex') = 1, 0)\n AND (SELECT value FROM local_index_setting WHERE \"key\" = 'enabled') = 1\nBEGIN\n DELETE FROM history\n WHERE url_canonical IN (\n SELECT DISTINCT url_canonical(CAST(affected.url AS TEXT))\n FROM tab AS affected\n WHERE affected.container_id = NEW.id\n AND affected.url IS NOT NULL\n AND url_indexable(CAST(affected.url AS TEXT)) = 1\n )\n AND NOT EXISTS (\n SELECT 1\n FROM tab AS candidate\n WHERE candidate.url IS NOT NULL\n AND url_indexable(CAST(candidate.url AS TEXT)) = 1\n AND url_canonical(CAST(candidate.url AS TEXT)) = history.url_canonical\n AND (candidate.tab_mode != 1\n OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)\n AND NOT EXISTS (\n SELECT 1 FROM container\n WHERE container.id = candidate.container_id\n AND json_extract(container.metadata, '$.excludeFromIndex') = 1\n )\n );\n\n INSERT INTO history (\n url_canonical, url_host, url_path, title, is_probably_readerable,\n extracted_content_markdown, extracted_content_plain,\n full_content_markdown, full_content_plain,\n content_hash, observed_at, observed_count\n )\n SELECT\n url_canonical(CAST(candidate.url AS TEXT)),\n url_host(CAST(candidate.url AS TEXT)),\n url_path(CAST(candidate.url AS TEXT)),\n candidate.title,\n candidate.is_probably_readerable,\n candidate.extracted_content_markdown,\n candidate.extracted_content_plain,\n candidate.full_content_markdown,\n candidate.full_content_plain,\n candidate.content_hash,\n strftime('%s','now') * 1000,\n 1\n FROM tab AS candidate\n WHERE candidate.url IS NOT NULL\n AND url_indexable(CAST(candidate.url AS TEXT)) = 1\n AND url_canonical(CAST(candidate.url AS TEXT)) IN (\n SELECT DISTINCT url_canonical(CAST(affected.url AS TEXT))\n FROM tab AS affected\n WHERE affected.container_id = NEW.id\n AND affected.url IS NOT NULL\n AND url_indexable(CAST(affected.url AS TEXT)) = 1\n )\n AND (candidate.tab_mode != 1\n OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)\n AND NOT EXISTS (\n SELECT 1 FROM container\n WHERE container.id = candidate.container_id\n AND json_extract(container.metadata, '$.excludeFromIndex') = 1\n )\n AND NOT EXISTS (\n SELECT 1\n FROM tab AS newer\n WHERE newer.url IS NOT NULL\n AND url_indexable(CAST(newer.url AS TEXT)) = 1\n AND url_canonical(CAST(newer.url AS TEXT)) = url_canonical(CAST(candidate.url AS TEXT))\n AND (newer.tab_mode != 1\n OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)\n AND NOT EXISTS (\n SELECT 1 FROM container\n WHERE container.id = newer.container_id\n AND json_extract(container.metadata, '$.excludeFromIndex') = 1\n )\n AND (\n newer.timestamp > candidate.timestamp\n OR (newer.timestamp = candidate.timestamp AND newer.rowid > candidate.rowid)\n )\n )\n ON CONFLICT(url_canonical) DO UPDATE SET\n title = COALESCE(excluded.title, history.title),\n is_probably_readerable = excluded.is_probably_readerable,\n extracted_content_markdown = excluded.extracted_content_markdown,\n extracted_content_plain = excluded.extracted_content_plain,\n full_content_markdown = excluded.full_content_markdown,\n full_content_plain = excluded.full_content_plain,\n content_hash = excluded.content_hash,\n observed_at = excluded.observed_at,\n observed_count = history.observed_count + 1;\nEND;" + } + }, + { + "id": 23, + "references": [ + 0 + ], + "type": "table", + "data": { + "name": "visit_container", + "was_declared_in_moor": true, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL PRIMARY KEY AUTOINCREMENT", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "auto-increment" + ] + }, + { + "name": "raw_url", + "getter_name": "rawUrl", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url_canonical", + "getter_name": "urlCanonical", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "visit_time", + "getter_name": "visitTime", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "container_id", + "getter_name": "containerId", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL REFERENCES container(id)ON DELETE CASCADE", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "container", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 24, + "references": [ + 23 + ], + "type": "index", + "data": { + "on": 23, + "name": "idx_vc_canonical", + "sql": "CREATE INDEX idx_vc_canonical ON visit_container(url_canonical, visit_time);", + "unique": false, + "columns": [] + } + }, + { + "id": 25, + "references": [ + 23 + ], + "type": "index", + "data": { + "on": 23, + "name": "idx_vc_container", + "sql": "CREATE INDEX idx_vc_container ON visit_container(container_id, visit_time DESC);", + "unique": false, + "columns": [] + } + } + ], + "fixed_sql": [ + { + "name": "container", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"container\" (\"id\" TEXT PRIMARY KEY NOT NULL, \"name\" TEXT, \"color\" INTEGER NOT NULL, \"order_key\" TEXT NOT NULL, \"is_pinned\" INTEGER NOT NULL DEFAULT 0, \"metadata\" TEXT);" + } + ] + }, + { + "name": "tab", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"tab\" (\"id\" TEXT PRIMARY KEY NOT NULL, \"source\" INTEGER NOT NULL, \"parent_id\" TEXT REFERENCES tab(id)ON DELETE SET NULL, \"container_id\" TEXT REFERENCES container(id)ON DELETE CASCADE, \"order_key\" TEXT NOT NULL, \"url\" TEXT, \"title\" TEXT, \"tab_mode\" INTEGER NOT NULL DEFAULT 0, \"isolation_context_id\" TEXT, \"is_pinned\" INTEGER NOT NULL DEFAULT 0, \"is_probably_readerable\" INTEGER, \"extracted_content_markdown\" TEXT, \"extracted_content_plain\" TEXT, \"full_content_markdown\" TEXT, \"full_content_plain\" TEXT, \"timestamp\" INTEGER NOT NULL, \"content_hash\" INTEGER GENERATED ALWAYS AS (generate_content_hash(title, extracted_content_plain, full_content_plain)) VIRTUAL, CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL)));" + } + ] + }, + { + "name": "closed_tab_tombstone", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"closed_tab_tombstone\" (\"tab_id\" TEXT PRIMARY KEY NOT NULL, \"closed_at\" INTEGER NOT NULL);" + } + ] + }, + { + "name": "idx_tab_parent_container", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)" + } + ] + }, + { + "name": "capture_tab", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"capture_tab\" (\"tab_id\" TEXT NOT NULL PRIMARY KEY REFERENCES tab(id)ON DELETE CASCADE, \"capture_id\" TEXT NOT NULL, \"source_url\" TEXT NOT NULL, \"status\" TEXT NOT NULL DEFAULT 'pending', \"created_at\" INTEGER NOT NULL);" + } + ] + }, + { + "name": "idx_capture_tab_capture_id", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_capture_tab_capture_id ON capture_tab (capture_id)" + } + ] + }, + { + "name": "tab_fts", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"tab_fts\" USING fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize=\"trigram\");" + } + ] + }, + { + "name": "tab_maintain_parent_chain_on_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END" + } + ] + }, + { + "name": "tab_after_insert", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts (\"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END" + } + ] + }, + { + "name": "tab_after_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, \"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END" + } + ] + }, + { + "name": "tab_after_update", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_update AFTER UPDATE OF title, url, extracted_content_plain, full_content_plain ON tab WHEN OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url BEGIN INSERT INTO tab_fts (tab_fts, \"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts (\"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END" + } + ] + }, + { + "name": "local_index_setting", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"local_index_setting\" (\"key\" TEXT PRIMARY KEY NOT NULL, \"value\" INTEGER NOT NULL) STRICT;" + } + ] + }, + { + "name": "history", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"history\" (\"url_canonical\" TEXT PRIMARY KEY NOT NULL, \"url_host\" TEXT NOT NULL, \"url_path\" TEXT, \"title\" TEXT, \"is_probably_readerable\" INTEGER, \"extracted_content_markdown\" TEXT, \"extracted_content_plain\" TEXT, \"full_content_markdown\" TEXT, \"full_content_plain\" TEXT, \"content_hash\" INTEGER, \"observed_at\" INTEGER NOT NULL, \"observed_count\" INTEGER NOT NULL DEFAULT 1);" + } + ] + }, + { + "name": "idx_history_host", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_history_host ON history (url_host)" + } + ] + }, + { + "name": "idx_history_observed", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_history_observed ON history (observed_at DESC)" + } + ] + }, + { + "name": "history_fts", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"history_fts\" USING fts5(title, url_host, url_path, extracted_content_plain, full_content_plain, content=history, tokenize=\"trigram\");" + } + ] + }, + { + "name": "history_after_insert", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER history_after_insert AFTER INSERT ON history BEGIN INSERT INTO history_fts (\"rowid\", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END" + } + ] + }, + { + "name": "history_after_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER history_after_delete AFTER DELETE ON history BEGIN INSERT INTO history_fts (history_fts, \"rowid\", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);END" + } + ] + }, + { + "name": "history_after_update", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER history_after_update AFTER UPDATE OF title, url_host, url_path, extracted_content_plain, full_content_plain ON history BEGIN INSERT INTO history_fts (history_fts, \"rowid\", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);INSERT INTO history_fts (\"rowid\", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END" + } + ] + }, + { + "name": "tab_to_history_on_insert", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_to_history_on_insert AFTER INSERT ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND (SELECT value FROM local_index_setting WHERE \"key\" = 'enabled') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = NEW.container_id AND json_extract(container.metadata, '$.excludeFromIndex') = 1) BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime('%s', 'now') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END" + } + ] + }, + { + "name": "tab_to_history_on_update", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_to_history_on_update AFTER UPDATE OF title, url, extracted_content_plain, extracted_content_markdown, full_content_plain, full_content_markdown, is_probably_readerable ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND(OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url)AND (SELECT value FROM local_index_setting WHERE \"key\" = 'enabled') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = NEW.container_id AND json_extract(container.metadata, '$.excludeFromIndex') = 1) BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime('%s', 'now') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END" + } + ] + }, + { + "name": "tab_to_history_on_container_update", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_to_history_on_container_update AFTER UPDATE OF container_id ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND (SELECT value FROM local_index_setting WHERE \"key\" = 'enabled') = 1 BEGIN DELETE FROM history WHERE url_canonical = url_canonical(CAST(NEW.url AS TEXT)) AND NOT EXISTS (SELECT 1 FROM tab AS candidate WHERE candidate.url IS NOT NULL AND url_indexable(CAST(candidate.url AS TEXT)) = 1 AND url_canonical(CAST(candidate.url AS TEXT)) = history.url_canonical AND(candidate.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = candidate.container_id AND json_extract(container.metadata, '$.excludeFromIndex') = 1));INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) SELECT url_canonical(CAST(candidate.url AS TEXT)), url_host(CAST(candidate.url AS TEXT)), url_path(CAST(candidate.url AS TEXT)), candidate.title, candidate.is_probably_readerable, candidate.extracted_content_markdown, candidate.extracted_content_plain, candidate.full_content_markdown, candidate.full_content_plain, candidate.content_hash, strftime('%s', 'now') * 1000, 1 FROM tab AS candidate WHERE candidate.url IS NOT NULL AND url_indexable(CAST(candidate.url AS TEXT)) = 1 AND url_canonical(CAST(candidate.url AS TEXT)) = url_canonical(CAST(NEW.url AS TEXT)) AND(candidate.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = candidate.container_id AND json_extract(container.metadata, '$.excludeFromIndex') = 1) ORDER BY candidate.timestamp DESC, candidate.\"rowid\" DESC LIMIT 1 ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1;END" + } + ] + }, + { + "name": "container_to_history_on_metadata_update", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER container_to_history_on_metadata_update AFTER UPDATE OF metadata ON container WHEN COALESCE(json_extract(OLD.metadata, '$.excludeFromIndex') = 1, 0) != COALESCE(json_extract(NEW.metadata, '$.excludeFromIndex') = 1, 0) AND (SELECT value FROM local_index_setting WHERE \"key\" = 'enabled') = 1 BEGIN DELETE FROM history WHERE url_canonical IN (SELECT DISTINCT url_canonical(CAST(affected.url AS TEXT)) FROM tab AS affected WHERE affected.container_id = NEW.id AND affected.url IS NOT NULL AND url_indexable(CAST(affected.url AS TEXT)) = 1) AND NOT EXISTS (SELECT 1 FROM tab AS candidate WHERE candidate.url IS NOT NULL AND url_indexable(CAST(candidate.url AS TEXT)) = 1 AND url_canonical(CAST(candidate.url AS TEXT)) = history.url_canonical AND(candidate.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = candidate.container_id AND json_extract(container.metadata, '$.excludeFromIndex') = 1));INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) SELECT url_canonical(CAST(candidate.url AS TEXT)), url_host(CAST(candidate.url AS TEXT)), url_path(CAST(candidate.url AS TEXT)), candidate.title, candidate.is_probably_readerable, candidate.extracted_content_markdown, candidate.extracted_content_plain, candidate.full_content_markdown, candidate.full_content_plain, candidate.content_hash, strftime('%s', 'now') * 1000, 1 FROM tab AS candidate WHERE candidate.url IS NOT NULL AND url_indexable(CAST(candidate.url AS TEXT)) = 1 AND url_canonical(CAST(candidate.url AS TEXT)) IN (SELECT DISTINCT url_canonical(CAST(affected.url AS TEXT)) FROM tab AS affected WHERE affected.container_id = NEW.id AND affected.url IS NOT NULL AND url_indexable(CAST(affected.url AS TEXT)) = 1) AND(candidate.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = candidate.container_id AND json_extract(container.metadata, '$.excludeFromIndex') = 1) AND NOT EXISTS (SELECT 1 FROM tab AS newer WHERE newer.url IS NOT NULL AND url_indexable(CAST(newer.url AS TEXT)) = 1 AND url_canonical(CAST(newer.url AS TEXT)) = url_canonical(CAST(candidate.url AS TEXT)) AND(newer.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE \"key\" = 'index_private') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = newer.container_id AND json_extract(container.metadata, '$.excludeFromIndex') = 1) AND(newer.timestamp > candidate.timestamp OR(newer.timestamp = candidate.timestamp AND newer.\"rowid\" > candidate.\"rowid\"))) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1;END" + } + ] + }, + { + "name": "visit_container", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"visit_container\" (\"id\" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, \"raw_url\" TEXT NOT NULL, \"url_canonical\" TEXT NOT NULL, \"visit_time\" INTEGER NOT NULL, \"container_id\" TEXT NOT NULL REFERENCES container(id)ON DELETE CASCADE);" + } + ] + }, + { + "name": "idx_vc_canonical", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_vc_canonical ON visit_container (url_canonical, visit_time)" + } + ] + }, + { + "name": "idx_vc_container", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_vc_container ON visit_container (container_id, visit_time DESC)" + } + ] + } + ] +} \ No newline at end of file diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.dart index 2336186e..8c376fc6 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.dart @@ -49,6 +49,9 @@ class BrowserDataService extends _$BrowserDataService { case DeleteBrowsingDataType.history: await _service.deleteBrowsingHistory(); await ref.read(tabDatabaseProvider).historyDao.clear(); + // Places visits are gone; drop their container tags too so they + // don't dangle (and can't re-attach to a future same-URL visit). + await ref.read(tabDatabaseProvider).visitContainerDao.clearAll(); case DeleteBrowsingDataType.recentSearches: await ref .read(bangDataRepositoryProvider.notifier) diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart index 8252104c..a197f78b 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart @@ -49,6 +49,7 @@ import 'package:weblibre/features/geckoview/features/browser/domain/services/eng import 'package:weblibre/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_home.dart'; import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart'; +import 'package:weblibre/features/geckoview/features/history/domain/services/history_exclusion_replication.dart'; import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart'; import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; @@ -675,6 +676,19 @@ class _BrowserViewState extends ConsumerState }, ); + ref.listenManual( + fireImmediately: true, + historyExclusionReplicationProvider, + (previous, next) {}, + onError: (error, stackTrace) { + logger.e( + 'Error listening to historyExclusionReplicationProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + ref.listenManual( fireImmediately: true, articleContentProcessorServiceProvider, diff --git a/apps/weblibre/lib/features/geckoview/features/history/domain/entities/history_entry.dart b/apps/weblibre/lib/features/geckoview/features/history/domain/entities/history_entry.dart new file mode 100644 index 00000000..afffb1ff --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/history/domain/entities/history_entry.dart @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; + +/// Maximum gap (epoch millis) between a WebLibre `visit_container` relation row +/// and a Mozilla Places `VisitInfo` for them to be considered the same visit. +/// The relation's `visit_time` is captured near — not exactly at — the Places +/// record time (the native delegate stamps `System.currentTimeMillis()`), so +/// tagging/filtering matches the closest relation within this tolerance. +const int historyVisitContainerMatchWindowMs = 5000; + +/// A single history-screen row: a Mozilla Places visit (the source of truth for +/// url, title, visit type and time) annotated with the WebLibre container(s) +/// it belonged to. +/// +/// [containerIds] is the visit's resolved container tag — at most one entry for +/// a normal visit (the nearest relation within +/// [historyVisitContainerMatchWindowMs]); empty when the visit was uncontained +/// or predates history-relation recording. +class HistoryEntry with FastEquatable { + /// The underlying Places visit; still used verbatim for opening the page and + /// for the precise `(url, time)` Places delete. + final VisitInfo visit; + final List containerIds; + + /// Primary key of the `visit_container` row that this visit paired with (the + /// same one-to-one pairing that produced [containerIds]), or null when the + /// visit is uncontained. Carried on the entry so a delete can drop exactly + /// this visit's relation without re-deriving the nearest-time join — which + /// could otherwise pick a sibling same-URL visit's relation and mislabel it. + final int? containerRelationId; + + HistoryEntry({ + required this.visit, + required this.containerIds, + this.containerRelationId, + }); + + String get url => visit.url; + String? get title => visit.title; + int get visitTime => visit.visitTime; + VisitType get visitType => visit.visitType; + String? get previewImageUrl => visit.previewImageUrl; + String? get contentId => visit.contentId; + + @override + List get hashParameters => [ + visit.url, + visit.title, + visit.visitTime, + visit.visitType, + visit.previewImageUrl, + visit.contentId, + containerIds, + containerRelationId, + ]; +} + +/// One-to-one nearest-time pairing between visits and `visit_container` +/// relations that share a canonical URL, given their epoch-millis timestamps. +/// +/// Returns a map from visit index (into [visitTimes]) to the relation index +/// (into [relationTimes]) it pairs with. Pairs are assigned greedily from the +/// smallest in-window delta, consuming both sides, so a relation never tags two +/// visits and a visit never takes two relations. Shared by history annotation +/// and the per-container Places-delete mirror so they always agree on which +/// relation belongs to which visit. +Map pairVisitsToRelationsByTime( + List visitTimes, + List relationTimes, +) { + // All in-window (visit, relation) pairs, smallest delta first. + final pairs = <({int visitIndex, int relationIndex, int delta})>[]; + for (var visitIndex = 0; visitIndex < visitTimes.length; visitIndex++) { + for ( + var relationIndex = 0; + relationIndex < relationTimes.length; + relationIndex++ + ) { + final delta = (relationTimes[relationIndex] - visitTimes[visitIndex]) + .abs(); + if (delta <= historyVisitContainerMatchWindowMs) { + pairs.add(( + visitIndex: visitIndex, + relationIndex: relationIndex, + delta: delta, + )); + } + } + } + pairs.sort((a, b) => a.delta.compareTo(b.delta)); + + final relationByVisit = {}; + final usedVisits = {}; + final usedRelations = {}; + for (final pair in pairs) { + if (usedVisits.contains(pair.visitIndex) || + usedRelations.contains(pair.relationIndex)) { + continue; + } + usedVisits.add(pair.visitIndex); + usedRelations.add(pair.relationIndex); + relationByVisit[pair.visitIndex] = pair.relationIndex; + } + return relationByVisit; +} diff --git a/apps/weblibre/lib/features/geckoview/features/history/domain/entities/history_filter_options.dart b/apps/weblibre/lib/features/geckoview/features/history/domain/entities/history_filter_options.dart index 7e7a07d4..975336f7 100644 --- a/apps/weblibre/lib/features/geckoview/features/history/domain/entities/history_filter_options.dart +++ b/apps/weblibre/lib/features/geckoview/features/history/domain/entities/history_filter_options.dart @@ -33,13 +33,22 @@ class HistoryFilterOptions with FastEquatable { final DateTimeRange? dateRange; final Set visitTypes; - HistoryFilterOptions({required this.dateRange, required this.visitTypes}); + /// When non-null, restrict the timeline to visits that belonged to this + /// WebLibre container (resolved via the visit→container relation). Null shows + /// all visits, each still annotated with its own container tag. + final String? containerId; + + HistoryFilterOptions({ + required this.dateRange, + required this.visitTypes, + this.containerId, + }); HistoryFilterOptions.withDefaults() : this(dateRange: null, visitTypes: {VisitType.link}); @override - List get hashParameters => [dateRange, visitTypes]; + List get hashParameters => [dateRange, visitTypes, containerId]; factory HistoryFilterOptions.fromJson(Map json) => _$HistoryFilterOptionsFromJson(json); diff --git a/apps/weblibre/lib/features/geckoview/features/history/domain/entities/history_filter_options.g.dart b/apps/weblibre/lib/features/geckoview/features/history/domain/entities/history_filter_options.g.dart index a173f6db..e2993c38 100644 --- a/apps/weblibre/lib/features/geckoview/features/history/domain/entities/history_filter_options.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/history/domain/entities/history_filter_options.g.dart @@ -11,6 +11,8 @@ abstract class _$HistoryFilterOptionsCWProxy { HistoryFilterOptions visitTypes(Set visitTypes); + HistoryFilterOptions containerId(String? containerId); + /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `HistoryFilterOptions(...).copyWith.fieldName(value)`. /// @@ -21,6 +23,7 @@ abstract class _$HistoryFilterOptionsCWProxy { HistoryFilterOptions call({ DateTimeRange? dateRange, Set visitTypes, + String? containerId, }); } @@ -40,6 +43,10 @@ class _$HistoryFilterOptionsCWProxyImpl HistoryFilterOptions visitTypes(Set visitTypes) => call(visitTypes: visitTypes); + @override + HistoryFilterOptions containerId(String? containerId) => + call(containerId: containerId); + @override /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `HistoryFilterOptions(...).copyWith.fieldName(value)`. @@ -51,6 +58,7 @@ class _$HistoryFilterOptionsCWProxyImpl HistoryFilterOptions call({ Object? dateRange = const $CopyWithPlaceholder(), Object? visitTypes = const $CopyWithPlaceholder(), + Object? containerId = const $CopyWithPlaceholder(), }) { return HistoryFilterOptions( dateRange: dateRange == const $CopyWithPlaceholder() @@ -62,6 +70,10 @@ class _$HistoryFilterOptionsCWProxyImpl ? _value.visitTypes // ignore: cast_nullable_to_non_nullable : visitTypes as Set, + containerId: containerId == const $CopyWithPlaceholder() + ? _value.containerId + // ignore: cast_nullable_to_non_nullable + : containerId as String?, ); } } @@ -87,6 +99,7 @@ HistoryFilterOptions _$HistoryFilterOptionsFromJson( visitTypes: (json['visitTypes'] as List) .map((e) => $enumDecode(_$VisitTypeEnumMap, e)) .toSet(), + containerId: json['containerId'] as String?, ); Map _$HistoryFilterOptionsToJson( @@ -94,6 +107,7 @@ Map _$HistoryFilterOptionsToJson( ) => { 'dateRange': const DateTimeRangeConverter().toJson(instance.dateRange), 'visitTypes': instance.visitTypes.map((e) => _$VisitTypeEnumMap[e]!).toList(), + 'containerId': instance.containerId, }; const _$VisitTypeEnumMap = { diff --git a/apps/weblibre/lib/features/geckoview/features/history/domain/providers.dart b/apps/weblibre/lib/features/geckoview/features/history/domain/providers.dart index 9b7a6d8c..3162c04b 100644 --- a/apps/weblibre/lib/features/geckoview/features/history/domain/providers.dart +++ b/apps/weblibre/lib/features/geckoview/features/history/domain/providers.dart @@ -22,9 +22,14 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:riverpod/experimental/persist.dart'; import 'package:riverpod_annotation/experimental/json_persist.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/features/history/domain/entities/history_entry.dart'; import 'package:weblibre/features/geckoview/features/history/domain/entities/history_filter_options.dart'; import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/visit_container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; import 'package:weblibre/features/user/data/providers.dart'; +import 'package:weblibre/utils/url_canonical.dart'; part 'providers.g.dart'; @@ -39,6 +44,10 @@ class HistoryVisitsFilter extends _$HistoryVisitsFilter { } } + void setContainer(String? containerId) { + state = state.copyWith.containerId(containerId); + } + void reset() { state = HistoryFilterOptions.withDefaults(); } @@ -87,20 +96,113 @@ class HistoryDownloadsFilter extends _$HistoryDownloadsFilter { } } +/// Annotate Mozilla Places [visits] with the WebLibre container each belonged +/// to (from the `visit_container` relation), matched by canonical URL and +/// nearest visit time. Uncontained visits get an empty tag list. When +/// [filterContainerId] is set, only visits resolving to that container are +/// returned. +/// +/// The match is **one-to-one within each canonical URL**: every relation row +/// tags at most one visit, and every visit takes at most one relation. Without +/// this, the same URL opened in several containers one after another (or a +/// container visit followed by an uncontained one of the same URL, within the +/// [historyVisitContainerMatchWindowMs] window) would let one relation bleed +/// onto neighbouring visits — an uncontained visit stealing the previous +/// container's tag. Pairs are assigned greedily from the smallest time delta, +/// consuming both sides, which approximates the minimum-skew assignment. +Future> _annotateVisits( + VisitContainerDao dao, + List visits, { + String? filterContainerId, +}) async { + // Visit indices grouped by canonical URL (Places rows without an + // indexable/canonicalizable URL simply carry no container). + final canonicals = {}; + final visitIndicesByCanonical = >{}; + for (var i = 0; i < visits.length; i++) { + final canonical = canonicalizeUrl(visits[i].url)?.canonical; + if (canonical != null) { + canonicals.add(canonical); + (visitIndicesByCanonical[canonical] ??= []).add(i); + } + } + + final relations = await dao.relationsForCanonicalUrls(canonicals); + final byCanonical = >{}; + for (final relation in relations) { + (byCanonical[relation.urlCanonical] ??= []).add( + relation, + ); + } + + // Resolve the relation per visit via one-to-one nearest-time matching within + // each canonical URL group. + final relationByVisitIndex = {}; + for (final MapEntry(key: canonical, value: visitIndices) + in visitIndicesByCanonical.entries) { + final candidates = byCanonical[canonical]; + if (candidates == null) continue; + + final pairing = pairVisitsToRelationsByTime( + [for (final visitIndex in visitIndices) visits[visitIndex].visitTime], + [for (final candidate in candidates) candidate.visitTime], + ); + pairing.forEach((localVisitIndex, relationIndex) { + relationByVisitIndex[visitIndices[localVisitIndex]] = + candidates[relationIndex]; + }); + } + + final entries = []; + for (var i = 0; i < visits.length; i++) { + final relation = relationByVisitIndex[i]; + + if (filterContainerId != null && + relation?.containerId != filterContainerId) { + continue; + } + + entries.add( + HistoryEntry( + visit: visits[i], + containerIds: relation == null ? const [] : [relation.containerId], + containerRelationId: relation?.id, + ), + ); + } + + return entries; +} + @Riverpod() -Future> browsingHistory(Ref ref) { +Future> browsingHistory(Ref ref) async { final options = ref.watch(historyVisitsFilterProvider); - return ref + final visits = await ref .read(historyRepositoryProvider.notifier) .getDetailedVisits(options); + + return _annotateVisits( + ref.read(tabDatabaseProvider).visitContainerDao, + visits, + filterContainerId: options.containerId, + ); } @Riverpod() -Future> browsingDownloads(Ref ref) { +Future> browsingDownloads(Ref ref) async { final options = ref.watch(historyDownloadsFilterProvider); - return ref + final visits = await ref .read(historyRepositoryProvider.notifier) .getDetailedVisits(options); + + // Downloads are never recorded in the visit→container relation (it is written + // only from page-visit `onVisited` events). Do NOT run the nearest-time + // annotation here: a download sharing a canonical URL + time window with a + // contained page visit would otherwise steal that visit's tag, show a bogus + // container chip, and — on delete — drop the page visit's relation row. + return visits + .map((visit) => HistoryEntry(visit: visit, containerIds: const [])) + .toList(growable: false); } diff --git a/apps/weblibre/lib/features/geckoview/features/history/domain/providers.g.dart b/apps/weblibre/lib/features/geckoview/features/history/domain/providers.g.dart index 8c103c41..46fac05a 100644 --- a/apps/weblibre/lib/features/geckoview/features/history/domain/providers.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/history/domain/providers.g.dart @@ -44,7 +44,7 @@ final class HistoryVisitsFilterProvider } String _$historyVisitsFilterHash() => - r'a4bf6c41c9180166365084cb7c7981558ac7ad36'; + r'4c1530cfd2d94b1cdbb04fbe2df8037cb211a668'; @JsonPersist() abstract class _$HistoryVisitsFilterBase @@ -129,11 +129,13 @@ final browsingHistoryProvider = BrowsingHistoryProvider._(); final class BrowsingHistoryProvider extends $FunctionalProvider< - AsyncValue>, - List, - FutureOr> + AsyncValue>, + List, + FutureOr> > - with $FutureModifier>, $FutureProvider> { + with + $FutureModifier>, + $FutureProvider> { BrowsingHistoryProvider._() : super( from: null, @@ -150,17 +152,17 @@ final class BrowsingHistoryProvider @$internal @override - $FutureProviderElement> $createElement( + $FutureProviderElement> $createElement( $ProviderPointer pointer, ) => $FutureProviderElement(pointer); @override - FutureOr> create(Ref ref) { + FutureOr> create(Ref ref) { return browsingHistory(ref); } } -String _$browsingHistoryHash() => r'6f26228da28f6f67844551bf430025b161731bb2'; +String _$browsingHistoryHash() => r'0ed9a3d1f10091eb1a20f1c388c1ace80c1f75da'; @ProviderFor(browsingDownloads) final browsingDownloadsProvider = BrowsingDownloadsProvider._(); @@ -168,11 +170,13 @@ final browsingDownloadsProvider = BrowsingDownloadsProvider._(); final class BrowsingDownloadsProvider extends $FunctionalProvider< - AsyncValue>, - List, - FutureOr> + AsyncValue>, + List, + FutureOr> > - with $FutureModifier>, $FutureProvider> { + with + $FutureModifier>, + $FutureProvider> { BrowsingDownloadsProvider._() : super( from: null, @@ -189,17 +193,17 @@ final class BrowsingDownloadsProvider @$internal @override - $FutureProviderElement> $createElement( + $FutureProviderElement> $createElement( $ProviderPointer pointer, ) => $FutureProviderElement(pointer); @override - FutureOr> create(Ref ref) { + FutureOr> create(Ref ref) { return browsingDownloads(ref); } } -String _$browsingDownloadsHash() => r'd05e3c079c6de349ff9e910ca597ad7c78f87a16'; +String _$browsingDownloadsHash() => r'938ae4d26b4e0a3f428d3d530a71cb5d8720f317'; // ************************************************************************** // JsonGenerator diff --git a/apps/weblibre/lib/features/geckoview/features/history/domain/repositories/container_history.dart b/apps/weblibre/lib/features/geckoview/features/history/domain/repositories/container_history.dart new file mode 100644 index 00000000..20c6ab12 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/history/domain/repositories/container_history.dart @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/features/history/domain/entities/history_entry.dart'; +import 'package:weblibre/features/geckoview/features/history/domain/entities/history_filter_options.dart'; +import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; +import 'package:weblibre/utils/url_canonical.dart'; + +part 'container_history.g.dart'; + +/// Mutations that combine the visit→container relation (`visit_container`) with +/// Mozilla Places, the source of truth for the visits themselves. +@Riverpod(keepAlive: true) +class ContainerHistoryRepository extends _$ContainerHistoryRepository { + /// Delete a single history entry: remove the Places visit precisely by its own + /// `(url, time)`, then drop the `visit_container` relation row that tagged it. + /// + /// Dropping the relation matters: left behind, the nearest-time join in + /// `_annotateVisits` could reattach that now-orphaned tag to a *different* + /// same-URL visit within [historyVisitContainerMatchWindowMs], mislabeling an + /// uncontained (or different-container) visit. We delete exactly the relation + /// the annotation paired with this visit — carried on the entry as + /// [HistoryEntry.containerRelationId] — rather than re-deriving "the nearest + /// relation", which greedy one-to-one pairing may have assigned to a sibling + /// same-URL visit (deleting that would strip the sibling's tag and leave this + /// visit's real relation dangling). + Future deleteVisit(HistoryEntry entry) async { + await ref.read(historyRepositoryProvider.notifier).deleteVisit(entry.visit); + + final relationId = entry.containerRelationId; + if (relationId == null) return; + + await ref + .read(tabDatabaseProvider) + .visitContainerDao + .deleteById(relationId); + } + + /// Clear a container's history: delete the container's Places visits, then + /// remove its relation rows. + Future deleteContainerHistory(String containerId) async { + await deletePlacesVisitsForContainer(containerId); + await ref + .read(tabDatabaseProvider) + .visitContainerDao + .deleteForContainer(containerId); + } + + /// Delete from Mozilla Places exactly the visits recorded for [containerId] in + /// the relation, matched by canonical URL and nearest time. Only visits that + /// have a relation row are touched — uncontained Places visits (including of + /// the same URL) are never deleted. Leaves the relation rows in place; callers + /// remove them separately (explicit clear) or let ON DELETE CASCADE do it + /// (container deletion). Safe to call before the container itself is deleted. + Future deletePlacesVisitsForContainer(String containerId) async { + final relations = await ref + .read(tabDatabaseProvider) + .visitContainerDao + .relationsForContainer(containerId); + if (relations.isEmpty) return; + + var minTime = relations.first.visitTime; + var maxTime = relations.first.visitTime; + for (final relation in relations) { + if (relation.visitTime < minTime) minTime = relation.visitTime; + if (relation.visitTime > maxTime) maxTime = relation.visitTime; + } + + final visits = await ref + .read(historyRepositoryProvider.notifier) + .getDetailedVisits( + HistoryFilterOptions( + dateRange: DateTimeRange( + start: DateTime.fromMillisecondsSinceEpoch( + minTime - historyVisitContainerMatchWindowMs, + ), + end: DateTime.fromMillisecondsSinceEpoch( + maxTime + historyVisitContainerMatchWindowMs, + ), + ), + // Relations are only ever recorded for page visits (onVisited), so + // never let a download that merely shares a canonical URL + time + // window become a delete candidate — it would delete an unrelated + // download (and deleteVisit routes downloads to + // deleteDownload(contentId!), which throws on a null contentId). + visitTypes: VisitType.values + .where((type) => type != VisitType.download) + .toSet(), + ), + ); + + // Index candidate Places visits by canonical URL for nearest-time matching. + final visitsByCanonical = >{}; + for (final visit in visits) { + final canonical = canonicalizeUrl(visit.url)?.canonical; + if (canonical != null) { + (visitsByCanonical[canonical] ??= []).add(visit); + } + } + + final relationsByCanonical = >{}; + for (final relation in relations) { + (relationsByCanonical[relation.urlCanonical] ??= []) + .add(relation); + } + + // Collect matched Places visits using the same one-to-one nearest-time + // strategy as history annotation, so two relation rows never consume the + // same Places row and leave a sibling visit behind. + final toDelete = <(String, int), VisitInfo>{}; + for (final MapEntry(key: canonical, value: candidates) + in visitsByCanonical.entries) { + final canonicalRelations = relationsByCanonical[canonical]; + if (canonicalRelations == null) continue; + + final pairing = pairVisitsToRelationsByTime( + [for (final visit in candidates) visit.visitTime], + [for (final relation in canonicalRelations) relation.visitTime], + ); + for (final visitIndex in pairing.keys) { + final visit = candidates[visitIndex]; + toDelete[(visit.url, visit.visitTime)] = visit; + } + } + + final historyRepository = ref.read(historyRepositoryProvider.notifier); + for (final visit in toDelete.values) { + await historyRepository.deleteVisit(visit); + } + } + + @override + void build() {} +} diff --git a/apps/weblibre/lib/features/geckoview/features/history/domain/repositories/container_history.g.dart b/apps/weblibre/lib/features/geckoview/features/history/domain/repositories/container_history.g.dart new file mode 100644 index 00000000..aa6d3b1c --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/history/domain/repositories/container_history.g.dart @@ -0,0 +1,73 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'container_history.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Mutations that combine the visit→container relation (`visit_container`) with +/// Mozilla Places, the source of truth for the visits themselves. + +@ProviderFor(ContainerHistoryRepository) +final containerHistoryRepositoryProvider = + ContainerHistoryRepositoryProvider._(); + +/// Mutations that combine the visit→container relation (`visit_container`) with +/// Mozilla Places, the source of truth for the visits themselves. +final class ContainerHistoryRepositoryProvider + extends $NotifierProvider { + /// Mutations that combine the visit→container relation (`visit_container`) with + /// Mozilla Places, the source of truth for the visits themselves. + ContainerHistoryRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'containerHistoryRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$containerHistoryRepositoryHash(); + + @$internal + @override + ContainerHistoryRepository create() => ContainerHistoryRepository(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(void value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$containerHistoryRepositoryHash() => + r'8872a421664c0ac8d6b0bd3ca76e1300677b0873'; + +/// Mutations that combine the visit→container relation (`visit_container`) with +/// Mozilla Places, the source of truth for the visits themselves. + +abstract class _$ContainerHistoryRepository extends $Notifier { + void build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + void, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/history/domain/services/history_exclusion_replication.dart b/apps/weblibre/lib/features/geckoview/features/history/domain/services/history_exclusion_replication.dart new file mode 100644 index 00000000..9f29d06c --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/history/domain/services/history_exclusion_replication.dart @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; + +part 'history_exclusion_replication.g.dart'; + +/// The Gecko contextIds that must be hard-excluded from history: containers with +/// `excludeFromHistory` enabled that actually have a contextId (contextId-less +/// containers can't be distinguished at the delegate, so they can't be +/// excluded — the invariant on [ContainerMetadata] keeps the flag off for them). +List excludedHistoryContextIds(Iterable containers) { + return containers + .where((container) => container.metadata.excludeFromHistory) + .map((container) => container.metadata.contextualIdentity) + .whereType() + .toList(growable: false); +} + +/// Keeps the native history delegate's hard exclude-from-history set in sync +/// with WebLibre's containers, re-pushing whenever the container set changes. +/// Activated eagerly at startup (and once more before engine init) so an +/// excluded container never leaks a restored-tab visit to Places. +@Riverpod(keepAlive: true) +Future historyExclusionReplication(Ref ref) async { + final containers = await ref.watch(watchContainersWithCountProvider.future); + + await GeckoEngineSettingsService().setExcludedHistoryContextIds( + excludedHistoryContextIds(containers), + ); +} diff --git a/apps/weblibre/lib/features/geckoview/features/history/domain/services/history_exclusion_replication.g.dart b/apps/weblibre/lib/features/geckoview/features/history/domain/services/history_exclusion_replication.g.dart new file mode 100644 index 00000000..5f543634 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/history/domain/services/history_exclusion_replication.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'history_exclusion_replication.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Keeps the native history delegate's hard exclude-from-history set in sync +/// with WebLibre's containers, re-pushing whenever the container set changes. +/// Activated eagerly at startup (and once more before engine init) so an +/// excluded container never leaks a restored-tab visit to Places. + +@ProviderFor(historyExclusionReplication) +final historyExclusionReplicationProvider = + HistoryExclusionReplicationProvider._(); + +/// Keeps the native history delegate's hard exclude-from-history set in sync +/// with WebLibre's containers, re-pushing whenever the container set changes. +/// Activated eagerly at startup (and once more before engine init) so an +/// excluded container never leaks a restored-tab visit to Places. + +final class HistoryExclusionReplicationProvider + extends $FunctionalProvider, void, FutureOr> + with $FutureModifier, $FutureProvider { + /// Keeps the native history delegate's hard exclude-from-history set in sync + /// with WebLibre's containers, re-pushing whenever the container set changes. + /// Activated eagerly at startup (and once more before engine init) so an + /// excluded container never leaks a restored-tab visit to Places. + HistoryExclusionReplicationProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'historyExclusionReplicationProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$historyExclusionReplicationHash(); + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return historyExclusionReplication(ref); + } +} + +String _$historyExclusionReplicationHash() => + r'11eaf8bf0b4ef6e833a4b3659e1dbb3687b566be'; diff --git a/apps/weblibre/lib/features/geckoview/features/history/domain/services/visit_container_recorder.dart b/apps/weblibre/lib/features/geckoview/features/history/domain/services/visit_container_recorder.dart new file mode 100644 index 00000000..d0cc925c --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/history/domain/services/visit_container_recorder.dart @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:async'; + +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; +import 'package:weblibre/utils/url_canonical.dart'; + +part 'visit_container_recorder.g.dart'; + +class _HistoryEventsReceiver extends GeckoHistoryEvents { + _HistoryEventsReceiver(this._onVisit); + + final void Function(String url, int visitTime, String? contextId) _onVisit; + + @override + void onVisitRecorded(String url, int visitTime, String? contextId) { + _onVisit(url, visitTime, contextId); + } +} + +/// Records the visit→container relation. Mozilla Places owns the visit itself; +/// on each Places visit the native [WebLibreHistoryDelegate] forwards the +/// producing tab's Gecko contextId, which this service maps to a WebLibre +/// container and persists as a `visit_container` row (keyed on the visit's +/// canonical URL + time so the history UI can join it back to Places). +/// +/// Graceful absence: a visit that resolves to no container — uncontained, or a +/// container without a Gecko contextId (cookie isolation off) — writes no row +/// and simply appears untagged. Activated eagerly at startup. +@Riverpod(keepAlive: true) +class VisitContainerRecorder extends _$VisitContainerRecorder { + @override + void build() { + // Cache contextId → container id so each visit event is an O(1) lookup + // instead of a linear scan of all containers. Kept fresh via ref.listen. + final contextIdToContainerId = {}; + void applyContainers(Iterable? containers) { + contextIdToContainerId.clear(); + if (containers != null) { + for (final container in containers) { + final contextId = container.metadata.contextualIdentity; + if (contextId != null) { + contextIdToContainerId[contextId] = container.id; + } + } + } + } + + // Seed from the stream's current value (may still be empty during startup — + // watchContainersWithCount is async and often hasn't emitted yet) and keep + // it fresh on every change. + applyContainers(ref.read(watchContainersWithCountProvider).value); + ref.listen( + watchContainersWithCountProvider, + (_, next) => applyContainers(next.value), + ); + + Future recordVisit( + String url, + int visitTime, + String contextId, + ) async { + var containerId = contextIdToContainerId[contextId]; + + // Empty cache means the container stream hasn't emitted yet (the startup + // window — restored tabs can fire visits before the first emission). A + // visit carrying a contextId implies a container with that contextId + // exists, so pull the containers directly rather than dropping the visit + // permanently. A genuine miss (deleted container) leaves the cache + // non-empty, so this fallback does not fire repeatedly in steady state. + if (containerId == null && contextIdToContainerId.isEmpty) { + applyContainers(await ref.read(watchContainersWithCountProvider.future)); + containerId = contextIdToContainerId[contextId]; + } + + // Resolved to a contextId that maps to no known container (deleted or not + // yet synced) → skip rather than write a dangling relation. + if (containerId == null) return; + + final canonical = canonicalizeUrl(url); + if (canonical == null) return; + + await ref + .read(tabDatabaseProvider) + .visitContainerDao + .insertRelation( + rawUrl: url, + urlCanonical: canonical.canonical, + visitTime: visitTime, + containerId: containerId, + ); + } + + final receiver = _HistoryEventsReceiver((url, visitTime, contextId) { + // No contextId (uncontained / non-isolated / unresolved) → no relation. + if (contextId == null) return; + + unawaited(recordVisit(url, visitTime, contextId)); + }); + + GeckoHistoryEvents.setUp(receiver); + ref.onDispose(() => GeckoHistoryEvents.setUp(null)); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/history/domain/services/visit_container_recorder.g.dart b/apps/weblibre/lib/features/geckoview/features/history/domain/services/visit_container_recorder.g.dart new file mode 100644 index 00000000..6b950c55 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/history/domain/services/visit_container_recorder.g.dart @@ -0,0 +1,100 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'visit_container_recorder.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Records the visit→container relation. Mozilla Places owns the visit itself; +/// on each Places visit the native [WebLibreHistoryDelegate] forwards the +/// producing tab's Gecko contextId, which this service maps to a WebLibre +/// container and persists as a `visit_container` row (keyed on the visit's +/// canonical URL + time so the history UI can join it back to Places). +/// +/// Graceful absence: a visit that resolves to no container — uncontained, or a +/// container without a Gecko contextId (cookie isolation off) — writes no row +/// and simply appears untagged. Activated eagerly at startup. + +@ProviderFor(VisitContainerRecorder) +final visitContainerRecorderProvider = VisitContainerRecorderProvider._(); + +/// Records the visit→container relation. Mozilla Places owns the visit itself; +/// on each Places visit the native [WebLibreHistoryDelegate] forwards the +/// producing tab's Gecko contextId, which this service maps to a WebLibre +/// container and persists as a `visit_container` row (keyed on the visit's +/// canonical URL + time so the history UI can join it back to Places). +/// +/// Graceful absence: a visit that resolves to no container — uncontained, or a +/// container without a Gecko contextId (cookie isolation off) — writes no row +/// and simply appears untagged. Activated eagerly at startup. +final class VisitContainerRecorderProvider + extends $NotifierProvider { + /// Records the visit→container relation. Mozilla Places owns the visit itself; + /// on each Places visit the native [WebLibreHistoryDelegate] forwards the + /// producing tab's Gecko contextId, which this service maps to a WebLibre + /// container and persists as a `visit_container` row (keyed on the visit's + /// canonical URL + time so the history UI can join it back to Places). + /// + /// Graceful absence: a visit that resolves to no container — uncontained, or a + /// container without a Gecko contextId (cookie isolation off) — writes no row + /// and simply appears untagged. Activated eagerly at startup. + VisitContainerRecorderProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'visitContainerRecorderProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$visitContainerRecorderHash(); + + @$internal + @override + VisitContainerRecorder create() => VisitContainerRecorder(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(void value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$visitContainerRecorderHash() => + r'2d5742816ec08bc37edf2b56a933b6a324e8b038'; + +/// Records the visit→container relation. Mozilla Places owns the visit itself; +/// on each Places visit the native [WebLibreHistoryDelegate] forwards the +/// producing tab's Gecko contextId, which this service maps to a WebLibre +/// container and persists as a `visit_container` row (keyed on the visit's +/// canonical URL + time so the history UI can join it back to Places). +/// +/// Graceful absence: a visit that resolves to no container — uncontained, or a +/// container without a Gecko contextId (cookie isolation off) — writes no row +/// and simply appears untagged. Activated eagerly at startup. + +abstract class _$VisitContainerRecorder extends $Notifier { + void build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + void, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/history/presentation/screens/history.dart b/apps/weblibre/lib/features/geckoview/features/history/presentation/screens/history.dart index c8b0e811..b249870f 100644 --- a/apps/weblibre/lib/features/geckoview/features/history/presentation/screens/history.dart +++ b/apps/weblibre/lib/features/geckoview/features/history/presentation/screens/history.dart @@ -35,11 +35,14 @@ import 'package:sliver_tools/sliver_tools.dart'; import 'package:timeago/timeago.dart' as timeago; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/delete_data.dart'; +import 'package:weblibre/features/geckoview/features/history/domain/entities/history_entry.dart'; import 'package:weblibre/features/geckoview/features/history/domain/entities/history_filter_options.dart'; import 'package:weblibre/features/geckoview/features/history/domain/providers.dart'; -import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart'; +import 'package:weblibre/features/geckoview/features/history/domain/repositories/container_history.dart'; import 'package:weblibre/features/geckoview/features/history/presentation/dialogs/delete_file.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/presentation/hooks/menu_controller.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; @@ -54,11 +57,12 @@ class Section extends MultiSliver { super.key, required BuildContext context, required String title, - required List items, - required Set selectedItems, - required void Function(VisitInfo) onTap, - required void Function(VisitInfo) onLongPress, - required Future Function(VisitInfo) onDelete, + required List items, + required Set selectedItems, + required Map containersById, + required void Function(HistoryEntry) onTap, + required void Function(HistoryEntry) onLongPress, + required Future Function(HistoryEntry) onDelete, }) : super( pushPinnedChildren: true, children: [ @@ -122,6 +126,7 @@ class Section extends MultiSliver { padding: const EdgeInsets.only(left: 54, right: 16), child: Wrap( spacing: 8.0, + runSpacing: 4.0, children: [ Chip( avatar: switch (item.visitType) { @@ -147,9 +152,7 @@ class Section extends MultiSliver { VisitType.download => const Text('Download'), VisitType.framedLink => const Text('Frame'), VisitType.reload => const Text('Page Reload'), - VisitType.bookmark => throw UnimplementedError( - 'VisitType.bookmark chip display not implemented', - ), + VisitType.bookmark => const Text('Bookmark'), }, ), Chip( @@ -161,6 +164,16 @@ class Section extends MultiSliver { ), ), ), + for (final containerId in item.containerIds) + if (containersById[containerId] + case final container?) + Chip( + avatar: CircleAvatar( + backgroundColor: container.color, + radius: 8, + ), + label: Text(container.name ?? 'Container'), + ), ], ), ), @@ -195,7 +208,18 @@ class HistoryScreen extends HookConsumerWidget { ? ref.watch(browsingDownloadsProvider) : ref.watch(browsingHistoryProvider); - final selectedItems = useState({}); + final containers = ref.watch( + watchContainersWithCountProvider.select((value) => value.value), + ); + final containersById = { + for (final container in containers ?? const []) + container.id: container, + }; + final filterContainer = historyFilter.containerId.mapNotNull( + (id) => containersById[id], + ); + + final selectedItems = useState({}); final defaultDownloadsFilter = HistoryFilterOptions( dateRange: null, visitTypes: const {VisitType.download}, @@ -222,8 +246,10 @@ class HistoryScreen extends HookConsumerWidget { } } - Future deleteHistoryItem(VisitInfo item) async { - await ref.read(historyRepositoryProvider.notifier).deleteVisit(item); + Future deleteHistoryItem(HistoryEntry item) async { + await ref + .read(containerHistoryRepositoryProvider.notifier) + .deleteVisit(item); final downloadedFile = item.title.mapNotNull((title) => File(title)); @@ -241,6 +267,38 @@ class HistoryScreen extends HookConsumerWidget { await refreshHistoryEntries(); } + Future clearContainerHistory(ContainerData container) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + icon: const Icon(Icons.warning), + title: const Text('Clear Container History'), + content: Text( + 'Delete all browsing history recorded for ' + '"${container.name ?? 'Container'}"? The visits are removed from ' + 'history.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Clear'), + ), + ], + ), + ); + + if (confirmed == true) { + await ref + .read(containerHistoryRepositoryProvider.notifier) + .deleteContainerHistory(container.id); + await refreshHistoryEntries(); + } + } + return Scaffold( appBar: AppBar( title: textFilterEnabled.value @@ -276,7 +334,7 @@ class HistoryScreen extends HookConsumerWidget { for (final item in selectedItems.value) { await ref - .read(historyRepositoryProvider.notifier) + .read(containerHistoryRepositoryProvider.notifier) .deleteVisit(item); final downloadedFile = item.title.mapNotNull( @@ -309,7 +367,18 @@ class HistoryScreen extends HookConsumerWidget { ) else IconButton( + // Mirror what the list currently shows: with a container filter + // active, clear only that container's history; otherwise fall + // back to the full delete-browsing-data sheet. + tooltip: filterContainer != null + ? 'Clear "${filterContainer.name ?? 'Container'}" history' + : null, onPressed: () async { + if (filterContainer != null) { + await clearContainerHistory(filterContainer); + return; + } + await showDeleteDataDialog( context, initialSettings: { @@ -403,12 +472,51 @@ class HistoryScreen extends HookConsumerWidget { VisitType.download => const Text('Downloads'), VisitType.framedLink => const Text('Frames'), VisitType.reload => const Text('Page Reloads'), - VisitType.bookmark => throw UnimplementedError( - 'VisitType.bookmark filter not implemented', - ), + VisitType.bookmark => const Text('Bookmarks'), }, ), ), + if (!isDownloadsMode && (containers?.isNotEmpty ?? false)) ...[ + const Divider(), + SubmenuButton( + leadingIcon: const Icon(MdiIcons.folderMultipleOutline), + menuChildren: [ + MenuItemButton( + leadingIcon: Icon( + historyFilter.containerId == null + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + ), + onPressed: () { + ref + .read(historyVisitsFilterProvider.notifier) + .setContainer(null); + }, + child: const Text('All Containers'), + ), + for (final container in containers!) + MenuItemButton( + leadingIcon: Icon( + historyFilter.containerId == container.id + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: container.color, + ), + onPressed: () { + ref + .read(historyVisitsFilterProvider.notifier) + .setContainer(container.id); + }, + child: Text(container.name ?? 'Container'), + ), + ], + child: Text( + filterContainer != null + ? 'Container: ${filterContainer.name ?? 'Container'}' + : 'Filter Container', + ), + ), + ], const Divider(), MenuItemButton( leadingIcon: const Icon(MdiIcons.restore), @@ -458,11 +566,11 @@ class HistoryScreen extends HookConsumerWidget { final groups = useMemoized( () => data .where( - (visit) => + (entry) => textFilter.isEmpty || - visit.title?.toLowerCase().contains(textFilter) == + entry.title?.toLowerCase().contains(textFilter) == true || - visit.url.toLowerCase().contains(textFilter), + entry.url.toLowerCase().contains(textFilter), ) .groupListsBy( (element) => timeago.format( @@ -474,7 +582,7 @@ class HistoryScreen extends HookConsumerWidget { [EquatableValue(data), textFilter], ); - void toggleSelected(VisitInfo item) { + void toggleSelected(HistoryEntry item) { if (selectedItems.value.contains(item)) { selectedItems.value = {...selectedItems.value} ..remove(item); @@ -495,6 +603,7 @@ class HistoryScreen extends HookConsumerWidget { title: key, items: value, selectedItems: selectedItems.value, + containersById: containersById, onLongPress: toggleSelected, onDelete: deleteHistoryItem, onTap: (item) async { diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/visit_container.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/visit_container.dart new file mode 100644 index 00000000..21124935 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/visit_container.dart @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:math' as math; + +import 'package:drift/drift.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/visit_container.drift.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'; + +/// Data access for the visit → container relation (`visit_container`). +/// +/// Mozilla Places owns the browsing history itself; this table only records +/// which WebLibre container each contained visit belonged to. Reads are joined +/// back to Places `VisitInfo`s in the domain layer on +/// (url_canonical, nearest visit_time). +@DriftAccessor() +class VisitContainerDao extends DatabaseAccessor + with $VisitContainerDaoMixin { + VisitContainerDao(super.db); + + /// Record that a visit to [rawUrl] (canonical [urlCanonical]) at [visitTime] + /// (epoch millis) belonged to [containerId]. Append-only: one row per visit. + Future insertRelation({ + required String rawUrl, + required String urlCanonical, + required int visitTime, + required String containerId, + }) { + return into(db.visitContainer).insert( + VisitContainerCompanion.insert( + rawUrl: rawUrl, + urlCanonical: urlCanonical, + visitTime: visitTime, + containerId: containerId, + ), + ); + } + + /// All relation rows whose canonical URL is in [canonicals]. Used to tag a + /// page of Places visits with their container (matched by nearest visit_time + /// in the domain layer). Returns empty for an empty input. + /// + /// Queried in chunks: an unfiltered history timeline can span every distinct + /// URL in Places, and a single `IN (?, ?, …)` over that whole set would blow + /// past SQLite's bound-variable limit and throw. + Future> relationsForCanonicalUrls( + Set canonicals, + ) async { + if (canonicals.isEmpty) return const []; + + // Stay well under SQLite's SQLITE_MAX_VARIABLE_NUMBER (defaults 999 on older + // builds, 32766 on newer ones). + const chunkSize = 500; + final canonicalList = canonicals.toList(growable: false); + final results = []; + for (var start = 0; start < canonicalList.length; start += chunkSize) { + final chunk = canonicalList.sublist( + start, + math.min(start + chunkSize, canonicalList.length), + ); + final rows = await (db.select(db.visitContainer) + ..where((t) => t.urlCanonical.isIn(chunk))) + .get(); + results.addAll(rows); + } + return results; + } + + /// All relation rows for [containerId], newest first. Used by the + /// container-filtered timeline and the per-container Places-delete mirror. + Future> relationsForContainer(String containerId) { + return (db.select(db.visitContainer) + ..where((t) => t.containerId.equals(containerId)) + ..orderBy([(t) => OrderingTerm.desc(t.visitTime)])) + .get(); + } + + /// Remove a single relation row by primary key. Used when an individual + /// history entry is deleted, so its tag can't later reattach (via the + /// nearest-time join) to a different same-URL visit within the match window. + Future deleteById(int id) { + return (db.delete(db.visitContainer)..where((t) => t.id.equals(id))).go(); + } + + /// Remove all relation rows for [containerId] (explicit per-container clear). + /// Container deletion dissolves relations automatically via ON DELETE CASCADE + /// and does not go through here. + Future deleteForContainer(String containerId) { + return (db.delete(db.visitContainer) + ..where((t) => t.containerId.equals(containerId))) + .go(); + } + + /// Remove every relation row, all containers. Used when the user clears all + /// browsing history: the Places visits these rows tag are gone, so the tags + /// must go too — otherwise they dangle and could re-attach to a future, + /// unrelated visit of the same URL within the nearest-time window. + Future clearAll() { + return db.delete(db.visitContainer).go(); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/visit_container.drift.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/visit_container.drift.dart new file mode 100644 index 00000000..e99b5af3 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/visit_container.drift.dart @@ -0,0 +1,14 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart' + as i1; + +mixin $VisitContainerDaoMixin on i0.DatabaseAccessor { + VisitContainerDaoManager get managers => VisitContainerDaoManager(this); +} + +class VisitContainerDaoManager { + final $VisitContainerDaoMixin _db; + VisitContainerDaoManager(this._db); +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.dart index 96ee2b06..73f7f077 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.dart @@ -28,6 +28,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/cap import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/history.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/visit_container.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/database.drift.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/database.steps.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'; @@ -36,11 +37,11 @@ import 'package:weblibre/features/search/domain/fts_tokenizer.dart'; @DriftDatabase( include: {'definitions.drift'}, - daos: [ContainerDao, TabDao, CaptureTabDao, HistoryDao], + daos: [ContainerDao, TabDao, CaptureTabDao, HistoryDao, VisitContainerDao], ) class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin { @override - final int schemaVersion = 14; + final int schemaVersion = 15; @override final int ftsTokenLimit = 10; @@ -247,5 +248,13 @@ class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin { await m.create(schema.tabToHistoryOnContainerUpdate); await m.create(schema.containerToHistoryOnMetadataUpdate); }, + from14To15: (m, schema) async { + // Visit → container relation. Mozilla Places stays the source of truth + // for history; this table only records which container each contained + // visit belonged to. See definitions.drift. + await m.create(schema.visitContainer); + await m.create(schema.idxVcCanonical); + await m.create(schema.idxVcContainer); + }, ); } diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.drift.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.drift.dart index 84de99a6..1386f83d 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.drift.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.drift.dart @@ -13,8 +13,10 @@ import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/cap as i5; import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/history.dart' as i6; -import 'package:drift/internal/modular.dart' as i7; -import 'package:sqlite3/common.dart' as i8; +import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/visit_container.dart' + as i7; +import 'package:drift/internal/modular.dart' as i8; +import 'package:sqlite3/common.dart' as i9; abstract class $TabDatabase extends i0.GeneratedDatabase { $TabDatabase(i0.QueryExecutor e) : super(e); @@ -31,6 +33,7 @@ abstract class $TabDatabase extends i0.GeneratedDatabase { ); late final i1.History history = i1.History(this); late final i1.HistoryFts historyFts = i1.HistoryFts(this); + late final i1.VisitContainer visitContainer = i1.VisitContainer(this); late final i2.ContainerDao containerDao = i2.ContainerDao( this as i3.TabDatabase, ); @@ -39,7 +42,10 @@ abstract class $TabDatabase extends i0.GeneratedDatabase { this as i3.TabDatabase, ); late final i6.HistoryDao historyDao = i6.HistoryDao(this as i3.TabDatabase); - i1.DefinitionsDrift get definitionsDrift => i7.ReadDatabaseContainer( + late final i7.VisitContainerDao visitContainerDao = i7.VisitContainerDao( + this as i3.TabDatabase, + ); + i1.DefinitionsDrift get definitionsDrift => i8.ReadDatabaseContainer( this, ).accessor(i1.DefinitionsDrift.new); @override @@ -70,6 +76,9 @@ abstract class $TabDatabase extends i0.GeneratedDatabase { i1.tabToHistoryOnUpdate, i1.tabToHistoryOnContainerUpdate, i1.containerToHistoryOnMetadataUpdate, + visitContainer, + i1.idxVcCanonical, + i1.idxVcContainer, ]; @override i0.StreamQueryUpdateRules get streamUpdateRules => @@ -171,6 +180,15 @@ abstract class $TabDatabase extends i0.GeneratedDatabase { i0.TableUpdate('history', kind: i0.UpdateKind.insert), ], ), + i0.WritePropagation( + on: i0.TableUpdateQuery.onTableName( + 'container', + limitUpdateKind: i0.UpdateKind.delete, + ), + result: [ + i0.TableUpdate('visit_container', kind: i0.UpdateKind.delete), + ], + ), ]); } @@ -191,9 +209,11 @@ class $TabDatabaseManager { i1.$HistoryTableManager(_db, _db.history); i1.$HistoryFtsTableManager get historyFts => i1.$HistoryFtsTableManager(_db, _db.historyFts); + i1.$VisitContainerTableManager get visitContainer => + i1.$VisitContainerTableManager(_db, _db.visitContainer); } -extension DefineFunctions on i8.CommonDatabase { +extension DefineFunctions on i9.CommonDatabase { void defineFunctions({ required String Function(int, String?) lexoRankNext, required String Function(int, String?) lexoRankPrevious, @@ -207,7 +227,7 @@ extension DefineFunctions on i8.CommonDatabase { }) { createFunction( functionName: 'lexo_rank_next', - argumentCount: const i8.AllowedArgumentCount(2), + argumentCount: const i9.AllowedArgumentCount(2), function: (args) { final arg0 = args[0] as int; final arg1 = args[1] as String?; @@ -216,7 +236,7 @@ extension DefineFunctions on i8.CommonDatabase { ); createFunction( functionName: 'lexo_rank_previous', - argumentCount: const i8.AllowedArgumentCount(2), + argumentCount: const i9.AllowedArgumentCount(2), function: (args) { final arg0 = args[0] as int; final arg1 = args[1] as String?; @@ -225,7 +245,7 @@ extension DefineFunctions on i8.CommonDatabase { ); createFunction( functionName: 'lexo_rank_reorder_after', - argumentCount: const i8.AllowedArgumentCount(2), + argumentCount: const i9.AllowedArgumentCount(2), function: (args) { final arg0 = args[0] as String?; final arg1 = args[1] as String?; @@ -234,7 +254,7 @@ extension DefineFunctions on i8.CommonDatabase { ); createFunction( functionName: 'lexo_rank_reorder_before', - argumentCount: const i8.AllowedArgumentCount(2), + argumentCount: const i9.AllowedArgumentCount(2), function: (args) { final arg0 = args[0] as String?; final arg1 = args[1] as String?; @@ -243,14 +263,14 @@ extension DefineFunctions on i8.CommonDatabase { ); createFunction( functionName: 'generate_content_hash', - argumentCount: const i8.AllowedArgumentCount(0), + argumentCount: const i9.AllowedArgumentCount(0), function: (args) { return generateContentHash(); }, ); createFunction( functionName: 'url_indexable', - argumentCount: const i8.AllowedArgumentCount(1), + argumentCount: const i9.AllowedArgumentCount(1), function: (args) { final arg0 = args[0] as String?; return urlIndexable(arg0); @@ -258,7 +278,7 @@ extension DefineFunctions on i8.CommonDatabase { ); createFunction( functionName: 'url_canonical', - argumentCount: const i8.AllowedArgumentCount(1), + argumentCount: const i9.AllowedArgumentCount(1), function: (args) { final arg0 = args[0] as String?; return urlCanonical(arg0); @@ -266,7 +286,7 @@ extension DefineFunctions on i8.CommonDatabase { ); createFunction( functionName: 'url_host', - argumentCount: const i8.AllowedArgumentCount(1), + argumentCount: const i9.AllowedArgumentCount(1), function: (args) { final arg0 = args[0] as String?; return urlHost(arg0); @@ -274,7 +294,7 @@ extension DefineFunctions on i8.CommonDatabase { ); createFunction( functionName: 'url_path', - argumentCount: const i8.AllowedArgumentCount(1), + argumentCount: const i9.AllowedArgumentCount(1), function: (args) { final arg0 = args[0] as String?; return urlPath(arg0); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.steps.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.steps.dart index 947ba0cd..e091a3cb 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.steps.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.steps.dart @@ -2039,6 +2039,299 @@ final class Schema14 extends i0.VersionedSchema { ); } +final class Schema15 extends i0.VersionedSchema { + Schema15({required super.database}) : super(version: 15); + @override + late final List entities = [ + container, + tab, + closedTabTombstone, + idxTabParentContainer, + captureTab, + idxCaptureTabCaptureId, + tabFts, + tabMaintainParentChainOnDelete, + tabAfterInsert, + tabAfterDelete, + tabAfterUpdate, + localIndexSetting, + history, + idxHistoryHost, + idxHistoryObserved, + historyFts, + historyAfterInsert, + historyAfterDelete, + historyAfterUpdate, + tabToHistoryOnInsert, + tabToHistoryOnUpdate, + tabToHistoryOnContainerUpdate, + containerToHistoryOnMetadataUpdate, + visitContainer, + idxVcCanonical, + idxVcContainer, + ]; + late final Shape12 container = Shape12( + source: i0.VersionedTable( + entityName: 'container', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_1, + _column_2, + _column_6, + _column_19, + _column_3, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape8 tab = Shape8( + source: i0.VersionedTable( + entityName: 'tab', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))', + ], + columns: [ + _column_0, + _column_16, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + _column_17, + _column_18, + _column_19, + _column_10, + _column_11, + _column_12, + _column_13, + _column_14, + _column_15, + _column_27, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape6 closedTabTombstone = Shape6( + source: i0.VersionedTable( + entityName: 'closed_tab_tombstone', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_20, _column_21], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxTabParentContainer = i1.Index( + 'idx_tab_parent_container', + 'CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)', + ); + late final Shape7 captureTab = Shape7( + source: i0.VersionedTable( + entityName: 'capture_tab', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_22, _column_23, _column_24, _column_25, _column_26], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxCaptureTabCaptureId = i1.Index( + 'idx_capture_tab_capture_id', + 'CREATE INDEX idx_capture_tab_capture_id ON capture_tab (capture_id)', + ); + late final Shape2 tabFts = Shape2( + source: i0.VersionedVirtualTable( + entityName: 'tab_fts', + moduleAndArgs: + 'fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize="trigram")', + columns: [_column_8, _column_7, _column_12, _column_14], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Trigger tabMaintainParentChainOnDelete = i1.Trigger( + 'CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END', + 'tab_maintain_parent_chain_on_delete', + ); + final i1.Trigger tabAfterInsert = i1.Trigger( + 'CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_insert', + ); + final i1.Trigger tabAfterDelete = i1.Trigger( + 'CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END', + 'tab_after_delete', + ); + final i1.Trigger tabAfterUpdate = i1.Trigger( + 'CREATE TRIGGER tab_after_update AFTER UPDATE OF title, url, extracted_content_plain, full_content_plain ON tab WHEN OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_update', + ); + late final Shape9 localIndexSetting = Shape9( + source: i0.VersionedTable( + entityName: 'local_index_setting', + withoutRowId: false, + isStrict: true, + tableConstraints: [], + columns: [_column_28, _column_29], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape10 history = Shape10( + source: i0.VersionedTable( + entityName: 'history', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_30, + _column_31, + _column_32, + _column_8, + _column_10, + _column_11, + _column_12, + _column_13, + _column_14, + _column_33, + _column_34, + _column_35, + ], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxHistoryHost = i1.Index( + 'idx_history_host', + 'CREATE INDEX idx_history_host ON history (url_host)', + ); + final i1.Index idxHistoryObserved = i1.Index( + 'idx_history_observed', + 'CREATE INDEX idx_history_observed ON history (observed_at DESC)', + ); + late final Shape11 historyFts = Shape11( + source: i0.VersionedVirtualTable( + entityName: 'history_fts', + moduleAndArgs: + 'fts5(title, url_host, url_path, extracted_content_plain, full_content_plain, content=history, tokenize="trigram")', + columns: [_column_8, _column_36, _column_32, _column_12, _column_14], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Trigger historyAfterInsert = i1.Trigger( + 'CREATE TRIGGER history_after_insert AFTER INSERT ON history BEGIN INSERT INTO history_fts ("rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END', + 'history_after_insert', + ); + final i1.Trigger historyAfterDelete = i1.Trigger( + 'CREATE TRIGGER history_after_delete AFTER DELETE ON history BEGIN INSERT INTO history_fts (history_fts, "rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);END', + 'history_after_delete', + ); + final i1.Trigger historyAfterUpdate = i1.Trigger( + 'CREATE TRIGGER history_after_update AFTER UPDATE OF title, url_host, url_path, extracted_content_plain, full_content_plain ON history BEGIN INSERT INTO history_fts (history_fts, "rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);INSERT INTO history_fts ("rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END', + 'history_after_update', + ); + final i1.Trigger tabToHistoryOnInsert = i1.Trigger( + 'CREATE TRIGGER tab_to_history_on_insert AFTER INSERT ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = NEW.container_id AND json_extract(container.metadata, \'\$.excludeFromIndex\') = 1) BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime(\'%s\', \'now\') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END', + 'tab_to_history_on_insert', + ); + final i1.Trigger tabToHistoryOnUpdate = i1.Trigger( + 'CREATE TRIGGER tab_to_history_on_update AFTER UPDATE OF title, url, extracted_content_plain, extracted_content_markdown, full_content_plain, full_content_markdown, is_probably_readerable ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND(OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url)AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = NEW.container_id AND json_extract(container.metadata, \'\$.excludeFromIndex\') = 1) BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime(\'%s\', \'now\') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END', + 'tab_to_history_on_update', + ); + final i1.Trigger tabToHistoryOnContainerUpdate = i1.Trigger( + 'CREATE TRIGGER tab_to_history_on_container_update AFTER UPDATE OF container_id ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 BEGIN DELETE FROM history WHERE url_canonical = url_canonical(CAST(NEW.url AS TEXT)) AND NOT EXISTS (SELECT 1 FROM tab AS candidate WHERE candidate.url IS NOT NULL AND url_indexable(CAST(candidate.url AS TEXT)) = 1 AND url_canonical(CAST(candidate.url AS TEXT)) = history.url_canonical AND(candidate.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = candidate.container_id AND json_extract(container.metadata, \'\$.excludeFromIndex\') = 1));INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) SELECT url_canonical(CAST(candidate.url AS TEXT)), url_host(CAST(candidate.url AS TEXT)), url_path(CAST(candidate.url AS TEXT)), candidate.title, candidate.is_probably_readerable, candidate.extracted_content_markdown, candidate.extracted_content_plain, candidate.full_content_markdown, candidate.full_content_plain, candidate.content_hash, strftime(\'%s\', \'now\') * 1000, 1 FROM tab AS candidate WHERE candidate.url IS NOT NULL AND url_indexable(CAST(candidate.url AS TEXT)) = 1 AND url_canonical(CAST(candidate.url AS TEXT)) = url_canonical(CAST(NEW.url AS TEXT)) AND(candidate.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = candidate.container_id AND json_extract(container.metadata, \'\$.excludeFromIndex\') = 1) ORDER BY candidate.timestamp DESC, candidate."rowid" DESC LIMIT 1 ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1;END', + 'tab_to_history_on_container_update', + ); + final i1.Trigger containerToHistoryOnMetadataUpdate = i1.Trigger( + 'CREATE TRIGGER container_to_history_on_metadata_update AFTER UPDATE OF metadata ON container WHEN COALESCE(json_extract(OLD.metadata, \'\$.excludeFromIndex\') = 1, 0) != COALESCE(json_extract(NEW.metadata, \'\$.excludeFromIndex\') = 1, 0) AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 BEGIN DELETE FROM history WHERE url_canonical IN (SELECT DISTINCT url_canonical(CAST(affected.url AS TEXT)) FROM tab AS affected WHERE affected.container_id = NEW.id AND affected.url IS NOT NULL AND url_indexable(CAST(affected.url AS TEXT)) = 1) AND NOT EXISTS (SELECT 1 FROM tab AS candidate WHERE candidate.url IS NOT NULL AND url_indexable(CAST(candidate.url AS TEXT)) = 1 AND url_canonical(CAST(candidate.url AS TEXT)) = history.url_canonical AND(candidate.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = candidate.container_id AND json_extract(container.metadata, \'\$.excludeFromIndex\') = 1));INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) SELECT url_canonical(CAST(candidate.url AS TEXT)), url_host(CAST(candidate.url AS TEXT)), url_path(CAST(candidate.url AS TEXT)), candidate.title, candidate.is_probably_readerable, candidate.extracted_content_markdown, candidate.extracted_content_plain, candidate.full_content_markdown, candidate.full_content_plain, candidate.content_hash, strftime(\'%s\', \'now\') * 1000, 1 FROM tab AS candidate WHERE candidate.url IS NOT NULL AND url_indexable(CAST(candidate.url AS TEXT)) = 1 AND url_canonical(CAST(candidate.url AS TEXT)) IN (SELECT DISTINCT url_canonical(CAST(affected.url AS TEXT)) FROM tab AS affected WHERE affected.container_id = NEW.id AND affected.url IS NOT NULL AND url_indexable(CAST(affected.url AS TEXT)) = 1) AND(candidate.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = candidate.container_id AND json_extract(container.metadata, \'\$.excludeFromIndex\') = 1) AND NOT EXISTS (SELECT 1 FROM tab AS newer WHERE newer.url IS NOT NULL AND url_indexable(CAST(newer.url AS TEXT)) = 1 AND url_canonical(CAST(newer.url AS TEXT)) = url_canonical(CAST(candidate.url AS TEXT)) AND(newer.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = newer.container_id AND json_extract(container.metadata, \'\$.excludeFromIndex\') = 1) AND(newer.timestamp > candidate.timestamp OR(newer.timestamp = candidate.timestamp AND newer."rowid" > candidate."rowid"))) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1;END', + 'container_to_history_on_metadata_update', + ); + late final Shape13 visitContainer = Shape13( + source: i0.VersionedTable( + entityName: 'visit_container', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_37, _column_38, _column_39, _column_40, _column_41], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxVcCanonical = i1.Index( + 'idx_vc_canonical', + 'CREATE INDEX idx_vc_canonical ON visit_container (url_canonical, visit_time)', + ); + final i1.Index idxVcContainer = i1.Index( + 'idx_vc_container', + 'CREATE INDEX idx_vc_container ON visit_container (container_id, visit_time DESC)', + ); +} + +class Shape13 extends i0.VersionedTable { + Shape13({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get rawUrl => + columnsByName['raw_url']! as i1.GeneratedColumn; + i1.GeneratedColumn get urlCanonical => + columnsByName['url_canonical']! as i1.GeneratedColumn; + i1.GeneratedColumn get visitTime => + columnsByName['visit_time']! as i1.GeneratedColumn; + i1.GeneratedColumn get containerId => + columnsByName['container_id']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_37(String aliasedName) => + i1.GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: i1.DriftSqlType.int, + $customConstraints: 'NOT NULL PRIMARY KEY AUTOINCREMENT', + ); +i1.GeneratedColumn _column_38(String aliasedName) => + i1.GeneratedColumn( + 'raw_url', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'NOT NULL', + ); +i1.GeneratedColumn _column_39(String aliasedName) => + i1.GeneratedColumn( + 'url_canonical', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'NOT NULL', + ); +i1.GeneratedColumn _column_40(String aliasedName) => + i1.GeneratedColumn( + 'visit_time', + aliasedName, + false, + type: i1.DriftSqlType.int, + $customConstraints: 'NOT NULL', + ); +i1.GeneratedColumn _column_41(String aliasedName) => + i1.GeneratedColumn( + 'container_id', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'NOT NULL REFERENCES container(id)ON DELETE CASCADE', + ); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema3 schema) from2To3, required Future Function(i1.Migrator m, Schema4 schema) from3To4, @@ -2052,6 +2345,7 @@ i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema12 schema) from11To12, required Future Function(i1.Migrator m, Schema13 schema) from12To13, required Future Function(i1.Migrator m, Schema14 schema) from13To14, + required Future Function(i1.Migrator m, Schema15 schema) from14To15, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -2115,6 +2409,11 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from13To14(migrator, schema); return 14; + case 14: + final schema = Schema15(database: database); + final migrator = i1.Migrator(database, schema); + await from14To15(migrator, schema); + return 15; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -2134,6 +2433,7 @@ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema12 schema) from11To12, required Future Function(i1.Migrator m, Schema13 schema) from12To13, required Future Function(i1.Migrator m, Schema14 schema) from13To14, + required Future Function(i1.Migrator m, Schema15 schema) from14To15, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( from2To3: from2To3, @@ -2148,5 +2448,6 @@ i1.OnUpgrade stepByStep({ from11To12: from11To12, from12To13: from12To13, from13To14: from13To14, + from14To15: from14To15, ), ); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift index cb959e79..6edf6f22 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift @@ -563,9 +563,42 @@ BEGIN observed_count = history.observed_count + 1; END; +-- =========================================================================== +-- Visit → container relation. Mozilla Places remains the source of truth for +-- browsing history (url, title, visit type, visit time, sync); this table only +-- carries the one thing Places can't store: which WebLibre container a visit +-- belonged to. One row per contained visit, written by the Dart +-- VisitContainerRecorder from native history-delegate events. Uncontained +-- visits produce no row. +-- +-- Joined back to a Places `VisitInfo` on (url_canonical, nearest visit_time): +-- `visit_time` is captured near — not exactly at — the Places record time, so +-- consumers match the closest visit within a small tolerance window. +-- +-- `container_id` is ON DELETE CASCADE: deleting a container dissolves its +-- relations (the Places visits survive, untagged). Optionally deleting the +-- visits themselves is a separate, explicit Places mirror step in the repo. +-- =========================================================================== +CREATE TABLE visit_container ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + -- Original visited URL; used to open the page and as the key for the Places + -- delete mirror (Places keys visits by URL + time). + raw_url TEXT NOT NULL, + -- Structural canonical form; the join key to Places visits (computed in Dart + -- via canonicalizeUrl so it matches the local index's canonicalization). + url_canonical TEXT NOT NULL, + -- Epoch MILLISECONDS (plain INTEGER, not a drift DATETIME which stores + -- seconds). Matches Places `VisitInfo.visitTime`. + visit_time INTEGER NOT NULL, + container_id TEXT NOT NULL REFERENCES container(id) ON DELETE CASCADE +); + +CREATE INDEX idx_vc_canonical ON visit_container(url_canonical, visit_time); +CREATE INDEX idx_vc_container ON visit_container(container_id, visit_time DESC); + containersWithCount WITH ContainerDataWithCount: - SELECT - container.*, + SELECT + container.*, tab_agg.tab_count FROM container LEFT JOIN ( diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart index 21c03408..f232d3c1 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart @@ -72,6 +72,31 @@ final class $ContainerReferences manager.$state.copyWith(prefetchedData: cache), ); } + + static i0.MultiTypedResultKey> + _visitContainerRefsTable(i0.GeneratedDatabase db) => + i0.MultiTypedResultKey.fromTable( + i9.ReadDatabaseContainer( + db, + ).resultSet('visit_container'), + aliasName: 'container__id__visit_container__container_id', + ); + + i3.$VisitContainerProcessedTableManager get visitContainerRefs { + final manager = i3 + .$VisitContainerTableManager( + $_db, + i9.ReadDatabaseContainer( + $_db, + ).resultSet('visit_container'), + ) + .filter((f) => f.containerId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_visitContainerRefsTable($_db)); + return i0.ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } } class $ContainerFilterComposer @@ -143,6 +168,35 @@ class $ContainerFilterComposer ); return f(composer); } + + i0.Expression visitContainerRefs( + i0.Expression Function(i3.$VisitContainerFilterComposer f) f, + ) { + final i3.$VisitContainerFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: i9.ReadDatabaseContainer( + $db, + ).resultSet('visit_container'), + getReferencedColumn: (t) => t.containerId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i3.$VisitContainerFilterComposer( + $db: $db, + $table: i9.ReadDatabaseContainer( + $db, + ).resultSet('visit_container'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } } class $ContainerOrderingComposer @@ -237,6 +291,35 @@ class $ContainerAnnotationComposer ); return f(composer); } + + i0.Expression visitContainerRefs( + i0.Expression Function(i3.$VisitContainerAnnotationComposer a) f, + ) { + final i3.$VisitContainerAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: i9.ReadDatabaseContainer( + $db, + ).resultSet('visit_container'), + getReferencedColumn: (t) => t.containerId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i3.$VisitContainerAnnotationComposer( + $db: $db, + $table: i9.ReadDatabaseContainer( + $db, + ).resultSet('visit_container'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } } class $ContainerTableManager @@ -252,7 +335,7 @@ class $ContainerTableManager $ContainerUpdateCompanionBuilder, (i1.ContainerData, i3.$ContainerReferences), i1.ContainerData, - i0.PrefetchHooks Function({bool tabRefs}) + i0.PrefetchHooks Function({bool tabRefs, bool visitContainerRefs}) > { $ContainerTableManager(i0.GeneratedDatabase db, i3.Container table) : super( @@ -309,38 +392,60 @@ class $ContainerTableManager (e.readTable(table), i3.$ContainerReferences(db, table, e)), ) .toList(), - prefetchHooksCallback: ({tabRefs = false}) { - return i0.PrefetchHooks( - db: db, - explicitlyWatchedTables: [ - if (tabRefs) - i9.ReadDatabaseContainer(db).resultSet('tab'), - ], - addJoins: null, - getPrefetchedDataCallback: (items) async { - return [ - if (tabRefs) - await i0.$_getPrefetchedData< - i1.ContainerData, - i3.Container, - i3.TabData - >( - currentTable: table, - referencedTable: i3.$ContainerReferences._tabRefsTable( + prefetchHooksCallback: + ({tabRefs = false, visitContainerRefs = false}) { + return i0.PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (tabRefs) + i9.ReadDatabaseContainer(db).resultSet('tab'), + if (visitContainerRefs) + i9.ReadDatabaseContainer( db, - ), - managerFromTypedResult: (p0) => - i3.$ContainerReferences(db, table, p0).tabRefs, - referencedItemsForCurrentItem: (item, referencedItems) => - referencedItems.where( - (e) => e.containerId == item.id, - ), - typedResults: items, - ), - ]; + ).resultSet('visit_container'), + ], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (tabRefs) + await i0.$_getPrefetchedData< + i1.ContainerData, + i3.Container, + i3.TabData + >( + currentTable: table, + referencedTable: i3.$ContainerReferences + ._tabRefsTable(db), + managerFromTypedResult: (p0) => + i3.$ContainerReferences(db, table, p0).tabRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.containerId == item.id, + ), + typedResults: items, + ), + if (visitContainerRefs) + await i0.$_getPrefetchedData< + i1.ContainerData, + i3.Container, + i3.VisitContainerData + >( + currentTable: table, + referencedTable: i3.$ContainerReferences + ._visitContainerRefsTable(db), + managerFromTypedResult: (p0) => i3 + .$ContainerReferences(db, table, p0) + .visitContainerRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.containerId == item.id, + ), + typedResults: items, + ), + ]; + }, + ); }, - ); - }, ), ); } @@ -357,7 +462,7 @@ typedef $ContainerProcessedTableManager = $ContainerUpdateCompanionBuilder, (i1.ContainerData, i3.$ContainerReferences), i1.ContainerData, - i0.PrefetchHooks Function({bool tabRefs}) + i0.PrefetchHooks Function({bool tabRefs, bool visitContainerRefs}) >; typedef $TabCreateCompanionBuilder = i3.TabCompanion Function({ @@ -2411,6 +2516,340 @@ typedef $HistoryFtsProcessedTableManager = i3.HistoryFt, i0.PrefetchHooks Function() >; +typedef $VisitContainerCreateCompanionBuilder = + i3.VisitContainerCompanion Function({ + i0.Value id, + required String rawUrl, + required String urlCanonical, + required int visitTime, + required String containerId, + }); +typedef $VisitContainerUpdateCompanionBuilder = + i3.VisitContainerCompanion Function({ + i0.Value id, + i0.Value rawUrl, + i0.Value urlCanonical, + i0.Value visitTime, + i0.Value containerId, + }); + +final class $VisitContainerReferences + extends + i0.BaseReferences< + i0.GeneratedDatabase, + i3.VisitContainer, + i3.VisitContainerData + > { + $VisitContainerReferences(super.$_db, super.$_table, super.$_typedResult); + + static i3.Container _containerIdTable(i0.GeneratedDatabase db) => + i9.ReadDatabaseContainer(db) + .resultSet('container') + .createAlias('visit_container__container_id__container__id'); + + i3.$ContainerProcessedTableManager get containerId { + final $_column = $_itemColumn('container_id')!; + + final manager = i3 + .$ContainerTableManager( + $_db, + i9.ReadDatabaseContainer($_db).resultSet('container'), + ) + .filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_containerIdTable($_db)); + if (item == null) return manager; + return i0.ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $VisitContainerFilterComposer + extends i0.Composer { + $VisitContainerFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get rawUrl => $composableBuilder( + column: $table.rawUrl, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get urlCanonical => $composableBuilder( + column: $table.urlCanonical, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get visitTime => $composableBuilder( + column: $table.visitTime, + builder: (column) => i0.ColumnFilters(column), + ); + + i3.$ContainerFilterComposer get containerId { + final i3.$ContainerFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.containerId, + referencedTable: i9.ReadDatabaseContainer( + $db, + ).resultSet('container'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i3.$ContainerFilterComposer( + $db: $db, + $table: i9.ReadDatabaseContainer( + $db, + ).resultSet('container'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $VisitContainerOrderingComposer + extends i0.Composer { + $VisitContainerOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get rawUrl => $composableBuilder( + column: $table.rawUrl, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get urlCanonical => $composableBuilder( + column: $table.urlCanonical, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get visitTime => $composableBuilder( + column: $table.visitTime, + builder: (column) => i0.ColumnOrderings(column), + ); + + i3.$ContainerOrderingComposer get containerId { + final i3.$ContainerOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.containerId, + referencedTable: i9.ReadDatabaseContainer( + $db, + ).resultSet('container'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i3.$ContainerOrderingComposer( + $db: $db, + $table: i9.ReadDatabaseContainer( + $db, + ).resultSet('container'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $VisitContainerAnnotationComposer + extends i0.Composer { + $VisitContainerAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + i0.GeneratedColumn get rawUrl => + $composableBuilder(column: $table.rawUrl, builder: (column) => column); + + i0.GeneratedColumn get urlCanonical => $composableBuilder( + column: $table.urlCanonical, + builder: (column) => column, + ); + + i0.GeneratedColumn get visitTime => + $composableBuilder(column: $table.visitTime, builder: (column) => column); + + i3.$ContainerAnnotationComposer get containerId { + final i3.$ContainerAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.containerId, + referencedTable: i9.ReadDatabaseContainer( + $db, + ).resultSet('container'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i3.$ContainerAnnotationComposer( + $db: $db, + $table: i9.ReadDatabaseContainer( + $db, + ).resultSet('container'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $VisitContainerTableManager + extends + i0.RootTableManager< + i0.GeneratedDatabase, + i3.VisitContainer, + i3.VisitContainerData, + i3.$VisitContainerFilterComposer, + i3.$VisitContainerOrderingComposer, + i3.$VisitContainerAnnotationComposer, + $VisitContainerCreateCompanionBuilder, + $VisitContainerUpdateCompanionBuilder, + (i3.VisitContainerData, i3.$VisitContainerReferences), + i3.VisitContainerData, + i0.PrefetchHooks Function({bool containerId}) + > { + $VisitContainerTableManager(i0.GeneratedDatabase db, i3.VisitContainer table) + : super( + i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i3.$VisitContainerFilterComposer($db: db, $table: table), + createOrderingComposer: () => + i3.$VisitContainerOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + i3.$VisitContainerAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + i0.Value id = const i0.Value.absent(), + i0.Value rawUrl = const i0.Value.absent(), + i0.Value urlCanonical = const i0.Value.absent(), + i0.Value visitTime = const i0.Value.absent(), + i0.Value containerId = const i0.Value.absent(), + }) => i3.VisitContainerCompanion( + id: id, + rawUrl: rawUrl, + urlCanonical: urlCanonical, + visitTime: visitTime, + containerId: containerId, + ), + createCompanionCallback: + ({ + i0.Value id = const i0.Value.absent(), + required String rawUrl, + required String urlCanonical, + required int visitTime, + required String containerId, + }) => i3.VisitContainerCompanion.insert( + id: id, + rawUrl: rawUrl, + urlCanonical: urlCanonical, + visitTime: visitTime, + containerId: containerId, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + i3.$VisitContainerReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({containerId = false}) { + return i0.PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends i0.TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (containerId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.containerId, + referencedTable: i3.$VisitContainerReferences + ._containerIdTable(db), + referencedColumn: i3.$VisitContainerReferences + ._containerIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $VisitContainerProcessedTableManager = + i0.ProcessedTableManager< + i0.GeneratedDatabase, + i3.VisitContainer, + i3.VisitContainerData, + i3.$VisitContainerFilterComposer, + i3.$VisitContainerOrderingComposer, + i3.$VisitContainerAnnotationComposer, + $VisitContainerCreateCompanionBuilder, + $VisitContainerUpdateCompanionBuilder, + (i3.VisitContainerData, i3.$VisitContainerReferences), + i3.VisitContainerData, + i0.PrefetchHooks Function({bool containerId}) + >; class Container extends i0.Table with i0.TableInfo { @@ -5430,6 +5869,318 @@ i0.Trigger get containerToHistoryOnMetadataUpdate => i0.Trigger( 'container_to_history_on_metadata_update', ); +class VisitContainer extends i0.Table + with i0.TableInfo { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + VisitContainer(this.attachedDatabase, [this._alias]); + late final i0.GeneratedColumn id = i0.GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: i0.DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL PRIMARY KEY AUTOINCREMENT', + ); + late final i0.GeneratedColumn rawUrl = i0.GeneratedColumn( + 'raw_url', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final i0.GeneratedColumn urlCanonical = + i0.GeneratedColumn( + 'url_canonical', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final i0.GeneratedColumn visitTime = i0.GeneratedColumn( + 'visit_time', + aliasedName, + false, + type: i0.DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final i0.GeneratedColumn containerId = + i0.GeneratedColumn( + 'container_id', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: + 'NOT NULL REFERENCES container(id)ON DELETE CASCADE', + ); + @override + List get $columns => [ + id, + rawUrl, + urlCanonical, + visitTime, + containerId, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'visit_container'; + @override + Set get $primaryKey => {id}; + @override + i3.VisitContainerData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i3.VisitContainerData( + id: attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + rawUrl: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}raw_url'], + )!, + urlCanonical: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}url_canonical'], + )!, + visitTime: attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}visit_time'], + )!, + containerId: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}container_id'], + )!, + ); + } + + @override + VisitContainer createAlias(String alias) { + return VisitContainer(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class VisitContainerData extends i0.DataClass + implements i0.Insertable { + final int id; + + /// Original visited URL; used to open the page and as the key for the Places + /// delete mirror (Places keys visits by URL + time). + final String rawUrl; + + /// Structural canonical form; the join key to Places visits (computed in Dart + /// via canonicalizeUrl so it matches the local index's canonicalization). + final String urlCanonical; + + /// Epoch MILLISECONDS (plain INTEGER, not a drift DATETIME which stores + /// seconds). Matches Places `VisitInfo.visitTime`. + final int visitTime; + final String containerId; + const VisitContainerData({ + required this.id, + required this.rawUrl, + required this.urlCanonical, + required this.visitTime, + required this.containerId, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = i0.Variable(id); + map['raw_url'] = i0.Variable(rawUrl); + map['url_canonical'] = i0.Variable(urlCanonical); + map['visit_time'] = i0.Variable(visitTime); + map['container_id'] = i0.Variable(containerId); + return map; + } + + factory VisitContainerData.fromJson( + Map json, { + i0.ValueSerializer? serializer, + }) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return VisitContainerData( + id: serializer.fromJson(json['id']), + rawUrl: serializer.fromJson(json['raw_url']), + urlCanonical: serializer.fromJson(json['url_canonical']), + visitTime: serializer.fromJson(json['visit_time']), + containerId: serializer.fromJson(json['container_id']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'raw_url': serializer.toJson(rawUrl), + 'url_canonical': serializer.toJson(urlCanonical), + 'visit_time': serializer.toJson(visitTime), + 'container_id': serializer.toJson(containerId), + }; + } + + i3.VisitContainerData copyWith({ + int? id, + String? rawUrl, + String? urlCanonical, + int? visitTime, + String? containerId, + }) => i3.VisitContainerData( + id: id ?? this.id, + rawUrl: rawUrl ?? this.rawUrl, + urlCanonical: urlCanonical ?? this.urlCanonical, + visitTime: visitTime ?? this.visitTime, + containerId: containerId ?? this.containerId, + ); + VisitContainerData copyWithCompanion(i3.VisitContainerCompanion data) { + return VisitContainerData( + id: data.id.present ? data.id.value : this.id, + rawUrl: data.rawUrl.present ? data.rawUrl.value : this.rawUrl, + urlCanonical: data.urlCanonical.present + ? data.urlCanonical.value + : this.urlCanonical, + visitTime: data.visitTime.present ? data.visitTime.value : this.visitTime, + containerId: data.containerId.present + ? data.containerId.value + : this.containerId, + ); + } + + @override + String toString() { + return (StringBuffer('VisitContainerData(') + ..write('id: $id, ') + ..write('rawUrl: $rawUrl, ') + ..write('urlCanonical: $urlCanonical, ') + ..write('visitTime: $visitTime, ') + ..write('containerId: $containerId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, rawUrl, urlCanonical, visitTime, containerId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i3.VisitContainerData && + other.id == this.id && + other.rawUrl == this.rawUrl && + other.urlCanonical == this.urlCanonical && + other.visitTime == this.visitTime && + other.containerId == this.containerId); +} + +class VisitContainerCompanion + extends i0.UpdateCompanion { + final i0.Value id; + final i0.Value rawUrl; + final i0.Value urlCanonical; + final i0.Value visitTime; + final i0.Value containerId; + const VisitContainerCompanion({ + this.id = const i0.Value.absent(), + this.rawUrl = const i0.Value.absent(), + this.urlCanonical = const i0.Value.absent(), + this.visitTime = const i0.Value.absent(), + this.containerId = const i0.Value.absent(), + }); + VisitContainerCompanion.insert({ + this.id = const i0.Value.absent(), + required String rawUrl, + required String urlCanonical, + required int visitTime, + required String containerId, + }) : rawUrl = i0.Value(rawUrl), + urlCanonical = i0.Value(urlCanonical), + visitTime = i0.Value(visitTime), + containerId = i0.Value(containerId); + static i0.Insertable custom({ + i0.Expression? id, + i0.Expression? rawUrl, + i0.Expression? urlCanonical, + i0.Expression? visitTime, + i0.Expression? containerId, + }) { + return i0.RawValuesInsertable({ + if (id != null) 'id': id, + if (rawUrl != null) 'raw_url': rawUrl, + if (urlCanonical != null) 'url_canonical': urlCanonical, + if (visitTime != null) 'visit_time': visitTime, + if (containerId != null) 'container_id': containerId, + }); + } + + i3.VisitContainerCompanion copyWith({ + i0.Value? id, + i0.Value? rawUrl, + i0.Value? urlCanonical, + i0.Value? visitTime, + i0.Value? containerId, + }) { + return i3.VisitContainerCompanion( + id: id ?? this.id, + rawUrl: rawUrl ?? this.rawUrl, + urlCanonical: urlCanonical ?? this.urlCanonical, + visitTime: visitTime ?? this.visitTime, + containerId: containerId ?? this.containerId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = i0.Variable(id.value); + } + if (rawUrl.present) { + map['raw_url'] = i0.Variable(rawUrl.value); + } + if (urlCanonical.present) { + map['url_canonical'] = i0.Variable(urlCanonical.value); + } + if (visitTime.present) { + map['visit_time'] = i0.Variable(visitTime.value); + } + if (containerId.present) { + map['container_id'] = i0.Variable(containerId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('VisitContainerCompanion(') + ..write('id: $id, ') + ..write('rawUrl: $rawUrl, ') + ..write('urlCanonical: $urlCanonical, ') + ..write('visitTime: $visitTime, ') + ..write('containerId: $containerId') + ..write(')')) + .toString(); + } +} + +i0.Index get idxVcCanonical => i0.Index( + 'idx_vc_canonical', + 'CREATE INDEX idx_vc_canonical ON visit_container (url_canonical, visit_time)', +); +i0.Index get idxVcContainer => i0.Index( + 'idx_vc_container', + 'CREATE INDEX idx_vc_container ON visit_container (container_id, visit_time DESC)', +); + class DefinitionsDrift extends i9.ModularAccessor { DefinitionsDrift(i0.GeneratedDatabase db) : super(db); Future optimizeFtsIndex() { diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart index 42344fc2..a5d45cdf 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart @@ -50,6 +50,18 @@ class ContainerMetadata with FastEquatable { @JsonKey(defaultValue: false) final bool excludeFromIndex; + // When true, this container's browsing history is not recorded at all: the + // native WebLibreHistoryDelegate skips the Mozilla Places write for its + // visits (hard exclude / "incognito container"), and no visit→container + // relation row is written. Gates history recording independently of + // `excludeFromIndex` (which only gates the local FTS search index). + // + // Invariant: requires a Gecko contextId — without one the native delegate + // can't distinguish the container's visits to skip them. Enforced by + // [sanitized] on write and normalized on read below. + @JsonKey(defaultValue: false) + final bool excludeFromHistory; + @JsonKey(defaultValue: false) final bool bypassGlobalProxy; @@ -67,6 +79,7 @@ class ContainerMetadata with FastEquatable { required this.proxyConnectionId, required this.clearDataOnExit, required this.excludeFromIndex, + required this.excludeFromHistory, required this.bypassGlobalProxy, required this.useCustomColor, required this.assignedSites, @@ -78,6 +91,7 @@ class ContainerMetadata with FastEquatable { ProxyConnectionId? proxyConnectionId, bool? clearDataOnExit, bool? excludeFromIndex, + bool? excludeFromHistory, bool? bypassGlobalProxy, bool? useCustomColor, List? assignedSites, @@ -87,11 +101,31 @@ class ContainerMetadata with FastEquatable { proxyConnectionId: proxyConnectionId, clearDataOnExit: clearDataOnExit ?? false, excludeFromIndex: excludeFromIndex ?? false, + // Invariant: exclude-from-history requires a Gecko contextId (cookie + // isolation). Without one there is no way to hard-exclude the + // container from Places — the native delegate can't tell its visits + // apart. This is the deserialization path, so a legacy/foreign record + // with the bad combination is normalized on read; writers re-apply it + // via [sanitized]. + excludeFromHistory: + (excludeFromHistory ?? false) && contextualIdentity != null, bypassGlobalProxy: bypassGlobalProxy ?? false, useCustomColor: useCustomColor ?? false, assignedSites: assignedSites, ); + /// Enforce the [excludeFromHistory] invariant before persistence: it can only + /// be true for a cookie-isolated (contextId-bearing) container, since the + /// native delegate needs the contextId to hard-exclude visits from Places. + /// The primary constructor can't normalize (copy_with_extension_gen requires + /// params to map 1:1 to fields), so writers route through this. + ContainerMetadata sanitized() { + if (excludeFromHistory && contextualIdentity == null) { + return copyWith(excludeFromHistory: false); + } + return this; + } + bool get usesTorProxy => proxyConnectionId is TorProxyConnectionId; factory ContainerMetadata.fromJson(Map json) => @@ -106,6 +140,7 @@ class ContainerMetadata with FastEquatable { proxyConnectionId, clearDataOnExit, excludeFromIndex, + excludeFromHistory, bypassGlobalProxy, useCustomColor, assignedSites, diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart index 8f38e6dc..bce40c22 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart @@ -17,6 +17,8 @@ abstract class _$ContainerMetadataCWProxy { ContainerMetadata excludeFromIndex(bool excludeFromIndex); + ContainerMetadata excludeFromHistory(bool excludeFromHistory); + ContainerMetadata bypassGlobalProxy(bool bypassGlobalProxy); ContainerMetadata useCustomColor(bool useCustomColor); @@ -36,6 +38,7 @@ abstract class _$ContainerMetadataCWProxy { ProxyConnectionId? proxyConnectionId, bool clearDataOnExit, bool excludeFromIndex, + bool excludeFromHistory, bool bypassGlobalProxy, bool useCustomColor, List? assignedSites, @@ -68,6 +71,10 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy { ContainerMetadata excludeFromIndex(bool excludeFromIndex) => call(excludeFromIndex: excludeFromIndex); + @override + ContainerMetadata excludeFromHistory(bool excludeFromHistory) => + call(excludeFromHistory: excludeFromHistory); + @override ContainerMetadata bypassGlobalProxy(bool bypassGlobalProxy) => call(bypassGlobalProxy: bypassGlobalProxy); @@ -94,6 +101,7 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy { Object? proxyConnectionId = const $CopyWithPlaceholder(), Object? clearDataOnExit = const $CopyWithPlaceholder(), Object? excludeFromIndex = const $CopyWithPlaceholder(), + Object? excludeFromHistory = const $CopyWithPlaceholder(), Object? bypassGlobalProxy = const $CopyWithPlaceholder(), Object? useCustomColor = const $CopyWithPlaceholder(), Object? assignedSites = const $CopyWithPlaceholder(), @@ -123,6 +131,12 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy { ? _value.excludeFromIndex // ignore: cast_nullable_to_non_nullable : excludeFromIndex as bool, + excludeFromHistory: + excludeFromHistory == const $CopyWithPlaceholder() || + excludeFromHistory == null + ? _value.excludeFromHistory + // ignore: cast_nullable_to_non_nullable + : excludeFromHistory as bool, bypassGlobalProxy: bypassGlobalProxy == const $CopyWithPlaceholder() || bypassGlobalProxy == null @@ -275,6 +289,7 @@ ContainerMetadata _$ContainerMetadataFromJson(Map json) => ), clearDataOnExit: json['clearDataOnExit'] as bool? ?? false, excludeFromIndex: json['excludeFromIndex'] as bool? ?? false, + excludeFromHistory: json['excludeFromHistory'] as bool? ?? false, bypassGlobalProxy: json['bypassGlobalProxy'] as bool? ?? false, useCustomColor: json['useCustomColor'] as bool? ?? false, assignedSites: (json['assignedSites'] as List?) @@ -293,6 +308,7 @@ Map _$ContainerMetadataToJson( 'proxyConnectionId': _proxyConnectionIdToJson(instance.proxyConnectionId), 'clearDataOnExit': instance.clearDataOnExit, 'excludeFromIndex': instance.excludeFromIndex, + 'excludeFromHistory': instance.excludeFromHistory, 'bypassGlobalProxy': instance.bypassGlobalProxy, 'useCustomColor': instance.useCustomColor, 'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(), diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/dialogs/delete_container_dialog.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/dialogs/delete_container_dialog.dart index d729b832..21a806a0 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/dialogs/delete_container_dialog.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/dialogs/delete_container_dialog.dart @@ -19,34 +19,79 @@ */ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; -/// Dialog to confirm container deletion. -/// Returns true if user confirms deletion, false if cancelled, null if dismissed. -Future showDeleteContainerDialog(BuildContext context) { - return showDialog( +/// Result of the delete-container confirmation. +class DeleteContainerDecision { + /// Whether the container's browsing history should also be deleted from + /// Mozilla Places. When false, the visit→container relation dissolves and the + /// visits are kept (shown as uncontained). + final bool wipeHistory; + + const DeleteContainerDecision({required this.wipeHistory}); +} + +/// Confirm container deletion. Returns the decision on confirm, or `null` if +/// cancelled/dismissed. +Future showDeleteContainerDialog( + BuildContext context, +) { + return showDialog( context: context, - builder: (BuildContext context) { - return AlertDialog( - icon: const Icon(Icons.warning), - title: const Text('Delete Container'), - content: const Text( - 'Are you sure you want to delete this container and close all attached tabs?', - ), - actions: [ - TextButton( - onPressed: () { - Navigator.pop(context, false); - }, - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - Navigator.pop(context, true); - }, - child: const Text('Delete'), - ), - ], - ); - }, + builder: (context) => const _DeleteContainerDialog(), ); } + +class _DeleteContainerDialog extends HookWidget { + const _DeleteContainerDialog(); + + @override + Widget build(BuildContext context) { + final wipeHistory = useState(false); + + return AlertDialog( + icon: const Icon(Icons.warning), + title: const Text('Delete Container'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Are you sure you want to delete this container and close all ' + 'attached tabs?', + ), + const SizedBox(height: 8), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + value: wipeHistory.value, + onChanged: (value) { + wipeHistory.value = value ?? false; + }, + title: const Text("Also delete this container's history"), + subtitle: const Text( + 'Otherwise it is kept and shown as uncontained', + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context, null); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop( + context, + DeleteContainerDecision(wipeHistory: wipeHistory.value), + ); + }, + child: const Text('Delete'), + ), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart index 1de6f28c..6891a7ad 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart @@ -26,6 +26,7 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/uuid.dart'; +import 'package:weblibre/features/geckoview/features/history/domain/repositories/container_history.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/controllers/container_topic.dart'; @@ -100,6 +101,9 @@ class ContainerEditScreen extends HookConsumerWidget { final excludeFromIndex = useState( initialContainer.metadata.excludeFromIndex, ); + final excludeFromHistory = useState( + initialContainer.metadata.excludeFromHistory, + ); final bypassGlobalProxy = useState( initialContainer.metadata.bypassGlobalProxy, ); @@ -126,13 +130,18 @@ class ContainerEditScreen extends HookConsumerWidget { clearDataOnExit: clearDataOnExit.value && contextualIdentity.value != null, excludeFromIndex: excludeFromIndex.value, + // Requires a Gecko contextId: the native delegate can't hard-exclude + // a container's visits without one. sanitized() enforces the same + // invariant defensively on write. + excludeFromHistory: + excludeFromHistory.value && contextualIdentity.value != null, bypassGlobalProxy: contextualIdentity.value != null && proxyConnectionId.value == null && bypassGlobalProxy.value, useCustomColor: useCustomColor.value, assignedSites: assignedSites.value, - ), + ).sanitized(), ); } @@ -234,7 +243,16 @@ class ContainerEditScreen extends HookConsumerWidget { Future deleteContainer() async { final result = await showDeleteContainerDialog(context); - if (result == true) { + if (result != null) { + // Delete the container's Places visits BEFORE the container itself so + // the relation rows still exist to find them; deleting the container + // then dissolves the relations via ON DELETE CASCADE. + if (result.wipeHistory) { + await ref + .read(containerHistoryRepositoryProvider.notifier) + .deletePlacesVisitsForContainer(initialContainer.id); + } + await ref .read(containerRepositoryProvider.notifier) .deleteContainer(initialContainer.id); @@ -541,6 +559,24 @@ class ContainerEditScreen extends HookConsumerWidget { excludeFromIndex.value = value; }, ), + const Divider(height: 1, indent: 56), + SwitchListTile.adaptive( + value: + contextualIdentity.value != null && + excludeFromHistory.value, + title: const Text('Exclude from History'), + subtitle: Text( + contextualIdentity.value != null + ? "Don't record this container's browsing history" + : 'Requires cookie isolation to be enabled', + ), + secondary: const Icon(MdiIcons.incognito), + onChanged: (contextualIdentity.value != null) + ? (value) { + excludeFromHistory.value = value; + } + : null, + ), ], ), ), diff --git a/apps/weblibre/lib/features/user/domain/repositories/engine_settings.g.dart b/apps/weblibre/lib/features/user/domain/repositories/engine_settings.g.dart index 2b09a6b1..152f36fa 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/engine_settings.g.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/engine_settings.g.dart @@ -34,7 +34,7 @@ final class EngineSettingsRepositoryProvider } String _$engineSettingsRepositoryHash() => - r'03cfe93b6d4ac8cdb0b33627f4baa75240bff745'; + r'db077d5aa6a7aec5cdb019308cc942079567bab9'; abstract class _$EngineSettingsRepository extends $StreamNotifier { diff --git a/apps/weblibre/lib/main.dart b/apps/weblibre/lib/main.dart index 67c3d45c..4be0a9a7 100644 --- a/apps/weblibre/lib/main.dart +++ b/apps/weblibre/lib/main.dart @@ -29,7 +29,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart' - show GeckoBrowserService, GeckoLoggingService, LogLevel; + show + GeckoBrowserService, + GeckoEngineSettingsService, + GeckoLoggingService, + LogLevel; import 'package:home_widget/home_widget.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:logger/logger.dart'; @@ -47,8 +51,11 @@ import 'package:weblibre/domain/services/app_initialization.dart'; import 'package:weblibre/domain/services/display_mode.dart'; import 'package:weblibre/features/account/domain/services/account_callback_handler.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/services/engine_settings_replication.dart'; +import 'package:weblibre/features/geckoview/features/history/domain/services/history_exclusion_replication.dart'; +import 'package:weblibre/features/geckoview/features/history/domain/services/visit_container_recorder.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_service.dart'; import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/services/local_index_pruner.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/services/local_index_settings_sync.dart'; import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_logs.dart'; @@ -235,6 +242,35 @@ class _MainWidget extends HookConsumerWidget { final clearStartupUBlockFilterListsPref = !engineSettings.ublockFilterListSettings.enabled; + // Push the hard exclude-from-history contextId set to native BEFORE the + // engine starts — it records visits as soon as restored tabs load, so an + // excluded ("incognito") container could otherwise leak to Places during + // the startup window. Best-effort: on failure the keepAlive provider still + // pushes once containers are available. + try { + final availableContainers = await ref + .read(containerRepositoryProvider.notifier) + .getAllContainersWithCount(); + + await GeckoEngineSettingsService().setExcludedHistoryContextIds( + excludedHistoryContextIds(availableContainers), + ); + } catch (e, s) { + logger.w( + 'Failed initial history-exclusion push at startup', + error: e, + stackTrace: s, + ); + } + + // Register the visit→container recorder BEFORE the engine starts. It only + // installs a Dart-side GeckoHistoryEvents handler (no native dependency), + // while native visit events can already fire as restored tabs load during + // initialize — Pigeon FlutterApi messages aren't buffered, so a recorder + // registered afterwards would silently drop those early visits' container + // relations. + ref.read(visitContainerRecorderProvider); + try { await GeckoBrowserService().initialize( filesystem.relativeProfilePath, diff --git a/apps/weblibre/test/drift/tabs/generated/schema.dart b/apps/weblibre/test/drift/tabs/generated/schema.dart index c1c94a54..8b002d94 100644 --- a/apps/weblibre/test/drift/tabs/generated/schema.dart +++ b/apps/weblibre/test/drift/tabs/generated/schema.dart @@ -17,6 +17,7 @@ import 'schema_v11.dart' as v11; import 'schema_v12.dart' as v12; import 'schema_v13.dart' as v13; import 'schema_v14.dart' as v14; +import 'schema_v15.dart' as v15; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -48,10 +49,27 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v13.DatabaseAtV13(db); case 14: return v14.DatabaseAtV14(db); + case 15: + return v15.DatabaseAtV15(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]; + static const versions = const [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + ]; } diff --git a/apps/weblibre/test/drift/tabs/generated/schema_v15.dart b/apps/weblibre/test/drift/tabs/generated/schema_v15.dart new file mode 100644 index 00000000..d0dfd62a --- /dev/null +++ b/apps/weblibre/test/drift/tabs/generated/schema_v15.dart @@ -0,0 +1,3507 @@ +// dart format width=80 +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class Container extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Container(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn color = GeneratedColumn( + 'color', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn orderKey = GeneratedColumn( + 'order_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isPinned = GeneratedColumn( + 'is_pinned', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn metadata = GeneratedColumn( + 'metadata', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [ + id, + name, + color, + orderKey, + isPinned, + metadata, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'container'; + @override + Set get $primaryKey => {id}; + @override + ContainerData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ContainerData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + ), + color: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}color'], + )!, + orderKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}order_key'], + )!, + isPinned: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_pinned'], + )!, + metadata: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}metadata'], + ), + ); + } + + @override + Container createAlias(String alias) { + return Container(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class ContainerData extends DataClass implements Insertable { + final String id; + final String? name; + final int color; + final String orderKey; + final int isPinned; + final String? metadata; + const ContainerData({ + required this.id, + this.name, + required this.color, + required this.orderKey, + required this.isPinned, + this.metadata, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || name != null) { + map['name'] = Variable(name); + } + map['color'] = Variable(color); + map['order_key'] = Variable(orderKey); + map['is_pinned'] = Variable(isPinned); + if (!nullToAbsent || metadata != null) { + map['metadata'] = Variable(metadata); + } + return map; + } + + factory ContainerData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ContainerData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + color: serializer.fromJson(json['color']), + orderKey: serializer.fromJson(json['orderKey']), + isPinned: serializer.fromJson(json['isPinned']), + metadata: serializer.fromJson(json['metadata']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'color': serializer.toJson(color), + 'orderKey': serializer.toJson(orderKey), + 'isPinned': serializer.toJson(isPinned), + 'metadata': serializer.toJson(metadata), + }; + } + + ContainerData copyWith({ + String? id, + Value name = const Value.absent(), + int? color, + String? orderKey, + int? isPinned, + Value metadata = const Value.absent(), + }) => ContainerData( + id: id ?? this.id, + name: name.present ? name.value : this.name, + color: color ?? this.color, + orderKey: orderKey ?? this.orderKey, + isPinned: isPinned ?? this.isPinned, + metadata: metadata.present ? metadata.value : this.metadata, + ); + ContainerData copyWithCompanion(ContainerCompanion data) { + return ContainerData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + color: data.color.present ? data.color.value : this.color, + orderKey: data.orderKey.present ? data.orderKey.value : this.orderKey, + isPinned: data.isPinned.present ? data.isPinned.value : this.isPinned, + metadata: data.metadata.present ? data.metadata.value : this.metadata, + ); + } + + @override + String toString() { + return (StringBuffer('ContainerData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('color: $color, ') + ..write('orderKey: $orderKey, ') + ..write('isPinned: $isPinned, ') + ..write('metadata: $metadata') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, name, color, orderKey, isPinned, metadata); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ContainerData && + other.id == this.id && + other.name == this.name && + other.color == this.color && + other.orderKey == this.orderKey && + other.isPinned == this.isPinned && + other.metadata == this.metadata); +} + +class ContainerCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value color; + final Value orderKey; + final Value isPinned; + final Value metadata; + final Value rowid; + const ContainerCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.color = const Value.absent(), + this.orderKey = const Value.absent(), + this.isPinned = const Value.absent(), + this.metadata = const Value.absent(), + this.rowid = const Value.absent(), + }); + ContainerCompanion.insert({ + required String id, + this.name = const Value.absent(), + required int color, + required String orderKey, + this.isPinned = const Value.absent(), + this.metadata = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + color = Value(color), + orderKey = Value(orderKey); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? color, + Expression? orderKey, + Expression? isPinned, + Expression? metadata, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (color != null) 'color': color, + if (orderKey != null) 'order_key': orderKey, + if (isPinned != null) 'is_pinned': isPinned, + if (metadata != null) 'metadata': metadata, + if (rowid != null) 'rowid': rowid, + }); + } + + ContainerCompanion copyWith({ + Value? id, + Value? name, + Value? color, + Value? orderKey, + Value? isPinned, + Value? metadata, + Value? rowid, + }) { + return ContainerCompanion( + id: id ?? this.id, + name: name ?? this.name, + color: color ?? this.color, + orderKey: orderKey ?? this.orderKey, + isPinned: isPinned ?? this.isPinned, + metadata: metadata ?? this.metadata, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (color.present) { + map['color'] = Variable(color.value); + } + if (orderKey.present) { + map['order_key'] = Variable(orderKey.value); + } + if (isPinned.present) { + map['is_pinned'] = Variable(isPinned.value); + } + if (metadata.present) { + map['metadata'] = Variable(metadata.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ContainerCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('color: $color, ') + ..write('orderKey: $orderKey, ') + ..write('isPinned: $isPinned, ') + ..write('metadata: $metadata, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Tab extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Tab(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'REFERENCES tab(id)ON DELETE SET NULL', + ); + late final GeneratedColumn containerId = GeneratedColumn( + 'container_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'REFERENCES container(id)ON DELETE CASCADE', + ); + late final GeneratedColumn orderKey = GeneratedColumn( + 'order_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn tabMode = GeneratedColumn( + 'tab_mode', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn isolationContextId = + GeneratedColumn( + 'isolation_context_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn isPinned = GeneratedColumn( + 'is_pinned', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn isProbablyReaderable = GeneratedColumn( + 'is_probably_readerable', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentMarkdown = + GeneratedColumn( + 'extracted_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentMarkdown = + GeneratedColumn( + 'full_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn timestamp = GeneratedColumn( + 'timestamp', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn contentHash = GeneratedColumn( + 'content_hash', + aliasedName, + true, + generatedAs: GeneratedAs( + const CustomExpression( + 'generate_content_hash(title, extracted_content_plain, full_content_plain)', + ), + false, + ), + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: + 'GENERATED ALWAYS AS (generate_content_hash(title, extracted_content_plain, full_content_plain)) VIRTUAL', + ); + @override + List get $columns => [ + id, + source, + parentId, + containerId, + orderKey, + url, + title, + tabMode, + isolationContextId, + isPinned, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + timestamp, + contentHash, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab'; + @override + Set get $primaryKey => {id}; + @override + TabData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + source: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}source'], + )!, + parentId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}parent_id'], + ), + containerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}container_id'], + ), + orderKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}order_key'], + )!, + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + ), + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + tabMode: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}tab_mode'], + )!, + isolationContextId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}isolation_context_id'], + ), + isPinned: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_pinned'], + )!, + isProbablyReaderable: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_probably_readerable'], + ), + extractedContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_markdown'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_markdown'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + timestamp: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}timestamp'], + )!, + contentHash: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}content_hash'], + ), + ); + } + + @override + Tab createAlias(String alias) { + return Tab(attachedDatabase, alias); + } + + @override + List get customConstraints => const [ + 'CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))', + ]; + @override + bool get dontWriteConstraints => true; +} + +class TabData extends DataClass implements Insertable { + final String id; + final int source; + final String? parentId; + final String? containerId; + final String orderKey; + final String? url; + final String? title; + final int tabMode; + final String? isolationContextId; + final int isPinned; + final int? isProbablyReaderable; + final String? extractedContentMarkdown; + final String? extractedContentPlain; + final String? fullContentMarkdown; + final String? fullContentPlain; + final int timestamp; + final int? contentHash; + const TabData({ + required this.id, + required this.source, + this.parentId, + this.containerId, + required this.orderKey, + this.url, + this.title, + required this.tabMode, + this.isolationContextId, + required this.isPinned, + this.isProbablyReaderable, + this.extractedContentMarkdown, + this.extractedContentPlain, + this.fullContentMarkdown, + this.fullContentPlain, + required this.timestamp, + this.contentHash, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['source'] = Variable(source); + if (!nullToAbsent || parentId != null) { + map['parent_id'] = Variable(parentId); + } + if (!nullToAbsent || containerId != null) { + map['container_id'] = Variable(containerId); + } + map['order_key'] = Variable(orderKey); + if (!nullToAbsent || url != null) { + map['url'] = Variable(url); + } + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + map['tab_mode'] = Variable(tabMode); + if (!nullToAbsent || isolationContextId != null) { + map['isolation_context_id'] = Variable(isolationContextId); + } + map['is_pinned'] = Variable(isPinned); + if (!nullToAbsent || isProbablyReaderable != null) { + map['is_probably_readerable'] = Variable(isProbablyReaderable); + } + if (!nullToAbsent || extractedContentMarkdown != null) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown, + ); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentMarkdown != null) { + map['full_content_markdown'] = Variable(fullContentMarkdown); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + map['timestamp'] = Variable(timestamp); + return map; + } + + factory TabData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabData( + id: serializer.fromJson(json['id']), + source: serializer.fromJson(json['source']), + parentId: serializer.fromJson(json['parentId']), + containerId: serializer.fromJson(json['containerId']), + orderKey: serializer.fromJson(json['orderKey']), + url: serializer.fromJson(json['url']), + title: serializer.fromJson(json['title']), + tabMode: serializer.fromJson(json['tabMode']), + isolationContextId: serializer.fromJson( + json['isolationContextId'], + ), + isPinned: serializer.fromJson(json['isPinned']), + isProbablyReaderable: serializer.fromJson( + json['isProbablyReaderable'], + ), + extractedContentMarkdown: serializer.fromJson( + json['extractedContentMarkdown'], + ), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentMarkdown: serializer.fromJson( + json['fullContentMarkdown'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + timestamp: serializer.fromJson(json['timestamp']), + contentHash: serializer.fromJson(json['contentHash']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'source': serializer.toJson(source), + 'parentId': serializer.toJson(parentId), + 'containerId': serializer.toJson(containerId), + 'orderKey': serializer.toJson(orderKey), + 'url': serializer.toJson(url), + 'title': serializer.toJson(title), + 'tabMode': serializer.toJson(tabMode), + 'isolationContextId': serializer.toJson(isolationContextId), + 'isPinned': serializer.toJson(isPinned), + 'isProbablyReaderable': serializer.toJson(isProbablyReaderable), + 'extractedContentMarkdown': serializer.toJson( + extractedContentMarkdown, + ), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentMarkdown': serializer.toJson(fullContentMarkdown), + 'fullContentPlain': serializer.toJson(fullContentPlain), + 'timestamp': serializer.toJson(timestamp), + 'contentHash': serializer.toJson(contentHash), + }; + } + + TabData copyWith({ + String? id, + int? source, + Value parentId = const Value.absent(), + Value containerId = const Value.absent(), + String? orderKey, + Value url = const Value.absent(), + Value title = const Value.absent(), + int? tabMode, + Value isolationContextId = const Value.absent(), + int? isPinned, + Value isProbablyReaderable = const Value.absent(), + Value extractedContentMarkdown = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentMarkdown = const Value.absent(), + Value fullContentPlain = const Value.absent(), + int? timestamp, + Value contentHash = const Value.absent(), + }) => TabData( + id: id ?? this.id, + source: source ?? this.source, + parentId: parentId.present ? parentId.value : this.parentId, + containerId: containerId.present ? containerId.value : this.containerId, + orderKey: orderKey ?? this.orderKey, + url: url.present ? url.value : this.url, + title: title.present ? title.value : this.title, + tabMode: tabMode ?? this.tabMode, + isolationContextId: isolationContextId.present + ? isolationContextId.value + : this.isolationContextId, + isPinned: isPinned ?? this.isPinned, + isProbablyReaderable: isProbablyReaderable.present + ? isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: extractedContentMarkdown.present + ? extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown.present + ? fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + timestamp: timestamp ?? this.timestamp, + contentHash: contentHash.present ? contentHash.value : this.contentHash, + ); + @override + String toString() { + return (StringBuffer('TabData(') + ..write('id: $id, ') + ..write('source: $source, ') + ..write('parentId: $parentId, ') + ..write('containerId: $containerId, ') + ..write('orderKey: $orderKey, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('tabMode: $tabMode, ') + ..write('isolationContextId: $isolationContextId, ') + ..write('isPinned: $isPinned, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('timestamp: $timestamp, ') + ..write('contentHash: $contentHash') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + source, + parentId, + containerId, + orderKey, + url, + title, + tabMode, + isolationContextId, + isPinned, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + timestamp, + contentHash, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabData && + other.id == this.id && + other.source == this.source && + other.parentId == this.parentId && + other.containerId == this.containerId && + other.orderKey == this.orderKey && + other.url == this.url && + other.title == this.title && + other.tabMode == this.tabMode && + other.isolationContextId == this.isolationContextId && + other.isPinned == this.isPinned && + other.isProbablyReaderable == this.isProbablyReaderable && + other.extractedContentMarkdown == this.extractedContentMarkdown && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentMarkdown == this.fullContentMarkdown && + other.fullContentPlain == this.fullContentPlain && + other.timestamp == this.timestamp && + other.contentHash == this.contentHash); +} + +class TabCompanion extends UpdateCompanion { + final Value id; + final Value source; + final Value parentId; + final Value containerId; + final Value orderKey; + final Value url; + final Value title; + final Value tabMode; + final Value isolationContextId; + final Value isPinned; + final Value isProbablyReaderable; + final Value extractedContentMarkdown; + final Value extractedContentPlain; + final Value fullContentMarkdown; + final Value fullContentPlain; + final Value timestamp; + final Value rowid; + const TabCompanion({ + this.id = const Value.absent(), + this.source = const Value.absent(), + this.parentId = const Value.absent(), + this.containerId = const Value.absent(), + this.orderKey = const Value.absent(), + this.url = const Value.absent(), + this.title = const Value.absent(), + this.tabMode = const Value.absent(), + this.isolationContextId = const Value.absent(), + this.isPinned = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.timestamp = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabCompanion.insert({ + required String id, + required int source, + this.parentId = const Value.absent(), + this.containerId = const Value.absent(), + required String orderKey, + this.url = const Value.absent(), + this.title = const Value.absent(), + this.tabMode = const Value.absent(), + this.isolationContextId = const Value.absent(), + this.isPinned = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + required int timestamp, + this.rowid = const Value.absent(), + }) : id = Value(id), + source = Value(source), + orderKey = Value(orderKey), + timestamp = Value(timestamp); + static Insertable custom({ + Expression? id, + Expression? source, + Expression? parentId, + Expression? containerId, + Expression? orderKey, + Expression? url, + Expression? title, + Expression? tabMode, + Expression? isolationContextId, + Expression? isPinned, + Expression? isProbablyReaderable, + Expression? extractedContentMarkdown, + Expression? extractedContentPlain, + Expression? fullContentMarkdown, + Expression? fullContentPlain, + Expression? timestamp, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (source != null) 'source': source, + if (parentId != null) 'parent_id': parentId, + if (containerId != null) 'container_id': containerId, + if (orderKey != null) 'order_key': orderKey, + if (url != null) 'url': url, + if (title != null) 'title': title, + if (tabMode != null) 'tab_mode': tabMode, + if (isolationContextId != null) + 'isolation_context_id': isolationContextId, + if (isPinned != null) 'is_pinned': isPinned, + if (isProbablyReaderable != null) + 'is_probably_readerable': isProbablyReaderable, + if (extractedContentMarkdown != null) + 'extracted_content_markdown': extractedContentMarkdown, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentMarkdown != null) + 'full_content_markdown': fullContentMarkdown, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (timestamp != null) 'timestamp': timestamp, + if (rowid != null) 'rowid': rowid, + }); + } + + TabCompanion copyWith({ + Value? id, + Value? source, + Value? parentId, + Value? containerId, + Value? orderKey, + Value? url, + Value? title, + Value? tabMode, + Value? isolationContextId, + Value? isPinned, + Value? isProbablyReaderable, + Value? extractedContentMarkdown, + Value? extractedContentPlain, + Value? fullContentMarkdown, + Value? fullContentPlain, + Value? timestamp, + Value? rowid, + }) { + return TabCompanion( + id: id ?? this.id, + source: source ?? this.source, + parentId: parentId ?? this.parentId, + containerId: containerId ?? this.containerId, + orderKey: orderKey ?? this.orderKey, + url: url ?? this.url, + title: title ?? this.title, + tabMode: tabMode ?? this.tabMode, + isolationContextId: isolationContextId ?? this.isolationContextId, + isPinned: isPinned ?? this.isPinned, + isProbablyReaderable: isProbablyReaderable ?? this.isProbablyReaderable, + extractedContentMarkdown: + extractedContentMarkdown ?? this.extractedContentMarkdown, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown ?? this.fullContentMarkdown, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + timestamp: timestamp ?? this.timestamp, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (source.present) { + map['source'] = Variable(source.value); + } + if (parentId.present) { + map['parent_id'] = Variable(parentId.value); + } + if (containerId.present) { + map['container_id'] = Variable(containerId.value); + } + if (orderKey.present) { + map['order_key'] = Variable(orderKey.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (tabMode.present) { + map['tab_mode'] = Variable(tabMode.value); + } + if (isolationContextId.present) { + map['isolation_context_id'] = Variable(isolationContextId.value); + } + if (isPinned.present) { + map['is_pinned'] = Variable(isPinned.value); + } + if (isProbablyReaderable.present) { + map['is_probably_readerable'] = Variable(isProbablyReaderable.value); + } + if (extractedContentMarkdown.present) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown.value, + ); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentMarkdown.present) { + map['full_content_markdown'] = Variable( + fullContentMarkdown.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (timestamp.present) { + map['timestamp'] = Variable(timestamp.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabCompanion(') + ..write('id: $id, ') + ..write('source: $source, ') + ..write('parentId: $parentId, ') + ..write('containerId: $containerId, ') + ..write('orderKey: $orderKey, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('tabMode: $tabMode, ') + ..write('isolationContextId: $isolationContextId, ') + ..write('isPinned: $isPinned, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('timestamp: $timestamp, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class ClosedTabTombstone extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ClosedTabTombstone(this.attachedDatabase, [this._alias]); + late final GeneratedColumn tabId = GeneratedColumn( + 'tab_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn closedAt = GeneratedColumn( + 'closed_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [tabId, closedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'closed_tab_tombstone'; + @override + Set get $primaryKey => {tabId}; + @override + ClosedTabTombstoneData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ClosedTabTombstoneData( + tabId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tab_id'], + )!, + closedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}closed_at'], + )!, + ); + } + + @override + ClosedTabTombstone createAlias(String alias) { + return ClosedTabTombstone(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class ClosedTabTombstoneData extends DataClass + implements Insertable { + final String tabId; + final int closedAt; + const ClosedTabTombstoneData({required this.tabId, required this.closedAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['tab_id'] = Variable(tabId); + map['closed_at'] = Variable(closedAt); + return map; + } + + factory ClosedTabTombstoneData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ClosedTabTombstoneData( + tabId: serializer.fromJson(json['tabId']), + closedAt: serializer.fromJson(json['closedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'tabId': serializer.toJson(tabId), + 'closedAt': serializer.toJson(closedAt), + }; + } + + ClosedTabTombstoneData copyWith({String? tabId, int? closedAt}) => + ClosedTabTombstoneData( + tabId: tabId ?? this.tabId, + closedAt: closedAt ?? this.closedAt, + ); + ClosedTabTombstoneData copyWithCompanion(ClosedTabTombstoneCompanion data) { + return ClosedTabTombstoneData( + tabId: data.tabId.present ? data.tabId.value : this.tabId, + closedAt: data.closedAt.present ? data.closedAt.value : this.closedAt, + ); + } + + @override + String toString() { + return (StringBuffer('ClosedTabTombstoneData(') + ..write('tabId: $tabId, ') + ..write('closedAt: $closedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(tabId, closedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ClosedTabTombstoneData && + other.tabId == this.tabId && + other.closedAt == this.closedAt); +} + +class ClosedTabTombstoneCompanion + extends UpdateCompanion { + final Value tabId; + final Value closedAt; + final Value rowid; + const ClosedTabTombstoneCompanion({ + this.tabId = const Value.absent(), + this.closedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + ClosedTabTombstoneCompanion.insert({ + required String tabId, + required int closedAt, + this.rowid = const Value.absent(), + }) : tabId = Value(tabId), + closedAt = Value(closedAt); + static Insertable custom({ + Expression? tabId, + Expression? closedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (tabId != null) 'tab_id': tabId, + if (closedAt != null) 'closed_at': closedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + ClosedTabTombstoneCompanion copyWith({ + Value? tabId, + Value? closedAt, + Value? rowid, + }) { + return ClosedTabTombstoneCompanion( + tabId: tabId ?? this.tabId, + closedAt: closedAt ?? this.closedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (tabId.present) { + map['tab_id'] = Variable(tabId.value); + } + if (closedAt.present) { + map['closed_at'] = Variable(closedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ClosedTabTombstoneCompanion(') + ..write('tabId: $tabId, ') + ..write('closedAt: $closedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class CaptureTab extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + CaptureTab(this.attachedDatabase, [this._alias]); + late final GeneratedColumn tabId = GeneratedColumn( + 'tab_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: + 'NOT NULL PRIMARY KEY REFERENCES tab(id)ON DELETE CASCADE', + ); + late final GeneratedColumn captureId = GeneratedColumn( + 'capture_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn sourceUrl = GeneratedColumn( + 'source_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'pending\'', + defaultValue: const CustomExpression('\'pending\''), + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [ + tabId, + captureId, + sourceUrl, + status, + createdAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'capture_tab'; + @override + Set get $primaryKey => {tabId}; + @override + CaptureTabData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CaptureTabData( + tabId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tab_id'], + )!, + captureId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}capture_id'], + )!, + sourceUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source_url'], + )!, + status: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + CaptureTab createAlias(String alias) { + return CaptureTab(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class CaptureTabData extends DataClass implements Insertable { + final String tabId; + final String captureId; + final String sourceUrl; + final String status; + final int createdAt; + const CaptureTabData({ + required this.tabId, + required this.captureId, + required this.sourceUrl, + required this.status, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['tab_id'] = Variable(tabId); + map['capture_id'] = Variable(captureId); + map['source_url'] = Variable(sourceUrl); + map['status'] = Variable(status); + map['created_at'] = Variable(createdAt); + return map; + } + + factory CaptureTabData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CaptureTabData( + tabId: serializer.fromJson(json['tabId']), + captureId: serializer.fromJson(json['captureId']), + sourceUrl: serializer.fromJson(json['sourceUrl']), + status: serializer.fromJson(json['status']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'tabId': serializer.toJson(tabId), + 'captureId': serializer.toJson(captureId), + 'sourceUrl': serializer.toJson(sourceUrl), + 'status': serializer.toJson(status), + 'createdAt': serializer.toJson(createdAt), + }; + } + + CaptureTabData copyWith({ + String? tabId, + String? captureId, + String? sourceUrl, + String? status, + int? createdAt, + }) => CaptureTabData( + tabId: tabId ?? this.tabId, + captureId: captureId ?? this.captureId, + sourceUrl: sourceUrl ?? this.sourceUrl, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + ); + CaptureTabData copyWithCompanion(CaptureTabCompanion data) { + return CaptureTabData( + tabId: data.tabId.present ? data.tabId.value : this.tabId, + captureId: data.captureId.present ? data.captureId.value : this.captureId, + sourceUrl: data.sourceUrl.present ? data.sourceUrl.value : this.sourceUrl, + status: data.status.present ? data.status.value : this.status, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('CaptureTabData(') + ..write('tabId: $tabId, ') + ..write('captureId: $captureId, ') + ..write('sourceUrl: $sourceUrl, ') + ..write('status: $status, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(tabId, captureId, sourceUrl, status, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CaptureTabData && + other.tabId == this.tabId && + other.captureId == this.captureId && + other.sourceUrl == this.sourceUrl && + other.status == this.status && + other.createdAt == this.createdAt); +} + +class CaptureTabCompanion extends UpdateCompanion { + final Value tabId; + final Value captureId; + final Value sourceUrl; + final Value status; + final Value createdAt; + final Value rowid; + const CaptureTabCompanion({ + this.tabId = const Value.absent(), + this.captureId = const Value.absent(), + this.sourceUrl = const Value.absent(), + this.status = const Value.absent(), + this.createdAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + CaptureTabCompanion.insert({ + required String tabId, + required String captureId, + required String sourceUrl, + this.status = const Value.absent(), + required int createdAt, + this.rowid = const Value.absent(), + }) : tabId = Value(tabId), + captureId = Value(captureId), + sourceUrl = Value(sourceUrl), + createdAt = Value(createdAt); + static Insertable custom({ + Expression? tabId, + Expression? captureId, + Expression? sourceUrl, + Expression? status, + Expression? createdAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (tabId != null) 'tab_id': tabId, + if (captureId != null) 'capture_id': captureId, + if (sourceUrl != null) 'source_url': sourceUrl, + if (status != null) 'status': status, + if (createdAt != null) 'created_at': createdAt, + if (rowid != null) 'rowid': rowid, + }); + } + + CaptureTabCompanion copyWith({ + Value? tabId, + Value? captureId, + Value? sourceUrl, + Value? status, + Value? createdAt, + Value? rowid, + }) { + return CaptureTabCompanion( + tabId: tabId ?? this.tabId, + captureId: captureId ?? this.captureId, + sourceUrl: sourceUrl ?? this.sourceUrl, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (tabId.present) { + map['tab_id'] = Variable(tabId.value); + } + if (captureId.present) { + map['capture_id'] = Variable(captureId.value); + } + if (sourceUrl.present) { + map['source_url'] = Variable(sourceUrl.value); + } + if (status.present) { + map['status'] = Variable(status.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CaptureTabCompanion(') + ..write('tabId: $tabId, ') + ..write('captureId: $captureId, ') + ..write('sourceUrl: $sourceUrl, ') + ..write('status: $status, ') + ..write('createdAt: $createdAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class TabFts extends Table + with TableInfo, VirtualTableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + TabFts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [ + title, + url, + extractedContentPlain, + fullContentPlain, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab_fts'; + @override + Set get $primaryKey => const {}; + @override + TabFtsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabFtsData( + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + ); + } + + @override + TabFts createAlias(String alias) { + return TabFts(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; + @override + String get moduleAndArgs => + 'fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize="trigram")'; +} + +class TabFtsData extends DataClass implements Insertable { + final String? title; + final String? url; + final String? extractedContentPlain; + final String? fullContentPlain; + const TabFtsData({ + this.title, + this.url, + this.extractedContentPlain, + this.fullContentPlain, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || url != null) { + map['url'] = Variable(url); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + return map; + } + + factory TabFtsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabFtsData( + title: serializer.fromJson(json['title']), + url: serializer.fromJson(json['url']), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'title': serializer.toJson(title), + 'url': serializer.toJson(url), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentPlain': serializer.toJson(fullContentPlain), + }; + } + + TabFtsData copyWith({ + Value title = const Value.absent(), + Value url = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentPlain = const Value.absent(), + }) => TabFtsData( + title: title.present ? title.value : this.title, + url: url.present ? url.value : this.url, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + ); + TabFtsData copyWithCompanion(TabFtsCompanion data) { + return TabFtsData( + title: data.title.present ? data.title.value : this.title, + url: data.url.present ? data.url.value : this.url, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + ); + } + + @override + String toString() { + return (StringBuffer('TabFtsData(') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(title, url, extractedContentPlain, fullContentPlain); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabFtsData && + other.title == this.title && + other.url == this.url && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentPlain == this.fullContentPlain); +} + +class TabFtsCompanion extends UpdateCompanion { + final Value title; + final Value url; + final Value extractedContentPlain; + final Value fullContentPlain; + final Value rowid; + const TabFtsCompanion({ + this.title = const Value.absent(), + this.url = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabFtsCompanion.insert({ + this.title = const Value.absent(), + this.url = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? title, + Expression? url, + Expression? extractedContentPlain, + Expression? fullContentPlain, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (title != null) 'title': title, + if (url != null) 'url': url, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (rowid != null) 'rowid': rowid, + }); + } + + TabFtsCompanion copyWith({ + Value? title, + Value? url, + Value? extractedContentPlain, + Value? fullContentPlain, + Value? rowid, + }) { + return TabFtsCompanion( + title: title ?? this.title, + url: url ?? this.url, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (title.present) { + map['title'] = Variable(title.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabFtsCompanion(') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class LocalIndexSetting extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalIndexSetting(this.attachedDatabase, [this._alias]); + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [key, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_index_setting'; + @override + Set get $primaryKey => {key}; + @override + LocalIndexSettingData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalIndexSettingData( + key: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}key'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + LocalIndexSetting createAlias(String alias) { + return LocalIndexSetting(attachedDatabase, alias); + } + + @override + bool get isStrict => true; + @override + bool get dontWriteConstraints => true; +} + +class LocalIndexSettingData extends DataClass + implements Insertable { + final String key; + final int value; + const LocalIndexSettingData({required this.key, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['key'] = Variable(key); + map['value'] = Variable(value); + return map; + } + + factory LocalIndexSettingData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalIndexSettingData( + key: serializer.fromJson(json['key']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'key': serializer.toJson(key), + 'value': serializer.toJson(value), + }; + } + + LocalIndexSettingData copyWith({String? key, int? value}) => + LocalIndexSettingData(key: key ?? this.key, value: value ?? this.value); + LocalIndexSettingData copyWithCompanion(LocalIndexSettingCompanion data) { + return LocalIndexSettingData( + key: data.key.present ? data.key.value : this.key, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('LocalIndexSettingData(') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(key, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalIndexSettingData && + other.key == this.key && + other.value == this.value); +} + +class LocalIndexSettingCompanion + extends UpdateCompanion { + final Value key; + final Value value; + final Value rowid; + const LocalIndexSettingCompanion({ + this.key = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + LocalIndexSettingCompanion.insert({ + required String key, + required int value, + this.rowid = const Value.absent(), + }) : key = Value(key), + value = Value(value); + static Insertable custom({ + Expression? key, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (key != null) 'key': key, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + LocalIndexSettingCompanion copyWith({ + Value? key, + Value? value, + Value? rowid, + }) { + return LocalIndexSettingCompanion( + key: key ?? this.key, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (key.present) { + map['key'] = Variable(key.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalIndexSettingCompanion(') + ..write('key: $key, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class History extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + History(this.attachedDatabase, [this._alias]); + late final GeneratedColumn urlCanonical = GeneratedColumn( + 'url_canonical', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn urlHost = GeneratedColumn( + 'url_host', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn urlPath = GeneratedColumn( + 'url_path', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn isProbablyReaderable = GeneratedColumn( + 'is_probably_readerable', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentMarkdown = + GeneratedColumn( + 'extracted_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentMarkdown = + GeneratedColumn( + 'full_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn contentHash = GeneratedColumn( + 'content_hash', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn observedAt = GeneratedColumn( + 'observed_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn observedCount = GeneratedColumn( + 'observed_count', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + @override + List get $columns => [ + urlCanonical, + urlHost, + urlPath, + title, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + contentHash, + observedAt, + observedCount, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'history'; + @override + Set get $primaryKey => {urlCanonical}; + @override + HistoryData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return HistoryData( + urlCanonical: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url_canonical'], + )!, + urlHost: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url_host'], + )!, + urlPath: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url_path'], + ), + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + isProbablyReaderable: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_probably_readerable'], + ), + extractedContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_markdown'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_markdown'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + contentHash: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}content_hash'], + ), + observedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}observed_at'], + )!, + observedCount: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}observed_count'], + )!, + ); + } + + @override + History createAlias(String alias) { + return History(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class HistoryData extends DataClass implements Insertable { + final String urlCanonical; + final String urlHost; + final String? urlPath; + final String? title; + final int? isProbablyReaderable; + final String? extractedContentMarkdown; + final String? extractedContentPlain; + final String? fullContentMarkdown; + final String? fullContentPlain; + final int? contentHash; + final int observedAt; + final int observedCount; + const HistoryData({ + required this.urlCanonical, + required this.urlHost, + this.urlPath, + this.title, + this.isProbablyReaderable, + this.extractedContentMarkdown, + this.extractedContentPlain, + this.fullContentMarkdown, + this.fullContentPlain, + this.contentHash, + required this.observedAt, + required this.observedCount, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['url_canonical'] = Variable(urlCanonical); + map['url_host'] = Variable(urlHost); + if (!nullToAbsent || urlPath != null) { + map['url_path'] = Variable(urlPath); + } + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || isProbablyReaderable != null) { + map['is_probably_readerable'] = Variable(isProbablyReaderable); + } + if (!nullToAbsent || extractedContentMarkdown != null) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown, + ); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentMarkdown != null) { + map['full_content_markdown'] = Variable(fullContentMarkdown); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + if (!nullToAbsent || contentHash != null) { + map['content_hash'] = Variable(contentHash); + } + map['observed_at'] = Variable(observedAt); + map['observed_count'] = Variable(observedCount); + return map; + } + + factory HistoryData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return HistoryData( + urlCanonical: serializer.fromJson(json['urlCanonical']), + urlHost: serializer.fromJson(json['urlHost']), + urlPath: serializer.fromJson(json['urlPath']), + title: serializer.fromJson(json['title']), + isProbablyReaderable: serializer.fromJson( + json['isProbablyReaderable'], + ), + extractedContentMarkdown: serializer.fromJson( + json['extractedContentMarkdown'], + ), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentMarkdown: serializer.fromJson( + json['fullContentMarkdown'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + contentHash: serializer.fromJson(json['contentHash']), + observedAt: serializer.fromJson(json['observedAt']), + observedCount: serializer.fromJson(json['observedCount']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'urlCanonical': serializer.toJson(urlCanonical), + 'urlHost': serializer.toJson(urlHost), + 'urlPath': serializer.toJson(urlPath), + 'title': serializer.toJson(title), + 'isProbablyReaderable': serializer.toJson(isProbablyReaderable), + 'extractedContentMarkdown': serializer.toJson( + extractedContentMarkdown, + ), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentMarkdown': serializer.toJson(fullContentMarkdown), + 'fullContentPlain': serializer.toJson(fullContentPlain), + 'contentHash': serializer.toJson(contentHash), + 'observedAt': serializer.toJson(observedAt), + 'observedCount': serializer.toJson(observedCount), + }; + } + + HistoryData copyWith({ + String? urlCanonical, + String? urlHost, + Value urlPath = const Value.absent(), + Value title = const Value.absent(), + Value isProbablyReaderable = const Value.absent(), + Value extractedContentMarkdown = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentMarkdown = const Value.absent(), + Value fullContentPlain = const Value.absent(), + Value contentHash = const Value.absent(), + int? observedAt, + int? observedCount, + }) => HistoryData( + urlCanonical: urlCanonical ?? this.urlCanonical, + urlHost: urlHost ?? this.urlHost, + urlPath: urlPath.present ? urlPath.value : this.urlPath, + title: title.present ? title.value : this.title, + isProbablyReaderable: isProbablyReaderable.present + ? isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: extractedContentMarkdown.present + ? extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown.present + ? fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + contentHash: contentHash.present ? contentHash.value : this.contentHash, + observedAt: observedAt ?? this.observedAt, + observedCount: observedCount ?? this.observedCount, + ); + HistoryData copyWithCompanion(HistoryCompanion data) { + return HistoryData( + urlCanonical: data.urlCanonical.present + ? data.urlCanonical.value + : this.urlCanonical, + urlHost: data.urlHost.present ? data.urlHost.value : this.urlHost, + urlPath: data.urlPath.present ? data.urlPath.value : this.urlPath, + title: data.title.present ? data.title.value : this.title, + isProbablyReaderable: data.isProbablyReaderable.present + ? data.isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: data.extractedContentMarkdown.present + ? data.extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: data.fullContentMarkdown.present + ? data.fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + contentHash: data.contentHash.present + ? data.contentHash.value + : this.contentHash, + observedAt: data.observedAt.present + ? data.observedAt.value + : this.observedAt, + observedCount: data.observedCount.present + ? data.observedCount.value + : this.observedCount, + ); + } + + @override + String toString() { + return (StringBuffer('HistoryData(') + ..write('urlCanonical: $urlCanonical, ') + ..write('urlHost: $urlHost, ') + ..write('urlPath: $urlPath, ') + ..write('title: $title, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('contentHash: $contentHash, ') + ..write('observedAt: $observedAt, ') + ..write('observedCount: $observedCount') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + urlCanonical, + urlHost, + urlPath, + title, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + contentHash, + observedAt, + observedCount, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is HistoryData && + other.urlCanonical == this.urlCanonical && + other.urlHost == this.urlHost && + other.urlPath == this.urlPath && + other.title == this.title && + other.isProbablyReaderable == this.isProbablyReaderable && + other.extractedContentMarkdown == this.extractedContentMarkdown && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentMarkdown == this.fullContentMarkdown && + other.fullContentPlain == this.fullContentPlain && + other.contentHash == this.contentHash && + other.observedAt == this.observedAt && + other.observedCount == this.observedCount); +} + +class HistoryCompanion extends UpdateCompanion { + final Value urlCanonical; + final Value urlHost; + final Value urlPath; + final Value title; + final Value isProbablyReaderable; + final Value extractedContentMarkdown; + final Value extractedContentPlain; + final Value fullContentMarkdown; + final Value fullContentPlain; + final Value contentHash; + final Value observedAt; + final Value observedCount; + final Value rowid; + const HistoryCompanion({ + this.urlCanonical = const Value.absent(), + this.urlHost = const Value.absent(), + this.urlPath = const Value.absent(), + this.title = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.contentHash = const Value.absent(), + this.observedAt = const Value.absent(), + this.observedCount = const Value.absent(), + this.rowid = const Value.absent(), + }); + HistoryCompanion.insert({ + required String urlCanonical, + required String urlHost, + this.urlPath = const Value.absent(), + this.title = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.contentHash = const Value.absent(), + required int observedAt, + this.observedCount = const Value.absent(), + this.rowid = const Value.absent(), + }) : urlCanonical = Value(urlCanonical), + urlHost = Value(urlHost), + observedAt = Value(observedAt); + static Insertable custom({ + Expression? urlCanonical, + Expression? urlHost, + Expression? urlPath, + Expression? title, + Expression? isProbablyReaderable, + Expression? extractedContentMarkdown, + Expression? extractedContentPlain, + Expression? fullContentMarkdown, + Expression? fullContentPlain, + Expression? contentHash, + Expression? observedAt, + Expression? observedCount, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (urlCanonical != null) 'url_canonical': urlCanonical, + if (urlHost != null) 'url_host': urlHost, + if (urlPath != null) 'url_path': urlPath, + if (title != null) 'title': title, + if (isProbablyReaderable != null) + 'is_probably_readerable': isProbablyReaderable, + if (extractedContentMarkdown != null) + 'extracted_content_markdown': extractedContentMarkdown, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentMarkdown != null) + 'full_content_markdown': fullContentMarkdown, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (contentHash != null) 'content_hash': contentHash, + if (observedAt != null) 'observed_at': observedAt, + if (observedCount != null) 'observed_count': observedCount, + if (rowid != null) 'rowid': rowid, + }); + } + + HistoryCompanion copyWith({ + Value? urlCanonical, + Value? urlHost, + Value? urlPath, + Value? title, + Value? isProbablyReaderable, + Value? extractedContentMarkdown, + Value? extractedContentPlain, + Value? fullContentMarkdown, + Value? fullContentPlain, + Value? contentHash, + Value? observedAt, + Value? observedCount, + Value? rowid, + }) { + return HistoryCompanion( + urlCanonical: urlCanonical ?? this.urlCanonical, + urlHost: urlHost ?? this.urlHost, + urlPath: urlPath ?? this.urlPath, + title: title ?? this.title, + isProbablyReaderable: isProbablyReaderable ?? this.isProbablyReaderable, + extractedContentMarkdown: + extractedContentMarkdown ?? this.extractedContentMarkdown, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown ?? this.fullContentMarkdown, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + contentHash: contentHash ?? this.contentHash, + observedAt: observedAt ?? this.observedAt, + observedCount: observedCount ?? this.observedCount, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (urlCanonical.present) { + map['url_canonical'] = Variable(urlCanonical.value); + } + if (urlHost.present) { + map['url_host'] = Variable(urlHost.value); + } + if (urlPath.present) { + map['url_path'] = Variable(urlPath.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (isProbablyReaderable.present) { + map['is_probably_readerable'] = Variable(isProbablyReaderable.value); + } + if (extractedContentMarkdown.present) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown.value, + ); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentMarkdown.present) { + map['full_content_markdown'] = Variable( + fullContentMarkdown.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (contentHash.present) { + map['content_hash'] = Variable(contentHash.value); + } + if (observedAt.present) { + map['observed_at'] = Variable(observedAt.value); + } + if (observedCount.present) { + map['observed_count'] = Variable(observedCount.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('HistoryCompanion(') + ..write('urlCanonical: $urlCanonical, ') + ..write('urlHost: $urlHost, ') + ..write('urlPath: $urlPath, ') + ..write('title: $title, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('contentHash: $contentHash, ') + ..write('observedAt: $observedAt, ') + ..write('observedCount: $observedCount, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class HistoryFts extends Table + with + TableInfo, + VirtualTableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + HistoryFts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn urlHost = GeneratedColumn( + 'url_host', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn urlPath = GeneratedColumn( + 'url_path', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [ + title, + urlHost, + urlPath, + extractedContentPlain, + fullContentPlain, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'history_fts'; + @override + Set get $primaryKey => const {}; + @override + HistoryFtsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return HistoryFtsData( + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + urlHost: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url_host'], + ), + urlPath: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url_path'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + ); + } + + @override + HistoryFts createAlias(String alias) { + return HistoryFts(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; + @override + String get moduleAndArgs => + 'fts5(title, url_host, url_path, extracted_content_plain, full_content_plain, content=history, tokenize="trigram")'; +} + +class HistoryFtsData extends DataClass implements Insertable { + final String? title; + final String? urlHost; + final String? urlPath; + final String? extractedContentPlain; + final String? fullContentPlain; + const HistoryFtsData({ + this.title, + this.urlHost, + this.urlPath, + this.extractedContentPlain, + this.fullContentPlain, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || urlHost != null) { + map['url_host'] = Variable(urlHost); + } + if (!nullToAbsent || urlPath != null) { + map['url_path'] = Variable(urlPath); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + return map; + } + + factory HistoryFtsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return HistoryFtsData( + title: serializer.fromJson(json['title']), + urlHost: serializer.fromJson(json['urlHost']), + urlPath: serializer.fromJson(json['urlPath']), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'title': serializer.toJson(title), + 'urlHost': serializer.toJson(urlHost), + 'urlPath': serializer.toJson(urlPath), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentPlain': serializer.toJson(fullContentPlain), + }; + } + + HistoryFtsData copyWith({ + Value title = const Value.absent(), + Value urlHost = const Value.absent(), + Value urlPath = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentPlain = const Value.absent(), + }) => HistoryFtsData( + title: title.present ? title.value : this.title, + urlHost: urlHost.present ? urlHost.value : this.urlHost, + urlPath: urlPath.present ? urlPath.value : this.urlPath, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + ); + HistoryFtsData copyWithCompanion(HistoryFtsCompanion data) { + return HistoryFtsData( + title: data.title.present ? data.title.value : this.title, + urlHost: data.urlHost.present ? data.urlHost.value : this.urlHost, + urlPath: data.urlPath.present ? data.urlPath.value : this.urlPath, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + ); + } + + @override + String toString() { + return (StringBuffer('HistoryFtsData(') + ..write('title: $title, ') + ..write('urlHost: $urlHost, ') + ..write('urlPath: $urlPath, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + title, + urlHost, + urlPath, + extractedContentPlain, + fullContentPlain, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is HistoryFtsData && + other.title == this.title && + other.urlHost == this.urlHost && + other.urlPath == this.urlPath && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentPlain == this.fullContentPlain); +} + +class HistoryFtsCompanion extends UpdateCompanion { + final Value title; + final Value urlHost; + final Value urlPath; + final Value extractedContentPlain; + final Value fullContentPlain; + final Value rowid; + const HistoryFtsCompanion({ + this.title = const Value.absent(), + this.urlHost = const Value.absent(), + this.urlPath = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + HistoryFtsCompanion.insert({ + this.title = const Value.absent(), + this.urlHost = const Value.absent(), + this.urlPath = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? title, + Expression? urlHost, + Expression? urlPath, + Expression? extractedContentPlain, + Expression? fullContentPlain, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (title != null) 'title': title, + if (urlHost != null) 'url_host': urlHost, + if (urlPath != null) 'url_path': urlPath, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (rowid != null) 'rowid': rowid, + }); + } + + HistoryFtsCompanion copyWith({ + Value? title, + Value? urlHost, + Value? urlPath, + Value? extractedContentPlain, + Value? fullContentPlain, + Value? rowid, + }) { + return HistoryFtsCompanion( + title: title ?? this.title, + urlHost: urlHost ?? this.urlHost, + urlPath: urlPath ?? this.urlPath, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (title.present) { + map['title'] = Variable(title.value); + } + if (urlHost.present) { + map['url_host'] = Variable(urlHost.value); + } + if (urlPath.present) { + map['url_path'] = Variable(urlPath.value); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('HistoryFtsCompanion(') + ..write('title: $title, ') + ..write('urlHost: $urlHost, ') + ..write('urlPath: $urlPath, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class VisitContainer extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + VisitContainer(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL PRIMARY KEY AUTOINCREMENT', + ); + late final GeneratedColumn rawUrl = GeneratedColumn( + 'raw_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn urlCanonical = GeneratedColumn( + 'url_canonical', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn visitTime = GeneratedColumn( + 'visit_time', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn containerId = GeneratedColumn( + 'container_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES container(id)ON DELETE CASCADE', + ); + @override + List get $columns => [ + id, + rawUrl, + urlCanonical, + visitTime, + containerId, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'visit_container'; + @override + Set get $primaryKey => {id}; + @override + VisitContainerData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return VisitContainerData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + rawUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}raw_url'], + )!, + urlCanonical: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url_canonical'], + )!, + visitTime: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}visit_time'], + )!, + containerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}container_id'], + )!, + ); + } + + @override + VisitContainer createAlias(String alias) { + return VisitContainer(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class VisitContainerData extends DataClass + implements Insertable { + final int id; + final String rawUrl; + final String urlCanonical; + final int visitTime; + final String containerId; + const VisitContainerData({ + required this.id, + required this.rawUrl, + required this.urlCanonical, + required this.visitTime, + required this.containerId, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['raw_url'] = Variable(rawUrl); + map['url_canonical'] = Variable(urlCanonical); + map['visit_time'] = Variable(visitTime); + map['container_id'] = Variable(containerId); + return map; + } + + factory VisitContainerData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return VisitContainerData( + id: serializer.fromJson(json['id']), + rawUrl: serializer.fromJson(json['rawUrl']), + urlCanonical: serializer.fromJson(json['urlCanonical']), + visitTime: serializer.fromJson(json['visitTime']), + containerId: serializer.fromJson(json['containerId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'rawUrl': serializer.toJson(rawUrl), + 'urlCanonical': serializer.toJson(urlCanonical), + 'visitTime': serializer.toJson(visitTime), + 'containerId': serializer.toJson(containerId), + }; + } + + VisitContainerData copyWith({ + int? id, + String? rawUrl, + String? urlCanonical, + int? visitTime, + String? containerId, + }) => VisitContainerData( + id: id ?? this.id, + rawUrl: rawUrl ?? this.rawUrl, + urlCanonical: urlCanonical ?? this.urlCanonical, + visitTime: visitTime ?? this.visitTime, + containerId: containerId ?? this.containerId, + ); + VisitContainerData copyWithCompanion(VisitContainerCompanion data) { + return VisitContainerData( + id: data.id.present ? data.id.value : this.id, + rawUrl: data.rawUrl.present ? data.rawUrl.value : this.rawUrl, + urlCanonical: data.urlCanonical.present + ? data.urlCanonical.value + : this.urlCanonical, + visitTime: data.visitTime.present ? data.visitTime.value : this.visitTime, + containerId: data.containerId.present + ? data.containerId.value + : this.containerId, + ); + } + + @override + String toString() { + return (StringBuffer('VisitContainerData(') + ..write('id: $id, ') + ..write('rawUrl: $rawUrl, ') + ..write('urlCanonical: $urlCanonical, ') + ..write('visitTime: $visitTime, ') + ..write('containerId: $containerId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, rawUrl, urlCanonical, visitTime, containerId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is VisitContainerData && + other.id == this.id && + other.rawUrl == this.rawUrl && + other.urlCanonical == this.urlCanonical && + other.visitTime == this.visitTime && + other.containerId == this.containerId); +} + +class VisitContainerCompanion extends UpdateCompanion { + final Value id; + final Value rawUrl; + final Value urlCanonical; + final Value visitTime; + final Value containerId; + const VisitContainerCompanion({ + this.id = const Value.absent(), + this.rawUrl = const Value.absent(), + this.urlCanonical = const Value.absent(), + this.visitTime = const Value.absent(), + this.containerId = const Value.absent(), + }); + VisitContainerCompanion.insert({ + this.id = const Value.absent(), + required String rawUrl, + required String urlCanonical, + required int visitTime, + required String containerId, + }) : rawUrl = Value(rawUrl), + urlCanonical = Value(urlCanonical), + visitTime = Value(visitTime), + containerId = Value(containerId); + static Insertable custom({ + Expression? id, + Expression? rawUrl, + Expression? urlCanonical, + Expression? visitTime, + Expression? containerId, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (rawUrl != null) 'raw_url': rawUrl, + if (urlCanonical != null) 'url_canonical': urlCanonical, + if (visitTime != null) 'visit_time': visitTime, + if (containerId != null) 'container_id': containerId, + }); + } + + VisitContainerCompanion copyWith({ + Value? id, + Value? rawUrl, + Value? urlCanonical, + Value? visitTime, + Value? containerId, + }) { + return VisitContainerCompanion( + id: id ?? this.id, + rawUrl: rawUrl ?? this.rawUrl, + urlCanonical: urlCanonical ?? this.urlCanonical, + visitTime: visitTime ?? this.visitTime, + containerId: containerId ?? this.containerId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (rawUrl.present) { + map['raw_url'] = Variable(rawUrl.value); + } + if (urlCanonical.present) { + map['url_canonical'] = Variable(urlCanonical.value); + } + if (visitTime.present) { + map['visit_time'] = Variable(visitTime.value); + } + if (containerId.present) { + map['container_id'] = Variable(containerId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('VisitContainerCompanion(') + ..write('id: $id, ') + ..write('rawUrl: $rawUrl, ') + ..write('urlCanonical: $urlCanonical, ') + ..write('visitTime: $visitTime, ') + ..write('containerId: $containerId') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV15 extends GeneratedDatabase { + DatabaseAtV15(QueryExecutor e) : super(e); + late final Container container = Container(this); + late final Tab tab = Tab(this); + late final ClosedTabTombstone closedTabTombstone = ClosedTabTombstone(this); + late final Index idxTabParentContainer = Index( + 'idx_tab_parent_container', + 'CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)', + ); + late final CaptureTab captureTab = CaptureTab(this); + late final Index idxCaptureTabCaptureId = Index( + 'idx_capture_tab_capture_id', + 'CREATE INDEX idx_capture_tab_capture_id ON capture_tab (capture_id)', + ); + late final TabFts tabFts = TabFts(this); + late final Trigger tabMaintainParentChainOnDelete = Trigger( + 'CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END', + 'tab_maintain_parent_chain_on_delete', + ); + late final Trigger tabAfterInsert = Trigger( + 'CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_insert', + ); + late final Trigger tabAfterDelete = Trigger( + 'CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END', + 'tab_after_delete', + ); + late final Trigger tabAfterUpdate = Trigger( + 'CREATE TRIGGER tab_after_update AFTER UPDATE OF title, url, extracted_content_plain, full_content_plain ON tab WHEN OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_update', + ); + late final LocalIndexSetting localIndexSetting = LocalIndexSetting(this); + late final History history = History(this); + late final Index idxHistoryHost = Index( + 'idx_history_host', + 'CREATE INDEX idx_history_host ON history (url_host)', + ); + late final Index idxHistoryObserved = Index( + 'idx_history_observed', + 'CREATE INDEX idx_history_observed ON history (observed_at DESC)', + ); + late final HistoryFts historyFts = HistoryFts(this); + late final Trigger historyAfterInsert = Trigger( + 'CREATE TRIGGER history_after_insert AFTER INSERT ON history BEGIN INSERT INTO history_fts ("rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END', + 'history_after_insert', + ); + late final Trigger historyAfterDelete = Trigger( + 'CREATE TRIGGER history_after_delete AFTER DELETE ON history BEGIN INSERT INTO history_fts (history_fts, "rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);END', + 'history_after_delete', + ); + late final Trigger historyAfterUpdate = Trigger( + 'CREATE TRIGGER history_after_update AFTER UPDATE OF title, url_host, url_path, extracted_content_plain, full_content_plain ON history BEGIN INSERT INTO history_fts (history_fts, "rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);INSERT INTO history_fts ("rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END', + 'history_after_update', + ); + late final Trigger tabToHistoryOnInsert = Trigger( + 'CREATE TRIGGER tab_to_history_on_insert AFTER INSERT ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = NEW.container_id AND json_extract(container.metadata, \'\$.excludeFromIndex\') = 1) BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime(\'%s\', \'now\') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END', + 'tab_to_history_on_insert', + ); + late final Trigger tabToHistoryOnUpdate = Trigger( + 'CREATE TRIGGER tab_to_history_on_update AFTER UPDATE OF title, url, extracted_content_plain, extracted_content_markdown, full_content_plain, full_content_markdown, is_probably_readerable ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND(OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url)AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = NEW.container_id AND json_extract(container.metadata, \'\$.excludeFromIndex\') = 1) BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime(\'%s\', \'now\') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END', + 'tab_to_history_on_update', + ); + late final Trigger tabToHistoryOnContainerUpdate = Trigger( + 'CREATE TRIGGER tab_to_history_on_container_update AFTER UPDATE OF container_id ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 BEGIN DELETE FROM history WHERE url_canonical = url_canonical(CAST(NEW.url AS TEXT)) AND NOT EXISTS (SELECT 1 FROM tab AS candidate WHERE candidate.url IS NOT NULL AND url_indexable(CAST(candidate.url AS TEXT)) = 1 AND url_canonical(CAST(candidate.url AS TEXT)) = history.url_canonical AND(candidate.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = candidate.container_id AND json_extract(container.metadata, \'\$.excludeFromIndex\') = 1));INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) SELECT url_canonical(CAST(candidate.url AS TEXT)), url_host(CAST(candidate.url AS TEXT)), url_path(CAST(candidate.url AS TEXT)), candidate.title, candidate.is_probably_readerable, candidate.extracted_content_markdown, candidate.extracted_content_plain, candidate.full_content_markdown, candidate.full_content_plain, candidate.content_hash, strftime(\'%s\', \'now\') * 1000, 1 FROM tab AS candidate WHERE candidate.url IS NOT NULL AND url_indexable(CAST(candidate.url AS TEXT)) = 1 AND url_canonical(CAST(candidate.url AS TEXT)) = url_canonical(CAST(NEW.url AS TEXT)) AND(candidate.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = candidate.container_id AND json_extract(container.metadata, \'\$.excludeFromIndex\') = 1) ORDER BY candidate.timestamp DESC, candidate."rowid" DESC LIMIT 1 ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1;END', + 'tab_to_history_on_container_update', + ); + late final Trigger containerToHistoryOnMetadataUpdate = Trigger( + 'CREATE TRIGGER container_to_history_on_metadata_update AFTER UPDATE OF metadata ON container WHEN COALESCE(json_extract(OLD.metadata, \'\$.excludeFromIndex\') = 1, 0) != COALESCE(json_extract(NEW.metadata, \'\$.excludeFromIndex\') = 1, 0) AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 BEGIN DELETE FROM history WHERE url_canonical IN (SELECT DISTINCT url_canonical(CAST(affected.url AS TEXT)) FROM tab AS affected WHERE affected.container_id = NEW.id AND affected.url IS NOT NULL AND url_indexable(CAST(affected.url AS TEXT)) = 1) AND NOT EXISTS (SELECT 1 FROM tab AS candidate WHERE candidate.url IS NOT NULL AND url_indexable(CAST(candidate.url AS TEXT)) = 1 AND url_canonical(CAST(candidate.url AS TEXT)) = history.url_canonical AND(candidate.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = candidate.container_id AND json_extract(container.metadata, \'\$.excludeFromIndex\') = 1));INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) SELECT url_canonical(CAST(candidate.url AS TEXT)), url_host(CAST(candidate.url AS TEXT)), url_path(CAST(candidate.url AS TEXT)), candidate.title, candidate.is_probably_readerable, candidate.extracted_content_markdown, candidate.extracted_content_plain, candidate.full_content_markdown, candidate.full_content_plain, candidate.content_hash, strftime(\'%s\', \'now\') * 1000, 1 FROM tab AS candidate WHERE candidate.url IS NOT NULL AND url_indexable(CAST(candidate.url AS TEXT)) = 1 AND url_canonical(CAST(candidate.url AS TEXT)) IN (SELECT DISTINCT url_canonical(CAST(affected.url AS TEXT)) FROM tab AS affected WHERE affected.container_id = NEW.id AND affected.url IS NOT NULL AND url_indexable(CAST(affected.url AS TEXT)) = 1) AND(candidate.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = candidate.container_id AND json_extract(container.metadata, \'\$.excludeFromIndex\') = 1) AND NOT EXISTS (SELECT 1 FROM tab AS newer WHERE newer.url IS NOT NULL AND url_indexable(CAST(newer.url AS TEXT)) = 1 AND url_canonical(CAST(newer.url AS TEXT)) = url_canonical(CAST(candidate.url AS TEXT)) AND(newer.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = newer.container_id AND json_extract(container.metadata, \'\$.excludeFromIndex\') = 1) AND(newer.timestamp > candidate.timestamp OR(newer.timestamp = candidate.timestamp AND newer."rowid" > candidate."rowid"))) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1;END', + 'container_to_history_on_metadata_update', + ); + late final VisitContainer visitContainer = VisitContainer(this); + late final Index idxVcCanonical = Index( + 'idx_vc_canonical', + 'CREATE INDEX idx_vc_canonical ON visit_container (url_canonical, visit_time)', + ); + late final Index idxVcContainer = Index( + 'idx_vc_container', + 'CREATE INDEX idx_vc_container ON visit_container (container_id, visit_time DESC)', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + container, + tab, + closedTabTombstone, + idxTabParentContainer, + captureTab, + idxCaptureTabCaptureId, + tabFts, + tabMaintainParentChainOnDelete, + tabAfterInsert, + tabAfterDelete, + tabAfterUpdate, + localIndexSetting, + history, + idxHistoryHost, + idxHistoryObserved, + historyFts, + historyAfterInsert, + historyAfterDelete, + historyAfterUpdate, + tabToHistoryOnInsert, + tabToHistoryOnUpdate, + tabToHistoryOnContainerUpdate, + containerToHistoryOnMetadataUpdate, + visitContainer, + idxVcCanonical, + idxVcContainer, + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'container', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('capture_tab', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.insert, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.update, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'history', + limitUpdateKind: UpdateKind.insert, + ), + result: [TableUpdate('history_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'history', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('history_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'history', + limitUpdateKind: UpdateKind.update, + ), + result: [TableUpdate('history_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.insert, + ), + result: [TableUpdate('history', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.update, + ), + result: [TableUpdate('history', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.update, + ), + result: [ + TableUpdate('history', kind: UpdateKind.delete), + TableUpdate('history', kind: UpdateKind.insert), + ], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'container', + limitUpdateKind: UpdateKind.update, + ), + result: [ + TableUpdate('history', kind: UpdateKind.delete), + TableUpdate('history', kind: UpdateKind.insert), + ], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'container', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('visit_container', kind: UpdateKind.delete)], + ), + ]); + @override + int get schemaVersion => 15; +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt index 0b26d3da..a7e453ed 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt @@ -7,8 +7,10 @@ package eu.weblibre.flutter_mozilla_components import eu.weblibre.flutter_mozilla_components.api.GeckoBrowserApiImpl +import eu.weblibre.flutter_mozilla_components.api.GeckoEngineSettingsApiImpl import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureFeature import eu.weblibre.flutter_mozilla_components.pigeons.GeckoBrowserApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettingsApi import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware @@ -23,10 +25,25 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware { browserApi.attachBinding(flutterPluginBinding) GeckoBrowserApi.setUp(flutterPluginBinding.binaryMessenger, browserApi) SandboxCaptureFeature.wireFlutterEvents(flutterPluginBinding.binaryMessenger) + + // Register the engine-settings API at attach time (before GeckoBrowserService + // .initialize) so Dart can push the history-exclusion contextId set to native + // *before* the engine starts recording restored-tab visits, closing the + // startup window where an excluded container could leak to Places. + // setExcludedHistoryContextIds only writes GlobalComponents state and needs + // no initialized components; the remaining settings methods resolve + // components lazily and are not invoked until after initialize. The same + // instance is reused by GeckoBrowserApiImpl.initialize. + val engineSettingsApiImpl = GeckoEngineSettingsApiImpl( + flutterPluginBinding.applicationContext, + ) + GeckoEngineSettingsApi.setUp(flutterPluginBinding.binaryMessenger, engineSettingsApiImpl) + GlobalComponents.engineSettingsApi = engineSettingsApiImpl } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { SandboxCaptureFeature.detachFlutterEvents(binding.binaryMessenger) + GlobalComponents.historyEvents = null } override fun onAttachedToActivity(binding: ActivityPluginBinding) { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt index 46981857..6d36f3ec 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt @@ -8,6 +8,8 @@ package eu.weblibre.flutter_mozilla_components import android.content.Context import android.content.Intent +import androidx.core.content.edit +import androidx.preference.PreferenceManager import eu.weblibre.flutter_mozilla_components.pigeons.AddonCollection import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMode @@ -15,6 +17,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.ContentBlocking import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettings import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents @@ -59,7 +62,9 @@ private const val HISTORY_METADATA_MAX_AGE_IN_MS = 14L * 24 * 60 * 60 * 1000 // private const val DEFAULT_QUERY_PARAMETER_STRIPPING_STRIP_LIST = "__hsfp __hssc __hstc __s _bhlid _branch_match_id _branch_referrer _gl _hsenc _kx _openstat at_recipient_id at_recipient_list bbeml bsft_clkid bsft_uid dclid et_rid fb_action_ids fb_comment_id fbclid gbraid gclid guce_referrer guce_referrer_sig hsCtaTracking igshid irclickid mc_eid mkt_tok ml_subscriber ml_subscriber_hash msclkid mtm_cid oft_c oft_ck oft_d oft_id oft_ids oft_k oft_lk oft_sk oly_anon_id oly_enc_id pk_cid rb_clickid s_cid sc_customer sc_eh sc_uid sms_click sms_source sms_uph srsltid ss_email_id syclid ttclid twclid unicorn_click_id vero_conv vero_id vgo_ee wbraid wickedid yclid ymclid ysclid" private const val UBLOCK_FILTER_LISTS_PREF = "browser.weblibre.uBO.filterLists" - +private const val EXCLUDED_HISTORY_CONTEXT_IDS_PREF = + "browser.weblibre.excludedHistoryContextIds" + object GlobalComponents { private var _components: Components? = null private var currentMode: ComponentsMode? = null @@ -105,6 +110,36 @@ object GlobalComponents { // GestureRecognizer on the UI thread. var gestureEvents: GeckoGestureEvents? = null + // Native -> Dart history visit notifications, consumed by Core's history + // delegate to forward the visit's WebLibre container. Null on the headless + // path (no Flutter engine); the delegate still hard-excludes persisted + // container contextIds but skips Dart relation emits. + var historyEvents: GeckoHistoryEvents? = null + + // Gecko contextIds of containers with hard exclude-from-history enabled. + // Pushed from Dart; read by WebLibreHistoryDelegate to skip the Places + // write for visits resolved to one of these containers. + @Volatile + var excludedHistoryContextIds: Set = emptySet() + + fun setExcludedHistoryContextIds(context: Context?, contextIds: Collection) { + val contextIdSet = contextIds.toSet() + excludedHistoryContextIds = contextIdSet + + if (context != null) { + PreferenceManager.getDefaultSharedPreferences(context).edit { + putStringSet(EXCLUDED_HISTORY_CONTEXT_IDS_PREF, contextIdSet) + } + } + } + + fun loadExcludedHistoryContextIds(context: Context) { + excludedHistoryContextIds = PreferenceManager.getDefaultSharedPreferences(context) + .getStringSet(EXCLUDED_HISTORY_CONTEXT_IDS_PREF, emptySet()) + .orEmpty() + .toSet() + } + @Volatile var gestureConfig: GestureConfig? = null @@ -461,6 +496,9 @@ object GlobalComponents { val profileContext = ProfileContext(baseContext.applicationContext, profileFolder) val messenger = NoopBinaryMessenger() + loadExcludedHistoryContextIds(baseContext.applicationContext) + historyEvents = null + val selectionActionEvents = GeckoSelectionActionEvents(messenger) val selectionActionDelegate = DefaultSelectionActionDelegate(selectionActionEvents) { actions -> val processTextAction = "android.intent.action.PROCESS_TEXT" @@ -493,7 +531,7 @@ object GlobalComponents { mode = ComponentsMode.EXTERNAL, ) - engineSettingsApi = GeckoEngineSettingsApiImpl() + engineSettingsApi = GeckoEngineSettingsApiImpl(baseContext.applicationContext) return true } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt index a983b15e..5278ca13 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt @@ -49,6 +49,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoMlApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPrefApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPwaApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionController +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSessionApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents @@ -265,6 +266,12 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { val syncStateEvents = GeckoSyncStateEvents(_flutterPluginBinding.binaryMessenger) + // Set before GlobalComponents.setUp (which lazily builds the engine and + // its history delegate) so Core can wrap the delegate to forward the + // visit's WebLibre container to Dart. + GlobalComponents.historyEvents = + GeckoHistoryEvents(_flutterPluginBinding.binaryMessenger) + GlobalComponents.setUp( profileApplicationContext, _flutterEvents, @@ -281,12 +288,20 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { syncTokenServerOverride, ) - val engineSettingsApiImpl = GeckoEngineSettingsApiImpl() - GeckoEngineSettingsApi.setUp( - _flutterPluginBinding.binaryMessenger, - engineSettingsApiImpl - ) - GlobalComponents.engineSettingsApi = engineSettingsApiImpl + // GeckoEngineSettingsApi was already registered at plugin-attach time + // (FlutterMozillaComponentsPlugin.onAttachedToEngine) so the startup + // history-exclusion push lands before the engine starts. Reuse that + // instance; only register a fresh one if attach somehow did not run. + if (GlobalComponents.engineSettingsApi == null) { + val engineSettingsApiImpl = GeckoEngineSettingsApiImpl( + _flutterPluginBinding.applicationContext, + ) + GeckoEngineSettingsApi.setUp( + _flutterPluginBinding.binaryMessenger, + engineSettingsApiImpl + ) + GlobalComponents.engineSettingsApi = engineSettingsApiImpl + } GeckoAddonsApi.setUp( _flutterPluginBinding.binaryMessenger, GeckoAddonsApiImpl(profileApplicationContext) diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt index 8136546f..216397de 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt @@ -6,6 +6,7 @@ package eu.weblibre.flutter_mozilla_components.api +import android.content.Context import androidx.core.content.edit import androidx.preference.PreferenceManager import eu.weblibre.flutter_mozilla_components.ColorSchemePreference @@ -74,7 +75,9 @@ internal fun TrackingProtectionPolicy.withBounceTrackingProtectionMode( /** * Implementation of GeckoEngineSettingsApi that manages engine-specific settings */ -class GeckoEngineSettingsApiImpl : GeckoEngineSettingsApi { +class GeckoEngineSettingsApiImpl( + private val applicationContext: Context? = null, +) : GeckoEngineSettingsApi { companion object { private const val TAG = "GeckoEngineSettingsApi" } @@ -515,4 +518,8 @@ class GeckoEngineSettingsApiImpl : GeckoEngineSettingsApi { activeReaderSessions, ) } + + override fun setExcludedHistoryContextIds(contextIds: List) { + GlobalComponents.setExcludedHistoryContextIds(applicationContext, contextIds) + } } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt index abb164f5..dde1e684 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt @@ -18,6 +18,7 @@ import eu.weblibre.flutter_mozilla_components.services.DownloadService import eu.weblibre.flutter_mozilla_components.EngineProvider import eu.weblibre.flutter_mozilla_components.EngineProvider.getOrCreateRuntime import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.history.WebLibreHistoryDelegate import eu.weblibre.flutter_mozilla_components.PermissionStorage import eu.weblibre.flutter_mozilla_components.services.MediaSessionService import eu.weblibre.flutter_mozilla_components.activities.NotificationActivity @@ -26,6 +27,7 @@ import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey import eu.weblibre.flutter_mozilla_components.middleware.FlutterEventMiddleware import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataMiddleware import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataService +import eu.weblibre.flutter_mozilla_components.middleware.HistoryVisitCorrelationMiddleware import eu.weblibre.flutter_mozilla_components.middleware.SandboxCaptureMiddleware import eu.weblibre.flutter_mozilla_components.middleware.SaveToPDFMiddleware import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents @@ -99,9 +101,14 @@ class Core( val engineSettings by lazy { DefaultSettings( - //historyTrackingDelegate = HistoryDelegate(lazyHistoryStorage) requestInterceptor = requestInterceptor, - historyTrackingDelegate = HistoryDelegate(lazyHistoryStorage), + // Wrap the Places-feeding delegate so WebLibre can hard-exclude + // container visits even on the headless external path. When Flutter + // is available, the wrapper also emits visit→container relations. + historyTrackingDelegate = WebLibreHistoryDelegate( + HistoryDelegate(lazyHistoryStorage), + GlobalComponents.historyEvents, + ), testingModeEnabled = false, remoteDebuggingEnabled = false, automaticFontSizeAdjustment = true, @@ -225,6 +232,9 @@ class Core( // sandbox new-tab URLs before Gecko issues a request. SandboxCaptureMiddleware, HistoryMetadataMiddleware(historyMetadataService), + // Correlates url -> contextId so WebLibreHistoryDelegate can + // resolve a visit's container at record time. + HistoryVisitCorrelationMiddleware(), FlutterEventMiddleware(flutterEvents), DownloadMiddleware( applicationContext = context, diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/history/HistoryVisitCorrelationCache.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/history/HistoryVisitCorrelationCache.kt new file mode 100644 index 00000000..635009fa --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/history/HistoryVisitCorrelationCache.kt @@ -0,0 +1,80 @@ +package eu.weblibre.flutter_mozilla_components.history + +/** + * Short-lived `url -> contextId` correlation captured at navigation time. + * + * Android Components strips the session before it reaches + * [mozilla.components.concept.engine.history.HistoryTrackingDelegate.onVisited], + * so the delegate only sees the visited URL — not which tab (and therefore which + * Gecko contextual identity / WebLibre container) produced it. The + * [eu.weblibre.flutter_mozilla_components.middleware.HistoryVisitCorrelationMiddleware] + * records the navigating tab's contextId here on every `UpdateUrlAction`; the + * delegate reads it back when the matching visit is recorded moments later. + * + * This is the primary signal: the delegate trusts a live correlation before + * falling back to the selected tab (see + * [eu.weblibre.flutter_mozilla_components.history.WebLibreHistoryDelegate]). The + * TTL is deliberately short — a record and its visit are milliseconds apart, so + * anything older is an orphan (e.g. recorded on a reload that fired its + * `onVisited` before the record) and must expire quickly, or it can leak onto a + * later same-URL visit in a different container. + */ +object HistoryVisitCorrelationCache { + private const val TTL_MS = 3_000L + private const val MAX_ENTRIES = 64 + + private data class Entry(val contextId: String?, val timestamp: Long) + + // Insertion-ordered so we can drop the oldest entry when over capacity. + private val entries = LinkedHashMap() + + /** Record the [contextId] (nullable = uncontained tab) that navigated to [url]. */ + @Synchronized + fun record(url: String, contextId: String?) { + val now = System.currentTimeMillis() + evictExpired(now) + // Re-insert so the most recent navigation to a URL wins and stays newest. + entries.remove(url) + entries[url] = Entry(contextId, now) + while (entries.size > MAX_ENTRIES) { + val oldest = entries.keys.firstOrNull() ?: break + entries.remove(oldest) + } + } + + /** A live correlation for a URL: present in the cache, its [contextId] + * possibly null (the producing tab was uncontained). Distinct from a cache + * miss (`resolve` returns null), so the caller can tell a known-uncontained + * navigation apart from an unknown one. */ + data class Resolution(val contextId: String?) + + /** + * The correlation most recently recorded for [url] within the TTL, or null + * if there is no live entry. A non-null [Resolution] with a null contextId + * means the producing tab was uncontained — the caller must treat that as an + * authoritative "uncontained" answer, NOT as a miss, so it does not fall + * back to a less specific signal (e.g. a merely-loading foreground tab). + * + * Consume-on-read: the entry is removed so a stale correlation can NEVER be + * reused for a later, unrelated visit of the same URL — e.g. a page first + * visited in a container and later reopened uncontained (or in a different + * container) must not inherit the earlier container. A repeat navigation + * re-records it via the middleware. + */ + @Synchronized + fun resolve(url: String): Resolution? { + val now = System.currentTimeMillis() + evictExpired(now) + val entry = entries.remove(url) ?: return null + return Resolution(entry.contextId) + } + + private fun evictExpired(now: Long) { + val iterator = entries.entries.iterator() + while (iterator.hasNext()) { + if (now - iterator.next().value.timestamp > TTL_MS) { + iterator.remove() + } + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/history/WebLibreHistoryDelegate.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/history/WebLibreHistoryDelegate.kt new file mode 100644 index 00000000..6d189b77 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/history/WebLibreHistoryDelegate.kt @@ -0,0 +1,150 @@ +package eu.weblibre.flutter_mozilla_components.history + +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import mozilla.components.browser.state.selector.selectedTab +import mozilla.components.concept.engine.history.HistoryTrackingDelegate +import mozilla.components.concept.storage.PageVisit + +/** + * Wraps the Android Components [HistoryTrackingDelegate] (which feeds Mozilla + * Places / FxA sync) and, on each recorded visit, additionally notifies Dart of + * the WebLibre container that produced it. + * + * Mozilla Places stays the source of truth for the visit itself (url, title, + * visit type, visit time); WebLibre only needs the one thing Places can't store: + * which container the visit belonged to. Places strips the session before + * [onVisited], so the container is recovered from the `url -> contextId` + * correlation cache populated by + * [eu.weblibre.flutter_mozilla_components.middleware.HistoryVisitCorrelationMiddleware] + * at navigation time; Dart maps the contextId to a WebLibre container and writes + * the visit→container relation row. + * + * Purely additive for Places/sync: every call delegates to [wrapped]. The one + * exception is hard exclude-from-history — for a container opted out of history + * recording, the Places write is skipped too so the visit never lands anywhere. + */ +class WebLibreHistoryDelegate( + private val wrapped: HistoryTrackingDelegate, + private val events: GeckoHistoryEvents?, +) : HistoryTrackingDelegate by wrapped { + + override suspend fun onVisited(uri: String, visit: PageVisit) { + // Resolve which container produced this visit once, and reuse it for + // both the hard-exclude decision and the Dart relation emit. + val resolution = resolveContextId(uri) + + // Hard exclude-from-history: for a visit whose container has + // exclude-from-history enabled, skip BOTH the Places write and the Dart + // relation emit — the visit must not land in either store. + if (isHistoryExcluded(uri, resolution)) { + return + } + + wrapped.onVisited(uri, visit) + + // Timestamp near the Places record time; Dart joins to the actual Places + // visit by (url, nearest visit_time), tolerating the small skew. Best + // effort for tagging: even the non-authoritative guess is emitted, since + // a wrong tag is recoverable (unlike the exclude decision above). + val visitTime = System.currentTimeMillis() + + // Flutter's binary messenger must be used on the platform (main) thread. + if (events != null) { + withContext(Dispatchers.Main) { + events.onVisitRecorded(uri, visitTime, resolution.contextId) {} + } + } + } + + /** + * Decide whether this visit belongs to a hard exclude-from-history + * ("incognito") container and must therefore land nowhere. + * + * Fails **closed**: unlike tagging (where a missing tag is recoverable), + * leaking an excluded container's visit to Places is not, so any ambiguity + * that could involve an excluded container is resolved as "excluded". + * + * - When [resolution] is authoritative (steps 1–2 pinned the producing tab), + * trust it: excluded iff that tab's contextId is in the excluded set. + * - Otherwise (the last-resort step-3 guess or a full miss), scan open tabs: + * if any non-private tab is currently on this exact URL in an excluded + * container, the visit may have originated there — drop it. This closes the + * window where a background-tab visit's correlation was evicted/expired. + */ + private fun isHistoryExcluded(uri: String, resolution: ContextResolution): Boolean { + val excluded = GlobalComponents.excludedHistoryContextIds + if (excluded.isEmpty()) { + return false + } + + if (resolution.authoritative) { + return resolution.contextId != null && resolution.contextId in excluded + } + + val tabs = GlobalComponents.components?.core?.store?.state?.tabs ?: return false + return tabs.any { tab -> + !tab.content.private && + tab.content.url == uri && + tab.contextId != null && + tab.contextId in excluded + } + } + + /** + * The producing Gecko [contextId] for a visit, plus whether it was pinned + * [authoritative]ly (a definite producer) or is only a best-effort guess. + */ + private data class ContextResolution( + val contextId: String?, + val authoritative: Boolean, + ) + + /** + * Resolve the visited [uri] to the Gecko contextId that produced it. + * + * Resolution order, most specific first: + * + * 1. The per-navigation correlation recorded by + * [eu.weblibre.flutter_mozilla_components.middleware.HistoryVisitCorrelationMiddleware] + * for the tab that actually navigated to [uri]. This correctly attributes + * a **background-tab** visit and — crucially — stops a loading foreground + * tab from stealing another tab's same-URL visit. A live + * [HistoryVisitCorrelationCache.Resolution] (even with a null contextId, + * i.e. known-uncontained) is authoritative. + * 2. On a cache miss, the selected tab only when it is **actively loading + * exactly [uri]** (`loading && url == uri`). With no recorded competing + * navigation, it is the best live producer signal. Authoritative. + * 3. Otherwise, the loading selected tab as a last resort: redirect chains + * can fire `onVisited(uri)` before the selected tab's url settles on [uri] + * and before the middleware records it. Marked **not** authoritative — it + * may mis-attribute a background-tab visit, so the exclude decision does + * not trust it and re-checks via [isHistoryExcluded]. + * + * A full miss returns a non-authoritative uncontained (null) result. + */ + private fun resolveContextId(uri: String): ContextResolution { + val selected = GlobalComponents.components?.core?.store?.state?.selectedTab + + val correlation = HistoryVisitCorrelationCache.resolve(uri) + if (correlation != null) { + return ContextResolution(correlation.contextId, authoritative = true) + } + + if (selected != null && !selected.content.private && + selected.content.loading && selected.content.url == uri + ) { + return ContextResolution(selected.contextId, authoritative = true) + } + + if (selected != null && !selected.content.private && + selected.content.loading + ) { + return ContextResolution(selected.contextId, authoritative = false) + } + + return ContextResolution(null, authoritative = false) + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/HistoryMetadataMiddleware.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/HistoryMetadataMiddleware.kt index 645639a2..6a23f0ff 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/HistoryMetadataMiddleware.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/HistoryMetadataMiddleware.kt @@ -6,6 +6,7 @@ package eu.weblibre.flutter_mozilla_components.middleware +import eu.weblibre.flutter_mozilla_components.GlobalComponents import mozilla.components.browser.state.action.BrowserAction import mozilla.components.browser.state.action.ContentAction import mozilla.components.browser.state.action.HistoryMetadataAction @@ -102,13 +103,30 @@ class HistoryMetadataMiddleware( store: Store, tab: TabSessionState, ) { + // Hard exclude-from-history: a tab in an excluded ("incognito") container + // must not persist anywhere in Places. WebLibreHistoryDelegate already + // skips the visit write; metadata is a separate persistent Places path + // (highlights / suggestions), so it must be gated on the same set. + if (isHistoryExcluded(tab)) { + return + } + val key = historyMetadataService.createMetadata(tab) store.dispatch(HistoryMetadataAction.SetHistoryMetadataKeyAction(tab.id, key)) } private fun updateHistoryMetadata(tab: TabSessionState) { + if (isHistoryExcluded(tab)) { + return + } + tab.historyMetadata?.let { historyMetadataService.updateMetadata(it, tab) } } + + private fun isHistoryExcluded(tab: TabSessionState): Boolean { + val contextId = tab.contextId ?: return false + return contextId in GlobalComponents.excludedHistoryContextIds + } } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/HistoryVisitCorrelationMiddleware.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/HistoryVisitCorrelationMiddleware.kt new file mode 100644 index 00000000..3436c77f --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/HistoryVisitCorrelationMiddleware.kt @@ -0,0 +1,48 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.middleware + +import eu.weblibre.flutter_mozilla_components.history.HistoryVisitCorrelationCache +import mozilla.components.browser.state.action.BrowserAction +import mozilla.components.browser.state.action.ContentAction +import mozilla.components.browser.state.selector.findCustomTab +import mozilla.components.browser.state.selector.findNormalTab +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.Store + +/** + * Records a short-lived `url -> contextId` correlation whenever a normal tab or + * custom tab/PWA URL changes, so + * [eu.weblibre.flutter_mozilla_components.history.WebLibreHistoryDelegate] can + * resolve the tab's Gecko contextual identity (and hence its WebLibre container) + * at visit time — the delegate itself only receives the visited URL. + * + * See [HistoryVisitCorrelationCache]. + */ +class HistoryVisitCorrelationMiddleware : + Middleware { + override fun invoke( + store: Store, + next: (BrowserAction) -> Unit, + action: BrowserAction, + ) { + next(action) + + if (action is ContentAction.UpdateUrlAction) { + val normalTab = store.state.findNormalTab(action.sessionId) + if (normalTab != null) { + HistoryVisitCorrelationCache.record(action.url, normalTab.contextId) + return + } + + store.state.findCustomTab(action.sessionId)?.let { customTab -> + HistoryVisitCorrelationCache.record(action.url, customTab.contextId) + } + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt index d36db067..b9014e39 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt @@ -7305,6 +7305,13 @@ interface GeckoEngineSettingsApi { * cold-started reader view resolves the right value before Flutter runs. */ fun setReaderViewPureBlack(enabled: Boolean) + /** + * The set of Gecko contextual-identity ids ("container" contextIds) whose + * browsing history must NOT be written to Mozilla Places (hard + * exclude-from-history / "incognito container"). WebLibreHistoryDelegate + * skips the Places write for a visit resolved to one of these containers. + */ + fun setExcludedHistoryContextIds(contextIds: List) companion object { /** The codec used by GeckoEngineSettingsApi. */ @@ -7490,6 +7497,24 @@ interface GeckoEngineSettingsApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.setExcludedHistoryContextIds$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val contextIdsArg = args[0] as List + val wrapped: List = try { + api.setExcludedHistoryContextIds(contextIdsArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } } @@ -8955,7 +8980,7 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val } } else { callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) - } + } } } fun onEngineReadyStateChange(sequenceArg: Long, stateArg: Boolean, callback: (Result) -> Unit) @@ -10314,6 +10339,46 @@ interface GeckoDeleteBrowsingDataController { } } } +/** + * Native -> Dart history visit notifications. Fired from WebLibreHistoryDelegate + * on each recorded Mozilla Places visit so WebLibre can persist the one thing + * Places can't store: which container the visit belonged to. The visit itself + * (title, visit type, exact time) stays owned by Places. + * + * Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. + */ +class GeckoHistoryEvents(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by GeckoHistoryEvents. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + } + /** + * [contextId] is the Gecko contextual identity of the tab that produced the + * visit, resolved via the URL→contextId correlation cache (null when it + * couldn't be resolved / the tab was uncontained). Dart maps it to a + * WebLibre container and writes the visit→container relation, keyed on + * ([url], [visitTime]) to join back to the Places visit. + */ + fun onVisitRecorded(urlArg: String, visitTimeArg: Long, contextIdArg: String?, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryEvents.onVisitRecorded$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(urlArg, visitTimeArg, contextIdArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } +} /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface GeckoHistoryApi { fun getDetailedVisits(startMillis: Long, endMillis: Long, excludeTypes: List, callback: (Result>) -> Unit) diff --git a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart index bdc7f500..8a575df6 100644 --- a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart +++ b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart @@ -72,6 +72,7 @@ export 'src/pigeons/gecko.g.dart' GeckoDeleteBrowsingDataController, GeckoEngineSettings, GeckoFetchResponse, + GeckoHistoryEvents, GeckoPref, GeckoProxySettings, GeckoPublicSuffixListApi, diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_engine_settings.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_engine_settings.dart index 3ecd4407..0690038c 100644 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_engine_settings.dart +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_engine_settings.dart @@ -236,4 +236,8 @@ class GeckoEngineSettingsService { Future setReaderViewPureBlack(bool enabled) { return _api.setReaderViewPureBlack(enabled); } + + Future setExcludedHistoryContextIds(List contextIds) { + return _api.setExcludedHistoryContextIds(contextIds); + } } diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_history.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_history.dart index 528603b6..f8ed5a36 100644 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_history.dart +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_history.dart @@ -46,11 +46,14 @@ class GeckoHistoryService { case VisitType.redirectTemporary: case VisitType.framedLink: case VisitType.reload: + // A bookmark-type visit is an ordinary Places visit (the user navigated + // via a bookmark); deleting it by (url, time) removes only that visit + // record and leaves the bookmark itself intact — same path as any page + // visit. + case VisitType.bookmark: return _api.deleteVisit(info.url, info.visitTime); case VisitType.download: return _api.deleteDownload(info.contentId!); - case VisitType.bookmark: - throw UnimplementedError('VisitType.bookmark deletion not implemented'); } } diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart index 96ce6ced..33d413ca 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -7421,6 +7421,28 @@ class GeckoEngineSettingsApi { ) ; } + + /// The set of Gecko contextual-identity ids ("container" contextIds) whose + /// browsing history must NOT be written to Mozilla Places (hard + /// exclude-from-history / "incognito container"). WebLibreHistoryDelegate + /// skips the Places write for a visit resolved to one of these containers. + Future setExcludedHistoryContextIds(List contextIds) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.setExcludedHistoryContextIds$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([contextIds]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } } class GeckoSessionApi { @@ -10310,6 +10332,48 @@ class GeckoDeleteBrowsingDataController { } } +/// Native -> Dart history visit notifications. Fired from WebLibreHistoryDelegate +/// on each recorded Mozilla Places visit so WebLibre can persist the one thing +/// Places can't store: which container the visit belonged to. The visit itself +/// (title, visit type, exact time) stays owned by Places. +abstract class GeckoHistoryEvents { + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + /// [contextId] is the Gecko contextual identity of the tab that produced the + /// visit, resolved via the URL→contextId correlation cache (null when it + /// couldn't be resolved / the tab was uncontained). Dart maps it to a + /// WebLibre container and writes the visit→container relation, keyed on + /// ([url], [visitTime]) to join back to the Places visit. + void onVisitRecorded(String url, int visitTime, String? contextId); + + static void setUp(GeckoHistoryEvents? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { + messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryEvents.onVisitRecorded$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + final List args = message! as List; + final String arg_url = args[0]! as String; + final int arg_visitTime = args[1]! as int; + final String? arg_contextId = args[2] as String?; + try { + api.onVisitRecorded(arg_url, arg_visitTime, arg_contextId); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + } +} + class GeckoHistoryApi { /// Constructor for [GeckoHistoryApi]. The [binaryMessenger] named argument is /// available for dependency injection. If it is left null, the default diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index 2425c0c8..e215d4da 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -1586,6 +1586,12 @@ abstract class GeckoEngineSettingsApi { /// Mozilla's reader view extension. Persisted in SharedPreferences so a /// cold-started reader view resolves the right value before Flutter runs. void setReaderViewPureBlack(bool enabled); + + /// The set of Gecko contextual-identity ids ("container" contextIds) whose + /// browsing history must NOT be written to Mozilla Places (hard + /// exclude-from-history / "incognito container"). WebLibreHistoryDelegate + /// skips the Places write for a visit resolved to one of these containers. + void setExcludedHistoryContextIds(List contextIds); } @HostApi() @@ -2230,6 +2236,20 @@ enum ClearDataType { onlyCaches, } +/// Native -> Dart history visit notifications. Fired from WebLibreHistoryDelegate +/// on each recorded Mozilla Places visit so WebLibre can persist the one thing +/// Places can't store: which container the visit belonged to. The visit itself +/// (title, visit type, exact time) stays owned by Places. +@FlutterApi() +abstract class GeckoHistoryEvents { + /// [contextId] is the Gecko contextual identity of the tab that produced the + /// visit, resolved via the URL→contextId correlation cache (null when it + /// couldn't be resolved / the tab was uncontained). Dart maps it to a + /// WebLibre container and writes the visit→container relation, keyed on + /// ([url], [visitTime]) to join back to the Places visit. + void onVisitRecorded(String url, int visitTime, String? contextId); +} + @HostApi() abstract class GeckoHistoryApi { @async