mirror of
https://github.com/grub-basket/SP.git
synced 2026-07-22 07:46:27 +00:00
0.99.23: cross-folder note paste (cut=move, copy=clone) with attachments + undo/redo
This commit is contained in:
parent
2e67b3d340
commit
bbe9229619
8 changed files with 448 additions and 80 deletions
131
main.js
131
main.js
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "stashpad",
|
||||
"name": "Stashpad",
|
||||
"version": "0.99.20",
|
||||
"version": "0.99.23",
|
||||
"minAppVersion": "1.7.0",
|
||||
"description": "Chat-style nested-notes view: rapid capture, outliner navigation, in-place editing.",
|
||||
"author": "Human",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "stashpad-obsidian",
|
||||
"version": "0.99.20",
|
||||
"version": "0.99.23",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
|
|
|
|||
37
release-notes/0.99.23.md
Normal file
37
release-notes/0.99.23.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# 0.99.23 — Cross-folder note paste
|
||||
|
||||
Cut or copy notes in one Stashpad folder and paste them into another. Previously
|
||||
the clipboard worked only within a single folder; a different-folder paste was
|
||||
refused.
|
||||
|
||||
## Paste across folders
|
||||
|
||||
- **Cut → move, copy → clone.** Pasting in the list of a different folder now
|
||||
moves the notes there (cut) or drops a fresh duplicate (copy). The whole
|
||||
subtree comes along — the note and all of its children.
|
||||
- **Attachments travel with the notes.** A moved or copied subtree carries its
|
||||
referenced attachments into the destination folder's `_attachments`, so the
|
||||
result is self-contained — no links pointing back at the original folder.
|
||||
On a move, the source's now-unused attachments are cleaned up; attachments
|
||||
shared with other notes are left in place.
|
||||
- **Copies get new identities; moves keep theirs.** A copy is a true duplicate
|
||||
(fresh ids), so it's independent of the original. A move preserves each note's
|
||||
id. Pasted roots land where your cursor is.
|
||||
- **Archive folders are protected.** An auto-encrypting archive folder is
|
||||
declined as a cross-folder paste target (its key might not be on this device),
|
||||
with a pointer to the explicit "Move to archive" command. Archive folders also
|
||||
stay out of cross-folder search, so they don't surface as targets.
|
||||
|
||||
## Undo / redo
|
||||
|
||||
- **Every cross-folder paste is undoable and redoable.** Undo a move and the
|
||||
notes (and their attachments) return to the source folder; undo a copy and the
|
||||
duplicate is removed. Redo replays it. The same applies to pasting a cut into a
|
||||
note's composer.
|
||||
|
||||
## Composer paste
|
||||
|
||||
- Pasting a cross-folder cut into a note's **composer** drops the full subtree in
|
||||
as an indented bullet outline — the note plus its children, nested by depth and
|
||||
ordered to match the list — then removes the originals (undoable).
|
||||
</content>
|
||||
|
|
@ -37,7 +37,7 @@ interface SubtreeNode { id: StashpadId; file: TFile; parent: StashpadId | null;
|
|||
|
||||
/** Collect a note + all its descendants within `folder` by walking frontmatter
|
||||
* `parent` links. Returns the root note and the rest, plus the root's parent. */
|
||||
async function collectSubtree(app: App, folder: string, rootId: StashpadId): Promise<{
|
||||
export async function collectSubtree(app: App, folder: string, rootId: StashpadId): Promise<{
|
||||
rootNote: SubtreeNode; descendants: SubtreeNode[]; parentId: StashpadId | null;
|
||||
} | null> {
|
||||
const cleaned = folder.replace(/\/+$/, "");
|
||||
|
|
|
|||
253
src/main.ts
253
src/main.ts
|
|
@ -7,7 +7,7 @@ import { STASHPAD_TRASH_VIEW_TYPE } from "./types";
|
|||
import { StashpadPanelsView, openStashpadPanelsView, PANEL_REGISTRY, type PanelId } from "./panels-view";
|
||||
import { StashpadFolderPanelView, openFolderPanelView } from "./folder-panel-view";
|
||||
import { EncryptionService, defaultEncryptionConfig } from "./encryption-service";
|
||||
import { lockSubtree, unlockBundle, readLockedMeta, type LockResult, deleteEncryptSubtree, restoreDeleted, listDeletedBlobs, readDeletedMeta, deletedRestoreDest, backfillTrashEncrypt, restoreRawTrash, OBSIDIAN_TRASH_DIR, type DeletedMeta } from "./encryption-ops";
|
||||
import { lockSubtree, unlockBundle, readLockedMeta, type LockResult, deleteEncryptSubtree, restoreDeleted, listDeletedBlobs, readDeletedMeta, deletedRestoreDest, backfillTrashEncrypt, restoreRawTrash, OBSIDIAN_TRASH_DIR, type DeletedMeta, collectSubtree } from "./encryption-ops";
|
||||
import { EncryptionPasswordModal } from "./modals";
|
||||
import {
|
||||
DEFAULT_SETTINGS, StashpadSettings, StashpadSettingTab, setSettings, SETTINGS_TABS,
|
||||
|
|
@ -15,7 +15,7 @@ import {
|
|||
} from "./settings";
|
||||
import { DEFAULT_STOPWORDS, bodyToSlug, buildFilename, parseIdFromFilename } from "./slug-service";
|
||||
import { getActiveView, onActiveViewChange } from "./active-view";
|
||||
import { importStashZip, STASH_EXT, splitFrontmatter } from "./stash-package";
|
||||
import { importStashZip, buildStashZip, resolveNoteAttachmentFiles, STASH_EXT, splitFrontmatter } from "./stash-package";
|
||||
import { formatDateTime } from "./format";
|
||||
import { resolveStashBytes, isEncryptedStash } from "./stash-crypto";
|
||||
import { StashpadLog } from "./log";
|
||||
|
|
@ -34,6 +34,9 @@ import { RenderCacheStore } from "./render-cache-store";
|
|||
* the next load knows to un-ghost the deferred Stashpad tabs. */
|
||||
const UNGHOST_FLAG = "stashpad:unghost-after-reload";
|
||||
|
||||
/** A captured file's content, for snapshot-backed undo/redo of file operations. */
|
||||
interface FileSnapshot { path: string; binary: boolean; text?: string; data?: ArrayBuffer; }
|
||||
|
||||
export default class StashpadPlugin extends Plugin {
|
||||
settings: StashpadSettings = { ...DEFAULT_SETTINGS };
|
||||
private undoStacks = new Map<string, UndoStack>();
|
||||
|
|
@ -3290,6 +3293,252 @@ export default class StashpadPlugin extends Plugin {
|
|||
return (this.settings.archiveFolders ?? []).includes(cleaned);
|
||||
}
|
||||
|
||||
/** Ids of the markdown notes living directly in `folder` (read from DISK — the
|
||||
* metadata cache can lag and an under-read here would miss a real id collision
|
||||
* on import). */
|
||||
async idsInFolder(folder: string): Promise<Set<StashpadId>> {
|
||||
const cleaned = folder.replace(/\/+$/, "");
|
||||
const out = new Set<StashpadId>();
|
||||
for (const f of this.app.vault.getMarkdownFiles()) {
|
||||
if ((f.parent?.path?.replace(/\/+$/, "") ?? "") !== cleaned) continue;
|
||||
try { const id = splitFrontmatter(await this.app.vault.read(f)).fm.id; if (typeof id === "string") out.add(id); } catch { /* skip unreadable */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Cross-folder note paste engine (cut = move, copy = clone). Routes the source
|
||||
* subtree(s) through the `.stash` bundle path so ATTACHMENTS travel into the
|
||||
* destination's `_attachments` folder, then (for a cut) trashes the source
|
||||
* notes and their EXCLUSIVE attachments. Refuses an archive / auto-encrypting
|
||||
* destination — a missing on-device key could strand the move — so that path
|
||||
* is left to the explicit "Move to archive" command (which checks the key
|
||||
* first). Returns the destination root ids, total note count, and reversible
|
||||
* `undo` / `redo` closures (file-level — the caller adds tree rebuild + render).
|
||||
* Undo is snapshot-backed: we capture the created destination files and (for a
|
||||
* cut) the source files BEFORE trashing them, so undo fully restores either
|
||||
* direction. Null on refusal / no-op. */
|
||||
async crossFolderPaste(
|
||||
srcFolder: string, rootIds: StashpadId[], destFolder: string,
|
||||
destParent: StashpadId, mode: "cut" | "copy",
|
||||
): Promise<{ rootIds: StashpadId[]; noteCount: number; undo: () => Promise<void>; redo: () => Promise<void> } | null> {
|
||||
const cleanDest = destFolder.replace(/\/+$/, "");
|
||||
if (this.isArchiveFolder(cleanDest)) {
|
||||
new Notice(`"${cleanDest.split("/").pop()}" auto-encrypts notes moved in, so cross-folder paste is disabled there. Use the "Move to archive" command — it checks the encryption key first.`);
|
||||
return null;
|
||||
}
|
||||
// Gather source subtree(s) from DISK (authoritative; the source folder's view
|
||||
// may be closed, so we can't rely on an in-memory tree).
|
||||
const rootNotes: { id: StashpadId; file: TFile }[] = [];
|
||||
const allDescendants: { id: StashpadId; file: TFile }[] = [];
|
||||
const srcRootOldIds: StashpadId[] = [];
|
||||
const srcNoteFiles: TFile[] = [];
|
||||
for (const rid of rootIds) {
|
||||
const sub = await collectSubtree(this.app, srcFolder, rid);
|
||||
if (!sub) continue;
|
||||
srcRootOldIds.push(sub.rootNote.id);
|
||||
rootNotes.push({ id: sub.rootNote.id, file: sub.rootNote.file });
|
||||
srcNoteFiles.push(sub.rootNote.file);
|
||||
for (const d of sub.descendants) { allDescendants.push({ id: d.id, file: d.file }); srcNoteFiles.push(d.file); }
|
||||
}
|
||||
if (!rootNotes.length) return null;
|
||||
const noteCount = rootNotes.length + allDescendants.length;
|
||||
|
||||
// For a CUT, snapshot the source (notes + EXCLUSIVE attachments) BEFORE we
|
||||
// touch anything, so undo can recreate it byte-for-byte.
|
||||
let srcExclusiveAtts: TFile[] = [];
|
||||
let srcSnapshot: FileSnapshot[] = [];
|
||||
if (mode === "cut") {
|
||||
srcExclusiveAtts = await this.exclusiveAttachmentsOf(srcNoteFiles);
|
||||
srcSnapshot = await this.snapshotPaths([...srcNoteFiles.map((f) => f.path), ...srcExclusiveAtts.map((f) => f.path)]);
|
||||
}
|
||||
|
||||
// Bundle the subtree (collects referenced attachments) → import into dest
|
||||
// (writes attachments into dest/_attachments). Copy → fresh ids; cut → keep
|
||||
// ids so the moved notes retain their identity. Roots reparent to the paste
|
||||
// target. dedupeExisting:false on purpose — the vault-wide reuse would route
|
||||
// the pasted note's attachment link back to the SOURCE folder's copy (and for
|
||||
// a cut, that copy then gets trashed → a dangling link). We want the
|
||||
// attachment physically in THIS folder's _attachments so the subtree is
|
||||
// self-contained. (An identical file already in dest's _attachments is still
|
||||
// reused — only the cross-folder reuse is dropped.)
|
||||
const zip = await buildStashZip(this.app, { rootNotes, allDescendants, sourceFolder: srcFolder });
|
||||
const destExistingIds = await this.idsInFolder(cleanDest);
|
||||
const beforePaths = new Set(this.filesUnder(cleanDest));
|
||||
const summary = await importStashZip(this.app, zip, cleanDest, destExistingIds, {
|
||||
dedupeExisting: false,
|
||||
forceNewIds: mode === "copy",
|
||||
reparentRootsTo: destParent,
|
||||
});
|
||||
const createdPaths = this.filesUnder(cleanDest).filter((p) => !beforePaths.has(p));
|
||||
const destSnapshot = await this.snapshotPaths(createdPaths);
|
||||
const newRootIds = srcRootOldIds.map((old) => summary.idRemap[old]).filter((x): x is StashpadId => !!x);
|
||||
|
||||
// CUT: the notes + attachments now live in dest, so trash the source subtree.
|
||||
if (mode === "cut") {
|
||||
for (const f of srcNoteFiles) { try { await this.app.fileManager.trashFile(f); } catch (e) { console.warn("[Stashpad] cross-folder move: couldn't trash source note", f.path, e); } }
|
||||
for (const f of srcExclusiveAtts) { try { await this.app.fileManager.trashFile(f); } catch (e) { console.warn("[Stashpad] cross-folder move: couldn't trash source attachment", f.path, e); } }
|
||||
}
|
||||
|
||||
const removeCreated = async () => {
|
||||
for (const p of [...createdPaths].reverse()) {
|
||||
const f = this.app.vault.getAbstractFileByPath(p);
|
||||
if (f) { try { await this.app.fileManager.trashFile(f as TFile); } catch { /* already gone */ } }
|
||||
}
|
||||
};
|
||||
const undo = mode === "cut"
|
||||
? async () => { await removeCreated(); await this.restoreSnapshot(srcSnapshot); }
|
||||
: async () => { await removeCreated(); };
|
||||
const redo = mode === "cut"
|
||||
? async () => { await this.restoreSnapshot(destSnapshot); for (const s of srcSnapshot) { const f = this.app.vault.getAbstractFileByPath(s.path); if (f) { try { await this.app.fileManager.trashFile(f as TFile); } catch { /* gone */ } } } }
|
||||
: async () => { await this.restoreSnapshot(destSnapshot); };
|
||||
|
||||
return { rootIds: newRootIds, noteCount, undo, redo };
|
||||
}
|
||||
|
||||
/** Trash (recoverable) the given subtrees in `folder`: every note file plus the
|
||||
* attachments referenced ONLY by those subtrees — shared attachments stay put.
|
||||
* Returns the files it trashed (for the caller's undo snapshot). */
|
||||
async trashSubtrees(folder: string, rootIds: StashpadId[]): Promise<TFile[]> {
|
||||
const files: TFile[] = [];
|
||||
for (const rid of rootIds) {
|
||||
const sub = await collectSubtree(this.app, folder, rid);
|
||||
if (!sub) continue;
|
||||
files.push(sub.rootNote.file, ...sub.descendants.map((d) => d.file));
|
||||
}
|
||||
if (!files.length) return [];
|
||||
const exclusiveAtts = await this.exclusiveAttachmentsOf(files);
|
||||
const trashed: TFile[] = [];
|
||||
for (const f of [...files, ...exclusiveAtts]) {
|
||||
try { await this.app.fileManager.trashFile(f); trashed.push(f); }
|
||||
catch (e) { console.warn("[Stashpad] trashSubtrees: couldn't trash", f.path, e); }
|
||||
}
|
||||
return trashed;
|
||||
}
|
||||
|
||||
/** Attachments referenced ONLY by `noteFiles` (not by any note outside the set).
|
||||
* Exclusivity is read from the live resolvedLinks graph — call while the notes
|
||||
* are still present. */
|
||||
async exclusiveAttachmentsOf(noteFiles: TFile[]): Promise<TFile[]> {
|
||||
const subtreePaths = new Set(noteFiles.map((f) => f.path));
|
||||
const subtreeAtts = new Map<string, TFile>();
|
||||
for (const f of noteFiles) for (const af of await resolveNoteAttachmentFiles(this.app, f)) subtreeAtts.set(af.path, af);
|
||||
const resolved = this.app.metadataCache.resolvedLinks ?? {};
|
||||
for (const notePath of Object.keys(resolved)) {
|
||||
if (subtreePaths.has(notePath)) continue;
|
||||
for (const target of Object.keys(resolved[notePath] ?? {})) subtreeAtts.delete(target); // referenced elsewhere → not exclusive
|
||||
}
|
||||
return [...subtreeAtts.values()];
|
||||
}
|
||||
|
||||
/** Paths of every file (notes + their exclusive attachments) in the given
|
||||
* subtrees — for an undo snapshot taken before trashing. */
|
||||
async subtreeFilePaths(folder: string, rootIds: StashpadId[]): Promise<string[]> {
|
||||
const files: TFile[] = [];
|
||||
for (const rid of rootIds) {
|
||||
const sub = await collectSubtree(this.app, folder, rid);
|
||||
if (!sub) continue;
|
||||
files.push(sub.rootNote.file, ...sub.descendants.map((d) => d.file));
|
||||
}
|
||||
if (!files.length) return [];
|
||||
const atts = await this.exclusiveAttachmentsOf(files);
|
||||
return [...files.map((f) => f.path), ...atts.map((f) => f.path)];
|
||||
}
|
||||
|
||||
/** Pre-ordered (parent → children, depth-tagged) nodes of the given subtrees,
|
||||
* read from disk — for building the indented outline of a CROSS-folder cut
|
||||
* pasted into a composer (the source folder's tree isn't loaded in the
|
||||
* destination view). Siblings are ordered by `position` frontmatter (then
|
||||
* `created`) to match the list's visual order. */
|
||||
async orderedSubtreeNodes(folder: string, rootIds: StashpadId[]): Promise<Array<{ file: TFile; created: string; depth: number }>> {
|
||||
const out: Array<{ file: TFile; created: string; depth: number }> = [];
|
||||
const seen = new Set<StashpadId>();
|
||||
const posOf = (f: TFile): number => { const v = (this.app.metadataCache.getFileCache(f)?.frontmatter as Record<string, unknown> | undefined)?.position; return typeof v === "number" ? v : Number.MAX_SAFE_INTEGER; };
|
||||
type N = { id: StashpadId; file: TFile; created: string };
|
||||
for (const rid of rootIds) {
|
||||
const sub = await collectSubtree(this.app, folder, rid);
|
||||
if (!sub) continue;
|
||||
const childrenOf = new Map<StashpadId, N[]>();
|
||||
for (const d of sub.descendants) {
|
||||
if (!d.parent) continue;
|
||||
const arr = childrenOf.get(d.parent as StashpadId) ?? [];
|
||||
arr.push({ id: d.id, file: d.file, created: d.created });
|
||||
childrenOf.set(d.parent as StashpadId, arr);
|
||||
}
|
||||
for (const arr of childrenOf.values()) arr.sort((a, b) => (posOf(a.file) - posOf(b.file)) || a.created.localeCompare(b.created));
|
||||
const walk = (node: N, depth: number): void => {
|
||||
if (seen.has(node.id)) return; // cycle / overlap guard
|
||||
seen.add(node.id);
|
||||
out.push({ file: node.file, created: node.created, depth });
|
||||
for (const c of childrenOf.get(node.id) ?? []) walk(c, depth + 1);
|
||||
};
|
||||
walk({ id: sub.rootNote.id, file: sub.rootNote.file, created: sub.rootNote.created }, 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** All file paths under `folder` (notes directly in it + its `_attachments`). */
|
||||
filesUnder(folder: string): string[] {
|
||||
const prefix = folder.replace(/\/+$/, "") + "/";
|
||||
return this.app.vault.getFiles().filter((f) => f.path.startsWith(prefix)).map((f) => f.path);
|
||||
}
|
||||
|
||||
/** Capture content for a set of paths (text for `.md`, binary otherwise) so an
|
||||
* undo/redo can recreate them exactly. Missing paths are skipped. */
|
||||
async snapshotPaths(paths: string[]): Promise<FileSnapshot[]> {
|
||||
const out: FileSnapshot[] = [];
|
||||
for (const path of paths) {
|
||||
const f = this.app.vault.getAbstractFileByPath(path) as TFile | null;
|
||||
if (!f) continue;
|
||||
const binary = !path.toLowerCase().endsWith(".md");
|
||||
try {
|
||||
if (binary) out.push({ path, binary, data: await this.app.vault.readBinary(f) });
|
||||
else out.push({ path, binary, text: await this.app.vault.read(f) });
|
||||
} catch (e) { console.warn("[Stashpad] snapshotPaths: couldn't read", path, e); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Recreate files from a snapshot (parents are created as needed). Overwrites an
|
||||
* existing file at the same path. */
|
||||
async restoreSnapshot(snaps: FileSnapshot[]): Promise<void> {
|
||||
for (const s of snaps) {
|
||||
const dir = s.path.split("/").slice(0, -1).join("/");
|
||||
await this.ensureVaultFolder(dir);
|
||||
const existing = this.app.vault.getAbstractFileByPath(s.path) as TFile | null;
|
||||
try {
|
||||
if (s.binary) {
|
||||
if (existing) await this.app.vault.adapter.writeBinary(s.path, s.data as ArrayBuffer);
|
||||
else await this.app.vault.createBinary(s.path, s.data as ArrayBuffer);
|
||||
} else {
|
||||
if (existing) await this.app.vault.modify(existing, s.text ?? "");
|
||||
else await this.app.vault.create(s.path, s.text ?? "");
|
||||
}
|
||||
} catch (e) { console.warn("[Stashpad] restoreSnapshot: couldn't write", s.path, e); }
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure a (possibly nested) vault folder exists. */
|
||||
async ensureVaultFolder(dir: string): Promise<void> {
|
||||
if (!dir) return;
|
||||
let acc = "";
|
||||
for (const seg of dir.split("/")) {
|
||||
acc = acc ? `${acc}/${seg}` : seg;
|
||||
if (!(await this.app.vault.adapter.exists(acc))) { try { await this.app.vault.createFolder(acc); } catch { /* race / exists */ } }
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild + re-render every open Stashpad view showing `folder` — after an
|
||||
* out-of-band change to its files (e.g. a cross-folder move that removed notes
|
||||
* from the source folder, whose view isn't the one that ran the command). */
|
||||
refreshOpenViewsForFolder(folder: string): void {
|
||||
const cleaned = folder.replace(/\/+$/, "");
|
||||
for (const leaf of this.app.workspace.getLeavesOfType(STASHPAD_VIEW_TYPE)) {
|
||||
const v = leaf.view as any;
|
||||
if ((v?.noteFolder?.replace(/\/+$/, "") ?? "") !== cleaned) continue;
|
||||
try { v.tree?.rebuild?.(folder); v.render?.(); } catch (e) { console.warn("[Stashpad] refresh view failed", e); }
|
||||
}
|
||||
}
|
||||
|
||||
/** Batches arrivals per archive folder: each move-in (re)arms a settle timer so
|
||||
* a multi-file move (a whole subtree dragged in) is swept ONCE, after the
|
||||
* re-home debounce (900ms) and metadata indexing have settled. */
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ export interface ImportSummary {
|
|||
/** Hex→name aliases from the manifest, for the caller to merge into the
|
||||
* destination folder's color aliases (importStashZip has no settings access). */
|
||||
colorAliases?: Record<string, string>;
|
||||
/** Old id → new id mapping applied on import (identity for kept ids). Lets a
|
||||
* caller (e.g. cross-folder paste) locate the written roots by their source id. */
|
||||
idRemap: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ParsedNote {
|
||||
|
|
@ -118,7 +121,7 @@ export async function importStashZip(
|
|||
buf: ArrayBuffer | Uint8Array,
|
||||
destFolder: string,
|
||||
existingIds: Set<StashpadId>,
|
||||
opts: { dedupeExisting?: boolean } = {},
|
||||
opts: { dedupeExisting?: boolean; forceNewIds?: boolean; reparentRootsTo?: StashpadId | null } = {},
|
||||
): Promise<ImportSummary> {
|
||||
const zip = await JSZip.loadAsync(buf as any);
|
||||
const manifestFile = zip.file("manifest.json");
|
||||
|
|
@ -152,7 +155,9 @@ export async function importStashZip(
|
|||
for (const p of parsed) {
|
||||
const oldId = p.fm.id as string | undefined;
|
||||
if (!oldId) continue;
|
||||
if (existingIds.has(oldId) || idRemap.has(oldId) /* dup within zip */) {
|
||||
if (opts.forceNewIds) {
|
||||
idRemap.set(oldId, newId(6)); // cross-folder COPY → a fresh identity (not a same-id twin)
|
||||
} else if (existingIds.has(oldId) || idRemap.has(oldId) /* dup within zip */) {
|
||||
idRemap.set(oldId, `${oldId}-${newId(4)}-Imported`);
|
||||
collisionsRenamed++;
|
||||
} else {
|
||||
|
|
@ -225,13 +230,18 @@ export async function importStashZip(
|
|||
|
||||
const oldParent = (p.fm.parent ?? null) as string | null;
|
||||
let newParent: string | null = oldParent;
|
||||
if (oldParent && oldParent !== ROOT_ID && idRemap.has(oldParent)) {
|
||||
newParent = idRemap.get(oldParent)!;
|
||||
} else if (oldParent && oldParent !== ROOT_ID && !idRemap.has(oldParent)) {
|
||||
if (!oldParent || oldParent === ROOT_ID) {
|
||||
// Top-level in the source → a bundle root. Honor reparentRootsTo (cross-
|
||||
// folder paste nests the pasted root where the cursor was); otherwise keep
|
||||
// it at ROOT as before.
|
||||
newParent = opts.reparentRootsTo ?? oldParent ?? ROOT_ID;
|
||||
} else if (idRemap.has(oldParent)) {
|
||||
newParent = idRemap.get(oldParent)!; // internal edge — remap to the moved parent
|
||||
} else {
|
||||
// Parent isn't in this bundle. If it already EXISTS in the destination
|
||||
// (e.g. UNLOCK: the locked subtree's parent stayed in the vault), keep the
|
||||
// link so nesting is restored; otherwise pin to ROOT for safety.
|
||||
newParent = existingIds.has(oldParent) ? oldParent : ROOT_ID;
|
||||
// link so nesting is restored; otherwise it's a bundle root → reparent/ROOT.
|
||||
newParent = existingIds.has(oldParent) ? oldParent : (opts.reparentRootsTo ?? ROOT_ID);
|
||||
}
|
||||
|
||||
// Rewrite body: ![[basename]] -> ![[<routed path>]] (the _attachments copy,
|
||||
|
|
@ -280,7 +290,7 @@ export async function importStashZip(
|
|||
if (Object.keys(clean).length) colorAliases = clean;
|
||||
}
|
||||
|
||||
return { notesWritten, attachmentsWritten, collisionsRenamed, warnings, colorAliases };
|
||||
return { notesWritten, attachmentsWritten, collisionsRenamed, warnings, colorAliases, idRemap: Object.fromEntries(idRemap) };
|
||||
}
|
||||
|
||||
// ---------------- Helpers ----------------
|
||||
|
|
|
|||
75
src/view.ts
75
src/view.ts
|
|
@ -6998,7 +6998,40 @@ export class StashpadView extends ItemView {
|
|||
async cmdPasteNotes(): Promise<void> {
|
||||
const clip = this.plugin.noteClipboard;
|
||||
if (!clip) { new Notice("The note clipboard is empty — copy or cut notes first."); return; }
|
||||
if (clip.folder !== this.noteFolder) { new Notice("Those notes were cut/copied in another folder — cross-folder paste isn't supported yet."); return; }
|
||||
// Cross-folder paste: the source notes live in another Stashpad folder, so
|
||||
// route through the plugin's bundle-based engine — it carries ATTACHMENTS
|
||||
// into this folder's _attachments, mints fresh ids for a copy (keeps them for
|
||||
// a cut), and refuses an archive/auto-encrypting destination.
|
||||
if (clip.folder !== this.noteFolder) {
|
||||
const cursorX = this.currentChildren[this.cursorIdx] ?? null;
|
||||
const destParent = ((cursorX?.parent ?? this.focusId) ?? ROOT_ID) as StashpadId;
|
||||
const mode = clip.mode;
|
||||
const srcFolder = clip.folder;
|
||||
const result = await this.plugin.crossFolderPaste(srcFolder, clip.ids, this.noteFolder, destParent, mode);
|
||||
if (!result || !result.rootIds.length) return; // refused (archive) / nothing found — Notice already shown
|
||||
if (mode === "cut") this.plugin.clearNoteClipboard();
|
||||
const folder = this.noteFolder;
|
||||
this.tree.rebuild(folder);
|
||||
this.pendingFocusIds = result.rootIds.slice();
|
||||
this.render();
|
||||
if (mode === "cut") this.plugin.refreshOpenViewsForFolder(srcFolder); // source lost notes
|
||||
const srcLabel = srcFolder.split("/").pop();
|
||||
const n = result.rootIds.length;
|
||||
// Undo/redo: the engine returns reversible file-level closures; we wrap them
|
||||
// with a rebuild + render of THIS folder and a refresh of the source folder.
|
||||
const refreshBoth = () => { this.tree.rebuild(folder); this.render(); this.plugin.refreshOpenViewsForFolder(srcFolder); };
|
||||
this.plugin.getUndoStack(folder).push({
|
||||
label: `${mode === "cut" ? "Move" : "Paste"} ${n} note${n === 1 ? "" : "s"} from ${srcLabel}`,
|
||||
undo: async () => { await result.undo(); refreshBoth(); },
|
||||
redo: async () => { await result.redo(); refreshBoth(); },
|
||||
});
|
||||
const verb = mode === "cut" ? "Moved" : "Pasted (copied)";
|
||||
this.plugin.notifications.show({
|
||||
message: `${verb} ${n} note${n === 1 ? "" : "s"} (${result.noteCount} total) from "${srcLabel}" into this folder. Undo (in the list) reverses it.`,
|
||||
kind: "success", category: mode === "cut" ? "move" : "clone", affectedIds: result.rootIds, folder, duration: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const nodes = clip.ids.map((id) => this.tree.get(id)).filter((n): n is TreeNode => !!n && !!n.file);
|
||||
if (!nodes.length) { this.plugin.clearNoteClipboard(); this.render(); new Notice("Those notes no longer exist."); return; }
|
||||
// Paste position: after the cursor row (same parent); fall back to the
|
||||
|
|
@ -7064,7 +7097,45 @@ export class StashpadView extends ItemView {
|
|||
* deleting the original notes + their subtrees (snapshot-undoable). */
|
||||
async completeCutIntoComposer(): Promise<void> {
|
||||
const clip = this.plugin.noteClipboard;
|
||||
if (!clip || clip.mode !== "cut" || clip.folder !== this.noteFolder) return;
|
||||
if (!clip || clip.mode !== "cut") return;
|
||||
if (clip.folder !== this.noteFolder) {
|
||||
// Cross-folder cut → composer: the cut text is on the system clipboard
|
||||
// (clip.text); insert it here, then trash the source subtree(s). This is the
|
||||
// "fold the text into what I'm writing" path — no structural move.
|
||||
const srcFolder = clip.folder;
|
||||
const rootIds = clip.ids.slice();
|
||||
this.plugin.clearNoteClipboard();
|
||||
// Build the SAME indented bullet outline as the same-folder path (note +
|
||||
// all children, 2-space indent per depth, optional time prefix), reading
|
||||
// the source subtree from disk since it isn't in this view's tree.
|
||||
const ordered = await this.plugin.orderedSubtreeNodes(srcFolder, rootIds);
|
||||
const prefixTs = getSettings().prefixTimestampsOnCopy;
|
||||
const outline: string[] = [];
|
||||
for (const { file, created, depth } of ordered) {
|
||||
try {
|
||||
const body = this.stripFrontmatter(await this.app.vault.cachedRead(file)).trim().split(/\r?\n/).join(" ");
|
||||
const ts = prefixTs ? `${this.formatTimeInline(created)} ` : "";
|
||||
outline.push(`${" ".repeat(depth)}- ${ts}${body}`);
|
||||
} catch { /* skip unreadable */ }
|
||||
}
|
||||
this.insertIntoComposer(outline.length ? outline.join("\n") : (clip.text ?? ""));
|
||||
// Snapshot the source BEFORE trashing so undo can restore it.
|
||||
const snapPaths = await this.plugin.subtreeFilePaths(srcFolder, rootIds);
|
||||
const snap = await this.plugin.snapshotPaths(snapPaths);
|
||||
const trashed = await this.plugin.trashSubtrees(srcFolder, rootIds);
|
||||
this.plugin.refreshOpenViewsForFolder(srcFolder);
|
||||
const noteN = trashed.filter((f) => f.extension === "md").length;
|
||||
this.plugin.getUndoStack(srcFolder).push({
|
||||
label: `Cut ${rootIds.length} note${rootIds.length === 1 ? "" : "s"} into composer (from ${srcFolder.split("/").pop()})`,
|
||||
undo: async () => { await this.plugin.restoreSnapshot(snap); this.plugin.refreshOpenViewsForFolder(srcFolder); },
|
||||
redo: async () => { await this.plugin.trashSubtrees(srcFolder, rootIds); this.plugin.refreshOpenViewsForFolder(srcFolder); },
|
||||
});
|
||||
this.plugin.notifications.show({
|
||||
message: `Pasted the text of ${rootIds.length} cut note${rootIds.length === 1 ? "" : "s"} from "${srcFolder.split("/").pop()}" into the composer and removed the original${noteN === 1 ? "" : "s"} (${noteN} note${noteN === 1 ? "" : "s"}). Undo restores them.`,
|
||||
kind: "warning", category: "delete", affectedIds: rootIds, folder: srcFolder, duration: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.plugin.clearNoteClipboard();
|
||||
const roots = clip.ids.map((id) => this.tree.get(id)).filter((n): n is TreeNode => !!n && !!n.file);
|
||||
if (!roots.length) return;
|
||||
|
|
|
|||
Loading…
Reference in a new issue