diff --git a/components/Doc.vue b/components/Doc.vue index c8baa8f5..7cf44a10 100644 --- a/components/Doc.vue +++ b/components/Doc.vue @@ -26,15 +26,9 @@ diff --git a/components/SettingsEditor.vue b/components/SettingsEditor.vue index 156b8a4f..30d5455c 100644 --- a/components/SettingsEditor.vue +++ b/components/SettingsEditor.vue @@ -110,7 +110,7 @@ import { SET_EDITOR_SPELLCHECK, SET_EDITOR_TAB_SIZE, SET_EDITOR_TOOLBAR, -} from '/src/store/modules/settings.js' +} from '#root/src/store/modules/settings' export default { computed: { diff --git a/components/settings/Encryption.vue b/components/settings/Encryption.vue index dd005f2e..de3225d3 100644 --- a/components/settings/Encryption.vue +++ b/components/settings/Encryption.vue @@ -51,14 +51,9 @@ + - - diff --git a/pages/docs/[docId]/versions/[versionId]/index.vue b/pages/docs/[docId]/versions/[versionId]/index.vue new file mode 100644 index 00000000..f7b1b2a0 --- /dev/null +++ b/pages/docs/[docId]/versions/[versionId]/index.vue @@ -0,0 +1,43 @@ + + + diff --git a/pages/docs/[docId]/versions/[versionId]/meta.vue b/pages/docs/[docId]/versions/[versionId]/meta.vue new file mode 100644 index 00000000..81ba2f87 --- /dev/null +++ b/pages/docs/[docId]/versions/[versionId]/meta.vue @@ -0,0 +1,5 @@ + + + diff --git a/pages/docs/[docId]/versions/index.vue b/pages/docs/[docId]/versions/index.vue new file mode 100644 index 00000000..fad44f5e --- /dev/null +++ b/pages/docs/[docId]/versions/index.vue @@ -0,0 +1,22 @@ + + + diff --git a/pages/menu.vue b/pages/menu.vue index c292f7b4..b34b3ea5 100644 --- a/pages/menu.vue +++ b/pages/menu.vue @@ -136,14 +136,10 @@ import ModK from '#root/components/ModK.vue' import ModKKey from '#root/components/ModKKey.vue' import TagLink from '#root/components/TagLink.vue' import TheLogo from '#root/components/TheLogo.vue' +import { DEACTIVATE_CONTEXT, SET_CONTEXT_TAGS } from '#root/src/store/actions' import { useFiles } from '#root/src/stores/useFiles' import { AsyncIterable } from '#root/src/utils/iterables' -import { - DEACTIVATE_CONTEXT, - SET_CONTEXT_TAGS, -} from '/src/store/actions.js' - export default { components: { AccountIcon, diff --git a/pages/notepad.vue b/pages/notepad.vue index 2cb278ca..d3dbc2a7 100644 --- a/pages/notepad.vue +++ b/pages/notepad.vue @@ -6,11 +6,7 @@ import moment from 'moment' import EditorPage from '#root/pages/docs/[docId]/index.vue' import Doc from '#root/src/models/doc.js' - -import { - DOCUMENTS_LOADED, - EDIT_DOCUMENT, -} from '/src/store/actions.js' +import { DOCUMENTS_LOADED, EDIT_DOCUMENT } from '#root/src/store/actions' export default { components: { diff --git a/pages/workspaces.vue b/pages/workspaces.vue index 3c4d44c2..543988c4 100644 --- a/pages/workspaces.vue +++ b/pages/workspaces.vue @@ -58,15 +58,8 @@ import { TrashIcon, Square2StackIcon as WorkspaceIcon } from '@heroicons/vue/24/ import { nanoid } from 'nanoid' import CoreButton from '#root/components/CoreButton.vue' import Tag from '#root/components/Tag.vue' - -import { - SET_CONTEXT_TAGS, -} from '/src/store/actions.js'; - -import { - ADD_CONTEXT, - REMOVE_CONTEXT, -} from '/src/store/modules/contexts.js' +import { SET_CONTEXT_TAGS } from '#root/src/store/actions' +import { ADD_CONTEXT, REMOVE_CONTEXT } from '#root/src/store/modules/contexts' export default { components: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19e6382f..72516202 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,14 +14,16 @@ specifiers: '@types/node': ^18.13.0 '@types/remarkable': ^2.0.3 '@vite-pwa/nuxt': ^0.0.4 - '@vueuse/core': ^9.9.0 + '@vueuse/core': ^9.10.0 '@vueuse/head': ^1.0.22 '@vueuse/nuxt': ^9.9.0 + '@vueuse/rxjs': ^9.10.0 autoprefixer: ^10.4.13 cypress: ^12.0.2 cypress-network-idle: ^1.11.2 deepmerge: ^4.2.2 deepmerge-ts: ^4.2.1 + dexie: ^3.2.2 file-saver: ^2.0.5 firebase: ^9.17.1 force-graph: ^1.43.0 @@ -89,13 +91,15 @@ devDependencies: '@types/node': 18.13.0 '@types/remarkable': 2.0.3 '@vite-pwa/nuxt': 0.0.4_vite-plugin-pwa@0.14.1 - '@vueuse/core': 9.9.0 + '@vueuse/core': 9.12.0 '@vueuse/head': 1.0.25 '@vueuse/nuxt': 9.9.0_nuxt@3.2.0 + '@vueuse/rxjs': 9.12.0 autoprefixer: 10.4.13_postcss@8.4.21 cypress: 12.0.2 cypress-network-idle: 1.11.2 deepmerge-ts: 4.2.1 + dexie: 3.2.3 nuxt: 3.2.0_dq2illufn2sdyxstnqbjq3sy6i postcss: 8.4.21 remarkable-front-matter: 1.0.1-beta.1 @@ -3746,6 +3750,18 @@ packages: resolution: {integrity: sha512-BHGyyGN3Q97EZx0taMQ+OLNuZcW3d37ZEVmEAyeoA9ERdGvm9Irc/0Fua8SNyOtV1w6BS4q25wbMzJujO9HIfQ==} dev: true + /@vueuse/core/9.12.0: + resolution: {integrity: sha512-h/Di8Bvf6xRcvS/PvUVheiMYYz3U0tH3X25YxONSaAUBa841ayMwxkuzx/DGUMCW/wHWzD8tRy2zYmOC36r4sg==} + dependencies: + '@types/web-bluetooth': 0.0.16 + '@vueuse/metadata': 9.12.0 + '@vueuse/shared': 9.12.0 + vue-demi: 0.13.1 + transitivePeerDependencies: + - '@vue/composition-api' + - vue + dev: true + /@vueuse/core/9.9.0: resolution: {integrity: sha512-JdDb7TrE0imZnwBhMF4+0PCJqGD3AxzH8S2sfk54P0rqvklK+EAtAR/mPb1HwV/JPujQFQJhghQ190Yq03YpVw==} dependencies: @@ -3781,6 +3797,10 @@ packages: vue: 3.2.47 dev: true + /@vueuse/metadata/9.12.0: + resolution: {integrity: sha512-9oJ9MM9lFLlmvxXUqsR1wLt1uF7EVbP5iYaHJYqk+G2PbMjY6EXvZeTjbdO89HgoF5cI6z49o2zT/jD9SVoNpQ==} + dev: true + /@vueuse/metadata/9.9.0: resolution: {integrity: sha512-pgxsUJv/d7IjKpLeB6TthggEsaBwM3ffc5jPrr5TmxAm/fup0mGR5VTzrdA/PSx85tpb+CIvP92D+55qBNc8ag==} dev: true @@ -3803,6 +3823,27 @@ packages: - vue dev: true + /@vueuse/rxjs/9.12.0: + resolution: {integrity: sha512-slzucPMzFhVkurXiLZ8YusyfHrkmwB3MFm3vKo1qrStpho3jof0mwDAJJE5xsucBxrVBSBbLeiPxCehMBj1tIQ==} + peerDependencies: + rxjs: '>=6.0.0' + dependencies: + '@vueuse/shared': 9.12.0 + vue-demi: 0.13.1 + transitivePeerDependencies: + - '@vue/composition-api' + - vue + dev: true + + /@vueuse/shared/9.12.0: + resolution: {integrity: sha512-TWuJLACQ0BVithVTRbex4Wf1a1VaRuSpVeyEd4vMUWl54PzlE0ciFUshKCXnlLuD0lxIaLK4Ypj3NXYzZh4+SQ==} + dependencies: + vue-demi: 0.13.1 + transitivePeerDependencies: + - '@vue/composition-api' + - vue + dev: true + /@vueuse/shared/9.9.0: resolution: {integrity: sha512-+D0XFwHG0T+uaIbCSlROBwm1wzs71B7n3KyDOxnvfEMMHDOzl09rYKwaE2AENmYwYPXfHPbSBRDD2gBVHbvTcg==} dependencies: @@ -5737,6 +5778,11 @@ packages: minimist: 1.2.7 dev: true + /dexie/3.2.3: + resolution: {integrity: sha512-iHayBd4UYryDCVUNa3PMsJMEnd8yjyh5p7a+RFeC8i8n476BC9wMhVvqiImq5zJZJf5Tuer+s4SSj+AA3x+ZbQ==} + engines: {node: '>=6.0'} + dev: true + /didyoumean/1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} dev: true diff --git a/src/models/container.ts b/src/models/container.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/models/doc.js b/src/models/doc.js index 6ae3c8d9..4501fac9 100644 --- a/src/models/doc.js +++ b/src/models/doc.js @@ -25,6 +25,10 @@ class Doc { this.ownerId = attributes.ownerId || null this.syncedAt = attributes.syncedAt || null this.public = attributes.public || false + + // version params + this.originalId = attributes.originalId || null + this.parentId = attributes.parentId || null } discard() { @@ -100,11 +104,17 @@ export const pack = async (doc, { preferEncryption = null, publicKey = null }) = export const unpack = async (packed, { privateKey }) => { try { - if (privateKey && packed.encrypted) { - const text = await decrypt({ cipher: packed.text, cipherKey: packed.textKey, iv: packed.iv, privateKey }) + const prepared = { + ...packed, + id: packed.id || packed.clientId, + textKey: packed.textKey || packed.dataKey, + } + + if (privateKey && prepared.encrypted) { + const text = await decrypt({ cipher: prepared.text, cipherKey: prepared.textKey, iv: prepared.iv, privateKey }) return new Doc( - Object.assign({}, packed, { + Object.assign({}, prepared, { encrypted: false, text: text, }) diff --git a/src/store.js b/src/store.js index 4f04f48a..eadac83b 100644 --- a/src/store.js +++ b/src/store.js @@ -29,7 +29,7 @@ import { SET_ONLINE, SET_RIGHT_SIDEBAR_VISIBILITY, SET_SHOW_WELCOME, -} from '/src/store/actions' +} from '#root/src/store/actions' export const store = createStore({ state() { diff --git a/src/store/modules/auth.js b/src/store/modules/auth.js index 35a5a42c..cdfe5f6e 100644 --- a/src/store/modules/auth.js +++ b/src/store/modules/auth.js @@ -1,8 +1,5 @@ import { getAuth } from 'firebase/auth' - -import { - SIGN_OUT, -} from '/src/store/actions' +import { SIGN_OUT } from '#root/src/store/actions' export const SET_SUBSCRIPTION = 'SET_SUBSCRIPTION' export const SET_USER = 'SET_USER' diff --git a/src/store/modules/documents.js b/src/store/modules/documents.js index 984b251b..b9470cfe 100644 --- a/src/store/modules/documents.js +++ b/src/store/modules/documents.js @@ -15,7 +15,7 @@ import { SET_DOCUMENT, SHARE_DOCUMENT, TOUCH_DOCUMENT, -} from '/src/store/actions' +} from '#root/src/store/actions' const findDoc = (state, id) => { // only return docs that are decrypted diff --git a/src/store/modules/sync.js b/src/store/modules/sync.js index 2199948d..68dcabab 100644 --- a/src/store/modules/sync.js +++ b/src/store/modules/sync.js @@ -1,11 +1,7 @@ import { unwrap } from '#root/src/common/vue' import { addDoc, fetchDocs, updateDoc } from '#root/src/firebase/firestore' import { pack, unpack } from '#root/src/models/doc' - -import { - MERGE_DOCUMENT, - SYNC, -} from '/src/store/actions' +import { MERGE_DOCUMENT, SYNC } from '#root/src/store/actions' // local actions const PULL_DOCUMENT = 'PULL_DOCUMENT' diff --git a/src/store/plugins/caching/contexts.js b/src/store/plugins/caching/contexts.js index 111418a7..edfd0ac1 100644 --- a/src/store/plugins/caching/contexts.js +++ b/src/store/plugins/caching/contexts.js @@ -7,7 +7,7 @@ import { ADD_CONTEXT, LOAD_CONTEXTS, REMOVE_CONTEXT, -} from '/src/store/modules/contexts' +} from '#root/src/store/modules/contexts' import { SETTINGS_LOADED } from '#root/src/store/modules/settings' diff --git a/src/store/plugins/caching/documents.js b/src/store/plugins/caching/documents.js index 0ed02b88..01fff2d9 100644 --- a/src/store/plugins/caching/documents.js +++ b/src/store/plugins/caching/documents.js @@ -1,9 +1,9 @@ import localforage from 'localforage' - +import moment from 'moment' +import { useDatabase } from '#root/composables/useDatabase' import Debouncer from '#root/src/common/debouncer' - -import { pack, unpack } from '#root/src/models/doc' - +import Doc, { pack, unpack } from '#root/src/models/doc' +import { SETTINGS_LOADED } from '#root/src/store/modules/settings' import { ADD_DOCUMENT, DISCARD_DOCUMENT, @@ -15,13 +15,11 @@ import { RESTRICT_DOCUMENT, SHARE_DOCUMENT, TOUCH_DOCUMENT, -} from '/src/store/actions' +} from '#root/src/store/actions' -import { SETTINGS_LOADED } from '#root/src/store/modules/settings' +const name = 'firebase/documents' -const cache = localforage.createInstance({ - name: 'firebase/documents', -}) +export const mainStore = localforage.createInstance({ name }) const debouncer = new Debouncer(800) @@ -44,20 +42,64 @@ export default (store) => { if (found) { debouncer.debounce(found.id, async () => { + // Keep docs versioned on the client (maybe under a config option). + try { + // fetch the original doc + const original = await mainStore.getItem(found.id) + + if (original) { + // unpack the original doc + const originalUnpacked = await unpack(original, { privateKey: state.settings.crypto.privateKey }) + // store the duplicate in historyStore + const { db } = useDatabase() + // find latest version + const [latestVersion, ..._otherVersions] = await db.docs.where({ parentId: originalUnpacked.id }).reverse().sortBy('touchedAt') + const unpackedLatestVersion = latestVersion && await unpack(latestVersion, { privateKey: state.settings.crypto.privateKey }) + const comparisonTime = unpackedLatestVersion?.touchedAt || originalUnpacked.createdAt + const isAfterVersionThreshold = moment(found.touchedAt).isAfter(moment(comparisonTime).add(5, 'minutes')) + const isSync = found.syncedAt && (!originalUnpacked.syncedAt || moment(originalUnpacked.syncedAt).isBefore(moment(found.syncedAt))) + + if (isAfterVersionThreshold || isSync) { + // create a copy of the original with a new id + const backup = new Doc({ ...originalUnpacked, id: undefined }) + + // create the relation (e.g. parentId, originalId, etc) + backup.parentId = found.id + backup.touch() + + // pack the backup + const docVersion = await pack(backup, { + preferEncryption: state.settings.crypto.enabled, + publicKey: state.settings.crypto.publicKey, + }) + + await db.docs.add(docVersion, [docVersion.id]) + + console.log('done adding version...') + } + } + } catch (error) { + // Todo: Show a prompt to the user. + console.error(error) + } + const doc = await pack(found, { preferEncryption: state.settings.crypto.enabled, publicKey: state.settings.crypto.publicKey, }) - cache.setItem(found.id, doc) + // store the new version + await mainStore.setItem(found.id, doc) + + console.log('done updating doc...') }) } break case SETTINGS_LOADED: // load all documents from the cache after settings are loaded - cache.keys() - .then(ids => Promise.all(ids.map(id => cache.getItem(id)))) + mainStore.keys() + .then(ids => Promise.all(ids.map(id => mainStore.getItem(id)))) .then((docs) => { // unpack cached data return Promise.all( diff --git a/src/store/plugins/caching/settings.js b/src/store/plugins/caching/settings.js index 8cd50f00..d2b2a3e5 100644 --- a/src/store/plugins/caching/settings.js +++ b/src/store/plugins/caching/settings.js @@ -19,7 +19,7 @@ import { SET_EXPERIMENTAL, SET_THEME, SETTINGS_LOADED, -} from '/src/store/modules/settings' +} from '#root/src/store/modules/settings' const CACHE_KEY = 'main' const cache = localforage.createInstance({ diff --git a/src/store/plugins/keybindings.js b/src/store/plugins/keybindings.js index 2e0e1b2e..325f75cf 100644 --- a/src/store/plugins/keybindings.js +++ b/src/store/plugins/keybindings.js @@ -1,10 +1,5 @@ -import { - LOAD_KEYBINDINGS, -} from '/src/store/modules/keybindings' - -import { - SETTINGS_LOADED, -} from '/src/store/modules/settings' +import { LOAD_KEYBINDINGS } from '#root/src/store/modules/keybindings' +import { SETTINGS_LOADED } from '#root/src/store/modules/settings' export default (store) => { store.subscribe(({ type, _payload }, state) => { diff --git a/src/store/plugins/packages.js b/src/store/plugins/packages.js index d749f2e6..bf286c57 100644 --- a/src/store/plugins/packages.js +++ b/src/store/plugins/packages.js @@ -8,7 +8,7 @@ import { LOAD_DOCUMENTS, RESTORE_DOCUMENT, TOUCH_DOCUMENT, -} from '/src/store/actions'; +} from '#root/src/store/actions' export default (store) => { store.subscribe(({ type, payload: { id } }, state) => { diff --git a/src/store/plugins/sync.js b/src/store/plugins/sync.js index e8718c28..ee97b302 100644 --- a/src/store/plugins/sync.js +++ b/src/store/plugins/sync.js @@ -11,7 +11,7 @@ import { SHARE_DOCUMENT, SYNC, TOUCH_DOCUMENT, -} from '/src/store/actions' +} from '#root/src/store/actions' import { SET_USER } from '#root/src/store/modules/auth' diff --git a/utils/database.ts b/utils/database.ts new file mode 100644 index 00000000..d3b57f19 --- /dev/null +++ b/utils/database.ts @@ -0,0 +1,51 @@ +import Dexie, { type Table } from 'dexie' + +export interface Doc { + id: string, + parentId?: string, // the current doc + createdAt: Date, + daily: boolean, + discardedAt?: Date, + encrypted: boolean, + firebaseId?: string, + iv?: string, + ownerId?: string, + public: boolean, + syncedAt?: Date, + tags: string[], + text: string, + textKey?: string, + touchedAt: Date, + updatedAt: Date, +} + +export const DatabaseName = 'octo' + +export class OctoDatabase extends Dexie { + docs!: Table + + constructor() { + super(DatabaseName) + + this.version(2).stores({ + docs: [ + // primary key + '++id', + // relationship identifiers + 'ownerId', + 'parentId', + // other properties + 'createdAt', + 'daily', + 'discardedAt', + 'public', + 'syncedAt', + 'tags', + 'touchedAt', + 'updatedAt', + ].join(','), + }) + } +} + +export const db = new OctoDatabase() diff --git a/utils/queries.ts b/utils/queries.ts new file mode 100644 index 00000000..e69de29b