diff --git a/components/DocMeta.vue b/components/DocMeta.vue new file mode 100644 index 00000000..06081084 --- /dev/null +++ b/components/DocMeta.vue @@ -0,0 +1,250 @@ + + + diff --git a/composables/index.ts b/composables/index.ts index 96de70f5..c05275a8 100644 --- a/composables/index.ts +++ b/composables/index.ts @@ -1,5 +1,6 @@ export * from './useAppearance' export * from './useAuth' +export * from './useDatabase' export * from './useLayout' export * from './usePinnedDocs' export * from './useTiers' diff --git a/composables/useDatabase.ts b/composables/useDatabase.ts new file mode 100644 index 00000000..bbf9c96d --- /dev/null +++ b/composables/useDatabase.ts @@ -0,0 +1,45 @@ +import { tryOnScopeDispose } from '@vueuse/core' +import { liveQuery } from 'dexie' +import { type Observable, type Subscription } from 'rxjs' +import { type Ref, type UnwrapRef, ref } from 'vue' +import { db } from '/src/database' + +export const useDatabase = () => { + const observe = (callback: () => T) => { + return useObservable(liveQuery(callback) as any) + } + + return { + db, + observe, + } +} + +type QueryReturnType = { result: Ref } + +export function useQuery(callback: () => Promise): QueryReturnType +export function useQuery(callback: () => Promise, initialValue: T): QueryReturnType +export function useQuery(callback: () => Promise, initialValue?: T) { + const result = initialValue ? ref(initialValue) : ref() + const subscription = ref() + + watch(callback, () => { + const observable = liveQuery(callback) + + subscription.value?.unsubscribe() + subscription.value = observable.subscribe({ + next: (value) => { + result.value = value + }, + error: (error) => { + console.error(error) + }, + }) as any + }, { immediate: true }) + + tryOnScopeDispose(() => subscription.value?.unsubscribe()) + + return { + result, + } +} diff --git a/composables/useDocVersions.ts b/composables/useDocVersions.ts new file mode 100644 index 00000000..edce8f0d --- /dev/null +++ b/composables/useDocVersions.ts @@ -0,0 +1,13 @@ +export const useDocVersions = () => { + const router = useRouter() + const { db } = useDatabase() + const { doc } = useDocs() + // Todo: Sort versions. + const { result: docVersions } = useQuery(() => db.docVersions.where({ docId: doc.value?.id }).reverse().sortBy('updatedAt'), []) + const docVersion = computed(() => docVersions.value.find(version => version.id === router.currentRoute.value.params.versionId)) + + return { + docVersion, + docVersions, + } +} diff --git a/composables/useDocs.ts b/composables/useDocs.ts index d17e64ad..f3714889 100644 --- a/composables/useDocs.ts +++ b/composables/useDocs.ts @@ -4,8 +4,8 @@ import type Doc from '/src/models/doc' export const useDocs = () => { const store = useStore() const router = useRouter() - const docs = computed(() => store.getters.decrypted) - const doc = computed(() => docs.value.find((doc: Doc) => doc.id === router.currentRoute.value.params.docId)) + const docs = computed(() => store.getters.decrypted) + const doc = computed(() => docs.value.find((doc: Doc) => doc.id === router.currentRoute.value.params.docId)) return { doc, diff --git a/layouts/dashboard.vue b/layouts/dashboard.vue index a959dfdd..aaa5f9d3 100644 --- a/layouts/dashboard.vue +++ b/layouts/dashboard.vue @@ -14,7 +14,7 @@ import LayoutNavbar from '/components/LayoutNavbar.vue' import TheLogo from '/components/TheLogo.vue' import { useLayout, usePinnedDocs } from '/composables' import TheLeftSidebar from '/pages/menu.vue' -import TheRightSidebar from '/pages/docs/[docId]/meta.vue' +import TheRightSidebar from '/components/DocMeta.vue' import { bindGlobal } from '/src/common/keybindings' export default defineComponent({ @@ -45,16 +45,21 @@ export default defineComponent({ const store = useStore() const router = useRouter() const { doc } = useDocs() + const { docVersion } = useDocVersions() const { showMenu, showMeta, toggleMenu, toggleMeta } = useLayout() const { pinnedDocs, unpinDoc } = usePinnedDocs() const { public: { discordInviteLink } } = useConfig() const modKey = computed(() => store.state.modKey) const mobile = computed(() => ['xs', 'sm'].includes(mq.current)) - const isDoc = computed(() => router.currentRoute.value.name === 'docs-docId') const isNew = computed(() => router.currentRoute.value.path === '/docs/new') + const isMeta = computed(() => router.currentRoute.value.path.endsWith('/meta')) const handleQuickActionClose = () => { if (doc.value) { + if (docVersion.value) { + return router.push({ path: `/docs/${doc.value.id}/versions/${docVersion.value.id}` }) + } + return router.push({ path: `/docs/${doc.value.id}` }) } @@ -99,10 +104,11 @@ export default defineComponent({ return { discordInviteLink, doc, + docVersion, handleLayoutChange, handleQuickActionClose, handleTabClose, - isDoc, + isMeta, isNew, isNuxt, mobile, @@ -170,7 +176,17 @@ export default defineComponent({ - + + + + + + + @@ -198,7 +214,7 @@ export default defineComponent({
- + {{ pinnedDoc.headers[0] || pinnedDoc.text.substring(0, 25) }} @@ -223,7 +239,7 @@ export default defineComponent({ -
diff --git a/package.json b/package.json index bd95400e..8e3ac821 100644 --- a/package.json +++ b/package.json @@ -41,13 +41,15 @@ "@types/mime-types": "^2.1.1", "@types/node": "^18.0.6", "@types/remarkable": "^2.0.3", - "@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-ts": "^4.2.1", + "dexie": "^3.2.2", "micromark": "^3.1.0", "nuxt": "^3.0.0", "postcss": "^8.4.20", diff --git a/pages/docs/[docId]/meta.vue b/pages/docs/[docId]/meta.vue index a54a4fbd..e74e790c 100644 --- a/pages/docs/[docId]/meta.vue +++ b/pages/docs/[docId]/meta.vue @@ -1,219 +1,5 @@ + + - - diff --git a/pages/docs/[docId]/versions/[versionId]/index.vue b/pages/docs/[docId]/versions/[versionId]/index.vue new file mode 100644 index 00000000..858df3e1 --- /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/src/database.ts b/src/database.ts new file mode 100644 index 00000000..72cb0362 --- /dev/null +++ b/src/database.ts @@ -0,0 +1,36 @@ +import Dexie, { type Table } from 'dexie' + +export interface DocVersion { + id: string, + createdAt: Date, + daily: boolean, + discardedAt?: Date, + docId: string, // the current doc + 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 { + docVersions!: Table + + constructor() { + super(DatabaseName) + + this.version(1).stores({ + docVersions: '++id, docId', + }) + } +} + +export const db = new OctoDatabase() 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 11713a79..d4f9d388 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/plugins/caching/documents.js b/src/store/plugins/caching/documents.js index dc06f2c9..cb3ec850 100644 --- a/src/store/plugins/caching/documents.js +++ b/src/store/plugins/caching/documents.js @@ -1,9 +1,7 @@ import localforage from 'localforage' - +import { useDatabase } from '/composables/useDatabase' import Debouncer from '/src/common/debouncer' - -import { pack, unpack } from '/src/models/doc' - +import Doc, { pack, unpack } from '/src/models/doc' import { ADD_DOCUMENT, DISCARD_DOCUMENT, @@ -16,12 +14,12 @@ import { SHARE_DOCUMENT, TOUCH_DOCUMENT, } from '/src/store/actions' - import { SETTINGS_LOADED } from '/src/store/modules/settings' -const cache = localforage.createInstance({ - name: 'firebase/documents', -}) +const name = 'firebase/documents' + +export const mainStore = localforage.createInstance({ name }) +export const historyStore = localforage.createInstance({ name, storeName: 'history' }) const debouncer = new Debouncer(800) @@ -49,15 +47,42 @@ export default (store) => { publicKey: state.settings.crypto.publicKey, }) - cache.setItem(found.id, doc) + // 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 unpacked = await unpack(original, { privateKey: state.settings.crypto.privateKey }) + // create a copy of the original with a new id + const backup = new Doc({ ...unpacked, id: undefined }) + // create the relation (e.g. parentId, originalId, etc) + backup.docId = found.id + // pack the backup + const docVersion = await pack(backup, { + preferEncryption: state.settings.crypto.enabled, + publicKey: state.settings.crypto.publicKey, + }) + // store the duplicate in historyStore + const { db } = useDatabase() + await db.docVersions.add(docVersion, [docVersion.id]) + } + } catch (error) { + // Todo: Show a prompt to the user. + console.error(error) + } + + // store the new version + await mainStore.setItem(found.id, 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/yarn.lock b/yarn.lock index 45aab1d1..34dc691a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3807,7 +3807,7 @@ __metadata: languageName: node linkType: hard -"@vueuse/core@npm:9.9.0, @vueuse/core@npm:^9.9.0": +"@vueuse/core@npm:9.9.0": version: 9.9.0 resolution: "@vueuse/core@npm:9.9.0" dependencies: @@ -3819,6 +3819,18 @@ __metadata: languageName: node linkType: hard +"@vueuse/core@npm:^9.10.0": + version: 9.10.0 + resolution: "@vueuse/core@npm:9.10.0" + dependencies: + "@types/web-bluetooth": ^0.0.16 + "@vueuse/metadata": 9.10.0 + "@vueuse/shared": 9.10.0 + vue-demi: "*" + checksum: 9e3b0c0baa1e3daf3ae508f76acf4e73ec80a2089d420f5de7c203e9fecd8cd4596a5e4635bbf09a762e0bba022f26806ee23b69d5d01a5d0e4b76e2ad2878a4 + languageName: node + linkType: hard + "@vueuse/head@npm:^1.0.15, @vueuse/head@npm:^1.0.22": version: 1.0.22 resolution: "@vueuse/head@npm:1.0.22" @@ -3833,6 +3845,13 @@ __metadata: languageName: node linkType: hard +"@vueuse/metadata@npm:9.10.0": + version: 9.10.0 + resolution: "@vueuse/metadata@npm:9.10.0" + checksum: ef9b4970cc1795e197279b59cfd82c7475323c17ae02701e5a6be26df1258330d4fafb7c05d005dfa7af97277509a8589d62467925319e1c5c17715805554bb7 + languageName: node + linkType: hard + "@vueuse/metadata@npm:9.9.0": version: 9.9.0 resolution: "@vueuse/metadata@npm:9.9.0" @@ -3855,6 +3874,27 @@ __metadata: languageName: node linkType: hard +"@vueuse/rxjs@npm:^9.10.0": + version: 9.10.0 + resolution: "@vueuse/rxjs@npm:9.10.0" + dependencies: + "@vueuse/shared": 9.10.0 + vue-demi: "*" + peerDependencies: + rxjs: ">=6.0.0" + checksum: 7d83f443e875020bc16f656ccd36388cd9d97593e1f2290eab6dea8639e557408c10ae8bd39602af67de68bd75c61fd249dcc07dac7ca84e60a54eed82b8ff60 + languageName: node + linkType: hard + +"@vueuse/shared@npm:9.10.0": + version: 9.10.0 + resolution: "@vueuse/shared@npm:9.10.0" + dependencies: + vue-demi: "*" + checksum: 1481ad7273eb8835a42fd49805d32ae97465188d260ddb28fdffebda7bedea5943bf49c3bd128121b32719307eaf1cb69b37b60fa1195c10a555e7aec3d78c22 + languageName: node + linkType: hard + "@vueuse/shared@npm:9.9.0": version: 9.9.0 resolution: "@vueuse/shared@npm:9.9.0" @@ -6504,6 +6544,13 @@ __metadata: languageName: node linkType: hard +"dexie@npm:^3.2.2": + version: 3.2.2 + resolution: "dexie@npm:3.2.2" + checksum: 7a21079f7ab139ebd724a009917f9293f2b01c341e2a3cd51d2455dda4d4e78b9ca7de0373e963108395cf1921ce7f6556cac967c1e957005a3c7c11794ceccf + languageName: node + linkType: hard + "didyoumean@npm:^1.2.2": version: 1.2.2 resolution: "didyoumean@npm:1.2.2" @@ -10890,14 +10937,16 @@ __metadata: "@types/mime-types": ^2.1.1 "@types/node": ^18.0.6 "@types/remarkable": ^2.0.3 - "@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.9.0 force-graph: ^1.42.16