doocus first ver
This commit is contained in:
110
ui/doocus-app/src/components/ExportBar.vue
Normal file
110
ui/doocus-app/src/components/ExportBar.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import JSZip from 'jszip'
|
||||
import {
|
||||
store, TARGETS, target, packageEstimate, humanBytes,
|
||||
} from '@/store'
|
||||
|
||||
const building = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// Build a zip: per selected doc, a folder holding the original source file plus
|
||||
// the textified content.md and meta.json. Originals are included because the
|
||||
// permitted services (Gemini web / NotebookLM) receive them directly; content.md
|
||||
// keeps each doc greppable and small.
|
||||
async function buildPackage(): Promise<void> {
|
||||
if (!store.selected.size) return
|
||||
building.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const zip = new JSZip()
|
||||
for (const id of store.selected) {
|
||||
const summary = store.docs.find((d) => d.id === id)
|
||||
const res = await fetch(`/api/docs/${encodeURIComponent(id)}`)
|
||||
if (!res.ok) throw new Error(`fetch ${id} failed`)
|
||||
const detail = await res.json()
|
||||
const folder = zip.folder(id.replace(/[\\/]+/g, '__')) ?? zip
|
||||
folder.file('content.md', detail.content ?? '')
|
||||
folder.file('meta.json', JSON.stringify(detail.meta ?? {}, null, 2))
|
||||
if (detail.originalUrl) {
|
||||
const bin = await fetch(detail.originalUrl)
|
||||
if (bin.ok) {
|
||||
const name = (summary?.name) || 'original'
|
||||
folder.file(name, await bin.blob())
|
||||
}
|
||||
}
|
||||
}
|
||||
const blob = await zip.generateAsync({ type: 'blob' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `doocus-package-${target.value.key}.zip`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
error.value = String(e)
|
||||
} finally {
|
||||
building.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection(): void {
|
||||
store.selected.clear()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="export-bar">
|
||||
<label class="target">
|
||||
Target
|
||||
<select v-model="store.targetKey">
|
||||
<option v-for="t in TARGETS" :key="t.key" :value="t.key">{{ t.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="estimate" :class="{ over: packageEstimate.overFiles || packageEstimate.overBytes }">
|
||||
{{ packageEstimate.docCount }} docs ·
|
||||
<span :class="{ bad: packageEstimate.overFiles }">
|
||||
{{ packageEstimate.files }}/{{ target.maxFiles }} files
|
||||
</span>
|
||||
·
|
||||
<span :class="{ bad: packageEstimate.overBytes }">
|
||||
{{ humanBytes(packageEstimate.bytes) }} / {{ humanBytes(target.maxBytes) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button :disabled="!store.selected.size" @click="clearSelection">Clear</button>
|
||||
<button :disabled="!store.selected.size || building" @click="buildPackage">
|
||||
{{ building ? 'Building…' : 'Download package' }}
|
||||
</button>
|
||||
<span v-if="error" class="err">{{ error }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.export-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.target {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.estimate {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.estimate.over {
|
||||
color: var(--status-error, #e0a030);
|
||||
}
|
||||
.estimate .bad {
|
||||
color: var(--status-error, #e0a030);
|
||||
font-weight: 600;
|
||||
}
|
||||
.err {
|
||||
color: var(--status-error, #e0a030);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user