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