diff --git a/assets/cloud.svg b/assets/cloud.svg new file mode 100644 index 00000000..2e06424f --- /dev/null +++ b/assets/cloud.svg @@ -0,0 +1,3 @@ + + + diff --git a/e2e/cloudSync.e2e.ts b/e2e/cloudSync.e2e.ts new file mode 100644 index 00000000..dee04ec5 --- /dev/null +++ b/e2e/cloudSync.e2e.ts @@ -0,0 +1,150 @@ +import { test, expect, Page } from "@playwright/test"; +import * as fs from "fs"; +import * as Path from "path"; +import { LametaE2ERunner } from "./lametaE2ERunner"; +import { createNewProject, E2eProject } from "./various-e2e-helpers"; + +// Exercises the cloud-sync UI (row status icon, " Status" card, +// hydrate button) end to end using the e2e-controllable FakeCloudFileProvider +// (see src/other/fakeCloudProvider.ts) instead of a real OneDrive/Dropbox/ +// iCloud account -- so this test runs the same way on any machine/OS. +// +// The fake provider reads its manifest (which file(s) are cloud-only, and +// which folder is the fake sync root) from the JSON file named by +// E2E_FAKE_CLOUD_PROVIDER, and it loads that manifest exactly once, on first +// use -- which happens as soon as the app constructs its very first File +// object (the project's own metadata file), well before we've created a +// session or added a file through the UI. So the manifest has to name the +// media file's FINAL on-disk path before the app is even launched. +// +// Project.addSession() names a brand-new project's first session folder +// deterministically: sanitizeForArchive("New Session") under the default +// ("ASCII") file-naming rules turns spaces into underscores, giving +// "New_Session", and getUniqueFolder() will use that name as-is since +// nothing else has claimed it yet in a fresh project. That lets us predict +// the session folder -- and therefore the added media file's absolute path +// -- before launch. +let lameta: LametaE2ERunner; +let page: Page; +let project: E2eProject; + +const projectName = "CloudSyncE2E"; +const mediaFileName = "cloud-test-audio.mp3"; + +let projectDirectory: string; +let sessionDirectory: string; +let mediaFilePath: string; +let manifestPath: string; + +test.describe("Cloud sync UI (fake provider)", () => { + test.beforeAll(async ({}) => { + // process.env.E2ERoot is set once, for the whole Playwright run, by + // e2e/globalSetup.ts -- see various-e2e-helpers.ts for the same + // assumption (E2eProject.createNewProject computes projectDirectory the + // same way). + const e2eRoot = process.env.E2ERoot!; + projectDirectory = Path.join(e2eRoot, "lameta", projectName); + sessionDirectory = Path.join(projectDirectory, "Sessions", "New_Session"); + mediaFilePath = Path.join(sessionDirectory, mediaFileName); + + manifestPath = Path.join(e2eRoot, "fake-cloud-manifest.json"); + fs.writeFileSync( + manifestPath, + JSON.stringify({ + syncRoots: [{ path: projectDirectory, providerName: "FakeDrive" }], + cloudOnly: [mediaFilePath], + hydrateDelayMs: 500 + }) + ); + // Must be set before launch(): LametaE2ERunner.launch() spreads + // process.env into the Electron process's env at spawn time. + process.env.E2E_FAKE_CLOUD_PROVIDER = manifestPath; + + lameta = new LametaE2ERunner(); + page = await lameta.launch(); + await lameta.cancelRegistration(); + project = await createNewProject(lameta, projectName); + + // Fail fast with a clear message if the project directory didn't land + // where predicted (e.g. a duplicate-name retry appended a suffix) + // instead of a confusing failure later on. + expect(project.projectDirectory).toBe(projectDirectory); + }); + + test.afterAll(async ({}) => { + delete process.env.E2E_FAKE_CLOUD_PROVIDER; + await lameta.quit(); + }); + + test("shows the fake provider's cloud status, lets the user fetch the file, and clears once hydrated", async ({}) => { + await project.goToSessions(); + await project.addSession(); + + // (a) Add a real media file -- its bytes are real and readable the whole + // time; only the STATUS lameta reads is faked (see fakeCloudProvider.ts). + const sourceMp3 = Path.join( + process.cwd(), + "sample data", + "Edolo sample", + "Sessions", + "ETR009", + "ETR009_Careful.mp3" + ); + if (!fs.existsSync(sourceMp3)) { + throw new Error(`Source sample mp3 not found at ${sourceMp3}`); + } + const stagedSource = Path.join(process.env.E2ERoot!, mediaFileName); + fs.copyFileSync(sourceMp3, stagedSource); + + await lameta.mockShowOpenDialog([stagedSource]); + await page.getByRole("button", { name: "Add Files" }).click(); + + const fileCell = page.getByRole("gridcell", { name: mediaFileName }); + await expect(fileCell).toBeVisible({ timeout: 10000 }); + + // Sanity check our predicted path actually matches where lameta put the + // file -- if this fails, the manifest named the wrong path and every + // assertion below would otherwise fail for a confusing reason. The row + // can appear before the background copy finishes (a known race -- see + // the comments in FileList-e2e-helpers.ts's addFile()), so poll instead + // of asserting once. + await expect + .poll(() => fs.existsSync(mediaFilePath), { timeout: 10000 }) + .toBe(true); + + // (b) The row shows the "online-only" cloud status icon (an svg with + // role="img" whose accessible name comes from its ; see + // CloudStatusIcon.tsx). + const fileRow = page.getByRole("row").filter({ has: fileCell }); + await expect(fileRow.getByRole("img", { name: /Online-only/ })).toBeVisible( + { timeout: 10000 } + ); + + // (c) Selecting the file shows the "FakeDrive Status" card with a + // "Download this file to my computer" button instead of a preview + // (CloudFileFetchControl renders this whenever canPin is false and the + // file isCloudFileNotPresent; see CloudFilePanel.tsx). + await fileCell.click(); + const statusHeading = page.getByRole("heading", { + name: "FakeDrive Status" + }); + await expect(statusHeading).toBeVisible({ timeout: 10000 }); + const downloadButton = page.getByRole("button", { + name: "Download this file to my computer" + }); + await expect(downloadButton).toBeVisible(); + + // (d) Clicking it asks the fake provider to hydrate the file; after + // hydrateDelayMs (500ms) the fake reports "local" and, once the + // polling reads that (every 1.5s, see cloudFilePoller.ts), the card + // goes away because the file is no longer isCloudFileNotPresent. + await downloadButton.click(); + await expect(statusHeading).not.toBeVisible({ timeout: 10000 }); + + // The row's "online-only" icon is gone too (a local file shows no cloud + // icon at all, per CloudStatusIcon.tsx's current design). + await expect( + fileRow.getByRole("img", { name: /Online-only/ }) + ).not.toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/e2e/renameContention.e2e.ts b/e2e/renameContention.e2e.ts new file mode 100644 index 00000000..fc78de75 --- /dev/null +++ b/e2e/renameContention.e2e.ts @@ -0,0 +1,172 @@ +import fs from "fs"; +import * as Path from "path"; +import { spawn, ChildProcess } from "child_process"; +import { Page, test, expect } from "@playwright/test"; +import { LametaE2ERunner } from "./lametaE2ERunner"; +import { createNewProject, E2eProject } from "./various-e2e-helpers"; +import { E2eFileList } from "./FileList-e2e-helpers"; + +/* + Renaming a person/session renames its folder and the files inside — the + operation most at risk from other processes (antivirus, sync clients, + indexers) holding transient locks. These tests verify, through the real app, + the PatientFS contract: + + - while another process holds a file in the folder WITHOUT delete-sharing + (which on Windows blocks renaming the folder), a rename attempt must fail + CLEANLY: everything keeps its old name, nothing half-renamed; + - once the lock is gone, the rename must succeed completely. + + Windows-only: on POSIX an open handle doesn't block renames, so the + contention class doesn't exist there. +*/ + +let lameta: LametaE2ERunner; +let page: Page; +let project: E2eProject; +let fileList: E2eFileList; + +test.describe("Rename under file-lock contention", () => { + test.skip(process.platform !== "win32", "Windows-only contention semantics"); + // the failed rename alone spends ~10s in the PatientFS retry loop + test.setTimeout(180_000); + + test.beforeEach(async () => { + lameta = new LametaE2ERunner(); + page = await lameta.launch(); + await lameta.cancelRegistration(); + project = await createNewProject( + lameta, + "RenameContention" + Math.random().toString() + ); + fileList = new E2eFileList(lameta, page, project.projectDirectory); + }); + test.afterEach(async () => { + await lameta.quit(); + }); + + test("rename fails cleanly while a file is locked, then succeeds after release", async () => { + await project.goToPeople(); + await project.addPerson(); + await setFullName("Paul Hewson"); + await page.waitForTimeout(1000); + + const personDir = Path.join( + project.projectDirectory, + "People", + "Paul_Hewson" + ); + expect(fs.existsSync(Path.join(personDir, "Paul_Hewson.person"))).toBe( + true + ); + const mediaPath = Path.join(personDir, "Paul_Hewson_foo.txt"); + await fileList.addFile("Paul_Hewson_foo.txt", { page, path: mediaPath }); + await fileList.selectFile("Paul_Hewson.person"); + + // another process opens the media file with no delete-sharing: renaming + // the file fails with EBUSY and renaming the folder fails with EPERM + const holder = await holdFileOpen(mediaPath, 90); + try { + const filesBefore = fs.readdirSync(personDir).sort(); + + // this triggers the rename attempt; the app spends a PatientFS retry + // budget (~10s) on the locked file, another on the folder itself, then + // gives up and rolls the file renames back + await setFullName("Bono", 60_000); + + // first, prove the rename attempt really started: some file transiently + // carries the new name (the mid-flight window lasts ~20s, poll can't + // miss it). Without this gate the next poll could pass trivially before + // the app even began. + await expect + .poll( + () => + fs.existsSync(personDir) + ? fs.readdirSync(personDir).some((f) => f.startsWith("Bono")) + : false, + { + message: + "the rename attempt never started (no file transiently got the new name)", + timeout: 30_000 + } + ) + .toBe(true); + + // then poll until the folder is back to exactly its old contents + await expect + .poll( + () => + fs.existsSync(personDir) ? fs.readdirSync(personDir).sort() : [], + { + message: + "after a failed rename, the folder must return to exactly its old contents (rollback)", + timeout: 60_000 + } + ) + .toEqual(filesBefore); + expect( + fs.existsSync( + Path.join(project.projectDirectory, "People", "Bono") + ), + "no folder with the new name may exist after a failed rename" + ).toBe(false); + } finally { + holder.kill(); + } + // let the OS release the handle + await page.waitForTimeout(1000); + + // now the same rename must go through completely + await setFullName("Ali G", 60_000); + await page.waitForTimeout(2000); + + const newDir = Path.join(project.projectDirectory, "People", "Ali_G"); + expect(fs.existsSync(newDir), "renamed folder must exist").toBe(true); + expect(fs.existsSync(personDir), "old folder must be gone").toBe(false); + expect(fs.existsSync(Path.join(newDir, "Ali_G.person"))).toBe(true); + expect(fs.existsSync(Path.join(newDir, "Ali_G_foo.txt"))).toBe(true); + expect( + fs + .readdirSync(newDir) + .filter((f) => f.startsWith("Paul_Hewson") || f.startsWith("Bono")), + "no files with stale names may remain" + ).toEqual([]); + }); +}); + +// Open a file from a separate process with FileShare.ReadWrite (no Delete), +// the way scanners/sync clients do. Resolves once the handle is held. +function holdFileOpen(file: string, seconds: number): Promise<ChildProcess> { + const ps = ` + $h = [System.IO.File]::Open('${file.replace(/'/g, "''")}', + [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, + [System.IO.FileShare]::ReadWrite); + [Console]::Out.WriteLine('HELD'); + Start-Sleep -Seconds ${seconds}; + $h.Close(); + `; + const child = spawn("powershell", ["-NoProfile", "-Command", ps], { + stdio: ["ignore", "pipe", "inherit"] + }); + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("file holder never became ready")), + 15000 + ); + child.stdout!.on("data", (d) => { + if (d.toString().includes("HELD")) { + clearTimeout(timer); + resolve(child); + } + }); + }); +} + +async function setFullName(name: string, timeout = 5000) { + const fullNameField = page.getByTestId("field-name-edit"); + await fullNameField.waitFor({ state: "visible", timeout }); + await expect(fullNameField).not.toBeEmpty({ timeout }); + await fullNameField.click({ clickCount: 3, timeout }); + await page.keyboard.type(name); + await fullNameField.press("Tab", { timeout }); +} diff --git a/e2e/twoUserSync.e2e.ts b/e2e/twoUserSync.e2e.ts new file mode 100644 index 00000000..250fc7b9 --- /dev/null +++ b/e2e/twoUserSync.e2e.ts @@ -0,0 +1,243 @@ +import fs from "fs"; +import * as Path from "path"; +import { Page, test, expect } from "@playwright/test"; +import { LametaE2ERunner } from "./lametaE2ERunner"; +import { createNewProject, E2eProject } from "./various-e2e-helpers"; + +/* + EXPERIMENT (opt-in): what happens when two people share a lameta project via + a cloud-sync service (OneDrive/Dropbox/...) and both have lameta open? + + From the point of view of a running lameta instance on machine B, everything + user A does arrives as EXTERNAL filesystem changes made by the sync client + while the project is open. We simulate exactly that: launch the real app, + then mutate/rename/delete project files behind its back and observe what the + app does next. No cloud account needed — the sync client's local behavior is + just file I/O. + + These tests LOG findings (grep the output for TWOUSER-FINDING); assertions + pin only what we're confident of. Run with: + + LAMETA_EXPERIMENT=1 npx playwright test twoUserSync +*/ + +const experimentEnabled = !!process.env.LAMETA_EXPERIMENT; + +let lameta: LametaE2ERunner; +let page: Page; +let project: E2eProject; + +function sessionDir(id: string) { + return Path.join(project.projectDirectory, "Sessions", id); +} +function sessionFile(id: string) { + return Path.join(sessionDir(id), `${id}.session`); +} +function logFinding(msg: string) { + console.log(`TWOUSER-FINDING: ${msg}`); +} + +async function createSession(id: string, baselineNote: string) { + await project.goToSessions(); + await project.addSession(); + const idField = page.getByTestId("field-id-edit"); + await idField.waitFor({ state: "visible" }); + await idField.click(); + await idField.fill(id); + await page.keyboard.press("Tab"); + await typeIntoNotes(baselineNote); + // back to the main session form + await page.getByRole("tab", { name: "Session", exact: true }).first().click(); + await page.waitForTimeout(300); +} + +async function setLocation(text: string) { + const locationField = page.getByTestId("field-location-edit"); + await locationField.waitFor({ state: "visible" }); + await locationField.click(); + await locationField.fill(text); + await page.keyboard.press("Tab"); + await page.waitForTimeout(300); +} + +async function selectSession(id: string) { + await page.getByRole("gridcell", { name: id }).first().click(); + await page.waitForTimeout(300); +} + +async function typeIntoNotes(text: string) { + await project.goToNotesOfThisSession(); + const notes = page.getByTestId("field-notes-edit"); + await notes.waitFor({ state: "visible" }); + await notes.click(); + await page.keyboard.type(text); + await page.keyboard.press("Tab"); + await page.waitForTimeout(300); +} + +test.describe("Two-user cloud-sync hazards (experiment)", () => { + test.skip(!experimentEnabled, "opt-in experiment: set LAMETA_EXPERIMENT=1"); + test.setTimeout(180_000); + + // Fresh app per test: a remotely-vanished session wounds the running app + // (the session-detail pane stops rendering until restart — itself one of the + // findings), so scenarios can't share an instance. + test.beforeEach(async () => { + lameta = new LametaE2ERunner(); + page = await lameta.launch(); + await lameta.cancelRegistration(); + project = await createNewProject(lameta, `TwoUserSync_${Date.now()}`); + // a "parking" session we can select to force the previous one to save + await createSession("parking", "parking lot note"); + }); + test.afterEach(async () => { + await lameta.quit(); + }); + + test("stale-overwrite: does user B's save clobber user A's synced-down edit?", async () => { + await createSession("stalecase", "original note text"); + await selectSession("parking"); // forces save of stalecase + await expect + .poll(() => fs.existsSync(sessionFile("stalecase")), { timeout: 10_000 }) + .toBe(true); + const savedXml = fs.readFileSync(sessionFile("stalecase"), "utf8"); + expect(savedXml).toContain("original note text"); + + // user A edits the notes on their machine; the change syncs down to B's + // disk while B's lameta still holds the session in memory + fs.writeFileSync( + sessionFile("stalecase"), + savedXml.replace("original note text", "REMOTE EDIT FROM USER A") + ); + + // B looks at the session: does the UI show A's edit or the stale memory? + await selectSession("stalecase"); + await project.goToNotesOfThisSession(); + const shownNotes = await page.getByTestId("field-notes-edit").innerText(); + logFinding( + `after remote edit synced down, B's UI shows notes: "${shownNotes.trim()}"` + ); + await page.getByRole("tab", { name: "Session", exact: true }).first().click(); + + // B edits a DIFFERENT field (location) and it saves + await setLocation("location typed by user B"); + await selectSession("parking"); // forces save of stalecase + + await expect + .poll( + () => fs.readFileSync(sessionFile("stalecase"), "utf8"), + { timeout: 10_000 } + ) + .toContain("location typed by user B"); + const finalXml = fs.readFileSync(sessionFile("stalecase"), "utf8"); + const remoteEditSurvived = finalXml.includes("REMOTE EDIT FROM USER A"); + + // With the freshness check in File.save(), B's stale write no longer + // clobbers A's synced-down edit: before writing its own version to the + // canonical name, lameta moves the externally-changed file aside to a + // sibling. Look for that set-aside copy and confirm A's edit is preserved + // in it (the canonical file now holds B's location edit). + const setAside = fs + .readdirSync(sessionDir("stalecase")) + .filter((f) => f.includes("changed on another computer")); + const setAsideHoldsRemoteEdit = setAside.some((f) => + fs + .readFileSync(Path.join(sessionDir("stalecase"), f), "utf8") + .includes("REMOTE EDIT FROM USER A") + ); + const nothingLost = remoteEditSurvived || setAsideHoldsRemoteEdit; + logFinding( + `after B saved another field: canonical file still holds A's edit? ${remoteEditSurvived}; set-aside sibling(s) preserving A's edit: [${setAside.join( + ", " + )}]` + ); + logFinding( + nothingLost + ? `user A's remote edit was NOT lost (preserved ${ + setAsideHoldsRemoteEdit ? "in a set-aside sibling" : "in place" + }); B's own edit is in the canonical file` + : "user A's remote edit was SILENTLY REVERTED by user B's save (stale in-memory state written over it, no set-aside copy)" + ); + // B's own edit must have landed in the canonical file regardless. + expect(finalXml).toContain("location typed by user B"); + }); + + test("remote delete: user A deleted the session; B edits it afterwards", async () => { + await createSession("delcase", "Delete Victim"); + await selectSession("parking"); + await expect + .poll(() => fs.existsSync(sessionFile("delcase")), { timeout: 10_000 }) + .toBe(true); + + // user A deletes the session; sync engine removes it on B's disk + fs.rmSync(sessionDir("delcase"), { recursive: true, force: true }); + + // B (list still shows it) selects it and tries to edit + await selectSession("delcase"); + try { + await typeIntoNotes("edit after remote delete"); + logFinding("after remote delete, B could still type into the session"); + } catch (e) { + logFinding( + `after remote delete, B's UI broke trying to edit the session: ${String(e).split("\n")[0]}` + ); + } + try { + await selectSession("parking"); + } catch (e) { + logFinding( + `after remote delete, B could not even select another session: ${String(e).split("\n")[0]}` + ); + } + await page.waitForTimeout(2000); + + const recreated = fs.existsSync(sessionFile("delcase")); + logFinding( + recreated + ? "B's save RESURRECTED the deleted session (it will sync back to user A)" + : `B's save did not resurrect the deleted session; dir exists: ${fs.existsSync(sessionDir("delcase"))}` + ); + }); + test("remote rename: user A renamed the session; B edits it afterwards", async () => { + await createSession("renbefore", "Rename Victim"); + await selectSession("parking"); + await expect + .poll(() => fs.existsSync(sessionFile("renbefore")), { timeout: 10_000 }) + .toBe(true); + + // user A renames the session id on their machine → folder + files rename, + // and the sync engine applies that on B's disk: + fs.renameSync( + sessionFile("renbefore"), + Path.join(sessionDir("renbefore"), "renafter.session") + ); + fs.renameSync(sessionDir("renbefore"), sessionDir("renafter")); + // (contents of renafter.session still say id "renbefore"; on A's machine + // it would say "renafter" — patch that too for realism) + const xml = fs.readFileSync(sessionFile("renafter"), "utf8"); + fs.writeFileSync(sessionFile("renafter"), xml.replace(/renbefore/g, "renafter")); + + // B (whose list still shows the old id) selects it and edits notes + await selectSession("renbefore"); + await typeIntoNotes("edit after remote rename"); + await selectSession("parking"); + await page.waitForTimeout(2000); + + const sessionsRoot = Path.join(project.projectDirectory, "Sessions"); + const tree = fs + .readdirSync(sessionsRoot) + .map( + (d) => + `${d}: [${fs.readdirSync(Path.join(sessionsRoot, d)).join(", ")}]` + ) + .join(" | "); + logFinding(`after remote rename + local edit, Sessions tree: ${tree}`); + const oldRecreated = fs.existsSync(sessionDir("renbefore")); + logFinding( + oldRecreated + ? "B's save RECREATED the old-named folder → project now has BOTH old and new folders; when this syncs back, user A sees a duplicated session" + : "B's save did not recreate the old-named folder" + ); + }); + +}); diff --git a/e2e/vanishedSession.e2e.ts b/e2e/vanishedSession.e2e.ts new file mode 100644 index 00000000..47be0dbd --- /dev/null +++ b/e2e/vanishedSession.e2e.ts @@ -0,0 +1,102 @@ +import fs from "fs"; +import * as Path from "path"; +import { Page, test, expect } from "@playwright/test"; +import { LametaE2ERunner } from "./lametaE2ERunner"; +import { createNewProject, E2eProject } from "./various-e2e-helpers"; + +/* + Regression: a session folder/metadata file that vanishes from disk EXTERNALLY + while the project is open (a collaborator renamed/deleted it on another machine + and OneDrive/Dropbox applied it locally; or someone deleted it in Explorer) + must not wound the running app. Concretely: + + (symptom 1) a stale save must NOT spam a raw ENOENT error toast on every save + trigger (window blur, selection change). It should fail softly. + (symptom 2) the session-detail pane must keep rendering for OTHER sessions -- + including a brand-new one made with the New Session button. + + Unlike twoUserSync.e2e.ts (which relaunches the app per scenario and so cannot + observe the wound persisting), this test keeps ONE app instance so it pins the + "does the wound carry into the next session?" symptom. +*/ + +let lameta: LametaE2ERunner; +let page: Page; +let project: E2eProject; + +// Raw file.save() error toasts seen in the renderer console during the test. +let rawSaveErrors: string[] = []; + +function sessionDir(id: string) { + return Path.join(project.projectDirectory, "Sessions", id); +} + +async function createSession(id: string) { + await project.goToSessions(); + await project.addSession(); + const idField = page.getByTestId("field-id-edit"); + await idField.waitFor({ state: "visible" }); + await idField.click(); + await idField.fill(id); + await page.keyboard.press("Tab"); + await page.waitForTimeout(300); +} + +async function selectSession(id: string) { + await page.getByRole("gridcell", { name: id }).first().click(); + await page.waitForTimeout(300); +} + +test.describe("vanished session must not wound the app", () => { + test.setTimeout(120_000); + + test.beforeEach(async () => { + lameta = new LametaE2ERunner(); + page = await lameta.launch(); + rawSaveErrors = []; + // Watch for the raw, per-trigger "(file.save)" error that symptom 1 spammed. + page.on("console", (msg) => { + if (msg.type() === "error" && msg.text().includes("(file.save)")) { + rawSaveErrors.push(msg.text()); + } + }); + await lameta.cancelRegistration(); + project = await createNewProject(lameta, `Vanished_${Date.now()}`); + }); + test.afterEach(async () => { + await lameta.quit(); + }); + + test("after a session vanishes and saves fail, a NEW session still renders and there is no raw error spam", async () => { + await createSession("keeper"); + await createSession("victim"); + // force victim to save so its folder/files exist on disk + await selectSession("keeper"); + await expect + .poll(() => fs.existsSync(sessionDir("victim")), { timeout: 10_000 }) + .toBe(true); + + // The session vanishes from disk behind the app's back. + fs.rmSync(sessionDir("victim"), { recursive: true, force: true }); + + // The user (whose list still shows it) selects it -- this renders the + // detail pane against a File whose on-disk file is now gone -- then moves + // back and forth several times, each of which triggers a (failing) save of + // the stale victim. Before the fix, each of these popped a fresh raw toast. + await selectSession("victim"); + await selectSession("keeper"); + await selectSession("victim"); + await selectSession("keeper"); + + // symptom 2: a brand-new session must still render its detail form. + await project.goToSessions(); + await project.addSession(); + const idField = page.getByTestId("field-id-edit"); + await idField.waitFor({ state: "visible", timeout: 15_000 }); + await expect(idField).toBeVisible(); + + // symptom 1: the stale saves must NOT have produced raw "(file.save)" + // error toasts (the fix warns softly, at most once, via a different path). + expect(rawSaveErrors).toHaveLength(0); + }); +}); diff --git a/electron-builder.json5 b/electron-builder.json5 index 02bc84d5..682f8a9d 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -56,6 +56,7 @@ "!node_modules/ffmpeg-ffprobe-static/bin/*/ffmpeg*" ], extraFiles: [], + asarUnpack: ["**/node_modules/fswin/**"], directories: { buildResources: "resources", output: "release" diff --git a/locale/en/fields.po b/locale/en/fields.po index 2771eeee..3cd386fc 100644 --- a/locale/en/fields.po +++ b/locale/en/fields.po @@ -206,6 +206,22 @@ msgctxt "lameta/project/location" msgid "Location/Address" msgstr "" +msgctxt "lameta/project/metadataLanguages" +msgid "Metadata Languages" +msgstr "" + +msgctxt "lameta/project/metadataLanguages/description" +msgid "Languages used in metadata fields throughout the project (e.g., descriptions, titles). These determine the language options available for multilingual text fields." +msgstr "" + +msgctxt "lameta/project/multilingualConversionPending" +msgid "Multilingual Conversion Pending" +msgstr "" + +msgctxt "lameta/project/multilingualConversionPending/description" +msgid "Internal flag: true if slash-syntax fields need conversion to tagged format" +msgstr "" + msgctxt "lameta/project/region" msgid "Region" msgstr "" diff --git a/locale/en/messages.po b/locale/en/messages.po index 7ec0c00b..7c2dbbb5 100644 --- a/locale/en/messages.po +++ b/locale/en/messages.po @@ -40,6 +40,9 @@ msgstr "" msgid "About lameta" msgstr "" +msgid "Access" +msgstr "" + msgid "Add Files" msgstr "" @@ -58,6 +61,15 @@ msgstr "" msgid "All Sessions" msgstr "" +msgid "All translations complete" +msgstr "" + +msgid "Always" +msgstr "" + +msgid "Always available on this device." +msgstr "" + msgid "Archive" msgstr "" @@ -77,6 +89,15 @@ msgstr "" msgid "Audio" msgstr "" +msgid "Automatically make cloud files available if smaller than" +msgstr "" + +msgid "Available after this file is made available on this device" +msgstr "" + +msgid "Available on this device. {providerName} may free up this space if the file goes unused." +msgstr "" + msgid "CSV export complete" msgstr "" @@ -89,6 +110,9 @@ msgstr "" msgid "Cannot add files of that type" msgstr "" +msgid "Cannot export to IMDI while multilingual text migration is pending." +msgstr "" + msgid "Change Name To:" msgstr "" @@ -116,6 +140,9 @@ msgstr "" msgid "Close" msgstr "" +msgid "Cloud" +msgstr "" + msgid "Collection" msgstr "" @@ -152,6 +179,9 @@ msgstr "" msgid "Could not find this file. Restart lameta to bring it up to date with what is actually on your hard drive. {path}" msgstr "" +msgid "Could not read media information for this file." +msgstr "" + msgid "Create" msgstr "" @@ -176,6 +206,9 @@ msgstr "" msgid "Custom Field" msgstr "" +msgid "Custom vocabulary item" +msgstr "" + msgid "Cut" msgstr "" @@ -209,6 +242,9 @@ msgstr "" msgid "Description Documents" msgstr "" +msgid "Dismiss" +msgstr "" + msgid "Do not quit" msgstr "" @@ -221,6 +257,9 @@ msgstr "" msgid "Download" msgstr "" +msgid "Download this file to my computer" +msgstr "" + msgid "Duplicate Person" msgstr "" @@ -230,9 +269,15 @@ msgstr "" msgid "Edit" msgstr "" +msgid "ElectronDropZone: preload script did not set window.electronAPI.getPathForFile; drag-and-drop will not work." +msgstr "" + msgid "Email Address" msgstr "" +msgid "Example:" +msgstr "" + msgid "Export" msgstr "" @@ -254,6 +299,9 @@ msgstr "" msgid "Field" msgstr "" +msgid "Fields with Unknown Languages" +msgstr "" + msgid "File" msgstr "" @@ -266,6 +314,15 @@ msgstr "" msgid "Find" msgstr "" +msgid "Finish Migration" +msgstr "" + +msgid "Finish Migration to Multilingual Fields" +msgstr "" + +msgid "Fix in Project:" +msgstr "" + msgid "Font Size" msgstr "" @@ -299,6 +356,12 @@ msgstr "" msgid "Generating RO-Crate metadata..." msgstr "" +msgid "Genres" +msgstr "" + +msgid "Getting project information from {0}..." +msgstr "" + msgid "Help" msgstr "" @@ -344,6 +407,12 @@ msgstr "" msgid "Importing..." msgstr "" +msgid "In cloud only, and this computer appears to be offline." +msgstr "" + +msgid "In cloud only, lameta cannot read it yet." +msgstr "" + msgid "Indicates that this is the father or mother's primary language." msgstr "" @@ -395,6 +464,15 @@ msgstr "" msgid "Media Folder Settings..." msgstr "" +msgid "Migrate" +msgstr "" + +msgid "Migrating: {0}" +msgstr "" + +msgid "Migration complete! {0} fields were migrated. You can hide language tags from View → \"Show language tags on multilingual fields\" if you don't need them anymore." +msgstr "" + msgid "Modified" msgstr "" @@ -404,18 +482,27 @@ msgstr "" msgid "Name" msgstr "" +msgid "Never" +msgstr "" + msgid "New Person" msgstr "" msgid "New Session" msgstr "" +msgid "No custom values found" +msgstr "" + msgid "Normally, when you add a file to a project, session, or person, that file is copied into the same folder as you lameta project. For some of your files, you might prefer to instead leave the files where they are and just 'link' to them." msgstr "" msgid "Not important to the archive, but will be included" msgstr "" +msgid "Note" +msgstr "" + msgid "Note that this is a link to a file in your Media Files directory. Here, you are renaming this link, not the original file that it points to. If you later create an export that includes this file, the export will use this name." msgstr "" @@ -431,6 +518,12 @@ msgstr "" msgid "One or more files are still being copied into your project." msgstr "" +msgid "Online-only ({providerName}), and this computer appears to be offline, so the file cannot be downloaded right now." +msgstr "" + +msgid "Online-only ({providerName}). The content of this file is not on this computer." +msgstr "" + msgid "Open" msgstr "" @@ -467,6 +560,12 @@ msgstr "" msgid "Please Register" msgstr "" +msgid "Please add the metadata languages you've been using with these \"/\" fields (at least two) before migrating." +msgstr "" + +msgid "Please complete the migration in the Project tab under Languages before exporting." +msgstr "" + msgid "Please remove characters that not allowed by the Archive Settings." msgstr "" @@ -494,6 +593,9 @@ msgstr "" msgid "RO-Crate export complete" msgstr "" +msgid "Ready to scan..." +msgstr "" + msgid "Redo" msgstr "" @@ -527,9 +629,18 @@ msgstr "" msgid "Restart lameta and do the rename before playing the video again." msgstr "" +msgid "Retry" +msgstr "" + +msgid "Retrying…" +msgstr "" + msgid "Role" msgstr "" +msgid "Roles" +msgstr "" + msgid "Save As" msgstr "" @@ -539,6 +650,15 @@ msgstr "" msgid "Saving before rename..." msgstr "" +msgid "Scan complete" +msgstr "" + +msgid "Scan failed" +msgstr "" + +msgid "Scanning..." +msgstr "" + msgid "Search" msgstr "" @@ -560,6 +680,12 @@ msgstr "" msgid "Show IMDI previews" msgstr "" +msgid "Show Language Tags" +msgstr "" + +msgid "Show Language Tags (locked during migration)" +msgstr "" + msgid "Show PARADISEC previews" msgstr "" @@ -587,6 +713,9 @@ msgstr "" msgid "Smaller" msgstr "" +msgid "Some fields have more language segments than there are Metadata Languages defined ({0} of {1} fields). If you proceed, these extra segments will be labeled as \"unknown\" languages." +msgstr "" + msgid "Some operating systems would not allow that name." msgstr "" @@ -599,9 +728,18 @@ msgstr "" msgid "Start Screen" msgstr "" +msgid "Starting scan..." +msgstr "" + msgid "Status" msgstr "" +msgid "Stop waiting" +msgstr "" + +msgid "Tell {providerName} that I want this file on my computer" +msgstr "" + msgid "Text" msgstr "" @@ -611,6 +749,9 @@ msgstr "" msgid "The IMDI files were validated." msgstr "" +msgid "The columns here are based on your project's metadata languages." +msgstr "" + msgid "The file is missing from its expected location in the Media Folder. The Media Folder is set to {m} and this file is supposed to be at {e}" msgstr "" @@ -650,9 +791,15 @@ msgstr "" msgid "This file does not comply with the file naming rules of the current archive." msgstr "" +msgid "This file is online-only ({providerName}). Select it to see how to make it available on this device." +msgstr "" + msgid "This is a link, but lameta does not know where to find the Media Folder for this project. See File:Media Folder Settings." msgstr "" +msgid "This project appears to use \"/\" to store multiple languages within a single field. In this version of lameta, we have a better approach in which lameta knows which languages are used in a field. After you have selected the appropriate metadata languages above and arranged them in the correct order, all multilingual fields in Project, Sessions, and People should display with the correct language tags. Once you have confirmed that everything appears as expected, click the button below to apply these changes permanently." +msgstr "" + msgid "This setting is unique to the account you are using on this computer. A colleague on another computer will need to set this value to the correct location on their computer." msgstr "" @@ -665,6 +812,9 @@ msgstr "" msgid "Undo" msgstr "" +msgid "Unknown error while scanning vocabulary" +msgstr "" + msgid "Update available" msgstr "" @@ -683,6 +833,15 @@ msgstr "" msgid "View Release Notes" msgstr "" +msgid "Vocabulary Translations" +msgstr "" + +msgid "Vocabulary scan failed" +msgstr "" + +msgid "Waiting" +msgstr "" + msgid "" "We don't have specific tips for this error. If you cannot solve it by\n" "restarting your computer, please report the problem to\n" @@ -698,12 +857,21 @@ msgstr "" msgid "You are running lameta version:" msgstr "" +msgid "You can either add more Metadata Languages above to match all segments, or proceed with the migration and manually fix the unknown language fields later." +msgstr "" + msgid "You cannot add files of that type" msgstr "" msgid "You cannot add folders." msgstr "" +msgid "You need at least two metadata languages defined to use vocabulary translations. Please add metadata languages in the Languages tab." +msgstr "" + +msgid "You requested this file less than a minute ago. lameta will show it when it becomes available." +msgstr "" + msgid "ZIP Archive" msgstr "" @@ -737,6 +905,12 @@ msgstr "" msgid "lameta could not rename the directory." msgstr "" +msgid "lameta couldn't download 1 file from {providerName}. It may be offline or busy right now." +msgstr "" + +msgid "lameta couldn't download {count} files from {providerName}. It may be offline or busy right now." +msgstr "" + msgid "lameta expected to find a .link file at {e}" msgstr "" @@ -746,6 +920,9 @@ msgstr "" msgid "lameta was not able to delete the file." msgstr "" +msgid "lameta was not able to make this file available on this device." +msgstr "" + msgid "lameta was not able to put this file in the trash" msgstr "" @@ -762,8 +939,32 @@ msgstr "" msgid "lameta will now open this folder on your hard disk and then exit. You should open these {projectType} files in a text editor and decide which one you want, and delete the others. The one you choose should be named {name}." msgstr "" +msgid "missing" +msgstr "" + +msgid "recorded when the file was last on this device" +msgstr "" + +msgid "the cloud" +msgstr "" + msgid "{0} Marked Sessions" msgstr "" +msgid "{0} of {1} items processed" +msgstr "" + msgid "{descriptionOfWhatWillBeDeleted} will be moved to the Trash" msgstr "" + +msgid "{minutesSinceRequest, plural, one {# minute since you requested this file. lameta will show it when it becomes available.} other {# minutes since you requested this file. lameta will show it when it becomes available.}}" +msgstr "" + +msgid "{providerName} Status" +msgstr "" + +msgid "{providerName} is downloading this file to this computer." +msgstr "" + +msgid "{totalMissing} items need translation" +msgstr "" diff --git a/locale/es/messages.po b/locale/es/messages.po index 8bf3fdd2..b6f3f156 100644 --- a/locale/es/messages.po +++ b/locale/es/messages.po @@ -45,6 +45,9 @@ msgstr "Abandonar archivos que aún se estén copiando" msgid "About lameta" msgstr "Sobre Lameta" +msgid "Access" +msgstr "" + msgid "Add Files" msgstr "Agregar archivos" @@ -63,6 +66,15 @@ msgstr "" msgid "All Sessions" msgstr "Todas las sesiones" +msgid "All translations complete" +msgstr "" + +msgid "Always" +msgstr "" + +msgid "Always available on this device." +msgstr "" + msgid "Archive" msgstr "" @@ -82,6 +94,15 @@ msgstr "" msgid "Audio" msgstr "Audio" +msgid "Automatically make cloud files available if smaller than" +msgstr "" + +msgid "Available after this file is made available on this device" +msgstr "" + +msgid "Available on this device. {providerName} may free up this space if the file goes unused." +msgstr "" + msgid "CSV export complete" msgstr "" @@ -94,6 +115,9 @@ msgstr "" msgid "Cannot add files of that type" msgstr "No se pueden añadir archivos de ese tipo" +msgid "Cannot export to IMDI while multilingual text migration is pending." +msgstr "" + msgid "Change Name To:" msgstr "Cambiar el nombre a:" @@ -121,6 +145,9 @@ msgstr "Borrar" msgid "Close" msgstr "Cerrar" +msgid "Cloud" +msgstr "" + msgid "Collection" msgstr "" @@ -157,6 +184,9 @@ msgstr "Copiando..." msgid "Could not find this file. Restart lameta to bring it up to date with what is actually on your hard drive. {path}" msgstr "" +msgid "Could not read media information for this file." +msgstr "" + msgid "Create" msgstr "Crear" @@ -181,6 +211,9 @@ msgstr "Créditos" msgid "Custom Field" msgstr "Campo personalizado" +msgid "Custom vocabulary item" +msgstr "" + msgid "Cut" msgstr "Cortar" @@ -214,6 +247,9 @@ msgstr "Eliminando..." msgid "Description Documents" msgstr "Documentos descriptivos" +msgid "Dismiss" +msgstr "" + msgid "Do not quit" msgstr "No salga del programa" @@ -226,6 +262,9 @@ msgstr "" msgid "Download" msgstr "Descargar" +msgid "Download this file to my computer" +msgstr "" + msgid "Duplicate Person" msgstr "Duplicar Persona" @@ -235,9 +274,15 @@ msgstr "Duplicar Sesión" msgid "Edit" msgstr "Editar" +msgid "ElectronDropZone: preload script did not set window.electronAPI.getPathForFile; drag-and-drop will not work." +msgstr "" + msgid "Email Address" msgstr "Correo electrónico" +msgid "Example:" +msgstr "" + msgid "Export" msgstr "Exportar" @@ -259,6 +304,9 @@ msgstr "" msgid "Field" msgstr "Campo" +msgid "Fields with Unknown Languages" +msgstr "" + msgid "File" msgstr "Archivo" @@ -271,6 +319,15 @@ msgstr "Archivo a importar:" msgid "Find" msgstr "" +msgid "Finish Migration" +msgstr "" + +msgid "Finish Migration to Multilingual Fields" +msgstr "" + +msgid "Fix in Project:" +msgstr "" + msgid "Font Size" msgstr "Tamaño de fuente" @@ -304,6 +361,12 @@ msgstr "" msgid "Generating RO-Crate metadata..." msgstr "" +msgid "Genres" +msgstr "" + +msgid "Getting project information from {0}..." +msgstr "" + msgid "Help" msgstr "Ayuda" @@ -349,6 +412,12 @@ msgstr "Importar {chosenCount} Sesiones" msgid "Importing..." msgstr "Importando..." +msgid "In cloud only, and this computer appears to be offline." +msgstr "" + +msgid "In cloud only, lameta cannot read it yet." +msgstr "" + msgid "Indicates that this is the father or mother's primary language." msgstr "Indica que este es el idioma principal del padre o de la madre." @@ -400,6 +469,15 @@ msgstr "Carpeta de medios" msgid "Media Folder Settings..." msgstr "Configuración de la carpeta de medios..." +msgid "Migrate" +msgstr "" + +msgid "Migrating: {0}" +msgstr "" + +msgid "Migration complete! {0} fields were migrated. You can hide language tags from View → \"Show language tags on multilingual fields\" if you don't need them anymore." +msgstr "" + msgid "Modified" msgstr "Modificado" @@ -409,18 +487,27 @@ msgstr "Más campos" msgid "Name" msgstr "Nombre" +msgid "Never" +msgstr "" + msgid "New Person" msgstr "Nueva persona" msgid "New Session" msgstr "Nueva Sesión" +msgid "No custom values found" +msgstr "" + msgid "Normally, when you add a file to a project, session, or person, that file is copied into the same folder as you lameta project. For some of your files, you might prefer to instead leave the files where they are and just 'link' to them." msgstr "Por lo general, cuando agregue un archivo a un proyecto, sesión o persona, este se copiará en la misma carpeta de su proyecto Lameta. Para algunos archivos, quizá prefiera dejar los archivos donde están y solo \"vincularlos\"." msgid "Not important to the archive, but will be included" msgstr "" +msgid "Note" +msgstr "" + msgid "Note that this is a link to a file in your Media Files directory. Here, you are renaming this link, not the original file that it points to. If you later create an export that includes this file, the export will use this name." msgstr "" "Tenga en cuenta que este es un enlace a un archivo en su carpeta de medios. Aquí, usted está renombrando el enlace, no el archivo original que se indica. Si más adelante crea una exportación que incluya este archivo, la exportación usará este nombre.\n" @@ -438,6 +525,12 @@ msgstr "OPEX + Archivos" msgid "One or more files are still being copied into your project." msgstr "Uno o más archivos aún están siendo copiados a su proyecto." +msgid "Online-only ({providerName}), and this computer appears to be offline, so the file cannot be downloaded right now." +msgstr "" + +msgid "Online-only ({providerName}). The content of this file is not on this computer." +msgstr "" + msgid "Open" msgstr "Abrir" @@ -474,6 +567,12 @@ msgstr "Persona" msgid "Please Register" msgstr "Registrarse" +msgid "Please add the metadata languages you've been using with these \"/\" fields (at least two) before migrating." +msgstr "" + +msgid "Please complete the migration in the Project tab under Languages before exporting." +msgstr "" + msgid "Please remove characters that not allowed by the Archive Settings." msgstr "Elimine caracteres que no estén permitidos por la configuración del archivado." @@ -501,6 +600,9 @@ msgstr "" msgid "RO-Crate export complete" msgstr "" +msgid "Ready to scan..." +msgstr "" + msgid "Redo" msgstr "Rehacer" @@ -534,9 +636,18 @@ msgstr "Reportar un problema" msgid "Restart lameta and do the rename before playing the video again." msgstr "Reiniciar Lameta y hacer el cambio de nombre antes de reproducir el video de nuevo." +msgid "Retry" +msgstr "" + +msgid "Retrying…" +msgstr "" + msgid "Role" msgstr "Rol" +msgid "Roles" +msgstr "" + msgid "Save As" msgstr "Guardar como" @@ -546,6 +657,15 @@ msgstr "Guardando" msgid "Saving before rename..." msgstr "Guardar antes de renombrar..." +msgid "Scan complete" +msgstr "" + +msgid "Scan failed" +msgstr "" + +msgid "Scanning..." +msgstr "" + msgid "Search" msgstr "" @@ -567,6 +687,12 @@ msgstr "Mostrar Todo" msgid "Show IMDI previews" msgstr "Mostrar IMDI" +msgid "Show Language Tags" +msgstr "" + +msgid "Show Language Tags (locked during migration)" +msgstr "" + msgid "Show PARADISEC previews" msgstr "Modo PARADISEC" @@ -594,6 +720,9 @@ msgstr "Tamaño" msgid "Smaller" msgstr "Más pequeño" +msgid "Some fields have more language segments than there are Metadata Languages defined ({0} of {1} fields). If you proceed, these extra segments will be labeled as \"unknown\" languages." +msgstr "" + msgid "Some operating systems would not allow that name." msgstr "Algunos sistemas operativos no permiten ese nombre." @@ -606,9 +735,18 @@ msgstr "Lo sentimos, no se permiten barras" msgid "Start Screen" msgstr "Pantalla de Inicio" +msgid "Starting scan..." +msgstr "" + msgid "Status" msgstr "Estado" +msgid "Stop waiting" +msgstr "" + +msgid "Tell {providerName} that I want this file on my computer" +msgstr "" + msgid "Text" msgstr "Texto" @@ -618,6 +756,9 @@ msgstr "Ese nombre hará que el nombre del archivo esté vacío." msgid "The IMDI files were validated." msgstr "Los archivos IMDI han sido validados." +msgid "The columns here are based on your project's metadata languages." +msgstr "" + msgid "The file is missing from its expected location in the Media Folder. The Media Folder is set to {m} and this file is supposed to be at {e}" msgstr "El archivo no aparece en la ubicación esperada en la carpeta de medios. La carpeta de medios está configurada para {m} y el archivo debería estar en {e}" @@ -657,9 +798,15 @@ msgstr "Este archivo XML se ajusta al esquema IMDI." msgid "This file does not comply with the file naming rules of the current archive." msgstr "" +msgid "This file is online-only ({providerName}). Select it to see how to make it available on this device." +msgstr "" + msgid "This is a link, but lameta does not know where to find the Media Folder for this project. See File:Media Folder Settings." msgstr "Este es un enlace, pero Lameta no logra encontrar la carpeta de medios para este proyecto. Vea Archivo: Configuración de carpeta de medios." +msgid "This project appears to use \"/\" to store multiple languages within a single field. In this version of lameta, we have a better approach in which lameta knows which languages are used in a field. After you have selected the appropriate metadata languages above and arranged them in the correct order, all multilingual fields in Project, Sessions, and People should display with the correct language tags. Once you have confirmed that everything appears as expected, click the button below to apply these changes permanently." +msgstr "" + msgid "This setting is unique to the account you are using on this computer. A colleague on another computer will need to set this value to the correct location on their computer." msgstr "Esta configuración es exclusiva para la cuenta que se utiliza en este equipo. Un colega deberá configurar este valor a la ubicación correcta en su equipo." @@ -672,6 +819,9 @@ msgstr "Tipo" msgid "Undo" msgstr "Deshacer" +msgid "Unknown error while scanning vocabulary" +msgstr "" + msgid "Update available" msgstr "Actualización disponible" @@ -690,6 +840,15 @@ msgstr "Vista" msgid "View Release Notes" msgstr "Ver Notas de esta Versión" +msgid "Vocabulary Translations" +msgstr "" + +msgid "Vocabulary scan failed" +msgstr "" + +msgid "Waiting" +msgstr "" + msgid "" "We don't have specific tips for this error. If you cannot solve it by\n" "restarting your computer, please report the problem to\n" @@ -705,12 +864,21 @@ msgstr "" msgid "You are running lameta version:" msgstr "Está usando la versión de Lameta:" +msgid "You can either add more Metadata Languages above to match all segments, or proceed with the migration and manually fix the unknown language fields later." +msgstr "" + msgid "You cannot add files of that type" msgstr "No se puede añadir archivos de ese tipo" msgid "You cannot add folders." msgstr "No se puede añadir carpetas." +msgid "You need at least two metadata languages defined to use vocabulary translations. Please add metadata languages in the Languages tab." +msgstr "" + +msgid "You requested this file less than a minute ago. lameta will show it when it becomes available." +msgstr "" + msgid "ZIP Archive" msgstr "Archivo ZIP" @@ -744,6 +912,12 @@ msgstr "Lameta no pudo encontrar una Persona que coincida con los siguientes col msgid "lameta could not rename the directory." msgstr "Lameta no pudo cambiar el nombre del directorio." +msgid "lameta couldn't download 1 file from {providerName}. It may be offline or busy right now." +msgstr "" + +msgid "lameta couldn't download {count} files from {providerName}. It may be offline or busy right now." +msgstr "" + msgid "lameta expected to find a .link file at {e}" msgstr "Lameta esperaba encontrar un archivo .link en {e}" @@ -753,6 +927,9 @@ msgstr "Lameta intentó renombrar la carpeta \"{oldFolderName}\" a \"{newFolderN msgid "lameta was not able to delete the file." msgstr "Lameta no pudo eliminar el archivo." +msgid "lameta was not able to make this file available on this device." +msgstr "" + msgid "lameta was not able to put this file in the trash" msgstr "Lameta no pudo enviar este archivo a la papelera de reciclaje" @@ -769,8 +946,32 @@ msgstr "" msgid "lameta will now open this folder on your hard disk and then exit. You should open these {projectType} files in a text editor and decide which one you want, and delete the others. The one you choose should be named {name}." msgstr "Lameta abrirá esta carpeta en su disco duro externo y luego se cerrará. Deberá abrir estos archivos {projectType} en un editor de texto y debe decidir cuáles desea mantener y borrar el resto. El archivo que elija deberá llamarse {name}." +msgid "missing" +msgstr "" + +msgid "recorded when the file was last on this device" +msgstr "" + +msgid "the cloud" +msgstr "" + msgid "{0} Marked Sessions" msgstr "{0} Sesiones Marcadas" +msgid "{0} of {1} items processed" +msgstr "" + msgid "{descriptionOfWhatWillBeDeleted} will be moved to the Trash" msgstr "{descriptionOfWhatWillBeDeleted} se enviará a la Papelera de reciclaje" + +msgid "{minutesSinceRequest, plural, one {# minute since you requested this file. lameta will show it when it becomes available.} other {# minutes since you requested this file. lameta will show it when it becomes available.}}" +msgstr "" + +msgid "{providerName} Status" +msgstr "" + +msgid "{providerName} is downloading this file to this computer." +msgstr "" + +msgid "{totalMissing} items need translation" +msgstr "" diff --git a/locale/fa/messages.po b/locale/fa/messages.po index 94c64ee5..f4c580c2 100644 --- a/locale/fa/messages.po +++ b/locale/fa/messages.po @@ -47,6 +47,9 @@ msgstr "از فایل هایی که در حال کپی شدن هستند، صر msgid "About lameta" msgstr "درباره لامتا" +msgid "Access" +msgstr "" + msgid "Add Files" msgstr "افزودن فایلها" @@ -65,6 +68,15 @@ msgstr "" msgid "All Sessions" msgstr "همه جلسات" +msgid "All translations complete" +msgstr "" + +msgid "Always" +msgstr "" + +msgid "Always available on this device." +msgstr "" + msgid "Archive" msgstr "" @@ -84,6 +96,15 @@ msgstr "" msgid "Audio" msgstr "فایل صوتی" +msgid "Automatically make cloud files available if smaller than" +msgstr "" + +msgid "Available after this file is made available on this device" +msgstr "" + +msgid "Available on this device. {providerName} may free up this space if the file goes unused." +msgstr "" + msgid "CSV export complete" msgstr "" @@ -96,6 +117,9 @@ msgstr "" msgid "Cannot add files of that type" msgstr "نمی توان این نوع فایلها را اضافه کرد" +msgid "Cannot export to IMDI while multilingual text migration is pending." +msgstr "" + msgid "Change Name To:" msgstr "تغییر نام به:" @@ -123,6 +147,9 @@ msgstr "" msgid "Close" msgstr "بستن" +msgid "Cloud" +msgstr "" + msgid "Collection" msgstr "" @@ -159,6 +186,9 @@ msgstr "در حال رونوشت گرفتن / کپی کردن فایلها" msgid "Could not find this file. Restart lameta to bring it up to date with what is actually on your hard drive. {path}" msgstr "" +msgid "Could not read media information for this file." +msgstr "" + msgid "Create" msgstr "" @@ -183,6 +213,9 @@ msgstr "اعتبار" msgid "Custom Field" msgstr "فیلد سفارشی" +msgid "Custom vocabulary item" +msgstr "" + msgid "Cut" msgstr "برش" @@ -216,6 +249,9 @@ msgstr "" msgid "Description Documents" msgstr "مدارک توصیف" +msgid "Dismiss" +msgstr "" + msgid "Do not quit" msgstr "خارج نشوید" @@ -228,6 +264,9 @@ msgstr "" msgid "Download" msgstr "دانلود" +msgid "Download this file to my computer" +msgstr "" + msgid "Duplicate Person" msgstr "شخص تکراری" @@ -237,9 +276,15 @@ msgstr "نشست تکراری" msgid "Edit" msgstr "ویرایش" +msgid "ElectronDropZone: preload script did not set window.electronAPI.getPathForFile; drag-and-drop will not work." +msgstr "" + msgid "Email Address" msgstr "آدرس ایمیل" +msgid "Example:" +msgstr "" + msgid "Export" msgstr "خروجی" @@ -261,6 +306,9 @@ msgstr "" msgid "Field" msgstr "فیلد" +msgid "Fields with Unknown Languages" +msgstr "" + msgid "File" msgstr "فایل" @@ -273,6 +321,15 @@ msgstr "" msgid "Find" msgstr "" +msgid "Finish Migration" +msgstr "" + +msgid "Finish Migration to Multilingual Fields" +msgstr "" + +msgid "Fix in Project:" +msgstr "" + msgid "Font Size" msgstr "اندازه فونت" @@ -306,6 +363,12 @@ msgstr "" msgid "Generating RO-Crate metadata..." msgstr "" +msgid "Genres" +msgstr "" + +msgid "Getting project information from {0}..." +msgstr "" + msgid "Help" msgstr "راهنما" @@ -351,6 +414,12 @@ msgstr "" msgid "Importing..." msgstr "" +msgid "In cloud only, and this computer appears to be offline." +msgstr "" + +msgid "In cloud only, lameta cannot read it yet." +msgstr "" + msgid "Indicates that this is the father or mother's primary language." msgstr "نشان می دهد که این زبان اول پدر یا مادر است." @@ -402,6 +471,15 @@ msgstr "" msgid "Media Folder Settings..." msgstr "" +msgid "Migrate" +msgstr "" + +msgid "Migrating: {0}" +msgstr "" + +msgid "Migration complete! {0} fields were migrated. You can hide language tags from View → \"Show language tags on multilingual fields\" if you don't need them anymore." +msgstr "" + msgid "Modified" msgstr "اصلاح شده" @@ -411,18 +489,27 @@ msgstr "فیلدهای بیشتر" msgid "Name" msgstr "نام" +msgid "Never" +msgstr "" + msgid "New Person" msgstr "فرد جدید" msgid "New Session" msgstr "نشست جدید" +msgid "No custom values found" +msgstr "" + msgid "Normally, when you add a file to a project, session, or person, that file is copied into the same folder as you lameta project. For some of your files, you might prefer to instead leave the files where they are and just 'link' to them." msgstr "" msgid "Not important to the archive, but will be included" msgstr "" +msgid "Note" +msgstr "" + msgid "Note that this is a link to a file in your Media Files directory. Here, you are renaming this link, not the original file that it points to. If you later create an export that includes this file, the export will use this name." msgstr "" @@ -438,6 +525,12 @@ msgstr "" msgid "One or more files are still being copied into your project." msgstr "یک یا چند پرونده در پروژه شما در حال کپی شدن هستند." +msgid "Online-only ({providerName}), and this computer appears to be offline, so the file cannot be downloaded right now." +msgstr "" + +msgid "Online-only ({providerName}). The content of this file is not on this computer." +msgstr "" + msgid "Open" msgstr "بازکردن" @@ -474,6 +567,12 @@ msgstr "فرد" msgid "Please Register" msgstr "لطفا ثبت نام کنید" +msgid "Please add the metadata languages you've been using with these \"/\" fields (at least two) before migrating." +msgstr "" + +msgid "Please complete the migration in the Project tab under Languages before exporting." +msgstr "" + msgid "Please remove characters that not allowed by the Archive Settings." msgstr "" @@ -501,6 +600,9 @@ msgstr "" msgid "RO-Crate export complete" msgstr "" +msgid "Ready to scan..." +msgstr "" + msgid "Redo" msgstr "دوباره انجام دادن" @@ -534,9 +636,18 @@ msgstr "گزارش یک مشکل" msgid "Restart lameta and do the rename before playing the video again." msgstr "لمتا را دوباره راه اندازی کنید، نام فایل را قبل از نمایش ویدئو تغییر دهید." +msgid "Retry" +msgstr "" + +msgid "Retrying…" +msgstr "" + msgid "Role" msgstr "نقش" +msgid "Roles" +msgstr "" + msgid "Save As" msgstr "ذخیره فایل به عنوان" @@ -546,6 +657,15 @@ msgstr "در حال ذخیره فایل" msgid "Saving before rename..." msgstr "" +msgid "Scan complete" +msgstr "" + +msgid "Scan failed" +msgstr "" + +msgid "Scanning..." +msgstr "" + msgid "Search" msgstr "" @@ -567,6 +687,12 @@ msgstr "نمایش همه" msgid "Show IMDI previews" msgstr "" +msgid "Show Language Tags" +msgstr "" + +msgid "Show Language Tags (locked during migration)" +msgstr "" + msgid "Show PARADISEC previews" msgstr "" @@ -594,6 +720,9 @@ msgstr "اندازه" msgid "Smaller" msgstr "کوچکتر" +msgid "Some fields have more language segments than there are Metadata Languages defined ({0} of {1} fields). If you proceed, these extra segments will be labeled as \"unknown\" languages." +msgstr "" + msgid "Some operating systems would not allow that name." msgstr "" @@ -606,9 +735,18 @@ msgstr "" msgid "Start Screen" msgstr "صفحه آغازین" +msgid "Starting scan..." +msgstr "" + msgid "Status" msgstr "وضعیت" +msgid "Stop waiting" +msgstr "" + +msgid "Tell {providerName} that I want this file on my computer" +msgstr "" + msgid "Text" msgstr "متن" @@ -618,6 +756,9 @@ msgstr "این نام منجر به یک پرونده خالی می شود." msgid "The IMDI files were validated." msgstr "" +msgid "The columns here are based on your project's metadata languages." +msgstr "" + msgid "The file is missing from its expected location in the Media Folder. The Media Folder is set to {m} and this file is supposed to be at {e}" msgstr "" @@ -657,9 +798,15 @@ msgstr "" msgid "This file does not comply with the file naming rules of the current archive." msgstr "" +msgid "This file is online-only ({providerName}). Select it to see how to make it available on this device." +msgstr "" + msgid "This is a link, but lameta does not know where to find the Media Folder for this project. See File:Media Folder Settings." msgstr "" +msgid "This project appears to use \"/\" to store multiple languages within a single field. In this version of lameta, we have a better approach in which lameta knows which languages are used in a field. After you have selected the appropriate metadata languages above and arranged them in the correct order, all multilingual fields in Project, Sessions, and People should display with the correct language tags. Once you have confirmed that everything appears as expected, click the button below to apply these changes permanently." +msgstr "" + msgid "This setting is unique to the account you are using on this computer. A colleague on another computer will need to set this value to the correct location on their computer." msgstr "" @@ -672,6 +819,9 @@ msgstr "تایپ کردن" msgid "Undo" msgstr "واگرد/ برگشت" +msgid "Unknown error while scanning vocabulary" +msgstr "" + msgid "Update available" msgstr "بروزرسانی موجود است" @@ -690,6 +840,15 @@ msgstr "دیدن" msgid "View Release Notes" msgstr "یادداشتهای انتشار را مشاهده کنید" +msgid "Vocabulary Translations" +msgstr "" + +msgid "Vocabulary scan failed" +msgstr "" + +msgid "Waiting" +msgstr "" + msgid "" "We don't have specific tips for this error. If you cannot solve it by\n" "restarting your computer, please report the problem to\n" @@ -705,12 +864,21 @@ msgstr "" msgid "You are running lameta version:" msgstr "" +msgid "You can either add more Metadata Languages above to match all segments, or proceed with the migration and manually fix the unknown language fields later." +msgstr "" + msgid "You cannot add files of that type" msgstr "نمی توانید پرونده هایی از این نوع اضافه کنید" msgid "You cannot add folders." msgstr "نمی توانید فولدر اضافه کنید." +msgid "You need at least two metadata languages defined to use vocabulary translations. Please add metadata languages in the Languages tab." +msgstr "" + +msgid "You requested this file less than a minute ago. lameta will show it when it becomes available." +msgstr "" + msgid "ZIP Archive" msgstr "آرشیو فایل زیپ" @@ -744,6 +912,12 @@ msgstr "" msgid "lameta could not rename the directory." msgstr "لمتا نمیتواند فایل مورد نظر را تغییر نام بدهد." +msgid "lameta couldn't download 1 file from {providerName}. It may be offline or busy right now." +msgstr "" + +msgid "lameta couldn't download {count} files from {providerName}. It may be offline or busy right now." +msgstr "" + msgid "lameta expected to find a .link file at {e}" msgstr "" @@ -753,6 +927,9 @@ msgstr "" msgid "lameta was not able to delete the file." msgstr "لمتا نمیتواند فایل را حذف کند." +msgid "lameta was not able to make this file available on this device." +msgstr "" + msgid "lameta was not able to put this file in the trash" msgstr "لمتا قادر به حذف این فایل نیست." @@ -769,8 +946,32 @@ msgstr "" msgid "lameta will now open this folder on your hard disk and then exit. You should open these {projectType} files in a text editor and decide which one you want, and delete the others. The one you choose should be named {name}." msgstr "" +msgid "missing" +msgstr "" + +msgid "recorded when the file was last on this device" +msgstr "" + +msgid "the cloud" +msgstr "" + msgid "{0} Marked Sessions" msgstr "نشستهای مشخص شده" +msgid "{0} of {1} items processed" +msgstr "" + msgid "{descriptionOfWhatWillBeDeleted} will be moved to the Trash" msgstr "" + +msgid "{minutesSinceRequest, plural, one {# minute since you requested this file. lameta will show it when it becomes available.} other {# minutes since you requested this file. lameta will show it when it becomes available.}}" +msgstr "" + +msgid "{providerName} Status" +msgstr "" + +msgid "{providerName} is downloading this file to this computer." +msgstr "" + +msgid "{totalMissing} items need translation" +msgstr "" diff --git a/locale/fr/messages.po b/locale/fr/messages.po index a6d85653..8ea5adcd 100644 --- a/locale/fr/messages.po +++ b/locale/fr/messages.po @@ -45,6 +45,9 @@ msgstr "Abandonner les fichiers qui sont encore en cours de copie" msgid "About lameta" msgstr "A propos de Lameta" +msgid "Access" +msgstr "" + msgid "Add Files" msgstr "Ajouter des fichiers" @@ -63,6 +66,15 @@ msgstr "" msgid "All Sessions" msgstr "Toutes les sessions" +msgid "All translations complete" +msgstr "" + +msgid "Always" +msgstr "" + +msgid "Always available on this device." +msgstr "" + msgid "Archive" msgstr "" @@ -82,6 +94,15 @@ msgstr "" msgid "Audio" msgstr "Audio" +msgid "Automatically make cloud files available if smaller than" +msgstr "" + +msgid "Available after this file is made available on this device" +msgstr "" + +msgid "Available on this device. {providerName} may free up this space if the file goes unused." +msgstr "" + msgid "CSV export complete" msgstr "" @@ -94,6 +115,9 @@ msgstr "" msgid "Cannot add files of that type" msgstr "Impossible d'ajouter des fichiers de ce type" +msgid "Cannot export to IMDI while multilingual text migration is pending." +msgstr "" + msgid "Change Name To:" msgstr "Changer le nom à :" @@ -121,6 +145,9 @@ msgstr "Effacer" msgid "Close" msgstr "Fermer" +msgid "Cloud" +msgstr "" + msgid "Collection" msgstr "" @@ -157,6 +184,9 @@ msgstr "Copiage..." msgid "Could not find this file. Restart lameta to bring it up to date with what is actually on your hard drive. {path}" msgstr "" +msgid "Could not read media information for this file." +msgstr "" + msgid "Create" msgstr "Créer" @@ -181,6 +211,9 @@ msgstr "Reconnaissance" msgid "Custom Field" msgstr "Champs Personnalisées" +msgid "Custom vocabulary item" +msgstr "" + msgid "Cut" msgstr "Couper" @@ -214,6 +247,9 @@ msgstr "Suppression en cours..." msgid "Description Documents" msgstr "Documents de description" +msgid "Dismiss" +msgstr "" + msgid "Do not quit" msgstr "Ne quittez pas" @@ -226,6 +262,9 @@ msgstr "" msgid "Download" msgstr "Télécharger" +msgid "Download this file to my computer" +msgstr "" + msgid "Duplicate Person" msgstr "Dupliquer la personne" @@ -235,9 +274,15 @@ msgstr "Dupliquer la session" msgid "Edit" msgstr "Modifier" +msgid "ElectronDropZone: preload script did not set window.electronAPI.getPathForFile; drag-and-drop will not work." +msgstr "" + msgid "Email Address" msgstr "Adresse email" +msgid "Example:" +msgstr "" + msgid "Export" msgstr "Exporter" @@ -259,6 +304,9 @@ msgstr "" msgid "Field" msgstr "Champ" +msgid "Fields with Unknown Languages" +msgstr "" + msgid "File" msgstr "Fichier" @@ -271,6 +319,15 @@ msgstr "Fichier à importer :" msgid "Find" msgstr "" +msgid "Finish Migration" +msgstr "" + +msgid "Finish Migration to Multilingual Fields" +msgstr "" + +msgid "Fix in Project:" +msgstr "" + msgid "Font Size" msgstr "Taille de police" @@ -304,6 +361,12 @@ msgstr "" msgid "Generating RO-Crate metadata..." msgstr "" +msgid "Genres" +msgstr "" + +msgid "Getting project information from {0}..." +msgstr "" + msgid "Help" msgstr "Aide" @@ -349,6 +412,12 @@ msgstr "Importer {chosenCount} Session(s)" msgid "Importing..." msgstr "" +msgid "In cloud only, and this computer appears to be offline." +msgstr "" + +msgid "In cloud only, lameta cannot read it yet." +msgstr "" + msgid "Indicates that this is the father or mother's primary language." msgstr "Indiquez que c'est la langue principale du père ou de la mère." @@ -400,6 +469,15 @@ msgstr "Dossier Multimédia" msgid "Media Folder Settings..." msgstr "Réglages Fichiers Multimédia..." +msgid "Migrate" +msgstr "" + +msgid "Migrating: {0}" +msgstr "" + +msgid "Migration complete! {0} fields were migrated. You can hide language tags from View → \"Show language tags on multilingual fields\" if you don't need them anymore." +msgstr "" + msgid "Modified" msgstr "Modifié" @@ -409,18 +487,27 @@ msgstr "Champs supplémentaires" msgid "Name" msgstr "Nom" +msgid "Never" +msgstr "" + msgid "New Person" msgstr "Nouvelle personne" msgid "New Session" msgstr "Nouvelle session" +msgid "No custom values found" +msgstr "" + msgid "Normally, when you add a file to a project, session, or person, that file is copied into the same folder as you lameta project. For some of your files, you might prefer to instead leave the files where they are and just 'link' to them." msgstr "Normalement, lorsque vous ajoutez un fichier à un projet, une session ou une personne, ce fichier est copié dans le même dossier que votre projet lameta. Pour certains de vos fichiers, vous préférerez peut-être plutôt laisser les fichiers là où ils se trouvent et simplement les \"lier\" à eux." msgid "Not important to the archive, but will be included" msgstr "" +msgid "Note" +msgstr "" + msgid "Note that this is a link to a file in your Media Files directory. Here, you are renaming this link, not the original file that it points to. If you later create an export that includes this file, the export will use this name." msgstr "Notez qu'il s'agit d'un lien vers un fichier de votre répertoire Multimédia . Ici, vous êtes en train de renommer ce lien, pas le fichier d'origine vers lequel il pointe. Si vous créez ultérieurement une exportation qui inclut ce fichier, l'exportation utilisera ce nom." @@ -436,6 +523,12 @@ msgstr "" msgid "One or more files are still being copied into your project." msgstr "Un ou plusieurs fichiers sont toujours en cours de copie dans votre projet." +msgid "Online-only ({providerName}), and this computer appears to be offline, so the file cannot be downloaded right now." +msgstr "" + +msgid "Online-only ({providerName}). The content of this file is not on this computer." +msgstr "" + msgid "Open" msgstr "Ouvrir" @@ -472,6 +565,12 @@ msgstr "Personne" msgid "Please Register" msgstr "Veuillez vous inscrire" +msgid "Please add the metadata languages you've been using with these \"/\" fields (at least two) before migrating." +msgstr "" + +msgid "Please complete the migration in the Project tab under Languages before exporting." +msgstr "" + msgid "Please remove characters that not allowed by the Archive Settings." msgstr "Veuillez supprimer les caractères non autorisés par les paramètres d'archivage." @@ -499,6 +598,9 @@ msgstr "" msgid "RO-Crate export complete" msgstr "" +msgid "Ready to scan..." +msgstr "" + msgid "Redo" msgstr "Refaire" @@ -532,9 +634,18 @@ msgstr "Signaler un problème" msgid "Restart lameta and do the rename before playing the video again." msgstr "Redémarrez lameta et renommez avant de rejouer la vidéo." +msgid "Retry" +msgstr "" + +msgid "Retrying…" +msgstr "" + msgid "Role" msgstr "Rôle" +msgid "Roles" +msgstr "" + msgid "Save As" msgstr "Enregistrer sous" @@ -544,6 +655,15 @@ msgstr "Sauvegarde" msgid "Saving before rename..." msgstr "Sauvegarder avant de renommer..." +msgid "Scan complete" +msgstr "" + +msgid "Scan failed" +msgstr "" + +msgid "Scanning..." +msgstr "" + msgid "Search" msgstr "" @@ -565,6 +685,12 @@ msgstr "Tout afficher" msgid "Show IMDI previews" msgstr "" +msgid "Show Language Tags" +msgstr "" + +msgid "Show Language Tags (locked during migration)" +msgstr "" + msgid "Show PARADISEC previews" msgstr "" @@ -592,6 +718,9 @@ msgstr "Taille" msgid "Smaller" msgstr "Plus petit" +msgid "Some fields have more language segments than there are Metadata Languages defined ({0} of {1} fields). If you proceed, these extra segments will be labeled as \"unknown\" languages." +msgstr "" + msgid "Some operating systems would not allow that name." msgstr "Certains systèmes d'exploitation n'autoriseraient pas ce nom." @@ -604,9 +733,18 @@ msgstr "Désolé, aucune barre oblique n'est autorisée" msgid "Start Screen" msgstr "Ecran de démarrage" +msgid "Starting scan..." +msgstr "" + msgid "Status" msgstr "État" +msgid "Stop waiting" +msgstr "" + +msgid "Tell {providerName} that I want this file on my computer" +msgstr "" + msgid "Text" msgstr "Texte" @@ -616,6 +754,9 @@ msgstr "Ce nom conduirait à un nom de fichier vide." msgid "The IMDI files were validated." msgstr "" +msgid "The columns here are based on your project's metadata languages." +msgstr "" + msgid "The file is missing from its expected location in the Media Folder. The Media Folder is set to {m} and this file is supposed to be at {e}" msgstr "Le fichier est absent de son emplacement prévu dans le dossier multimédia. Le dossier multimédia est défini sur {m} et ce fichier est censé être à {e}" @@ -655,9 +796,15 @@ msgstr "" msgid "This file does not comply with the file naming rules of the current archive." msgstr "" +msgid "This file is online-only ({providerName}). Select it to see how to make it available on this device." +msgstr "" + msgid "This is a link, but lameta does not know where to find the Media Folder for this project. See File:Media Folder Settings." msgstr "Ceci est un lien, mais Lameta ne sait pas où trouver le dossier multimédia pour ce projet. Voir Fichier:Paramètres du dossier multimédia." +msgid "This project appears to use \"/\" to store multiple languages within a single field. In this version of lameta, we have a better approach in which lameta knows which languages are used in a field. After you have selected the appropriate metadata languages above and arranged them in the correct order, all multilingual fields in Project, Sessions, and People should display with the correct language tags. Once you have confirmed that everything appears as expected, click the button below to apply these changes permanently." +msgstr "" + msgid "This setting is unique to the account you are using on this computer. A colleague on another computer will need to set this value to the correct location on their computer." msgstr "Ce paramètre est unique au compte que vous utilisez sur l’ordinateur. Un collègue sur un autre ordinateur devra définir cette valeur à l'emplacement correct sur son ordinateur." @@ -670,6 +817,9 @@ msgstr "Type" msgid "Undo" msgstr "Annuler l'action" +msgid "Unknown error while scanning vocabulary" +msgstr "" + msgid "Update available" msgstr "Une mise à jour est disponible" @@ -688,6 +838,15 @@ msgstr "Affichage" msgid "View Release Notes" msgstr "Consulter les notes de versions" +msgid "Vocabulary Translations" +msgstr "" + +msgid "Vocabulary scan failed" +msgstr "" + +msgid "Waiting" +msgstr "" + msgid "" "We don't have specific tips for this error. If you cannot solve it by\n" "restarting your computer, please report the problem to\n" @@ -706,12 +865,21 @@ msgstr "" msgid "You are running lameta version:" msgstr "Vous utilisez la version lameta:" +msgid "You can either add more Metadata Languages above to match all segments, or proceed with the migration and manually fix the unknown language fields later." +msgstr "" + msgid "You cannot add files of that type" msgstr "Vous ne pouvez pas ajouter des fichiers de ce type" msgid "You cannot add folders." msgstr "Vous ne pouvez pas ajouter de dossiers." +msgid "You need at least two metadata languages defined to use vocabulary translations. Please add metadata languages in the Languages tab." +msgstr "" + +msgid "You requested this file less than a minute ago. lameta will show it when it becomes available." +msgstr "" + msgid "ZIP Archive" msgstr "Archive ZIP" @@ -745,6 +913,12 @@ msgstr "" msgid "lameta could not rename the directory." msgstr "lameta n'a pas pu renommer le répertoire." +msgid "lameta couldn't download 1 file from {providerName}. It may be offline or busy right now." +msgstr "" + +msgid "lameta couldn't download {count} files from {providerName}. It may be offline or busy right now." +msgstr "" + msgid "lameta expected to find a .link file at {e}" msgstr "lameta s'attendait à trouver un .lien fichier sur {e}" @@ -754,6 +928,9 @@ msgstr "lameta a essayé de renommer le dossier \"{oldFolderName}\" en \"{newFol msgid "lameta was not able to delete the file." msgstr "lameta n'a pas pu supprimer ce fichier." +msgid "lameta was not able to make this file available on this device." +msgstr "" + msgid "lameta was not able to put this file in the trash" msgstr "lameta n'a pas pu mettre ce fichier à la corbeille" @@ -770,8 +947,32 @@ msgstr "" msgid "lameta will now open this folder on your hard disk and then exit. You should open these {projectType} files in a text editor and decide which one you want, and delete the others. The one you choose should be named {name}." msgstr "Lameta va maintenant ouvrir ce dossier sur votre disque dur ensuite va quitter. Vous devez ouvrir ces fichiers {projectType} dans un éditeur de texte et décider lequel vous voulez, et supprimer les autres. Celui que vous choisissez doit s'appeler {name}." +msgid "missing" +msgstr "" + +msgid "recorded when the file was last on this device" +msgstr "" + +msgid "the cloud" +msgstr "" + msgid "{0} Marked Sessions" msgstr "{0} Sessions Cochées" +msgid "{0} of {1} items processed" +msgstr "" + msgid "{descriptionOfWhatWillBeDeleted} will be moved to the Trash" msgstr "" + +msgid "{minutesSinceRequest, plural, one {# minute since you requested this file. lameta will show it when it becomes available.} other {# minutes since you requested this file. lameta will show it when it becomes available.}}" +msgstr "" + +msgid "{providerName} Status" +msgstr "" + +msgid "{providerName} is downloading this file to this computer." +msgstr "" + +msgid "{totalMissing} items need translation" +msgstr "" diff --git a/locale/id/messages.po b/locale/id/messages.po index e99003a3..df8bf647 100644 --- a/locale/id/messages.po +++ b/locale/id/messages.po @@ -45,6 +45,9 @@ msgstr "Abaikan file-file yang sedang disalin" msgid "About lameta" msgstr "Tentang lameta" +msgid "Access" +msgstr "" + msgid "Add Files" msgstr "Tambah File-File" @@ -63,6 +66,15 @@ msgstr "" msgid "All Sessions" msgstr "Semua Sesi" +msgid "All translations complete" +msgstr "" + +msgid "Always" +msgstr "" + +msgid "Always available on this device." +msgstr "" + msgid "Archive" msgstr "" @@ -82,6 +94,15 @@ msgstr "" msgid "Audio" msgstr "Audio" +msgid "Automatically make cloud files available if smaller than" +msgstr "" + +msgid "Available after this file is made available on this device" +msgstr "" + +msgid "Available on this device. {providerName} may free up this space if the file goes unused." +msgstr "" + msgid "CSV export complete" msgstr "" @@ -94,6 +115,9 @@ msgstr "" msgid "Cannot add files of that type" msgstr "Tidak dapat tambah file jenis itu" +msgid "Cannot export to IMDI while multilingual text migration is pending." +msgstr "" + msgid "Change Name To:" msgstr "Ubah Nama Menjadi:" @@ -121,6 +145,9 @@ msgstr "Hapus" msgid "Close" msgstr "Tutup" +msgid "Cloud" +msgstr "" + msgid "Collection" msgstr "" @@ -157,6 +184,9 @@ msgstr "Menyalin..." msgid "Could not find this file. Restart lameta to bring it up to date with what is actually on your hard drive. {path}" msgstr "" +msgid "Could not read media information for this file." +msgstr "" + msgid "Create" msgstr "Buat" @@ -181,6 +211,9 @@ msgstr "Kredit/Penghargaan" msgid "Custom Field" msgstr "Kotak yang disesuaikan" +msgid "Custom vocabulary item" +msgstr "" + msgid "Cut" msgstr "Potong" @@ -214,6 +247,9 @@ msgstr "Hapus..." msgid "Description Documents" msgstr "Dokumen-Dokumen Keterangan" +msgid "Dismiss" +msgstr "" + msgid "Do not quit" msgstr "Jangan tutup" @@ -226,6 +262,9 @@ msgstr "" msgid "Download" msgstr "Unduh" +msgid "Download this file to my computer" +msgstr "" + msgid "Duplicate Person" msgstr "Duplikasi orang" @@ -235,9 +274,15 @@ msgstr "Duplikasi Sesi" msgid "Edit" msgstr "Edit" +msgid "ElectronDropZone: preload script did not set window.electronAPI.getPathForFile; drag-and-drop will not work." +msgstr "" + msgid "Email Address" msgstr "Alamat Email" +msgid "Example:" +msgstr "" + msgid "Export" msgstr "Ekspor" @@ -259,6 +304,9 @@ msgstr "" msgid "Field" msgstr "Kotak" +msgid "Fields with Unknown Languages" +msgstr "" + msgid "File" msgstr "File" @@ -271,6 +319,15 @@ msgstr "File untuk Impor:" msgid "Find" msgstr "" +msgid "Finish Migration" +msgstr "" + +msgid "Finish Migration to Multilingual Fields" +msgstr "" + +msgid "Fix in Project:" +msgstr "" + msgid "Font Size" msgstr "Ukuran Font" @@ -304,6 +361,12 @@ msgstr "" msgid "Generating RO-Crate metadata..." msgstr "" +msgid "Genres" +msgstr "" + +msgid "Getting project information from {0}..." +msgstr "" + msgid "Help" msgstr "Bantuan" @@ -349,6 +412,12 @@ msgstr "Impor {chosenCount} Sesi" msgid "Importing..." msgstr "Sedang mengimpor..." +msgid "In cloud only, and this computer appears to be offline." +msgstr "" + +msgid "In cloud only, lameta cannot read it yet." +msgstr "" + msgid "Indicates that this is the father or mother's primary language." msgstr "Menunjukkan bahwa ini adalah bahasa utama ayah atau ibu." @@ -400,6 +469,15 @@ msgstr "Folder Media" msgid "Media Folder Settings..." msgstr "Pengaturan Folder Media..." +msgid "Migrate" +msgstr "" + +msgid "Migrating: {0}" +msgstr "" + +msgid "Migration complete! {0} fields were migrated. You can hide language tags from View → \"Show language tags on multilingual fields\" if you don't need them anymore." +msgstr "" + msgid "Modified" msgstr "Dimodifikasi" @@ -409,18 +487,27 @@ msgstr "Kotak Lainnya" msgid "Name" msgstr "Nama" +msgid "Never" +msgstr "" + msgid "New Person" msgstr "Orang Baru" msgid "New Session" msgstr "Sesi Baru" +msgid "No custom values found" +msgstr "" + msgid "Normally, when you add a file to a project, session, or person, that file is copied into the same folder as you lameta project. For some of your files, you might prefer to instead leave the files where they are and just 'link' to them." msgstr "Biasanya, ketika Anda menambahkan file ke proyek, sesi, atau orang, file tersebut disalin ke dalam folder yang sama dengan proyek lameta Anda. Untuk beberapa file, mungkin Anda lebih suka tidak menyalin file tersebut dari lokasi asalnya dan hanya membuat 'tautan' ke file tersebut." msgid "Not important to the archive, but will be included" msgstr "" +msgid "Note" +msgstr "" + msgid "Note that this is a link to a file in your Media Files directory. Here, you are renaming this link, not the original file that it points to. If you later create an export that includes this file, the export will use this name." msgstr "Perhatikan bahwa ini adalah tautan ke file di direktori Media Files Anda. Di sini, Anda mengganti nama tautan ini, bukan file asli yang ditunjuknya. Jika nanti Anda membuat ekspor yang menyertakan file ini, ekspor tersebut akan menggunakan nama ini." @@ -436,6 +523,12 @@ msgstr "OPEX + File-File" msgid "One or more files are still being copied into your project." msgstr "Satu atau beberapa file masih dalam proses penyalinan ke proyek Anda." +msgid "Online-only ({providerName}), and this computer appears to be offline, so the file cannot be downloaded right now." +msgstr "" + +msgid "Online-only ({providerName}). The content of this file is not on this computer." +msgstr "" + msgid "Open" msgstr "Buka" @@ -472,6 +565,12 @@ msgstr "Orang" msgid "Please Register" msgstr "Tolong Daftarkan" +msgid "Please add the metadata languages you've been using with these \"/\" fields (at least two) before migrating." +msgstr "" + +msgid "Please complete the migration in the Project tab under Languages before exporting." +msgstr "" + msgid "Please remove characters that not allowed by the Archive Settings." msgstr "Silakan hapus karakter yang tidak diizinkan oleh Pengaturan Arsip." @@ -499,6 +598,9 @@ msgstr "" msgid "RO-Crate export complete" msgstr "" +msgid "Ready to scan..." +msgstr "" + msgid "Redo" msgstr "Ulangi" @@ -532,9 +634,18 @@ msgstr "Laporkan masalah" msgid "Restart lameta and do the rename before playing the video again." msgstr "Restart lameta dan lakukan penggantian nama sebelum memutar video lagi." +msgid "Retry" +msgstr "" + +msgid "Retrying…" +msgstr "" + msgid "Role" msgstr "Peran" +msgid "Roles" +msgstr "" + msgid "Save As" msgstr "Simpan Sebagai" @@ -544,6 +655,15 @@ msgstr "Sedang menyimpan" msgid "Saving before rename..." msgstr "Menyimpan sebelum ubah nama..." +msgid "Scan complete" +msgstr "" + +msgid "Scan failed" +msgstr "" + +msgid "Scanning..." +msgstr "" + msgid "Search" msgstr "" @@ -565,6 +685,12 @@ msgstr "Tampilkan Semua" msgid "Show IMDI previews" msgstr "" +msgid "Show Language Tags" +msgstr "" + +msgid "Show Language Tags (locked during migration)" +msgstr "" + msgid "Show PARADISEC previews" msgstr "" @@ -592,6 +718,9 @@ msgstr "Ukuran" msgid "Smaller" msgstr "Lebih kecil" +msgid "Some fields have more language segments than there are Metadata Languages defined ({0} of {1} fields). If you proceed, these extra segments will be labeled as \"unknown\" languages." +msgstr "" + msgid "Some operating systems would not allow that name." msgstr "Beberapa sistem operasi mungkin tidak mengizinkan nama tersebut." @@ -604,9 +733,18 @@ msgstr "Maaf, tidak diperbolehkan menggunakan tanda garis miring (\"/\")" msgid "Start Screen" msgstr "Layar Awal" +msgid "Starting scan..." +msgstr "" + msgid "Status" msgstr "Status" +msgid "Stop waiting" +msgstr "" + +msgid "Tell {providerName} that I want this file on my computer" +msgstr "" + msgid "Text" msgstr "Teks" @@ -616,6 +754,9 @@ msgstr "Nama itu akan menghasilkan nama file kosong." msgid "The IMDI files were validated." msgstr "File IMDI telah divalidasi." +msgid "The columns here are based on your project's metadata languages." +msgstr "" + msgid "The file is missing from its expected location in the Media Folder. The Media Folder is set to {m} and this file is supposed to be at {e}" msgstr "File tersebut tidak ada di lokasi yang diharapkan di Folder Media. Folder Media diatur ke {m} dan fili ini seharusnya berada di {e}" @@ -655,9 +796,15 @@ msgstr "." msgid "This file does not comply with the file naming rules of the current archive." msgstr "" +msgid "This file is online-only ({providerName}). Select it to see how to make it available on this device." +msgstr "" + msgid "This is a link, but lameta does not know where to find the Media Folder for this project. See File:Media Folder Settings." msgstr "Ini adalah sebuah tautan, namun lameta tidak tahu di mana Folder Media untuk proyek ini. Lihat file: Pengaturan Folder Media." +msgid "This project appears to use \"/\" to store multiple languages within a single field. In this version of lameta, we have a better approach in which lameta knows which languages are used in a field. After you have selected the appropriate metadata languages above and arranged them in the correct order, all multilingual fields in Project, Sessions, and People should display with the correct language tags. Once you have confirmed that everything appears as expected, click the button below to apply these changes permanently." +msgstr "" + msgid "This setting is unique to the account you are using on this computer. A colleague on another computer will need to set this value to the correct location on their computer." msgstr "Pengaturan ini unik untuk akun yang Anda gunakan di komputer ini. Seorang rekan di komputer lain perlu mengatur nilai ini ke lokasi yang benar di komputer mereka." @@ -670,6 +817,9 @@ msgstr "Tipe" msgid "Undo" msgstr "Batalkan" +msgid "Unknown error while scanning vocabulary" +msgstr "" + msgid "Update available" msgstr "Pembaruan/Update tersedia" @@ -688,6 +838,15 @@ msgstr "Lihat" msgid "View Release Notes" msgstr "Lihat Catatan Rilis" +msgid "Vocabulary Translations" +msgstr "" + +msgid "Vocabulary scan failed" +msgstr "" + +msgid "Waiting" +msgstr "" + msgid "" "We don't have specific tips for this error. If you cannot solve it by\n" "restarting your computer, please report the problem to\n" @@ -703,12 +862,21 @@ msgstr "" msgid "You are running lameta version:" msgstr "Anda menjalankan versi lameta:" +msgid "You can either add more Metadata Languages above to match all segments, or proceed with the migration and manually fix the unknown language fields later." +msgstr "" + msgid "You cannot add files of that type" msgstr "Tidak dapat tambah file jenis itu" msgid "You cannot add folders." msgstr "Anda tidak bisa menambahkan folder." +msgid "You need at least two metadata languages defined to use vocabulary translations. Please add metadata languages in the Languages tab." +msgstr "" + +msgid "You requested this file less than a minute ago. lameta will show it when it becomes available." +msgstr "" + msgid "ZIP Archive" msgstr "Arsip ZIP" @@ -742,6 +910,12 @@ msgstr "lameta tidak dapat menemukan Orang yang sesuai dengan kontributor beriku msgid "lameta could not rename the directory." msgstr "lameta tidak bisa mengubah nama direktori tersebut." +msgid "lameta couldn't download 1 file from {providerName}. It may be offline or busy right now." +msgstr "" + +msgid "lameta couldn't download {count} files from {providerName}. It may be offline or busy right now." +msgstr "" + msgid "lameta expected to find a .link file at {e}" msgstr "lameta mengharapkan menemukan sebuah file .link di {e}" @@ -751,6 +925,9 @@ msgstr "lameta mencoba mengubah nama folder \"{oldFolderName}\" menjadi \"{newFo msgid "lameta was not able to delete the file." msgstr "lameta tidak dapat menghapus file tersebut." +msgid "lameta was not able to make this file available on this device." +msgstr "" + msgid "lameta was not able to put this file in the trash" msgstr "lameta tidak dapat memindahkan file ini ke tempat sampah" @@ -767,8 +944,32 @@ msgstr "" msgid "lameta will now open this folder on your hard disk and then exit. You should open these {projectType} files in a text editor and decide which one you want, and delete the others. The one you choose should be named {name}." msgstr "Sekarang, lameta akan membuka folder ini di hard disk Anda dan kemudian akan keluar. Anda sebaiknya membuka file-file {projectType} ini di editor teks dan memutuskan mana yang ingin Anda gunakan, kemudian hapus yang lainnya. File yang Anda pilih sebaiknya dinamai {name}." +msgid "missing" +msgstr "" + +msgid "recorded when the file was last on this device" +msgstr "" + +msgid "the cloud" +msgstr "" + msgid "{0} Marked Sessions" msgstr "{0} Sesi yang Ditandai" +msgid "{0} of {1} items processed" +msgstr "" + msgid "{descriptionOfWhatWillBeDeleted} will be moved to the Trash" msgstr "{descriptionOfWhatWillBeDeleted} akan dipindahkan ke Tempat Sampah" + +msgid "{minutesSinceRequest, plural, one {# minute since you requested this file. lameta will show it when it becomes available.} other {# minutes since you requested this file. lameta will show it when it becomes available.}}" +msgstr "" + +msgid "{providerName} Status" +msgstr "" + +msgid "{providerName} is downloading this file to this computer." +msgstr "" + +msgid "{totalMissing} items need translation" +msgstr "" diff --git a/locale/ps/messages.po b/locale/ps/messages.po index 8419695a..d5f0e63c 100644 --- a/locale/ps/messages.po +++ b/locale/ps/messages.po @@ -40,6 +40,9 @@ msgstr "" msgid "About lameta" msgstr "" +msgid "Access" +msgstr "" + msgid "Add Files" msgstr "" @@ -58,6 +61,15 @@ msgstr "" msgid "All Sessions" msgstr "" +msgid "All translations complete" +msgstr "" + +msgid "Always" +msgstr "" + +msgid "Always available on this device." +msgstr "" + msgid "Archive" msgstr "" @@ -77,6 +89,15 @@ msgstr "" msgid "Audio" msgstr "" +msgid "Automatically make cloud files available if smaller than" +msgstr "" + +msgid "Available after this file is made available on this device" +msgstr "" + +msgid "Available on this device. {providerName} may free up this space if the file goes unused." +msgstr "" + msgid "CSV export complete" msgstr "" @@ -89,6 +110,9 @@ msgstr "" msgid "Cannot add files of that type" msgstr "" +msgid "Cannot export to IMDI while multilingual text migration is pending." +msgstr "" + msgid "Change Name To:" msgstr "" @@ -116,6 +140,9 @@ msgstr "" msgid "Close" msgstr "" +msgid "Cloud" +msgstr "" + msgid "Collection" msgstr "" @@ -152,6 +179,9 @@ msgstr "" msgid "Could not find this file. Restart lameta to bring it up to date with what is actually on your hard drive. {path}" msgstr "" +msgid "Could not read media information for this file." +msgstr "" + msgid "Create" msgstr "" @@ -176,6 +206,9 @@ msgstr "" msgid "Custom Field" msgstr "" +msgid "Custom vocabulary item" +msgstr "" + msgid "Cut" msgstr "" @@ -209,6 +242,9 @@ msgstr "" msgid "Description Documents" msgstr "" +msgid "Dismiss" +msgstr "" + msgid "Do not quit" msgstr "" @@ -221,6 +257,9 @@ msgstr "" msgid "Download" msgstr "" +msgid "Download this file to my computer" +msgstr "" + msgid "Duplicate Person" msgstr "" @@ -230,9 +269,15 @@ msgstr "" msgid "Edit" msgstr "" +msgid "ElectronDropZone: preload script did not set window.electronAPI.getPathForFile; drag-and-drop will not work." +msgstr "" + msgid "Email Address" msgstr "" +msgid "Example:" +msgstr "" + msgid "Export" msgstr "" @@ -254,6 +299,9 @@ msgstr "" msgid "Field" msgstr "" +msgid "Fields with Unknown Languages" +msgstr "" + msgid "File" msgstr "" @@ -266,6 +314,15 @@ msgstr "" msgid "Find" msgstr "" +msgid "Finish Migration" +msgstr "" + +msgid "Finish Migration to Multilingual Fields" +msgstr "" + +msgid "Fix in Project:" +msgstr "" + msgid "Font Size" msgstr "" @@ -299,6 +356,12 @@ msgstr "" msgid "Generating RO-Crate metadata..." msgstr "" +msgid "Genres" +msgstr "" + +msgid "Getting project information from {0}..." +msgstr "" + msgid "Help" msgstr "" @@ -344,6 +407,12 @@ msgstr "" msgid "Importing..." msgstr "" +msgid "In cloud only, and this computer appears to be offline." +msgstr "" + +msgid "In cloud only, lameta cannot read it yet." +msgstr "" + msgid "Indicates that this is the father or mother's primary language." msgstr "" @@ -395,6 +464,15 @@ msgstr "" msgid "Media Folder Settings..." msgstr "" +msgid "Migrate" +msgstr "" + +msgid "Migrating: {0}" +msgstr "" + +msgid "Migration complete! {0} fields were migrated. You can hide language tags from View → \"Show language tags on multilingual fields\" if you don't need them anymore." +msgstr "" + msgid "Modified" msgstr "" @@ -404,18 +482,27 @@ msgstr "" msgid "Name" msgstr "" +msgid "Never" +msgstr "" + msgid "New Person" msgstr "" msgid "New Session" msgstr "" +msgid "No custom values found" +msgstr "" + msgid "Normally, when you add a file to a project, session, or person, that file is copied into the same folder as you lameta project. For some of your files, you might prefer to instead leave the files where they are and just 'link' to them." msgstr "" msgid "Not important to the archive, but will be included" msgstr "" +msgid "Note" +msgstr "" + msgid "Note that this is a link to a file in your Media Files directory. Here, you are renaming this link, not the original file that it points to. If you later create an export that includes this file, the export will use this name." msgstr "" @@ -431,6 +518,12 @@ msgstr "" msgid "One or more files are still being copied into your project." msgstr "" +msgid "Online-only ({providerName}), and this computer appears to be offline, so the file cannot be downloaded right now." +msgstr "" + +msgid "Online-only ({providerName}). The content of this file is not on this computer." +msgstr "" + msgid "Open" msgstr "" @@ -467,6 +560,12 @@ msgstr "" msgid "Please Register" msgstr "" +msgid "Please add the metadata languages you've been using with these \"/\" fields (at least two) before migrating." +msgstr "" + +msgid "Please complete the migration in the Project tab under Languages before exporting." +msgstr "" + msgid "Please remove characters that not allowed by the Archive Settings." msgstr "" @@ -494,6 +593,9 @@ msgstr "" msgid "RO-Crate export complete" msgstr "" +msgid "Ready to scan..." +msgstr "" + msgid "Redo" msgstr "" @@ -527,9 +629,18 @@ msgstr "" msgid "Restart lameta and do the rename before playing the video again." msgstr "" +msgid "Retry" +msgstr "" + +msgid "Retrying…" +msgstr "" + msgid "Role" msgstr "" +msgid "Roles" +msgstr "" + msgid "Save As" msgstr "" @@ -539,6 +650,15 @@ msgstr "" msgid "Saving before rename..." msgstr "" +msgid "Scan complete" +msgstr "" + +msgid "Scan failed" +msgstr "" + +msgid "Scanning..." +msgstr "" + msgid "Search" msgstr "" @@ -560,6 +680,12 @@ msgstr "" msgid "Show IMDI previews" msgstr "" +msgid "Show Language Tags" +msgstr "" + +msgid "Show Language Tags (locked during migration)" +msgstr "" + msgid "Show PARADISEC previews" msgstr "" @@ -587,6 +713,9 @@ msgstr "" msgid "Smaller" msgstr "" +msgid "Some fields have more language segments than there are Metadata Languages defined ({0} of {1} fields). If you proceed, these extra segments will be labeled as \"unknown\" languages." +msgstr "" + msgid "Some operating systems would not allow that name." msgstr "" @@ -599,9 +728,18 @@ msgstr "" msgid "Start Screen" msgstr "" +msgid "Starting scan..." +msgstr "" + msgid "Status" msgstr "" +msgid "Stop waiting" +msgstr "" + +msgid "Tell {providerName} that I want this file on my computer" +msgstr "" + msgid "Text" msgstr "" @@ -611,6 +749,9 @@ msgstr "" msgid "The IMDI files were validated." msgstr "" +msgid "The columns here are based on your project's metadata languages." +msgstr "" + msgid "The file is missing from its expected location in the Media Folder. The Media Folder is set to {m} and this file is supposed to be at {e}" msgstr "" @@ -650,9 +791,15 @@ msgstr "" msgid "This file does not comply with the file naming rules of the current archive." msgstr "" +msgid "This file is online-only ({providerName}). Select it to see how to make it available on this device." +msgstr "" + msgid "This is a link, but lameta does not know where to find the Media Folder for this project. See File:Media Folder Settings." msgstr "" +msgid "This project appears to use \"/\" to store multiple languages within a single field. In this version of lameta, we have a better approach in which lameta knows which languages are used in a field. After you have selected the appropriate metadata languages above and arranged them in the correct order, all multilingual fields in Project, Sessions, and People should display with the correct language tags. Once you have confirmed that everything appears as expected, click the button below to apply these changes permanently." +msgstr "" + msgid "This setting is unique to the account you are using on this computer. A colleague on another computer will need to set this value to the correct location on their computer." msgstr "" @@ -665,6 +812,9 @@ msgstr "" msgid "Undo" msgstr "" +msgid "Unknown error while scanning vocabulary" +msgstr "" + msgid "Update available" msgstr "" @@ -683,6 +833,15 @@ msgstr "" msgid "View Release Notes" msgstr "" +msgid "Vocabulary Translations" +msgstr "" + +msgid "Vocabulary scan failed" +msgstr "" + +msgid "Waiting" +msgstr "" + msgid "" "We don't have specific tips for this error. If you cannot solve it by\n" "restarting your computer, please report the problem to\n" @@ -698,12 +857,21 @@ msgstr "" msgid "You are running lameta version:" msgstr "" +msgid "You can either add more Metadata Languages above to match all segments, or proceed with the migration and manually fix the unknown language fields later." +msgstr "" + msgid "You cannot add files of that type" msgstr "" msgid "You cannot add folders." msgstr "" +msgid "You need at least two metadata languages defined to use vocabulary translations. Please add metadata languages in the Languages tab." +msgstr "" + +msgid "You requested this file less than a minute ago. lameta will show it when it becomes available." +msgstr "" + msgid "ZIP Archive" msgstr "" @@ -737,6 +905,12 @@ msgstr "" msgid "lameta could not rename the directory." msgstr "" +msgid "lameta couldn't download 1 file from {providerName}. It may be offline or busy right now." +msgstr "" + +msgid "lameta couldn't download {count} files from {providerName}. It may be offline or busy right now." +msgstr "" + msgid "lameta expected to find a .link file at {e}" msgstr "" @@ -746,6 +920,9 @@ msgstr "" msgid "lameta was not able to delete the file." msgstr "" +msgid "lameta was not able to make this file available on this device." +msgstr "" + msgid "lameta was not able to put this file in the trash" msgstr "" @@ -762,8 +939,32 @@ msgstr "" msgid "lameta will now open this folder on your hard disk and then exit. You should open these {projectType} files in a text editor and decide which one you want, and delete the others. The one you choose should be named {name}." msgstr "" +msgid "missing" +msgstr "" + +msgid "recorded when the file was last on this device" +msgstr "" + +msgid "the cloud" +msgstr "" + msgid "{0} Marked Sessions" msgstr "" +msgid "{0} of {1} items processed" +msgstr "" + msgid "{descriptionOfWhatWillBeDeleted} will be moved to the Trash" msgstr "" + +msgid "{minutesSinceRequest, plural, one {# minute since you requested this file. lameta will show it when it becomes available.} other {# minutes since you requested this file. lameta will show it when it becomes available.}}" +msgstr "" + +msgid "{providerName} Status" +msgstr "" + +msgid "{providerName} is downloading this file to this computer." +msgstr "" + +msgid "{totalMissing} items need translation" +msgstr "" diff --git a/locale/pt-BR/messages.po b/locale/pt-BR/messages.po index 53301b14..6ef1e00d 100644 --- a/locale/pt-BR/messages.po +++ b/locale/pt-BR/messages.po @@ -40,6 +40,9 @@ msgstr "" msgid "About lameta" msgstr "" +msgid "Access" +msgstr "" + msgid "Add Files" msgstr "" @@ -58,6 +61,15 @@ msgstr "" msgid "All Sessions" msgstr "" +msgid "All translations complete" +msgstr "" + +msgid "Always" +msgstr "" + +msgid "Always available on this device." +msgstr "" + msgid "Archive" msgstr "" @@ -77,6 +89,15 @@ msgstr "" msgid "Audio" msgstr "" +msgid "Automatically make cloud files available if smaller than" +msgstr "" + +msgid "Available after this file is made available on this device" +msgstr "" + +msgid "Available on this device. {providerName} may free up this space if the file goes unused." +msgstr "" + msgid "CSV export complete" msgstr "" @@ -89,6 +110,9 @@ msgstr "" msgid "Cannot add files of that type" msgstr "" +msgid "Cannot export to IMDI while multilingual text migration is pending." +msgstr "" + msgid "Change Name To:" msgstr "" @@ -116,6 +140,9 @@ msgstr "" msgid "Close" msgstr "" +msgid "Cloud" +msgstr "" + msgid "Collection" msgstr "" @@ -152,6 +179,9 @@ msgstr "" msgid "Could not find this file. Restart lameta to bring it up to date with what is actually on your hard drive. {path}" msgstr "" +msgid "Could not read media information for this file." +msgstr "" + msgid "Create" msgstr "" @@ -176,6 +206,9 @@ msgstr "" msgid "Custom Field" msgstr "" +msgid "Custom vocabulary item" +msgstr "" + msgid "Cut" msgstr "" @@ -209,6 +242,9 @@ msgstr "" msgid "Description Documents" msgstr "" +msgid "Dismiss" +msgstr "" + msgid "Do not quit" msgstr "" @@ -221,6 +257,9 @@ msgstr "" msgid "Download" msgstr "" +msgid "Download this file to my computer" +msgstr "" + msgid "Duplicate Person" msgstr "" @@ -230,9 +269,15 @@ msgstr "" msgid "Edit" msgstr "" +msgid "ElectronDropZone: preload script did not set window.electronAPI.getPathForFile; drag-and-drop will not work." +msgstr "" + msgid "Email Address" msgstr "" +msgid "Example:" +msgstr "" + msgid "Export" msgstr "" @@ -254,6 +299,9 @@ msgstr "" msgid "Field" msgstr "" +msgid "Fields with Unknown Languages" +msgstr "" + msgid "File" msgstr "" @@ -266,6 +314,15 @@ msgstr "" msgid "Find" msgstr "" +msgid "Finish Migration" +msgstr "" + +msgid "Finish Migration to Multilingual Fields" +msgstr "" + +msgid "Fix in Project:" +msgstr "" + msgid "Font Size" msgstr "" @@ -299,6 +356,12 @@ msgstr "" msgid "Generating RO-Crate metadata..." msgstr "" +msgid "Genres" +msgstr "" + +msgid "Getting project information from {0}..." +msgstr "" + msgid "Help" msgstr "" @@ -344,6 +407,12 @@ msgstr "" msgid "Importing..." msgstr "" +msgid "In cloud only, and this computer appears to be offline." +msgstr "" + +msgid "In cloud only, lameta cannot read it yet." +msgstr "" + msgid "Indicates that this is the father or mother's primary language." msgstr "" @@ -395,6 +464,15 @@ msgstr "" msgid "Media Folder Settings..." msgstr "" +msgid "Migrate" +msgstr "" + +msgid "Migrating: {0}" +msgstr "" + +msgid "Migration complete! {0} fields were migrated. You can hide language tags from View → \"Show language tags on multilingual fields\" if you don't need them anymore." +msgstr "" + msgid "Modified" msgstr "" @@ -404,18 +482,27 @@ msgstr "" msgid "Name" msgstr "" +msgid "Never" +msgstr "" + msgid "New Person" msgstr "" msgid "New Session" msgstr "" +msgid "No custom values found" +msgstr "" + msgid "Normally, when you add a file to a project, session, or person, that file is copied into the same folder as you lameta project. For some of your files, you might prefer to instead leave the files where they are and just 'link' to them." msgstr "" msgid "Not important to the archive, but will be included" msgstr "" +msgid "Note" +msgstr "" + msgid "Note that this is a link to a file in your Media Files directory. Here, you are renaming this link, not the original file that it points to. If you later create an export that includes this file, the export will use this name." msgstr "" @@ -431,6 +518,12 @@ msgstr "" msgid "One or more files are still being copied into your project." msgstr "" +msgid "Online-only ({providerName}), and this computer appears to be offline, so the file cannot be downloaded right now." +msgstr "" + +msgid "Online-only ({providerName}). The content of this file is not on this computer." +msgstr "" + msgid "Open" msgstr "" @@ -467,6 +560,12 @@ msgstr "" msgid "Please Register" msgstr "" +msgid "Please add the metadata languages you've been using with these \"/\" fields (at least two) before migrating." +msgstr "" + +msgid "Please complete the migration in the Project tab under Languages before exporting." +msgstr "" + msgid "Please remove characters that not allowed by the Archive Settings." msgstr "" @@ -494,6 +593,9 @@ msgstr "" msgid "RO-Crate export complete" msgstr "" +msgid "Ready to scan..." +msgstr "" + msgid "Redo" msgstr "" @@ -527,9 +629,18 @@ msgstr "" msgid "Restart lameta and do the rename before playing the video again." msgstr "" +msgid "Retry" +msgstr "" + +msgid "Retrying…" +msgstr "" + msgid "Role" msgstr "" +msgid "Roles" +msgstr "" + msgid "Save As" msgstr "" @@ -539,6 +650,15 @@ msgstr "" msgid "Saving before rename..." msgstr "" +msgid "Scan complete" +msgstr "" + +msgid "Scan failed" +msgstr "" + +msgid "Scanning..." +msgstr "" + msgid "Search" msgstr "" @@ -560,6 +680,12 @@ msgstr "" msgid "Show IMDI previews" msgstr "" +msgid "Show Language Tags" +msgstr "" + +msgid "Show Language Tags (locked during migration)" +msgstr "" + msgid "Show PARADISEC previews" msgstr "" @@ -587,6 +713,9 @@ msgstr "" msgid "Smaller" msgstr "" +msgid "Some fields have more language segments than there are Metadata Languages defined ({0} of {1} fields). If you proceed, these extra segments will be labeled as \"unknown\" languages." +msgstr "" + msgid "Some operating systems would not allow that name." msgstr "" @@ -599,9 +728,18 @@ msgstr "" msgid "Start Screen" msgstr "" +msgid "Starting scan..." +msgstr "" + msgid "Status" msgstr "" +msgid "Stop waiting" +msgstr "" + +msgid "Tell {providerName} that I want this file on my computer" +msgstr "" + msgid "Text" msgstr "" @@ -611,6 +749,9 @@ msgstr "" msgid "The IMDI files were validated." msgstr "" +msgid "The columns here are based on your project's metadata languages." +msgstr "" + msgid "The file is missing from its expected location in the Media Folder. The Media Folder is set to {m} and this file is supposed to be at {e}" msgstr "" @@ -650,9 +791,15 @@ msgstr "" msgid "This file does not comply with the file naming rules of the current archive." msgstr "" +msgid "This file is online-only ({providerName}). Select it to see how to make it available on this device." +msgstr "" + msgid "This is a link, but lameta does not know where to find the Media Folder for this project. See File:Media Folder Settings." msgstr "" +msgid "This project appears to use \"/\" to store multiple languages within a single field. In this version of lameta, we have a better approach in which lameta knows which languages are used in a field. After you have selected the appropriate metadata languages above and arranged them in the correct order, all multilingual fields in Project, Sessions, and People should display with the correct language tags. Once you have confirmed that everything appears as expected, click the button below to apply these changes permanently." +msgstr "" + msgid "This setting is unique to the account you are using on this computer. A colleague on another computer will need to set this value to the correct location on their computer." msgstr "" @@ -665,6 +812,9 @@ msgstr "" msgid "Undo" msgstr "" +msgid "Unknown error while scanning vocabulary" +msgstr "" + msgid "Update available" msgstr "" @@ -683,6 +833,15 @@ msgstr "" msgid "View Release Notes" msgstr "" +msgid "Vocabulary Translations" +msgstr "" + +msgid "Vocabulary scan failed" +msgstr "" + +msgid "Waiting" +msgstr "" + msgid "" "We don't have specific tips for this error. If you cannot solve it by\n" "restarting your computer, please report the problem to\n" @@ -698,12 +857,21 @@ msgstr "" msgid "You are running lameta version:" msgstr "" +msgid "You can either add more Metadata Languages above to match all segments, or proceed with the migration and manually fix the unknown language fields later." +msgstr "" + msgid "You cannot add files of that type" msgstr "" msgid "You cannot add folders." msgstr "" +msgid "You need at least two metadata languages defined to use vocabulary translations. Please add metadata languages in the Languages tab." +msgstr "" + +msgid "You requested this file less than a minute ago. lameta will show it when it becomes available." +msgstr "" + msgid "ZIP Archive" msgstr "" @@ -737,6 +905,12 @@ msgstr "" msgid "lameta could not rename the directory." msgstr "" +msgid "lameta couldn't download 1 file from {providerName}. It may be offline or busy right now." +msgstr "" + +msgid "lameta couldn't download {count} files from {providerName}. It may be offline or busy right now." +msgstr "" + msgid "lameta expected to find a .link file at {e}" msgstr "" @@ -746,6 +920,9 @@ msgstr "" msgid "lameta was not able to delete the file." msgstr "" +msgid "lameta was not able to make this file available on this device." +msgstr "" + msgid "lameta was not able to put this file in the trash" msgstr "" @@ -762,8 +939,32 @@ msgstr "" msgid "lameta will now open this folder on your hard disk and then exit. You should open these {projectType} files in a text editor and decide which one you want, and delete the others. The one you choose should be named {name}." msgstr "" +msgid "missing" +msgstr "" + +msgid "recorded when the file was last on this device" +msgstr "" + +msgid "the cloud" +msgstr "" + msgid "{0} Marked Sessions" msgstr "" +msgid "{0} of {1} items processed" +msgstr "" + msgid "{descriptionOfWhatWillBeDeleted} will be moved to the Trash" msgstr "" + +msgid "{minutesSinceRequest, plural, one {# minute since you requested this file. lameta will show it when it becomes available.} other {# minutes since you requested this file. lameta will show it when it becomes available.}}" +msgstr "" + +msgid "{providerName} Status" +msgstr "" + +msgid "{providerName} is downloading this file to this computer." +msgstr "" + +msgid "{totalMissing} items need translation" +msgstr "" diff --git a/locale/ru/messages.po b/locale/ru/messages.po index ba8c56dd..e823b921 100644 --- a/locale/ru/messages.po +++ b/locale/ru/messages.po @@ -45,6 +45,9 @@ msgstr "" msgid "About lameta" msgstr "" +msgid "Access" +msgstr "" + msgid "Add Files" msgstr "Добавление файлов" @@ -63,6 +66,15 @@ msgstr "" msgid "All Sessions" msgstr "" +msgid "All translations complete" +msgstr "" + +msgid "Always" +msgstr "" + +msgid "Always available on this device." +msgstr "" + msgid "Archive" msgstr "" @@ -82,6 +94,15 @@ msgstr "" msgid "Audio" msgstr "Аудио" +msgid "Automatically make cloud files available if smaller than" +msgstr "" + +msgid "Available after this file is made available on this device" +msgstr "" + +msgid "Available on this device. {providerName} may free up this space if the file goes unused." +msgstr "" + msgid "CSV export complete" msgstr "" @@ -94,6 +115,9 @@ msgstr "" msgid "Cannot add files of that type" msgstr "" +msgid "Cannot export to IMDI while multilingual text migration is pending." +msgstr "" + msgid "Change Name To:" msgstr "Изменить имя на:" @@ -121,6 +145,9 @@ msgstr "" msgid "Close" msgstr "Закрыть" +msgid "Cloud" +msgstr "" + msgid "Collection" msgstr "" @@ -157,6 +184,9 @@ msgstr "" msgid "Could not find this file. Restart lameta to bring it up to date with what is actually on your hard drive. {path}" msgstr "" +msgid "Could not read media information for this file." +msgstr "" + msgid "Create" msgstr "" @@ -181,6 +211,9 @@ msgstr "" msgid "Custom Field" msgstr "" +msgid "Custom vocabulary item" +msgstr "" + msgid "Cut" msgstr "" @@ -214,6 +247,9 @@ msgstr "" msgid "Description Documents" msgstr "Документы описания" +msgid "Dismiss" +msgstr "" + msgid "Do not quit" msgstr "" @@ -226,6 +262,9 @@ msgstr "" msgid "Download" msgstr "" +msgid "Download this file to my computer" +msgstr "" + msgid "Duplicate Person" msgstr "" @@ -235,9 +274,15 @@ msgstr "" msgid "Edit" msgstr "" +msgid "ElectronDropZone: preload script did not set window.electronAPI.getPathForFile; drag-and-drop will not work." +msgstr "" + msgid "Email Address" msgstr "" +msgid "Example:" +msgstr "" + msgid "Export" msgstr "Экспортировать" @@ -259,6 +304,9 @@ msgstr "" msgid "Field" msgstr "Поле" +msgid "Fields with Unknown Languages" +msgstr "" + msgid "File" msgstr "" @@ -271,6 +319,15 @@ msgstr "" msgid "Find" msgstr "" +msgid "Finish Migration" +msgstr "" + +msgid "Finish Migration to Multilingual Fields" +msgstr "" + +msgid "Fix in Project:" +msgstr "" + msgid "Font Size" msgstr "" @@ -304,6 +361,12 @@ msgstr "" msgid "Generating RO-Crate metadata..." msgstr "" +msgid "Genres" +msgstr "" + +msgid "Getting project information from {0}..." +msgstr "" + msgid "Help" msgstr "Справка" @@ -349,6 +412,12 @@ msgstr "" msgid "Importing..." msgstr "" +msgid "In cloud only, and this computer appears to be offline." +msgstr "" + +msgid "In cloud only, lameta cannot read it yet." +msgstr "" + msgid "Indicates that this is the father or mother's primary language." msgstr "" @@ -400,6 +469,15 @@ msgstr "" msgid "Media Folder Settings..." msgstr "" +msgid "Migrate" +msgstr "" + +msgid "Migrating: {0}" +msgstr "" + +msgid "Migration complete! {0} fields were migrated. You can hide language tags from View → \"Show language tags on multilingual fields\" if you don't need them anymore." +msgstr "" + msgid "Modified" msgstr "" @@ -409,18 +487,27 @@ msgstr "" msgid "Name" msgstr "Имя" +msgid "Never" +msgstr "" + msgid "New Person" msgstr "Новое лицо" msgid "New Session" msgstr "Новое событие" +msgid "No custom values found" +msgstr "" + msgid "Normally, when you add a file to a project, session, or person, that file is copied into the same folder as you lameta project. For some of your files, you might prefer to instead leave the files where they are and just 'link' to them." msgstr "" msgid "Not important to the archive, but will be included" msgstr "" +msgid "Note" +msgstr "" + msgid "Note that this is a link to a file in your Media Files directory. Here, you are renaming this link, not the original file that it points to. If you later create an export that includes this file, the export will use this name." msgstr "" @@ -436,6 +523,12 @@ msgstr "" msgid "One or more files are still being copied into your project." msgstr "" +msgid "Online-only ({providerName}), and this computer appears to be offline, so the file cannot be downloaded right now." +msgstr "" + +msgid "Online-only ({providerName}). The content of this file is not on this computer." +msgstr "" + msgid "Open" msgstr "Открыть" @@ -472,6 +565,12 @@ msgstr "Лицо" msgid "Please Register" msgstr "" +msgid "Please add the metadata languages you've been using with these \"/\" fields (at least two) before migrating." +msgstr "" + +msgid "Please complete the migration in the Project tab under Languages before exporting." +msgstr "" + msgid "Please remove characters that not allowed by the Archive Settings." msgstr "" @@ -499,6 +598,9 @@ msgstr "" msgid "RO-Crate export complete" msgstr "" +msgid "Ready to scan..." +msgstr "" + msgid "Redo" msgstr "" @@ -532,9 +634,18 @@ msgstr "" msgid "Restart lameta and do the rename before playing the video again." msgstr "" +msgid "Retry" +msgstr "" + +msgid "Retrying…" +msgstr "" + msgid "Role" msgstr "Роль" +msgid "Roles" +msgstr "" + msgid "Save As" msgstr "" @@ -544,6 +655,15 @@ msgstr "" msgid "Saving before rename..." msgstr "" +msgid "Scan complete" +msgstr "" + +msgid "Scan failed" +msgstr "" + +msgid "Scanning..." +msgstr "" + msgid "Search" msgstr "" @@ -565,6 +685,12 @@ msgstr "" msgid "Show IMDI previews" msgstr "" +msgid "Show Language Tags" +msgstr "" + +msgid "Show Language Tags (locked during migration)" +msgstr "" + msgid "Show PARADISEC previews" msgstr "" @@ -592,6 +718,9 @@ msgstr "Размер" msgid "Smaller" msgstr "" +msgid "Some fields have more language segments than there are Metadata Languages defined ({0} of {1} fields). If you proceed, these extra segments will be labeled as \"unknown\" languages." +msgstr "" + msgid "Some operating systems would not allow that name." msgstr "" @@ -604,9 +733,18 @@ msgstr "" msgid "Start Screen" msgstr "" +msgid "Starting scan..." +msgstr "" + msgid "Status" msgstr "Статус" +msgid "Stop waiting" +msgstr "" + +msgid "Tell {providerName} that I want this file on my computer" +msgstr "" + msgid "Text" msgstr "" @@ -616,6 +754,9 @@ msgstr "" msgid "The IMDI files were validated." msgstr "" +msgid "The columns here are based on your project's metadata languages." +msgstr "" + msgid "The file is missing from its expected location in the Media Folder. The Media Folder is set to {m} and this file is supposed to be at {e}" msgstr "" @@ -655,9 +796,15 @@ msgstr "" msgid "This file does not comply with the file naming rules of the current archive." msgstr "" +msgid "This file is online-only ({providerName}). Select it to see how to make it available on this device." +msgstr "" + msgid "This is a link, but lameta does not know where to find the Media Folder for this project. See File:Media Folder Settings." msgstr "" +msgid "This project appears to use \"/\" to store multiple languages within a single field. In this version of lameta, we have a better approach in which lameta knows which languages are used in a field. After you have selected the appropriate metadata languages above and arranged them in the correct order, all multilingual fields in Project, Sessions, and People should display with the correct language tags. Once you have confirmed that everything appears as expected, click the button below to apply these changes permanently." +msgstr "" + msgid "This setting is unique to the account you are using on this computer. A colleague on another computer will need to set this value to the correct location on their computer." msgstr "" @@ -670,6 +817,9 @@ msgstr "Тип" msgid "Undo" msgstr "Отменить" +msgid "Unknown error while scanning vocabulary" +msgstr "" + msgid "Update available" msgstr "" @@ -688,6 +838,15 @@ msgstr "Вид" msgid "View Release Notes" msgstr "" +msgid "Vocabulary Translations" +msgstr "" + +msgid "Vocabulary scan failed" +msgstr "" + +msgid "Waiting" +msgstr "" + msgid "" "We don't have specific tips for this error. If you cannot solve it by\n" "restarting your computer, please report the problem to\n" @@ -703,12 +862,21 @@ msgstr "" msgid "You are running lameta version:" msgstr "" +msgid "You can either add more Metadata Languages above to match all segments, or proceed with the migration and manually fix the unknown language fields later." +msgstr "" + msgid "You cannot add files of that type" msgstr "" msgid "You cannot add folders." msgstr "" +msgid "You need at least two metadata languages defined to use vocabulary translations. Please add metadata languages in the Languages tab." +msgstr "" + +msgid "You requested this file less than a minute ago. lameta will show it when it becomes available." +msgstr "" + msgid "ZIP Archive" msgstr "" @@ -742,6 +910,12 @@ msgstr "" msgid "lameta could not rename the directory." msgstr "" +msgid "lameta couldn't download 1 file from {providerName}. It may be offline or busy right now." +msgstr "" + +msgid "lameta couldn't download {count} files from {providerName}. It may be offline or busy right now." +msgstr "" + msgid "lameta expected to find a .link file at {e}" msgstr "" @@ -751,6 +925,9 @@ msgstr "" msgid "lameta was not able to delete the file." msgstr "" +msgid "lameta was not able to make this file available on this device." +msgstr "" + msgid "lameta was not able to put this file in the trash" msgstr "" @@ -767,8 +944,32 @@ msgstr "" msgid "lameta will now open this folder on your hard disk and then exit. You should open these {projectType} files in a text editor and decide which one you want, and delete the others. The one you choose should be named {name}." msgstr "" +msgid "missing" +msgstr "" + +msgid "recorded when the file was last on this device" +msgstr "" + +msgid "the cloud" +msgstr "" + msgid "{0} Marked Sessions" msgstr "" +msgid "{0} of {1} items processed" +msgstr "" + msgid "{descriptionOfWhatWillBeDeleted} will be moved to the Trash" msgstr "" + +msgid "{minutesSinceRequest, plural, one {# minute since you requested this file. lameta will show it when it becomes available.} other {# minutes since you requested this file. lameta will show it when it becomes available.}}" +msgstr "" + +msgid "{providerName} Status" +msgstr "" + +msgid "{providerName} is downloading this file to this computer." +msgstr "" + +msgid "{totalMissing} items need translation" +msgstr "" diff --git a/locale/zh-cn/messages.po b/locale/zh-cn/messages.po index 6bcb63fc..293c7731 100644 --- a/locale/zh-cn/messages.po +++ b/locale/zh-cn/messages.po @@ -43,6 +43,9 @@ msgstr "" msgid "About lameta" msgstr "" +msgid "Access" +msgstr "" + msgid "Add Files" msgstr "" @@ -61,6 +64,15 @@ msgstr "" msgid "All Sessions" msgstr "" +msgid "All translations complete" +msgstr "" + +msgid "Always" +msgstr "" + +msgid "Always available on this device." +msgstr "" + msgid "Archive" msgstr "" @@ -80,6 +92,15 @@ msgstr "" msgid "Audio" msgstr "" +msgid "Automatically make cloud files available if smaller than" +msgstr "" + +msgid "Available after this file is made available on this device" +msgstr "" + +msgid "Available on this device. {providerName} may free up this space if the file goes unused." +msgstr "" + msgid "CSV export complete" msgstr "" @@ -92,6 +113,9 @@ msgstr "" msgid "Cannot add files of that type" msgstr "" +msgid "Cannot export to IMDI while multilingual text migration is pending." +msgstr "" + msgid "Change Name To:" msgstr "" @@ -119,6 +143,9 @@ msgstr "" msgid "Close" msgstr "" +msgid "Cloud" +msgstr "" + msgid "Collection" msgstr "" @@ -155,6 +182,9 @@ msgstr "" msgid "Could not find this file. Restart lameta to bring it up to date with what is actually on your hard drive. {path}" msgstr "" +msgid "Could not read media information for this file." +msgstr "" + msgid "Create" msgstr "" @@ -179,6 +209,9 @@ msgstr "" msgid "Custom Field" msgstr "" +msgid "Custom vocabulary item" +msgstr "" + msgid "Cut" msgstr "" @@ -212,6 +245,9 @@ msgstr "" msgid "Description Documents" msgstr "" +msgid "Dismiss" +msgstr "" + msgid "Do not quit" msgstr "" @@ -224,6 +260,9 @@ msgstr "" msgid "Download" msgstr "" +msgid "Download this file to my computer" +msgstr "" + msgid "Duplicate Person" msgstr "" @@ -233,9 +272,15 @@ msgstr "" msgid "Edit" msgstr "" +msgid "ElectronDropZone: preload script did not set window.electronAPI.getPathForFile; drag-and-drop will not work." +msgstr "" + msgid "Email Address" msgstr "" +msgid "Example:" +msgstr "" + msgid "Export" msgstr "" @@ -257,6 +302,9 @@ msgstr "" msgid "Field" msgstr "" +msgid "Fields with Unknown Languages" +msgstr "" + msgid "File" msgstr "" @@ -269,6 +317,15 @@ msgstr "" msgid "Find" msgstr "" +msgid "Finish Migration" +msgstr "" + +msgid "Finish Migration to Multilingual Fields" +msgstr "" + +msgid "Fix in Project:" +msgstr "" + msgid "Font Size" msgstr "" @@ -302,6 +359,12 @@ msgstr "" msgid "Generating RO-Crate metadata..." msgstr "" +msgid "Genres" +msgstr "" + +msgid "Getting project information from {0}..." +msgstr "" + msgid "Help" msgstr "" @@ -347,6 +410,12 @@ msgstr "" msgid "Importing..." msgstr "" +msgid "In cloud only, and this computer appears to be offline." +msgstr "" + +msgid "In cloud only, lameta cannot read it yet." +msgstr "" + msgid "Indicates that this is the father or mother's primary language." msgstr "" @@ -398,6 +467,15 @@ msgstr "" msgid "Media Folder Settings..." msgstr "" +msgid "Migrate" +msgstr "" + +msgid "Migrating: {0}" +msgstr "" + +msgid "Migration complete! {0} fields were migrated. You can hide language tags from View → \"Show language tags on multilingual fields\" if you don't need them anymore." +msgstr "" + msgid "Modified" msgstr "" @@ -407,18 +485,27 @@ msgstr "" msgid "Name" msgstr "" +msgid "Never" +msgstr "" + msgid "New Person" msgstr "" msgid "New Session" msgstr "" +msgid "No custom values found" +msgstr "" + msgid "Normally, when you add a file to a project, session, or person, that file is copied into the same folder as you lameta project. For some of your files, you might prefer to instead leave the files where they are and just 'link' to them." msgstr "" msgid "Not important to the archive, but will be included" msgstr "" +msgid "Note" +msgstr "" + msgid "Note that this is a link to a file in your Media Files directory. Here, you are renaming this link, not the original file that it points to. If you later create an export that includes this file, the export will use this name." msgstr "" @@ -434,6 +521,12 @@ msgstr "" msgid "One or more files are still being copied into your project." msgstr "" +msgid "Online-only ({providerName}), and this computer appears to be offline, so the file cannot be downloaded right now." +msgstr "" + +msgid "Online-only ({providerName}). The content of this file is not on this computer." +msgstr "" + msgid "Open" msgstr "" @@ -470,6 +563,12 @@ msgstr "" msgid "Please Register" msgstr "" +msgid "Please add the metadata languages you've been using with these \"/\" fields (at least two) before migrating." +msgstr "" + +msgid "Please complete the migration in the Project tab under Languages before exporting." +msgstr "" + msgid "Please remove characters that not allowed by the Archive Settings." msgstr "" @@ -497,6 +596,9 @@ msgstr "" msgid "RO-Crate export complete" msgstr "" +msgid "Ready to scan..." +msgstr "" + msgid "Redo" msgstr "" @@ -530,9 +632,18 @@ msgstr "" msgid "Restart lameta and do the rename before playing the video again." msgstr "" +msgid "Retry" +msgstr "" + +msgid "Retrying…" +msgstr "" + msgid "Role" msgstr "" +msgid "Roles" +msgstr "" + msgid "Save As" msgstr "" @@ -542,6 +653,15 @@ msgstr "" msgid "Saving before rename..." msgstr "" +msgid "Scan complete" +msgstr "" + +msgid "Scan failed" +msgstr "" + +msgid "Scanning..." +msgstr "" + msgid "Search" msgstr "" @@ -563,6 +683,12 @@ msgstr "" msgid "Show IMDI previews" msgstr "" +msgid "Show Language Tags" +msgstr "" + +msgid "Show Language Tags (locked during migration)" +msgstr "" + msgid "Show PARADISEC previews" msgstr "" @@ -590,6 +716,9 @@ msgstr "" msgid "Smaller" msgstr "" +msgid "Some fields have more language segments than there are Metadata Languages defined ({0} of {1} fields). If you proceed, these extra segments will be labeled as \"unknown\" languages." +msgstr "" + msgid "Some operating systems would not allow that name." msgstr "" @@ -602,9 +731,18 @@ msgstr "" msgid "Start Screen" msgstr "" +msgid "Starting scan..." +msgstr "" + msgid "Status" msgstr "" +msgid "Stop waiting" +msgstr "" + +msgid "Tell {providerName} that I want this file on my computer" +msgstr "" + msgid "Text" msgstr "" @@ -614,6 +752,9 @@ msgstr "" msgid "The IMDI files were validated." msgstr "" +msgid "The columns here are based on your project's metadata languages." +msgstr "" + msgid "The file is missing from its expected location in the Media Folder. The Media Folder is set to {m} and this file is supposed to be at {e}" msgstr "" @@ -653,9 +794,15 @@ msgstr "" msgid "This file does not comply with the file naming rules of the current archive." msgstr "" +msgid "This file is online-only ({providerName}). Select it to see how to make it available on this device." +msgstr "" + msgid "This is a link, but lameta does not know where to find the Media Folder for this project. See File:Media Folder Settings." msgstr "" +msgid "This project appears to use \"/\" to store multiple languages within a single field. In this version of lameta, we have a better approach in which lameta knows which languages are used in a field. After you have selected the appropriate metadata languages above and arranged them in the correct order, all multilingual fields in Project, Sessions, and People should display with the correct language tags. Once you have confirmed that everything appears as expected, click the button below to apply these changes permanently." +msgstr "" + msgid "This setting is unique to the account you are using on this computer. A colleague on another computer will need to set this value to the correct location on their computer." msgstr "" @@ -668,6 +815,9 @@ msgstr "" msgid "Undo" msgstr "" +msgid "Unknown error while scanning vocabulary" +msgstr "" + msgid "Update available" msgstr "" @@ -686,6 +836,15 @@ msgstr "" msgid "View Release Notes" msgstr "" +msgid "Vocabulary Translations" +msgstr "" + +msgid "Vocabulary scan failed" +msgstr "" + +msgid "Waiting" +msgstr "" + msgid "" "We don't have specific tips for this error. If you cannot solve it by\n" "restarting your computer, please report the problem to\n" @@ -701,12 +860,21 @@ msgstr "" msgid "You are running lameta version:" msgstr "" +msgid "You can either add more Metadata Languages above to match all segments, or proceed with the migration and manually fix the unknown language fields later." +msgstr "" + msgid "You cannot add files of that type" msgstr "" msgid "You cannot add folders." msgstr "" +msgid "You need at least two metadata languages defined to use vocabulary translations. Please add metadata languages in the Languages tab." +msgstr "" + +msgid "You requested this file less than a minute ago. lameta will show it when it becomes available." +msgstr "" + msgid "ZIP Archive" msgstr "" @@ -740,6 +908,12 @@ msgstr "" msgid "lameta could not rename the directory." msgstr "" +msgid "lameta couldn't download 1 file from {providerName}. It may be offline or busy right now." +msgstr "" + +msgid "lameta couldn't download {count} files from {providerName}. It may be offline or busy right now." +msgstr "" + msgid "lameta expected to find a .link file at {e}" msgstr "" @@ -749,6 +923,9 @@ msgstr "" msgid "lameta was not able to delete the file." msgstr "" +msgid "lameta was not able to make this file available on this device." +msgstr "" + msgid "lameta was not able to put this file in the trash" msgstr "" @@ -765,8 +942,32 @@ msgstr "" msgid "lameta will now open this folder on your hard disk and then exit. You should open these {projectType} files in a text editor and decide which one you want, and delete the others. The one you choose should be named {name}." msgstr "" +msgid "missing" +msgstr "" + +msgid "recorded when the file was last on this device" +msgstr "" + +msgid "the cloud" +msgstr "" + msgid "{0} Marked Sessions" msgstr "" +msgid "{0} of {1} items processed" +msgstr "" + msgid "{descriptionOfWhatWillBeDeleted} will be moved to the Trash" msgstr "" + +msgid "{minutesSinceRequest, plural, one {# minute since you requested this file. lameta will show it when it becomes available.} other {# minutes since you requested this file. lameta will show it when it becomes available.}}" +msgstr "" + +msgid "{providerName} Status" +msgstr "" + +msgid "{providerName} is downloading this file to this computer." +msgstr "" + +msgid "{totalMissing} items need translation" +msgstr "" diff --git a/package.json b/package.json index 9de1f36a..529234c3 100644 --- a/package.json +++ b/package.json @@ -198,7 +198,6 @@ "fs-extra": "^11.2.0", "fuse.js": "^7.0.0", "glob": "^9.3.3", - "graceful-fs": "^4.2.11", "history": "^4.6.1", "hotkeys-js": "^3.3.1", "humanize-duration": "^3.15.3", @@ -276,5 +275,8 @@ "vite-electron-plugin": "upgrade from 12 to 14 broke because they apparently don't support node in renderer anymore", "electron-builder": "upgrade from 25 to 26 broke, package just hangs.", "react-dropzone": "They broke electron in 14.3.0 https://github.com/react-dropzone/react-dropzone/issues/1411" + }, + "optionalDependencies": { + "fswin": "^3.25.1108" } } diff --git a/readme-cloud-testing.md b/readme-cloud-testing.md new file mode 100644 index 00000000..c65f513c --- /dev/null +++ b/readme-cloud-testing.md @@ -0,0 +1,256 @@ +# Manual test plan: cloud sync (macOS + Windows) + +How to gain confidence in lameta's cloud-sync support, with emphasis on boundary +conditions that could lose data or crash the app at startup. Written for a human +tester on macOS 12.3+ (FileProvider era); a Windows regression pass is at the end. + +Implementation background that shapes these tests: + +- lameta detects cloud placeholders by **dataless stat** (`size > 0, blocks == 0`) + but only for paths under a known **sync root**: `~/Library/CloudStorage/*` + (OneDrive, Dropbox, Google Drive, Box), home-folder symlinks into it + (`~/OneDrive`), iCloud Drive (`~/Library/Mobile Documents/com~apple~CloudDocs`), + and — when Desktop & Documents sync is on — `~/Desktop` and `~/Documents`. +- "Download" on macOS is a **one-shot fetch** (there is no durable pin like + Windows' "Always keep on this device"); the provider may re-evict files later. +- At project load, lameta reads every `.sprj` / `.session` / `.person` / `.meta` + file synchronously; evicted ones hydrate on demand during that read. A failed + hydration should trip a **circuit breaker**: one "couldn't reach {provider}" + banner with Retry — never a storm of error dialogs, never an aborted load. +- Auto-fetch: selecting a cloud-only file smaller than the threshold + (**default 10 MB**, File menu) downloads it after a ~1.5 s dwell. Skipped while + offline. +- Offline detection uses the OS "network up" signal (`navigator.onLine`). + +## Tester's toolbox + +| Action | How | +|---|---| +| Evict an iCloud file (make it cloud-only) | `brctl evict <file>` in Terminal, or Finder → right-click → Remove Download | +| Evict a OneDrive/Dropbox/GDrive file | Finder → right-click → Remove Download, or `fileproviderctl evict <file>` | +| Check whether a file is really dataless | `stat -f "blocks=%b size=%z" <file>` → dataless = `blocks=0` with `size>0` | +| Force download outside lameta | open the file in Finder, or `brctl download <file>` (iCloud) | +| Go offline | Wi-Fi off in menu bar (see also the "LAN but no internet" case below) | +| Pause OneDrive without going offline | OneDrive menu-bar icon → Pause syncing | +| Watch lameta's logs | run a dev build (`yarn dev`) and watch the terminal, or Console.app filtered to lameta | + +Use a **throwaway copy** of a real project for anything in the data-loss section. +Keep a pristine zip of it outside any cloud folder so you can diff afterwards +(`diff -r pristine/ tested/` ignoring media). + +--- + +## 1. Smoke test, per provider + +Run this list once per provider you care about — at minimum **OneDrive** and +**iCloud Drive** (they use different plumbing), ideally also Dropbox and Google +Drive (untested by the developers so far): + +- [ ] Put a project in the provider's folder, let it sync, evict all media. +- [ ] Open the project: evicted files show a **blue cloud icon** and grey name; + local files show no icon. +- [ ] Select an evicted file > 10 MB: the "{Provider} Status" card appears with + the provider's real name in the title, and a green **Download this file to + my computer** button. It must NOT download by itself. +- [ ] Click Download: card shows "Waiting" + spinner, row icon becomes sync + arrows; when the download finishes, the preview (player/image/text) + appears by itself — no reselect needed. +- [ ] Select an evicted file < 10 MB and just wait ~2 s: it downloads by itself + (auto-fetch) and the preview appears. +- [ ] "Stop waiting" during a large download returns the card to the Download + state (the provider may quietly finish the download anyway — that's fine). +- [ ] "Show in Finder" opens the enclosing folder. +- [ ] Open the same project through the other path spelling where applicable: + `~/OneDrive/...` (symlink) vs `~/Library/CloudStorage/OneDrive-.../...`. + Both must behave identically. +- [ ] iCloud only: put the project in `~/Documents` (with Desktop & Documents + sync on) rather than in iCloud Drive proper; icons and card must still + appear and say "iCloud Drive". + +## 2. Startup / project-load boundaries (crash hunting) + +These are the highest-value crash tests. After each, the bar is: **lameta opens, +shows what it can, and shows at most one banner** — no crash, no dialog storm, +no half-written files. + +- [ ] **Everything evicted, online.** Evict the entire project folder including + the `.sprj` itself (Finder → Remove Download on the folder). Open lameta. + Expect: project loads with all metadata intact. lameta prefetches all + evicted metadata files in parallel (watch for the + `[cloudMetadataPrefetch] Prefetching N…` log line), so the load should + take roughly as long as the slowest single file, not minutes — but the UI + still freezes without feedback for that duration. Note how bad it feels; + a progress indicator may still be warranted. +- [ ] **Everything evicted, offline.** Same starting point, Wi-Fi off, open + lameta. Expect: single "couldn't reach {provider}" banner with Retry; rows + show a cloud-unavailable look; NO crash; NO storm of provider error + dialogs. Turn Wi-Fi on, press Retry: everything loads. +- [ ] **Metadata evicted, media local** and the reverse. Both should load fine. +- [ ] **Wi-Fi dies mid-load.** Start opening a fully-evicted project online, + turn Wi-Fi off while sessions are still appearing. Expect: banner for the + remainder, already-loaded sessions fine, Retry finishes the job later. +- [ ] **Provider app not running.** Quit OneDrive completely (menu bar icon → + Quit), then open a project inside the OneDrive folder with evicted files. + Expect: files may read as unavailable and stat calls can take a long time + (macOS gives "Operation timed out" after ~1 min per access when the + provider extension is dead) — lameta must survive it, however slowly, and + recover after OneDrive restarts. This is a known rough edge: note the hang + duration. +- [ ] **Provider signed out / mid-setup.** Sign out of OneDrive (or test right + after first sign-in while it's still indexing) and open the project. + Same expectation as above. +- [ ] **Zero-byte and truncated metadata.** Manually create a 0-byte + `x.session` file and a `y.session` containing half an XML document inside + a session folder (simulates an interrupted sync). Open the project. + Expect: one error message per broken file — not a crash. lameta now + refuses to save over an unparseable metadata file (unit-tested), so after + editing and quitting, verify the broken file's bytes on disk are + unchanged (recoverable by hand or from the provider's version history). +- [ ] **Legacy iCloud placeholder.** If you can find/construct one, a session + folder where the real file is missing and only `.name.session.icloud` + exists. Expect: treated as cloud-only, not as "file missing". +- [ ] **Project outside any cloud folder** (plain local). Everything behaves as + before this feature existed: no icons, no cards, no cloud code in the way. +- [ ] **Reopen loop.** Open/close the app 5 times in a row against a cloud + project, alternating online/offline. Watch for crashes on the 2nd+ launch + (stale state, half-hydrated files). + +## 3. Losing the network at every awkward moment + +- [ ] **During a download.** Click Download on a ~100 MB file, kill Wi-Fi at + ~50%. Expect: "Waiting" persists (with the minutes counter hidden and the + offline wording shown); when Wi-Fi returns, the download completes and the + preview appears. "Stop waiting" must work while offline. +- [ ] **Between selection and auto-fetch.** Select a small evicted file and cut + Wi-Fi within a second. Expect: no download starts, no hang; card shows + offline wording; Download button disabled (grey). +- [ ] **Offline UI coherence.** While offline with a cloud-only file selected: + row icons show the crossed-out cloud, card says "…appears to be offline", + Download button is grey/disabled, "Show in Finder" still works. Going back + online re-enables the button *without* reselecting or restarting. +- [ ] **Router up, internet down.** Unplug the router's WAN side (or block the + provider's traffic) while Wi-Fi stays associated. The OS still reports + "online", so lameta will allow a Download that cannot complete. + Expect: card sits in "Waiting" indefinitely with the minutes counter + climbing; "Stop waiting" recovers. This is a known blind spot — confirm + nothing worse than an honest endless wait happens. +- [ ] **Captive-ish flakiness.** Toggle Wi-Fi off/on repeatedly (5×, a few + seconds apart) while a project with evicted files is open and a download + is running. Expect: no crash, status icons settle correctly afterwards. + +## 4. Data-loss risk scenarios (use a throwaway project!) + +The theme: lameta writes metadata files and renames files/folders; the sync +engine moves data underneath it. Diff against your pristine copy after each. + +- [ ] **Edit metadata while offline.** Open project offline (metadata already + local), edit session titles/notes, quit, go online, let it sync. On a + second machine (or web view), confirm the edits arrived and no + "conflicted copy" files appeared. +- [ ] **Two-machine conflict.** Edit the *same session* on two machines while + one is offline, then reconnect. The provider will create a conflict file + (`ETR009 (conflicted copy).session`, `-DESKTOP-XYZ` suffixes, etc.). + Reopen in lameta. Documented (unit-tested) behavior: the real session + loads normally and the conflicted copy appears as an ordinary attached + file in the file list — visible for a human to resolve, never merged or + silently chosen. Confirm that holds with a real provider-generated + conflict file, and that opening/deleting the conflict file from within + lameta works. +- [ ] **Rename with evicted files.** Change a session's ID (which renames the + folder and every file in it) while that session's media is evicted. + Expect: rename succeeds, placeholders stay placeholders under the new + name, and after downloading, content matches the pristine copy. Repeat + while offline; repeat again mid-download of one of the files (this is the + scariest one — a rename racing an in-flight materialization). +- [ ] **"Free Up Space" under a running app.** With lameta open and a session + selected, use Finder/OneDrive to evict that session's media (and then, + separately, its already-read `.session` file). Expect: icons flip back to + clouds on the next poll/selection; nothing crashes; editing and saving + the session still works (the save must rehydrate or rewrite, never write + a 0-byte file). +- [ ] **Evict while playing.** Play a long audio file and evict it mid-playback. + Playback will die — acceptable — but lameta must not crash, and the file + must return to cloud-only state cleanly. +- [ ] **Quit mid-download / mid-save.** Quit lameta (and once, force-quit) while + a large download is in "Waiting" and again immediately after editing + metadata. Relaunch. Expect: clean startup; the metadata file on disk is + whole (never truncated); the interrupted download either finished (the + provider owns it) or the file is still a valid placeholder. +- [ ] **Add Files while offline.** Use "Add Files" to copy new media into a + cloud project while offline, quit, reconnect. Expect: files upload, the + project on a second machine sees them, nothing is lost. +- [ ] **Delete vs evict confusion.** Delete a file from within lameta while it + is cloud-only (0 local bytes). Confirm the deletion propagates to the + cloud (check the provider's web UI + trash) — and that lameta's trash + behavior (`.trash` folder) works when the file had no local content. +- [ ] **Move the whole project** (Finder-drag it between a cloud and a + non-cloud folder) while lameta has it open. Expect: bad things may happen + to the open session, but no data destroyed and next launch recovers + (worst case: "project not found" and a clean re-open). + +## 5. Boundary values & odd files + +- [ ] File exactly 0 bytes in the cloud (e.g. the sample project's empty + `.txt`): must read as **local** (a 0-byte file has 0 blocks — make sure it + isn't misread as cloud-only, which would block its preview forever). +- [ ] File just under / just over the 10 MB auto-fetch threshold; then set the + threshold to "never" and "always" in the File menu and confirm both + extremes behave. +- [ ] A genuinely **sparse** local file inside the cloud folder if you can make + one (`dd if=/dev/zero of=sparse bs=1 count=1 seek=100m` then evict/restore) + — and one *outside* any cloud folder (must show no cloud UI at all). +- [ ] Filenames with spaces, `é`/diacritics (NFC/NFD!), emoji, very long names; + a session folder > 200 chars deep. Evict, reopen, download. +- [ ] Case games: rename `Sound.WAV` → `sound.wav` on one machine while evicted + on the other (APFS and OneDrive are both case-insensitive-ish; watch for + duplicate/lost files). +- [ ] A file that exists in the provider's web UI but was **deleted locally** + (sync lag): row should disappear or show missing — not crash. +- [ ] Hundreds of files in one session, all evicted: file list stays responsive + (status is polled per file); select-all-ish operations don't trigger a + mass download. + +## 6. Provider misbehavior + +- [ ] Kill the provider's process (`killall OneDrive`) during a download. + Expect: eventual failure back to cloud-only or endless Waiting + + functional "Stop waiting" — no crash. ⚠️ The exact error signature macOS + returns here feeds lameta's "couldn't reach provider" detection and is + only provisionally implemented — please capture lameta's log output for + the developers when you do this. +- [ ] OneDrive "Pause syncing" (2 h) with evicted metadata, then open the + project: does it hydrate anyway (OS-level requests sometimes bypass + pause), or produce the single banner? Either is acceptable; dialogs + storming or a crash is not. +- [ ] Storage quota full on the provider (if testable): uploading new files + from a lameta project fails at the provider level; lameta shouldn't care + or corrupt anything. +- [ ] iCloud "Optimize Mac Storage" ON with a nearly-full disk: let macOS evict + things on its own overnight, then reopen the project. +- [ ] Uninstall/reinstall the provider between lameta runs (sync-root list is + cached per app-run; a re-launch must pick up the new state). + +## 7. Windows regression pass (the interface changed underneath it) + +Quick pass on Windows 10/11 + OneDrive to confirm nothing regressed: + +- [ ] Icons, status card, and the **checkbox** (not a button — Windows keeps + "Tell OneDrive that I want this file on my computer") still work; checkbox + pins durably (file survives "Free up space"). +- [ ] `.meta` sidecars still get pinned automatically when their media is read. +- [ ] Offline: checkbox behavior unchanged, banner + Retry on failed metadata + reads unchanged. +- [ ] Auto-fetch under threshold works; the new offline suppression also + applies (select small cloud file while offline → no fetch attempt). + +## 8. What to record when something goes wrong + +1. The provider, macOS version, and whether the path went through a symlink. +2. `stat -f "blocks=%b size=%z flags=%f" <file>` for the file in question. +3. lameta's log (dev terminal or Console.app) — especially any line starting + with `cloudFileStatus:` or mentioning the cloud read guard, and the exact + error `{ code, errno, syscall }` if shown. +4. Whether the provider's own client showed an error dialog at the same time + (a design goal is that lameta *prevents* triggering those storms). +5. For suspected data loss: the `diff -r` against your pristine copy, plus the + provider's web-UI version history for the affected file. diff --git a/readme-images/onedrive-file-list-icons.png b/readme-images/onedrive-file-list-icons.png new file mode 100644 index 00000000..57e88130 Binary files /dev/null and b/readme-images/onedrive-file-list-icons.png differ diff --git a/readme-images/onedrive-status-card-offline.png b/readme-images/onedrive-status-card-offline.png new file mode 100644 index 00000000..03c9bf35 Binary files /dev/null and b/readme-images/onedrive-status-card-offline.png differ diff --git a/readme-images/onedrive-status-card-waiting.png b/readme-images/onedrive-status-card-waiting.png new file mode 100644 index 00000000..7b358461 Binary files /dev/null and b/readme-images/onedrive-status-card-waiting.png differ diff --git a/readme-images/onedrive-status-card.png b/readme-images/onedrive-status-card.png new file mode 100644 index 00000000..5d4e5e12 Binary files /dev/null and b/readme-images/onedrive-status-card.png differ diff --git a/readme-onedrive.md b/readme-onedrive.md new file mode 100644 index 00000000..0f439cc6 --- /dev/null +++ b/readme-onedrive.md @@ -0,0 +1,99 @@ +# Using lameta with Cloud Sync services (OneDrive, Dropbox, Google Drive, iCloud, etc.) + +If your lameta project lives inside a folder managed by a cloud sync service, +that service may keep some of your files "in the cloud" instead of on your +computer. This saves disk space: a file that is *online-only* takes up almost +no room, but its actual content (the recording, the video, the photo…) is not +on your computer until the cloud service downloads it. + +lameta understands these cloud files and will never download a big file +without being asked. This page explains what you will see and how to get a +file onto your computer when you need it. + +The screenshots below happen to show OneDrive, but lameta works the same way +with any of these services — the status box simply shows the name of whichever +service is managing your folder. + +## What you will see in the file list + +Next to each file, lameta shows the same kind of icons that Windows File +Explorer uses: + +- **Blue cloud** — the file is online-only. Its content is in the cloud, not +on this computer. Its name is also shown dimmed. +- **Spinning arrows** — you (or lameta) asked for this file, and the cloud +service is downloading it. +- **Grey crossed-out cloud** — the file is online-only *and* your computer is +currently offline, so it cannot be downloaded right now. +- **No icon** — the file is on your computer and ready to use. + +![The file list: the session file is online-only (blue cloud, dimmed), the first +recording is being downloaded (spinning arrows), and the two files below it are +already on this computer (no icon)](readme-images/onedrive-file-list-icons.png) + +Your session and person information always works, even when files are +online-only. lameta automatically keeps its own small record-keeping files on +your computer, so you can always see and edit titles, dates, contributors, +notes, and so on. Only the large files (audio, video, images, documents) wait +in the cloud. + +## Getting a file onto your computer + +1. Click the file in the list. Instead of the usual preview, you will see a + box titled with your cloud service's name — for example **OneDrive Status** + or **Dropbox Status**. + ![The status box for an online-only file, with the checkbox not + yet ticked](readme-images/onedrive-status-card.png) +2. Tick **"Tell [your cloud service] that I want this file on my computer"**. +3. The status changes to **Waiting**. The cloud service downloads the file in + the background — how long this takes depends on the file size and your + internet connection. lameta shows the file as soon as it arrives. + ![The status box after ticking the checkbox: the status is + Waiting, and lameta notes when you requested the + file](readme-images/onedrive-status-card-waiting.png) + +Changed your mind? Untick the box to cancel the request. (This never removes +anything from your computer or from the cloud.) + +## Downloading small files automatically + +Downloading every tiny file by hand would be tedious, so lameta can request +small files for you. In the **View** menu, choose **"Automatically make cloud +files available if smaller than"** and pick a size (for example 10 MB). From +then on, when you click an online-only file smaller than that, lameta asks the +cloud service for it automatically. Choose **Never** to turn this off, or +**Always** if you want every file you click to be downloaded regardless of +size. + +## When you are offline + +If your computer has no internet connection, online-only files show a +crossed-out cloud, and the status box explains that the file cannot +be downloaded right now. You can still tick the box — the cloud service will +remember and download the file once you are connected again. + +![The status box while offline: a crossed-out cloud and a note that +this computer appears to be offline](readme-images/onedrive-status-card-offline.png) + +## Freeing up disk space + +You can hand space back to the cloud at any time: in Windows File Explorer, +right-click a file (or a whole session folder) and choose **Free up space** +(the exact wording depends on your cloud service). The files become +online-only again. + +One thing to know: the next time you open such a session in lameta, lameta +immediately re-downloads its own small record-keeping files (they are tiny — +usually just a few kilobytes) so it can show you the session's information. +Your recordings and other large files stay in the cloud until you ask for +them. + +## Good to know + +- This feature works with cloud sync services on **Windows** that use the +built-in Windows Cloud Files system — including OneDrive, Dropbox, Google +Drive, iCloud Drive, and Box. On other systems, or with services that don't +use this system, lameta treats files normally. +- lameta never deletes or "de-downloads" anything. Moving files back to the +cloud is always something you do yourself, in Windows File Explorer. +- Hover over any status icon to see a short explanation. diff --git a/src/components/CloudFilePanel.tsx b/src/components/CloudFilePanel.tsx new file mode 100644 index 00000000..3b2e1160 --- /dev/null +++ b/src/components/CloudFilePanel.tsx @@ -0,0 +1,444 @@ +import { css, keyframes } from "@emotion/react"; +/* removed emotion jsx declaration */ + +import * as React from "react"; +import { observer } from "mobx-react"; +import { t, Trans, Plural } from "@lingui/macro"; +import { File } from "../model/file/File"; +import { NotifyError } from "./Notify"; +import { + getCloudFileProvider, + getCloudProviderNameForPath +} from "../other/cloudFileStatus"; +import { + revealInFolder, + revealInFolderLabel +} from "../other/crossPlatformUtilities"; +import { networkStatus } from "../other/networkStatus"; +import { + CloudDisplayStatus, + getCloudDisplayStatusOfFile +} from "./CloudStatusIcon"; + +// Palette from the "OneDrive Status" design (claude.ai/design): +const cardGreen = "#6a9a3a"; +const titleColor = "#1a1d1a"; +const bodyColor = "#5a615a"; +const dividerColor = "#ececec"; +const checkboxBorder = "#b8bdb6"; + +function useMinutesSinceHydrationRequest(file: File): number { + const [minutes, setMinutes] = React.useState(0); + + React.useEffect(() => { + if (file.cloudStatus !== "hydrating" || file.hydratingSinceMs === undefined) { + setMinutes(0); + return; + } + const start = file.hydratingSinceMs; + const update = () => setMinutes(Math.floor((Date.now() - start) / 60000)); + update(); + const interval = window.setInterval(update, 30000); + return () => window.clearInterval(interval); + }, [file.cloudStatus, file.hydratingSinceMs]); + + return minutes; +} + +function getStatusLabel(status: CloudDisplayStatus): string { + switch (status) { + case "hydrating": + return t`Waiting`; + case "cloudOnlyOffline": + return t`In cloud only, and this computer appears to be offline.`; + default: + return t`In cloud only, lameta cannot read it yet.`; + } +} + +const spin = keyframes` + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +`; + +// Stroke-style icons matching the design's outlined cloud, in the card green. +const PanelStatusIcon: React.FunctionComponent<{ + status: CloudDisplayStatus; +}> = ({ status }) => { + const common = { + width: 22, + height: 22, + viewBox: "0 0 24 24", + fill: "none" as const, + css: css` + flex-shrink: 0; + color: ${cardGreen}; + ` + }; + const cloudPath = ( + <path + d="M18.5 15.5a3.5 3.5 0 0 0-.6-6.95A5 5 0 0 0 8.1 9.2 3.75 3.75 0 0 0 8.5 16.7h10a1.2 1.2 0 0 0 0-1.2Z" + stroke="currentColor" + strokeWidth="1.6" + strokeLinejoin="round" + /> + ); + switch (status) { + case "hydrating": + return ( + <svg + {...common} + css={css` + ${common.css}; + animation: ${spin} 2.5s linear infinite; + `} + > + <polyline + points="23 4 23 10 17 10" + stroke="currentColor" + strokeWidth="1.6" + strokeLinecap="round" + strokeLinejoin="round" + /> + <polyline + points="1 20 1 14 7 14" + stroke="currentColor" + strokeWidth="1.6" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" + stroke="currentColor" + strokeWidth="1.6" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + ); + case "cloudOnlyOffline": + return ( + <svg {...common}> + {cloudPath} + <path + d="M4.5 3.5 19.5 20.5" + stroke="currentColor" + strokeWidth="1.6" + strokeLinecap="round" + /> + </svg> + ); + default: + return <svg {...common}>{cloudPath}</svg>; + } +}; + +// The cloud status card ("OneDrive Status", "Dropbox Status", ...): title +// row, explanation, divider, and the +// request checkbox. Shared by every tab that would need to read the file's +// content (Audio/Video/Image/Text/PDF previews). Renders nothing once the +// file is on this computer, ready to read. +export const CloudFileFetchControl: React.FunctionComponent<{ + file: File; +}> = observer((props) => { + const { file } = props; + const minutesSinceRequest = useMinutesSinceHydrationRequest(file); + + const capabilities = getCloudFileProvider().capabilities; + if (!capabilities.canFetch) { + return null; + } + if (!file.isCloudFileNotPresent) { + return null; + } + + const hydrating = file.cloudStatus === "hydrating"; + const displayStatus = getCloudDisplayStatusOfFile(file) ?? "cloudOnly"; + // "OneDrive", "Dropbox", ... whichever sync engine owns this folder. + const providerName = + getCloudProviderNameForPath(file.getActualFilePath()) ?? t`Cloud`; + + return ( + <div + css={css` + width: 640px; + max-width: 100%; + box-sizing: border-box; + background: #ffffff; + border: 1.5px solid ${cardGreen}; + border-radius: 14px; + padding: 22px 26px; + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04), + 0 6px 18px rgba(16, 24, 40, 0.06); + `} + > + <div + css={css` + display: flex; + align-items: center; + gap: 10px; + `} + > + <PanelStatusIcon status={displayStatus} /> + <h2 + css={css` + margin: 0; + font-size: 17px; + font-weight: 700; + letter-spacing: -0.01em; + color: ${titleColor}; + `} + > + <Trans>{providerName} Status</Trans> + </h2> + </div> + + <p + css={css` + margin: 11px 0 0; + font-size: 15px; + line-height: 1.5; + color: ${bodyColor}; + `} + > + {getStatusLabel(displayStatus)} + </p> + + <div + css={css` + height: 1px; + background: ${dividerColor}; + margin: 18px 0; + `} + /> + + {capabilities.canPin ? ( + <label + css={css` + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + user-select: none; + `} + > + <input + type="checkbox" + checked={hydrating} + onChange={(e) => { + if (e.target.checked) { + file.makeAvailableOffline().catch((err) => { + NotifyError( + t`lameta was not able to make this file available on this device.`, + `${err}` + ); + }); + } else { + file.stopWaiting(); + } + }} + css={css` + appearance: none; + margin: 0; + width: 20px; + height: 20px; + flex-shrink: 0; + border: 1.5px solid ${checkboxBorder}; + border-radius: 5px; + background-color: #ffffff; + background-position: center; + background-repeat: no-repeat; + transition: all 0.12s ease; + cursor: pointer; + &:checked { + background-color: ${cardGreen}; + background-image: url("data:image/svg+xml,%3Csvg width='13' height='13' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 12.5 10 17 19 7' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + } + `} + /> + <span + css={css` + font-size: 15px; + color: ${titleColor}; + `} + > + <Trans> + Tell {providerName} that I want this file on my computer + </Trans> + </span> + </label> + ) : hydrating ? ( + <div + css={css` + display: flex; + justify-content: flex-start; + `} + > + <button + type="button" + onClick={() => file.stopWaiting()} + css={css` + display: inline-flex; + align-items: center; + padding: 6px 12px; + font-size: 13px; + font-weight: 500; + color: ${titleColor}; + background: #f5f6f4; + border: 1px solid ${checkboxBorder}; + border-radius: 8px; + cursor: pointer; + transition: background-color 0.12s ease; + &:hover { + background: #e9ebe6; + } + `} + > + <Trans>Stop waiting</Trans> + </button> + </div> + ) : ( + <button + type="button" + disabled={!networkStatus.isOnline} + onClick={() => { + file.makeAvailableOffline().catch((err) => { + NotifyError( + t`lameta was not able to make this file available on this device.`, + `${err}` + ); + }); + }} + css={ + networkStatus.isOnline + ? css` + display: inline-flex; + align-items: center; + padding: 10px 18px; + font-size: 15px; + font-weight: 600; + color: #ffffff; + background: ${cardGreen}; + border: none; + border-radius: 8px; + cursor: pointer; + transition: filter 0.12s ease; + &:hover { + filter: brightness(1.08); + } + ` + : css` + display: inline-flex; + align-items: center; + padding: 10px 18px; + font-size: 15px; + font-weight: 600; + color: #ffffff; + background: #b9c2b4; + border: none; + border-radius: 8px; + cursor: default; + ` + } + > + <Trans>Download this file to my computer</Trans> + </button> + )} + + <div + css={css` + height: 1px; + background: ${dividerColor}; + margin: 18px 0; + `} + /> + + <div + css={css` + display: flex; + justify-content: flex-end; + `} + > + <button + type="button" + onClick={() => revealInFolder(file.getActualFilePath())} + css={css` + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + font-size: 14px; + font-weight: 500; + color: ${titleColor}; + background: #f5f6f4; + border: 1px solid ${checkboxBorder}; + border-radius: 8px; + cursor: pointer; + transition: background-color 0.12s ease; + &:hover { + background: #e9ebe6; + } + `} + > + <svg + width="16" + height="16" + viewBox="0 0 24 24" + fill="none" + css={css` + flex-shrink: 0; + color: ${cardGreen}; + `} + > + <path + d="M3 6.5A1.5 1.5 0 0 1 4.5 5h4l2 2.5h9A1.5 1.5 0 0 1 21 9v9a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 18Z" + stroke="currentColor" + strokeWidth="1.6" + strokeLinejoin="round" + /> + </svg> + {revealInFolderLabel()} + </button> + </div> + + {hydrating && networkStatus.isOnline && ( + <p + css={css` + margin: 11px 0 0; + font-size: 15px; + line-height: 1.5; + color: ${bodyColor}; + `} + > + {minutesSinceRequest < 1 ? ( + <Trans> + You requested this file less than a minute ago. lameta will + show it when it becomes available. + </Trans> + ) : ( + <Plural + value={minutesSinceRequest} + one="# minute since you requested this file. lameta will show it when it becomes available." + other="# minutes since you requested this file. lameta will show it when it becomes available." + /> + )} + </p> + )} + </div> + ); +}); + +// Shown instead of the media/text/pdf preview when the file's content has not +// been fetched from the cloud. See file.isCloudFileNotPresent. +export const CloudFilePanel: React.FunctionComponent<{ file: File }> = + observer((props) => { + return ( + <div + css={css` + padding: 0 24px 24px 0; + `} + > + <CloudFileFetchControl file={props.file} /> + </div> + ); + }); diff --git a/src/components/CloudStatusIcon.spec.ts b/src/components/CloudStatusIcon.spec.ts new file mode 100644 index 00000000..d0a15191 --- /dev/null +++ b/src/components/CloudStatusIcon.spec.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { getCloudDisplayStatus } from "./CloudStatusIcon"; + +describe("getCloudDisplayStatus", () => { + it("shows the blue cloud for a cloud-only file while online", () => { + expect(getCloudDisplayStatus("cloudOnly", true, true)).toBe("cloudOnly"); + }); + + it("shows the offline cloud for a cloud-only file while offline", () => { + expect(getCloudDisplayStatus("cloudOnly", false, true)).toBe( + "cloudOnlyOffline" + ); + }); + + it("shows sync arrows while hydrating online", () => { + expect(getCloudDisplayStatus("hydrating", true, true)).toBe("hydrating"); + }); + + it("shows the offline cloud while hydrating offline, since the download cannot progress", () => { + expect(getCloudDisplayStatus("hydrating", false, true)).toBe( + "cloudOnlyOffline" + ); + }); + + // Checkmarks for locally-available files are currently disabled (see the + // commented-out cases in getCloudDisplayStatus); if re-enabled, these + // should go back to expecting "local" / "localPinned" under a sync root. + it("shows nothing for hydrated files, even under a sync root", () => { + expect(getCloudDisplayStatus("local", true, true)).toBeUndefined(); + expect(getCloudDisplayStatus("localPinned", true, true)).toBeUndefined(); + expect(getCloudDisplayStatus("local", true, false)).toBeUndefined(); + expect(getCloudDisplayStatus("localPinned", true, false)).toBeUndefined(); + }); + + it("still shows cloud states outside a detected sync root (attributes prove they are cloud files)", () => { + expect(getCloudDisplayStatus("cloudOnly", true, false)).toBe("cloudOnly"); + expect(getCloudDisplayStatus("hydrating", true, false)).toBe("hydrating"); + }); + + it("shows nothing when the status is unknown", () => { + expect(getCloudDisplayStatus("unknown", true, true)).toBeUndefined(); + expect(getCloudDisplayStatus("unknown", false, true)).toBeUndefined(); + }); +}); diff --git a/src/components/CloudStatusIcon.tsx b/src/components/CloudStatusIcon.tsx new file mode 100644 index 00000000..9247b458 --- /dev/null +++ b/src/components/CloudStatusIcon.tsx @@ -0,0 +1,184 @@ +import { css, keyframes } from "@emotion/react"; +/* removed emotion jsx declaration */ + +import * as React from "react"; +import { observer } from "mobx-react"; +import { t } from "@lingui/macro"; +import { File } from "../model/file/File"; +import { + CloudFileStatus, + getCloudProviderNameForPath, + isUnderCloudSyncRoot +} from "../other/cloudFileStatus"; +import { networkStatus } from "../other/networkStatus"; + +// The sync-state a file should display, mirroring the icons OneDrive shows in +// File Explorer. Unlike CloudFileStatus, this folds in connectivity (a +// cloud-only file cannot arrive while offline) and whether the file is under +// a sync root at all (a plain local file outside OneDrive shows no icon). +export type CloudDisplayStatus = + | "cloudOnly" // blue cloud: online-only + | "cloudOnlyOffline" // grey crossed-out cloud: online-only and no network + | "hydrating" // blue sync arrows: downloading + | "local" // green outlined check: on this device, could be dehydrated + | "localPinned"; // solid green check: always keep on this device + +export function getCloudDisplayStatus( + cloudStatus: CloudFileStatus, + isOnline: boolean, + isUnderSyncRoot: boolean +): CloudDisplayStatus | undefined { + switch (cloudStatus) { + case "cloudOnly": + return isOnline ? "cloudOnly" : "cloudOnlyOffline"; + case "hydrating": + // The download cannot progress without a network; show why. + return isOnline ? "hydrating" : "cloudOnlyOffline"; + // Per John (2026-07): don't show checkmarks for files that are simply + // here on this device -- but keep the ability around in case he changes + // his mind. Re-enabling this gives Explorer-style green checks (outlined + // for "local", solid for "always keep on this device"). + // case "local": + // case "localPinned": + // // Attributes cannot distinguish a hydrated OneDrive file from a plain + // // local file, so only claim "available on this device" under a sync root. + // return isUnderSyncRoot ? cloudStatus : undefined; + default: + return undefined; + } +} + +export function getCloudDisplayStatusOfFile( + file: File +): CloudDisplayStatus | undefined { + return getCloudDisplayStatus( + file.cloudStatus, + networkStatus.isOnline, + isUnderCloudSyncRoot(file.getActualFilePath()) + ); +} + +function getTooltip( + status: CloudDisplayStatus, + providerName: string +): string { + switch (status) { + case "cloudOnly": + return t`Online-only (${providerName}). The content of this file is not on this computer.`; + case "cloudOnlyOffline": + return t`Online-only (${providerName}), and this computer appears to be offline, so the file cannot be downloaded right now.`; + case "hydrating": + return t`${providerName} is downloading this file to this computer.`; + case "local": + return t`Available on this device. ${providerName} may free up this space if the file goes unused.`; + case "localPinned": + return t`Always available on this device.`; + } +} + +const oneDriveBlue = "#0078d4"; +const availableGreen = "#0f7b0f"; +const offlineGrey = "#605e5c"; + +const spin = keyframes` + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +`; + +// Renders a given display status as an icon closely matching the ones +// File Explorer shows for OneDrive files, so users instantly recognize them. +// `color` overrides the per-status OneDrive colors (e.g. the cloud panel +// draws the icon in the surrounding text color per the design). +export const CloudStatusGlyph: React.FunctionComponent<{ + status: CloudDisplayStatus; + size?: number; + color?: string; + providerName?: string; // "OneDrive", "Dropbox", ... for the tooltip +}> = ({ status, size = 16, color, providerName }) => { + const common = { + width: size, + height: size, + viewBox: "0 0 24 24", + role: "img" as const + }; + const title = <title>{getTooltip(status, providerName ?? t`Cloud`)}; + switch (status) { + case "cloudOnly": + return ( + + {title} + + + ); + case "cloudOnlyOffline": + return ( + + {title} + + + ); + case "hydrating": + return ( + + {title} + + + ); + case "local": + return ( + + {title} + + + ); + case "localPinned": + return ( + + {title} + + + ); + } +}; + +// The shared entry point: shows the OneDrive-style status icon for a file, +// or nothing when the file has no cloud story to tell. Used by the file +// list's status column and by the panel that lets the user request a file. +export const CloudStatusIcon: React.FunctionComponent<{ + file: File; + size?: number; +}> = observer((props) => { + const status = getCloudDisplayStatusOfFile(props.file); + if (!status) { + return null; + } + return ( + + ); +}); diff --git a/src/components/CloudUnavailableBanner.tsx b/src/components/CloudUnavailableBanner.tsx new file mode 100644 index 00000000..51fce789 --- /dev/null +++ b/src/components/CloudUnavailableBanner.tsx @@ -0,0 +1,108 @@ +import { css } from "@emotion/react"; +import * as React from "react"; +import { observer } from "mobx-react"; +import { t, Trans } from "@lingui/macro"; +import { cloudReadGuard } from "../other/cloudReadGuard"; +import { Project } from "../model/Project/Project"; + +// A single, persistent banner shown across the top of the project view when a +// cloud sync provider (OneDrive, Dropbox, Nextcloud, ...) couldn't deliver one +// or more files during load. It replaces what would otherwise be a storm of +// per-file error toasts (and provider modal dialogs). See cloudReadGuard. +export const CloudUnavailableBanner: React.FunctionComponent<{ + project: Project; +}> = observer((props) => { + const [dismissed, setDismissed] = React.useState(false); + const [retrying, setRetrying] = React.useState(false); + + const failures = cloudReadGuard.failedReads; + const count = failures.length; + + // Re-show the banner if a fresh batch of failures comes in after a dismissal. + React.useEffect(() => { + if (count > 0) setDismissed(false); + }, [count]); + + if (count === 0 || dismissed) { + return null; + } + + // If every failure is from the same provider, name it; otherwise stay generic. + const providerNames = Array.from( + new Set(failures.map((f) => f.providerName).filter(Boolean)) + ); + const providerName = + providerNames.length === 1 ? (providerNames[0] as string) : t`the cloud`; + + const message = + count === 1 + ? t`lameta couldn't download 1 file from ${providerName}. It may be offline or busy right now.` + : t`lameta couldn't download ${count} files from ${providerName}. It may be offline or busy right now.`; + + const onRetry = () => { + setRetrying(true); + // Let the button repaint as "retrying" before the synchronous re-reads run. + window.setTimeout(() => { + try { + props.project.retryFailedCloudReads(); + } finally { + setRetrying(false); + } + }, 0); + }; + + return ( +
+ + ⚠️ + + {message} + + +
+ ); +}); diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index aa95f479..53ce807b 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -8,6 +8,12 @@ import { sentryException } from "../other/errorHandling"; interface IProps { children: ReactNode; context?: string; + // When any value in this array changes, a boundary that is currently showing + // its fallback resets and re-renders its children. Pass something that + // identifies the current subject (e.g. the selected file's path) so that a + // render error caused by one item cannot wedge the pane for every other item + // until the app is restarted. + resetKeys?: unknown[]; } interface IState { @@ -39,7 +45,11 @@ const handleError = (error: Error, info: any) => { export const ErrorBoundary = (props: IProps) => { return ( // todo: there is props.context that we are not using yet - + {props.children} ); diff --git a/src/components/FileList.css b/src/components/FileList.css index d054613d..e5e4bd01 100644 --- a/src/components/FileList.css +++ b/src/components/FileList.css @@ -74,3 +74,6 @@ .noMediaFolderConnection { color: var(--project-color) !important; } +.cloudOnly { + color: rgba(0, 0, 0, 0.5) !important; +} diff --git a/src/components/FileList.tsx b/src/components/FileList.tsx index dcfe19e4..31dd9042 100644 --- a/src/components/FileList.tsx +++ b/src/components/FileList.tsx @@ -6,7 +6,10 @@ import { ElectronDropZone } from "./ElectronDropZone"; import { OpenDialogOptions, ipcRenderer } from "electron"; import * as remote from "@electron/remote"; import "./FileList.css"; -import { revealInFolder } from "../other/crossPlatformUtilities"; +import { + revealInFolder, + revealInFolderLabel +} from "../other/crossPlatformUtilities"; import { ShowRenameDialog } from "./RenameFileDialog/RenameFileDialog"; import { i18n, translateFileType } from "../other/localization"; import { t, Trans } from "@lingui/macro"; @@ -30,7 +33,19 @@ import HighlightSearchTerm from "./HighlightSearchTerm"; import { lameta_orange } from "../containers/theme"; import SearchIcon from "@mui/icons-material/Search"; import { getTestEnvironment } from "../getTestEnvironment"; +import { + AutoFetchCloudFiles, + mbToBytes +} from "../other/autoFetchCloudFiles"; +import { + CloudStatusIcon, + getCloudDisplayStatusOfFile +} from "./CloudStatusIcon"; const electron = require("electron"); + +// Throttle window-focus-triggered cloud status refreshes so that rapid +// alt-tabbing doesn't hammer the filesystem for every file in the folder. +const kWindowFocusRefreshThrottleMs = 5000; export const _FileList: React.FunctionComponent<{ folder: Folder; extraButtons?: object[]; @@ -65,6 +80,33 @@ export const _FileList: React.FunctionComponent<{ const { searchTerm } = React.useContext(SearchContext); const highlight = (text: string) => ; + // Debounced/capped auto-fetch of small cloud-only files as the selection + // rests on them. One scheduler per mounted FileList (i.e. per folder). + const autoFetchSchedulerRef = React.useRef(); + if (!autoFetchSchedulerRef.current) { + autoFetchSchedulerRef.current = new AutoFetchCloudFiles(); + } + React.useEffect(() => { + return () => autoFetchSchedulerRef.current?.dispose(); + }, []); + + // Refresh cloud status of this folder's files when the window regains + // focus (e.g. after OneDrive finishes syncing in the background), but no + // more often than every few seconds. + const lastFocusRefreshRef = React.useRef(0); + React.useEffect(() => { + const onFocus = () => { + const now = Date.now(); + if (now - lastFocusRefreshRef.current < kWindowFocusRefreshThrottleMs) { + return; + } + lastFocusRefreshRef.current = now; + props.folder.files.forEach((f) => f.updateCloudStatus()); + }; + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); + }, [props.folder]); + const fileHasMetadataMatch = (file: any, trimmed: string): boolean => { if (!trimmed || !file) return false; let metadataMatch = false; @@ -99,6 +141,18 @@ export const _FileList: React.FunctionComponent<{ return metadataMatch; }; + // MobX tracking: deliberately touch getLinkStatusIconPath and the cloud + // display status (and through them cloudStatus and networkStatus) for + // EVERY file, with no short-circuit -- if a later file's status changes + // (e.g. OneDrive finishes hydrating it), this render must re-run so its + // row icon and cloudOnly row class update. + const linkStatusIconPaths = props.folder.files.map((f) => + getLinkStatusIconPath(f) + ); + const cloudDisplayStatuses = props.folder.files.map((f) => + getCloudDisplayStatusOfFile(f) + ); + let columns: any[] = [ { id: "icon", @@ -124,12 +178,17 @@ export const _FileList: React.FunctionComponent<{ id: "linkStatus", Header: "", width: 30, - show: props.folder.files.some((f) => getLinkStatusIconPath(f)), - accessor: (d: any) => { - const f: File = d; - return getLinkStatusIconPath(f); - }, - Cell: (p) => (p.value ? : null) + show: + linkStatusIconPaths.some((p) => !!p) || + cloudDisplayStatuses.some((s) => !!s), + accessor: (d: any) => d, + Cell: (p) => { + const f: File = p.value; + const iconPath = getLinkStatusIconPath(f); + // Link/missing/naming problems take precedence; otherwise show the + // OneDrive sync-state icon (or nothing for non-cloud files). + return iconPath ? : ; + } }, { id: "type", @@ -361,6 +420,11 @@ export const _FileList: React.FunctionComponent<{ } props.folder.selectedFile = rowInfo.original; setSelectedFile(rowInfo.original); // trigger re-render so that the following style: takes effect + file.updateCloudStatus(); + autoFetchSchedulerRef.current?.onSelectionChanged( + file, + mbToBytes(userSettings.AutoFetchCloudFilesUnderMB) + ); } }, className: @@ -403,10 +467,7 @@ function showFileMenu( let items = [ { - label: - process.platform === "darwin" - ? t`Show in Finder` - : t`Show in File Explorer`, + label: revealInFolderLabel(), click: () => { revealInFolder(file.getActualFilePath()); }, diff --git a/src/components/FolderList.tsx b/src/components/FolderList.tsx index 52d5a1a2..0ec444d3 100644 --- a/src/components/FolderList.tsx +++ b/src/components/FolderList.tsx @@ -261,11 +261,23 @@ class FolderList extends React.Component { //NB: "rowInfo.row" is a subset of things that are mentioned with an accessor. "original" is the original. return { onClick: (e: any, x: any) => { - // Save current selection before changing + // Save current selection before changing. This must never abort + // the selection change: if saving the outgoing folder throws + // (e.g. its folder vanished from disk externally), we still want + // the user to be able to move to another folder. save() already + // fails softly, but guard here too so no future throw can wedge + // the UI on the old, now-unusable selection. if (selectedFolderIndex > -1) { - this.props.folders.items[ - selectedFolderIndex - ].saveAllFilesInFolder(); + try { + this.props.folders.items[ + selectedFolderIndex + ].saveAllFilesInFolder(); + } catch (err) { + console.error( + "Error saving the outgoing folder while switching selection; continuing anyway.", + err + ); + } } // rowInfo.index refers to the index within the currently displayed (possibly filtered) data set. // We need to store the index relative to the full, unfiltered items array, because other code diff --git a/src/components/FolderPane.tsx b/src/components/FolderPane.tsx index f0da9d2e..be607cc2 100644 --- a/src/components/FolderPane.tsx +++ b/src/components/FolderPane.tsx @@ -42,6 +42,7 @@ import { TiffViewer } from "./TiffViewer"; import AccessChooser from "./session/AccessChooser"; import { TextFieldEdit } from "./TextFieldEdit"; import { getSessionFormClass } from "./session/SessionFormVariant"; +import { CloudFilePanel } from "./CloudFilePanel"; export interface IProps { folder: Folder; @@ -115,7 +116,14 @@ export const FolderPane: React.FunctionComponent< file={props.folder.selectedFile} fileName={props.folder.selectedFile.getTextProperty("filename")} /> - + @@ -162,6 +170,10 @@ const FileTabs: React.FunctionComponent< } const notesField = file.properties.getTextField("notes"); const filename = file.getTextProperty("filename"); + // Reading media/text/pdf content would hydrate (fully download) a + // cloud-only OneDrive placeholder file, which can be huge and slow. Show + // CloudFilePanel instead until the user explicitly fetches it. + const previewBlocked = file.isCloudFileNotPresent; const notesPanel = directoryObject.properties.getValue("notes") ? ( @@ -527,9 +539,13 @@ const FileTabs: React.FunctionComponent< {projectDocsTabs} - + {previewBlocked ? ( + + ) : ( + + )} {standardMetaPanels} {projectDocsPanels} @@ -546,17 +562,21 @@ const FileTabs: React.FunctionComponent< {projectDocsTabs} - { - NotifyError("video error:" + e); - }} - /> + {previewBlocked ? ( + + ) : ( + { + NotifyError("video error:" + e); + }} + /> + )} {standardMetaPanels} {projectDocsPanels} @@ -574,7 +594,9 @@ const FileTabs: React.FunctionComponent< {projectDocsTabs} - {isTiff ? ( + {previewBlocked ? ( + + ) : isTiff ? ( ) : ( - {/* NB: not a url, just a file here path */} - + {previewBlocked ? ( + + ) : ( + /* NB: not a url, just a file here path */ + + )} {standardMetaPanels} {projectDocsPanels} @@ -615,31 +641,35 @@ const FileTabs: React.FunctionComponent< {projectDocsTabs} - -

- Unable to display PDF.{" "} - { - e.preventDefault(); - electron.shell.openPath(file.getActualFilePath()); - }} - > - Open externally - -

-
+ {previewBlocked ? ( + + ) : ( + +

+ Unable to display PDF.{" "} + { + e.preventDefault(); + electron.shell.openPath(file.getActualFilePath()); + }} + > + Open externally + +

+
+ )}
{standardMetaPanels} {projectDocsPanels} diff --git a/src/components/LoadingProjectDialog.tsx b/src/components/LoadingProjectDialog.tsx index 5507f0e3..7c3ab312 100644 --- a/src/components/LoadingProjectDialog.tsx +++ b/src/components/LoadingProjectDialog.tsx @@ -4,6 +4,11 @@ import { t } from "@lingui/macro"; import { Dialog, DialogContent, LinearProgress } from "@mui/material"; import { DialogMiddle } from "./LametaDialog"; import { Project, ProjectHolder } from "../model/Project/Project"; +import { + getCloudFileProvider, + getCloudProviderNameForPath +} from "../other/cloudFileStatus"; +import { collectMetadataFilePaths } from "../other/cloudMetadataPrefetch"; export interface LoadingProgress { phase: "sessions" | "people"; @@ -16,20 +21,74 @@ export interface LoadingProgress { // Projects with more than this many sessions will show a loading dialog export const ASYNC_LOADING_THRESHOLD = 30; -// Check if a project directory should use async loading with progress -export const shouldUseAsyncLoading = (directory: string): boolean => { +// Does this project have any cloud-evicted (cloud-only) metadata file that a +// real provider could fetch? Such files hydrate one-at-a-time when read, at a +// few seconds each, which would freeze the renderer -- so we route these +// projects through the async loader + progress dialog just like large ones. +export const hasCloudEvictedMetadata = (directory: string): boolean => { + try { + const provider = getCloudFileProvider(); + // No provider that can deliver placeholders: nothing would be cloud-only. + if (!provider.capabilities.canFetch) { + return false; + } + for (const path of collectMetadataFilePaths(directory)) { + if (provider.getStatus(path) === "cloudOnly") { + return true; // short-circuit on the first hit + } + } + return false; + } catch { + return false; + } +}; + +// The decision about how to load a project, computed once per load so we don't +// stat every metadata file twice (deciding async vs sync, and labeling the +// dialog). +export interface LoadPlan { + // Whether to use the async, yielding loader with a progress dialog. + useAsync: boolean; + // Set only when cloud-evicted metadata is what makes this load slow -- the + // name of the sync engine ("OneDrive", "Dropbox", ...) for the dialog. + cloudProviderName?: string; +} + +// Compute the load plan for a directory in a single pass: async when the +// project is large (> ASYNC_LOADING_THRESHOLD sessions) or has cloud-evicted +// metadata that would otherwise block the renderer while it hydrates. +export const getLoadPlan = (directory: string): LoadPlan => { + // Cloud eviction, when present, dominates the wait and also labels the + // dialog, so check it first. + if (hasCloudEvictedMetadata(directory)) { + let cloudProviderName: string; + try { + cloudProviderName = + getCloudProviderNameForPath(directory) ?? t`the cloud`; + } catch { + cloudProviderName = t`the cloud`; + } + return { useAsync: true, cloudProviderName }; + } const folderCounts = Project.countFoldersInDirectory(directory); - return folderCounts.sessionCount > ASYNC_LOADING_THRESHOLD; + return { useAsync: folderCounts.sessionCount > ASYNC_LOADING_THRESHOLD }; }; -// Load a project either synchronously or asynchronously based on size. -// For large projects (>10 sessions), loads async with progress callback. +// Check if a project directory should use async loading with progress. +export const shouldUseAsyncLoading = (directory: string): boolean => + getLoadPlan(directory).useAsync; + +// Load a project either synchronously or asynchronously based on the plan. +// Callers that already computed a plan (to drive dialog visibility/message) +// should pass it so we don't recompute it; others get it computed here. // Returns the loaded project. export const loadProject = async ( directory: string, - onProgress?: (progress: LoadingProgress) => void + onProgress?: (progress: LoadingProgress) => void, + plan?: LoadPlan ): Promise => { - if (shouldUseAsyncLoading(directory)) { + const { useAsync } = plan ?? getLoadPlan(directory); + if (useAsync) { return Project.fromDirectoryAsync(directory, onProgress); } else { return Project.fromDirectory(directory); @@ -50,6 +109,9 @@ export const loadProjectIntoHolder = async ( export const LoadingProjectDialog: React.FunctionComponent<{ open: boolean; progress: LoadingProgress; + // When the load was triggered by cloud-evicted files, the name of the sync + // engine ("OneDrive", "Dropbox", ...) so we can say where we're waiting on. + cloudProviderName?: string; }> = (props) => { if (!props.open) { return null; @@ -66,6 +128,12 @@ export const LoadingProjectDialog: React.FunctionComponent<{ const phaseLabel = props.progress.phase === "sessions" ? t`Sessions` : t`People`; + // When cloud files are being fetched, the wait is dominated by the download, + // not by our per-item work, so lead with that instead of the phase. + const message = props.cloudProviderName + ? t`Getting project information from ${props.cloudProviderName}...` + : t`Loading ${phaseLabel}...`; + return ( - {t`Loading ${phaseLabel}...`} + {message} | undefined +): IMediaStatsCacheHost { + return { + isCloudFileNotPresent, + getCachedMediaStats: () => cached + }; +} + +describe("decideMediaStatsFlow", () => { + it("blocks (no probe) when cloud-only and there is no cache", () => { + const flow = decideMediaStatsFlow(makeHost(true, undefined)); + expect(flow.kind).toBe("blocked"); + }); + + it("shows cached stats flagged as recorded-while-cloud-only when cloud-only but a cache exists", () => { + const cached = { Length: "1:02", Format: "MPEG Audio" }; + const flow = decideMediaStatsFlow(makeHost(true, cached)); + expect(flow.kind).toBe("cached"); + expect(flow.kind === "cached" && flow.stats).toEqual(cached); + expect(flow.kind === "cached" && flow.recordedWhileCloudOnly).toBe(true); + }); + + it("shows cached stats not flagged as recorded-while-cloud-only when local and a cache exists", () => { + const cached = { Length: "1:02", Format: "MPEG Audio" }; + const flow = decideMediaStatsFlow(makeHost(false, cached)); + expect(flow.kind).toBe("cached"); + expect(flow.kind === "cached" && flow.stats).toEqual(cached); + expect(flow.kind === "cached" && flow.recordedWhileCloudOnly).toBe(false); + }); + + it("goes to the probe path when local and there is no cache", () => { + const flow = decideMediaStatsFlow(makeHost(false, undefined)); + expect(flow.kind).toBe("probe"); + }); +}); diff --git a/src/components/MediaStats.tsx b/src/components/MediaStats.tsx index c8fdbca6..507fec8e 100644 --- a/src/components/MediaStats.tsx +++ b/src/components/MediaStats.tsx @@ -1,10 +1,10 @@ import { css } from "@emotion/react"; import { default as React, useState, useEffect } from "react"; +import { observer } from "mobx-react"; import ReactTable from "react-table-6"; +import { t } from "@lingui/macro"; import { File } from "../model/file/File"; import ffmpeg from "fluent-ffmpeg"; -// import { i18n } from "../localization"; -// import { t } from "@lingui/macro"; import ExifReader from "exifreader"; import fs from "fs"; import * as Path from "path"; @@ -12,7 +12,7 @@ import * as Path from "path"; //const imagesize = require("image-size"); const humanizeDuration = require("humanize-duration"); -type Stats = object; +type Stats = Record; let ffprobePath = require("ffmpeg-ffprobe-static").ffprobePath; //console.log("raw ffprobePath: " + ffprobePath); @@ -21,51 +21,132 @@ ffprobePath = ffprobePath.replace("app.asar", "app.asar.unpacked"); //console.log(`final ffprobePath: ${ffprobePath}`); ffmpeg.setFfprobePath(ffprobePath); -export const MediaStats: React.FunctionComponent<{ file: File }> = (props) => { - const [message, setMessage] = useState("Processing..."); - const [stats, setStats] = useState({}); +// The minimal shape decideMediaStatsFlow() needs from a File. Kept narrow +// (rather than importing the whole `File` type) so this stays trivially +// unit-testable with a plain object. +export interface IMediaStatsCacheHost { + isCloudFileNotPresent: boolean; + getCachedMediaStats(): Record | undefined; +} - useEffect(() => { - getStatsFromFileAsync(props.file).then((s) => { - setStats(s); - setMessage(""); - }); - }, [props.file]); +// Note: deliberately holds no user-facing text -- that's translated at +// render/use time (see MediaStats below) so this stays both unit-testable +// without an i18n context and fully localized for the user. +export type MediaStatsFlow = + | { kind: "cached"; stats: Stats; recordedWhileCloudOnly: boolean } + | { kind: "blocked" } + | { kind: "probe" }; - const columns = [ - { - id: "key", - Header: "Stat", - width: 120, - accessor: (key) => key - }, - { - id: "value", - Header: "Value", - //width: 200, - accessor: (key) => (stats[key] ? stats[key].toString() : "---") - } - ]; +// Decide, without touching the file's content, whether to show cached stats, +// show a "not available on this device yet" message, or go probe the file. +// Pulled out of the component so it can be unit-tested without React/ffmpeg/ExifReader. +export function decideMediaStatsFlow( + file: IMediaStatsCacheHost +): MediaStatsFlow { + const cached = file.getCachedMediaStats(); + if (cached) { + return { + kind: "cached", + stats: cached, + recordedWhileCloudOnly: file.isCloudFileNotPresent + }; + } + if (file.isCloudFileNotPresent) { + return { kind: "blocked" }; + } + return { kind: "probe" }; +} - return ( -
- {message.toString()} - -
- ); -}; +export const MediaStats: React.FunctionComponent<{ file: File }> = observer( + (props) => { + const [message, setMessage] = useState(""); + const [stats, setStats] = useState({}); + + useEffect(() => { + const flow = decideMediaStatsFlow(props.file); + switch (flow.kind) { + case "cached": + setMessage(""); + setStats( + flow.recordedWhileCloudOnly + ? { + ...flow.stats, + [t`Note`]: t`recorded when the file was last on this device` + } + : flow.stats + ); + break; + case "blocked": + setMessage(""); + setStats({ + [t`Status`]: t`Available after this file is made available on this device` + }); + break; + case "probe": + setMessage("Processing..."); + getStatsFromFileAsync(props.file) + .then((s) => { + setStats(s); + setMessage(""); + if (!s.error) { + props.file.setCachedMediaStats(s, { + sizeBytes: props.file.getSizeInBytes(), + mtimeMs: props.file.getMtimeMs() + }); + } + }) + .catch((err) => { + // ffprobe can reject (corrupt file, unsupported codec, or a cloud + // file that failed to hydrate). Without this, the panel stays stuck + // on "Processing..." forever with no recovery. Surface the failure + // instead of swallowing the rejection. + console.warn( + `getStatsFromFileAsync failed for ${props.file.getActualFilePath()}: ${err}` + ); + setMessage(""); + setStats({ + [t`Status`]: t`Could not read media information for this file.` + }); + }); + break; + } + }, [props.file, props.file.cloudStatus]); + + const columns = [ + { + id: "key", + Header: "Stat", + width: 120, + accessor: (key) => key + }, + { + id: "value", + Header: "Value", + //width: 200, + accessor: (key) => (stats[key] ? stats[key].toString() : "---") + } + ]; + + return ( +
+ {message.toString()} + +
+ ); + } +); function roundToOneDecimalPlace(n: number): number { return Math.round(10 * n) / 10; @@ -115,7 +196,7 @@ function getStatsFromFileAsync(file: File): Promise { round: true } ); - stats["Format"] = result.format.format_long_name; + stats["Format"] = result.format.format_long_name ?? ""; result.streams.forEach((stream) => { processVideoStream(stream, stats); }); @@ -136,8 +217,10 @@ function getStatsFromFileAsync(file: File): Promise { function processVideoStream(stream: ffmpeg.FfprobeStream, stats: Stats) { switch (stream.codec_type) { case "audio": - stats["Audio Codec"] = stream.codec_name; - stats["Audio Channels"] = stream.channels; + stats["Audio Codec"] = stream.codec_name ?? ""; + if (stream.channels !== undefined) { + stats["Audio Channels"] = stream.channels.toString(); + } if (stream.bit_rate) { const br = Number(stream.bit_rate); stats["Audio Bit Rate"] = Math.round(br / 1000).toString() + " Kbps"; @@ -152,7 +235,7 @@ function processVideoStream(stream: ffmpeg.FfprobeStream, stats: Stats) { break; case "video": - stats["Video Codec"] = stream.codec_name; + stats["Video Codec"] = stream.codec_name ?? ""; if (stream.width && stream.height) { stats["Resolution"] = `${stream.width} x ${stream.height}`; } diff --git a/src/containers/HomePage.tsx b/src/containers/HomePage.tsx index 10f4a5de..83afa667 100644 --- a/src/containers/HomePage.tsx +++ b/src/containers/HomePage.tsx @@ -3,6 +3,7 @@ import { css } from "@emotion/react"; import pkg from "package.json"; import Workspace from "../components/Workspace"; +import { CloudUnavailableBanner } from "../components/CloudUnavailableBanner"; import * as React from "react"; import { observable, makeObservable } from "mobx"; import { observer } from "mobx-react"; @@ -44,7 +45,7 @@ import "react-toastify/dist/ReactToastify.css"; import { LoadingProjectDialog, LoadingProgress, - shouldUseAsyncLoading, + getLoadPlan, loadProject } from "../components/LoadingProjectDialog"; @@ -63,6 +64,9 @@ interface IState { useSampleProject: boolean; showLoadingDialog: boolean; loadingProgress: LoadingProgress; + // Set when the current load was triggered by cloud-evicted metadata; drives + // the "Getting project information from OneDrive..." dialog message. + loadingCloudProviderName?: string; } class HomePage extends React.Component { @@ -88,7 +92,8 @@ class HomePage extends React.Component { phase: "sessions", overallCurrent: 0, overallTotal: 0 - } + }, + loadingCloudProviderName: undefined }; let expectedProjectDirectory = userSettings.PreviousProjectDirectory; @@ -118,17 +123,12 @@ class HomePage extends React.Component { expectedProjectDirectory = null; } - // Check if we should use async loading with progress dialog - // for projects with more than 10 sessions + // Always load the initial project via the async loader (in + // componentDidMount). loadProjectWithProgress -> loadProject internally + // decides sync vs async, so small local projects still load promptly, but + // large or cloud-evicted projects no longer freeze the renderer here. if (expectedProjectDirectory && fs.existsSync(expectedProjectDirectory)) { - if (shouldUseAsyncLoading(expectedProjectDirectory)) { - // Defer to async loading with progress - will be done in componentDidMount - this.pendingProjectDirectory = expectedProjectDirectory; - } else { - // Small project - load synchronously as before - const project = Project.fromDirectory(expectedProjectDirectory); - this.projectHolder.setProject(project); - } + this.pendingProjectDirectory = expectedProjectDirectory; } else { this.projectHolder.setProject(null); } @@ -149,31 +149,50 @@ class HomePage extends React.Component { }); } - // Load a project asynchronously with progress dialog + // Load a project via the shared loader. Used by every project-open path so + // cloud-evicted projects never block the renderer. private async loadProjectWithProgress(directory: string) { - this.setState({ showLoadingDialog: true }); + // Compute the plan once (it stats every metadata file); reuse it for both + // the dialog decision and the load itself. + const plan = getLoadPlan(directory); - const project = await loadProject(directory, (progress) => { + // Only show the modal for the async path. A small local project loads in + // ~100ms, so flashing the dialog on it is just flicker. + if (plan.useAsync) { this.setState({ - loadingProgress: { - phase: progress.phase, - overallCurrent: progress.overallCurrent, - overallTotal: progress.overallTotal - } + showLoadingDialog: true, + loadingCloudProviderName: plan.cloudProviderName }); - }); + } + + const project = await loadProject( + directory, + (progress) => { + this.setState({ + loadingProgress: { + phase: progress.phase, + overallCurrent: progress.overallCurrent, + overallTotal: progress.overallTotal + } + }); + }, + plan + ); - this.setState({ showLoadingDialog: false }); + if (plan.useAsync) { + this.setState({ showLoadingDialog: false }); + } this.projectHolder.setProject(project); } // for e2e (but not entirely?) - public softReload() { + public async softReload() { const dir = this.projectHolder.project?.directory; this.projectHolder.setProject(null); - const project = Project.fromDirectory(dir!); - this.projectHolder.setProject(project); + // Route through the async loader + progress dialog (same machinery as every + // other open path) so a reload of a cloud-evicted project doesn't freeze. + await this.loadProjectWithProgress(dir!); } public goToStartScreenForTests() { @@ -279,7 +298,7 @@ class HomePage extends React.Component { }, 1000); } - private handleCreateProjectDialogClose( + private async handleCreateProjectDialogClose( directory: string, useSampleProject: boolean ) { @@ -300,10 +319,10 @@ class HomePage extends React.Component { Path.join(directory, projectName + ".sprj") ); - this.projectHolder.setProject(Project.fromDirectory(directory)); + await this.loadProjectWithProgress(directory); analyticsEvent("Create Project", "Create Sample Project"); } else { - this.projectHolder.setProject(Project.fromDirectory(directory)); + await this.loadProjectWithProgress(directory); analyticsEvent("Create Project", "Create Custom Project"); } userSettings.PreviousProjectDirectory = directory; @@ -342,11 +361,25 @@ class HomePage extends React.Component { return (
{(this.projectHolder.project && ( - { +
+ +
+ { // currently, I'm trying to just use softReload because that is more e2e // friendly and having the same behavior in e2e and non-e2e is good. @@ -355,7 +388,9 @@ class HomePage extends React.Component { //if (getTestEnvironment().E2E) this.softReload(); //else remote.getCurrentWindow().reload(); }} - /> + /> +
+
)) || (
@@ -412,6 +447,7 @@ class HomePage extends React.Component { { const directory = fs.realpathSync(Path.dirname(results.filePaths[0])); userSettings.PreviousProjectDirectory = directory; - // Use async loading with progress dialog for large projects - if (shouldUseAsyncLoading(directory)) { - this.loadProjectWithProgress(directory); - } else { - this.projectHolder.setProject(Project.fromDirectory(directory)); - } + // Always route through the async loader; it internally decides sync vs + // async and shows the progress dialog for large/cloud-evicted projects. + this.loadProjectWithProgress(directory); } }); } diff --git a/src/export/ImdiGenerator.ts b/src/export/ImdiGenerator.ts index ce2694d6..adec25e9 100644 --- a/src/export/ImdiGenerator.ts +++ b/src/export/ImdiGenerator.ts @@ -5,7 +5,7 @@ import * as XmlBuilder from "xmlbuilder"; import { Project } from "../model/Project/Project"; import { Folder } from "../model/Folder/Folder"; import moment from "moment"; -import { File } from "../model/file/File"; +import { File, kMediaStatsCacheKey } from "../model/file/File"; import * as Path from "path"; import { Person } from "../model/Project/Person/Person"; import { Set } from "typescript-collections"; @@ -706,7 +706,8 @@ export default class ImdiGenerator { "contributions", "access", "notes", // ELAR says not to export Notes to IMDI - "accessDescription" // output by addAccess() + "accessDescription", // output by addAccess() + kMediaStatsCacheKey // internal cache of probed media stats, not archival metadata ]; if (target instanceof Person) { blacklist.push("description"); // gets its own element diff --git a/src/export/imdiGenerator-media.spec.ts b/src/export/imdiGenerator-media.spec.ts index ef0139b5..a8114c07 100644 --- a/src/export/imdiGenerator-media.spec.ts +++ b/src/export/imdiGenerator-media.spec.ts @@ -1,5 +1,6 @@ import ImdiGenerator, { IMDIMode } from "./ImdiGenerator"; import { Project } from "../model/Project/Project"; +import { kMediaStatsCacheKey } from "../model/file/File"; import { setResultXml, xexpect as expect, @@ -74,4 +75,31 @@ describe("Imdi generation for images", () => { expect(value("MediaFile/Type")).toBe("Audio"); expect(value("MediaFile/Format")).toBe("audio/mpeg"); }); + + // Regression test: mediaStatsCache is internal bookkeeping (probed + // ffprobe/ExifReader results + the size/mtime used to invalidate them), + // not archival metadata, and must never show up in IMDI output. + it("does not leak the internal mediaStatsCache field into IMDI Keys", () => { + const session = project.sessions.items[0]; + const gen = new ImdiGenerator(IMDIMode.RAW_IMDI, session, project); + const f = session.files.find((file) => file.type === "Audio"); + assert(f !== undefined); + + f!.setCachedMediaStats( + { Length: "42s", Format: "MPEG Audio" }, + { sizeBytes: f!.getSizeInBytes(), mtimeMs: f!.getMtimeMs() } + ); + expect(f!.getCachedMediaStats()).toBeTruthy(); + + const xml = gen.mediaFile(f!); + setResultXml(xml!); + + // Check case-insensitively: the Keys writer runs the raw property key + // through capitalCase() for the @Name attribute, so a literal-casing + // check on "mediaStatsCache" would miss a leak. + expect(xml!.toLowerCase()).not.toContain(kMediaStatsCacheKey.toLowerCase()); + // And check that the actual cached values don't show up anywhere either. + expect(xml).not.toContain("MPEG Audio"); + expect(xml).not.toContain("42s"); + }); }); diff --git a/src/index.tsx b/src/index.tsx index 00b84ddd..6efdeb3f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,7 +8,6 @@ import { initializeSentry as initializeErrorReporting } from "./other/errorHandl import { i18n, initializeLocalization } from "./other/localization"; import { CopyManager } from "./other/CopyManager"; import { t } from "@lingui/macro"; -import { PatientFS } from "./other/patientFile"; import ReactModal from "react-modal"; import * as mobx from "mobx"; @@ -26,7 +25,6 @@ mobx.configure({ }); app.whenReady().then(async () => { - PatientFS.init(); initializeErrorReporting(false); initializeLocalization(); diff --git a/src/mainProcess/main.ts b/src/mainProcess/main.ts index 8a92cb93..8006b6bd 100644 --- a/src/mainProcess/main.ts +++ b/src/mainProcess/main.ts @@ -23,6 +23,11 @@ if (release().startsWith("6.1")) app.disableHardwareAcceleration(); // Set application name for Windows 10+ notifications if (process.platform === "win32") app.setAppUserModelId(app.getName()); +// In dev, expose CDP so tooling (and AI agents) can inspect/drive the live app. +if (is.dev || process.env.VITE_DEV_SERVER_URL) { + app.commandLine.appendSwitch("remote-debugging-port", "9222"); +} + // In normal runs we enforce a single instance. For E2E we allow parallel instances // so tests can launch while a developer has `yarn dev` running. The E2E harness // sets process.env.E2E. We also optionally redirect userData to an isolated temp diff --git a/src/model/Folder/Folder.ts b/src/model/Folder/Folder.ts index f4a60859..8784f47b 100644 --- a/src/model/Folder/Folder.ts +++ b/src/model/Folder/Folder.ts @@ -403,20 +403,14 @@ export abstract class Folder { ); return false; } - // first, we just do a trial run to see if this will work - console.debug( - `renameFilesAndFolders() at point where it test the directory name.` - ); - try { - PatientFS.renameSync(this.directory, newDirPath); - PatientFS.renameSync(newDirPath, this.directory); - } catch (err) { - NotifyFileAccessProblem( - couldNotRenameDirectory + " [[STEP:Precheck]]", - err - ); - return false; - } + // NB: we used to do a "trial run" here (rename the directory to the new + // name and immediately back). That precheck could itself strand the + // folder under the NEW name: a handle opened on a file inside during the + // instant the folder had the new name survives the rename and blocks the + // rename-back, and there was no recovery from that. Now that a failure of + // the real rename below rolls the file renames back, the precheck is + // redundant as well as risky, so it is gone; the directory is renamed + // exactly once, at the end. try { this.files.forEach((f) => { f.throwIfFilesMissing(); @@ -433,7 +427,14 @@ export abstract class Folder { `renameFilesAndFolders() at point where checks for files that have the basename as part of their name.` ); - // ok, that worked, so now have all the files rename themselves if their name depends on the folder name + // ok, that worked, so now have all the files rename themselves if their name depends on the folder name. + // Keep a snapshot per file so that if the folder rename below fails, we can + // rename the files back instead of stranding a folder with the old name + // full of files carrying the new name. + const rollbackSnapshots = this.files.map((f) => ({ + file: f, + snapshot: f.getRenameRollbackSnapshot() + })); this.files.forEach((f) => { try { f.updateNameBasedOnNewFolderName(newFolderName); @@ -457,6 +458,18 @@ export abstract class Folder { // `** Renamed Folder from ${oldFolderName} to ${newFolderName}.` // ); } catch (err) { + // Roll the per-file renames back; each rollback gets its own PatientFS + // retry budget. Reverse order to unwind in the opposite order we renamed. + for (const { file, snapshot } of [...rollbackSnapshots].reverse()) { + try { + file.rollbackRename(snapshot); + } catch (rollbackErr) { + console.error( + `renameFilesAndFolders(): rollback failed for ${snapshot.describedFileOrLinkFilePath}`, + rollbackErr + ); + } + } const msg = t`lameta was not able to rename the folder.`; NotifyFileAccessProblem( `${msg} (${this.displayName}).` + " [[STEP:Actual folder]]", @@ -550,14 +563,21 @@ export abstract class Folder { directory, metadataFileExtensionWithDot ); - if (matchingPaths.length > 1) { + // If the file we intend to save to doesn't exist but a file with the + // right extension and the wrong name does, rename it to what we expect; + // otherwise the save below would create a second metadata file and leave + // the old one behind as a zombie. (This was `> 1` for years, which + // skipped the common single-zombie case.) + if (matchingPaths.length >= 1) { + // findZombieMetadataFiles returns bare file names from readdirSync + const zombiePath = Path.join(directory, matchingPaths[0]); try { - PatientFS.renameSync(matchingPaths[0], expectedMetadataFilePath); + PatientFS.renameSync(zombiePath, expectedMetadataFilePath); return; } catch (err) { NotifyException( err, - `lameta was not able to fix the name of ${matchingPaths[0]} to fit the folder name.` // intentionally not adding the translation list + `lameta was not able to fix the name of ${zombiePath} to fit the folder name.` // intentionally not adding the translation list ); // not sure what to do now.... } @@ -565,6 +585,12 @@ export abstract class Folder { } } private findZombieMetadataFiles(directory: string, extension: string) { + // The directory can be gone if the folder vanished from disk externally + // (e.g. a sync service removed it while the project was open). readdirSync + // would throw ENOENT; there are simply no zombies to find in that case. + if (!fs.existsSync(directory)) { + return []; + } const dir = fs.readdirSync(directory); return dir.filter((f) => f.match(new RegExp(`.*(${extension})$`, "ig"))); } diff --git a/src/model/Folder/LockedFileScenarios.spec.ts b/src/model/Folder/LockedFileScenarios.spec.ts index 0e44549e..fc03a586 100644 --- a/src/model/Folder/LockedFileScenarios.spec.ts +++ b/src/model/Folder/LockedFileScenarios.spec.ts @@ -2,8 +2,9 @@ import fs from "fs"; import Path from "path"; import { Session } from "../Project/Session/Session"; import { EncounteredVocabularyRegistry } from "../Project/EncounteredVocabularyRegistry"; +import { PatientFS } from "../../other/patientFile"; import temp from "temp"; -import { describe, it, beforeEach, expect, afterEach } from "vitest"; +import { describe, it, beforeEach, expect, afterEach, vi } from "vitest"; temp.track(); describe("Duplicate Folder", () => { @@ -125,5 +126,72 @@ describe("Duplicate Folder", () => { expect(fs.existsSync(Path.join(originalDir, "foo.session"))).toBeTruthy(); expect(fs.existsSync(Path.join(originalDir, "baz.session"))).toBeFalsy(); }); + // Simulates something grabbing a handle mid-way through a rename: the + // per-file renames succeed, then the rename of the folder itself fails. + // Before the rollback was added, this stranded a folder with the OLD name + // full of files carrying the NEW name (and on the next load the .session + // got "repaired" back to the folder name, silently reverting the user's + // rename). We simulate the lock by making PatientFS fail only the directory + // rename, which is deterministic and doesn't spend ~10s in the retry loop + // the way a real lock does; the opt-in RenameContention.stress.spec.ts + // covers the same window with real locks. + it("nameMightHaveChanged(): if the final folder rename fails, file renames are rolled back", () => { + const originalDir = Path.join(rootDirectory, "foo"); + fs.mkdirSync(originalDir); + fs.writeFileSync(Path.join(originalDir, "foo_someMedia.txt"), "hello"); + fs.writeFileSync(Path.join(originalDir, "unrelated.txt"), "hello"); + + const original = Session.fromDirectory( + originalDir, + new EncounteredVocabularyRegistry() + ); + original.properties.setText("id", "foo"); + original.saveFolderMetaData(); + original.saveAllFilesInFolder(false); + const filesBefore = fs.readdirSync(originalDir).sort(); + + const newDir = Path.join(rootDirectory, "baz"); + const realRenameSync = PatientFS.renameSync.bind(PatientFS); + const spy = vi + .spyOn(PatientFS, "renameSync") + .mockImplementation((from: string, to: string) => { + if (from === originalDir && to === newDir) { + const err: NodeJS.ErrnoException = new Error( + "simulated lock landed after the files were renamed" + ); + err.code = "EPERM"; + throw err; + } + return realRenameSync(from, to); + }); + try { + original.properties.setText("id", "baz"); + expect(original.nameMightHaveChanged()).toBeFalsy(); + } finally { + spy.mockRestore(); + } + + // the folder must still be fully in its OLD state: old name, old file names + expect(fs.existsSync(originalDir)).toBeTruthy(); + expect(fs.existsSync(newDir)).toBeFalsy(); + expect(fs.readdirSync(originalDir).sort()).toEqual(filesBefore); + // and the in-memory model must agree with the disk + expect(fs.existsSync(original.metadataFile!.metadataFilePath)).toBeTruthy(); + expect(original.metadataFile!.metadataFilePath).toBe( + Path.join(originalDir, "foo.session") + ); + + // having recovered, a later rename (lock gone) must succeed completely + expect(original.nameMightHaveChanged()).toBeTruthy(); + expect(fs.existsSync(newDir)).toBeTruthy(); + expect(fs.existsSync(originalDir)).toBeFalsy(); + expect(fs.existsSync(Path.join(newDir, "baz.session"))).toBeTruthy(); + expect(fs.existsSync(Path.join(newDir, "baz_someMedia.txt"))).toBeTruthy(); + expect(fs.existsSync(Path.join(newDir, "unrelated.txt"))).toBeTruthy(); + expect( + fs.readdirSync(newDir).filter((f) => f.startsWith("foo")) + ).toEqual([]); + }); + // TODO: test when a folder already has two sessions? We could pick the bigger one? }); diff --git a/src/model/Folder/RenameContention.stress.spec.ts b/src/model/Folder/RenameContention.stress.spec.ts new file mode 100644 index 00000000..0411e4ac --- /dev/null +++ b/src/model/Folder/RenameContention.stress.spec.ts @@ -0,0 +1,252 @@ +import fs from "fs"; +import Path from "path"; +import { spawn, ChildProcess } from "child_process"; +import temp from "temp"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Session } from "../Project/Session/Session"; +import { EncounteredVocabularyRegistry } from "../Project/EncounteredVocabularyRegistry"; + +/* + Torture test for PatientFS: an external "meddler" process plays the role of an + antivirus scanner / OneDrive / Dropbox / search indexer. It watches the project + directory and, on every file event, briefly opens the changed file WITHOUT + delete-sharing — which is exactly what real scanners do, and is what makes + renames fail with EBUSY (the file itself) or EPERM (its parent directory). + Meanwhile we drive session renames through the real Folder/Session code. + + LockedFileScenarios.spec.ts notes it "cannot simulate something getting locked + mid-way through a rename" — this test can, because the meddler reacts to the + rename events themselves, landing locks in the windows between the individual + file renames and the final folder rename. + + Windows-only: on POSIX an open handle does not block rename, so the whole + contention class doesn't exist there. Slow (retries sleep ~1s), so opt-in: + + LAMETA_STRESS=1 yarn vitest run RenameContention + + Real-provider mode: point it at a directory inside an actual syncing + OneDrive/Dropbox root and the sync engine itself is the adversary — no + artificial meddler is spawned, and media files are made big enough that + uploads are still in flight when the renames land: + + LAMETA_STRESS=1 LAMETA_STRESS_DIR=C:\\OneDrive-tests\\OneDrive yarn vitest run RenameContention + + If this test fails, it found a real robustness bug; a pass after N cycles is + evidence the PatientFS retry strategy is holding up. +*/ + +const RENAME_CYCLES = 12; + +const stressEnabled = + process.platform === "win32" && !!process.env.LAMETA_STRESS; +// When targeting a real cloud-synced directory, the provider is the meddler. +const realProviderDir = process.env.LAMETA_STRESS_DIR; +const mediaFileBytes = realProviderDir ? 8 * 1024 * 1024 : 32; + +temp.track(); + +// The meddler: continuously picks a random file under the target directory and +// opens it with FileShare.ReadWrite — i.e. NO Delete sharing, so while held, +// renaming the file fails with EBUSY and renaming any ancestor directory fails +// with EPERM (verified empirically; that is exactly how AV scanners / sync +// clients / indexers interfere). Mostly short scanner-like holds, occasionally +// a long one to force multiple PatientFS retries. +const meddlerScript = ` +param([string]$target) +[Console]::Out.WriteLine("MEDDLER-READY") +$rand = New-Object System.Random +while ($true) { + try { + $files = [System.IO.Directory]::GetFiles($target, '*', 'AllDirectories') + if ($files.Length -gt 0) { + $p = $files[$rand.Next($files.Length)] + $holdMs = $rand.Next(30, 400) + if ($rand.Next(8) -eq 0) { $holdMs = 1500 } # occasional slow scan + $h = [System.IO.File]::Open($p, [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) + [Console]::Out.WriteLine("HELD $holdMs $p") + [System.Threading.Thread]::Sleep($holdMs) + $h.Close() + } + } catch {} + [System.Threading.Thread]::Sleep($rand.Next(10, 80)) +} +`; + +describe.skipIf(!stressEnabled)("Rename contention stress (PatientFS)", () => { + let rootDirectory: string; + let meddler: ChildProcess; + const meddlerHolds: string[] = []; + + beforeAll(async () => { + if (realProviderDir) { + rootDirectory = Path.join( + realProviderDir, + `renameContention-${process.pid}` + ); + fs.mkdirSync(rootDirectory, { recursive: true }); + return; // no artificial meddler; the sync engine is the adversary + } + rootDirectory = temp.mkdirSync("renameContention"); + const scriptPath = Path.join(rootDirectory, "..", "meddler.ps1"); + fs.writeFileSync(scriptPath, meddlerScript); + meddler = spawn( + "powershell", + ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath, "-target", rootDirectory], + { stdio: ["ignore", "pipe", "inherit"] } + ); + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("meddler never became ready")), + 15000 + ); + meddler.stdout!.on("data", (d) => { + const s = d.toString(); + for (const line of s.split(/\r?\n/)) + if (line.startsWith("HELD")) meddlerHolds.push(line); + if (s.includes("MEDDLER-READY")) { + clearTimeout(timer); + resolve(); + } + }); + }); + }); + + afterAll(() => { + meddler?.kill(); + if (realProviderDir) { + try { + fs.rmSync(rootDirectory, { recursive: true, force: true }); + } catch { + // the provider may be holding something; leave it for manual cleanup + } + } + }); + + it( + `survives ${RENAME_CYCLES} rename cycles while a scanner-like process grabs file handles`, + { timeout: 600_000 }, + async () => { + const startName = "cycle0"; + const dir = Path.join(rootDirectory, startName); + fs.mkdirSync(dir); + // In real-provider mode these are big enough that the sync engine is + // still uploading them while we rename. + const fakeMedia = Buffer.alloc(mediaFileBytes, 7); + fs.writeFileSync(Path.join(dir, `${startName}_video.mp4`), fakeMedia); + fs.writeFileSync(Path.join(dir, `${startName}_audio.wav`), fakeMedia); + fs.writeFileSync(Path.join(dir, "unrelated.txt"), "not renamed"); + + const session = Session.fromDirectory( + dir, + new EncounteredVocabularyRegistry() + ); + session.properties.setText("id", startName); + session.saveFolderMetaData(); + + // Warm-up rename with no assertion: the first save/rename creates the + // .meta sidecars, after which the file census must stay constant. + session.properties.setText("id", "warmup"); + let warm = false; + for (let t = 0; t < 5 && !warm; t++) { + warm = session.nameMightHaveChanged(); + } + expect(warm, "warmup rename never succeeded").toBe(true); + let expectedName = "warmup"; + const expectedFileCount = fs.readdirSync( + Path.join(rootDirectory, expectedName) + ).length; + + const anomalies: string[] = []; + const failedRenames: number[] = []; + const cycleDurations: number[] = []; + + for (let i = 1; i <= RENAME_CYCLES; i++) { + // Pause so the meddler (reacting to the previous cycle's file events) + // is still holding handles when this rename runs — that's the mid-rename + // collision we're trying to produce. + await new Promise((r) => setTimeout(r, 100 + (i % 4) * 60)); + + const newName = `cycle${i}`; + session.properties.setText("id", newName); + const t0 = Date.now(); + const succeeded = session.nameMightHaveChanged(); + cycleDurations.push(Date.now() - t0); + // A rename may legitimately fail under sustained contention (the + // retry budget is finite). That is NOT an anomaly by itself — the + // contract is that a failed rename must leave the folder in the fully + // consistent OLD state, and a successful one in the fully consistent + // NEW state. + if (succeeded) { + expectedName = newName; + } else { + failedRenames.push(i); + } + + const expectedDir = Path.join(rootDirectory, expectedName); + + // Invariant 1: exactly one directory, under the name the model believes in + const dirs = fs + .readdirSync(rootDirectory) + .filter((f) => + fs.statSync(Path.join(rootDirectory, f)).isDirectory() + ); + if (dirs.length !== 1 || dirs[0] !== expectedName) { + anomalies.push( + `cycle ${i}: expected single dir "${expectedName}", found [${dirs.join(", ")}]` + ); + break; // state is corrupt; later cycles would just cascade + } + + // Invariant 2: no files lost, no duplicated metadata, no stragglers + // still carrying an older cycle's name + const files = fs.readdirSync(expectedDir); + const sessionFiles = files.filter((f) => f.endsWith(".session")); + if (sessionFiles.length !== 1) { + anomalies.push( + `cycle ${i}: expected 1 .session file, found [${sessionFiles.join(", ")}]` + ); + } else if (sessionFiles[0] !== `${expectedName}.session`) { + anomalies.push( + `cycle ${i}: .session file is "${sessionFiles[0]}" but folder is "${expectedName}"` + ); + } + if (files.length !== expectedFileCount) { + anomalies.push( + `cycle ${i}: expected ${expectedFileCount} files, found ${files.length}: [${files.join(", ")}]` + ); + } + const stragglers = files.filter( + (f) => + /^(cycle\d+|warmup)/.test(f) && + !f.startsWith(expectedName + ".") && + !f.startsWith(expectedName + "_") + ); + if (stragglers.length > 0) { + anomalies.push( + `cycle ${i}: files left behind with a stale name: [${stragglers.join(", ")}]` + ); + } + + // Invariant 3: the in-memory model points at a file that exists + if (!fs.existsSync(session.metadataFile!.metadataFilePath)) { + anomalies.push( + `cycle ${i}: model's metadataFilePath ${session.metadataFile!.metadataFilePath} does not exist on disk` + ); + } + } + + const retriedCycles = cycleDurations.filter((d) => d > 900).length; + console.log( + `meddler holds: ${meddlerHolds.length}, cycles that hit the PatientFS retry path (>900ms): ${retriedCycles}, ` + + `renames that gave up: [${failedRenames.join(",")}], durations: ${cycleDurations.join(",")}` + ); + // If the meddler never grabbed anything, this run was not a real torture + // test — fail loudly rather than report false confidence. (In + // real-provider mode we can't observe the provider's handles, so we just + // report retry counts and trust the invariants.) + if (!realProviderDir) expect(meddlerHolds.length).toBeGreaterThan(0); + expect(anomalies, anomalies.join("\n")).toEqual([]); + } + ); +}); diff --git a/src/model/Folder/ZombieMetadataRepair.spec.ts b/src/model/Folder/ZombieMetadataRepair.spec.ts new file mode 100644 index 00000000..39d1fdef --- /dev/null +++ b/src/model/Folder/ZombieMetadataRepair.spec.ts @@ -0,0 +1,73 @@ +import fs from "fs"; +import Path from "path"; +import { Session } from "../Project/Session/Session"; +import { EncounteredVocabularyRegistry } from "../Project/EncounteredVocabularyRegistry"; +import temp from "temp"; +import { describe, it, beforeEach, expect } from "vitest"; + +temp.track(); + +/* + When the metadata file the model believes in (e.g. foo/foo.session) is + missing but a file with the right extension and a different name exists + (e.g. after an interrupted rename, or the user meddling in the file + explorer), saveFolderMetaData() should rename that "zombie" to the expected + name rather than writing a fresh expected file NEXT TO the zombie — + otherwise the folder accumulates two .session files and, on the next load, + which one wins is arbitrary. + + Historically the repair had two bugs that made it dead code: it only fired + when there were TWO OR MORE zombies (`> 1`), and it passed a bare file name + from readdirSync to renameSync, which can't resolve. These tests pin the + fixed behavior. +*/ +describe("zombie metadata repair at save time", () => { + let rootDirectory: string; + beforeEach(() => { + rootDirectory = temp.mkdirSync("testZombieRepair"); + }); + + it("saveFolderMetaData() renames a single misnamed metadata file instead of creating a second one", () => { + const dir = Path.join(rootDirectory, "foo"); + fs.mkdirSync(dir); + const session = Session.fromDirectory( + dir, + new EncounteredVocabularyRegistry() + ); + session.properties.setText("id", "foo"); + session.saveFolderMetaData(); + expect(fs.existsSync(Path.join(dir, "foo.session"))).toBeTruthy(); + + // someone/something renames the metadata file behind our back + fs.renameSync( + Path.join(dir, "foo.session"), + Path.join(dir, "stale.session") + ); + + session.saveFolderMetaData(); + + const sessionFiles = fs + .readdirSync(dir) + .filter((f) => f.endsWith(".session")); + expect(sessionFiles).toEqual(["foo.session"]); + }); + + it("saveFolderMetaData() still works when the metadata file is simply missing", () => { + const dir = Path.join(rootDirectory, "bar"); + fs.mkdirSync(dir); + const session = Session.fromDirectory( + dir, + new EncounteredVocabularyRegistry() + ); + session.properties.setText("id", "bar"); + session.saveFolderMetaData(); + + fs.rmSync(Path.join(dir, "bar.session")); + session.saveFolderMetaData(); + + const sessionFiles = fs + .readdirSync(dir) + .filter((f) => f.endsWith(".session")); + expect(sessionFiles).toEqual(["bar.session"]); + }); +}); diff --git a/src/model/Project/Project.ts b/src/model/Project/Project.ts index 2c1495e8..e0b76ce3 100644 --- a/src/model/Project/Project.ts +++ b/src/model/Project/Project.ts @@ -30,6 +30,8 @@ import { LanguageSlot } from "../field/TextHolder"; // FIXED: Import safeCaptureException instead of using Sentry directly // CONTEXT: This prevents E2E test failures from Sentry RendererTransport errors import { safeCaptureException } from "../../other/errorHandling"; +import { cloudReadGuard } from "../../other/cloudReadGuard"; +import { prefetchCloudMetadata } from "../../other/cloudMetadataPrefetch"; import genres from "./Session/genres.json"; @@ -502,6 +504,13 @@ export class Project extends Folder { let sessionCount = 0; let personCount = 0; try { + // Start each load with a clean cloud circuit breaker (see cloudReadGuard). + cloudReadGuard.reset(); + + // Kick off hydration of all cloud-only metadata placeholders in parallel + // before the serial reads below, so we don't wait for them one at a time. + prefetchCloudMetadata(directory); + const customVocabularies = new EncounteredVocabularyRegistry(); const metadataFile = new ProjectMetadataFile( directory, @@ -656,6 +665,13 @@ export class Project extends Folder { ): Promise { const startTime = performance.now(); try { + // Start each load with a clean cloud circuit breaker (see cloudReadGuard). + cloudReadGuard.reset(); + + // Kick off hydration of all cloud-only metadata placeholders in parallel + // before the serial reads below, so we don't wait for them one at a time. + prefetchCloudMetadata(directory); + const customVocabularies = new EncounteredVocabularyRegistry(); const metadataFile = new ProjectMetadataFile( directory, @@ -796,6 +812,33 @@ export class Project extends Folder { } } + // Every Folder in the project whose metadata files we read at load time. + private getAllFolders(): Folder[] { + const folders: Folder[] = [this]; + if (this.descriptionFolder) folders.push(this.descriptionFolder); + if (this.otherDocsFolder) folders.push(this.otherDocsFolder); + folders.push(...this.sessions.items, ...this.persons.items); + return folders; + } + + // Re-attempt reading the metadata of files that a cloud provider couldn't + // deliver during load. Backs the "cloud unavailable" banner's Retry action. + // If the provider is still down, the circuit breaker simply trips again and + // the banner stays; otherwise the rows fill in and the banner clears. + public retryFailedCloudReads(): void { + cloudReadGuard.reset(); + for (const folder of this.getAllFolders()) { + const files: File[] = []; + if (folder.metadataFile) files.push(folder.metadataFile); + files.push(...folder.files); + for (const file of files) { + if (file.cloudMetadataUnavailable) { + file.readMetadataFile(); + } + } + } + } + private makePersonFromDirectory(dir: string): Person { return Person.fromDirectory( dir, diff --git a/src/model/file/CorruptMetadata.spec.ts b/src/model/file/CorruptMetadata.spec.ts new file mode 100644 index 00000000..eb976aa9 --- /dev/null +++ b/src/model/file/CorruptMetadata.spec.ts @@ -0,0 +1,218 @@ +import * as fs from "fs-extra"; +import * as Path from "path"; +import * as temp from "temp"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { OtherFile } from "./File"; +import { Session, SessionMetadataFile } from "../Project/Session/Session"; +import { EncounteredVocabularyRegistry } from "../Project/EncounteredVocabularyRegistry"; +import { PatientFS } from "../../other/patientFile"; +import * as NotifyModule from "../../components/Notify"; + +// A cloud sync (OneDrive, Dropbox, ...) that gets interrupted mid-write can +// leave a 0-byte or truncated .session/.person file on disk. These tests +// document what currently happens when lameta tries to read one, and confirm +// the specific data-loss path this change closes: a failed read must never +// let a later save() overwrite the user's real (possibly still recoverable) +// bytes with an empty template. + +function getPretendAudioFile(): string { + const path = temp.path({ suffix: ".mp3" }) as string; + fs.writeFileSync(path, "pretend contents"); + return path; +} + +function makeSessionFolder(): { + tmpFolder: string; + sessionFolder: string; + sessionFilePath: string; +} { + const tmpFolder = temp.mkdirSync(); + const sessionFolder = Path.join(tmpFolder, "ETR009"); + fs.mkdirSync(sessionFolder); + const sessionFilePath = Path.join(sessionFolder, "ETR009.session"); + return { tmpFolder, sessionFolder, sessionFilePath }; +} + +const validSessionXml = ` + + ETR009 + Test Session +`; + +describe("corrupt/truncated metadata files", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("throws (does not silently corrupt) on a 0-byte .session file, and leaves the file untouched", () => { + const { tmpFolder, sessionFolder, sessionFilePath } = makeSessionFolder(); + try { + fs.writeFileSync(sessionFilePath, ""); + + let threw = false; + try { + new SessionMetadataFile(sessionFolder, new EncounteredVocabularyRegistry()); + } catch (e) { + threw = true; + } + + // Current architecture: a genuinely unparseable metadata file throws out + // of the File constructor, and that exception propagates all the way up + // through Session.fromDirectory / Project.fromDirectory, aborting the + // WHOLE project load (see Project.fromDirectory's outer catch). Fixing + // that propagation is a separate, larger change outside + // src/model/file/File.ts. What this test guarantees is narrower but + // still important: the failed read never touches the bytes on disk, so + // the 0-byte file remains exactly as it was for manual recovery. + expect(threw).toBe(true); + expect(fs.readFileSync(sessionFilePath, "utf8")).toBe(""); + } finally { + fs.removeSync(tmpFolder); + } + }); + + it("throws (does not silently corrupt) on a truncated-XML .session file, and leaves the file untouched", () => { + const { tmpFolder, sessionFolder, sessionFilePath } = makeSessionFolder(); + try { + // First half of a valid file -- an interrupted cloud sync writes exactly + // this kind of half-written file. + const truncated = validSessionXml.slice( + 0, + Math.floor(validSessionXml.length / 2) + ); + fs.writeFileSync(sessionFilePath, truncated); + + let threw = false; + try { + new SessionMetadataFile(sessionFolder, new EncounteredVocabularyRegistry()); + } catch (e) { + threw = true; + } + + expect(threw).toBe(true); + expect(fs.readFileSync(sessionFilePath, "utf8")).toBe(truncated); + } finally { + fs.removeSync(tmpFolder); + } + }); + + it("loads the real session fine alongside a OneDrive/Dropbox '(conflicted copy)' sibling file", () => { + const { tmpFolder, sessionFolder, sessionFilePath } = makeSessionFolder(); + try { + fs.writeFileSync(sessionFilePath, validSessionXml); + // What OneDrive/Dropbox create when the same file is edited from two + // devices while offline and both changes later sync: a second copy + // named after the sync engine's conflict convention, sitting right next + // to the original. + const conflictedCopyPath = Path.join( + sessionFolder, + "ETR009 (conflicted copy).session" + ); + fs.writeFileSync(conflictedCopyPath, validSessionXml); + + let threw = false; + let session: Session | undefined; + try { + session = Session.fromDirectory( + sessionFolder, + new EncounteredVocabularyRegistry() + ); + } catch (e) { + threw = true; + } + + expect(threw).toBe(false); + // The real session still loads and reads correctly. + expect(session!.metadataFile!.getTextProperty("title")).toBe( + "Test Session" + ); + + // Documented current behavior: lameta has no special handling for a + // sync engine's "(conflicted copy)" naming convention. Folder.loadChildFiles + // only special-cases the folder's own metadata file (by exact path) and + // skips ".meta"/".test" files -- everything else, including this sibling + // ".session" file, is picked up as an ordinary attached OtherFile (with + // its own auto-created ".meta" sidecar), just like any other + // unrecognized document. It is NOT parsed as session metadata, merged, + // or flagged to the user as a conflict. + const conflictedAsOtherFile = session!.files.find( + (f) => f.pathInFolderToLinkFileOrLocalCopy === conflictedCopyPath + ); + expect(conflictedAsOtherFile).toBeDefined(); + } finally { + fs.removeSync(tmpFolder); + } + }); + + it("sets metadataReadFailed and refuses to save (even forced) over an unparseable .meta file, warning instead", () => { + const mediaFilePath = getPretendAudioFile(); + const metaFilePath = mediaFilePath + ".meta"; + // 0 bytes: exactly what an interrupted cloud sync leaves behind. + fs.writeFileSync(metaFilePath, ""); + + // partialLoadWhileCopyingInThisFile=true defers finishLoading() so we can + // call it ourselves and keep a reference to `f` even though the read + // throws -- otherwise (as in the tests above) the failed constructor call + // would discard the object and there would be nothing to call save() on. + const f = new OtherFile( + mediaFilePath, + new EncounteredVocabularyRegistry(), + /*partialLoadWhileCopyingInThisFile*/ true + ); + + const notifySpy = vi + .spyOn(NotifyModule, "NotifyException") + .mockImplementation(() => { + /* swallow */ + }); + + let threw = false; + try { + f.finishLoading(); + } catch (e) { + threw = true; + } + + expect(threw).toBe(true); + expect(f.metadataReadFailed).toBe(true); + expect(notifySpy).toHaveBeenCalledTimes(1); + + const writeSpy = vi.spyOn(PatientFS, "writeFileSyncWithNotifyThenRethrow"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { + /* swallow */ + }); + + f.save(); // normal save + f.save(/*beforeRename*/ false, /*forceSave*/ true); // forced save + + expect(writeSpy).not.toHaveBeenCalled(); + expect(fs.readFileSync(metaFilePath, "utf8")).toBe(""); + expect(warnSpy).toHaveBeenCalled(); + }); + + it("fires NotifyException at most once per file even if readMetadataFile is invoked again afterward", () => { + const mediaFilePath = getPretendAudioFile(); + const metaFilePath = mediaFilePath + ".meta"; + fs.writeFileSync(metaFilePath, ""); + + const f = new OtherFile( + mediaFilePath, + new EncounteredVocabularyRegistry(), + /*partialLoadWhileCopyingInThisFile*/ true + ); + + const notifySpy = vi + .spyOn(NotifyModule, "NotifyException") + .mockImplementation(() => { + /* swallow */ + }); + + expect(() => f.finishLoading()).toThrow(); + expect(notifySpy).toHaveBeenCalledTimes(1); + + // haveReadMetadataFile is already set, so a second, direct call just logs + // and returns -- it must not attempt to re-parse or re-notify. + expect(() => f.readMetadataFile()).not.toThrow(); + expect(notifySpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/model/file/ExternallyChangedFile.spec.ts b/src/model/file/ExternallyChangedFile.spec.ts new file mode 100644 index 00000000..9b26e97b --- /dev/null +++ b/src/model/file/ExternallyChangedFile.spec.ts @@ -0,0 +1,135 @@ +import * as fs from "fs-extra"; +import * as Path from "path"; +import * as temp from "temp"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { Session } from "../Project/Session/Session"; +import { EncounteredVocabularyRegistry } from "../Project/EncounteredVocabularyRegistry"; +import * as NotifyModule from "../../components/Notify"; + +// When two people share a project via a cloud-sync service (OneDrive/Dropbox), +// user A's edit to a session syncs down onto user B's disk while B's lameta +// still holds that session in memory. lameta never re-reads metadata after +// load, so when B next saves that session, B's whole stale XML used to +// overwrite the file -- silently reverting A's edit with no trace anywhere. +// +// The fix: at the write choke point (File.save), if the metadata file on disk +// no longer matches what lameta last read/wrote, move that on-disk version +// aside to a sibling before writing ours, and warn once. These tests pin that +// "never silently clobber" behavior, plus the no-false-positive guard for +// mere mtime churn (a sync engine rewriting byte-identical content). + +function makeSavedSession(): { + folder: string; + session: Session; + sessionFilePath: string; +} { + const tmp = temp.mkdirSync("externally-changed-session"); + const folder = Path.join(tmp, "ETR009"); + fs.mkdirSync(folder); + const session = Session.fromDirectory( + folder, + new EncounteredVocabularyRegistry() + ); + session.properties.setText("id", "ETR009"); + session.saveAllFilesInFolder(); + const sessionFilePath = session.metadataFile!.metadataFilePath; + expect(fs.existsSync(sessionFilePath)).toBe(true); + return { folder, session, sessionFilePath }; +} + +function setAsideSiblings(folder: string): string[] { + return fs + .readdirSync(folder) + .filter((f) => f.includes("changed on another computer")); +} + +// Rewrite the file with different content AND a distinctly different mtime, as +// a sync service delivering another machine's edit would. +function externallyReplace(path: string, contents: string) { + fs.writeFileSync(path, contents); + const future = new Date(Date.now() + 60_000); + fs.utimesSync(path, future, future); +} + +describe("a metadata file changed on disk underneath lameta", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("moves the external version aside, writes ours, warns exactly once, and does not re-trigger on the next save", () => { + const { folder, session, sessionFilePath } = makeSavedSession(); + const file = session.metadataFile!; + + const warnSpy = vi + .spyOn(NotifyModule, "NotifyWarning") + .mockImplementation(() => {}); + const errorSpy = vi + .spyOn(NotifyModule, "NotifyError") + .mockImplementation(() => {}); + + // user A's edit arrives on disk (a whole valid session file, different + // content) while B's lameta still has the session in memory. + const remoteXml = fs + .readFileSync(sessionFilePath, "utf8") + .replace("", " REMOTE EDIT FROM USER A\n"); + externallyReplace(sessionFilePath, remoteXml); + + // B edits a different field and saves. + session.properties.setText("title", "TITLE TYPED BY USER B"); + file.save(); + + // Our version won the canonical name... + const canonical = fs.readFileSync(sessionFilePath, "utf8"); + expect(canonical).toContain("TITLE TYPED BY USER B"); + expect(canonical).not.toContain("REMOTE EDIT FROM USER A"); + + // ...and A's edit was preserved in exactly one set-aside sibling. + const siblings = setAsideSiblings(folder); + expect(siblings.length).toBe(1); + expect(siblings[0].endsWith(".session")).toBe(true); + const setAside = fs.readFileSync(Path.join(folder, siblings[0]), "utf8"); + expect(setAside).toContain("REMOTE EDIT FROM USER A"); + + // Exactly one gentle warning; no raw error toast. + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(String(warnSpy.mock.calls[0][0]).toLowerCase()).toContain( + "outside of lameta" + ); + expect(errorSpy).not.toHaveBeenCalled(); + expect(file.metadataChangedExternally).toBe(true); + + // The NEXT save (no new external change) writes normally: no additional + // set-aside, no additional warning. + session.properties.setText("title", "SECOND EDIT BY USER B"); + file.save(); + expect(setAsideSiblings(folder).length).toBe(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(fs.readFileSync(sessionFilePath, "utf8")).toContain( + "SECOND EDIT BY USER B" + ); + }); + + it("does NOT set aside on mere mtime churn (sync engine rewrote byte-identical content)", () => { + const { folder, session, sessionFilePath } = makeSavedSession(); + const file = session.metadataFile!; + + const warnSpy = vi + .spyOn(NotifyModule, "NotifyWarning") + .mockImplementation(() => {}); + + // The sync engine rewrites the exact same bytes lameta last wrote, only + // bumping the mtime -- no real edit. + const sameBytes = fs.readFileSync(sessionFilePath, "utf8"); + externallyReplace(sessionFilePath, sameBytes); + + session.properties.setText("title", "EDIT AFTER CHURN"); + file.save(); + + expect(setAsideSiblings(folder).length).toBe(0); + expect(warnSpy).not.toHaveBeenCalled(); + expect(file.metadataChangedExternally).toBe(false); + expect(fs.readFileSync(sessionFilePath, "utf8")).toContain( + "EDIT AFTER CHURN" + ); + }); +}); diff --git a/src/model/file/File.spec.ts b/src/model/file/File.spec.ts index 0a634918..c0925563 100644 --- a/src/model/file/File.spec.ts +++ b/src/model/file/File.spec.ts @@ -6,7 +6,14 @@ import { SessionMetadataFile } from "../Project/Session/Session"; import { ProjectMetadataFile } from "../Project/Project"; import { EncounteredVocabularyRegistry } from "../Project/EncounteredVocabularyRegistry"; import { setResultXml, xexpect as expect } from "../../other/xmlUnitTestUtils"; -import { describe, it } from "vitest"; +import { describe, it, afterEach, vi } from "vitest"; +import { + setAttributeReaderForTests, + setPinWriterForTests +} from "../../other/cloudFileStatus"; +import { cloudFilePoller } from "../../other/cloudFilePoller"; +import { PatientFS } from "../../other/patientFile"; +import { cloudReadGuard } from "../../other/cloudReadGuard"; function getPretendAudioFile(): string { const path = temp.path({ suffix: ".mp3" }) as string; @@ -448,3 +455,414 @@ describe("Genre normalization during reading", () => { } }); }); + +describe("File cached media stats", () => { + it("roundtrips cached stats through save/load", () => { + const mediaFilePath = getPretendAudioFile(); + const f = new OtherFile(mediaFilePath, new EncounteredVocabularyRegistry()); + const identity = { + sizeBytes: f.getSizeInBytes(), + mtimeMs: f.getMtimeMs() + }; + f.setCachedMediaStats({ Length: "1:02", Format: "MPEG Audio" }, identity); + f.save(); + + const f2 = new OtherFile( + mediaFilePath, + new EncounteredVocabularyRegistry() + ); + expect(f2.getCachedMediaStats()).toEqual({ + Length: "1:02", + Format: "MPEG Audio" + }); + }); + + it("returns undefined when there is no cache yet", () => { + const mediaFilePath = getPretendAudioFile(); + const f = new OtherFile(mediaFilePath, new EncounteredVocabularyRegistry()); + expect(f.getCachedMediaStats()).toBeUndefined(); + }); + + it("is stale (undefined) when the file's size has changed since caching", () => { + const mediaFilePath = getPretendAudioFile(); + const f = new OtherFile(mediaFilePath, new EncounteredVocabularyRegistry()); + f.setCachedMediaStats( + { Length: "1:02" }, + { sizeBytes: f.getSizeInBytes(), mtimeMs: f.getMtimeMs() } + ); + f.save(); + + fs.appendFileSync(mediaFilePath, "more bytes, changing the size"); + + const f2 = new OtherFile( + mediaFilePath, + new EncounteredVocabularyRegistry() + ); + expect(f2.getCachedMediaStats()).toBeUndefined(); + }); + + it("is stale (undefined) when the file's mtime has changed since caching, even with the same size", () => { + const mediaFilePath = getPretendAudioFile(); + const f = new OtherFile(mediaFilePath, new EncounteredVocabularyRegistry()); + f.setCachedMediaStats( + { Length: "1:02" }, + { sizeBytes: f.getSizeInBytes(), mtimeMs: f.getMtimeMs() } + ); + f.save(); + + const future = new Date(Date.now() + 60_000); + fs.utimesSync(mediaFilePath, future, future); + + const f2 = new OtherFile( + mediaFilePath, + new EncounteredVocabularyRegistry() + ); + expect(f2.getCachedMediaStats()).toBeUndefined(); + }); + + it("does not rewrite the .meta file when setting identical cached stats again", () => { + const mediaFilePath = getPretendAudioFile(); + const f = new OtherFile(mediaFilePath, new EncounteredVocabularyRegistry()); + const identity = { + sizeBytes: f.getSizeInBytes(), + mtimeMs: f.getMtimeMs() + }; + f.setCachedMediaStats({ Length: "1:02" }, identity); + f.save(); + + const writeSpy = vi.spyOn(PatientFS, "writeFileSyncWithNotifyThenRethrow"); + try { + f.setCachedMediaStats({ Length: "1:02" }, identity); + f.save(); + expect(writeSpy).not.toHaveBeenCalled(); + } finally { + writeSpy.mockRestore(); + } + }); +}); + +describe("File cloudStatus", () => { + afterEach(() => { + setAttributeReaderForTests(undefined); + setPinWriterForTests(undefined); + cloudFilePoller.dispose(); + cloudReadGuard.reset(); + vi.restoreAllMocks(); + }); + + function makeCloudPlaceholderReader() { + return () => ({ + IS_OFFLINE: true, + IS_RECALL_ON_DATA_ACCESS: true, + IS_RECALL_ON_OPEN: false, + IS_PINNED: false + }); + } + + function throwCloudReadError(): never { + // How the Windows Cloud Files API surfaces a failed placeholder hydration + // to Node (verified against a broken Nextcloud provider). + const err: any = new Error("UNKNOWN: unknown error, read"); + err.code = "UNKNOWN"; + err.errno = -4094; + err.syscall = "read"; + throw err; + } + + it("soft-fails (no throw) and records a cloud failure when a placeholder metadata read fails", () => { + const mediaFilePath = getPretendAudioFile(); + // Seed a real .meta on disk so existsSync passes during the reload. + const seed = new OtherFile( + mediaFilePath, + new EncounteredVocabularyRegistry() + ); + seed.save(); + + cloudReadGuard.reset(); + setPinWriterForTests(async () => {}); + setAttributeReaderForTests(makeCloudPlaceholderReader()); + vi.spyOn(PatientFS, "readFileSyncNoNotify").mockImplementation( + throwCloudReadError + ); + + let threw = false; + let f: OtherFile | undefined; + try { + f = new OtherFile(mediaFilePath, new EncounteredVocabularyRegistry()); + } catch { + threw = true; + } + + expect(threw).toBe(false); + expect(f?.cloudMetadataUnavailable).toBe(true); + expect(cloudReadGuard.isTripped).toBe(true); + expect(cloudReadGuard.hasFailures).toBe(true); + }); + + it("does not read or pin further placeholders once the breaker is tripped", () => { + const mediaFilePath = getPretendAudioFile(); + const seed = new OtherFile( + mediaFilePath, + new EncounteredVocabularyRegistry() + ); + seed.save(); + + cloudReadGuard.reset(); + // Simulate an earlier failure in this same load having tripped the breaker. + cloudReadGuard.recordFailure("earlier/failed.session", "Nextcloud"); + + let pinCount = 0; + setPinWriterForTests(async () => { + pinCount++; + }); + setAttributeReaderForTests(makeCloudPlaceholderReader()); + let readCount = 0; + vi.spyOn(PatientFS, "readFileSyncNoNotify").mockImplementation(() => { + readCount++; + return ""; + }); + + const f = new OtherFile(mediaFilePath, new EncounteredVocabularyRegistry()); + + expect(readCount).toBe(0); + expect(pinCount).toBe(0); + expect(f.cloudMetadataUnavailable).toBe(true); + }); + + it("save() refuses to overwrite a file whose placeholder read soft-failed (data-loss guard)", () => { + // A cloud placeholder we couldn't read has empty in-memory properties; + // saving it would destroy the real file on disk (and sync the empty + // version back). save() must skip it -- even a forced save. + const mediaFilePath = getPretendAudioFile(); + const seed = new OtherFile( + mediaFilePath, + new EncounteredVocabularyRegistry() + ); + seed.save(); // real .meta exists on disk + + cloudReadGuard.reset(); + setPinWriterForTests(async () => {}); + setAttributeReaderForTests(makeCloudPlaceholderReader()); + vi.spyOn(PatientFS, "readFileSyncNoNotify").mockImplementation( + throwCloudReadError + ); + + const f = new OtherFile(mediaFilePath, new EncounteredVocabularyRegistry()); + expect(f.cloudMetadataUnavailable).toBe(true); + + const writeSpy = vi.spyOn(PatientFS, "writeFileSyncWithNotifyThenRethrow"); + f.save(); // normal save + f.save(/*beforeRename*/ false, /*forceSave*/ true); // forced save + expect(writeSpy.mock.calls.length).toBe(0); + }); + + it("is set from the attribute reader when the file is loaded", () => { + setAttributeReaderForTests(() => ({ + IS_OFFLINE: true, + IS_RECALL_ON_DATA_ACCESS: true, + IS_RECALL_ON_OPEN: false, + IS_PINNED: false + })); + + const mediaFilePath = getPretendAudioFile(); + const f = new OtherFile(mediaFilePath, new EncounteredVocabularyRegistry()); + + expect(f.cloudStatus).toBe("cloudOnly"); + expect(f.isCloudFileNotPresent).toBe(true); + }); + + it("refreshes cloudStatus after readMetadataFile hydrates a dehydrated metadata file", () => { + // Create a file with a real .meta on disk, then reload it while the + // attribute reader claims everything is a cloud placeholder (as after + // Explorer's "Free up space"). Loading pins + sync-reads the metadata, + // which hydrates it; simulate OneDrive by flipping the reader to + // hydrated attrs once the pin lands. + const mediaFilePath = getPretendAudioFile(); + const first = new OtherFile( + mediaFilePath, + new EncounteredVocabularyRegistry() + ); + first.save(); // writes the .meta so the reload below actually reads it + + let hydrated = false; + setPinWriterForTests(async () => { + hydrated = true; + }); + setAttributeReaderForTests(() => ({ + IS_OFFLINE: !hydrated, + IS_RECALL_ON_DATA_ACCESS: !hydrated, + IS_RECALL_ON_OPEN: false, + IS_PINNED: hydrated + })); + + const f = new OtherFile(mediaFilePath, new EncounteredVocabularyRegistry()); + + // Without the post-read refresh this would be stuck on "cloudOnly". + expect(f.cloudStatus).toBe("localPinned"); + expect(f.isCloudFileNotPresent).toBe(false); + }); + + it("reports local and not-cloud-file-not-present for a normal file", () => { + setAttributeReaderForTests(() => ({ + IS_OFFLINE: false, + IS_RECALL_ON_DATA_ACCESS: false, + IS_RECALL_ON_OPEN: false, + IS_PINNED: false + })); + + const mediaFilePath = getPretendAudioFile(); + const f = new OtherFile(mediaFilePath, new EncounteredVocabularyRegistry()); + + expect(f.cloudStatus).toBe("local"); + expect(f.isCloudFileNotPresent).toBe(false); + }); + + it("makeAvailableOffline() pins the file and, once disk reports pinned+recall attrs, lands on hydrating", async () => { + let pinned: boolean | undefined; + setPinWriterForTests(async (_path, isPinned) => { + pinned = isPinned; + }); + setAttributeReaderForTests(() => ({ + IS_OFFLINE: false, + IS_RECALL_ON_DATA_ACCESS: false, + IS_RECALL_ON_OPEN: false, + IS_PINNED: false + })); + + const mediaFilePath = getPretendAudioFile(); + const f = new OtherFile(mediaFilePath, new EncounteredVocabularyRegistry()); + + setAttributeReaderForTests(() => ({ + IS_OFFLINE: true, + IS_RECALL_ON_DATA_ACCESS: false, + IS_RECALL_ON_OPEN: false, + IS_PINNED: true + })); + + await f.makeAvailableOffline(); + + expect(pinned).toBe(true); + expect(f.cloudStatus).toBe("hydrating"); + }); + + it("stopWaiting() unpins the file and, once disk reports unpinned recall attrs, lands on cloudOnly", async () => { + let pinned: boolean | undefined; + setPinWriterForTests(async (_path, isPinned) => { + pinned = isPinned; + }); + setAttributeReaderForTests(() => ({ + IS_OFFLINE: true, + IS_RECALL_ON_DATA_ACCESS: false, + IS_RECALL_ON_OPEN: false, + IS_PINNED: true + })); + + const mediaFilePath = getPretendAudioFile(); + const f = new OtherFile(mediaFilePath, new EncounteredVocabularyRegistry()); + expect(f.cloudStatus).toBe("hydrating"); + + setAttributeReaderForTests(() => ({ + IS_OFFLINE: true, + IS_RECALL_ON_DATA_ACCESS: false, + IS_RECALL_ON_OPEN: false, + IS_PINNED: false + })); + + await f.stopWaiting(); + + expect(pinned).toBe(false); + expect(f.cloudStatus).toBe("cloudOnly"); + }); + + it("updateCloudStatus() re-reads disk even when cloudStatus was previously hydrating or local (no clobber guard)", () => { + setAttributeReaderForTests(() => ({ + IS_OFFLINE: true, + IS_RECALL_ON_DATA_ACCESS: false, + IS_RECALL_ON_OPEN: false, + IS_PINNED: true + })); + const mediaFilePath = getPretendAudioFile(); + const f = new OtherFile(mediaFilePath, new EncounteredVocabularyRegistry()); + // Loading the file already calls updateCloudStatus() once. + expect(f.cloudStatus).toBe("hydrating"); + + // Disk now says the file finished downloading. A pre-pin-model + // updateCloudStatus() refused to leave "hydrating" once set; the new + // implementation must trust disk over the stale in-memory value. + setAttributeReaderForTests(() => ({ + IS_OFFLINE: false, + IS_RECALL_ON_DATA_ACCESS: false, + IS_RECALL_ON_OPEN: false, + IS_PINNED: true + })); + f.updateCloudStatus(); + expect(f.cloudStatus).toBe("localPinned"); + + // And it must also be willing to move a local file back to "hydrating" + // if disk now reports a pinned placeholder. + setAttributeReaderForTests(() => ({ + IS_OFFLINE: true, + IS_RECALL_ON_DATA_ACCESS: false, + IS_RECALL_ON_OPEN: false, + IS_PINNED: true + })); + f.updateCloudStatus(); + expect(f.cloudStatus).toBe("hydrating"); + }); + + it("entering 'hydrating' sets hydratingSinceMs", () => { + setAttributeReaderForTests(() => ({ + IS_OFFLINE: true, + IS_RECALL_ON_DATA_ACCESS: false, + IS_RECALL_ON_OPEN: false, + IS_PINNED: true + })); + const mediaFilePath = getPretendAudioFile(); + const f = new OtherFile(mediaFilePath, new EncounteredVocabularyRegistry()); + + expect(f.cloudStatus).toBe("hydrating"); + expect(typeof f.hydratingSinceMs).toBe("number"); + }); + + it("staying 'hydrating' across two updateCloudStatus() calls does not reset hydratingSinceMs", () => { + setAttributeReaderForTests(() => ({ + IS_OFFLINE: true, + IS_RECALL_ON_DATA_ACCESS: false, + IS_RECALL_ON_OPEN: false, + IS_PINNED: true + })); + const mediaFilePath = getPretendAudioFile(); + const f = new OtherFile(mediaFilePath, new EncounteredVocabularyRegistry()); + expect(f.cloudStatus).toBe("hydrating"); + const firstValue = f.hydratingSinceMs; + + f.updateCloudStatus(); + + expect(f.cloudStatus).toBe("hydrating"); + expect(f.hydratingSinceMs).toBe(firstValue); + }); + + it("leaving 'hydrating' clears hydratingSinceMs to undefined", () => { + setAttributeReaderForTests(() => ({ + IS_OFFLINE: true, + IS_RECALL_ON_DATA_ACCESS: false, + IS_RECALL_ON_OPEN: false, + IS_PINNED: true + })); + const mediaFilePath = getPretendAudioFile(); + const f = new OtherFile(mediaFilePath, new EncounteredVocabularyRegistry()); + expect(f.cloudStatus).toBe("hydrating"); + expect(f.hydratingSinceMs).not.toBeUndefined(); + + setAttributeReaderForTests(() => ({ + IS_OFFLINE: false, + IS_RECALL_ON_DATA_ACCESS: false, + IS_RECALL_ON_OPEN: false, + IS_PINNED: true + })); + f.updateCloudStatus(); + + expect(f.cloudStatus).toBe("localPinned"); + expect(f.hydratingSinceMs).toBeUndefined(); + }); +}); diff --git a/src/model/file/File.ts b/src/model/file/File.ts index e8eb3d77..1af6572a 100644 --- a/src/model/file/File.ts +++ b/src/model/file/File.ts @@ -36,6 +36,17 @@ import { PatientFS } from "../../other/patientFile"; import { t } from "@lingui/macro"; import { ShowMessageDialog } from "../../components/ShowMessageDialog/MessageDialog"; import userSettings from "../../other/UserSettings"; +import { + CloudFileStatus, + getCloudFileStatus, + getCloudFileProvider, + getCloudProviderNameForPath +} from "../../other/cloudFileStatus"; +import { + cloudReadGuard, + isCloudProviderReadFailure +} from "../../other/cloudReadGuard"; +import { cloudFilePoller } from "../../other/cloudFilePoller"; import { getMediaFolderOrEmptyForThisProjectAndMachine } from "../Project/MediaFolderAccess"; @@ -45,6 +56,18 @@ function getCannotRenameFileMsg() { export const kLinkExtensionWithFullStop = ".link"; +// Property key under which probed media stats (ffprobe/ExifReader results) +// are cached into the .meta file, alongside the file identity (size + mtime) +// they were probed from. Exported so exporters (IMDI, RO-Crate, CSV, ...) can +// blacklist it -- it's internal bookkeeping, not metadata to archive. +export const kMediaStatsCacheKey = "mediaStatsCache"; + +interface IMediaStatsCachePayload { + stats: Record; + sizeBytes: number; + mtimeMs: number; +} + export class Contribution { //review this @observable public personReference: string; // this is the contributor @@ -84,6 +107,58 @@ export /*babel doesn't like this: abstract*/ class File { public copyProgress: string; + public cloudStatus: CloudFileStatus = "unknown"; + + // True when we tried (or deliberately skipped trying) to read this file's + // metadata from the cloud during load and the provider couldn't deliver it. + // Drives the "cloud unavailable" affordance and lets Retry find the files + // that need re-reading. See cloudReadGuard. + public cloudMetadataUnavailable: boolean = false; + + // True when readMetadataFile() failed to parse the on-disk XML (0-byte file, + // truncated write from an interrupted cloud sync, etc.). The in-memory + // properties are then empty/default, so save() must refuse to write -- + // otherwise it would overwrite the user's real (possibly recoverable) bytes + // with an empty template. There is no automatic Retry for this (unlike + // cloudMetadataUnavailable): the file needs manual recovery. + public metadataReadFailed: boolean = false; + + // True when the folder that should contain this file has vanished from disk + // while the project was open -- e.g. a collaborator deleted/renamed the + // session on another machine and a sync service (OneDrive/Dropbox) applied + // that locally, or someone removed it in Explorer. Every save trigger (window + // blur, selection change, rename) would otherwise re-attempt the write, hit + // ENOENT, and pop a fresh error toast. Instead save() fails softly: it warns + // ONCE (gated on this flag) and then silently skips, leaving the in-memory + // data intact. The flag clears itself if the folder reappears and a save + // succeeds. We deliberately do NOT try to reload/reconcile here -- that is a + // separate planned workstream. + public fileMissing: boolean = false; + + // True once save() has found the metadata file changed on disk underneath us + // (a cloud sync service delivering another machine's edit, or an external + // editor) and moved that version aside so our write wouldn't silently revert + // it. Purely informational; the once-per-change notification is driven by the + // disk-identity tracking below, not by this flag. Siblings: fileMissing, + // metadataReadFailed, cloudMetadataUnavailable. + public metadataChangedExternally: boolean = false; + + // On-disk identity (mtimeMs + size) of the metadata file as of the last time + // lameta itself read or wrote it, plus the exact bytes involved. save() uses + // these to notice that the file was changed underneath us so it never + // silently overwrites (and thereby reverts) another machine's synced-down + // edit. The bytes let us tell a real external edit from mere mtime churn (a + // sync engine can rewrite byte-identical content and bump the mtime). Both + // are undefined until the first successful read or write. See save()'s + // freshness check and setAsideExternallyChangedFile(). + private lastKnownDiskIdentity: { mtimeMs: number; size: number } | undefined; + private lastKnownDiskContent: string | undefined; + + // Start time (ms since epoch) of the current hydration, or undefined when + // not hydrating. Lives on the model (not a component ref) so the elapsed + // timer survives unmount/remount of the panel showing it. + public hydratingSinceMs: number | undefined; + // This file can be *just* metadata for a folder, in which case it has the fileExtensionForFolderMetadata. // But it can also be paired with a file in the folder, such as an image, sound, video, elan file, etc., // in which case the metadata will be stored in afile with the same name as the described file, but @@ -155,10 +230,8 @@ export /*babel doesn't like this: abstract*/ class File { return ""; } - let date: moment.Moment; - // 1) Try strict YYYY-MM-DD first - date = moment(dateString, "YYYY-MM-DD", true /* strict */); + const date = moment(dateString, "YYYY-MM-DD", true /* strict */); if (date.isValid()) { return date.format("YYYY-MM-DD"); } @@ -384,6 +457,12 @@ export /*babel doesn't like this: abstract*/ class File { describedFileOrLinkFilePath: observable, copyInProgress: observable, copyProgress: observable, + cloudStatus: observable, + cloudMetadataUnavailable: observable, + metadataReadFailed: observable, + fileMissing: observable, + metadataChangedExternally: observable, + hydratingSinceMs: observable, properties: observable, contributions: observable }); @@ -463,6 +542,109 @@ export /*babel doesn't like this: abstract*/ class File { return this.describedFileOrLinkFilePath; } } + public updateCloudStatus(): void { + this.cloudStatus = getCloudFileStatus(this.getActualFilePath()); + if (this.cloudStatus === "hydrating") { + if (this.hydratingSinceMs === undefined) { + this.hydratingSinceMs = Date.now(); + } + cloudFilePoller.watch(this); + } else { + this.hydratingSinceMs = undefined; + } + } + + public get isCloudFileNotPresent(): boolean { + return this.cloudStatus === "cloudOnly" || this.cloudStatus === "hydrating"; + } + + // Size in bytes of the actual file on disk. For a cloud-only placeholder, + // fs.statSync() reports the eventual full size without triggering hydration + // (unlike opening/reading the file, which downloads it). + public getSizeInBytes(): number { + try { + return fs.statSync(this.getActualFilePath()).size; + } catch (e) { + return 0; + } + } + + // Modification time (ms since epoch) of the actual file on disk. Like + // getSizeInBytes(), this is safe to call on a cloud-only placeholder. + public getMtimeMs(): number { + try { + return fs.statSync(this.getActualFilePath()).mtimeMs; + } catch (e) { + return 0; + } + } + + // Returns media stats (e.g. from ffprobe/ExifReader) previously cached via + // setCachedMediaStats(), or undefined if there is none, or if the file's + // size/mtime no longer match what was recorded (i.e. the file has changed). + public getCachedMediaStats(): Record | undefined { + const raw = this.getTextProperty(kMediaStatsCacheKey, ""); + if (!raw) { + return undefined; + } + let parsed: IMediaStatsCachePayload | undefined; + try { + parsed = JSON.parse(raw); + } catch (e) { + return undefined; + } + if (!parsed || !parsed.stats) { + return undefined; + } + if ( + parsed.sizeBytes !== this.getSizeInBytes() || + parsed.mtimeMs !== this.getMtimeMs() + ) { + return undefined; + } + return parsed.stats; + } + + // Persist probed media stats into this file's .meta, along with the file + // identity (size + mtime) they were probed from, so future loads can serve + // them without re-probing (which, for a cloud-only file, would force a full + // download of its content). A no-op (and does not dirty the file) if the + // incoming value is identical to what's already cached. + public setCachedMediaStats( + stats: Record, + identity: { sizeBytes: number; mtimeMs: number } + ): void { + const payload: IMediaStatsCachePayload = { + stats, + sizeBytes: identity.sizeBytes, + mtimeMs: identity.mtimeMs + }; + const json = JSON.stringify(payload); + const isNewField = !this.properties.getValue(kMediaStatsCacheKey); + this.addTextProperty(kMediaStatsCacheKey, json, true, false, false); + // Creating a brand-new property on `properties` isn't itself observed by + // the mobx reaction that watches for changes to save (see + // recomputedChangeWatcher()); and setting an unchanged value on an + // existing field is already a no-op for mobx. So we only need to + // explicitly mark the file dirty when the field didn't exist before. + if (isNewField) { + this.wasChangeThatMobxDoesNotNotice(); + } + } + + public async makeAvailableOffline(): Promise { + await getCloudFileProvider().setPinned(this.getActualFilePath(), true); + this.updateCloudStatus(); // → "hydrating" (pinned placeholder), auto-enrolls in the poller + } + + // Stops *waiting* for hydration to complete; OneDrive may continue fetching + // the file in the background regardless. Safe to call even if nothing is + // in progress. + public async stopWaiting(): Promise { + await getCloudFileProvider().setPinned(this.getActualFilePath(), false); + this.updateCloudStatus(); + } + public getModifiedDate(): Date | undefined { // when you drag a file into the filelist, it doesn't have a modifiedDate if (this.properties.getHasValue("modifiedDate")) @@ -477,6 +659,7 @@ export /*babel doesn't like this: abstract*/ class File { } const stats = fs.statSync(this.getActualFilePath()); + this.updateCloudStatus(); this.addTextProperty("size", filesize(stats.size, { round: 0 }), false); this.addDateProperty("modifiedDate", stats.mtime, false); @@ -756,6 +939,20 @@ export /*babel doesn't like this: abstract*/ class File { } private haveReadMetadataFile: boolean = false; + + // Record that this file's metadata could not be read from the cloud, and + // leave it in a state where a later Retry can re-attempt the read. + private markCloudMetadataUnavailable(): void { + this.cloudMetadataUnavailable = true; + // Reset so a Retry re-enters readMetadataFile for this file. + this.haveReadMetadataFile = false; + this.updateCloudStatus(); + cloudReadGuard.recordFailure( + this.metadataFilePath, + getCloudProviderNameForPath(this.metadataFilePath) + ); + } + public readMetadataFile() { try { sentryBreadCrumb(`enter readMetadataFile ${this.metadataFilePath}`); @@ -766,9 +963,78 @@ export /*babel doesn't like this: abstract*/ class File { this.haveReadMetadataFile = true; //console.log("readMetadataFile() " + this.metadataFilePath); if (fs.existsSync(this.metadataFilePath)) { - const xml: string = PatientFS.readFileSyncWithNotifyAndRethrow( - this.metadataFilePath - ); + const provider = getCloudFileProvider(); + // "cloudOnly"/"hydrating" are the only statuses that mean "this is a + // placeholder the provider needs to fetch" -- "unknown" (e.g. a file + // not under any sync root on macOS) is NOT a placeholder, so this must + // stay a positive check rather than "not locally available". + const status = provider.getStatus(this.metadataFilePath); + const isCloudPlaceholder = + provider.capabilities.canFetch && + (status === "cloudOnly" || status === "hydrating"); + + if (isCloudPlaceholder) { + // If the cloud provider already failed to deliver a file during this + // load, don't touch this placeholder at all -- both pinning and + // reading ask the sync engine to hydrate it, and each failed attempt + // can make the provider's own client pop a modal error dialog. Fail + // softly so the project keeps loading; the row shows a "cloud + // unavailable" state and the user can Retry. See cloudReadGuard. + if (cloudReadGuard.isTripped) { + this.markCloudMetadataUnavailable(); + return; + } + + // On Windows, this durably pins our own metadata sidecar so OneDrive's + // "free up space" can never dehydrate it again; lameta must always be + // able to read these regardless of the user's auto-fetch threshold. On + // macOS there is no durable pin, so this just triggers a one-shot fetch + // of a file the sync read below needs anyway -- still worth calling + // unconditionally. + provider.setPinned(this.metadataFilePath, true).catch(() => {}); + } + + let xml: string; + try { + xml = isCloudPlaceholder + ? PatientFS.readFileSyncNoNotify(this.metadataFilePath) + : PatientFS.readFileSyncWithNotifyAndRethrow(this.metadataFilePath); + } catch (err) { + if (isCloudPlaceholder && isCloudProviderReadFailure(err)) { + // The provider couldn't hydrate this placeholder. Trip the breaker, + // record it for the consolidated banner, and fail softly: no + // per-file toast, and no aborting the whole project load. + this.markCloudMetadataUnavailable(); + return; + } + // A genuine file problem (missing, locked, corrupt). Preserve the + // original notify-and-rethrow behavior. + if (isCloudPlaceholder) { + NotifyFileAccessProblem( + `Could not read ${this.metadataFilePath}`, + err + ); + } + throw err; + } + + // The read succeeded; clear any prior cloud-unavailable state (e.g. this + // is a successful Retry). + this.cloudMetadataUnavailable = false; + cloudReadGuard.clearFailure(this.metadataFilePath); + + // Remember what we just read (bytes + on-disk identity) so a later + // save() can tell whether the file was changed underneath us before it + // overwrites. See save()'s freshness check. + this.recordDiskIdentity(xml); + + // That sync read just hydrated a dehydrated metadata file. For + // session/person files the metadata file IS the file shown in the + // list, and its cloudStatus was captured (as cloudOnly) by + // addFieldsUsedInternally() BEFORE this read -- refresh it so the + // row doesn't stay grey with a cloud icon until the next + // focus/reselect. + this.updateCloudStatus(); let xmlAsObject: any = {}; xml2js.parseString( @@ -825,6 +1091,14 @@ export /*babel doesn't like this: abstract*/ class File { } this.recomputedChangeWatcher(); } catch (err) { + // The read/parse failed (0-byte or truncated file from an interrupted + // cloud sync, corrupt XML, unsupported version, ...). `properties` is + // empty/default at this point, so save() must refuse to write -- see the + // matching guard there. `haveReadMetadataFile` stays true (nothing below + // resets it, unlike the cloud-placeholder retry path), so a later call to + // this method just no-ops instead of re-attempting the read -- which is + // also why NotifyException below cannot fire more than once per file load. + this.metadataReadFailed = true; NotifyException( err, `There was a problem reading ${this.metadataFilePath}` @@ -866,6 +1140,53 @@ export /*babel doesn't like this: abstract*/ class File { return; } + // Never overwrite a file whose real metadata we could not read from the + // cloud. On that soft-fail path (see markCloudMetadataUnavailable) the + // in-memory properties are empty/default, so writing them would destroy the + // real file on disk -- and sync that empty version back to the cloud and + // other machines. A successful (re)read clears this flag and re-enables + // saving. This must run before the dirty check because that soft-fail path + // leaves `dirty` undefined (clearDirty() was skipped), which the + // `dirty === false` guard below would not catch. + if (this.cloudMetadataUnavailable) { + return; + } + + // Never overwrite a file whose on-disk XML we failed to parse (0-byte or + // truncated file from an interrupted cloud sync, corrupt XML, ...). The + // in-memory properties are empty/default in that case (see + // readMetadataFile's catch), so writing them would destroy the user's real + // -- possibly still recoverable -- bytes on disk. There is no automatic + // Retry for this case, so leave the file alone for manual recovery. + if (this.metadataReadFailed) { + console.warn( + `Refusing to save ${this.metadataFilePath} because its metadata failed to read/parse; the file on disk was left untouched.` + ); + return; + } + + // If the folder that should contain this file has vanished from disk (a + // collaborator deleted/renamed the session on another machine and a sync + // service applied that locally, or someone removed it in Explorer), we + // cannot write it: the open() would throw ENOENT. Fail softly instead of + // re-throwing (and re-toasting) on every save trigger. Warn exactly once + // -- gated on fileMissing -- then skip silently, leaving the in-memory data + // intact. This must run before the dirty check below because a dirty file + // would otherwise fall through to the write and hit ENOENT anyway. + if (!fs.existsSync(Path.dirname(this.metadataFilePath))) { + if (!this.fileMissing) { + this.fileMissing = true; + NotifyWarning( + `lameta could not save "${Path.basename( + this.metadataFilePath + )}" because its folder is no longer on your hard drive. It looks like it was moved or deleted outside of lameta (for example by a file-synchronization service such as OneDrive or Dropbox). lameta will stop trying to save it. Restart lameta to bring it up to date with what is actually on disk.` + ); + } + return; + } + // The folder is present (again); allow saving and future warnings. + this.fileMissing = false; + // console.log( // `dirty of ${this.metadataFilePath} is ${ // this.dirty === undefined ? "undefined" : this.dirty @@ -899,18 +1220,41 @@ export /*babel doesn't like this: abstract*/ class File { this.metadataFilePath } ` ); + // Never silently clobber a version of this file that was changed on + // disk underneath us (a cloud sync service delivering another machine's + // edit, or an external editor). If we detect that, the real version is + // moved aside to a sibling BEFORE we write ours; if it could not be + // moved aside, we must not overwrite it, so skip the write entirely. + if (!this.setAsideExternallyChangedFile(xml)) { + return; + } ShowSavingNotifier(Path.basename(this.metadataFilePath), beforeRename); PatientFS.writeFileSyncWithNotifyThenRethrow( this.metadataFilePath, xml ); this.clearDirty(); + // Record the identity of what we just wrote so the next save() can + // recognize our own write and not mistake it for an external change. + this.recordDiskIdentity(xml); } catch (error) { if (error.code === "EPERM") { NotifyFileAccessProblem( `Cannot save because lameta was denied write access to ${this.metadataFilePath}.`, error ); + } else if (error.code === "ENOENT") { + // The file/folder vanished between the existence check above and the + // write (a sync service or another process removed it). Same soft + // fail as the pre-check: warn once, then stay quiet. + if (!this.fileMissing) { + this.fileMissing = true; + NotifyWarning( + `lameta could not save "${Path.basename( + this.metadataFilePath + )}" because it is no longer on your hard drive. It looks like it was moved or deleted outside of lameta (for example by a file-synchronization service such as OneDrive or Dropbox). lameta will stop trying to save it. Restart lameta to bring it up to date with what is actually on disk.` + ); + } } else { NotifyError( `While saving ${this.metadataFilePath}, got ${error} (file.save)` @@ -921,6 +1265,124 @@ export /*babel doesn't like this: abstract*/ class File { } } + // Capture the on-disk identity (mtime + size) and exact bytes of the + // metadata file as it is right now, tagged as "what lameta last read/wrote". + // Called after every successful read (readMetadataFile) and write (save) so + // the freshness check has an accurate baseline. If the file can't be stat'd + // (e.g. it isn't there), the baseline is cleared -- with no baseline the + // freshness check does nothing, which is the safe default. + private recordDiskIdentity(content: string): void { + try { + const stat = fs.statSync(this.metadataFilePath); + this.lastKnownDiskIdentity = { mtimeMs: stat.mtimeMs, size: stat.size }; + this.lastKnownDiskContent = content; + } catch (e) { + this.lastKnownDiskIdentity = undefined; + this.lastKnownDiskContent = undefined; + } + } + + // Guard against silently overwriting a version of the metadata file that was + // changed on disk underneath us. Returns true if save() should proceed to + // write the canonical file, false if it must not (the on-disk version could + // not be moved to safety, so overwriting it would lose it). + // + // If the file's on-disk identity differs from what we last read/wrote, we + // read it back: identical bytes (or bytes already equal to what we're about + // to write) mean nothing would be lost -- just mtime churn from a sync + // engine -- so we refresh the baseline and proceed. Otherwise it's a genuine + // external edit: move it aside to a uniquely-named sibling, warn once, then + // let save() write our version to the canonical name. + private setAsideExternallyChangedFile(xmlAboutToWrite: string): boolean { + // No baseline -> no evidence of an external change; nothing to do. + if (!this.lastKnownDiskIdentity) { + return true; + } + + let stat: fs.Stats; + try { + stat = fs.statSync(this.metadataFilePath); + } catch (e) { + // The file isn't there right now (the missing-folder/ENOENT handling in + // save() and its catch cover that). Nothing to set aside. + return true; + } + + const unchanged = + stat.mtimeMs === this.lastKnownDiskIdentity.mtimeMs && + stat.size === this.lastKnownDiskIdentity.size; + if (unchanged) { + return true; + } + + // Identity differs. Read what's actually there to tell a real edit from + // mere mtime churn. + let onDisk: string; + try { + onDisk = PatientFS.readFileSyncNoNotify(this.metadataFilePath); + } catch (e) { + // Couldn't read it (locked, etc.). We can neither confirm a change nor + // safely move it; don't risk clobbering -- skip this write and try again + // on the next save trigger. + return false; + } + + if (onDisk === this.lastKnownDiskContent || onDisk === xmlAboutToWrite) { + // No real change (identical bytes) -- refresh the baseline so we stop + // re-checking, and let the normal write proceed. + this.lastKnownDiskIdentity = { mtimeMs: stat.mtimeMs, size: stat.size }; + this.lastKnownDiskContent = onDisk; + return true; + } + + // A genuine external change. Move it aside so our write can't erase it. + const setAsidePath = this.makeSetAsidePath(); + try { + PatientFS.renameSyncWithNotifyAndRethrow( + this.metadataFilePath, + setAsidePath + ); + } catch (e) { + // renameSyncWithNotifyAndRethrow already told the user it couldn't move + // the file. Do NOT overwrite it -- preserving it is the whole point. + return false; + } + + this.metadataChangedExternally = true; + const originalName = Path.basename(this.metadataFilePath); + const keptName = Path.basename(setAsidePath); + NotifyWarning( + t`The file "${originalName}" was changed outside of lameta, perhaps by a file-synchronization service such as OneDrive or Dropbox, or on another computer. To avoid losing that change, lameta kept it next to the original with the name "${keptName}".` + ); + return true; + } + + // Build a unique sibling path to preserve an externally-changed metadata + // file, e.g. "ETR009.session" -> "ETR009 (changed on another computer + // 2026-07-12).session" and "foo.jpg.meta" -> "foo.jpg (changed on another + // computer 2026-07-12).meta". The date disambiguates day-to-day; a numeric + // counter guarantees uniqueness if several land on the same day. The final + // extension is preserved so the sibling loads as an ordinary attached file + // (never re-parsed as the folder's canonical metadata -- the zombie-repair + // in Folder only fires when the canonical file is MISSING, and save() always + // rewrites it here). + private makeSetAsidePath(): string { + const dir = Path.dirname(this.metadataFilePath); + const base = Path.basename(this.metadataFilePath); + const ext = Path.extname(base); // ".session"/".person"/".sprj"/".meta" + const stem = base.substring(0, base.length - ext.length); + const label = `changed on another computer ${moment().format( + "YYYY-MM-DD" + )}`; + let candidate = Path.join(dir, `${stem} (${label})${ext}`); + let counter = 2; + while (fs.existsSync(candidate)) { + candidate = Path.join(dir, `${stem} (${label} ${counter})${ext}`); + counter++; + } + return candidate; + } + private getUniqueFilePath(intendedPath: string): string { if (fs.existsSync(intendedPath)) { // Dec 2019, I don't think this ever gets used @@ -1045,6 +1507,51 @@ export /*babel doesn't like this: abstract*/ class File { } this.setFileNameProperty(); } + + // Capture the paths before updateNameBasedOnNewFolderName() so that if the + // folder rename that follows the per-file renames fails, Folder can undo + // our on-disk renames instead of leaving a folder with the old name full of + // files carrying the new name. + public getRenameRollbackSnapshot(): { + metadataFilePath: string; + describedFileOrLinkFilePath: string; + } { + return { + metadataFilePath: this.metadataFilePath, + describedFileOrLinkFilePath: this.describedFileOrLinkFilePath + }; + } + public rollbackRename(snapshot: { + metadataFilePath: string; + describedFileOrLinkFilePath: string; + }) { + const hasSeparateMetaDataFile = + this.metadataFilePath !== this.describedFileOrLinkFilePath; + if ( + this.describedFileOrLinkFilePath !== + snapshot.describedFileOrLinkFilePath && + fs.existsSync(this.describedFileOrLinkFilePath) + ) { + PatientFS.renameSync( + this.describedFileOrLinkFilePath, + snapshot.describedFileOrLinkFilePath + ); + } + this.describedFileOrLinkFilePath = snapshot.describedFileOrLinkFilePath; + if (hasSeparateMetaDataFile) { + if ( + this.metadataFilePath !== snapshot.metadataFilePath && + fs.existsSync(this.metadataFilePath) + ) { + PatientFS.renameSync(this.metadataFilePath, snapshot.metadataFilePath); + } + this.metadataFilePath = snapshot.metadataFilePath; + } else { + this.metadataFilePath = this.describedFileOrLinkFilePath; + } + this.setFileNameProperty(); + } + public updateRecordOfWhatFolderThisIsLocatedIn(newFolderName: string) { console.debug( `this.getFilenameToShowInList updateRecordOfWhatFolderThisIsLocatedIn(${newFolderName})` @@ -1242,14 +1749,14 @@ export /*babel doesn't like this: abstract*/ class File { try { PatientFS.copyFileSync( newMetadataFilePath, - Path.join(this.metadataFilePath, ".maybeLostInfoHere") + this.metadataFilePath + ".maybeLostInfoHere" ); } catch (e) { // ok this is getting stupid. just fall through. } ShowMessageDialog({ title: `Error`, - text: `During the failed rename, the meta file got renamed and lameta can't seem to get it back to its original name. The ` + text: `During the failed rename, the meta file got renamed and lameta can't seem to get it back to its original name. The metadata is currently in "${newMetadataFilePath}".` }); } } diff --git a/src/model/file/FileStatus.spec.ts b/src/model/file/FileStatus.spec.ts new file mode 100644 index 00000000..158d33ee --- /dev/null +++ b/src/model/file/FileStatus.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { OtherFile } from "./File"; +import { getStatusOfFile, getLinkStatusIconPath } from "./FileStatus"; +import * as fs from "fs-extra"; +import * as temp from "temp"; +import { EncounteredVocabularyRegistry } from "../Project/EncounteredVocabularyRegistry"; +import { i18nUnitTestPrep } from "../../other/localization"; +i18nUnitTestPrep(); + +function getPretendFile(): OtherFile { + const path = temp.path({ suffix: ".mp3" }) as string; + fs.writeFileSync(path, "pretend contents"); + return new OtherFile(path, new EncounteredVocabularyRegistry()); +} + +describe("getStatusOfFile / getLinkStatusIconPath for cloud-only files", () => { + it("reports cloudOnly, and not missing, when cloudStatus is cloudOnly", () => { + const f = getPretendFile(); + f.cloudStatus = "cloudOnly"; + const status = getStatusOfFile(f); + expect(status.missing).toBe(false); + expect(status.status).toBe("cloudOnly"); + }); + + it("also reports cloudOnly while hydrating", () => { + const f = getPretendFile(); + f.cloudStatus = "hydrating"; + expect(getStatusOfFile(f).status).toBe("cloudOnly"); + }); + + it("does not report cloudOnly for a normal local file", () => { + const f = getPretendFile(); + f.cloudStatus = "local"; + expect(getStatusOfFile(f).status).not.toBe("cloudOnly"); + }); + + it("returns no icon path for cloudOnly: cloud states are drawn by CloudStatusIcon", () => { + const f = getPretendFile(); + f.cloudStatus = "cloudOnly"; + expect(getLinkStatusIconPath(f)).toBe(""); + }); +}); diff --git a/src/model/file/FileStatus.tsx b/src/model/file/FileStatus.tsx index c5e6b741..1a725cf2 100644 --- a/src/model/file/FileStatus.tsx +++ b/src/model/file/FileStatus.tsx @@ -11,6 +11,7 @@ import { Button } from "@mui/material"; // Update this import import { t, Trans } from "@lingui/macro"; import { error_color, lameta_orange } from "../../containers/theme"; import { sanitizeForArchive } from "../../other/sanitizeForArchive"; +import { getCloudProviderNameForPath } from "../../other/cloudFileStatus"; import { observer } from "mobx-react"; import { Folder } from "../Folder/Folder"; @@ -22,7 +23,8 @@ export function getStatusOfFile(f: File): { | "missing" | "goodLink" | "copyInProgress" - | "noMediaFolderConnection"; + | "noMediaFolderConnection" + | "cloudOnly"; info: string; } { if (f.copyInProgress) { @@ -44,6 +46,18 @@ export function getStatusOfFile(f: File): { }; } + // Read only the observable cloudStatus here -- this runs per row on every + // render, so it must never call fswin/getCloudFileStatus() itself. + if (f.isCloudFileNotPresent) { + const providerName = + getCloudProviderNameForPath(f.getActualFilePath()) ?? t`Cloud`; + return { + missing: false, + status: "cloudOnly", + info: t`This file is online-only (${providerName}). Select it to see how to make it available on this device.` + }; + } + if (f.getActualFileExists()) { if (f.isLinkFile()) { const info = t({ @@ -130,6 +144,9 @@ export function getLinkStatusIconPath(f: File): string { return "assets/noMediaFolder.png"; case "fileNamingProblem": return "assets/error.png"; + // "cloudOnly" deliberately returns no path: cloud sync states are drawn + // by , which covers all five OneDrive states, not just + // cloud-only. default: return ""; } @@ -141,13 +158,23 @@ export const FileStatusBlock: React.FunctionComponent<{ folder: Folder; }> = observer((props) => { const fileStatus = getStatusOfFile(props.file); + + if ( + fileStatus.status === "normalFile" || + fileStatus.status === "goodLink" || + // Cloud-only files get their "OneDrive Status" box inside whichever tab + // would need to read the file (see CloudFilePanel); no strip here. + fileStatus.status === "cloudOnly" + ) { + return null; + } + const color = fileStatus.status === "noMediaFolderConnection" ? lameta_orange : error_color; - return fileStatus.status === "normalFile" || - fileStatus.status === "goodLink" ? null : ( + return (
{ + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("save() fails softly (no throw, no raw error toast, warns once) and keeps data", () => { + const { folder, session } = makeSavedSession(); + const file = session.metadataFile!; + expect(fs.existsSync(file.metadataFilePath)).toBe(true); + + // the folder disappears behind the app's back + fs.removeSync(folder); + expect(fs.existsSync(folder)).toBe(false); + + const warnSpy = vi + .spyOn(NotifyModule, "NotifyWarning") + .mockImplementation(() => {}); + const errorSpy = vi + .spyOn(NotifyModule, "NotifyError") + .mockImplementation(() => {}); + const accessSpy = vi + .spyOn(NotifyModule, "NotifyFileAccessProblem") + .mockImplementation(() => {}); + const writeSpy = vi.spyOn(PatientFS, "writeFileSyncWithNotifyThenRethrow"); + + // make it dirty, then save many times, as repeated blur/selection would + session.properties.setText("title", "edited after vanish"); + expect(() => { + file.save(); + file.save(); + file.save(); + }).not.toThrow(); + + // the raw "(file.save)" error path must NOT fire even once + expect(errorSpy).not.toHaveBeenCalled(); + expect(accessSpy).not.toHaveBeenCalled(); + // exactly one gentle warning, regardless of how many saves were attempted + expect(warnSpy).toHaveBeenCalledTimes(1); + // and it should be worded as an external move/deletion, not a lameta bug + expect(String(warnSpy.mock.calls[0][0]).toLowerCase()).toContain( + "outside of lameta" + ); + // no write was attempted, the folder is still gone, and the data survives + expect(writeSpy).not.toHaveBeenCalled(); + expect(fs.existsSync(folder)).toBe(false); + expect(file.fileMissing).toBe(true); + expect(file.getTextProperty("title")).toBe("edited after vanish"); + }); + + it("saveAllFilesInFolder() does not throw when the folder is gone", () => { + const { folder, session } = makeSavedSession(); + fs.removeSync(folder); + vi.spyOn(NotifyModule, "NotifyWarning").mockImplementation(() => {}); + expect(() => session.saveAllFilesInFolder()).not.toThrow(); + }); + + it("resumes saving (and clears fileMissing) if the folder reappears", () => { + const { folder, session } = makeSavedSession(); + const file = session.metadataFile!; + fs.removeSync(folder); + vi.spyOn(NotifyModule, "NotifyWarning").mockImplementation(() => {}); + + session.properties.setText("title", "edited while gone"); + file.save(); + expect(file.fileMissing).toBe(true); + + // the sync service restores the folder + fs.mkdirSync(folder, { recursive: true }); + session.properties.setText("title", "edited after return"); + file.save(); + + expect(file.fileMissing).toBe(false); + expect(fs.existsSync(file.metadataFilePath)).toBe(true); + expect(fs.readFileSync(file.metadataFilePath, "utf8")).toContain( + "edited after return" + ); + }); +}); diff --git a/src/other/UserSettings.ts b/src/other/UserSettings.ts index 2cfc8382..fbcb3a3d 100644 --- a/src/other/UserSettings.ts +++ b/src/other/UserSettings.ts @@ -33,6 +33,8 @@ export class UserSettings { private sendErrors: boolean; private ignoreFileNamingRules: boolean; // for testing only private disableSavingProjectData: boolean; // DEBUG: flag to prevent saving project data during testing + // 0 = never auto-fetch cloud-only files, Infinity = always auto-fetch, regardless of size. + private autoFetchCloudFilesUnderMB: number; private clientId: string; @@ -46,6 +48,7 @@ export class UserSettings { | "sendErrors" | "ignoreFileNamingRules" | "disableSavingProjectData" + | "autoFetchCloudFilesUnderMB" >(this, { showIMDI: observable, paradisecMode: observable, @@ -55,6 +58,7 @@ export class UserSettings { sendErrors: observable, ignoreFileNamingRules: observable, disableSavingProjectData: observable, + autoFetchCloudFilesUnderMB: observable, HowUsing: computed }); @@ -118,6 +122,14 @@ export class UserSettings { "disableSavingProjectData", false ); + // JSON (and thus electron-store) cannot round-trip Infinity, so "always" is + // persisted as the sentinel -1 and translated back here. + const storedAutoFetchUnderMB = this.store.get( + "autoFetchCloudFilesUnderMB", + 10 + ); + this.autoFetchCloudFilesUnderMB = + storedAutoFetchUnderMB === -1 ? Infinity : storedAutoFetchUnderMB; } public get SendErrors() { @@ -197,6 +209,17 @@ export class UserSettings { this.disableSavingProjectData = disable; this.store.set("disableSavingProjectData", disable); } + // 0 = never auto-fetch cloud-only files; Infinity = always auto-fetch, regardless of size. + public get AutoFetchCloudFilesUnderMB() { + return this.autoFetchCloudFilesUnderMB; + } + public set AutoFetchCloudFilesUnderMB(mb: number) { + this.autoFetchCloudFilesUnderMB = mb; + this.store.set( + "autoFetchCloudFilesUnderMB", + mb === Infinity ? -1 : mb + ); + } public get Email() { return this.store.get("email", ""); } diff --git a/src/other/autoFetchCloudFiles.spec.ts b/src/other/autoFetchCloudFiles.spec.ts new file mode 100644 index 00000000..a17d2557 --- /dev/null +++ b/src/other/autoFetchCloudFiles.spec.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + AutoFetchCloudFiles, + mbToBytes, + AutoFetchableFile +} from "./autoFetchCloudFiles"; + +function makeFile( + overrides: { cloudStatus?: string; sizeBytes?: number } = {} +): AutoFetchableFile & { fetchCount: number } { + const file: any = { + cloudStatus: overrides.cloudStatus ?? "cloudOnly", + fetchCount: 0, + getSizeInBytes: () => overrides.sizeBytes ?? 1024, + makeAvailableOffline: async () => { + file.fetchCount++; + file.cloudStatus = "local"; + } + }; + return file; +} + +describe("AutoFetchCloudFiles", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("does not fetch before the dwell time elapses", () => { + const scheduler = new AutoFetchCloudFiles(1500); + const file = makeFile(); + scheduler.onSelectionChanged(file, mbToBytes(10)); + vi.advanceTimersByTime(1000); + expect(file.fetchCount).toBe(0); + }); + + it("fetches once the dwell time elapses on a small cloud-only file", async () => { + const scheduler = new AutoFetchCloudFiles(1500); + const file = makeFile(); + scheduler.onSelectionChanged(file, mbToBytes(10)); + await vi.advanceTimersByTimeAsync(1500); + expect(file.fetchCount).toBe(1); + }); + + it("does not fetch rows arrow-keyed past before the selection rests", async () => { + const scheduler = new AutoFetchCloudFiles(1500); + const passedThrough = [makeFile(), makeFile(), makeFile()]; + const landedOn = makeFile(); + for (const f of passedThrough) { + scheduler.onSelectionChanged(f, mbToBytes(10)); + vi.advanceTimersByTime(200); // much less than the dwell time + } + scheduler.onSelectionChanged(landedOn, mbToBytes(10)); + await vi.advanceTimersByTimeAsync(1500); + + passedThrough.forEach((f) => expect(f.fetchCount).toBe(0)); + expect(landedOn.fetchCount).toBe(1); + }); + + it("does not fetch when the file size is at or above the threshold", async () => { + const scheduler = new AutoFetchCloudFiles(1500); + const file = makeFile({ sizeBytes: mbToBytes(20) }); + scheduler.onSelectionChanged(file, mbToBytes(10)); + await vi.advanceTimersByTimeAsync(1500); + expect(file.fetchCount).toBe(0); + }); + + it("never fetches when the threshold is 0 (never)", async () => { + const scheduler = new AutoFetchCloudFiles(1500); + const file = makeFile({ sizeBytes: 1 }); + scheduler.onSelectionChanged(file, 0); + await vi.advanceTimersByTimeAsync(5000); + expect(file.fetchCount).toBe(0); + }); + + it("always fetches regardless of size when the threshold is Infinity (always)", async () => { + const scheduler = new AutoFetchCloudFiles(1500); + const file = makeFile({ sizeBytes: mbToBytes(5000) }); + scheduler.onSelectionChanged(file, Infinity); + await vi.advanceTimersByTimeAsync(1500); + expect(file.fetchCount).toBe(1); + }); + + it("does not fetch a file that is not cloudOnly", async () => { + const scheduler = new AutoFetchCloudFiles(1500); + const file = makeFile({ cloudStatus: "local" }); + scheduler.onSelectionChanged(file, mbToBytes(10)); + await vi.advanceTimersByTimeAsync(1500); + expect(file.fetchCount).toBe(0); + }); + + it("does not fetch once the dwell time elapses while offline", async () => { + const scheduler = new AutoFetchCloudFiles(1500, () => false); + const file = makeFile(); + scheduler.onSelectionChanged(file, mbToBytes(10)); + await vi.advanceTimersByTimeAsync(1500); + expect(file.fetchCount).toBe(0); + }); + + it("fetches once the dwell time elapses while online (explicit isOnline)", async () => { + const scheduler = new AutoFetchCloudFiles(1500, () => true); + const file = makeFile(); + scheduler.onSelectionChanged(file, mbToBytes(10)); + await vi.advanceTimersByTimeAsync(1500); + expect(file.fetchCount).toBe(1); + }); +}); diff --git a/src/other/autoFetchCloudFiles.ts b/src/other/autoFetchCloudFiles.ts new file mode 100644 index 00000000..c3ea6ceb --- /dev/null +++ b/src/other/autoFetchCloudFiles.ts @@ -0,0 +1,85 @@ +// Cloud-only OneDrive files can be huge (2 GB+) and lameta has no way to know +// how long a fetch will take, so auto-fetching must be conservative: +// - only kick in once the user's selection has RESTED on a file for a bit +// (so arrow-keying/clicking through a list doesn't fetch every row passed) +// makeAvailableOffline() now just pins the file (a fast attribute flip) and +// lets OneDrive's own sync engine queue and throttle the actual download, so +// there is no concurrency cap to manage here. +// Explicit, user-initiated fetches (checking the "available offline" +// checkbox) are not subject to the dwell delay -- those go through +// File.makeAvailableOffline() directly. + +import { networkStatus } from "./networkStatus"; + +export const kDefaultAutoFetchDwellMs = 1500; + +export interface AutoFetchableFile { + cloudStatus: string; + getSizeInBytes(): number; + makeAvailableOffline(): Promise; +} + +export function mbToBytes(mb: number): number { + return mb * 1024 * 1024; +} + +export class AutoFetchCloudFiles { + private dwellTimer: ReturnType | undefined; + + public constructor( + private readonly dwellMs: number = kDefaultAutoFetchDwellMs, + private readonly isOnline: () => boolean = () => networkStatus.isOnline + ) {} + + // Call whenever the user's selection changes. `thresholdBytes` is the + // current AutoFetchCloudFilesUnderMB setting, already converted to bytes + // (0 = never auto-fetch, Infinity = always). + public onSelectionChanged( + file: AutoFetchableFile | undefined, + thresholdBytes: number + ): void { + if (this.dwellTimer !== undefined) { + clearTimeout(this.dwellTimer); + this.dwellTimer = undefined; + } + + if ( + !file || + file.cloudStatus !== "cloudOnly" || + thresholdBytes <= 0 || + file.getSizeInBytes() >= thresholdBytes + ) { + return; + } + + this.dwellTimer = setTimeout(() => { + this.dwellTimer = undefined; + this.startFetch(file); + }, this.dwellMs); + } + + private startFetch(file: AutoFetchableFile): void { + // Re-check status: it may have changed (e.g. someone already fetched it, + // or it's mid-hydration) between when the dwell timer was scheduled and + // when it fired. + if (file.cloudStatus !== "cloudOnly") { + return; + } + // Offline: a fetch kicked off now can't complete (and on macOS just hangs + // as an in-flight materialization). Skip it; the file stays cloud-only. + if (!this.isOnline()) { + return; + } + file.makeAvailableOffline().catch(() => { + // Best-effort background fetch. If the user cares, the file will + // still show as cloud-only and they can retry explicitly. + }); + } + + public dispose(): void { + if (this.dwellTimer !== undefined) { + clearTimeout(this.dwellTimer); + this.dwellTimer = undefined; + } + } +} diff --git a/src/other/cloudFilePoller.spec.ts b/src/other/cloudFilePoller.spec.ts new file mode 100644 index 00000000..2c2f59a4 --- /dev/null +++ b/src/other/cloudFilePoller.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { cloudFilePoller, kCloudPollIntervalMs, PollableFile } from "./cloudFilePoller"; + +function makeFile(initialStatus: string): PollableFile { + return { + cloudStatus: initialStatus, + updateCloudStatus: vi.fn() + }; +} + +describe("cloudFilePoller", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + cloudFilePoller.dispose(); + vi.useRealTimers(); + }); + + it("polls a watched file on each interval tick", () => { + const file = makeFile("hydrating"); + cloudFilePoller.watch(file); + + vi.advanceTimersByTime(kCloudPollIntervalMs); + expect(file.updateCloudStatus).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(kCloudPollIntervalMs); + expect(file.updateCloudStatus).toHaveBeenCalledTimes(2); + }); + + it("drops a file once its status is no longer hydrating", () => { + const file = makeFile("hydrating"); + (file.updateCloudStatus as any).mockImplementation(() => { + file.cloudStatus = "local"; + }); + cloudFilePoller.watch(file); + + vi.advanceTimersByTime(kCloudPollIntervalMs); + expect(file.updateCloudStatus).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(kCloudPollIntervalMs); + expect(file.updateCloudStatus).toHaveBeenCalledTimes(1); + }); + + it("clears the interval once the last watched file drops", () => { + const file = makeFile("hydrating"); + (file.updateCloudStatus as any).mockImplementation(() => { + file.cloudStatus = "local"; + }); + cloudFilePoller.watch(file); + + vi.advanceTimersByTime(kCloudPollIntervalMs); + expect(file.updateCloudStatus).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(kCloudPollIntervalMs * 5); + expect(file.updateCloudStatus).toHaveBeenCalledTimes(1); + }); + + it("restarts polling when watch() is called again after the set emptied", () => { + const file1 = makeFile("hydrating"); + (file1.updateCloudStatus as any).mockImplementation(() => { + file1.cloudStatus = "local"; + }); + cloudFilePoller.watch(file1); + vi.advanceTimersByTime(kCloudPollIntervalMs); + expect(file1.updateCloudStatus).toHaveBeenCalledTimes(1); + + const file2 = makeFile("hydrating"); + cloudFilePoller.watch(file2); + + vi.advanceTimersByTime(kCloudPollIntervalMs); + expect(file2.updateCloudStatus).toHaveBeenCalledTimes(1); + }); + + it("does not double-poll a file watched twice", () => { + const file = makeFile("hydrating"); + cloudFilePoller.watch(file); + cloudFilePoller.watch(file); + + vi.advanceTimersByTime(kCloudPollIntervalMs); + expect(file.updateCloudStatus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/other/cloudFilePoller.ts b/src/other/cloudFilePoller.ts new file mode 100644 index 00000000..2e3f3fe4 --- /dev/null +++ b/src/other/cloudFilePoller.ts @@ -0,0 +1,49 @@ +// Structural type -- deliberately no import of the File class. +export interface PollableFile { + cloudStatus: string; + updateCloudStatus(): void; +} + +export const kCloudPollIntervalMs = 1500; + +// With no worker to notify us, a file's hydrating->local transition can only +// be noticed by re-reading its attributes. One singleton timer serves every +// watched file -- a previous version of this feature leaked a per-file abort +// listener, so this design deliberately has exactly one setInterval and zero +// event listeners. +class CloudFilePoller { + private watched = new Set(); + private timer: ReturnType | undefined; + + public watch(file: PollableFile): void { + this.watched.add(file); + if (!this.timer) { + this.timer = setInterval(() => this.tick(), kCloudPollIntervalMs); + } + } + + private tick(): void { + // Snapshot so a watch() call triggered by updateCloudStatus() can't + // mutate the set out from under this iteration. + for (const file of [...this.watched]) { + file.updateCloudStatus(); + if (file.cloudStatus !== "hydrating") { + this.watched.delete(file); + } + } + if (this.watched.size === 0 && this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + public dispose(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + this.watched.clear(); + } +} + +export const cloudFilePoller = new CloudFilePoller(); diff --git a/src/other/cloudFileStatus.spec.ts b/src/other/cloudFileStatus.spec.ts new file mode 100644 index 00000000..d22ab3a9 --- /dev/null +++ b/src/other/cloudFileStatus.spec.ts @@ -0,0 +1,535 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { + getCloudFileStatus, + getCloudFileProvider, + setAttributeReaderForTests, + setPinWriterForTests, + isLocallyAvailable, + isUnderCloudSyncRoot, + getCloudProviderNameForPath, + parseSyncRootManagerOutput, + setCloudSyncRootsForTests, + statusFromMacStat, + providerNameFromCloudStorageDir, + setMacStatReaderForTests, + setICloudSiblingCheckerForTests, + setMaterializerForTests +} from "./cloudFileStatus"; + +describe("getCloudFileStatus", () => { + afterEach(() => { + setAttributeReaderForTests(undefined); + setPinWriterForTests(undefined); + }); + + it("returns cloudOnly when IS_OFFLINE and IS_RECALL_ON_DATA_ACCESS are set", () => { + setAttributeReaderForTests(() => ({ + IS_OFFLINE: true, + IS_RECALL_ON_DATA_ACCESS: true, + IS_RECALL_ON_OPEN: false, + IS_PINNED: false + })); + expect(getCloudFileStatus("C:\\fake\\path.mp3")).toBe("cloudOnly"); + }); + + it("returns cloudOnly when only IS_RECALL_ON_OPEN is set", () => { + setAttributeReaderForTests(() => ({ + IS_OFFLINE: false, + IS_RECALL_ON_DATA_ACCESS: false, + IS_RECALL_ON_OPEN: true, + IS_PINNED: false + })); + expect(getCloudFileStatus("C:\\fake\\path.mp3")).toBe("cloudOnly"); + }); + + it("returns local when attributes indicate a normal, hydrated file", () => { + setAttributeReaderForTests(() => ({ + IS_OFFLINE: false, + IS_RECALL_ON_DATA_ACCESS: false, + IS_RECALL_ON_OPEN: false + })); + expect(getCloudFileStatus("C:\\fake\\path.mp3")).toBe("local"); + }); + + it("returns unknown when the reader returns undefined", () => { + setAttributeReaderForTests(() => undefined); + expect(getCloudFileStatus("C:\\fake\\path.mp3")).toBe("unknown"); + }); + + it("returns unknown when the reader throws", () => { + setAttributeReaderForTests(() => { + throw new Error("boom"); + }); + expect(getCloudFileStatus("C:\\fake\\path.mp3")).toBe("unknown"); + }); + + it("returns hydrating when placeholder attrs are set and IS_PINNED is true", () => { + setAttributeReaderForTests(() => ({ + IS_OFFLINE: true, + IS_RECALL_ON_DATA_ACCESS: false, + IS_RECALL_ON_OPEN: false, + IS_PINNED: true + })); + expect(getCloudFileStatus("C:\\fake\\path.mp3")).toBe("hydrating"); + }); + + it("returns cloudOnly when placeholder attrs are set and IS_PINNED is false", () => { + setAttributeReaderForTests(() => ({ + IS_OFFLINE: true, + IS_RECALL_ON_DATA_ACCESS: false, + IS_RECALL_ON_OPEN: false, + IS_PINNED: false + })); + expect(getCloudFileStatus("C:\\fake\\path.mp3")).toBe("cloudOnly"); + }); + + it("returns localPinned when no placeholder attrs are set and IS_PINNED is true", () => { + setAttributeReaderForTests(() => ({ + IS_OFFLINE: false, + IS_RECALL_ON_DATA_ACCESS: false, + IS_RECALL_ON_OPEN: false, + IS_PINNED: true + })); + expect(getCloudFileStatus("C:\\fake\\path.mp3")).toBe("localPinned"); + }); +}); + +describe("getCloudFileProvider().setPinned", () => { + afterEach(() => { + setAttributeReaderForTests(undefined); + setPinWriterForTests(undefined); + }); + + it("calls the writer with (path, true) when pinning", async () => { + const writer = vi.fn().mockResolvedValue(undefined); + setPinWriterForTests(writer); + + await getCloudFileProvider().setPinned("C:\\fake\\path.mp3", true); + + expect(writer).toHaveBeenCalledWith("C:\\fake\\path.mp3", true); + }); + + it("calls the writer with (path, false) when unpinning", async () => { + const writer = vi.fn().mockResolvedValue(undefined); + setPinWriterForTests(writer); + + await getCloudFileProvider().setPinned("C:\\fake\\path.mp3", false); + + expect(writer).toHaveBeenCalledWith("C:\\fake\\path.mp3", false); + }); + + it("propagates a rejection from the writer", async () => { + setPinWriterForTests(() => Promise.reject(new Error("write failed"))); + + await expect( + getCloudFileProvider().setPinned("C:\\fake\\path.mp3", true) + ).rejects.toThrow(/write failed/); + }); + + it("reports canPin true when the attribute-reader seam is set", () => { + setAttributeReaderForTests(() => undefined); + expect(getCloudFileProvider().capabilities.canPin).toBe(true); + }); +}); + +describe("isLocallyAvailable", () => { + it("is true only for the two hydrated states", () => { + expect(isLocallyAvailable("local")).toBe(true); + expect(isLocallyAvailable("localPinned")).toBe(true); + expect(isLocallyAvailable("cloudOnly")).toBe(false); + expect(isLocallyAvailable("hydrating")).toBe(false); + expect(isLocallyAvailable("unknown")).toBe(false); + }); +}); + +describe("isUnderCloudSyncRoot", () => { + afterEach(() => { + setCloudSyncRootsForTests(undefined); + }); + + it("matches files under a sync root, case- and separator-insensitively", () => { + setCloudSyncRootsForTests(["C:\\Users\\me\\OneDrive"]); + expect(isUnderCloudSyncRoot("C:\\Users\\me\\OneDrive\\proj\\a.mp3")).toBe( + true + ); + expect(isUnderCloudSyncRoot("c:/users/me/onedrive/proj/a.mp3")).toBe(true); + }); + + it("does not match sibling folders that merely share the root as a name prefix", () => { + setCloudSyncRootsForTests(["C:\\Users\\me\\OneDrive"]); + expect( + isUnderCloudSyncRoot("C:\\Users\\me\\OneDriveBackup\\a.mp3") + ).toBe(false); + expect(isUnderCloudSyncRoot("C:\\Elsewhere\\a.mp3")).toBe(false); + }); + + it("tolerates a trailing slash on the configured root", () => { + setCloudSyncRootsForTests(["C:\\Users\\me\\OneDrive\\"]); + expect(isUnderCloudSyncRoot("C:\\Users\\me\\OneDrive\\a.mp3")).toBe(true); + }); + + it("returns false when there are no sync roots", () => { + setCloudSyncRootsForTests([]); + expect(isUnderCloudSyncRoot("C:\\Users\\me\\OneDrive\\a.mp3")).toBe(false); + }); +}); + +describe("getCloudProviderNameForPath", () => { + afterEach(() => { + setCloudSyncRootsForTests(undefined); + }); + + it("names the sync engine that owns the path", () => { + setCloudSyncRootsForTests([ + { path: "C:\\Users\\me\\Dropbox", providerName: "Dropbox" }, + { path: "C:\\Users\\me\\OneDrive", providerName: "OneDrive" } + ]); + expect( + getCloudProviderNameForPath("C:\\Users\\me\\Dropbox\\proj\\a.mp3") + ).toBe("Dropbox"); + expect( + getCloudProviderNameForPath("C:\\Users\\me\\OneDrive\\proj\\a.mp3") + ).toBe("OneDrive"); + expect(getCloudProviderNameForPath("C:\\Elsewhere\\a.mp3")).toBeUndefined(); + }); +}); + +describe("parseSyncRootManagerOutput", () => { + // Trimmed-down copy of real `reg query ...\SyncRootManager /s` output from + // a machine with both OneDrive and Dropbox. + const regOutput = [ + "", + "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\SyncRootManager\\Dropbox!S-1-5-21-111!dbid:AADS_xyz", + " NamespaceCLSID REG_SZ {6539AFDE-D4FB-4757-91E5-373C2132F6A0}", + " DisplayNameResource REG_EXPAND_SZ Dropbox", + " IconResource REG_EXPAND_SZ C:\\Program Files (x86)\\Dropbox\\Client\\Dropbox.exe,-6001", + "", + "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\SyncRootManager\\Dropbox!S-1-5-21-111!dbid:AADS_xyz\\UserSyncRoots", + " S-1-5-21-111 REG_SZ C:\\Users\\me\\Dropbox", + "", + "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\SyncRootManager\\OneDrive!S-1-5-21-111!Personal|ae8e", + " DisplayNameResource REG_SZ OneDrive - Personal", + " IconResource REG_SZ C:\\Program Files\\Microsoft OneDrive\\OneDrive.exe,-501", + "", + "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\SyncRootManager\\OneDrive!S-1-5-21-111!Personal|ae8e\\UserSyncRoots", + " S-1-5-21-111 REG_SZ C:\\Users\\me\\OneDrive", + "" + ].join("\r\n"); + + it("finds each provider's sync root with a friendly name", () => { + expect(parseSyncRootManagerOutput(regOutput)).toEqual([ + { path: "C:\\Users\\me\\Dropbox", providerName: "Dropbox" }, + { path: "C:\\Users\\me\\OneDrive", providerName: "OneDrive" } + ]); + }); + + it("does not mistake other REG_SZ values (icons, CLSIDs) for sync roots", () => { + const roots = parseSyncRootManagerOutput(regOutput); + expect(roots).toHaveLength(2); + expect(roots.every((r) => !r.path.includes(".exe"))).toBe(true); + }); + + it("maps known program ids to friendly names and passes unknown ones through", () => { + const output = [ + "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\SyncRootManager\\GoogleDrive!S-1-5-21-111!acct\\UserSyncRoots", + " S-1-5-21-111 REG_SZ G:\\My Drive", + "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\SyncRootManager\\pCloud!S-1-5-21-111!acct\\UserSyncRoots", + " S-1-5-21-111 REG_SZ P:\\pCloud Drive" + ].join("\r\n"); + expect(parseSyncRootManagerOutput(output)).toEqual([ + { path: "G:\\My Drive", providerName: "Google Drive" }, + { path: "P:\\pCloud Drive", providerName: "pCloud" } + ]); + }); + + it("returns an empty list for empty or unrelated output", () => { + expect(parseSyncRootManagerOutput("")).toEqual([]); + }); +}); + +// --- macOS --------------------------------------------------------------- + +describe("statusFromMacStat", () => { + const base = { + underSyncRoot: true, + underICloudRoot: false, + inFlight: false, + hasICloudSibling: false + }; + + it("classifies a dataless placeholder (size>0, blocks==0) as cloudOnly", () => { + expect( + statusFromMacStat({ isFile: true, size: 100, blocks: 0 }, base) + ).toBe("cloudOnly"); + }); + + it("classifies a dataless placeholder that is in-flight as hydrating", () => { + expect( + statusFromMacStat( + { isFile: true, size: 100, blocks: 0 }, + { ...base, inFlight: true } + ) + ).toBe("hydrating"); + }); + + it("classifies a file with blocks>0 as local", () => { + expect( + statusFromMacStat({ isFile: true, size: 100, blocks: 8 }, base) + ).toBe("local"); + }); + + it("treats a zero-length file as local, not a placeholder", () => { + expect(statusFromMacStat({ isFile: true, size: 0, blocks: 0 }, base)).toBe( + "local" + ); + }); + + it("classifies a missing file with an .icloud sibling under iCloud as cloudOnly", () => { + expect( + statusFromMacStat(undefined, { + ...base, + underICloudRoot: true, + hasICloudSibling: true + }) + ).toBe("cloudOnly"); + }); + + it("classifies a missing+sibling+in-flight iCloud file as hydrating", () => { + expect( + statusFromMacStat(undefined, { + ...base, + underICloudRoot: true, + hasICloudSibling: true, + inFlight: true + }) + ).toBe("hydrating"); + }); + + it("returns unknown for a path not under any sync root", () => { + expect( + statusFromMacStat( + { isFile: true, size: 100, blocks: 0 }, + { ...base, underSyncRoot: false } + ) + ).toBe("unknown"); + }); + + it("returns unknown for a missing file with no iCloud sibling", () => { + expect(statusFromMacStat(undefined, base)).toBe("unknown"); + }); +}); + +describe("providerNameFromCloudStorageDir", () => { + it("maps a CloudStorage directory name to a friendly provider name", () => { + expect(providerNameFromCloudStorageDir("OneDrive-Personal")).toBe( + "OneDrive" + ); + expect( + providerNameFromCloudStorageDir("GoogleDrive-user@gmail.com") + ).toBe("Google Drive"); + expect(providerNameFromCloudStorageDir("Dropbox-Work")).toBe("Dropbox"); + expect(providerNameFromCloudStorageDir("Box-me@work.com")).toBe("Box"); + }); + + it("passes an unknown provider id through unchanged", () => { + expect(providerNameFromCloudStorageDir("MEGA-foo")).toBe("MEGA"); + }); +}); + +describe("darwin-style sync-root matching", () => { + afterEach(() => { + setCloudSyncRootsForTests(undefined); + }); + + it("matches files under a forward-slash root, case-insensitively", () => { + setCloudSyncRootsForTests([ + { path: "/Users/me/Library/CloudStorage/OneDrive-Personal", providerName: "OneDrive" } + ]); + expect( + isUnderCloudSyncRoot( + "/Users/me/Library/CloudStorage/OneDrive-Personal/proj/a.mp3" + ) + ).toBe(true); + expect( + getCloudProviderNameForPath( + "/users/ME/library/cloudstorage/onedrive-personal/proj/a.mp3" + ) + ).toBe("OneDrive"); + }); + + it("does not match a sibling that merely shares the root as a name prefix", () => { + setCloudSyncRootsForTests([{ path: "/Users/x/OneDrive", providerName: "OneDrive" }]); + expect(isUnderCloudSyncRoot("/Users/x/OneDriveOther/a.mp3")).toBe(false); + expect(isUnderCloudSyncRoot("/Users/x/Elsewhere/a.mp3")).toBe(false); + }); + + it("matches a home-dir symlink alias root", () => { + setCloudSyncRootsForTests([ + { path: "/Users/me/OneDrive", providerName: "OneDrive" } + ]); + expect(isUnderCloudSyncRoot("/Users/me/OneDrive/proj/a.mp3")).toBe(true); + }); + + it("matches across NFC/NFD Unicode forms (macOS APIs can return decomposed names)", () => { + // Root as macOS readdir might report it: "\u00e9" decomposed as "e" + U+0301. + const nfdRoot = "/Users/me/Library/CloudStorage/OneDrive-Rene\u0301"; + // Path as settings/UI would supply it: precomposed U+00E9. + const nfcPath = "/Users/me/Library/CloudStorage/OneDrive-Ren\u00e9/proj/a.mp3"; + expect(nfdRoot.includes("\u00e9")).toBe(false); // really different forms + setCloudSyncRootsForTests([{ path: nfdRoot, providerName: "OneDrive" }]); + expect(isUnderCloudSyncRoot(nfcPath)).toBe(true); + expect(getCloudProviderNameForPath(nfcPath)).toBe("OneDrive"); + }); +}); + +describe("MacCloudFileProvider getStatus", () => { + afterEach(() => { + setMacStatReaderForTests(undefined); + setICloudSiblingCheckerForTests(undefined); + setMaterializerForTests(undefined); + setCloudSyncRootsForTests(undefined); + }); + + it("reports capabilities canPin=false, canFetch=true", () => { + setMacStatReaderForTests(() => undefined); + expect(getCloudFileProvider().capabilities.canPin).toBe(false); + expect(getCloudFileProvider().capabilities.canFetch).toBe(true); + }); + + it("maps a dataless placeholder under a sync root to cloudOnly", () => { + setCloudSyncRootsForTests([{ path: "/Users/me/OneDrive", providerName: "OneDrive" }]); + setMacStatReaderForTests(() => ({ isFile: true, size: 100, blocks: 0 })); + expect(getCloudFileProvider().getStatus("/Users/me/OneDrive/a.mp3")).toBe( + "cloudOnly" + ); + }); + + it("maps a hydrated file (blocks>0) to local", () => { + setCloudSyncRootsForTests([{ path: "/Users/me/OneDrive", providerName: "OneDrive" }]); + setMacStatReaderForTests(() => ({ isFile: true, size: 100, blocks: 8 })); + expect(getCloudFileProvider().getStatus("/Users/me/OneDrive/a.mp3")).toBe( + "local" + ); + }); + + it("returns unknown for a path not under any sync root", () => { + setCloudSyncRootsForTests([{ path: "/Users/me/OneDrive", providerName: "OneDrive" }]); + setMacStatReaderForTests(() => ({ isFile: true, size: 100, blocks: 0 })); + expect( + getCloudFileProvider().getStatus("/Users/me/Elsewhere/a.mp3") + ).toBe("unknown"); + }); + + it("maps a missing file with an .icloud sibling under an iCloud root to cloudOnly", () => { + setCloudSyncRootsForTests([ + { + path: "/Users/me/Library/Mobile Documents/com~apple~CloudDocs", + providerName: "iCloud Drive" + } + ]); + setMacStatReaderForTests(() => undefined); + setICloudSiblingCheckerForTests(() => true); + expect( + getCloudFileProvider().getStatus( + "/Users/me/Library/Mobile Documents/com~apple~CloudDocs/a.mp3" + ) + ).toBe("cloudOnly"); + }); + + it("returns unknown for a missing file under an iCloud root with no sibling", () => { + setCloudSyncRootsForTests([ + { + path: "/Users/me/Library/Mobile Documents/com~apple~CloudDocs", + providerName: "iCloud Drive" + } + ]); + setMacStatReaderForTests(() => undefined); + setICloudSiblingCheckerForTests(() => false); + expect( + getCloudFileProvider().getStatus( + "/Users/me/Library/Mobile Documents/com~apple~CloudDocs/a.mp3" + ) + ).toBe("unknown"); + }); + + it("wraps a stat-reader throw as unknown", () => { + setCloudSyncRootsForTests([{ path: "/Users/me/OneDrive", providerName: "OneDrive" }]); + setMacStatReaderForTests(() => { + throw new Error("boom"); + }); + expect(getCloudFileProvider().getStatus("/Users/me/OneDrive/a.mp3")).toBe( + "unknown" + ); + }); +}); + +describe("MacCloudFileProvider setPinned (one-shot materialization)", () => { + afterEach(() => { + setMacStatReaderForTests(undefined); + setMaterializerForTests(undefined); + setCloudSyncRootsForTests(undefined); + }); + + it("tracks in-flight so a dataless file reads as hydrating, and self-cleans to local", async () => { + setCloudSyncRootsForTests([{ path: "/Users/me/OneDrive", providerName: "OneDrive" }]); + const materialize = vi.fn().mockResolvedValue(undefined); + setMaterializerForTests(materialize); + + let dataless = true; + setMacStatReaderForTests(() => + dataless + ? { isFile: true, size: 100, blocks: 0 } + : { isFile: true, size: 100, blocks: 8 } + ); + + const provider = getCloudFileProvider(); + const p = "/Users/me/OneDrive/a.mp3"; + + // Before pinning: cloud-only. + expect(provider.getStatus(p)).toBe("cloudOnly"); + + await provider.setPinned(p, true); + expect(materialize).toHaveBeenCalledWith(p); + // Now in-flight and still dataless -> hydrating. + expect(provider.getStatus(p)).toBe("hydrating"); + + // Download completes: file now has data. getStatus self-cleans the + // in-flight entry, so it reads as local. + dataless = false; + expect(provider.getStatus(p)).toBe("local"); + + // And if it were to appear dataless again, it is no longer in-flight. + dataless = true; + expect(provider.getStatus(p)).toBe("cloudOnly"); + }); + + it("setPinned(false) clears the in-flight state (back to cloudOnly)", async () => { + setCloudSyncRootsForTests([{ path: "/Users/me/OneDrive", providerName: "OneDrive" }]); + setMaterializerForTests(() => Promise.resolve()); + setMacStatReaderForTests(() => ({ isFile: true, size: 100, blocks: 0 })); + + const provider = getCloudFileProvider(); + const p = "/Users/me/OneDrive/b.mp3"; + + await provider.setPinned(p, true); + expect(provider.getStatus(p)).toBe("hydrating"); + + await provider.setPinned(p, false); + expect(provider.getStatus(p)).toBe("cloudOnly"); + }); + + it("drops in-flight when the materializer rejects", async () => { + setCloudSyncRootsForTests([{ path: "/Users/me/OneDrive", providerName: "OneDrive" }]); + setMaterializerForTests(() => Promise.reject(new Error("nope"))); + setMacStatReaderForTests(() => ({ isFile: true, size: 100, blocks: 0 })); + + const provider = getCloudFileProvider(); + const p = "/Users/me/OneDrive/c.mp3"; + + await provider.setPinned(p, true); + // Let the rejected materialize promise's .catch run. + await Promise.resolve(); + await Promise.resolve(); + expect(provider.getStatus(p)).toBe("cloudOnly"); + }); +}); diff --git a/src/other/cloudFileStatus.ts b/src/other/cloudFileStatus.ts new file mode 100644 index 00000000..6cb59a47 --- /dev/null +++ b/src/other/cloudFileStatus.ts @@ -0,0 +1,725 @@ +import fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { + getFakeCloudProviderIfActive, + getFakeCloudSyncRootsIfActive +} from "./fakeCloudProvider"; + +// "local" = hydrated, but OneDrive's "free up space" could dehydrate it. +// "localPinned" = hydrated and the user (or lameta) asked to always keep it +// on this device (IS_PINNED). +export type CloudFileStatus = + | "local" + | "localPinned" + | "cloudOnly" + | "hydrating" + | "unknown"; + +export function isLocallyAvailable(status: CloudFileStatus): boolean { + return status === "local" || status === "localPinned"; +} + +export type AttributeReader = (path: string) => + | { + IS_OFFLINE: boolean; + IS_RECALL_ON_DATA_ACCESS: boolean; + IS_RECALL_ON_OPEN: boolean; + IS_PINNED?: boolean; + } + | undefined; + +export type PinWriter = (path: string, pinned: boolean) => Promise; + +interface FswinModule { + getAttributesSync: AttributeReader; + setAttributesAsync: ( + path: string, + attributes: { IS_PINNED?: boolean; IS_UNPINNED?: boolean } + ) => Promise; +} + +let testAttributeReader: AttributeReader | undefined; +let testPinWriter: PinWriter | undefined; +let cachedFswinModule: FswinModule | undefined; +let haveWarnedAboutFswinFailure = false; + +export function setAttributeReaderForTests(reader?: AttributeReader): void { + testAttributeReader = reader; +} + +export function setPinWriterForTests(writer?: PinWriter): void { + testPinWriter = writer; +} + +function getFswinModule(): FswinModule | undefined { + if (process.platform !== "win32") { + return undefined; + } + if (!cachedFswinModule) { + try { + // Lazy require: must never run at module-load time, or this module + // would blow up on macOS/CI/Vitest where fswin isn't installed. + // eslint-disable-next-line @typescript-eslint/no-var-requires + cachedFswinModule = require("fswin"); + } catch (e) { + if (!haveWarnedAboutFswinFailure) { + haveWarnedAboutFswinFailure = true; + console.warn("cloudFileStatus: failed to load fswin", e); + } + return undefined; + } + } + return cachedFswinModule; +} + +function getAttributeReader(): AttributeReader | undefined { + if (testAttributeReader) { + return testAttributeReader; + } + return getFswinModule()?.getAttributesSync; +} + +function statusFromAttributes( + attributes: + | { + IS_OFFLINE: boolean; + IS_RECALL_ON_DATA_ACCESS: boolean; + IS_RECALL_ON_OPEN: boolean; + IS_PINNED?: boolean; + } + | undefined +): CloudFileStatus { + if (!attributes) { + return "unknown"; + } + const isPlaceholder = + attributes.IS_OFFLINE || + attributes.IS_RECALL_ON_DATA_ACCESS || + attributes.IS_RECALL_ON_OPEN; + if (isPlaceholder) { + return attributes.IS_PINNED ? "hydrating" : "cloudOnly"; + } + return attributes.IS_PINNED ? "localPinned" : "local"; +} + +function readStatus(path: string): CloudFileStatus { + const reader = getAttributeReader(); + if (!reader) { + return "unknown"; + } + try { + return statusFromAttributes(reader(path)); + } catch (e) { + if (!haveWarnedAboutFswinFailure) { + haveWarnedAboutFswinFailure = true; + console.warn("cloudFileStatus: failed to read file attributes", e); + } + return "unknown"; + } +} + +// Platform abstraction over the local cloud sync engine. +// +// capabilities.canFetch = a real provider exists that can deliver placeholder +// (cloud-only) files on demand. canFetch=false means we cannot tell placeholder +// files apart or make them appear -- there is nothing to fetch. +// +// capabilities.canPin = a durable "keep this file on this device" pin exists +// that survives a "free up space" sweep. Only Windows (OneDrive's Files +// On-Demand, driven via fswin attributes) has this. +// +// On macOS there is no user-space durable pin (nothing like Windows IS_PINNED), +// so the mac provider has canPin=false: setPinned(path, true) instead triggers a +// one-shot materialization (download-now), and setPinned(path, false) stops +// waiting on it. That is why callers gate durable-pin behavior on canPin while +// gating "can we fetch this placeholder at all" on canFetch. +export interface CloudFileProvider { + readonly capabilities: { canPin: boolean; canFetch: boolean }; + getStatus(path: string): CloudFileStatus; + // pinned=true: ask the sync engine to download the file (Windows: & keep it + // local durably; macOS: a one-shot materialization). + // pinned=false: cancel/stop waiting on that request; never dehydrates. + setPinned(path: string, pinned: boolean): Promise; +} + +async function writePinAttribute(path: string, pinned: boolean): Promise { + const fswin = getFswinModule(); + if (!fswin) { + throw new Error("cloudFileStatus: fswin is unavailable, cannot set pin state"); + } + // Only ever clear IS_PINNED to unpin -- never set IS_UNPINNED, which would + // invite OneDrive to dehydrate the file. Unpinning should just stop asking + // for it to be kept local, not push it back to the cloud. + const attributes = pinned + ? { IS_PINNED: true, IS_UNPINNED: false } + : { IS_PINNED: false }; + const succeeded = await fswin.setAttributesAsync(path, attributes); + if (!succeeded) { + throw new Error( + `cloudFileStatus: fswin.setAttributesAsync failed to ${pinned ? "pin" : "unpin"} ${path}` + ); + } +} + +class WindowsCloudFileProvider implements CloudFileProvider { + public readonly capabilities = { canPin: true, canFetch: true }; + + getStatus(path: string): CloudFileStatus { + return readStatus(path); + } + + async setPinned(path: string, pinned: boolean): Promise { + const writer = testPinWriter ?? writePinAttribute; + await writer(path, pinned); + } +} + +class NullCloudFileProvider implements CloudFileProvider { + public readonly capabilities = { canPin: false, canFetch: false }; + + getStatus(_path: string): CloudFileStatus { + return "unknown"; + } + + async setPinned(_path: string, _pinned: boolean): Promise { + // No provider available: nothing to do. + } +} + +// ------------------------------------------------------------------------- +// macOS provider +// ------------------------------------------------------------------------- +// +// Modern providers (OneDrive, Dropbox, Google Drive, Box) use Apple's +// FileProvider framework. A cloud-only placeholder is a "dataless" file: it +// stats with a real size > 0 but blocks === 0. Node doesn't expose st_flags / +// SF_DATALESS, so "regular file, size > 0, blocks === 0" is our heuristic. +// iCloud Drive is older/separate: evicted files may show up as dataless files +// OR as a legacy "..icloud" sibling placeholder (real file missing). +// +// macOS gives us no OS notification when a download finishes and no durable +// pin, so "pinning" is a one-shot materialization: we ask the provider to +// download the file and track it as in-flight until getStatus() sees it become +// a real (non-dataless) file. cloudFilePoller drives that polling. + +// What we need from a stat() to classify a file. Kept minimal so the pure +// classifier and the test seam don't depend on the full fs.Stats shape. +export type MacStat = { isFile: boolean; size: number; blocks: number }; + +export type MacStatReader = (path: string) => MacStat | undefined; + +// undefined result = file missing. +let testMacStatReader: MacStatReader | undefined; +let testICloudSiblingChecker: ((path: string) => boolean) | undefined; +let testMaterializer: ((path: string) => Promise) | undefined; +let haveWarnedAboutMacStatFailure = false; +let haveWarnedAboutMacMaterializeFailure = false; + +export function setMacStatReaderForTests(reader?: MacStatReader): void { + testMacStatReader = reader; +} + +// Seam for the "..icloud" legacy-placeholder sibling existence check. +export function setICloudSiblingCheckerForTests( + checker?: (path: string) => boolean +): void { + testICloudSiblingChecker = checker; +} + +// Seam for the one-shot materialization (brctl / cat). Resolves once the +// request is *initiated*, not once the download completes. +export function setMaterializerForTests( + m?: (path: string) => Promise +): void { + testMaterializer = m; +} + +const kICloudProviderName = "iCloud Drive"; + +function isICloudRoot(root: CloudSyncRoot | undefined): boolean { + return root?.providerName === kICloudProviderName; +} + +// Pure stat -> status classifier. The caller supplies the facts that require +// fs / sync-root lookups; this function does no IO so it is trivially testable. +export function statusFromMacStat( + stat: MacStat | undefined, + ctx: { + underSyncRoot: boolean; + underICloudRoot: boolean; + inFlight: boolean; + hasICloudSibling: boolean; + } +): CloudFileStatus { + // Not under any sync root: not our concern, and this bounds sparse-file + // (size>0, blocks==0) false positives to cloud folders. + if (!ctx.underSyncRoot) { + return "unknown"; + } + if (stat) { + if (stat.isFile && stat.size > 0 && stat.blocks === 0) { + // Dataless placeholder: cloud-only unless we asked for it. + return ctx.inFlight ? "hydrating" : "cloudOnly"; + } + // Exists and has data (or is a dir/other) -> present locally. macOS never + // reports "localPinned" (no durable pin). in-flight self-cleanup happens + // in the caller, which owns the mutable set. + return "local"; + } + // File missing. On iCloud, an evicted file may only exist as a legacy + // "..icloud" sibling placeholder. + if (ctx.underICloudRoot && ctx.hasICloudSibling) { + return ctx.inFlight ? "hydrating" : "cloudOnly"; + } + return "unknown"; +} + +// The legacy iCloud placeholder sibling for a missing file: +// /a/b/foo.mp3 -> /a/b/.foo.mp3.icloud +function iCloudSiblingPath(filePath: string): string { + return path.join( + path.dirname(filePath), + "." + path.basename(filePath) + ".icloud" + ); +} + +function readMacStat(filePath: string): MacStat | undefined { + if (testMacStatReader) { + return testMacStatReader(filePath); + } + const s = fs.statSync(filePath, { throwIfNoEntry: false }); + if (!s) { + return undefined; + } + return { isFile: s.isFile(), size: s.size, blocks: s.blocks }; +} + +function iCloudSiblingExists(filePath: string): boolean { + if (testICloudSiblingChecker) { + return testICloudSiblingChecker(filePath); + } + return fs.existsSync(iCloudSiblingPath(filePath)); +} + +class MacCloudFileProvider implements CloudFileProvider { + public readonly capabilities = { canPin: false, canFetch: true }; + + // Paths (normalized) for which a one-shot materialization is in progress. + private inFlight = new Set(); + // Reader child processes keyed by normalized path, so setPinned(false) can + // kill the blocking read. + private readers = new Map(); + + getStatus(filePath: string): CloudFileStatus { + const root = getSyncRootForPath(filePath); + if (!root) { + return "unknown"; + } + const key = normalizeForCompare(filePath); + const inFlight = this.inFlight.has(key); + const underICloud = isICloudRoot(root); + try { + const stat = readMacStat(filePath); + const hasICloudSibling = + !stat && underICloud ? iCloudSiblingExists(filePath) : false; + const status = statusFromMacStat(stat, { + underSyncRoot: true, + underICloudRoot: underICloud, + inFlight, + hasICloudSibling + }); + // Self-clean: once a requested file has materialized (become "local"), + // drop it from the in-flight set. This is how "hydrating" transitions to + // "local" for the poller. + if (status === "local" && inFlight) { + this.inFlight.delete(key); + } + return status; + } catch (e) { + if (!haveWarnedAboutMacStatFailure) { + haveWarnedAboutMacStatFailure = true; + console.warn("cloudFileStatus: failed to stat file", e); + } + return "unknown"; + } + } + + async setPinned(filePath: string, pinned: boolean): Promise { + const key = normalizeForCompare(filePath); + if (!pinned) { + // Stop waiting: forget it and kill any blocking reader. The provider may + // keep downloading in the background; that matches Windows' unpin caveat. + this.inFlight.delete(key); + const child = this.readers.get(key); + if (child) { + this.readers.delete(key); + try { + child.kill(); + } catch { + // already gone + } + } + return; + } + + // pinned=true: one-shot materialization. Mark in-flight *before* kicking off + // the request so a concurrent getStatus() already sees "hydrating". + this.inFlight.add(key); + + if (testMaterializer) { + // Don't await completion -- resolve once the request is initiated. + testMaterializer(filePath).catch((e) => { + this.inFlight.delete(key); + this.warnMaterializeFailedOnce(e); + }); + return; + } + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { execFile, spawn } = require("child_process"); + const root = getSyncRootForPath(filePath); + if (isICloudRoot(root)) { + // iCloud: brctl requests a download; it returns after requesting, not + // after completion. + execFile("brctl", ["download", filePath], (err: unknown) => { + if (err) { + this.inFlight.delete(key); + this.warnMaterializeFailedOnce(err); + } + }); + return; + } + + // FileProvider root: reading any byte makes the kernel materialize the + // whole file, and the read blocks until done. Do it in a child process so a + // multi-GB blocking read never occupies Node's threadpool. + const child = spawn("/bin/cat", [filePath], { stdio: "ignore" }); + this.readers.set(key, child); + child.on("exit", (code: number | null) => { + this.readers.delete(key); + if (code !== 0) { + // getStatus self-cleans on success; on failure fall back to cloudOnly. + this.inFlight.delete(key); + this.warnMaterializeFailedOnce(new Error(`/bin/cat exited ${code}`)); + } + }); + child.on("error", (e: unknown) => { + this.readers.delete(key); + this.inFlight.delete(key); + this.warnMaterializeFailedOnce(e); + }); + } + + private warnMaterializeFailedOnce(e: unknown): void { + if (!haveWarnedAboutMacMaterializeFailure) { + haveWarnedAboutMacMaterializeFailure = true; + console.warn("cloudFileStatus: failed to materialize file", e); + } + } +} + +const windowsProvider = new WindowsCloudFileProvider(); +const macProvider = new MacCloudFileProvider(); +const nullProvider = new NullCloudFileProvider(); + +export function getCloudFileProvider(): CloudFileProvider { + // Windows/macOS unit-test seams take precedence, preserving existing + // behavior exactly (and keeping existing unit tests unaffected by the e2e + // env var below). + if (testAttributeReader || testPinWriter) { + return windowsProvider; + } + if (testMacStatReader || testMaterializer || testICloudSiblingChecker) { + return macProvider; + } + // E2E_FAKE_CLOUD_PROVIDER: an e2e-controllable fake, see fakeCloudProvider.ts. + // This check is cheap (an env var read cached after the first call) so it's + // safe to make on every getCloudFileProvider() call. + const fakeProvider = getFakeCloudProviderIfActive(); + if (fakeProvider) { + return fakeProvider; + } + if (process.platform === "win32") { + return windowsProvider; + } + if (process.platform === "darwin") { + return macProvider; + } + return nullProvider; +} + +// Kept as a delegate so existing callers don't change. +export function getCloudFileStatus(path: string): CloudFileStatus { + return getCloudFileProvider().getStatus(path); +} + +// A folder synced by some cloud sync engine, plus the name of that engine +// as the user knows it ("OneDrive", "Dropbox", ...) for use in UI text. +export interface CloudSyncRoot { + path: string; + providerName: string; +} + +let testCloudSyncRoots: CloudSyncRoot[] | undefined; + +export function setCloudSyncRootsForTests( + roots?: (string | CloudSyncRoot)[] +): void { + testCloudSyncRoots = roots?.map((r) => + typeof r === "string" ? { path: r, providerName: "OneDrive" } : r + ); + cachedSyncRoots = undefined; +} + +let cachedSyncRoots: CloudSyncRoot[] | undefined; + +// Every sync engine built on the Windows Cloud Files API (OneDrive, Dropbox, +// Google Drive, iCloud, ...) registers its sync roots under this key. It is +// the same API whose placeholder attributes fswin reads above, so anything +// that can produce a cloud-only file shows up here. +const syncRootManagerKey = + "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\SyncRootManager"; + +// Key names are program-ids like "GoogleDrive!S-1-5-...!account"; map the +// known id prefixes to the names users see. Unknown engines fall back to +// their id, which is usually recognizable ("MEGA", "pCloud", ...). +const friendlyProviderNames: Record = { + OneDrive: "OneDrive", + Dropbox: "Dropbox", + GoogleDrive: "Google Drive", + DriveFS: "Google Drive", + iCloudDrive: "iCloud Drive", + Box: "Box" +}; + +// Parses `reg query /s` output. Each sync root is a key +// like "...\SyncRootManager\Dropbox!!" whose UserSyncRoots +// subkey maps the user's SID to the synced folder: +// HKEY_LOCAL_MACHINE\...\SyncRootManager\Dropbox!S-1-5-21-...!dbid:...\UserSyncRoots +// S-1-5-21-... REG_SZ C:\Users\me\Dropbox +export function parseSyncRootManagerOutput(output: string): CloudSyncRoot[] { + const roots: CloudSyncRoot[] = []; + let currentProviderId: string | undefined; + let inUserSyncRoots = false; + for (const line of output.split(/\r?\n/)) { + if (line.startsWith("HKEY_")) { + const key = line.trim().match(/\\SyncRootManager\\([^\\]+)(\\UserSyncRoots)?$/i); + currentProviderId = key?.[1].split("!")[0]; + inUserSyncRoots = !!key?.[2]; + continue; + } + if (!inUserSyncRoots || !currentProviderId) { + continue; + } + const value = line.match(/^\s+\S+\s+REG_(?:EXPAND_)?SZ\s+(.+?)\s*$/); + if (value) { + roots.push({ + path: value[1], + providerName: + friendlyProviderNames[currentProviderId] ?? currentProviderId + }); + } + } + return roots; +} + +// macOS FileProvider sync roots live at +// ~/Library/CloudStorage/-/ +// e.g. "OneDrive-Personal", "GoogleDrive-user@gmail.com". The provider id is +// the part before the first "-"; map it through friendlyProviderNames, falling +// back to the id itself for unknown engines (same policy as Windows). +export function providerNameFromCloudStorageDir(dirName: string): string { + const id = dirName.split("-")[0]; + return friendlyProviderNames[id] ?? id; +} + +// iCloud Drive root ("iCloud Drive" in Finder). +function iCloudDriveRoot(home: string): string { + return path.join(home, "Library", "Mobile Documents", "com~apple~CloudDocs"); +} + +function readSyncRootsFromCloudStorage(): CloudSyncRoot[] { + if (process.platform !== "darwin") { + return []; + } + const home = os.homedir(); + const roots: CloudSyncRoot[] = []; + const cloudStorage = path.join(home, "Library", "CloudStorage"); + + // Modern FileProvider roots. + try { + for (const entry of fs.readdirSync(cloudStorage, { withFileTypes: true })) { + if (entry.isDirectory()) { + roots.push({ + path: path.join(cloudStorage, entry.name), + providerName: providerNameFromCloudStorageDir(entry.name) + }); + } + } + } catch { + // No CloudStorage dir (or unreadable): no modern providers. + } + + // Finder also creates home-dir symlinks (e.g. ~/OneDrive -> + // ~/Library/CloudStorage/OneDrive-Personal); users may open projects through + // either path, so add each such symlink as an alias root. + const cloudStorageNorm = normalizeForCompare(cloudStorage); + try { + for (const entry of fs.readdirSync(home, { withFileTypes: true })) { + if (!entry.isSymbolicLink()) { + continue; + } + const linkPath = path.join(home, entry.name); + try { + const resolved = fs.realpathSync(linkPath); + const resolvedNorm = normalizeForCompare(resolved); + if ( + resolvedNorm === cloudStorageNorm || + resolvedNorm.startsWith(cloudStorageNorm + "/") + ) { + const seg = + resolved + .slice(cloudStorage.length) + .replace(/^[/\\]+/, "") + .split(/[/\\]/)[0] || path.basename(resolved); + roots.push({ + path: linkPath, + providerName: providerNameFromCloudStorageDir(seg) + }); + } + } catch { + // Dangling symlink; ignore. + } + } + } catch { + // Home unreadable; ignore. + } + + // iCloud Drive (older/separate framework). + const iCloudRoot = iCloudDriveRoot(home); + try { + if (fs.existsSync(iCloudRoot)) { + roots.push({ path: iCloudRoot, providerName: kICloudProviderName }); + // With Desktop & Documents sync on, ~/Desktop and ~/Documents are also + // iCloud-synced roots. Detect it by the mirrored Desktop folder. + if (fs.existsSync(path.join(iCloudRoot, "Desktop"))) { + roots.push({ + path: path.join(home, "Desktop"), + providerName: kICloudProviderName + }); + roots.push({ + path: path.join(home, "Documents"), + providerName: kICloudProviderName + }); + } + } + } catch { + // ignore + } + + return roots; +} + +function readSyncRootsFromRegistry(): CloudSyncRoot[] { + if (process.platform !== "win32") { + return []; + } + try { + // Lazy require for the same reason as fswin above. + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { execFileSync } = require("child_process"); + const output: string = execFileSync( + "reg", + ["query", syncRootManagerKey, "/s"], + { encoding: "utf8", windowsHide: true, timeout: 3000 } + ); + return parseSyncRootManagerOutput(output); + } catch (e) { + console.warn("cloudFileStatus: failed to enumerate cloud sync roots", e); + return []; + } +} + +function getCloudSyncRoots(): CloudSyncRoot[] { + if (testCloudSyncRoots) { + return testCloudSyncRoots; + } + const fakeSyncRoots = getFakeCloudSyncRootsIfActive(); + if (fakeSyncRoots) { + return fakeSyncRoots; + } + if (!cachedSyncRoots) { + if (process.platform === "darwin") { + cachedSyncRoots = readSyncRootsFromCloudStorage(); + } else { + const roots = readSyncRootsFromRegistry(); + // OneDrive also publishes its sync root(s) in env vars; keep them as a + // Windows-only fallback in case the registry read fails or misses one. + for (const envRoot of [ + process.env.OneDrive, + process.env.OneDriveConsumer, + process.env.OneDriveCommercial + ]) { + if ( + envRoot && + !roots.some( + (r) => normalizeForCompare(r.path) === normalizeForCompare(envRoot) + ) + ) { + roots.push({ path: envRoot, providerName: "OneDrive" }); + } + } + cachedSyncRoots = roots; + } + } + return cachedSyncRoots; +} + +// The separator paths are compared with on this platform: backslash on +// Windows, forward slash everywhere else (macOS/APFS). +function compareSeparator(): string { + return process.platform === "win32" ? "\\" : "/"; +} + +// Fold paths into a comparable form: canonicalize to the platform separator, +// strip trailing separators, and lowercase (Windows and the APFS default are +// both case-insensitive). On darwin we fold "\" -> "/" so synthetic Windows +// test paths still compare correctly. Unicode is folded to NFC because macOS +// file APIs can hand back decomposed (NFD) names -- e.g. an accented account +// name in a CloudStorage folder -- while paths from settings/UI are usually +// precomposed, and a mixed-form prefix compare would silently fail. +export function normalizeForCompare(p: string): string { + const composed = p.normalize("NFC"); + if (process.platform === "win32") { + return composed.replace(/\//g, "\\").replace(/\\+$/, "").toLowerCase(); + } + return composed.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); +} + +// The sync root a path lives under, or undefined for a plain local path. +// File attributes alone cannot distinguish a fully-hydrated cloud file from a +// plain local file, so this is what decides whether a "local" file gets a +// checkmark icon -- and it is where UI text learns which sync engine +// ("OneDrive Status" vs "Dropbox Status") owns the file. +export function getSyncRootForPath(p: string): CloudSyncRoot | undefined { + const sep = compareSeparator(); + const normalized = normalizeForCompare(p); + return getCloudSyncRoots().find((root) => { + const normalizedRoot = normalizeForCompare(root.path); + return ( + normalized === normalizedRoot || + normalized.startsWith(normalizedRoot + sep) + ); + }); +} + +export function isUnderCloudSyncRoot(path: string): boolean { + return !!getSyncRootForPath(path); +} + +// "OneDrive", "Dropbox", ... or undefined when the path is not under any +// known sync root (callers fall back to generic wording). +export function getCloudProviderNameForPath(path: string): string | undefined { + return getSyncRootForPath(path)?.providerName; +} diff --git a/src/other/cloudMetadataPrefetch.spec.ts b/src/other/cloudMetadataPrefetch.spec.ts new file mode 100644 index 00000000..d043b40d --- /dev/null +++ b/src/other/cloudMetadataPrefetch.spec.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import fs from "fs"; +import * as Path from "path"; +import { + collectMetadataFilePaths, + prefetchCloudFiles, + prefetchCloudMetadata +} from "./cloudMetadataPrefetch"; +import { + setMacStatReaderForTests, + setMaterializerForTests, + setCloudSyncRootsForTests +} from "./cloudFileStatus"; +import { cloudReadGuard } from "./cloudReadGuard"; + +// A unique temp project directory for the tests that need real Sessions/People +// folders on disk (collectMetadataFilePaths enumerates directories via fs). +const scratchRoot = Path.join( + process.env.TMPDIR || "/tmp", + "cloudMetadataPrefetch-spec" +); + +function touch(filePath: string) { + fs.mkdirSync(Path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, ""); +} + +function makeProject(name: string, sessionDirs: string[], personDirs: string[]) { + const projectDir = Path.join(scratchRoot, name); + fs.rmSync(projectDir, { recursive: true, force: true }); + fs.mkdirSync(Path.join(projectDir, "Sessions"), { recursive: true }); + fs.mkdirSync(Path.join(projectDir, "People"), { recursive: true }); + for (const s of sessionDirs) { + fs.mkdirSync(Path.join(projectDir, "Sessions", s), { recursive: true }); + } + for (const p of personDirs) { + fs.mkdirSync(Path.join(projectDir, "People", p), { recursive: true }); + } + return projectDir; +} + +const dataless = { isFile: true, size: 100, blocks: 0 }; +const hydrated = { isFile: true, size: 100, blocks: 8 }; + +describe("collectMetadataFilePaths", () => { + afterEach(() => { + fs.rmSync(scratchRoot, { recursive: true, force: true }); + }); + + it("returns the .sprj plus each session's .session and each person's .person", () => { + const projectDir = makeProject("Proj1", ["s1", "s2"], ["p1"]); + const paths = collectMetadataFilePaths(projectDir); + expect(paths).toEqual([ + Path.join(projectDir, "Proj1.sprj"), + Path.join(projectDir, "Sessions", "s1", "s1.session"), + Path.join(projectDir, "Sessions", "s2", "s2.session"), + Path.join(projectDir, "People", "p1", "p1.person") + ]); + }); + + it("includes .meta media sidecars in session and person folders", () => { + const projectDir = makeProject("Proj2", ["s1"], ["p1"]); + touch(Path.join(projectDir, "Sessions", "s1", "s1_Careful.mp3.meta")); + touch(Path.join(projectDir, "Sessions", "s1", "s1_Photo.jpg.meta")); + // A non-.meta file must not be collected. + touch(Path.join(projectDir, "Sessions", "s1", "s1_Careful.mp3")); + touch(Path.join(projectDir, "People", "p1", "p1_Consent.pdf.meta")); + + const paths = collectMetadataFilePaths(projectDir); + + expect(paths).toContain( + Path.join(projectDir, "Sessions", "s1", "s1_Careful.mp3.meta") + ); + expect(paths).toContain( + Path.join(projectDir, "Sessions", "s1", "s1_Photo.jpg.meta") + ); + expect(paths).toContain( + Path.join(projectDir, "People", "p1", "p1_Consent.pdf.meta") + ); + // The folder's own metadata file is still there. + expect(paths).toContain( + Path.join(projectDir, "Sessions", "s1", "s1.session") + ); + // The media file itself (no .meta suffix) is not collected. + expect(paths).not.toContain( + Path.join(projectDir, "Sessions", "s1", "s1_Careful.mp3") + ); + }); + + it("includes .meta sidecars in DescriptionDocuments and OtherDocuments", () => { + const projectDir = makeProject("Proj3", [], []); + touch(Path.join(projectDir, "DescriptionDocuments", "readme.txt.meta")); + touch(Path.join(projectDir, "OtherDocuments", "budget.xls.meta")); + touch(Path.join(projectDir, "OtherDocuments", "notes.txt")); // not .meta + + const paths = collectMetadataFilePaths(projectDir); + + expect(paths).toContain( + Path.join(projectDir, "DescriptionDocuments", "readme.txt.meta") + ); + expect(paths).toContain( + Path.join(projectDir, "OtherDocuments", "budget.xls.meta") + ); + expect(paths).not.toContain( + Path.join(projectDir, "OtherDocuments", "notes.txt") + ); + }); + + it("tolerates a project with no Sessions/People/doc folders (just the .sprj)", () => { + const projectDir = Path.join(scratchRoot, "Empty"); + fs.rmSync(projectDir, { recursive: true, force: true }); + fs.mkdirSync(projectDir, { recursive: true }); + expect(collectMetadataFilePaths(projectDir)).toEqual([ + Path.join(projectDir, "Empty.sprj") + ]); + }); +}); + +describe("prefetchCloudFiles", () => { + beforeEach(() => { + cloudReadGuard.reset(); + }); + afterEach(() => { + setMacStatReaderForTests(undefined); + setMaterializerForTests(undefined); + setCloudSyncRootsForTests(undefined); + cloudReadGuard.reset(); + }); + + it("requests hydration for cloud-only files and skips already-local ones", () => { + setCloudSyncRootsForTests([ + { path: "/Users/me/OneDrive/Proj", providerName: "OneDrive" } + ]); + const materialize = vi.fn().mockResolvedValue(undefined); + setMaterializerForTests(materialize); + + const cloudFile = "/Users/me/OneDrive/Proj/Sessions/s1/s1.session"; + const localFile = "/Users/me/OneDrive/Proj/Sessions/s2/s2.session"; + setMacStatReaderForTests((p) => (p === localFile ? hydrated : dataless)); + + const n = prefetchCloudFiles([cloudFile, localFile]); + + expect(n).toBe(1); + expect(materialize).toHaveBeenCalledTimes(1); + expect(materialize).toHaveBeenCalledWith(cloudFile); + }); + + it("returns 0 and fires nothing when there are no cloud-only files", () => { + setCloudSyncRootsForTests([ + { path: "/Users/me/OneDrive/Proj", providerName: "OneDrive" } + ]); + const materialize = vi.fn().mockResolvedValue(undefined); + setMaterializerForTests(materialize); + setMacStatReaderForTests(() => hydrated); + + const n = prefetchCloudFiles([ + "/Users/me/OneDrive/Proj/Sessions/s1/s1.session" + ]); + + expect(n).toBe(0); + expect(materialize).not.toHaveBeenCalled(); + }); + + it("skips paths that are not under any cloud sync root", () => { + setCloudSyncRootsForTests([ + { path: "/Users/me/OneDrive/Proj", providerName: "OneDrive" } + ]); + const materialize = vi.fn().mockResolvedValue(undefined); + setMaterializerForTests(materialize); + setMacStatReaderForTests(() => dataless); + + const n = prefetchCloudFiles(["/Users/me/Elsewhere/x.session"]); + + expect(n).toBe(0); + expect(materialize).not.toHaveBeenCalled(); + }); + + it("does nothing when the cloud circuit breaker is tripped", () => { + setCloudSyncRootsForTests([ + { path: "/Users/me/OneDrive/Proj", providerName: "OneDrive" } + ]); + const materialize = vi.fn().mockResolvedValue(undefined); + setMaterializerForTests(materialize); + setMacStatReaderForTests(() => dataless); + + cloudReadGuard.recordFailure("/Users/me/OneDrive/Proj/whatever.session"); + expect(cloudReadGuard.isTripped).toBe(true); + + const n = prefetchCloudFiles([ + "/Users/me/OneDrive/Proj/Sessions/s1/s1.session" + ]); + + expect(n).toBe(0); + expect(materialize).not.toHaveBeenCalled(); + }); +}); + +describe("prefetchCloudMetadata (end-to-end over a temp project)", () => { + beforeEach(() => { + cloudReadGuard.reset(); + }); + afterEach(() => { + setMacStatReaderForTests(undefined); + setMaterializerForTests(undefined); + setCloudSyncRootsForTests(undefined); + cloudReadGuard.reset(); + fs.rmSync(scratchRoot, { recursive: true, force: true }); + }); + + it("collects and prefetches every cloud-only metadata file in the project", () => { + const projectDir = makeProject("MyProject", ["s1", "s2"], ["p1", "p2"]); + + setCloudSyncRootsForTests([ + { path: projectDir, providerName: "OneDrive" } + ]); + const materialize = vi.fn().mockResolvedValue(undefined); + setMaterializerForTests(materialize); + // Every metadata file is a cloud-only placeholder. + setMacStatReaderForTests(() => dataless); + + const n = prefetchCloudMetadata(projectDir); + + // 1 .sprj + 2 .session + 2 .person = 5 + expect(n).toBe(5); + expect(materialize).toHaveBeenCalledTimes(5); + const requested = materialize.mock.calls.map((c) => c[0]).sort(); + expect(requested).toEqual( + [ + Path.join(projectDir, "MyProject.sprj"), + Path.join(projectDir, "Sessions", "s1", "s1.session"), + Path.join(projectDir, "Sessions", "s2", "s2.session"), + Path.join(projectDir, "People", "p1", "p1.person"), + Path.join(projectDir, "People", "p2", "p2.person") + ].sort() + ); + }); + + it("does not fire when the metadata files are already local", () => { + const projectDir = makeProject("AllLocal", ["s1"], ["p1"]); + setCloudSyncRootsForTests([ + { path: projectDir, providerName: "OneDrive" } + ]); + const materialize = vi.fn().mockResolvedValue(undefined); + setMaterializerForTests(materialize); + setMacStatReaderForTests(() => hydrated); + + const n = prefetchCloudMetadata(projectDir); + + expect(n).toBe(0); + expect(materialize).not.toHaveBeenCalled(); + }); +}); diff --git a/src/other/cloudMetadataPrefetch.ts b/src/other/cloudMetadataPrefetch.ts new file mode 100644 index 00000000..225da7ec --- /dev/null +++ b/src/other/cloudMetadataPrefetch.ts @@ -0,0 +1,136 @@ +import fs from "fs"; +import * as Path from "path"; +import { getCloudFileProvider } from "./cloudFileStatus"; +import { cloudReadGuard } from "./cloudReadGuard"; + +// Opening a project reads each session/person/.sprj metadata file synchronously +// and in series. When those files are cloud-evicted placeholders, each read +// blocks while the provider hydrates that one file on demand (~2-3s each), so +// an 80-file project can take minutes and the UI appears frozen. +// +// This helper kicks off hydration for ALL the cloud-only metadata placeholders +// up front, without awaiting completion, so downloads run in parallel while the +// existing serial reads proceed unchanged. Each read still blocks until ITS +// file arrives, but by the time the loop reaches most files they are already +// downloading (macOS: one reader process per file; Windows: OneDrive +// parallelizes the pins). +// +// This never reads file bytes itself, so it cannot trip the cloud circuit +// breaker; and it is a no-op when there is no fetching provider or the breaker +// is already tripped (a failing provider must not be hammered -- see +// cloudReadGuard). + +// Flat (non-recursive) list of directory entries, tolerating a missing or +// unreadable directory. +function readDirSafe(dir: string): string[] { + try { + return fs.readdirSync(dir, "utf8"); + } catch { + // Directory doesn't exist (or isn't readable) yet. + return []; + } +} + +// The paths of every ".meta" media sidecar directly in `folder` (flat, no +// recursion). These are read serially during folder load exactly like the +// folder's own metadata file, so they block one-at-a-time too. +function metaSidecarPaths(folder: string): string[] { + return readDirSafe(folder) + .filter((name) => name.endsWith(".meta")) + .map((name) => Path.join(folder, name)); +} + +// For each child folder under `parentDir` (Sessions/ or People/): the folder's +// own metadata file (named after the folder plus a well-known extension -- see +// FolderMetadataFile, e.g. Sessions/foo/foo.session) plus every .meta media +// sidecar in that folder. +function folderMetadataPaths(parentDir: string, extension: string): string[] { + const result: string[] = []; + for (const childName of readDirSafe(parentDir)) { + const dir = Path.join(parentDir, childName); + try { + if (!fs.lstatSync(dir).isDirectory()) { + continue; + } + } catch { + // Racing deletion / unreadable entry: skip it. + continue; + } + result.push(Path.join(dir, childName + extension)); + result.push(...metaSidecarPaths(dir)); + } + return result; +} + +// Collect the conventional metadata file paths for a project, all of which are +// read serially at load time: the .sprj; every Sessions/*/.session and +// People/*/.person plus the .meta media sidecars in those folders; and +// the .meta sidecars in DescriptionDocuments/ and OtherDocuments/. Best-effort: +// paths that don't exist are harmless (they simply won't be cloud-only). +export function collectMetadataFilePaths(projectDir: string): string[] { + const paths: string[] = []; + paths.push(Path.join(projectDir, Path.basename(projectDir) + ".sprj")); + paths.push( + ...folderMetadataPaths(Path.join(projectDir, "Sessions"), ".session") + ); + paths.push( + ...folderMetadataPaths(Path.join(projectDir, "People"), ".person") + ); + // ProjectDocuments loads these two folders the same way, reading each file's + // .meta sidecar serially. + paths.push( + ...metaSidecarPaths(Path.join(projectDir, "DescriptionDocuments")) + ); + paths.push(...metaSidecarPaths(Path.join(projectDir, "OtherDocuments"))); + return paths; +} + +// Fire off hydration for every cloud-only file in `paths`, in parallel, without +// awaiting completion. Returns the number of files for which hydration was +// requested (0 if none / no fetching provider / breaker tripped). +export function prefetchCloudFiles(paths: string[]): number { + const provider = getCloudFileProvider(); + // No real provider that can deliver placeholders: nothing to prefetch. + if (!provider.capabilities.canFetch) { + return 0; + } + // The provider already failed to deliver a file during this load; don't + // hammer a broken/rate-limited provider. The normal serial reads will fail + // softly and surface a single "couldn't reach " banner. + if (cloudReadGuard.isTripped) { + return 0; + } + + const placeholders = paths.filter((p) => { + try { + return provider.getStatus(p) === "cloudOnly"; + } catch { + return false; + } + }); + + if (placeholders.length === 0) { + return 0; + } + + console.log( + `☁️ [cloudMetadataPrefetch] Prefetching ${placeholders.length} cloud-only metadata file(s) in parallel so project load doesn't block one-at-a-time` + ); + + for (const p of placeholders) { + // Fire and forget: resolve/rejection is irrelevant here. setPinned only + // *initiates* the download; the subsequent serial read is what waits for + // each file. Swallow errors so one bad path can't reject unhandled. + provider.setPinned(p, true).catch(() => { + /* the serial read will surface any real failure */ + }); + } + + return placeholders.length; +} + +// Convenience wrapper used by Project loading: collect the project's metadata +// file paths and prefetch the cloud-only ones. Returns the number requested. +export function prefetchCloudMetadata(projectDir: string): number { + return prefetchCloudFiles(collectMetadataFilePaths(projectDir)); +} diff --git a/src/other/cloudReadGuard.spec.ts b/src/other/cloudReadGuard.spec.ts new file mode 100644 index 00000000..ac74affe --- /dev/null +++ b/src/other/cloudReadGuard.spec.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { isCloudProviderReadFailure } from "./cloudReadGuard"; + +describe("isCloudProviderReadFailure", () => { + it("returns false for undefined/null errors", () => { + expect(isCloudProviderReadFailure(undefined)).toBe(false); + expect(isCloudProviderReadFailure(null)).toBe(false); + }); + + // Windows signature + it("matches the Windows errno -4094 read signature", () => { + expect( + isCloudProviderReadFailure({ errno: -4094, code: "UNKNOWN", syscall: "read" }) + ).toBe(true); + }); + + it("matches the Windows code UNKNOWN read signature even without matching errno", () => { + expect( + isCloudProviderReadFailure({ errno: -1, code: "UNKNOWN", syscall: "read" }) + ).toBe(true); + }); + + it("does not match UNKNOWN/errno -4094 on a non-read syscall", () => { + expect( + isCloudProviderReadFailure({ errno: -4094, code: "UNKNOWN", syscall: "open" }) + ).toBe(false); + }); + + // macOS signatures + it("matches EDEADLK on read", () => { + expect(isCloudProviderReadFailure({ code: "EDEADLK", syscall: "read" })).toBe( + true + ); + }); + + it("matches EDEADLK on open", () => { + expect(isCloudProviderReadFailure({ code: "EDEADLK", syscall: "open" })).toBe( + true + ); + }); + + it("matches ETIMEDOUT on read", () => { + expect(isCloudProviderReadFailure({ code: "ETIMEDOUT", syscall: "read" })).toBe( + true + ); + }); + + it("matches EIO on open", () => { + expect(isCloudProviderReadFailure({ code: "EIO", syscall: "open" })).toBe(true); + }); + + it("does not match EDEADLK on stat", () => { + expect(isCloudProviderReadFailure({ code: "EDEADLK", syscall: "stat" })).toBe( + false + ); + }); + + // Plain filesystem errors must never match + it("does not match ENOENT on read", () => { + expect(isCloudProviderReadFailure({ code: "ENOENT", syscall: "read" })).toBe( + false + ); + }); + + it("does not match EBUSY on open", () => { + expect(isCloudProviderReadFailure({ code: "EBUSY", syscall: "open" })).toBe( + false + ); + }); + + it("does not match EPERM on read", () => { + expect(isCloudProviderReadFailure({ code: "EPERM", syscall: "read" })).toBe( + false + ); + }); + + it("does not match EACCES on open", () => { + expect(isCloudProviderReadFailure({ code: "EACCES", syscall: "open" })).toBe( + false + ); + }); +}); diff --git a/src/other/cloudReadGuard.ts b/src/other/cloudReadGuard.ts new file mode 100644 index 00000000..077b0152 --- /dev/null +++ b/src/other/cloudReadGuard.ts @@ -0,0 +1,92 @@ +import { makeAutoObservable } from "mobx"; + +// When a cloud sync provider (OneDrive, Dropbox, Nextcloud, ...) cannot deliver +// the contents of a placeholder file, reading its bytes fails. On Windows the +// Cloud Files API surfaces this to Node as a generic read error: +// { code: "UNKNOWN", errno: -4094, syscall: "read" } +// (Verified against a broken/rate-limited Nextcloud demo server: the read of a +// cloud-only placeholder threw exactly this.) A plain missing/locked file uses +// ENOENT/EBUSY/EPERM instead, so this signature is specific to "the provider +// tried to hydrate and failed". +// +// On macOS, a read/open of a dataless (cloud-only) file whose materialization +// fails is expected to surface as EDEADLK ("Resource deadlock avoided" -- the +// dataless-fault errno the kernel uses when the FileProvider can't fulfil the +// fault), and possibly ETIMEDOUT or EIO, with syscall "read" or "open". This +// list is NOT yet empirically confirmed (unlike the Windows signature above) +// -- it is pending verification by evicting a file, cutting the network, and +// reading it; it may be tightened once that's done. +export function isCloudProviderReadFailure(err: any): boolean { + if (!err) return false; + if ((err.errno === -4094 || err.code === "UNKNOWN") && err.syscall === "read") { + return true; + } + return ( + (err.code === "EDEADLK" || err.code === "ETIMEDOUT" || err.code === "EIO") && + (err.syscall === "read" || err.syscall === "open") + ); +} + +export interface FailedCloudRead { + path: string; + // "OneDrive", "Dropbox", "Nextcloud", ... or undefined if unknown. + providerName?: string; +} + +// A per-load circuit breaker for cloud reads. lameta reads each session/person +// metadata file (and .meta sidecars) synchronously at project-load time, which +// forces the sync engine to hydrate them on demand. When the provider is down +// or rate-limiting, every such read fails -- and each failed attempt can make +// the provider's own client pop a modal error dialog. Rather than hammer a +// broken provider (one storm of dialogs + toasts per file), we trip this +// breaker on the first failure and skip the remaining placeholder reads for the +// rest of the load, surfacing a single "couldn't reach " banner with +// a Retry action instead. +class CloudReadGuard { + private tripped = false; + private failures: FailedCloudRead[] = []; + + constructor() { + makeAutoObservable(this); + } + + // Call at the start of each project load. + public reset(): void { + this.tripped = false; + this.failures = []; + } + + public get isTripped(): boolean { + return this.tripped; + } + + public get failedReads(): ReadonlyArray { + return this.failures; + } + + public get hasFailures(): boolean { + return this.failures.length > 0; + } + + // Record that a placeholder read failed (or was skipped because the breaker + // was already tripped). Trips the breaker so subsequent placeholder reads in + // this load are skipped. + public recordFailure(path: string, providerName?: string): void { + this.tripped = true; + if (!this.failures.some((f) => f.path === path)) { + this.failures.push({ path, providerName }); + } + } + + // Called when a previously-failed file has been (or is about to be) re-read + // successfully. Clears the breaker once nothing is left outstanding so future + // placeholder reads are attempted again. + public clearFailure(path: string): void { + this.failures = this.failures.filter((f) => f.path !== path); + if (this.failures.length === 0) { + this.tripped = false; + } + } +} + +export const cloudReadGuard = new CloudReadGuard(); diff --git a/src/other/crossPlatformUtilities.ts b/src/other/crossPlatformUtilities.ts index 70eddc9b..346eb618 100644 --- a/src/other/crossPlatformUtilities.ts +++ b/src/other/crossPlatformUtilities.ts @@ -6,6 +6,15 @@ import { t } from "@lingui/macro"; import { mainProcessApi } from "../mainProcess/MainProcessApiAccess"; import { globSync } from "glob"; +// The platform-appropriate label for the "reveal this file in the OS file +// manager" action. Shared by the file-list context menu and the cloud status +// card so the wording stays consistent. +export function revealInFolderLabel(): string { + return process.platform === "darwin" + ? t`Show in Finder` + : t`Show in File Explorer`; +} + export function revealInFolder(path: string) { console.log("Revealing in folder:", path); if (!path) return; diff --git a/src/other/fakeCloudProvider.spec.ts b/src/other/fakeCloudProvider.spec.ts new file mode 100644 index 00000000..16f786ba --- /dev/null +++ b/src/other/fakeCloudProvider.spec.ts @@ -0,0 +1,163 @@ +import * as fs from "fs-extra"; +import * as Path from "path"; +import * as temp from "temp"; +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { + getFakeCloudProviderIfActive, + getFakeCloudSyncRootsIfActive, + resetFakeCloudProviderForTests +} from "./fakeCloudProvider"; + +function writeManifest(manifest: unknown): string { + const dir = temp.mkdirSync(); + const manifestPath = Path.join(dir, "manifest.json"); + fs.writeFileSync(manifestPath, JSON.stringify(manifest)); + return manifestPath; +} + +describe("fakeCloudProvider", () => { + const originalEnv = process.env.E2E_FAKE_CLOUD_PROVIDER; + + beforeEach(() => { + resetFakeCloudProviderForTests(); + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.E2E_FAKE_CLOUD_PROVIDER; + } else { + process.env.E2E_FAKE_CLOUD_PROVIDER = originalEnv; + } + resetFakeCloudProviderForTests(); + vi.useRealTimers(); + }); + + it("is inactive (undefined) when the env var is not set", () => { + delete process.env.E2E_FAKE_CLOUD_PROVIDER; + expect(getFakeCloudProviderIfActive()).toBeUndefined(); + expect(getFakeCloudSyncRootsIfActive()).toBeUndefined(); + }); + + it("reports cloudOnly for manifest-listed files and local for everything else", () => { + const cloudOnlyPath = "/fake/root/session/audio.mp3"; + process.env.E2E_FAKE_CLOUD_PROVIDER = writeManifest({ + syncRoots: [{ path: "/fake/root", providerName: "FakeDrive" }], + cloudOnly: [cloudOnlyPath], + hydrateDelayMs: 500 + }); + + const provider = getFakeCloudProviderIfActive(); + expect(provider).toBeDefined(); + expect(provider!.capabilities).toEqual({ canPin: false, canFetch: true }); + expect(provider!.getStatus(cloudOnlyPath)).toBe("cloudOnly"); + expect(provider!.getStatus("/fake/root/session/other-file.txt")).toBe( + "local" + ); + + expect(getFakeCloudSyncRootsIfActive()).toEqual([ + { path: "/fake/root", providerName: "FakeDrive" } + ]); + }); + + it("compares paths case-insensitively, like the real providers", () => { + const cloudOnlyPath = "/Fake/Root/Session/Audio.mp3"; + process.env.E2E_FAKE_CLOUD_PROVIDER = writeManifest({ + syncRoots: [{ path: "/Fake/Root", providerName: "FakeDrive" }], + cloudOnly: [cloudOnlyPath], + hydrateDelayMs: 500 + }); + + const provider = getFakeCloudProviderIfActive()!; + expect(provider.getStatus("/fake/root/session/audio.mp3")).toBe( + "cloudOnly" + ); + }); + + it("loads the manifest only once -- a later rewrite of the file is not picked up", () => { + const cloudOnlyPath = "/fake/root/session/audio.mp3"; + const manifestPath = writeManifest({ + syncRoots: [{ path: "/fake/root", providerName: "FakeDrive" }], + cloudOnly: [cloudOnlyPath], + hydrateDelayMs: 500 + }); + process.env.E2E_FAKE_CLOUD_PROVIDER = manifestPath; + + const provider = getFakeCloudProviderIfActive()!; + expect(provider.getStatus(cloudOnlyPath)).toBe("cloudOnly"); + + // Rewrite the manifest with a different file marked cloud-only instead. + fs.writeFileSync( + manifestPath, + JSON.stringify({ + syncRoots: [{ path: "/fake/root", providerName: "FakeDrive" }], + cloudOnly: ["/fake/root/session/other.mp3"], + hydrateDelayMs: 500 + }) + ); + + // Still using the manifest cached at first use. + expect(provider.getStatus(cloudOnlyPath)).toBe("cloudOnly"); + expect(getFakeCloudProviderIfActive()!.getStatus(cloudOnlyPath)).toBe( + "cloudOnly" + ); + }); + + it("setPinned(true) moves a cloud-only file to hydrating, then to local after hydrateDelayMs", () => { + vi.useFakeTimers(); + const cloudOnlyPath = "/fake/root/session/audio.mp3"; + process.env.E2E_FAKE_CLOUD_PROVIDER = writeManifest({ + syncRoots: [{ path: "/fake/root", providerName: "FakeDrive" }], + cloudOnly: [cloudOnlyPath], + hydrateDelayMs: 500 + }); + + const provider = getFakeCloudProviderIfActive()!; + provider.setPinned(cloudOnlyPath, true); + expect(provider.getStatus(cloudOnlyPath)).toBe("hydrating"); + + vi.advanceTimersByTime(499); + expect(provider.getStatus(cloudOnlyPath)).toBe("hydrating"); + + vi.advanceTimersByTime(1); + expect(provider.getStatus(cloudOnlyPath)).toBe("local"); + }); + + it("setPinned(false) while hydrating cancels back to cloudOnly", () => { + vi.useFakeTimers(); + const cloudOnlyPath = "/fake/root/session/audio.mp3"; + process.env.E2E_FAKE_CLOUD_PROVIDER = writeManifest({ + syncRoots: [{ path: "/fake/root", providerName: "FakeDrive" }], + cloudOnly: [cloudOnlyPath], + hydrateDelayMs: 500 + }); + + const provider = getFakeCloudProviderIfActive()!; + provider.setPinned(cloudOnlyPath, true); + expect(provider.getStatus(cloudOnlyPath)).toBe("hydrating"); + + provider.setPinned(cloudOnlyPath, false); + expect(provider.getStatus(cloudOnlyPath)).toBe("cloudOnly"); + + // The cancelled timer must not still fire and flip it to local later. + vi.advanceTimersByTime(1000); + expect(provider.getStatus(cloudOnlyPath)).toBe("cloudOnly"); + }); + + it("setPinned(false) never dehydrates a file that already became local", async () => { + vi.useFakeTimers(); + const cloudOnlyPath = "/fake/root/session/audio.mp3"; + process.env.E2E_FAKE_CLOUD_PROVIDER = writeManifest({ + syncRoots: [{ path: "/fake/root", providerName: "FakeDrive" }], + cloudOnly: [cloudOnlyPath], + hydrateDelayMs: 500 + }); + + const provider = getFakeCloudProviderIfActive()!; + provider.setPinned(cloudOnlyPath, true); + vi.advanceTimersByTime(500); + expect(provider.getStatus(cloudOnlyPath)).toBe("local"); + + await provider.setPinned(cloudOnlyPath, false); + expect(provider.getStatus(cloudOnlyPath)).toBe("local"); + }); +}); diff --git a/src/other/fakeCloudProvider.ts b/src/other/fakeCloudProvider.ts new file mode 100644 index 00000000..838fcba8 --- /dev/null +++ b/src/other/fakeCloudProvider.ts @@ -0,0 +1,179 @@ +import fs from "fs"; +import { + CloudFileProvider, + CloudFileStatus, + CloudSyncRoot, + normalizeForCompare +} from "./cloudFileStatus"; + +// An e2e-controllable fake cloud provider, so Playwright tests can exercise +// lameta's cloud-sync UI (icons, the " Status" card, hydrate +// button, ...) on any machine -- without a real OneDrive/Dropbox/iCloud +// account or the platform-specific placeholder-file machinery the real +// providers need (fswin on Windows, FileProvider dataless files on macOS). +// +// Activated by setting E2E_FAKE_CLOUD_PROVIDER to the path of a JSON manifest +// file before the app launches: +// { +// "syncRoots": [{ "path": "...", "providerName": "FakeDrive" }], +// "cloudOnly": ["/abs/path/to/file1", ...], +// "hydrateDelayMs": 500 +// } +// The real files on disk have real content the whole time -- this fakes only +// the STATUS lameta reads, which is all the UI actually depends on. + +export interface FakeCloudManifest { + syncRoots: CloudSyncRoot[]; + // Normalized (see normalizeForCompare) absolute paths of files to report as + // cloud-only until a setPinned(path, true) hydrates them. + cloudOnly: string[]; + hydrateDelayMs: number; +} + +const kDefaultHydrateDelayMs = 500; + +let haveCheckedEnv = false; +let cachedManifest: FakeCloudManifest | undefined; + +// Reads and parses the manifest the first time any of this module's exported +// functions is called, then caches it for the rest of the process's life -- +// matching the existing lazy-cache pattern this file's sibling +// (cloudFileStatus.ts) uses for cachedSyncRoots/cachedFswinModule. A test run +// is expected to write its final manifest before launching the app and never +// change it afterward; re-reading on every call would be pointless work (and +// would make "first use" caching -- which mirrors the real providers this +// fake stands in for -- untestable). +function loadManifestOnce(): FakeCloudManifest | undefined { + if (haveCheckedEnv) { + return cachedManifest; + } + haveCheckedEnv = true; + // Indexing instead of process.env.E2E_FAKE_CLOUD_PROVIDER: Vite statically + // replaces `process.env` in renderer code (see getTestEnvironment.ts), which + // would otherwise bake in whatever this var was (usually unset) at build + // time instead of reading it live from the launched process. + const manifestPath = process["env"]["E2E_FAKE_CLOUD_PROVIDER"]; + if (!manifestPath) { + return undefined; + } + try { + const raw = fs.readFileSync(manifestPath, "utf8"); + const parsed = JSON.parse(raw); + cachedManifest = { + syncRoots: Array.isArray(parsed.syncRoots) ? parsed.syncRoots : [], + cloudOnly: Array.isArray(parsed.cloudOnly) + ? parsed.cloudOnly.map(normalizeForCompare) + : [], + hydrateDelayMs: + typeof parsed.hydrateDelayMs === "number" + ? parsed.hydrateDelayMs + : kDefaultHydrateDelayMs + }; + } catch (e) { + console.warn( + `fakeCloudProvider: failed to load manifest at ${manifestPath}`, + e + ); + cachedManifest = undefined; + } + return cachedManifest; +} + +type FakeFileState = "hydrating" | "local"; + +export class FakeCloudFileProvider implements CloudFileProvider { + // Matches the macOS provider's shape: no durable pin, but can fetch. This + // is what makes the UI show the "Download this file to my computer" / + // "Stop waiting" button pair instead of the Windows pin checkbox. + public readonly capabilities = { canPin: false, canFetch: true }; + + // Per-path override of the manifest's default "cloudOnly" classification. + // Absent = still cloudOnly (the manifest's static list is the ground truth + // until a setPinned(true) starts hydrating it). + private fileStates = new Map(); + private hydrateTimers = new Map>(); + + getStatus(filePath: string): CloudFileStatus { + const manifest = loadManifestOnce(); + if (!manifest) { + return "unknown"; + } + const key = normalizeForCompare(filePath); + if (!manifest.cloudOnly.includes(key)) { + // Not one of the manifest's cloud-only files: as far as this fake is + // concerned, it's just a normal local file. + return "local"; + } + return this.fileStates.get(key) ?? "cloudOnly"; + } + + async setPinned(filePath: string, pinned: boolean): Promise { + const manifest = loadManifestOnce(); + if (!manifest) { + return; + } + const key = normalizeForCompare(filePath); + + if (!pinned) { + const timer = this.hydrateTimers.get(key); + if (timer) { + clearTimeout(timer); + this.hydrateTimers.delete(key); + } + // Cancel back to cloudOnly -- but never dehydrate a file that already + // finished hydrating, matching the real providers' "unpin never + // dehydrates" contract (see CloudFileProvider.setPinned's doc comment). + if (this.fileStates.get(key) !== "local") { + this.fileStates.delete(key); + } + return; + } + + if (!manifest.cloudOnly.includes(key)) { + // Nothing to hydrate; this path was never marked cloud-only. + return; + } + + this.fileStates.set(key, "hydrating"); + const existingTimer = this.hydrateTimers.get(key); + if (existingTimer) { + clearTimeout(existingTimer); + } + const timer = setTimeout(() => { + this.fileStates.set(key, "local"); + this.hydrateTimers.delete(key); + }, manifest.hydrateDelayMs); + this.hydrateTimers.set(key, timer); + } +} + +let singleton: FakeCloudFileProvider | undefined; + +// Returns the fake provider if E2E_FAKE_CLOUD_PROVIDER names a loadable +// manifest, else undefined (so the real factory in cloudFileStatus.ts falls +// through to the platform providers unchanged). +export function getFakeCloudProviderIfActive(): CloudFileProvider | undefined { + if (!loadManifestOnce()) { + return undefined; + } + if (!singleton) { + singleton = new FakeCloudFileProvider(); + } + return singleton; +} + +// Returns the manifest's sync roots if the fake provider is active, else +// undefined (so getCloudSyncRoots() in cloudFileStatus.ts falls through to +// real sync-root detection unchanged). +export function getFakeCloudSyncRootsIfActive(): CloudSyncRoot[] | undefined { + return loadManifestOnce()?.syncRoots; +} + +// Test-only seam: forget the cached manifest/env check and all per-path +// hydrate state, so each unit test starts clean regardless of what a +// previous test wrote to process.env.E2E_FAKE_CLOUD_PROVIDER. +export function resetFakeCloudProviderForTests(): void { + haveCheckedEnv = false; + cachedManifest = undefined; + singleton = undefined; +} diff --git a/src/other/menu.ts b/src/other/menu.ts index c831fed8..8df6272d 100644 --- a/src/other/menu.ts +++ b/src/other/menu.ts @@ -311,6 +311,22 @@ export default class LametaMenu { enabled: !Project.getMultilingualConversionPending(), click: () => (userSettings.ShowLanguageTags = !userSettings.ShowLanguageTags) + }, + { + label: t`Automatically make cloud files available if smaller than`, + submenu: [ + { mb: 0, label: t`Never` }, + { mb: 1, label: "1 MB" }, + { mb: 10, label: "10 MB" }, + { mb: 100, label: "100 MB" }, + { mb: 1024, label: "1 GB" }, + { mb: Infinity, label: t`Always` } + ].map(({ mb, label }) => ({ + label, + type: "radio", + checked: userSettings.AutoFetchCloudFilesUnderMB === mb, + click: () => (userSettings.AutoFetchCloudFilesUnderMB = mb) + })) } ] }; diff --git a/src/other/networkStatus.ts b/src/other/networkStatus.ts new file mode 100644 index 00000000..e59394f8 --- /dev/null +++ b/src/other/networkStatus.ts @@ -0,0 +1,25 @@ +import { observable, runInAction } from "mobx"; + +// Observable network connectivity, driven by the browser's online/offline +// events. navigator.onLine only tells us whether the OS has a network +// connection (not whether OneDrive is reachable), but that is enough to warn +// the user that a requested cloud file cannot arrive right now. +export const networkStatus = observable({ + isOnline: + typeof navigator === "undefined" || navigator.onLine === undefined + ? true + : navigator.onLine +}); + +if (typeof window !== "undefined" && window.addEventListener) { + window.addEventListener("online", () => + runInAction(() => (networkStatus.isOnline = true)) + ); + window.addEventListener("offline", () => + runInAction(() => (networkStatus.isOnline = false)) + ); +} + +export function setOnlineForTests(isOnline: boolean): void { + runInAction(() => (networkStatus.isOnline = isOnline)); +} diff --git a/src/other/patientFile.ts b/src/other/patientFile.ts index 2cb7b767..5a3116aa 100644 --- a/src/other/patientFile.ts +++ b/src/other/patientFile.ts @@ -10,35 +10,39 @@ import { } from "../components/Notify"; import { t } from "@lingui/macro"; -/* Do what we can to co-exist with things like Dropbox that can temporarily lock files. - To torture test this stuff, use https://github.com/hatton/filemeddler +/* Do what we can to co-exist with things like antivirus scanners and file-sync + services that can temporarily lock files. On Windows (only), a process + holding a file open without delete-sharing makes renaming that file fail + with EBUSY and renaming any ancestor directory fail with EPERM; we retry + those two codes for ~10 seconds. (On POSIX, open handles don't block + renames, so this class of contention doesn't exist there.) - Note, I wrote this before discovering graceful-fs. I've added that, as it's more - extensive. Keeping this around for now because there is some question about some - windows corner-cases in the graceful-fs system. - - UPDATE: It turns out that graceful-fs is only prevents contention between threads, - not with other processes. + To torture test this, run the RenameContention.stress.spec.ts suite + (LAMETA_STRESS=1), or use https://github.com/hatton/filemeddler + Note: we used to also monkey-patch fs with graceful-fs here, but its + Windows rename retry only wraps the *async* fs.rename, and everything in + this file is synchronous — it contributed nothing. */ export class PatientFS { - public static init() { - // monkey-patch fs - const realFs = require("fs"); - const gracefulFs = require("graceful-fs"); - gracefulFs.gracefulify(realFs); - } public static readFileSyncWithNotifyAndRethrow(path: string): string { try { - return PatientFS.patientFileOperationSync(() => - fs.readFileSync(path, "utf8") - ); + return PatientFS.readFileSyncNoNotify(path); } catch (err) { NotifyFileAccessProblem(`Could not read ${path}`, err); throw err; } } + // Same patient retry behavior, but throws the raw error without showing any + // notification. For callers that need to classify the error first (e.g. a + // cloud-provider hydration failure that should fail softly rather than pop a + // toast per file -- see cloudReadGuard). + public static readFileSyncNoNotify(path: string): string { + return PatientFS.patientFileOperationSync(() => + fs.readFileSync(path, "utf8") + ); + } public static writeFileSyncWithNotifyThenRethrow( path: string, contents: string @@ -99,7 +103,6 @@ export class PatientFS { } } private static patientFileOperationSync(operation: () => any): any { - // note, graceful-fs is already pausing up to 60 seconds on each attempt. const kretryAttempts = 10; // I wish i could visibly show something if we're going to wait... let attempt = 1; for (; attempt <= kretryAttempts; attempt++) { diff --git a/vite.config.ts b/vite.config.ts index aa7c8733..e0382a7a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,9 +19,16 @@ export default defineConfig({ build: { emptyOutDir: true, rollupOptions: { - external: ["ts-node"] + external: ["ts-node", "fswin"] } }, + optimizeDeps: { + // fswin ships prebuilt .node binaries per-arch; esbuild's dev-server + // dependency scanner has no loader for those and crashes trying to + // pre-bundle it. It's lazily require()'d (Windows-only, main-process-only) + // in cloudFileStatus.ts, so keep it out of the scan entirely. + exclude: ["fswin"] + }, plugins: [ // Custom plugin to copy vocabulary files to dist/vocabularies { @@ -86,7 +93,12 @@ export default defineConfig({ renderer({ nodeIntegration: true, optimizeDeps: { - include: ["xml2js", /*"glob",*/ "fs-extra", "graceful-fs"] + include: ["xml2js", /*"glob",*/ "fs-extra", "graceful-fs"], + // fswin ships prebuilt .node binaries for every arch; esbuild's dev-server + // dependency scanner has no loader for those and crashes if it tries to + // pre-bundle it. It's lazily require()'d (Windows-only) in cloudFileStatus.ts, + // so it must stay external to the renderer bundle rather than be scanned. + exclude: ["fswin"] } }), dsv() // for importing csv diff --git a/yarn.lock b/yarn.lock index 9cd20671..acd1dd3b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10562,6 +10562,11 @@ fstream@^1.0.12: mkdirp ">=0.5 0" rimraf "2" +fswin@^3.25.1108: + version "3.25.1108" + resolved "https://registry.npmjs.org/fswin/-/fswin-3.25.1108.tgz#dd20c3e5d0de4af9a4024d842317ac9dc82b5871" + integrity sha512-GyWixip07xo7+8oWheDynqdTn96h7W9Nlv6mHTK/jfDXAD+sSucWuqrk5mipLJKxe7vOsM22bisJPyGgLjdoIw== + function-bind@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"