diff --git a/components/DocMeta.vue b/components/DocMeta.vue new file mode 100644 index 00000000..15da25a0 --- /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..6bd10a5a --- /dev/null +++ b/composables/useDatabase.ts @@ -0,0 +1,45 @@ +import { tryOnScopeDispose } from '@vueuse/core' +import { liveQuery } from 'dexie' +import { type Subscription } from 'rxjs' +import { type Ref, ref } from 'vue' +import { db } from '#root/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..0351ad5c --- /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 c2dee47f..8be6576b 100644 --- a/composables/useDocs.ts +++ b/composables/useDocs.ts @@ -4,8 +4,8 @@ import type Doc from '#root/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 99d2b995..349ccdde 100644 --- a/layouts/dashboard.vue +++ b/layouts/dashboard.vue @@ -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 a0b87114..3207944f 100644 --- a/package.json +++ b/package.json @@ -44,13 +44,15 @@ "@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-ts": "^4.2.1", + "dexie": "^3.2.2", "micromark": "^3.1.0", "nuxt": "^3.2.0", "postcss": "^8.4.21", diff --git a/pages/docs/[docId]/meta.vue b/pages/docs/[docId]/meta.vue index 388e5a47..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..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/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/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 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/plugins/caching/documents.js b/src/store/plugins/caching/documents.js index 0ed02b88..c4c9b583 100644 --- a/src/store/plugins/caching/documents.js +++ b/src/store/plugins/caching/documents.js @@ -1,9 +1,8 @@ import localforage from 'localforage' - +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 +14,12 @@ 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 }) +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(