tidyf 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/dist/cli.js +2185 -24
- package/dist/index.js +119 -8
- package/package.json +57 -56
- package/src/cli.ts +9 -1
- package/src/commands/organize.ts +19 -1
- package/src/commands/watch.ts +45 -1
- package/src/lib/config.ts +14 -0
- package/src/lib/opencode.ts +14 -2
- package/src/lib/scanner.ts +103 -1
package/dist/index.js
CHANGED
|
@@ -13671,6 +13671,20 @@ You are an AI assistant that organizes files from a download folder. Analyze eac
|
|
|
13671
13671
|
5. Installer files go to Applications/Installers
|
|
13672
13672
|
6. Compressed files stay as Archives unless clearly part of another category
|
|
13673
13673
|
|
|
13674
|
+
## Existing Folder Preferences
|
|
13675
|
+
|
|
13676
|
+
When existing folders are provided in the prompt:
|
|
13677
|
+
|
|
13678
|
+
1. **STRONGLY PREFER existing folders** - Use them when the file fits the category
|
|
13679
|
+
2. **Match naming conventions** - If "Screenshots" exists, don't create "Screen Captures"
|
|
13680
|
+
3. **Extend existing hierarchy** - OK to create subfolders under existing folders
|
|
13681
|
+
4. **Only create new top-level folders** when no existing category applies
|
|
13682
|
+
|
|
13683
|
+
Examples:
|
|
13684
|
+
- If "Images/Screenshots" exists, use it for screenshot files
|
|
13685
|
+
- If "Documents/Work" exists but no "Reports" subfolder, create "Documents/Work/Reports"
|
|
13686
|
+
- If only "Documents" exists, prefer "Documents/Receipts" over top-level "Receipts"
|
|
13687
|
+
|
|
13674
13688
|
## Output Format
|
|
13675
13689
|
|
|
13676
13690
|
Return JSON with this exact structure:
|
|
@@ -13822,7 +13836,7 @@ function getDefaultRules() {
|
|
|
13822
13836
|
}
|
|
13823
13837
|
// src/lib/scanner.ts
|
|
13824
13838
|
import { readdir, stat, readFile } from "fs/promises";
|
|
13825
|
-
import { join as join2, extname as extname2, basename } from "path";
|
|
13839
|
+
import { join as join2, extname as extname2, basename, relative } from "path";
|
|
13826
13840
|
|
|
13827
13841
|
// node_modules/mime-types/index.js
|
|
13828
13842
|
/*!
|
|
@@ -13871,6 +13885,55 @@ function populateMaps(extensions, types) {
|
|
|
13871
13885
|
}
|
|
13872
13886
|
|
|
13873
13887
|
// src/lib/scanner.ts
|
|
13888
|
+
async function scanFolderStructure(dirPath, options = {}) {
|
|
13889
|
+
const {
|
|
13890
|
+
maxDepth = 3,
|
|
13891
|
+
includeEmpty = false,
|
|
13892
|
+
ignore = [],
|
|
13893
|
+
maxFolders = 100
|
|
13894
|
+
} = options;
|
|
13895
|
+
const folders = [];
|
|
13896
|
+
await scanFoldersInternal(dirPath, dirPath, 0, maxDepth, includeEmpty, ignore, folders, maxFolders);
|
|
13897
|
+
folders.sort((a, b) => {
|
|
13898
|
+
const depthA = a.split("/").length;
|
|
13899
|
+
const depthB = b.split("/").length;
|
|
13900
|
+
if (depthA !== depthB)
|
|
13901
|
+
return depthA - depthB;
|
|
13902
|
+
return a.localeCompare(b);
|
|
13903
|
+
});
|
|
13904
|
+
return folders.slice(0, maxFolders);
|
|
13905
|
+
}
|
|
13906
|
+
async function scanFoldersInternal(basePath, currentPath, currentDepth, maxDepth, includeEmpty, ignore, folders, maxFolders) {
|
|
13907
|
+
if (currentDepth >= maxDepth || folders.length >= maxFolders) {
|
|
13908
|
+
return;
|
|
13909
|
+
}
|
|
13910
|
+
try {
|
|
13911
|
+
const entries = await readdir(currentPath, { withFileTypes: true });
|
|
13912
|
+
for (const entry of entries) {
|
|
13913
|
+
if (folders.length >= maxFolders)
|
|
13914
|
+
break;
|
|
13915
|
+
if (!entry.isDirectory())
|
|
13916
|
+
continue;
|
|
13917
|
+
if (entry.name.startsWith("."))
|
|
13918
|
+
continue;
|
|
13919
|
+
if (shouldIgnore(entry.name, ignore))
|
|
13920
|
+
continue;
|
|
13921
|
+
const fullPath = join2(currentPath, entry.name);
|
|
13922
|
+
const relativePath = relative(basePath, fullPath);
|
|
13923
|
+
if (!includeEmpty) {
|
|
13924
|
+
try {
|
|
13925
|
+
const contents = await readdir(fullPath);
|
|
13926
|
+
if (contents.length === 0)
|
|
13927
|
+
continue;
|
|
13928
|
+
} catch {
|
|
13929
|
+
continue;
|
|
13930
|
+
}
|
|
13931
|
+
}
|
|
13932
|
+
folders.push(relativePath);
|
|
13933
|
+
await scanFoldersInternal(basePath, fullPath, currentDepth + 1, maxDepth, includeEmpty, ignore, folders, maxFolders);
|
|
13934
|
+
}
|
|
13935
|
+
} catch {}
|
|
13936
|
+
}
|
|
13874
13937
|
async function scanDirectory(dirPath, options = {}) {
|
|
13875
13938
|
const {
|
|
13876
13939
|
recursive = false,
|
|
@@ -16578,7 +16641,7 @@ async function checkConflicts(proposal) {
|
|
|
16578
16641
|
};
|
|
16579
16642
|
}
|
|
16580
16643
|
async function analyzeFiles(options) {
|
|
16581
|
-
const { files, targetDir, instructions, model } = options;
|
|
16644
|
+
const { files, targetDir, instructions, model, existingFolders } = options;
|
|
16582
16645
|
if (files.length === 0) {
|
|
16583
16646
|
return {
|
|
16584
16647
|
proposals: [],
|
|
@@ -16592,11 +16655,18 @@ async function analyzeFiles(options) {
|
|
|
16592
16655
|
const config = resolveConfig();
|
|
16593
16656
|
const rulesPrompt = getRulesPrompt();
|
|
16594
16657
|
const filesJson = formatFilesForPrompt(files);
|
|
16658
|
+
const existingFoldersSection = existingFolders?.length ? `
|
|
16659
|
+
EXISTING FOLDERS in target directory:
|
|
16660
|
+
${existingFolders.join(`
|
|
16661
|
+
`)}
|
|
16662
|
+
|
|
16663
|
+
IMPORTANT: Prefer using these existing folders when appropriate. Only create new folders when no suitable existing folder matches the file's category.
|
|
16664
|
+
` : "";
|
|
16595
16665
|
const userPrompt = `
|
|
16596
16666
|
Analyze the following files and organize them according to the rules.
|
|
16597
16667
|
|
|
16598
16668
|
Target directory: ${targetDir}
|
|
16599
|
-
|
|
16669
|
+
${existingFoldersSection}
|
|
16600
16670
|
${instructions ? `Additional instructions: ${instructions}
|
|
16601
16671
|
` : ""}
|
|
16602
16672
|
Files to organize:
|
|
@@ -16989,13 +17059,25 @@ async function organizeCommand(options) {
|
|
|
16989
17059
|
return;
|
|
16990
17060
|
}
|
|
16991
17061
|
M2.info(`Total size: ${formatFileSize(files.reduce((sum, f) => sum + f.size, 0))}`);
|
|
17062
|
+
let existingFolders = [];
|
|
17063
|
+
try {
|
|
17064
|
+
existingFolders = await scanFolderStructure(targetPath, {
|
|
17065
|
+
maxDepth: 3,
|
|
17066
|
+
includeEmpty: false,
|
|
17067
|
+
ignore: config.ignore
|
|
17068
|
+
});
|
|
17069
|
+
if (existingFolders.length > 0) {
|
|
17070
|
+
M2.info(`Found ${import_picocolors4.default.bold(String(existingFolders.length))} existing folders in target`);
|
|
17071
|
+
}
|
|
17072
|
+
} catch {}
|
|
16992
17073
|
spinner.start("Analyzing files with AI...");
|
|
16993
17074
|
let proposal;
|
|
16994
17075
|
try {
|
|
16995
17076
|
proposal = await analyzeFiles({
|
|
16996
17077
|
files,
|
|
16997
17078
|
targetDir: targetPath,
|
|
16998
|
-
model: parseModelString(options.model)
|
|
17079
|
+
model: parseModelString(options.model),
|
|
17080
|
+
existingFolders
|
|
16999
17081
|
});
|
|
17000
17082
|
spinner.stop("Analysis complete");
|
|
17001
17083
|
} catch (error) {
|
|
@@ -17077,7 +17159,8 @@ async function organizeCommand(options) {
|
|
|
17077
17159
|
files,
|
|
17078
17160
|
targetDir: targetPath,
|
|
17079
17161
|
instructions: newInstructions || undefined,
|
|
17080
|
-
model: parseModelString(options.model)
|
|
17162
|
+
model: parseModelString(options.model),
|
|
17163
|
+
existingFolders
|
|
17081
17164
|
});
|
|
17082
17165
|
spinner.stop("Analysis complete");
|
|
17083
17166
|
displayAllProposals(proposal);
|
|
@@ -17105,7 +17188,8 @@ async function organizeCommand(options) {
|
|
|
17105
17188
|
files,
|
|
17106
17189
|
targetDir: targetPath,
|
|
17107
17190
|
instructions: newInstructions || undefined,
|
|
17108
|
-
model: pickedModel
|
|
17191
|
+
model: pickedModel,
|
|
17192
|
+
existingFolders
|
|
17109
17193
|
});
|
|
17110
17194
|
spinner.stop("Analysis complete");
|
|
17111
17195
|
displayAllProposals(proposal);
|
|
@@ -17123,6 +17207,26 @@ async function organizeCommand(options) {
|
|
|
17123
17207
|
}
|
|
17124
17208
|
// src/commands/watch.ts
|
|
17125
17209
|
var import_picocolors5 = __toESM(require_picocolors(), 1);
|
|
17210
|
+
var folderCache = [];
|
|
17211
|
+
var lastFolderScan = 0;
|
|
17212
|
+
var FOLDER_CACHE_TTL = 5 * 60 * 1000;
|
|
17213
|
+
async function getExistingFolders(targetPath, ignore = []) {
|
|
17214
|
+
const now = Date.now();
|
|
17215
|
+
if (now - lastFolderScan > FOLDER_CACHE_TTL) {
|
|
17216
|
+
try {
|
|
17217
|
+
folderCache = await scanFolderStructure(targetPath, {
|
|
17218
|
+
maxDepth: 3,
|
|
17219
|
+
includeEmpty: false,
|
|
17220
|
+
ignore
|
|
17221
|
+
});
|
|
17222
|
+
lastFolderScan = now;
|
|
17223
|
+
} catch {}
|
|
17224
|
+
}
|
|
17225
|
+
return folderCache;
|
|
17226
|
+
}
|
|
17227
|
+
function invalidateFolderCache() {
|
|
17228
|
+
lastFolderScan = 0;
|
|
17229
|
+
}
|
|
17126
17230
|
function displayProposalBrief(proposals) {
|
|
17127
17231
|
for (const prop of proposals) {
|
|
17128
17232
|
const icon = getFileIcon(prop.file.name);
|
|
@@ -17143,6 +17247,9 @@ async function executeProposalsSilent(proposals) {
|
|
|
17143
17247
|
failed++;
|
|
17144
17248
|
}
|
|
17145
17249
|
}
|
|
17250
|
+
if (success > 0) {
|
|
17251
|
+
invalidateFolderCache();
|
|
17252
|
+
}
|
|
17146
17253
|
return { success, failed };
|
|
17147
17254
|
}
|
|
17148
17255
|
async function interactiveReview(proposal) {
|
|
@@ -17233,10 +17340,12 @@ ${import_picocolors5.default.green("+")} ${newFilePaths.length} new file(s) dete
|
|
|
17233
17340
|
s.start("Analyzing files with AI...");
|
|
17234
17341
|
let proposal;
|
|
17235
17342
|
try {
|
|
17343
|
+
const existingFolders = await getExistingFolders(targetPath, config.ignore);
|
|
17236
17344
|
proposal = await analyzeFiles({
|
|
17237
17345
|
files,
|
|
17238
17346
|
targetDir: targetPath,
|
|
17239
|
-
model: parseModelString(options.model)
|
|
17347
|
+
model: parseModelString(options.model),
|
|
17348
|
+
existingFolders
|
|
17240
17349
|
});
|
|
17241
17350
|
s.stop("Analysis complete");
|
|
17242
17351
|
} catch (error) {
|
|
@@ -17281,10 +17390,12 @@ ${import_picocolors5.default.green("+")} ${newFilePaths.length} new file(s) dete
|
|
|
17281
17390
|
const s = Y2();
|
|
17282
17391
|
s.start("Analyzing queued files...");
|
|
17283
17392
|
try {
|
|
17393
|
+
const existingFolders = await getExistingFolders(targetPath, config.ignore);
|
|
17284
17394
|
const proposal = await analyzeFiles({
|
|
17285
17395
|
files: reviewQueue,
|
|
17286
17396
|
targetDir: targetPath,
|
|
17287
|
-
model: parseModelString(options.model)
|
|
17397
|
+
model: parseModelString(options.model),
|
|
17398
|
+
existingFolders
|
|
17288
17399
|
});
|
|
17289
17400
|
s.stop("Analysis complete");
|
|
17290
17401
|
await interactiveReview(proposal);
|
package/package.json
CHANGED
|
@@ -1,58 +1,59 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
2
|
+
"name": "tidyf",
|
|
3
|
+
"version": "1.0.3",
|
|
4
|
+
"description": "AI-powered file organizer using opencode.ai",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"tidyf": "./dist/cli.js",
|
|
9
|
+
"td": "./dist/cli.js",
|
|
10
|
+
"tidyfiles": "./dist/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/yafyx/tidyf.git"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public",
|
|
18
|
+
"registry": "https://registry.npmjs.org/"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"start": "node dist/cli.js",
|
|
22
|
+
"dev": "bun --watch src/cli.ts",
|
|
23
|
+
"build": "bun build src/cli.ts --outdir=dist --target=node --format=esm && bun build src/index.ts --outdir=dist --target=node --format=esm",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"prepublishOnly": "bun run build"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18.0.0"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"file-organizer",
|
|
32
|
+
"ai",
|
|
33
|
+
"opencode",
|
|
34
|
+
"cli",
|
|
35
|
+
"downloads",
|
|
36
|
+
"tidy",
|
|
37
|
+
"automation"
|
|
38
|
+
],
|
|
39
|
+
"author": "",
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"files": [
|
|
42
|
+
"dist",
|
|
43
|
+
"src"
|
|
44
|
+
],
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@clack/prompts": "^0.11.0",
|
|
47
|
+
"@opencode-ai/sdk": "^1.0.155",
|
|
48
|
+
"chokidar": "^3.5.3",
|
|
49
|
+
"commander": "^12.1.0",
|
|
50
|
+
"mime-types": "^2.1.35",
|
|
51
|
+
"picocolors": "^1.1.1",
|
|
52
|
+
"simple-update-notifier": "^2.0.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/bun": "latest",
|
|
56
|
+
"@types/mime-types": "^2.1.4",
|
|
57
|
+
"typescript": "^5.7.2"
|
|
58
|
+
}
|
|
58
59
|
}
|
package/src/cli.ts
CHANGED
|
@@ -5,16 +5,24 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { Command } from "commander";
|
|
8
|
+
import updateNotifier from "simple-update-notifier";
|
|
9
|
+
import { createRequire } from "module";
|
|
8
10
|
import { configCommand } from "./commands/config.ts";
|
|
9
11
|
import { organizeCommand } from "./commands/organize.ts";
|
|
10
12
|
import { watchCommand } from "./commands/watch.ts";
|
|
11
13
|
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const pkg = require("../package.json");
|
|
16
|
+
|
|
17
|
+
// Check for updates (non-blocking, cached for 1 day)
|
|
18
|
+
updateNotifier({ pkg });
|
|
19
|
+
|
|
12
20
|
const program = new Command();
|
|
13
21
|
|
|
14
22
|
program
|
|
15
23
|
.name("tidyf")
|
|
16
24
|
.description("AI-powered file organizer using opencode.ai")
|
|
17
|
-
.version(
|
|
25
|
+
.version(pkg.version);
|
|
18
26
|
|
|
19
27
|
// Default command - organize files
|
|
20
28
|
program
|
package/src/commands/organize.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
resolveConfig,
|
|
20
20
|
} from "../lib/config.ts";
|
|
21
21
|
import { analyzeFiles, cleanup } from "../lib/opencode.ts";
|
|
22
|
-
import { scanDirectory } from "../lib/scanner.ts";
|
|
22
|
+
import { scanDirectory, scanFolderStructure } from "../lib/scanner.ts";
|
|
23
23
|
import type {
|
|
24
24
|
FileMoveProposal,
|
|
25
25
|
MoveResult,
|
|
@@ -276,6 +276,21 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
|
|
|
276
276
|
`Total size: ${formatFileSize(files.reduce((sum, f) => sum + f.size, 0))}`,
|
|
277
277
|
);
|
|
278
278
|
|
|
279
|
+
// Scan existing folder structure in target directory
|
|
280
|
+
let existingFolders: string[] = [];
|
|
281
|
+
try {
|
|
282
|
+
existingFolders = await scanFolderStructure(targetPath, {
|
|
283
|
+
maxDepth: 3,
|
|
284
|
+
includeEmpty: false,
|
|
285
|
+
ignore: config.ignore,
|
|
286
|
+
});
|
|
287
|
+
if (existingFolders.length > 0) {
|
|
288
|
+
p.log.info(`Found ${color.bold(String(existingFolders.length))} existing folders in target`);
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
// Target directory might not exist yet - OK
|
|
292
|
+
}
|
|
293
|
+
|
|
279
294
|
// Analyze with AI
|
|
280
295
|
spinner.start("Analyzing files with AI...");
|
|
281
296
|
|
|
@@ -285,6 +300,7 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
|
|
|
285
300
|
files,
|
|
286
301
|
targetDir: targetPath,
|
|
287
302
|
model: parseModelString(options.model),
|
|
303
|
+
existingFolders,
|
|
288
304
|
});
|
|
289
305
|
spinner.stop("Analysis complete");
|
|
290
306
|
} catch (error: any) {
|
|
@@ -383,6 +399,7 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
|
|
|
383
399
|
targetDir: targetPath,
|
|
384
400
|
instructions: newInstructions || undefined,
|
|
385
401
|
model: parseModelString(options.model),
|
|
402
|
+
existingFolders,
|
|
386
403
|
});
|
|
387
404
|
spinner.stop("Analysis complete");
|
|
388
405
|
displayAllProposals(proposal);
|
|
@@ -416,6 +433,7 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
|
|
|
416
433
|
targetDir: targetPath,
|
|
417
434
|
instructions: newInstructions || undefined,
|
|
418
435
|
model: pickedModel,
|
|
436
|
+
existingFolders,
|
|
419
437
|
});
|
|
420
438
|
spinner.stop("Analysis complete");
|
|
421
439
|
displayAllProposals(proposal);
|
package/src/commands/watch.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
resolveConfig,
|
|
12
12
|
} from "../lib/config.ts";
|
|
13
13
|
import { analyzeFiles, cleanup } from "../lib/opencode.ts";
|
|
14
|
-
import { getFileMetadata } from "../lib/scanner.ts";
|
|
14
|
+
import { getFileMetadata, scanFolderStructure } from "../lib/scanner.ts";
|
|
15
15
|
import { createWatcher, type FileWatcher } from "../lib/watcher.ts";
|
|
16
16
|
import type {
|
|
17
17
|
FileMetadata,
|
|
@@ -23,6 +23,41 @@ import type {
|
|
|
23
23
|
import { formatFileSize, moveFile } from "../utils/files.ts";
|
|
24
24
|
import { getCategoryIcon, getFileIcon } from "../utils/icons.ts";
|
|
25
25
|
|
|
26
|
+
// Folder cache for watch mode to avoid re-scanning on every file event
|
|
27
|
+
let folderCache: string[] = [];
|
|
28
|
+
let lastFolderScan = 0;
|
|
29
|
+
const FOLDER_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get existing folders with caching
|
|
33
|
+
*/
|
|
34
|
+
async function getExistingFolders(
|
|
35
|
+
targetPath: string,
|
|
36
|
+
ignore: string[] = [],
|
|
37
|
+
): Promise<string[]> {
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
if (now - lastFolderScan > FOLDER_CACHE_TTL) {
|
|
40
|
+
try {
|
|
41
|
+
folderCache = await scanFolderStructure(targetPath, {
|
|
42
|
+
maxDepth: 3,
|
|
43
|
+
includeEmpty: false,
|
|
44
|
+
ignore,
|
|
45
|
+
});
|
|
46
|
+
lastFolderScan = now;
|
|
47
|
+
} catch {
|
|
48
|
+
// Target directory might not exist yet - use cached or empty
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return folderCache;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Invalidate folder cache (call after successful file moves)
|
|
56
|
+
*/
|
|
57
|
+
function invalidateFolderCache(): void {
|
|
58
|
+
lastFolderScan = 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
26
61
|
/**
|
|
27
62
|
* Display a proposal briefly for watch mode
|
|
28
63
|
*/
|
|
@@ -57,6 +92,11 @@ async function executeProposalsSilent(
|
|
|
57
92
|
}
|
|
58
93
|
}
|
|
59
94
|
|
|
95
|
+
// Invalidate folder cache since new folders may have been created
|
|
96
|
+
if (success > 0) {
|
|
97
|
+
invalidateFolderCache();
|
|
98
|
+
}
|
|
99
|
+
|
|
60
100
|
return { success, failed };
|
|
61
101
|
}
|
|
62
102
|
|
|
@@ -207,10 +247,12 @@ export async function watchCommand(options: WatchOptions): Promise<void> {
|
|
|
207
247
|
|
|
208
248
|
let proposal: OrganizationProposal;
|
|
209
249
|
try {
|
|
250
|
+
const existingFolders = await getExistingFolders(targetPath, config.ignore);
|
|
210
251
|
proposal = await analyzeFiles({
|
|
211
252
|
files,
|
|
212
253
|
targetDir: targetPath,
|
|
213
254
|
model: parseModelString(options.model),
|
|
255
|
+
existingFolders,
|
|
214
256
|
});
|
|
215
257
|
s.stop("Analysis complete");
|
|
216
258
|
} catch (error: any) {
|
|
@@ -276,10 +318,12 @@ export async function watchCommand(options: WatchOptions): Promise<void> {
|
|
|
276
318
|
s.start("Analyzing queued files...");
|
|
277
319
|
|
|
278
320
|
try {
|
|
321
|
+
const existingFolders = await getExistingFolders(targetPath, config.ignore);
|
|
279
322
|
const proposal = await analyzeFiles({
|
|
280
323
|
files: reviewQueue,
|
|
281
324
|
targetDir: targetPath,
|
|
282
325
|
model: parseModelString(options.model),
|
|
326
|
+
existingFolders,
|
|
283
327
|
});
|
|
284
328
|
s.stop("Analysis complete");
|
|
285
329
|
|
package/src/lib/config.ts
CHANGED
|
@@ -94,6 +94,20 @@ You are an AI assistant that organizes files from a download folder. Analyze eac
|
|
|
94
94
|
5. Installer files go to Applications/Installers
|
|
95
95
|
6. Compressed files stay as Archives unless clearly part of another category
|
|
96
96
|
|
|
97
|
+
## Existing Folder Preferences
|
|
98
|
+
|
|
99
|
+
When existing folders are provided in the prompt:
|
|
100
|
+
|
|
101
|
+
1. **STRONGLY PREFER existing folders** - Use them when the file fits the category
|
|
102
|
+
2. **Match naming conventions** - If "Screenshots" exists, don't create "Screen Captures"
|
|
103
|
+
3. **Extend existing hierarchy** - OK to create subfolders under existing folders
|
|
104
|
+
4. **Only create new top-level folders** when no existing category applies
|
|
105
|
+
|
|
106
|
+
Examples:
|
|
107
|
+
- If "Images/Screenshots" exists, use it for screenshot files
|
|
108
|
+
- If "Documents/Work" exists but no "Reports" subfolder, create "Documents/Work/Reports"
|
|
109
|
+
- If only "Documents" exists, prefer "Documents/Receipts" over top-level "Receipts"
|
|
110
|
+
|
|
97
111
|
## Output Format
|
|
98
112
|
|
|
99
113
|
Return JSON with this exact structure:
|
package/src/lib/opencode.ts
CHANGED
|
@@ -169,6 +169,8 @@ export interface AnalyzeFilesOptions {
|
|
|
169
169
|
instructions?: string;
|
|
170
170
|
/** Model override */
|
|
171
171
|
model?: ModelSelection;
|
|
172
|
+
/** Existing folders in target directory for consistency */
|
|
173
|
+
existingFolders?: string[];
|
|
172
174
|
}
|
|
173
175
|
|
|
174
176
|
/**
|
|
@@ -311,7 +313,7 @@ export async function checkConflicts(
|
|
|
311
313
|
export async function analyzeFiles(
|
|
312
314
|
options: AnalyzeFilesOptions,
|
|
313
315
|
): Promise<OrganizationProposal> {
|
|
314
|
-
const { files, targetDir, instructions, model } = options;
|
|
316
|
+
const { files, targetDir, instructions, model, existingFolders } = options;
|
|
315
317
|
|
|
316
318
|
if (files.length === 0) {
|
|
317
319
|
return {
|
|
@@ -332,11 +334,21 @@ export async function analyzeFiles(
|
|
|
332
334
|
// Build the prompt
|
|
333
335
|
const filesJson = formatFilesForPrompt(files);
|
|
334
336
|
|
|
337
|
+
// Build existing folders section if available
|
|
338
|
+
const existingFoldersSection = existingFolders?.length
|
|
339
|
+
? `
|
|
340
|
+
EXISTING FOLDERS in target directory:
|
|
341
|
+
${existingFolders.join("\n")}
|
|
342
|
+
|
|
343
|
+
IMPORTANT: Prefer using these existing folders when appropriate. Only create new folders when no suitable existing folder matches the file's category.
|
|
344
|
+
`
|
|
345
|
+
: "";
|
|
346
|
+
|
|
335
347
|
const userPrompt = `
|
|
336
348
|
Analyze the following files and organize them according to the rules.
|
|
337
349
|
|
|
338
350
|
Target directory: ${targetDir}
|
|
339
|
-
|
|
351
|
+
${existingFoldersSection}
|
|
340
352
|
${instructions ? `Additional instructions: ${instructions}\n` : ""}
|
|
341
353
|
Files to organize:
|
|
342
354
|
${filesJson}
|
package/src/lib/scanner.ts
CHANGED
|
@@ -3,12 +3,114 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { readdir, stat, readFile } from "fs/promises";
|
|
6
|
-
import { join, extname, basename } from "path";
|
|
6
|
+
import { join, extname, basename, relative } from "path";
|
|
7
7
|
import { lookup as lookupMimeType } from "mime-types";
|
|
8
8
|
import type { FileMetadata } from "../types/organizer.ts";
|
|
9
9
|
import { shouldIgnore } from "./config.ts";
|
|
10
10
|
import { isDirectory, isFile } from "../utils/files.ts";
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Options for scanning folder structure
|
|
14
|
+
*/
|
|
15
|
+
export interface ScanFolderOptions {
|
|
16
|
+
/** Maximum depth to scan (default: 3) */
|
|
17
|
+
maxDepth?: number;
|
|
18
|
+
/** Include empty folders (default: false) */
|
|
19
|
+
includeEmpty?: boolean;
|
|
20
|
+
/** Patterns to ignore */
|
|
21
|
+
ignore?: string[];
|
|
22
|
+
/** Maximum number of folders to return (default: 100) */
|
|
23
|
+
maxFolders?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Scan a directory and return existing folder structure as relative paths
|
|
28
|
+
* Used to inform AI about existing organization
|
|
29
|
+
*/
|
|
30
|
+
export async function scanFolderStructure(
|
|
31
|
+
dirPath: string,
|
|
32
|
+
options: ScanFolderOptions = {}
|
|
33
|
+
): Promise<string[]> {
|
|
34
|
+
const {
|
|
35
|
+
maxDepth = 3,
|
|
36
|
+
includeEmpty = false,
|
|
37
|
+
ignore = [],
|
|
38
|
+
maxFolders = 100,
|
|
39
|
+
} = options;
|
|
40
|
+
|
|
41
|
+
const folders: string[] = [];
|
|
42
|
+
|
|
43
|
+
await scanFoldersInternal(dirPath, dirPath, 0, maxDepth, includeEmpty, ignore, folders, maxFolders);
|
|
44
|
+
|
|
45
|
+
// Sort by depth (shallower first) then alphabetically
|
|
46
|
+
folders.sort((a, b) => {
|
|
47
|
+
const depthA = a.split("/").length;
|
|
48
|
+
const depthB = b.split("/").length;
|
|
49
|
+
if (depthA !== depthB) return depthA - depthB;
|
|
50
|
+
return a.localeCompare(b);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return folders.slice(0, maxFolders);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function scanFoldersInternal(
|
|
57
|
+
basePath: string,
|
|
58
|
+
currentPath: string,
|
|
59
|
+
currentDepth: number,
|
|
60
|
+
maxDepth: number,
|
|
61
|
+
includeEmpty: boolean,
|
|
62
|
+
ignore: string[],
|
|
63
|
+
folders: string[],
|
|
64
|
+
maxFolders: number
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
if (currentDepth >= maxDepth || folders.length >= maxFolders) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const entries = await readdir(currentPath, { withFileTypes: true });
|
|
72
|
+
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (folders.length >= maxFolders) break;
|
|
75
|
+
|
|
76
|
+
if (!entry.isDirectory()) continue;
|
|
77
|
+
|
|
78
|
+
// Skip hidden folders and ignored patterns
|
|
79
|
+
if (entry.name.startsWith(".")) continue;
|
|
80
|
+
if (shouldIgnore(entry.name, ignore)) continue;
|
|
81
|
+
|
|
82
|
+
const fullPath = join(currentPath, entry.name);
|
|
83
|
+
const relativePath = relative(basePath, fullPath);
|
|
84
|
+
|
|
85
|
+
// Check if folder has contents (if we care about empty folders)
|
|
86
|
+
if (!includeEmpty) {
|
|
87
|
+
try {
|
|
88
|
+
const contents = await readdir(fullPath);
|
|
89
|
+
if (contents.length === 0) continue;
|
|
90
|
+
} catch {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
folders.push(relativePath);
|
|
96
|
+
|
|
97
|
+
// Recursively scan subdirectories
|
|
98
|
+
await scanFoldersInternal(
|
|
99
|
+
basePath,
|
|
100
|
+
fullPath,
|
|
101
|
+
currentDepth + 1,
|
|
102
|
+
maxDepth,
|
|
103
|
+
includeEmpty,
|
|
104
|
+
ignore,
|
|
105
|
+
folders,
|
|
106
|
+
maxFolders
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// Silently skip directories we can't read
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
12
114
|
/**
|
|
13
115
|
* Options for scanning a directory
|
|
14
116
|
*/
|