diff --git a/src/model/Folder/Folder.ts b/src/model/Folder/Folder.ts
index f4a60859..997dc5b2 100644
--- a/src/model/Folder/Folder.ts
+++ b/src/model/Folder/Folder.ts
@@ -15,7 +15,8 @@ import { FieldSet } from "../field/FieldSet";
import assert from "assert";
import {
asyncTrash,
- getAllFilesSync
+ getAllFilesSync,
+ isMacOSMetadataFile
} from "../../other/crossPlatformUtilities";
import { EncounteredVocabularyRegistry } from "../Project/EncounteredVocabularyRegistry";
import { CopyManager, getExtension } from "../../other/CopyManager";
@@ -566,7 +567,12 @@ export abstract class Folder {
}
private findZombieMetadataFiles(directory: string, extension: string) {
const dir = fs.readdirSync(directory);
- return dir.filter((f) => f.match(new RegExp(`.*(${extension})$`, "ig")));
+ return dir.filter(
+ (f) =>
+ // ignore macOS AppleDouble sidecars (._*) — a binary "._Foo.session"
+ // must never be renamed into place as the real metadata file (issue #68)
+ !isMacOSMetadataFile(f) && f.match(new RegExp(`.*(${extension})$`, "ig"))
+ );
}
public saveAllFilesInFolder(beforeRename: boolean = false) {
if (this.beingDeleted) {
@@ -605,14 +611,19 @@ export abstract class Folder {
if (fs.existsSync(this.directory)) {
// will sometimes be false for things like DescriptionDocuments
const dir = fs.readdirSync(this.directory);
- const x = dir.filter((elm) =>
- // about ^(?!~)
- // opening a file in ms word creates a hidden file that starts with ~
- // the fact that it's open with Word is probably going to cause problems,
- // but this temp file is not a problem, don't want to give a misleading error message.
- elm.match(
- new RegExp(`^(?!~).*(${this.metadataFileExtensionWithDot})$`, "ig")
- )
+ const x = dir.filter(
+ (elm) =>
+ // ignore macOS AppleDouble sidecars (._*) so copying a project to a
+ // non-HFS volume doesn't trigger a spurious "multiple project files"
+ // warning (issue #68)
+ !isMacOSMetadataFile(elm) &&
+ // about ^(?!~)
+ // opening a file in ms word creates a hidden file that starts with ~
+ // the fact that it's open with Word is probably going to cause problems,
+ // but this temp file is not a problem, don't want to give a misleading error message.
+ elm.match(
+ new RegExp(`^(?!~).*(${this.metadataFileExtensionWithDot})$`, "ig")
+ )
);
if (x.length > 1) {
NotifyMultipleProjectFiles(
diff --git a/src/model/file/FolderMetaDataFile.ts b/src/model/file/FolderMetaDataFile.ts
index 58714173..b836786b 100644
--- a/src/model/file/FolderMetaDataFile.ts
+++ b/src/model/file/FolderMetaDataFile.ts
@@ -9,6 +9,7 @@ import {
prepareGlobalFieldDefinitionCatalog
} from "../field/ConfiguredFieldDefinitions";
import { PatientFS } from "../../other/patientFile";
+import { isMacOSMetadataFile } from "../../other/crossPlatformUtilities";
// project, sessions, and person folders have a single metadata file describing their contents, and this ends
// in a special extension (.sprj, .session, .person)
@@ -28,8 +29,9 @@ export class FolderMetadataFile extends File {
if (!fs.existsSync(metadataPath)) {
// does a file exist here with the right extension but the wrong name?
const files = fs.readdirSync(directory);
- const possibleFiles = files.filter((f) =>
- f.endsWith(fileExtensionForMetadata)
+ const possibleFiles = files.filter(
+ (f) =>
+ f.endsWith(fileExtensionForMetadata) && !isMacOSMetadataFile(f)
);
if (possibleFiles.length > 0) {
// use the first one. The next save will try to sort out the names
diff --git a/src/other/crossPlatformUtilities.spec.ts b/src/other/crossPlatformUtilities.spec.ts
index 11b7cf46..713d2a3b 100644
--- a/src/other/crossPlatformUtilities.spec.ts
+++ b/src/other/crossPlatformUtilities.spec.ts
@@ -1,5 +1,12 @@
-import { vi, describe, it, beforeAll, beforeEach, expect } from "vitest";
-import { normalizePath } from "./crossPlatformUtilities";
+import { describe, it, afterEach, expect } from "vitest";
+import * as fs from "fs-extra";
+import * as Path from "path";
+import * as os from "os";
+import {
+ normalizePath,
+ isMacOSMetadataFile,
+ getAllFilesSync
+} from "./crossPlatformUtilities";
describe("Linked file", () => {
it("normalizePath converts to forward slashes", () => {
@@ -7,3 +14,48 @@ describe("Linked file", () => {
expect(normalizePath("foo/bar\\baz")).toBe("foo/bar/baz");
});
});
+
+describe("isMacOSMetadataFile", () => {
+ it("detects AppleDouble sidecar files", () => {
+ expect(isMacOSMetadataFile("._foo.jpg")).toBe(true);
+ expect(isMacOSMetadataFile("._foo.jpg.meta")).toBe(true);
+ expect(isMacOSMetadataFile("._MySession.session")).toBe(true);
+ });
+ it("detects .DS_Store", () => {
+ expect(isMacOSMetadataFile(".DS_Store")).toBe(true);
+ });
+ it("uses the basename so full paths work", () => {
+ expect(isMacOSMetadataFile("/some/dir/._foo.jpg.meta")).toBe(true);
+ expect(isMacOSMetadataFile("C:\\some\\dir\\.DS_Store")).toBe(true);
+ expect(isMacOSMetadataFile("C:\\some\\dir\\foo.jpg")).toBe(false);
+ });
+ it("does not flag normal files", () => {
+ expect(isMacOSMetadataFile("foo.jpg")).toBe(false);
+ expect(isMacOSMetadataFile("foo.jpg.meta")).toBe(false);
+ expect(isMacOSMetadataFile("MySession.session")).toBe(false);
+ // a leading single dot (hidden but not AppleDouble) is not our concern here
+ expect(isMacOSMetadataFile(".gitignore")).toBe(false);
+ });
+});
+
+describe("getAllFilesSync", () => {
+ let dir: string;
+ afterEach(() => {
+ if (dir) fs.removeSync(dir);
+ });
+
+ it("skips macOS AppleDouble and .DS_Store files (issue #68)", () => {
+ dir = fs.mkdtempSync(Path.join(os.tmpdir(), "lameta-issue68-"));
+ fs.writeFileSync(Path.join(dir, "foo.jpg"), "content");
+ fs.writeFileSync(Path.join(dir, "foo.jpg.meta"), "");
+ // the junk macOS creates on non-HFS volumes:
+ fs.writeFileSync(Path.join(dir, "._foo.jpg"), "binary");
+ fs.writeFileSync(Path.join(dir, "._foo.jpg.meta"), "binary");
+ fs.writeFileSync(Path.join(dir, ".DS_Store"), "binary");
+
+ const names = getAllFilesSync(dir)
+ .map((p) => Path.basename(p))
+ .sort();
+ expect(names).toEqual(["foo.jpg", "foo.jpg.meta"]);
+ });
+});
diff --git a/src/other/crossPlatformUtilities.ts b/src/other/crossPlatformUtilities.ts
index 70eddc9b..e22a0cb7 100644
--- a/src/other/crossPlatformUtilities.ts
+++ b/src/other/crossPlatformUtilities.ts
@@ -111,6 +111,20 @@ function globSyncSaferAndLimited(pattern: string): string[] {
return globSync(p);
}
+// Detects macOS filesystem junk files that must never be treated as project
+// content. When a project is copied to a non-HFS+ volume (e.g. an external
+// SSD, a FAT/exFAT drive, or a network share), macOS creates AppleDouble
+// sidecar files named "._" to preserve resource forks and
+// extended attributes, plus ".DS_Store" for Finder view settings. These are
+// binary, not XML. If lameta scans them it can, for example, find the
+// AppleDouble companion of a "foo.jpg.meta" file ("._foo.jpg.meta") and try
+// to parse it as XML, producing "Non-whitespace before first tag." See
+// https://github.com/onset/lameta/issues/68.
+export function isMacOSMetadataFile(fileName: string): boolean {
+ const base = Path.basename(fileName);
+ return base.startsWith("._") || base === ".DS_Store";
+}
+
// PERFORMANCE: Replaced glob-based implementation with fs.readdirSync
// Glob was taking 5800ms for 89 directories, fs.readdirSync takes ~2ms
// The original used "*.*" pattern which only matches files with extensions,
@@ -119,7 +133,12 @@ export function getAllFilesSync(directory: string): string[] {
try {
const entries = fs.readdirSync(directory, { withFileTypes: true });
return entries
- .filter((entry) => entry.isFile() && entry.name.includes("."))
+ .filter(
+ (entry) =>
+ entry.isFile() &&
+ entry.name.includes(".") &&
+ !isMacOSMetadataFile(entry.name)
+ )
.map((entry) => Path.join(directory, entry.name));
} catch (err: any) {
// If the directory doesn't exist or can't be read, return empty array