Skip to content

Commit c0083ee

Browse files
committed
Merge origin/main into migration-block-init-staging-branch
Single conflict: apps/roam/src/components/LeftSidebarView.tsx — HEAD's dual-read plumbing (initialSnapshot, sectionIndex, handleToggle) unioned with main's drag-and-drop (SortableList, dragHandle, reorderGlobalChildren). SectionChildren removed (dead after main's ChildRow/SortableList path). Known followup: DnD reorder + fold toggle can race on stale section index when toggle fires before reorder write settles. To be tracked separately.
1 parent c86a3d1 commit c0083ee

11 files changed

Lines changed: 925 additions & 53 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: Complete Roam Linear Release
2+
on:
3+
workflow_dispatch:
4+
inputs:
5+
version:
6+
description: "Roam release version to complete in Linear, for example 0.18.1."
7+
required: true
8+
type: string
9+
10+
jobs:
11+
complete-release:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- name: Complete Linear release
15+
uses: linear/linear-release-action@v0
16+
with:
17+
access_key: ${{ secrets.LINEAR_RELEASES_ACCESS_KEY }}
18+
command: complete
19+
version: ${{ inputs.version }}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: Database tests
2+
on:
3+
pull_request:
4+
paths:
5+
- "packages/database/**"
6+
env:
7+
SUPABASE_USE_DB: local
8+
SUPABASE_PROJECT_ID: test
9+
GITHUB_TEST: test
10+
11+
jobs:
12+
build:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v6
16+
- name: Install pnpm
17+
uses: pnpm/action-setup@v4
18+
with:
19+
version: 10.15.1
20+
run_install: false
21+
- name: Setup node
22+
uses: actions/setup-node@v4
23+
with:
24+
node-version: "20"
25+
cache: "pnpm"
26+
- name: Install Dependencies
27+
run: pnpm install --frozen-lockfile
28+
# - name: Get supabase version
29+
# working-directory: ./packages/database
30+
# run: echo "SUPABASE_VERSION=$(./node_modules/.bin/supabase --version)" >> $GITHUB_ENV
31+
# - name: Cache Docker images.
32+
# uses: AndreKurait/docker-cache@0.6.0
33+
# with:
34+
# key: docker-${{ runner.os }}-${{ env.SUPABASE_VERSION }}
35+
- name: Setup database
36+
working-directory: ./packages/database
37+
run: pnpm run setup
38+
- name: Serve and run tests
39+
working-directory: ./packages/database
40+
run: pnpm run test:withserve
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { DataAdapter, Notice } from "obsidian";
2+
import type DiscourseGraphPlugin from "~/index";
3+
import type { ImportFolderMetadata } from "~/types";
4+
5+
const DG_METADATA_FILE = ".dg.metadata";
6+
const IMPORT_ROOT = "import";
7+
8+
const sanitizeFileName = (fileName: string): string => {
9+
return fileName
10+
.replace(/[<>:"/\\|?*]/g, "")
11+
.replace(/\s+/g, " ")
12+
.trim();
13+
};
14+
15+
const generateShortId = (): string => Math.random().toString(36).slice(2, 8);
16+
17+
const readImportFolderMetadata = async (
18+
adapter: DataAdapter,
19+
folderPath: string,
20+
): Promise<ImportFolderMetadata | null> => {
21+
const metadataPath = `${folderPath}/${DG_METADATA_FILE}`;
22+
try {
23+
const exists = await adapter.exists(metadataPath);
24+
if (!exists) return null;
25+
26+
const raw = await adapter.read(metadataPath);
27+
const parsed: unknown = JSON.parse(raw);
28+
29+
if (
30+
parsed !== null &&
31+
typeof parsed === "object" &&
32+
"spaceUri" in parsed &&
33+
typeof (parsed as Record<string, unknown>).spaceUri === "string"
34+
) {
35+
return parsed as ImportFolderMetadata;
36+
}
37+
38+
return null;
39+
} catch {
40+
return null;
41+
}
42+
};
43+
44+
const writeImportFolderMetadata = async ({
45+
adapter,
46+
folderPath,
47+
metadata,
48+
}: {
49+
adapter: DataAdapter;
50+
folderPath: string;
51+
metadata: ImportFolderMetadata;
52+
}): Promise<void> => {
53+
const metadataPath = `${folderPath}/${DG_METADATA_FILE}`;
54+
await adapter.write(metadataPath, JSON.stringify(metadata, null, 2));
55+
};
56+
57+
const resolveMetadataDuplicate = async ({
58+
adapter,
59+
existingFolderPath,
60+
newFolderPath,
61+
}: {
62+
adapter: DataAdapter;
63+
existingFolderPath: string;
64+
newFolderPath: string;
65+
}): Promise<string> => {
66+
const existingMetadataPath = `${existingFolderPath}/${DG_METADATA_FILE}`;
67+
const newMetadataPath = `${newFolderPath}/${DG_METADATA_FILE}`;
68+
69+
const existingStat = await adapter.stat(existingMetadataPath);
70+
const newStat = await adapter.stat(newMetadataPath);
71+
72+
const newIsNewer =
73+
existingStat && newStat && existingStat.mtime < newStat.mtime;
74+
if (newIsNewer) {
75+
await adapter.remove(existingMetadataPath);
76+
return newFolderPath;
77+
}
78+
79+
await adapter.remove(newMetadataPath);
80+
return existingFolderPath;
81+
};
82+
83+
const buildSpaceUriToFolderMap = async (
84+
adapter: DataAdapter,
85+
): Promise<Map<string, string>> => {
86+
const map = new Map<string, string>();
87+
88+
const importExists = await adapter.exists(IMPORT_ROOT);
89+
if (!importExists) return map;
90+
91+
const { folders } = await adapter.list(IMPORT_ROOT);
92+
93+
for (const folderPath of folders) {
94+
const metadata = await readImportFolderMetadata(adapter, folderPath);
95+
if (!metadata) continue;
96+
97+
if (map.has(metadata.spaceUri)) {
98+
const existingPath = map.get(metadata.spaceUri)!;
99+
const keptPath = await resolveMetadataDuplicate({
100+
adapter,
101+
existingFolderPath: existingPath,
102+
newFolderPath: folderPath,
103+
});
104+
map.set(metadata.spaceUri, keptPath);
105+
} else {
106+
map.set(metadata.spaceUri, folderPath);
107+
}
108+
}
109+
110+
return map;
111+
};
112+
113+
export const resolveFolderForSpaceUri = async ({
114+
adapter,
115+
spaceUri,
116+
spaceName,
117+
}: {
118+
adapter: DataAdapter;
119+
spaceUri: string;
120+
spaceName: string;
121+
}): Promise<string> => {
122+
const spaceUriToFolder = await buildSpaceUriToFolderMap(adapter);
123+
124+
// 1. Exact spaceUri match
125+
if (spaceUriToFolder.has(spaceUri)) {
126+
const folderPath = spaceUriToFolder.get(spaceUri)!;
127+
const existingMetadata = await readImportFolderMetadata(
128+
adapter,
129+
folderPath,
130+
);
131+
if (existingMetadata && existingMetadata.spaceName !== spaceName) {
132+
await writeImportFolderMetadata({
133+
adapter,
134+
folderPath,
135+
metadata: { ...existingMetadata, spaceName },
136+
});
137+
}
138+
return folderPath;
139+
}
140+
141+
// 2. Fallback: scan for a folder whose basename matches the sanitized spaceName
142+
// but has no metadata yet
143+
const { folders } = (await adapter.exists(IMPORT_ROOT))
144+
? await adapter.list(IMPORT_ROOT)
145+
: { folders: [] };
146+
147+
const sanitized = sanitizeFileName(spaceName);
148+
149+
for (const folderPath of folders) {
150+
const basename = folderPath.split("/").pop();
151+
if (basename === sanitized) {
152+
const existingMetadata = await readImportFolderMetadata(
153+
adapter,
154+
folderPath,
155+
);
156+
if (!existingMetadata) {
157+
await writeImportFolderMetadata({
158+
adapter,
159+
folderPath,
160+
metadata: { spaceUri, spaceName },
161+
});
162+
return folderPath;
163+
}
164+
}
165+
}
166+
167+
// 3. Create a new folder, handling name collisions
168+
const desiredPath = `${IMPORT_ROOT}/${sanitized}`;
169+
const desiredExists = await adapter.exists(desiredPath);
170+
171+
let newPath: string;
172+
if (desiredExists) {
173+
// The existing folder has a different spaceUri (would have been returned above otherwise)
174+
newPath = `${IMPORT_ROOT}/${sanitized}-${generateShortId()}`;
175+
} else {
176+
newPath = desiredPath;
177+
}
178+
179+
await adapter.mkdir(newPath);
180+
await writeImportFolderMetadata({
181+
adapter,
182+
folderPath: newPath,
183+
metadata: { spaceUri, spaceName },
184+
});
185+
186+
return newPath;
187+
};
188+
189+
export const migrateImportFolderMetadata = async (
190+
plugin: DiscourseGraphPlugin,
191+
): Promise<void> => {
192+
const adapter = plugin.app.vault.adapter;
193+
194+
const importExists = await adapter.exists(IMPORT_ROOT);
195+
if (!importExists) return;
196+
197+
const { folders } = await adapter.list(IMPORT_ROOT);
198+
199+
// Invert spaceNames: Record<spaceUri, spaceName> → Map<sanitizedName, Set<spaceUri>>
200+
// Using a Set per name to detect collisions — two different spaceUris can share
201+
// the same sanitized folder name, making the mapping ambiguous.
202+
const spaceNames = plugin.settings.spaceNames ?? {};
203+
const nameToSpaceUris = new Map<string, Set<string>>();
204+
for (const [spaceUri, name] of Object.entries(spaceNames)) {
205+
const sanitized = sanitizeFileName(name);
206+
const existing = nameToSpaceUris.get(sanitized);
207+
if (existing) {
208+
existing.add(spaceUri);
209+
new Notice(
210+
`Discourse Graphs: ambiguous import folder name "${sanitized}" maps to multiple spaces — skipping migration for this folder.`,
211+
);
212+
} else {
213+
nameToSpaceUris.set(sanitized, new Set([spaceUri]));
214+
}
215+
}
216+
217+
for (const folderPath of folders) {
218+
const metadataPath = `${folderPath}/${DG_METADATA_FILE}`;
219+
const metadataExists = await adapter.exists(metadataPath);
220+
if (metadataExists) continue;
221+
222+
const basename = folderPath.split("/").pop() ?? "";
223+
const spaceUris = nameToSpaceUris.get(basename);
224+
225+
if (spaceUris?.size === 1) {
226+
const spaceUri = [...spaceUris][0]!;
227+
await writeImportFolderMetadata({
228+
adapter,
229+
folderPath,
230+
metadata: { spaceUri, spaceName: spaceNames[spaceUri] ?? basename },
231+
});
232+
}
233+
}
234+
};
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import React from "react";
2+
import { Popover, Position, Button, Menu, MenuItem } from "@blueprintjs/core";
3+
import { OnloadArgs } from "roamjs-components/types";
4+
import { createDiscourseNodeFromCommand } from "~/utils/registerCommandPaletteCommands";
5+
6+
export const cleanCommandName = (name: string): string => {
7+
if (name.startsWith("{") && name.endsWith("}"))
8+
name = name.substring(1, name.length - 1);
9+
name = name.trim();
10+
// sentence case
11+
name = name.charAt(0).toUpperCase() + name.slice(1);
12+
return name;
13+
};
14+
15+
export const commands: Record<
16+
string,
17+
(onloadArgs: OnloadArgs) => Promise<void>
18+
> = {
19+
/* eslint-disable @typescript-eslint/require-await */
20+
// eslint-disable-next-line @typescript-eslint/naming-convention
21+
"{create node}": async (onloadArgs: OnloadArgs) => {
22+
createDiscourseNodeFromCommand(onloadArgs.extensionAPI);
23+
// typescript-eslint/naming-convention
24+
},
25+
/* eslint-enable @typescript-eslint/require-await */
26+
};
27+
28+
export const SidebarCommandPopover = ({
29+
onSelect,
30+
}: {
31+
onSelect: (value: string) => void;
32+
}) => {
33+
return (
34+
<Popover
35+
content={
36+
<Menu>
37+
{Object.keys(commands).map((commandName) => (
38+
<MenuItem
39+
key={commandName}
40+
text={commandName}
41+
onClick={() => onSelect(commandName)}
42+
/>
43+
))}
44+
</Menu>
45+
}
46+
position={Position.BOTTOM_LEFT}
47+
>
48+
<Button icon="cog" small minimal title="Commands" />
49+
</Popover>
50+
);
51+
};

0 commit comments

Comments
 (0)