diff --git a/addon/src/_locales/en/messages.json b/addon/src/_locales/en/messages.json index c0f8baf2..347da10b 100644 --- a/addon/src/_locales/en/messages.json +++ b/addon/src/_locales/en/messages.json @@ -454,8 +454,14 @@ "description": "Exclude tabs with sound" }, "notPossibleSwitchGroupBecauseSomeTabShareMicrophoneOrCamera": { - "message": "It is not possible to change the group because one of the opened tabs share microphone or camera", - "description": "It is not possible to change the group because one of the opened tabs share microphone or camera" + "message": "These tabs are sharing microphone, camera or screen and will keep doing so: \"$titles$\". Click here to switch anyway.", + "description": "Warning shown when switching a group would hide tabs that share microphone, camera or screen. Lists the tabs and lets the user switch anyway by clicking.", + "placeholders": { + "titles": { + "content": "$1", + "example": "some titles" + } + } }, "foundHiddenUnSyncTabsDescription": { "message": "Found hidden unsynchronized tabs. Click on tab to show it into current group/window or:", @@ -759,6 +765,34 @@ "message": "Discard tab", "description": "Discard tab" }, + "pinTabInGroupTitle": { + "message": "Pin to group", + "description": "Pin this tab within its group (pinned only while the group is active)" + }, + "pinTabInCurrentGroupTitle": { + "message": "Pin to current group", + "description": "Pin a tab that is not yet in a group into the window's active group (pinned only while that group is active)" + }, + "unpinTabInGroupTitle": { + "message": "Unpin from group", + "description": "Remove the group-scoped pin from this tab" + }, + "pinnedGroupTitle": { + "message": "Pinned", + "description": "Default title of the pinned group at first creation" + }, + "unpinToCurrentGroupTitle": { + "message": "Unpin into current group", + "description": "Move a pinned-group tab into the window's current group as a normal tab" + }, + "showPinnedGroupTitle": { + "message": "Show pinned tabs here", + "description": "Show the pinned group's tabs in the current window" + }, + "hidePinnedGroupTitle": { + "message": "Hide pinned tabs", + "description": "Hide the pinned group's tabs" + }, "folderNameTitle": { "message": "Folder name", "description": "Folder name title" @@ -1381,21 +1415,89 @@ "message": "Start synchronization", "description": "Start synchronization" }, - "syncStartTrustLocal": { - "message": "Start synchronization and trust local data", - "description": "Start synchronization and trust local data" + "cloudSettingsTitle": { + "message": "Cloud", + "description": "Title of the section grouping the cloud access, sync and backup settings blocks" + }, + "githubGistAccessTitle": { + "message": "GitHub Gist access", + "description": "Title of the GitHub Gist access settings block (token, file, storage location)" + }, + "githubGistAccessLocation": { + "message": "Store cloud settings in", + "description": "Label for the selector choosing where the roaming sync option bag is stored (Firefox Sync or local profile)" + }, + "githubGistNameTitle": { + "message": "Gist name", + "description": "Label for the gist name (the gist description) used to find or create the cloud gist" + }, + "githubGistNameHelp": { + "message": "Names the gist used for syncing. The same name on another device connects to the same gist.", + "description": "Help text under the gist name field in the GitHub Gist access block" + }, + "githubGistNameChangeWarning": { + "message": "Changing the gist name makes __MSG_extensionName__ forget the current gist: it stays on GitHub but is no longer synced. Synchronization restarts from this device's current state.", + "description": "Warning shown when the user changes the gist name to a different value" }, - "syncStartTrustCloud": { - "message": "Start synchronization and trust cloud data", - "description": "Start synchronization and trust cloud data" + "githubGistBackupFileNameTitle": { + "message": "Backup file name", + "description": "Label for the cloud backup file name field" }, - "syncDataInCloudCanBeDifferent": { - "message": "The data in the cloud can be very different from the current state.", - "description": "The data in the cloud can be very different from the current state." + "githubGistBackupFileNameChangeWarning": { + "message": "Changing the backup file name makes __MSG_extensionName__ forget the previous backups: they stay on GitHub but are no longer listed or restored.", + "description": "Warning shown when the user changes the cloud backup file name" + }, + "githubGistSyncTitle": { + "message": "Cloud sync", + "description": "Title of the cloud sync settings block" + }, + "githubGistBackupTitle": { + "message": "Cloud backup", + "description": "Title of the cloud backup settings block" + }, + "createBackupButton": { + "message": "Create backup", + "description": "Button that pushes the current full state to the cloud as a new gist revision" + }, + "backupChangesLabel": { + "message": "changes", + "description": "Label prefixing the change count of a cloud backup revision" + }, + "resetSyncStateButton": { + "message": "Reset sync state", + "description": "Reset this device's local sync state (recovery action)" + }, + "resetSyncStateConfirm": { + "message": "This clears the local synchronization state for this device only (baseline and change log). The data in the cloud is NOT touched. The next synchronization will behave like a first sync: nothing is removed and your current tabs are re-uploaded.", + "description": "Confirmation text for the reset sync state action" }, "syncEnableTitle": { - "message": "Enable auto backup to the cloud", - "description": "Enable auto backup to the cloud" + "message": "Enable sync to the cloud", + "description": "Enable sync to the cloud" + }, + "autoSyncEnableTitle": { + "message": "Enable auto sync to the cloud", + "description": "Enable auto sync to the cloud" + }, + "cloudSyncEveryTitle": { + "message": "Cloud sync every", + "description": "Cloud sync every" + }, + "syncBackupBeforeApply": { + "message": "Create a backup before applying sync changes (recommended)", + "description": "Toggle: take a local STG backup before delta-sync applies any change, so a bad sync can be rolled back" + }, + "syncSleepNewTabs": { + "message": "Create new synced tabs asleep (recommended)", + "description": "Toggle: tabs created by sync show title+favicon and only load when activated" + }, + "syncSleepPinnedTabs": { + "message": "Sleep synced pinned tabs (Firefox loads pinned tabs by default)", + "description": "Toggle: also create synced pinned tabs asleep via create-then-discard; off by default since pinned tabs normally load" + }, + "syncActivatePreviouslyActiveTabs": { + "message": "Activate tabs that were active on the other machine", + "description": "Sub-toggle: load a synced tab if it was active (loaded) on the source machine" }, "syncSettingsLocation": { "message": "Sync settings location", @@ -1435,6 +1537,18 @@ "message": "GitHub Gist backup has incorrect data", "description": "GitHub Gist backup has incorrect data" }, + "githubGistFileNotInRevision": { + "message": "The backup file is not present in this revision.", + "description": "The selected gist revision does not contain the cloud backup file" + }, + "githubGistBackupFileNameInvalid": { + "message": "Enter a file name ending in .json that does not start with «STG-sync-» (that prefix is reserved for cloud sync).", + "description": "Cloud backup file name is empty, not .json, or uses the reserved STG-sync- prefix" + }, + "githubBackupFileNameReserved": { + "message": "The cloud backup file name must not start with «STG-sync-»; that prefix is reserved for cloud sync files.", + "description": "Backup push refused because the file name collides with the reserved sync namespace" + }, "githubInvalidToken": { "message": "Your GitHub token is invalid", "description": "Your GitHub token is invalid" diff --git a/addon/src/_locales/ru/messages.json b/addon/src/_locales/ru/messages.json index 9da6dc44..b972b992 100644 --- a/addon/src/_locales/ru/messages.json +++ b/addon/src/_locales/ru/messages.json @@ -442,8 +442,14 @@ "description": "Exclude tabs with sound" }, "notPossibleSwitchGroupBecauseSomeTabShareMicrophoneOrCamera": { - "message": "Невозможно изменить группу, потому что на одной из вкладок используется микрофон или камера", - "description": "It is not possible to change the group because one of the opened tabs share microphone or camera" + "message": "Эти вкладки используют микрофон, камеру или доступ к экрану и продолжат это делать: «$titles$». Нажмите здесь, чтобы переключиться всё равно.", + "description": "Warning shown when switching a group would hide tabs that share microphone, camera or screen. Lists the tabs and lets the user switch anyway by clicking.", + "placeholders": { + "titles": { + "content": "$1", + "example": "some titles" + } + } }, "foundHiddenUnSyncTabsDescription": { "message": "Найдены скрытые не синхронизированные вкладки. Нажмите на вкладку, чтобы отобразить ее в текущей группе/окне или:", @@ -747,6 +753,34 @@ "message": "Выгрузить вкладку из памяти", "description": "Discard tab" }, + "pinTabInGroupTitle": { + "message": "Закрепить в группе", + "description": "Pin this tab within its group (pinned only while the group is active)" + }, + "pinTabInCurrentGroupTitle": { + "message": "Закрепить в текущей группе", + "description": "Pin a tab that is not yet in a group into the window's active group (pinned only while that group is active)" + }, + "unpinTabInGroupTitle": { + "message": "Открепить от группы", + "description": "Remove the group-scoped pin from this tab" + }, + "pinnedGroupTitle": { + "message": "Закреплённые", + "description": "Default title of the pinned group at first creation" + }, + "unpinToCurrentGroupTitle": { + "message": "Открепить в текущую группу", + "description": "Move a pinned-group tab into the window's current group as a normal tab" + }, + "showPinnedGroupTitle": { + "message": "Показать закреплённые вкладки здесь", + "description": "Show the pinned group's tabs in the current window" + }, + "hidePinnedGroupTitle": { + "message": "Скрыть закреплённые вкладки", + "description": "Hide the pinned group's tabs" + }, "folderNameTitle": { "message": "Имя папки", "description": "Folder name title" @@ -1369,21 +1403,89 @@ "message": "Синхронизировать", "description": "Start synchronization" }, - "syncStartTrustLocal": { - "message": "Синхронизировать и доверять локальным данным", - "description": "Start synchronization and trust local data" + "cloudSettingsTitle": { + "message": "Облако", + "description": "Title of the section grouping the cloud access, sync and backup settings blocks" + }, + "githubGistAccessTitle": { + "message": "Доступ к GitHub Gist", + "description": "Title of the GitHub Gist access settings block (token, file, storage location)" + }, + "githubGistAccessLocation": { + "message": "Хранить настройки облака в", + "description": "Label for the selector choosing where the roaming sync option bag is stored (Firefox Sync or local profile)" + }, + "githubGistNameTitle": { + "message": "Имя Gist", + "description": "Label for the gist name (the gist description) used to find or create the cloud gist" + }, + "githubGistNameHelp": { + "message": "Задаёт Gist, используемый для синхронизации. То же имя на другом устройстве подключается к тому же Gist.", + "description": "Help text under the gist name field in the GitHub Gist access block" }, - "syncStartTrustCloud": { - "message": "Синхронизировать и доверьтесь облачным данным", - "description": "Start synchronization and trust cloud data" + "githubGistNameChangeWarning": { + "message": "Изменение имени Gist приведёт к тому, что __MSG_extensionName__ забудет текущий Gist: он останется на GitHub, но больше не будет синхронизироваться. Синхронизация начнётся заново из текущего состояния этого устройства.", + "description": "Warning shown when the user changes the gist name to a different value" }, - "syncDataInCloudCanBeDifferent": { - "message": "Данные в облаке могут сильно отличаться от текущего состояния.", - "description": "The data in the cloud can be very different from the current state." + "githubGistBackupFileNameTitle": { + "message": "Имя файла резервной копии", + "description": "Label for the cloud backup file name field" + }, + "githubGistBackupFileNameChangeWarning": { + "message": "Изменение имени файла резервной копии приведёт к тому, что __MSG_extensionName__ забудет предыдущие резервные копии: они останутся на GitHub, но больше не будут отображаться и восстанавливаться.", + "description": "Warning shown when the user changes the cloud backup file name" + }, + "githubGistSyncTitle": { + "message": "Облачная синхронизация", + "description": "Title of the cloud sync settings block" + }, + "githubGistBackupTitle": { + "message": "Облачная резервная копия", + "description": "Title of the cloud backup settings block" + }, + "createBackupButton": { + "message": "Создать резервную копию", + "description": "Button that pushes the current full state to the cloud as a new gist revision" + }, + "backupChangesLabel": { + "message": "изменений", + "description": "Label prefixing the change count of a cloud backup revision" + }, + "resetSyncStateButton": { + "message": "Сбросить состояние синхронизации", + "description": "Reset this device's local sync state (recovery action)" + }, + "resetSyncStateConfirm": { + "message": "Это очистит локальное состояние синхронизации только на этом устройстве (базовое состояние и журнал изменений). Данные в облаке НЕ затрагиваются. Следующая синхронизация пройдёт как первая: ничего не будет удалено, а текущие вкладки будут повторно отправлены в облако.", + "description": "Confirmation text for the reset sync state action" }, "syncEnableTitle": { - "message": "Включить автоматическое резервное копирование в облако", - "description": "Enable auto backup to the cloud" + "message": "Включить синхронизацию с облаком", + "description": "Enable sync to the cloud" + }, + "autoSyncEnableTitle": { + "message": "Включить автоматическую синхронизацию с облаком", + "description": "Enable auto sync to the cloud" + }, + "cloudSyncEveryTitle": { + "message": "Синхронизировать с облаком каждые", + "description": "Cloud sync every" + }, + "syncBackupBeforeApply": { + "message": "Создавать резервную копию перед применением изменений синхронизации (рекомендуется)", + "description": "Toggle: take a local STG backup before delta-sync applies any change, so a bad sync can be rolled back" + }, + "syncSleepNewTabs": { + "message": "Создавать новые синхронизированные вкладки спящими (рекомендуется)", + "description": "Toggle: tabs created by sync show title+favicon and only load when activated" + }, + "syncSleepPinnedTabs": { + "message": "Усыплять синхронизированные закреплённые вкладки (Firefox по умолчанию загружает закреплённые вкладки)", + "description": "Toggle: also create synced pinned tabs asleep via create-then-discard; off by default since pinned tabs normally load" + }, + "syncActivatePreviouslyActiveTabs": { + "message": "Активировать вкладки, которые были активны на другом устройстве", + "description": "Sub-toggle: load a synced tab if it was active (loaded) on the source machine" }, "syncSettingsLocation": { "message": "Расположение настроек синхронизации", @@ -1423,6 +1525,18 @@ "message": "Резервная копия GitHub Gist содержит неверные данные", "description": "GitHub Gist backup has incorrect data" }, + "githubGistFileNotInRevision": { + "message": "В этой ревизии нет файла резервной копии.", + "description": "The selected gist revision does not contain the cloud backup file" + }, + "githubGistBackupFileNameInvalid": { + "message": "Укажите имя файла, оканчивающееся на .json и не начинающееся с «STG-sync-» (этот префикс зарезервирован для облачной синхронизации).", + "description": "Cloud backup file name is empty, not .json, or uses the reserved STG-sync- prefix" + }, + "githubBackupFileNameReserved": { + "message": "Имя файла облачной резервной копии не должно начинаться с «STG-sync-»; этот префикс зарезервирован для файлов облачной синхронизации.", + "description": "Backup push refused because the file name collides with the reserved sync namespace" + }, "githubInvalidToken": { "message": "Ваш токен GitHub недействителен", "description": "Your GitHub token is invalid" diff --git a/addon/src/_locales/uk/messages.json b/addon/src/_locales/uk/messages.json index 7764bc25..913c3bbe 100644 --- a/addon/src/_locales/uk/messages.json +++ b/addon/src/_locales/uk/messages.json @@ -442,8 +442,14 @@ "description": "Exclude tabs with sound" }, "notPossibleSwitchGroupBecauseSomeTabShareMicrophoneOrCamera": { - "message": "Неможливо змінити групу, тому що на одній з вкладок використовується мікрофон або камера", - "description": "It is not possible to change the group because one of the opened tabs share microphone or camera" + "message": "Ці вкладки використовують мікрофон, камеру або доступ до екрана і продовжать це робити: «$titles$». Натисніть тут, щоб переключитися все одно.", + "description": "Warning shown when switching a group would hide tabs that share microphone, camera or screen. Lists the tabs and lets the user switch anyway by clicking.", + "placeholders": { + "titles": { + "content": "$1", + "example": "some titles" + } + } }, "foundHiddenUnSyncTabsDescription": { "message": "Знайдено приховані не синхронізовані вкладки. Натисніть на вкладку, щоб відобразити її в поточній групі/вікні або: ", @@ -747,6 +753,34 @@ "message": "Вивантажити вкладку з пам'яті", "description": "Discard tab" }, + "pinTabInGroupTitle": { + "message": "Закріпити в групі", + "description": "Pin this tab within its group (pinned only while the group is active)" + }, + "pinTabInCurrentGroupTitle": { + "message": "Закріпити в поточній групі", + "description": "Pin a tab that is not yet in a group into the window's active group (pinned only while that group is active)" + }, + "unpinTabInGroupTitle": { + "message": "Відкріпити від групи", + "description": "Remove the group-scoped pin from this tab" + }, + "pinnedGroupTitle": { + "message": "Закріплені", + "description": "Default title of the pinned group at first creation" + }, + "unpinToCurrentGroupTitle": { + "message": "Відкріпити в поточну групу", + "description": "Move a pinned-group tab into the window's current group as a normal tab" + }, + "showPinnedGroupTitle": { + "message": "Показати закріплені вкладки тут", + "description": "Show the pinned group's tabs in the current window" + }, + "hidePinnedGroupTitle": { + "message": "Сховати закріплені вкладки", + "description": "Hide the pinned group's tabs" + }, "folderNameTitle": { "message": "Ім'я теки", "description": "Folder name title" @@ -1369,21 +1403,89 @@ "message": "Синхронізувати", "description": "Start synchronization" }, - "syncStartTrustLocal": { - "message": "Синхронізувати та довіряти локальним даним", - "description": "Start synchronization and trust local data" + "cloudSettingsTitle": { + "message": "Хмара", + "description": "Title of the section grouping the cloud access, sync and backup settings blocks" + }, + "githubGistAccessTitle": { + "message": "Доступ до GitHub Gist", + "description": "Title of the GitHub Gist access settings block (token, file, storage location)" + }, + "githubGistAccessLocation": { + "message": "Зберігати налаштування хмари в", + "description": "Label for the selector choosing where the roaming sync option bag is stored (Firefox Sync or local profile)" + }, + "githubGistNameTitle": { + "message": "Ім'я Gist", + "description": "Label for the gist name (the gist description) used to find or create the cloud gist" + }, + "githubGistNameHelp": { + "message": "Задає Gist, що використовується для синхронізації. Те саме ім'я на іншому пристрої під'єднується до того самого Gist.", + "description": "Help text under the gist name field in the GitHub Gist access block" + }, + "githubGistNameChangeWarning": { + "message": "Зміна імені Gist призведе до того, що __MSG_extensionName__ забуде поточний Gist: він залишиться на GitHub, але більше не синхронізуватиметься. Синхронізація почнеться заново з поточного стану цього пристрою.", + "description": "Warning shown when the user changes the gist name to a different value" }, - "syncStartTrustCloud": { - "message": "Синхронізувати та довіряти хмарним даним", - "description": "Start synchronization and trust cloud data" + "githubGistBackupFileNameTitle": { + "message": "Ім'я файлу резервної копії", + "description": "Label for the cloud backup file name field" }, - "syncDataInCloudCanBeDifferent": { - "message": "Дані в хмарі можуть сильно відрізнятися від поточного стану.", - "description": "The data in the cloud can be very different from the current state." + "githubGistBackupFileNameChangeWarning": { + "message": "Зміна імені файлу резервної копії призведе до того, що __MSG_extensionName__ забуде попередні резервні копії: вони залишаться на GitHub, але більше не відображатимуться й не відновлюватимуться.", + "description": "Warning shown when the user changes the cloud backup file name" + }, + "githubGistSyncTitle": { + "message": "Хмарна синхронізація", + "description": "Title of the cloud sync settings block" + }, + "githubGistBackupTitle": { + "message": "Хмарна резервна копія", + "description": "Title of the cloud backup settings block" + }, + "createBackupButton": { + "message": "Створити резервну копію", + "description": "Button that pushes the current full state to the cloud as a new gist revision" + }, + "backupChangesLabel": { + "message": "змін", + "description": "Label prefixing the change count of a cloud backup revision" + }, + "resetSyncStateButton": { + "message": "Скинути стан синхронізації", + "description": "Reset this device's local sync state (recovery action)" + }, + "resetSyncStateConfirm": { + "message": "Це очистить локальний стан синхронізації лише на цьому пристрої (базовий стан та журнал змін). Дані в хмарі НЕ зачіпаються. Наступна синхронізація пройде як перша: нічого не буде видалено, а поточні вкладки будуть повторно надіслані в хмару.", + "description": "Confirmation text for the reset sync state action" }, "syncEnableTitle": { - "message": "Увімкнути автоматичне резервне копіювання в хмару", - "description": "Enable auto backup to the cloud" + "message": "Увімкнути синхронізацію з хмарою", + "description": "Enable sync to the cloud" + }, + "autoSyncEnableTitle": { + "message": "Увімкнути автоматичну синхронізацію з хмарою", + "description": "Enable auto sync to the cloud" + }, + "cloudSyncEveryTitle": { + "message": "Синхронізувати з хмарою кожні", + "description": "Cloud sync every" + }, + "syncBackupBeforeApply": { + "message": "Створювати резервну копію перед застосуванням змін синхронізації (рекомендовано)", + "description": "Toggle: take a local STG backup before delta-sync applies any change, so a bad sync can be rolled back" + }, + "syncSleepNewTabs": { + "message": "Створювати нові синхронізовані вкладки сплячими (рекомендовано)", + "description": "Toggle: tabs created by sync show title+favicon and only load when activated" + }, + "syncSleepPinnedTabs": { + "message": "Присипляти синхронізовані закріплені вкладки (Firefox за замовчуванням завантажує закріплені вкладки)", + "description": "Toggle: also create synced pinned tabs asleep via create-then-discard; off by default since pinned tabs normally load" + }, + "syncActivatePreviouslyActiveTabs": { + "message": "Активувати вкладки, які були активними на іншому пристрої", + "description": "Sub-toggle: load a synced tab if it was active (loaded) on the source machine" }, "syncSettingsLocation": { "message": "Розташування налаштувань синхронізації", @@ -1423,6 +1525,18 @@ "message": "Резервна копія GitHub Gist містить некоректні дані", "description": "GitHub Gist backup has incorrect data" }, + "githubGistFileNotInRevision": { + "message": "У цій ревізії немає файлу резервної копії.", + "description": "The selected gist revision does not contain the cloud backup file" + }, + "githubGistBackupFileNameInvalid": { + "message": "Вкажіть ім'я файлу, що закінчується на .json і не починається з «STG-sync-» (цей префікс зарезервовано для хмарної синхронізації).", + "description": "Cloud backup file name is empty, not .json, or uses the reserved STG-sync- prefix" + }, + "githubBackupFileNameReserved": { + "message": "Ім'я файлу хмарної резервної копії не повинно починатися з «STG-sync-»; цей префікс зарезервовано для файлів хмарної синхронізації.", + "description": "Backup push refused because the file name collides with the reserved sync namespace" + }, "githubInvalidToken": { "message": "Ваш токен GitHub недійсний", "description": "Your GitHub token is invalid" diff --git a/addon/src/background.js b/addon/src/background.js index d6b6ba1e..90f15745 100644 --- a/addon/src/background.js +++ b/addon/src/background.js @@ -52,6 +52,10 @@ import * as Bookmarks from '/js/bookmarks.js'; import * as Permissions from '/js/permissions.js'; import * as BrowserSettings from '/js/browser-settings.js'; import * as Cloud from '/js/sync/cloud/cloud.js?can-do-synchronization'; +import {deltaSynchronization, resetSyncState} from '/js/sync/delta/delta-sync.js'; +import * as DeltaCapture from '/js/sync/delta/delta-capture.js'; +import {reconcileClosedTabRecords} from '/js/sync/delta/local-state.js'; +import {invalidateCaptureGate} from '/js/sync/delta/capture-gate-state.js'; const storage = localStorage.create(Constants.MODULES.BACKGROUND); @@ -431,6 +435,7 @@ Listeners.runtime.onMessageExternal.add(async function onMessageExternal(request }); self.saveOptions = saveOptions; +self.createBackup = createBackup; const INTERNAL_MODULES = { BG: { @@ -438,6 +443,8 @@ const INTERNAL_MODULES = { restoreBackup, clearAddon, cloudSync, + cloudBackupPush, + cloudBackupRestore, }, Tabs, Groups, @@ -504,6 +511,9 @@ async function onBackgroundMessage(message, sender) { result.ok = true; return result; + case 'reset-cloud-sync-state': + return await resetSyncState(); + default: break; } @@ -592,13 +602,19 @@ async function onBackgroundMessage(message, sender) { result.ok = await Groups.applyByHistory('prev', currentWindow.id, notArchivedGroups); break; case 'load-first-group': - if (notArchivedGroups.length) { - result.ok = await Groups.apply(currentWindow.id, notArchivedGroups.shift().id); + { + const navigableGroups = notArchivedGroups.filter(group => !Groups.isPinnedGroup(group)); + if (navigableGroups.length) { + result.ok = await Groups.apply(currentWindow.id, navigableGroups.shift().id); + } } break; case 'load-last-group': - if (notArchivedGroups.length) { - result.ok = await Groups.apply(currentWindow.id, notArchivedGroups.pop().id); + { + const navigableGroups = notArchivedGroups.filter(group => !Groups.isPinnedGroup(group)); + if (navigableGroups.length) { + result.ok = await Groups.apply(currentWindow.id, navigableGroups.pop().id); + } } break; case 'load-custom-group': @@ -644,7 +660,7 @@ async function onBackgroundMessage(message, sender) { action: 'show-groups-popup', popupAction: 'load-custom-group', popupTitle: Lang('hotkeyActionTitleLoadCustomGroup'), - groups: notArchivedGroups.map(Groups.mapForExternalExtension), + groups: notArchivedGroups.filter(group => !group.isPinnedGroup).map(Groups.mapForExternalExtension), disableGroupIds: [currentGroup?.id].filter(Boolean), }); @@ -1147,7 +1163,8 @@ async function saveOptions(_options) { await resetLocalBackupAlarm(); } - if (optionsKeys.some(key => ['syncEnable', 'syncOptionsLocation', 'syncIntervalKey', 'syncIntervalValue'].includes(key))) { + if (optionsKeys.some(key => ['syncEnable', 'autoSyncEnable', 'syncOptionsLocation', 'syncIntervalKey', 'syncIntervalValue'].includes(key))) { + invalidateCaptureGate(); await resetSyncAlarm(); } @@ -1155,6 +1172,8 @@ async function saveOptions(_options) { await Containers.updateTemporaryContainerTitle(options.temporaryContainerTitle); } + await DeltaCapture.optionsChanged(optionsToSave); + sendMessageFromBackground('options-updated', { keys: optionsKeys, }); @@ -1177,7 +1196,7 @@ async function resetLocalBackupAlarm() { async function resetSyncAlarm(useCurrentTimeAsLastRun = false) { await resetAlarm( Cloud.ALARM_NAME, - options.syncEnable, + options.syncEnable && options.autoSyncEnable, options.syncIntervalKey, options.syncIntervalValue, useCurrentTimeAsLastRun ? undefined : storage.autoSyncLastTimeStamp @@ -1226,8 +1245,8 @@ async function resetAlarm( log.stop(); } -async function createBackup(includeTabFavIcons, includeTabThumbnails, isAutoBackup = false) { - const log = logger.start('createBackup', {includeTabFavIcons, includeTabThumbnails, isAutoBackup}); +async function createBackup(includeTabFavIcons, includeTabThumbnails, isAutoBackup = false, filePathOverride = null, locationOverride = null) { + const log = logger.start('createBackup', {includeTabFavIcons, includeTabThumbnails, isAutoBackup, filePathOverride, locationOverride}); const data = await Storage.get(); const {groups} = await Groups.load(null, true, includeTabFavIcons, includeTabThumbnails); @@ -1286,6 +1305,22 @@ async function createBackup(includeTabFavIcons, includeTabThumbnails, isAutoBack data.containers = Containers.getToExport(data); + if (filePathOverride) { + data.autoBackupFilePath = filePathOverride; + + const location = locationOverride ?? data.autoBackupLocation; + + if (location === Constants.AUTO_BACKUP_LOCATIONS.DOWNLOADS) { + await File.saveBackup(data, true); + } else { + await Host.saveBackup(data); + } + + log.stop(); + + return true; + } + if (isAutoBackup) { if (data.autoBackupLocation === Constants.AUTO_BACKUP_LOCATIONS.DOWNLOADS) { await File.saveBackup(data, true); @@ -1367,10 +1402,20 @@ async function restoreBackup(data, clearAddonDataBeforeRestore = false) { const neededContainers = new Set; const defaultGroupProps = clearAddonDataBeforeRestore ? data.defaultGroupProps : options.defaultGroupProps; + let pinnedGroupClaimed = currentData.groups.some(Groups.isPinnedGroup); + data.groups = data.groups.map(group => { - const newGroupId = existGroupIds.has(group.id) - ? Groups.createId() - : (group.id || Groups.createId()); + const keepAsPinnedGroup = Groups.isPinnedGroup(group) && !pinnedGroupClaimed; + + let newGroupId; + if (keepAsPinnedGroup) { + newGroupId = Groups.PINNED_GROUP_ID; + pinnedGroupClaimed = true; + } else { + newGroupId = existGroupIds.has(group.id) || Groups.isPinnedGroupId(group.id) + ? Groups.createId() + : (group.id || Groups.createId()); + } const newGroup = Groups.create(newGroupId, group.title, defaultGroupProps); @@ -1384,6 +1429,10 @@ async function restoreBackup(data, clearAddonDataBeforeRestore = false) { } } + if (!keepAsPinnedGroup) { + newGroup.isPinnedGroup = false; + } + if (!newGroup.isArchive) { Extensions.tabsToUUID(newGroup.tabs); } @@ -1499,21 +1548,7 @@ async function clearAddon(reloadAddonOnFinish = true) { } } -async function cloudSync({ - trigger = Cloud.TRIGGER_MANUAL, - trust = null, - revision = null, - }) { - const log = logger.start(cloudSync, {trust, trigger, revision: revision?.slice(0, 7) ?? null}); - - let shouldResetSyncAlarm = false; - - if (trigger === Cloud.TRIGGER_MANUAL) { - const autoSyncLastTimeStamp = (storage.autoSyncLastTimeStamp ?? 0) * 1000; - const EXTENSION_START_TIME = await getExtensionStartTime(); - shouldResetSyncAlarm = autoSyncLastTimeStamp < EXTENSION_START_TIME; - } - +async function withCloudActionProgress(operation) { const syncSuccessColor = 'hsl(153, 53%, 53%)'; // --bulma-success const syncDangerColor = 'hsl(348, 100%, 70%)'; // --bulma-danger @@ -1543,13 +1578,31 @@ async function cloudSync({ actionListeners.add(Cloud.on('sync-end', () => browserActionProgress(100, syncSuccessColor, true))); actionListeners.add(Cloud.on('sync-error', ({progress}) => browserActionProgress(progress, syncDangerColor, true))); actionListeners.add(Cloud.on('sync-finish', ({ok}) => { - cloudSync.resetTimer = setTimeout(() => Browser.actionLoading(false), ok ? 0 : 5_000); + withCloudActionProgress.resetTimer = setTimeout(() => Browser.actionLoading(false), ok ? 0 : 5_000); })); - clearTimeout(cloudSync.resetTimer); + clearTimeout(withCloudActionProgress.resetTimer); + + try { + return await operation(); + } finally { + actionListeners.forEach(off => off()); + } +} - const syncResult = await Cloud.synchronization(trust, revision); +async function cloudSync({ + trigger = Cloud.TRIGGER_MANUAL, + } = {}) { + const log = logger.start(cloudSync, {trigger}); - actionListeners.forEach(off => off()); + let shouldResetSyncAlarm = false; + + if (trigger === Cloud.TRIGGER_MANUAL) { + const autoSyncLastTimeStamp = (storage.autoSyncLastTimeStamp ?? 0) * 1000; + const EXTENSION_START_TIME = await getExtensionStartTime(); + shouldResetSyncAlarm = autoSyncLastTimeStamp < EXTENSION_START_TIME; + } + + const syncResult = await withCloudActionProgress(deltaSynchronization); if (syncResult.inProgress) { log.stopWarn('sync in progress'); @@ -1588,6 +1641,40 @@ async function cloudSync({ return syncResult; } +async function cloudBackup(trust, revision = null) { + const log = logger.start('cloudBackup', {trust, revision: revision?.slice(0, 7) ?? null}); + + const result = await withCloudActionProgress(() => Cloud.synchronization(trust, revision, {useBackupFile: true})); + + if (result.inProgress) { + log.stopWarn('cloud backup in progress'); + return result; + } + + if (!result.ok && result.langId !== 'githubInvalidToken') { + Notification(objectToNativeError(result), { + id: Cloud.ERROR_NOTIFICATION_ID, + module: ['tabs', 'createUrlOnce', Constants.PAGES.SETTINGS + '#backup/backup'], + }); + } + + if (result.ok) { + log.stop(result); + } else { + log.stopError(result); + } + + return result; +} + +function cloudBackupPush() { + return cloudBackup(Cloud.TRUST_LOCAL); +} + +function cloudBackupRestore(revision) { + return cloudBackup(Cloud.TRUST_CLOUD, revision); +} + self.sendExternalMessage = sendExternalMessage; @@ -1659,6 +1746,10 @@ async function initializeGroupWindows(windows, currentGroupIds) { } win.tabs.forEach(function (tab) { + if (Groups.isPinnedGroupId(tab.groupId)) { + return; + } + if (tab.groupId && !currentGroupIds.includes(tab.groupId)) { delete tab.groupId; Cache.removeTabGroup(tab.id).catch(log.onCatch(['cant removeTabGroup', tab.id], false)); @@ -1773,6 +1864,8 @@ async function init() { dataChanged.add(Groups.normalizeContainersInGroups(data.groups)); + dataChanged.add(Groups.ensurePinnedGroup(data.groups)); + if (data.autoBackupLocation === Constants.AUTO_BACKUP_LOCATIONS.HOST) { if (Constants.IS_WINDOWS && await Host.hasPermission()) { Host.checkVersion().catch(e => { @@ -1826,6 +1919,11 @@ async function init() { await initializeGroupWindows(windows, data.groups.map(g => g.id)); + await Groups.absorbNativePinnedTabs(); + + await reconcileClosedTabRecords() + .catch(log.onCatch('reconcileClosedTabRecords', false)); + await Groups.fillHistory(windows); let tabs = Utils.flatTabs(windows); diff --git a/addon/src/components/context-menu-group.vue b/addon/src/components/context-menu-group.vue index de53f3b8..d170cd55 100644 --- a/addon/src/components/context-menu-group.vue +++ b/addon/src/components/context-menu-group.vue @@ -28,6 +28,10 @@ export default { type: Boolean, default: true, }, + currentWindowId: { + type: Number, + default: null, + }, }, components: { 'context-menu': contextMenu, @@ -40,6 +44,10 @@ export default { isOpened({id}) { return this.openedWindows.some(win => win.groupId === id); }, + isPinnedGroupShownHere(group) { + const shownTab = group.tabs?.find(tab => tab.pinned); + return Boolean(shownTab) && shownTab.windowId === this.currentWindowId; + }, }, }; @@ -50,7 +58,15 @@ export default {