parse the DCF structure under DCIM (closes #2)
A card is now read by the naming rules in CIPA DC-009-2010, not by looking for media extensions anywhere on the volume. Store.Directories holds the DCF directories and their objects, with directory and file numbers. Store.NonConforming lists everything else under DCIM. Media under PRIVATE/M4ROOT stays, with no DCF numbers. Media anywhere else is no longer part of the store. Model: opus-5-5
This commit is contained in:
1 parent
445424ba3e
commit
34cc2c30c1
5 files changed
+801
-49
No files matched your search
+33
-39
@@ -1,6 +1,8 @@
|
||||
// Package dcf reads DCF cards: memory cards laid out to the "Design rule for
|
||||
// Camera File system" specification (JEITA CP-3461). It finds the cards among
|
||||
// the mounted filesystems and lists the images and videos on each one.
|
||||
// the mounted filesystems and parses the DCF directories and files on each one
|
||||
// into their directory numbers and file numbers. It lists the images and videos
|
||||
// on each card, and the names under DCIM that break the DCF naming rules.
|
||||
package dcf
|
||||
|
||||
import (
|
||||
@@ -20,8 +22,28 @@ import (
|
||||
// images and videos in the root of a mounted filesystem.
|
||||
type Store struct {
|
||||
RootDirectory string
|
||||
Images []*Image
|
||||
Videos []*Video
|
||||
// Directories holds the DCF directories under DCIM.
|
||||
Directories []*Directory
|
||||
// Images and Videos hold the images and videos among the objects in
|
||||
// Directories, and those under PRIVATE/M4ROOT.
|
||||
Images []*Image
|
||||
Videos []*Video
|
||||
// NonConforming holds the path, relative to RootDirectory, of every file
|
||||
// and directory under DCIM that breaks the DCF naming rules. It includes
|
||||
// everything inside a directory that breaks them.
|
||||
NonConforming []string
|
||||
}
|
||||
|
||||
// Directory represents a DCF directory: a directory directly under DCIM whose
|
||||
// name follows the DCF naming rules.
|
||||
type Directory struct {
|
||||
// Number is the directory number, from 100 to 999.
|
||||
Number int
|
||||
// Name is the name of the directory as it appears on the card.
|
||||
Name string
|
||||
// Objects holds every DCF file in the directory, including those that are
|
||||
// neither images nor videos.
|
||||
Objects []*Object
|
||||
}
|
||||
|
||||
// Object represents a file in a DCF store. It is embedded in Image and Video
|
||||
@@ -31,6 +53,10 @@ type Object struct {
|
||||
Path string
|
||||
Size int64
|
||||
Extension string
|
||||
// DirectoryNumber and FileNumber are the DCF numbers of a file in a DCF
|
||||
// directory. Both are 0 for a file under PRIVATE/M4ROOT.
|
||||
DirectoryNumber int
|
||||
FileNumber int
|
||||
}
|
||||
|
||||
// Image represents an image file on a DCF store.
|
||||
@@ -140,8 +166,8 @@ func pathToSHA256(path string) (string, error) {
|
||||
// any mounted filesystems. The integer argument is the number of DCF stores to
|
||||
// return. If the argument is 0, all DCF stores are returned. It does not mount
|
||||
// filesystems or search outside of filesystem root directories. It does,
|
||||
// however, walk the filesystems to find images and videos and populate the
|
||||
// returned Stores.
|
||||
// however, read the DCIM and PRIVATE/M4ROOT directories of each filesystem to
|
||||
// populate the returned Stores.
|
||||
func GetDCFStores(requestedCount int) (*[]Store, error) {
|
||||
dcfStorePaths, err := findDCFMountPoints(requestedCount)
|
||||
if err != nil {
|
||||
@@ -151,44 +177,12 @@ func GetDCFStores(requestedCount int) (*[]Store, error) {
|
||||
dcfStores := []Store{}
|
||||
|
||||
for _, dcfStorePath := range dcfStorePaths {
|
||||
dcfStore := Store{
|
||||
RootDirectory: dcfStorePath,
|
||||
Images: make([]*Image, 0),
|
||||
Videos: make([]*Video, 0),
|
||||
}
|
||||
|
||||
addFile := func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !info.IsDir() && isImageFile(path) {
|
||||
image := Image{}
|
||||
image.Path = strings.TrimPrefix(path, dcfStorePath+"/")
|
||||
image.Size = info.Size()
|
||||
image.Extension = filepath.Ext(path)
|
||||
image.DCFStoreRoot = dcfStorePath
|
||||
dcfStore.Images = append(dcfStore.Images, &image)
|
||||
}
|
||||
|
||||
if !info.IsDir() && isVideoFile(path) {
|
||||
video := Video{}
|
||||
video.Path = strings.TrimPrefix(path, dcfStorePath+"/")
|
||||
video.Size = info.Size()
|
||||
video.Extension = filepath.Ext(path)
|
||||
video.DCFStoreRoot = dcfStorePath
|
||||
dcfStore.Videos = append(dcfStore.Videos, &video)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
err := filepath.Walk(dcfStorePath, addFile)
|
||||
dcfStore, err := parseStore(os.DirFS(dcfStorePath), dcfStorePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dcfStores = append(dcfStores, dcfStore)
|
||||
dcfStores = append(dcfStores, *dcfStore)
|
||||
}
|
||||
|
||||
return &dcfStores, nil
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ func filterDCFMountPoints(mountpoints []string, requestedCount int) []string {
|
||||
// isDCFMountPoint reports whether the root of mountpoint holds a DCIM or
|
||||
// PRIVATE/M4ROOT directory, which is how we detect DCF stores.
|
||||
func isDCFMountPoint(mountpoint string) bool {
|
||||
for _, directory := range []string{"DCIM", "PRIVATE/M4ROOT"} {
|
||||
for _, directory := range []string{dcimDirectory, m4RootDirectory} {
|
||||
path := filepath.Join(mountpoint, directory)
|
||||
|
||||
info, err := os.Stat(path)
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
package dcf
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// The two directories at the root of a card that hold its files.
|
||||
const (
|
||||
dcimDirectory = "DCIM"
|
||||
m4RootDirectory = "PRIVATE/M4ROOT"
|
||||
)
|
||||
|
||||
// The naming rules of the DCF specification, CIPA DC-009-2010, sections 2.3,
|
||||
// 4.2.2 and 4.3.1. Names use A-Z, 0-9 and "_", and lower-case letters count as
|
||||
// upper-case. Each pattern captures the number in the name.
|
||||
var (
|
||||
dcfDirectoryName = regexp.MustCompile(`^([1-9][0-9]{2})[0-9A-Za-z_]{5}$`)
|
||||
dcfFileName = regexp.MustCompile(`^[0-9A-Za-z_]{4}([0-9]{4})\.[0-9A-Za-z_]{3}$`)
|
||||
)
|
||||
|
||||
// parseStore reads one card from fsys, which is rooted at the card's mount
|
||||
// point. The root argument is the path of that mount point. Only DCIM and
|
||||
// PRIVATE/M4ROOT are read. A card can lack either one.
|
||||
func parseStore(fsys fs.FS, root string) (*Store, error) {
|
||||
store := &Store{RootDirectory: root}
|
||||
|
||||
hasDCIM, err := hasDirectory(fsys, dcimDirectory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if hasDCIM {
|
||||
err = parseDCIM(fsys, store)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
hasM4Root, err := hasDirectory(fsys, m4RootDirectory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if hasM4Root {
|
||||
err = parseM4Root(fsys, store)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// hasDirectory reports whether the card holds the named directory. Only a
|
||||
// missing directory counts as false: any other failure to look is an error. A
|
||||
// plain file named PRIVATE makes a lookup of PRIVATE/M4ROOT fail with ENOTDIR.
|
||||
func hasDirectory(fsys fs.FS, name string) (bool, error) {
|
||||
info, err := fs.Stat(fsys, name)
|
||||
if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return info.IsDir(), nil
|
||||
}
|
||||
|
||||
// parseDCIM adds the DCF directories under DCIM to store. Everything else there
|
||||
// is not conforming. That includes directories that share a directory number,
|
||||
// which section 7.1.2 of the specification treats as non-DCF directories.
|
||||
func parseDCIM(fsys fs.FS, store *Store) error {
|
||||
entries, err := fs.ReadDir(fsys, dcimDirectory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
directoriesWithNumber := map[int]int{}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
directoriesWithNumber[parseNumber(dcfDirectoryName, entry.Name())]++
|
||||
}
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
number := parseNumber(dcfDirectoryName, entry.Name())
|
||||
|
||||
if entry.IsDir() && number != 0 && directoriesWithNumber[number] == 1 {
|
||||
err = parseDirectory(fsys, store, entry.Name(), number)
|
||||
} else {
|
||||
entryPath := path.Join(dcimDirectory, entry.Name())
|
||||
err = addNonConforming(fsys, store, entryPath)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseDirectory adds one DCF directory and the DCF files directly inside it to
|
||||
// store. Everything else there is not conforming. That includes JPG files that
|
||||
// share a file number, which section 7.2.2 of the specification says must not
|
||||
// show DCF numbers. Other files that share a file number do conform.
|
||||
func parseDirectory(fsys fs.FS, store *Store, name string, number int) error {
|
||||
directoryPath := path.Join(dcimDirectory, name)
|
||||
|
||||
entries, err := fs.ReadDir(fsys, directoryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
jpgsWithNumber := map[int]int{}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && isJPG(entry.Name()) {
|
||||
jpgsWithNumber[parseNumber(dcfFileName, entry.Name())]++
|
||||
}
|
||||
}
|
||||
|
||||
directory := &Directory{Number: number, Name: name}
|
||||
|
||||
for _, entry := range entries {
|
||||
entryPath := path.Join(directoryPath, entry.Name())
|
||||
fileNumber := parseNumber(dcfFileName, entry.Name())
|
||||
sharedJPG := isJPG(entry.Name()) && jpgsWithNumber[fileNumber] > 1
|
||||
|
||||
if entry.IsDir() || fileNumber == 0 || sharedJPG {
|
||||
err = addNonConforming(fsys, store, entryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
object, err := newObject(store, entryPath, entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
object.DirectoryNumber = number
|
||||
object.FileNumber = fileNumber
|
||||
directory.Objects = append(directory.Objects, &object)
|
||||
|
||||
addMedia(store, object)
|
||||
}
|
||||
|
||||
store.Directories = append(store.Directories, directory)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseM4Root adds the images and videos under PRIVATE/M4ROOT to store. The DCF
|
||||
// naming rules do not apply there, so these files have no DCF numbers.
|
||||
func parseM4Root(fsys fs.FS, store *Store) error {
|
||||
return fs.WalkDir(fsys, m4RootDirectory,
|
||||
func(filePath string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
object, err := newObject(store, filePath, entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
addMedia(store, object)
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// addNonConforming lists the named file or directory on store as not
|
||||
// conforming, along with everything inside it.
|
||||
func addNonConforming(fsys fs.FS, store *Store, name string) error {
|
||||
return fs.WalkDir(fsys, name,
|
||||
func(entryPath string, _ fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store.NonConforming = append(store.NonConforming, entryPath)
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// newObject returns the Object for the file at filePath, with no DCF numbers.
|
||||
func newObject(store *Store, filePath string, entry fs.DirEntry) (Object, error) {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return Object{}, err
|
||||
}
|
||||
|
||||
return Object{
|
||||
DCFStoreRoot: store.RootDirectory,
|
||||
Path: filePath,
|
||||
Size: info.Size(),
|
||||
Extension: path.Ext(filePath),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// addMedia adds object to the images or videos of store. A file with any other
|
||||
// extension is neither, and is not added.
|
||||
func addMedia(store *Store, object Object) {
|
||||
if isImageFile(object.Path) {
|
||||
store.Images = append(store.Images, &Image{Object: object})
|
||||
}
|
||||
|
||||
if isVideoFile(object.Path) {
|
||||
store.Videos = append(store.Videos, &Video{Object: object})
|
||||
}
|
||||
}
|
||||
|
||||
// parseNumber returns the number that pattern captures in name. It returns 0
|
||||
// when name does not match. No DCF number is 0, so this also rejects the file
|
||||
// number 0000, which the specification forbids.
|
||||
func parseNumber(pattern *regexp.Regexp, name string) int {
|
||||
match := pattern.FindStringSubmatch(name)
|
||||
if match == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
number, err := strconv.Atoi(match[1])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return number
|
||||
}
|
||||
|
||||
func isJPG(name string) bool {
|
||||
return strings.EqualFold(path.Ext(name), ".JPG")
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
package dcf
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
const testRoot = "/mnt/card"
|
||||
|
||||
// newCard returns a card holding an empty file at each of the given paths.
|
||||
func newCard(paths ...string) fstest.MapFS {
|
||||
card := fstest.MapFS{}
|
||||
|
||||
for _, filePath := range paths {
|
||||
card[filePath] = &fstest.MapFile{}
|
||||
}
|
||||
|
||||
return card
|
||||
}
|
||||
|
||||
func mustParseStore(t *testing.T, fsys fs.FS) *Store {
|
||||
t.Helper()
|
||||
|
||||
store, err := parseStore(fsys, testRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("parseStore: %v", err)
|
||||
}
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func imagePaths(store *Store) []string {
|
||||
paths := make([]string, 0, len(store.Images))
|
||||
|
||||
for _, image := range store.Images {
|
||||
paths = append(paths, image.Path)
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
func videoPaths(store *Store) []string {
|
||||
paths := make([]string, 0, len(store.Videos))
|
||||
|
||||
for _, video := range store.Videos {
|
||||
paths = append(paths, video.Path)
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
func objectPaths(directory *Directory) []string {
|
||||
paths := make([]string, 0, len(directory.Objects))
|
||||
|
||||
for _, object := range directory.Objects {
|
||||
paths = append(paths, object.Path)
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
func directoryNumbers(store *Store) []int {
|
||||
numbers := make([]int, 0, len(store.Directories))
|
||||
|
||||
for _, directory := range store.Directories {
|
||||
numbers = append(numbers, directory.Number)
|
||||
}
|
||||
|
||||
return numbers
|
||||
}
|
||||
|
||||
func assertPaths(t *testing.T, what string, got []string, want ...string) {
|
||||
t.Helper()
|
||||
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("%s = %q, want %q", what, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStorePutsEachFileWhereItBelongs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
card := fstest.MapFS{
|
||||
"DCIM/100MSDCF/ABCD0001.ARW": {Data: make([]byte, 2000)},
|
||||
"DCIM/100MSDCF/readme.txt": {Data: make([]byte, 7)},
|
||||
"DCIM/101CANON/MVI_0002.MP4": {Data: make([]byte, 9000)},
|
||||
"DCIM/101CANON/MVI_0002.THM": {Data: make([]byte, 100)},
|
||||
"DCIM/MISC/DSC00003.JPG": {Data: make([]byte, 7)},
|
||||
"DCIM/DSC00004.JPG": {Data: make([]byte, 7)},
|
||||
"PRIVATE/M4ROOT/CLIP/C0002.MP4": {Data: make([]byte, 400)},
|
||||
"PRIVATE/M4ROOT/CLIP/C0002M01.XML": {Data: make([]byte, 7)},
|
||||
"PRIVATE/OTHER/C0003.MP4": {Data: make([]byte, 7)},
|
||||
"BACKUP/DSC00005.JPG": {Data: make([]byte, 7)},
|
||||
"DSC00006.JPG": {Data: make([]byte, 7)},
|
||||
}
|
||||
|
||||
store := mustParseStore(t, card)
|
||||
|
||||
arw := Object{
|
||||
DCFStoreRoot: testRoot,
|
||||
Path: "DCIM/100MSDCF/ABCD0001.ARW",
|
||||
Size: 2000,
|
||||
Extension: ".ARW",
|
||||
DirectoryNumber: 100,
|
||||
FileNumber: 1,
|
||||
}
|
||||
mp4 := Object{
|
||||
DCFStoreRoot: testRoot,
|
||||
Path: "DCIM/101CANON/MVI_0002.MP4",
|
||||
Size: 9000,
|
||||
Extension: ".MP4",
|
||||
DirectoryNumber: 101,
|
||||
FileNumber: 2,
|
||||
}
|
||||
thm := Object{
|
||||
DCFStoreRoot: testRoot,
|
||||
Path: "DCIM/101CANON/MVI_0002.THM",
|
||||
Size: 100,
|
||||
Extension: ".THM",
|
||||
DirectoryNumber: 101,
|
||||
FileNumber: 2,
|
||||
}
|
||||
clip := Object{
|
||||
DCFStoreRoot: testRoot,
|
||||
Path: "PRIVATE/M4ROOT/CLIP/C0002.MP4",
|
||||
Size: 400,
|
||||
Extension: ".MP4",
|
||||
}
|
||||
|
||||
wantDirectories := []*Directory{
|
||||
{Number: 100, Name: "100MSDCF", Objects: []*Object{&arw}},
|
||||
{Number: 101, Name: "101CANON", Objects: []*Object{&mp4, &thm}},
|
||||
}
|
||||
if !reflect.DeepEqual(store.Directories, wantDirectories) {
|
||||
t.Errorf("Directories = %+v, want %+v", store.Directories, wantDirectories)
|
||||
}
|
||||
|
||||
wantImages := []*Image{{Object: arw}}
|
||||
if !reflect.DeepEqual(store.Images, wantImages) {
|
||||
t.Errorf("Images = %+v, want %+v", store.Images, wantImages)
|
||||
}
|
||||
|
||||
wantVideos := []*Video{{Object: mp4}, {Object: clip}}
|
||||
if !reflect.DeepEqual(store.Videos, wantVideos) {
|
||||
t.Errorf("Videos = %+v, want %+v", store.Videos, wantVideos)
|
||||
}
|
||||
|
||||
assertPaths(t, "NonConforming", store.NonConforming,
|
||||
"DCIM/100MSDCF/readme.txt",
|
||||
"DCIM/DSC00004.JPG",
|
||||
"DCIM/MISC",
|
||||
"DCIM/MISC/DSC00003.JPG",
|
||||
)
|
||||
}
|
||||
|
||||
func TestParseStoreCountsOnlyImagesAndVideos(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
card := fstest.MapFS{
|
||||
"DCIM/100ABCDE/ABCD0001.ARW": {Data: make([]byte, 2000)},
|
||||
"DCIM/100ABCDE/ABCD0002.THM": {Data: make([]byte, 100)},
|
||||
"DCIM/100ABCDE/ABCD0003.MP4": {Data: make([]byte, 9000)},
|
||||
"DCIM/MISC/ABCD0004.JPG": {Data: make([]byte, 50)},
|
||||
"PRIVATE/M4ROOT/CLIP/C0004.MP4": {Data: make([]byte, 400)},
|
||||
"ABCD0005.JPG": {Data: make([]byte, 30)},
|
||||
}
|
||||
|
||||
store := mustParseStore(t, card)
|
||||
|
||||
if got := store.ImagesCount(); got != 1 {
|
||||
t.Errorf("ImagesCount() = %d, want 1", got)
|
||||
}
|
||||
|
||||
if got := store.VideosCount(); got != 2 {
|
||||
t.Errorf("VideosCount() = %d, want 2", got)
|
||||
}
|
||||
|
||||
if got := store.TotalSize(); got != 11400 {
|
||||
t.Errorf("TotalSize() = %d, want 11400", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStoreSetsFullFilePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := mustParseStore(t, newCard("DCIM/100MSDCF/DSC00001.JPG"))
|
||||
|
||||
want := testRoot + "/DCIM/100MSDCF/DSC00001.JPG"
|
||||
if got := store.Images[0].FullFilePath(); got != want {
|
||||
t.Fatalf("FullFilePath() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNumberOfDirectoryName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]int{
|
||||
"100MSDCF": 100,
|
||||
"999VWXYZ": 999,
|
||||
"100D3500": 100,
|
||||
"101abcde": 101,
|
||||
"102A_B_C": 102,
|
||||
"099ABCDE": 0,
|
||||
"000ABCDE": 0,
|
||||
"103A": 0,
|
||||
"IM104ABC": 0,
|
||||
"105_abcdefg": 0,
|
||||
"107@-%+]": 0,
|
||||
"IM08ABCD": 0,
|
||||
"100ABC-E": 0,
|
||||
"100ABCD": 0,
|
||||
"100ABCDEF": 0,
|
||||
"TEMP": 0,
|
||||
}
|
||||
|
||||
for name, want := range cases {
|
||||
if got := parseNumber(dcfDirectoryName, name); got != want {
|
||||
t.Errorf("directory %q parsed to %d, want %d", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNumberOfFileName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]int{
|
||||
"DSC00001.ARW": 1,
|
||||
"ABCD9999.JPG": 9999,
|
||||
"abcd0002.jpg": 2,
|
||||
"_ABC0001.JPG": 1,
|
||||
"IMG_0001.JPG": 1,
|
||||
"12340005.JPG": 5,
|
||||
"ABCD0000.JPG": 0,
|
||||
"A0004.JPG": 0,
|
||||
"ABCDE0005.JPG": 0,
|
||||
"abcdefg0006.JPG": 0,
|
||||
"+-@]0007.JPG": 0,
|
||||
"ABCD00~1.JPG": 0,
|
||||
"ABCDEFGH.JPG": 0,
|
||||
"ABCD0001": 0,
|
||||
"ABCD0001.": 0,
|
||||
"ABCD0001.JPEG": 0,
|
||||
"ABCD0001.J-G": 0,
|
||||
"._DSC0001.JPG": 0,
|
||||
".DS_Store": 0,
|
||||
}
|
||||
|
||||
for name, want := range cases {
|
||||
if got := parseNumber(dcfFileName, name); got != want {
|
||||
t.Errorf("file %q parsed to %d, want %d", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStoreTreatsLowerCaseNamesAsDCFNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := mustParseStore(t, newCard("DCIM/101abcde/abcd0002.jpg"))
|
||||
|
||||
want := []*Directory{{
|
||||
Number: 101,
|
||||
Name: "101abcde",
|
||||
Objects: []*Object{{
|
||||
DCFStoreRoot: testRoot,
|
||||
Path: "DCIM/101abcde/abcd0002.jpg",
|
||||
Extension: ".jpg",
|
||||
DirectoryNumber: 101,
|
||||
FileNumber: 2,
|
||||
}},
|
||||
}}
|
||||
if !reflect.DeepEqual(store.Directories, want) {
|
||||
t.Errorf("Directories = %+v, want %+v", store.Directories, want)
|
||||
}
|
||||
|
||||
assertPaths(t, "Images", imagePaths(store), "DCIM/101abcde/abcd0002.jpg")
|
||||
assertPaths(t, "NonConforming", store.NonConforming)
|
||||
}
|
||||
|
||||
func TestParseStoreAcceptsUnderscoreInNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := mustParseStore(t, newCard("DCIM/100A_B_C/_ABC0001.JPG"))
|
||||
|
||||
assertPaths(t, "Images", imagePaths(store), "DCIM/100A_B_C/_ABC0001.JPG")
|
||||
assertPaths(t, "NonConforming", store.NonConforming)
|
||||
}
|
||||
|
||||
func TestParseStoreRejectsDirectoriesSharingANumber(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := mustParseStore(t, newCard(
|
||||
"DCIM/102ABCDE/ABCD0001.JPG",
|
||||
"DCIM/102PQRST/ABCD0002.JPG",
|
||||
"DCIM/103ABCDE/ABCD0003.JPG",
|
||||
))
|
||||
|
||||
if got, want := directoryNumbers(store), []int{103}; !slices.Equal(got, want) {
|
||||
t.Errorf("directory numbers = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
assertPaths(t, "Images", imagePaths(store), "DCIM/103ABCDE/ABCD0003.JPG")
|
||||
assertPaths(t, "NonConforming", store.NonConforming,
|
||||
"DCIM/102ABCDE",
|
||||
"DCIM/102ABCDE/ABCD0001.JPG",
|
||||
"DCIM/102PQRST",
|
||||
"DCIM/102PQRST/ABCD0002.JPG",
|
||||
)
|
||||
}
|
||||
|
||||
func TestParseStoreDoesNotCountPlainFileAsSharingADirectoryNumber(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := mustParseStore(t, newCard(
|
||||
"DCIM/100ABCDE",
|
||||
"DCIM/100PQRST/ABCD0001.JPG",
|
||||
))
|
||||
|
||||
if got, want := directoryNumbers(store), []int{100}; !slices.Equal(got, want) {
|
||||
t.Errorf("directory numbers = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
assertPaths(t, "NonConforming", store.NonConforming, "DCIM/100ABCDE")
|
||||
}
|
||||
|
||||
func TestParseStoreRejectsJPGsSharingAFileNumber(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := mustParseStore(t, newCard(
|
||||
"DCIM/100ABCDE/ABCD0003.JPG",
|
||||
"DCIM/100ABCDE/PQRS0003.jpg",
|
||||
"DCIM/101ABCDE/ABCD0003.JPG",
|
||||
))
|
||||
|
||||
assertPaths(t, "objects", objectPaths(store.Directories[0]))
|
||||
assertPaths(t, "objects", objectPaths(store.Directories[1]),
|
||||
"DCIM/101ABCDE/ABCD0003.JPG",
|
||||
)
|
||||
assertPaths(t, "Images", imagePaths(store), "DCIM/101ABCDE/ABCD0003.JPG")
|
||||
assertPaths(t, "NonConforming", store.NonConforming,
|
||||
"DCIM/100ABCDE/ABCD0003.JPG",
|
||||
"DCIM/100ABCDE/PQRS0003.jpg",
|
||||
)
|
||||
}
|
||||
|
||||
func TestParseStoreKeepsOtherFilesSharingAFileNumber(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := mustParseStore(t, newCard(
|
||||
"DCIM/100ABCDE/DSC00004.ARW",
|
||||
"DCIM/100ABCDE/DSC00004.JPG",
|
||||
"DCIM/100ABCDE/MVI_0005.MP4",
|
||||
"DCIM/100ABCDE/MVI_0005.THM",
|
||||
))
|
||||
|
||||
assertPaths(t, "objects", objectPaths(store.Directories[0]),
|
||||
"DCIM/100ABCDE/DSC00004.ARW",
|
||||
"DCIM/100ABCDE/DSC00004.JPG",
|
||||
"DCIM/100ABCDE/MVI_0005.MP4",
|
||||
"DCIM/100ABCDE/MVI_0005.THM",
|
||||
)
|
||||
assertPaths(t, "Images", imagePaths(store),
|
||||
"DCIM/100ABCDE/DSC00004.ARW",
|
||||
"DCIM/100ABCDE/DSC00004.JPG",
|
||||
)
|
||||
assertPaths(t, "Videos", videoPaths(store), "DCIM/100ABCDE/MVI_0005.MP4")
|
||||
assertPaths(t, "NonConforming", store.NonConforming)
|
||||
}
|
||||
|
||||
func TestParseStoreRejectsSubdirectoryOfDCFDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := mustParseStore(t, newCard(
|
||||
"DCIM/100ABCDE/SUB/ABCD0001.JPG",
|
||||
"DCIM/100ABCDE/ABCD0002.JPG/ABCD0003.JPG",
|
||||
))
|
||||
|
||||
assertPaths(t, "objects", objectPaths(store.Directories[0]))
|
||||
assertPaths(t, "Images", imagePaths(store))
|
||||
assertPaths(t, "NonConforming", store.NonConforming,
|
||||
"DCIM/100ABCDE/ABCD0002.JPG",
|
||||
"DCIM/100ABCDE/ABCD0002.JPG/ABCD0003.JPG",
|
||||
"DCIM/100ABCDE/SUB",
|
||||
"DCIM/100ABCDE/SUB/ABCD0001.JPG",
|
||||
)
|
||||
}
|
||||
|
||||
func TestParseStoreListsEmptyDirectories(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := mustParseStore(t, fstest.MapFS{
|
||||
"DCIM/100EMPTY": {Mode: fs.ModeDir},
|
||||
"DCIM/MISC": {Mode: fs.ModeDir},
|
||||
})
|
||||
|
||||
if got, want := directoryNumbers(store), []int{100}; !slices.Equal(got, want) {
|
||||
t.Errorf("directory numbers = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
assertPaths(t, "NonConforming", store.NonConforming, "DCIM/MISC")
|
||||
}
|
||||
|
||||
func TestParseStoreWithoutDCIM(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := mustParseStore(t, newCard("PRIVATE/M4ROOT/CLIP/C0001.MP4"))
|
||||
|
||||
if len(store.Directories) != 0 {
|
||||
t.Errorf("Directories = %+v, want none", store.Directories)
|
||||
}
|
||||
|
||||
assertPaths(t, "Videos", videoPaths(store), "PRIVATE/M4ROOT/CLIP/C0001.MP4")
|
||||
}
|
||||
|
||||
func TestParseStoreWithoutM4Root(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := mustParseStore(t, newCard("DCIM/100ABCDE/ABCD0001.JPG"))
|
||||
|
||||
assertPaths(t, "Images", imagePaths(store), "DCIM/100ABCDE/ABCD0001.JPG")
|
||||
assertPaths(t, "Videos", videoPaths(store))
|
||||
}
|
||||
|
||||
func TestParseStoreTreatsPlainFilesAsMissingDirectories(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := mustParseStore(t, newCard("DCIM", "PRIVATE/M4ROOT"))
|
||||
|
||||
assertPaths(t, "Images", imagePaths(store))
|
||||
assertPaths(t, "Videos", videoPaths(store))
|
||||
assertPaths(t, "NonConforming", store.NonConforming)
|
||||
}
|
||||
|
||||
// A real filesystem reports a lookup under a plain file differently from a
|
||||
// missing path, which fstest.MapFS does not reproduce.
|
||||
func TestParseStoreReadsRealFilesystemWithPlainFileNamedPrivate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
|
||||
err := os.MkdirAll(filepath.Join(root, "DCIM", "100ABCDE"), 0o750)
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
jpg := filepath.Join(root, "DCIM", "100ABCDE", "ABCD0001.JPG")
|
||||
|
||||
err = os.WriteFile(jpg, []byte("abc"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(filepath.Join(root, "PRIVATE"), nil, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
store := mustParseStore(t, os.DirFS(root))
|
||||
|
||||
assertPaths(t, "Images", imagePaths(store), "DCIM/100ABCDE/ABCD0001.JPG")
|
||||
|
||||
if got := store.TotalImageSize(); got != 3 {
|
||||
t.Errorf("TotalImageSize() = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// unreadableFS fails every read the way a failing card does.
|
||||
type unreadableFS struct{}
|
||||
|
||||
func (unreadableFS) Open(string) (fs.File, error) {
|
||||
return nil, fs.ErrPermission
|
||||
}
|
||||
|
||||
func TestParseStoreReturnsReadErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseStore(unreadableFS{}, testRoot)
|
||||
if !errors.Is(err, fs.ErrPermission) {
|
||||
t.Fatalf("parseStore error = %v, want %v", err, fs.ErrPermission)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user