Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions src/model/Folder/Folder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 4 additions & 2 deletions src/model/file/FolderMetaDataFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
56 changes: 54 additions & 2 deletions src/other/crossPlatformUtilities.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,61 @@
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", () => {
expect(normalizePath("c:\\foo\\bar")).toBe("c:/foo/bar");
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"), "<file></file>");
// 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"]);
});
});
21 changes: 20 additions & 1 deletion src/other/crossPlatformUtilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "._<originalname>" 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,
Expand All @@ -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
Expand Down