mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
export, import: an export that pours back into a workspace whole
An export used to lose what plain files cannot carry: dates, names as typed, the boards. It now writes .workspace.json beside the tree (format 1), and an import that finds one restores them. The markdown stays plain, so the archive still reads without the application. POST /api/import (:import! / in the UI) imports at the root: each top-level directory becomes a space, or merges into the space already there by that name, with its board and cards and the diary role. With a manifest, media/ comes back as embedded files rather than a folder, a file whose name ends in .md stays a file, and empty folders come back. Export claims every path in one set, folders, then documents, then files, so a file named notes.md beside a document notes no longer makes a duplicate entry that failed the whole zip. Links to folders are rewritten like links to documents, in one regex pass per kind instead of a replace per file and document. A sealed file that will not open now fails the export instead of being left out silently. The temporary zip is made in the data directory, not in /tmp.
This commit is contained in:
1 parent
b1ba63dc61
commit
aa34be8663
10 files changed
+799
-199
No files matched your search
+9
-2
@@ -36,6 +36,8 @@
|
||||
let sealUpload = false
|
||||
let folderInput = $state<HTMLInputElement>()
|
||||
let importInput = $state<HTMLInputElement>()
|
||||
// Whether the next archive picked is poured in at the root, `:import! /`.
|
||||
let importRoot = false
|
||||
let shell = $state<HTMLDivElement>()
|
||||
let overlayEl = $state<HTMLDivElement>()
|
||||
let pending = $state('')
|
||||
@@ -53,7 +55,10 @@
|
||||
treeInput?.click()
|
||||
}
|
||||
hooks.pickFolder = () => folderInput?.click()
|
||||
hooks.pickArchive = () => importInput?.click()
|
||||
hooks.pickArchive = (root) => {
|
||||
importRoot = root
|
||||
importInput?.click()
|
||||
}
|
||||
hooks.focusList = () => shell?.focus()
|
||||
hooks.openCommandLine = (initial) => {
|
||||
cmdGlobal = false
|
||||
@@ -156,8 +161,10 @@
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = Array.from(input.files ?? [])
|
||||
input.value = ''
|
||||
const root = importRoot
|
||||
importRoot = false
|
||||
if (files.length === 0) return
|
||||
await workspace.guard(() => workspace.importFolder(files))
|
||||
await workspace.guard(() => workspace.importFolder(files, root))
|
||||
}
|
||||
|
||||
// What the empty pane offers, in the key/value shape of a status readout.
|
||||
|
||||
@@ -72,10 +72,13 @@ export interface SharedDocument {
|
||||
|
||||
/** What an import did, as `POST /api/nodes/{id}/import` reports it. */
|
||||
export interface ImportReport {
|
||||
/** New spaces, made by an import at the root. */
|
||||
spaces: number
|
||||
folders: number
|
||||
documents: number
|
||||
files: number
|
||||
links: number
|
||||
cards: number
|
||||
skipped: string[]
|
||||
}
|
||||
|
||||
@@ -230,6 +233,13 @@ export const api = {
|
||||
return request<ImportReport>(`/nodes/${id}/import`, { method: 'POST', body: form })
|
||||
},
|
||||
|
||||
/** An `:export!` poured back in whole: its directories become spaces. */
|
||||
importRoot: (files: File[]) => {
|
||||
const form = new FormData()
|
||||
for (const file of files) form.append('file', file, file.webkitRelativePath || file.name)
|
||||
return request<ImportReport>('/import', { method: 'POST', body: form })
|
||||
},
|
||||
|
||||
createNode: (input: {
|
||||
parent_id: number
|
||||
kind?: NodeKind
|
||||
|
||||
@@ -10,7 +10,8 @@ export const hooks = {
|
||||
/** Files for the folder being browsed, rather than for the document. */
|
||||
pickTreeFiles: (_encrypt: boolean) => {},
|
||||
pickFolder: () => {},
|
||||
pickArchive: () => {},
|
||||
/** `root`: into the workspace itself, where each directory is a space. */
|
||||
pickArchive: (_root: boolean) => {},
|
||||
focusEditor: () => {},
|
||||
focusList: () => {},
|
||||
insertText: (_text: string) => {},
|
||||
@@ -239,8 +240,8 @@ export const commands: CommandSpec[] = [
|
||||
help: 'upload a folder into this one — markdown becomes documents, links follow',
|
||||
run: () => hooks.pickFolder(),
|
||||
bang: {
|
||||
help: 'import loose files, or a .zip of a folder, instead of a folder',
|
||||
run: () => hooks.pickArchive(),
|
||||
help: 'import loose files, or a .zip of a folder; :import! / pours an :export! back in, spaces and boards and all',
|
||||
run: (arg) => hooks.pickArchive(arg.trim() === '/'),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1130,19 +1130,22 @@ class Workspace {
|
||||
* The report is worth a line of its own: an import that quietly skipped half
|
||||
* its files would only be noticed much later.
|
||||
*/
|
||||
async importFolder(files: File[]) {
|
||||
async importFolder(files: File[], root = false) {
|
||||
const parent = this.parentId
|
||||
if (parent === null) return
|
||||
if (parent === null && !root) return
|
||||
this.say(`importing ${files.length} file${files.length === 1 ? '' : 's'} …`)
|
||||
|
||||
const report = await api.importFolder(parent, files)
|
||||
const report = root ? await api.importRoot(files) : await api.importFolder(parent!, files)
|
||||
if (root) this.spaces = await api.listSpaces()
|
||||
await this.refresh()
|
||||
|
||||
const parts = [
|
||||
...(report.spaces > 0 ? [`${report.spaces} space${report.spaces === 1 ? '' : 's'}`] : []),
|
||||
`${report.documents} document${report.documents === 1 ? '' : 's'}`,
|
||||
`${report.folders} folder${report.folders === 1 ? '' : 's'}`,
|
||||
`${report.files} file${report.files === 1 ? '' : 's'}`,
|
||||
`${report.links} link${report.links === 1 ? '' : 's'} rewritten`,
|
||||
...(report.cards > 0 ? [`${report.cards} card${report.cards === 1 ? '' : 's'}`] : []),
|
||||
]
|
||||
if (report.skipped.length > 0) parts.push(`${report.skipped.length} skipped`)
|
||||
this.say(`imported ${parts.join(', ')}`)
|
||||
|
||||
Reference in new issue
Block a user